mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
refactor(pages): 精简部署源模型并重构详情页交互
将 Remote 网络策略收敛为 allow_insecure,去掉脱敏与无用字段; Pages 详情拆为部署/设置 Tab,统一卡片样式与来源信息展示。
This commit is contained in:
+21
-16
@@ -5568,6 +5568,12 @@ const docTemplate = `{
|
||||
"description": "统计时间范围(小时)",
|
||||
"name": "hours",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "趋势桶分钟数(1、5 或 60,默认 60)",
|
||||
"name": "bucket_minutes",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -8815,7 +8821,7 @@ const docTemplate = `{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "返回脱敏后的项目部署源配置与运行状态,需要管理员权限",
|
||||
"description": "返回项目部署源配置与运行状态,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -16874,6 +16880,9 @@ const docTemplate = `{
|
||||
"observability.AccessLogOverview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"device_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -17822,6 +17831,9 @@ const docTemplate = `{
|
||||
"pages.SourceUpdateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_insecure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"asset_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17837,15 +17849,9 @@ const docTemplate = `{
|
||||
"release_tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_network_policy": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_url_set": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"repository_url": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17871,6 +17877,9 @@ const docTemplate = `{
|
||||
"pages.SourceView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_insecure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"asset_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17880,15 +17889,9 @@ const docTemplate = `{
|
||||
"check_interval_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"display_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"github_repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"has_remote_url": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"last_applied": {
|
||||
"$ref": "#/definitions/pages.SourceRevisionView"
|
||||
},
|
||||
@@ -17913,7 +17916,7 @@ const docTemplate = `{
|
||||
"release_tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_network_policy": {
|
||||
"remote_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_type": {
|
||||
@@ -19895,7 +19898,8 @@ const docTemplate = `{
|
||||
"ip_match",
|
||||
"geo_match",
|
||||
"pow",
|
||||
"ua_check"
|
||||
"ua_check",
|
||||
"security_check"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"RuleNodeStart",
|
||||
@@ -19904,7 +19908,8 @@ const docTemplate = `{
|
||||
"RuleNodeIPMatch",
|
||||
"RuleNodeGeoMatch",
|
||||
"RuleNodePoW",
|
||||
"RuleNodeUACheck"
|
||||
"RuleNodeUACheck",
|
||||
"RuleNodeSecurityCheck"
|
||||
]
|
||||
},
|
||||
"waf.RulePosition": {
|
||||
|
||||
+21
-16
@@ -5561,6 +5561,12 @@
|
||||
"description": "统计时间范围(小时)",
|
||||
"name": "hours",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "趋势桶分钟数(1、5 或 60,默认 60)",
|
||||
"name": "bucket_minutes",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -8808,7 +8814,7 @@
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "返回脱敏后的项目部署源配置与运行状态,需要管理员权限",
|
||||
"description": "返回项目部署源配置与运行状态,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -16867,6 +16873,9 @@
|
||||
"observability.AccessLogOverview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"device_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -17815,6 +17824,9 @@
|
||||
"pages.SourceUpdateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_insecure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"asset_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17830,15 +17842,9 @@
|
||||
"release_tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_network_policy": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_url_set": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"repository_url": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17864,6 +17870,9 @@
|
||||
"pages.SourceView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_insecure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"asset_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17873,15 +17882,9 @@
|
||||
"check_interval_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"display_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"github_repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"has_remote_url": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"last_applied": {
|
||||
"$ref": "#/definitions/pages.SourceRevisionView"
|
||||
},
|
||||
@@ -17906,7 +17909,7 @@
|
||||
"release_tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_network_policy": {
|
||||
"remote_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_type": {
|
||||
@@ -19888,7 +19891,8 @@
|
||||
"ip_match",
|
||||
"geo_match",
|
||||
"pow",
|
||||
"ua_check"
|
||||
"ua_check",
|
||||
"security_check"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"RuleNodeStart",
|
||||
@@ -19897,7 +19901,8 @@
|
||||
"RuleNodeIPMatch",
|
||||
"RuleNodeGeoMatch",
|
||||
"RuleNodePoW",
|
||||
"RuleNodeUACheck"
|
||||
"RuleNodeUACheck",
|
||||
"RuleNodeSecurityCheck"
|
||||
]
|
||||
},
|
||||
"waf.RulePosition": {
|
||||
|
||||
+14
-10
@@ -2017,6 +2017,8 @@ definitions:
|
||||
type: object
|
||||
observability.AccessLogOverview:
|
||||
properties:
|
||||
bucket_minutes:
|
||||
type: integer
|
||||
device_types:
|
||||
items:
|
||||
$ref: '#/definitions/observability.DistributionItem'
|
||||
@@ -2637,6 +2639,8 @@ definitions:
|
||||
type: object
|
||||
pages.SourceUpdateInput:
|
||||
properties:
|
||||
allow_insecure:
|
||||
type: boolean
|
||||
asset_name:
|
||||
type: string
|
||||
auto_update_enabled:
|
||||
@@ -2647,12 +2651,8 @@ definitions:
|
||||
type: string
|
||||
release_tag:
|
||||
type: string
|
||||
remote_network_policy:
|
||||
type: string
|
||||
remote_url:
|
||||
type: string
|
||||
remote_url_set:
|
||||
type: boolean
|
||||
repository_url:
|
||||
type: string
|
||||
source_type:
|
||||
@@ -2669,18 +2669,16 @@ definitions:
|
||||
type: object
|
||||
pages.SourceView:
|
||||
properties:
|
||||
allow_insecure:
|
||||
type: boolean
|
||||
asset_name:
|
||||
type: string
|
||||
auto_update_enabled:
|
||||
type: boolean
|
||||
check_interval_minutes:
|
||||
type: integer
|
||||
display_url:
|
||||
type: string
|
||||
github_repository:
|
||||
type: string
|
||||
has_remote_url:
|
||||
type: boolean
|
||||
last_applied:
|
||||
$ref: '#/definitions/pages.SourceRevisionView'
|
||||
last_checked_at:
|
||||
@@ -2697,7 +2695,7 @@ definitions:
|
||||
type: string
|
||||
release_tag:
|
||||
type: string
|
||||
remote_network_policy:
|
||||
remote_url:
|
||||
type: string
|
||||
source_type:
|
||||
type: string
|
||||
@@ -4023,6 +4021,7 @@ definitions:
|
||||
- geo_match
|
||||
- pow
|
||||
- ua_check
|
||||
- security_check
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- RuleNodeStart
|
||||
@@ -4032,6 +4031,7 @@ definitions:
|
||||
- RuleNodeGeoMatch
|
||||
- RuleNodePoW
|
||||
- RuleNodeUACheck
|
||||
- RuleNodeSecurityCheck
|
||||
waf.RulePosition:
|
||||
properties:
|
||||
x:
|
||||
@@ -7522,6 +7522,10 @@ paths:
|
||||
in: query
|
||||
name: hours
|
||||
type: integer
|
||||
- description: 趋势桶分钟数(1、5 或 60,默认 60)
|
||||
in: query
|
||||
name: bucket_minutes
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -9430,7 +9434,7 @@ paths:
|
||||
- openflare-pages
|
||||
/api/v1/d/pages/{id}/source:
|
||||
get:
|
||||
description: 返回脱敏后的项目部署源配置与运行状态,需要管理员权限
|
||||
description: 返回项目部署源配置与运行状态,需要管理员权限
|
||||
parameters:
|
||||
- description: 项目 ID
|
||||
in: path
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -17,86 +14,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { type PagesProject, PagesService } from '@/lib/services/openflare';
|
||||
|
||||
import { projectQueryKey, projectsQueryKey } from './pages-utils';
|
||||
|
||||
const pagesProjectSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, '请输入项目名称').max(255),
|
||||
slug: z.string().trim().max(255).optional().or(z.literal('')),
|
||||
description: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||
spa_fallback_enabled: z.boolean(),
|
||||
spa_fallback_path: z.string().trim(),
|
||||
api_proxy_enabled: z.boolean(),
|
||||
api_proxy_path: z.string().trim(),
|
||||
api_proxy_pass: z.string().trim(),
|
||||
api_proxy_rewrite: z.string().trim(),
|
||||
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
|
||||
entry_file: z.string().trim().min(1, '请输入入口文件').max(512),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['spa_fallback_path'],
|
||||
message: '回退路径必须以 / 开头',
|
||||
});
|
||||
}
|
||||
if (data.api_proxy_enabled) {
|
||||
if (!data.api_proxy_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: '匹配路径必须以 / 开头',
|
||||
});
|
||||
}
|
||||
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: '后端地址必须以 http:// 或 https:// 开头',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
|
||||
|
||||
function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
|
||||
if (!project) {
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
spa_fallback_enabled: false,
|
||||
spa_fallback_path: '/index.html',
|
||||
api_proxy_enabled: false,
|
||||
api_proxy_path: '',
|
||||
api_proxy_pass: '',
|
||||
api_proxy_rewrite: '',
|
||||
root_dir: '',
|
||||
entry_file: 'index.html',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
description: project.description || '',
|
||||
spa_fallback_enabled: project.spa_fallback_enabled,
|
||||
spa_fallback_path: project.spa_fallback_path,
|
||||
api_proxy_enabled: project.api_proxy_enabled || false,
|
||||
api_proxy_path: project.api_proxy_path || '',
|
||||
api_proxy_pass: project.api_proxy_pass || '',
|
||||
api_proxy_rewrite: project.api_proxy_rewrite || '',
|
||||
root_dir: project.root_dir || '',
|
||||
entry_file: project.entry_file || 'index.html',
|
||||
};
|
||||
}
|
||||
import {
|
||||
buildProjectPayload,
|
||||
ProjectFormFields,
|
||||
toFormValues,
|
||||
usePagesProjectForm,
|
||||
} from './project-form';
|
||||
|
||||
interface ProjectEditorDialogProps {
|
||||
open: boolean;
|
||||
@@ -110,39 +36,15 @@ export function ProjectEditorDialog({
|
||||
project,
|
||||
}: ProjectEditorDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<PagesProjectFormValues>({
|
||||
resolver: zodResolver(pagesProjectSchema),
|
||||
defaultValues: toFormValues(project),
|
||||
});
|
||||
const form = usePagesProjectForm(project);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) form.reset(toFormValues(project));
|
||||
}, [form, project, open]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: PagesProjectFormValues) => {
|
||||
const payload = {
|
||||
name: values.name.trim(),
|
||||
slug: values.slug?.trim() || '',
|
||||
description: values.description?.trim() || '',
|
||||
enabled: project ? project.enabled : true,
|
||||
spa_fallback_enabled: values.spa_fallback_enabled,
|
||||
spa_fallback_path: values.spa_fallback_enabled
|
||||
? values.spa_fallback_path.trim()
|
||||
: project?.spa_fallback_path || '/index.html',
|
||||
api_proxy_enabled: values.api_proxy_enabled,
|
||||
api_proxy_path: values.api_proxy_enabled
|
||||
? values.api_proxy_path.trim()
|
||||
: '',
|
||||
api_proxy_pass: values.api_proxy_enabled
|
||||
? values.api_proxy_pass.trim()
|
||||
: '',
|
||||
api_proxy_rewrite: values.api_proxy_enabled
|
||||
? values.api_proxy_rewrite.trim()
|
||||
: '',
|
||||
root_dir: values.root_dir?.trim() || '',
|
||||
entry_file: values.entry_file.trim(),
|
||||
};
|
||||
mutationFn: async (values: Parameters<typeof buildProjectPayload>[0]) => {
|
||||
const payload = buildProjectPayload(values, project);
|
||||
return project
|
||||
? PagesService.updateProject(project.id, payload)
|
||||
: PagesService.createProject(payload);
|
||||
@@ -162,9 +64,6 @@ export function ProjectEditorDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const spaEnabled = form.watch('spa_fallback_enabled');
|
||||
const apiEnabled = form.watch('api_proxy_enabled');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
|
||||
@@ -182,113 +81,7 @@ export function ProjectEditorDialog({
|
||||
className='space-y-4'
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='name'>项目名称</Label>
|
||||
<Input id='name' {...form.register('name')} />
|
||||
{form.formState.errors.name ? (
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='slug'>项目标识</Label>
|
||||
<Input
|
||||
id='slug'
|
||||
placeholder='留空自动生成'
|
||||
{...form.register('slug')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='description'>描述</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
rows={2}
|
||||
{...form.register('description')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='entry_file'>入口文件</Label>
|
||||
<Input id='entry_file' {...form.register('entry_file')} />
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='root_dir'>根目录</Label>
|
||||
<Input
|
||||
id='root_dir'
|
||||
placeholder='可选'
|
||||
{...form.register('root_dir')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>SPA fallback</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
未命中静态文件时回退到指定路径
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={spaEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('spa_fallback_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{spaEnabled ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='spa_fallback_path'>回退路径</Label>
|
||||
<Input
|
||||
id='spa_fallback_path'
|
||||
{...form.register('spa_fallback_path')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>API 反向代理</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
为静态站点附加 API 反代规则
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={apiEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('api_proxy_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{apiEnabled ? (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='api_proxy_path'>匹配路径</Label>
|
||||
<Input
|
||||
id='api_proxy_path'
|
||||
{...form.register('api_proxy_path')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='api_proxy_pass'>后端地址</Label>
|
||||
<Input
|
||||
id='api_proxy_pass'
|
||||
{...form.register('api_proxy_pass')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5 md:col-span-2'>
|
||||
<Label htmlFor='api_proxy_rewrite'>重写规则</Label>
|
||||
<Input
|
||||
id='api_proxy_rewrite'
|
||||
{...form.register('api_proxy_rewrite')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ProjectFormFields form={form} idPrefix='dialog' />
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, type UseFormReturn } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { type PagesProject } from '@/lib/services/openflare';
|
||||
|
||||
export const pagesProjectSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, '请输入项目名称').max(255),
|
||||
slug: z.string().trim().max(255).optional().or(z.literal('')),
|
||||
spa_fallback_enabled: z.boolean(),
|
||||
spa_fallback_path: z.string().trim(),
|
||||
api_proxy_enabled: z.boolean(),
|
||||
api_proxy_path: z.string().trim(),
|
||||
api_proxy_pass: z.string().trim(),
|
||||
api_proxy_rewrite: z.string().trim(),
|
||||
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
|
||||
entry_file: z.string().trim().min(1, '请输入入口文件').max(512),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['spa_fallback_path'],
|
||||
message: '回退路径必须以 / 开头',
|
||||
});
|
||||
}
|
||||
if (data.api_proxy_enabled) {
|
||||
if (!data.api_proxy_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: '匹配路径必须以 / 开头',
|
||||
});
|
||||
}
|
||||
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: '后端地址必须以 http:// 或 https:// 开头',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
|
||||
|
||||
export function toFormValues(
|
||||
project?: PagesProject | null,
|
||||
): PagesProjectFormValues {
|
||||
if (!project) {
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
spa_fallback_enabled: false,
|
||||
spa_fallback_path: '/index.html',
|
||||
api_proxy_enabled: false,
|
||||
api_proxy_path: '',
|
||||
api_proxy_pass: '',
|
||||
api_proxy_rewrite: '',
|
||||
root_dir: '',
|
||||
entry_file: 'index.html',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
spa_fallback_enabled: project.spa_fallback_enabled,
|
||||
spa_fallback_path: project.spa_fallback_path,
|
||||
api_proxy_enabled: project.api_proxy_enabled || false,
|
||||
api_proxy_path: project.api_proxy_path || '',
|
||||
api_proxy_pass: project.api_proxy_pass || '',
|
||||
api_proxy_rewrite: project.api_proxy_rewrite || '',
|
||||
root_dir: project.root_dir || '',
|
||||
entry_file: project.entry_file || 'index.html',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProjectPayload(
|
||||
values: PagesProjectFormValues,
|
||||
project?: PagesProject | null,
|
||||
) {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
slug: values.slug?.trim() || '',
|
||||
description: project?.description || '',
|
||||
enabled: project ? project.enabled : true,
|
||||
spa_fallback_enabled: values.spa_fallback_enabled,
|
||||
spa_fallback_path: values.spa_fallback_enabled
|
||||
? values.spa_fallback_path.trim()
|
||||
: project?.spa_fallback_path || '/index.html',
|
||||
api_proxy_enabled: values.api_proxy_enabled,
|
||||
api_proxy_path: values.api_proxy_enabled
|
||||
? values.api_proxy_path.trim()
|
||||
: '',
|
||||
api_proxy_pass: values.api_proxy_enabled
|
||||
? values.api_proxy_pass.trim()
|
||||
: '',
|
||||
api_proxy_rewrite: values.api_proxy_enabled
|
||||
? values.api_proxy_rewrite.trim()
|
||||
: '',
|
||||
root_dir: values.root_dir?.trim() || '',
|
||||
entry_file: values.entry_file.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function usePagesProjectForm(project?: PagesProject | null) {
|
||||
return useForm<PagesProjectFormValues>({
|
||||
resolver: zodResolver(pagesProjectSchema),
|
||||
defaultValues: toFormValues(project),
|
||||
});
|
||||
}
|
||||
|
||||
interface ProjectFormFieldsProps {
|
||||
form: UseFormReturn<PagesProjectFormValues>;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export function ProjectFormFields({
|
||||
form,
|
||||
idPrefix = '',
|
||||
}: ProjectFormFieldsProps) {
|
||||
const spaEnabled = form.watch('spa_fallback_enabled');
|
||||
const apiEnabled = form.watch('api_proxy_enabled');
|
||||
const fieldId = (name: string) => (idPrefix ? `${idPrefix}-${name}` : name);
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('name')}>项目名称</Label>
|
||||
<Input id={fieldId('name')} {...form.register('name')} />
|
||||
{form.formState.errors.name ? (
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('slug')}>项目标识</Label>
|
||||
<Input
|
||||
id={fieldId('slug')}
|
||||
placeholder='留空自动生成'
|
||||
{...form.register('slug')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('entry_file')}>入口文件</Label>
|
||||
<Input id={fieldId('entry_file')} {...form.register('entry_file')} />
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('root_dir')}>根目录</Label>
|
||||
<Input
|
||||
id={fieldId('root_dir')}
|
||||
placeholder='可选'
|
||||
{...form.register('root_dir')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>SPA fallback</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
未命中静态文件时回退到指定路径
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={spaEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('spa_fallback_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{spaEnabled ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('spa_fallback_path')}>回退路径</Label>
|
||||
<Input
|
||||
id={fieldId('spa_fallback_path')}
|
||||
{...form.register('spa_fallback_path')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>API 反向代理</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
为静态站点附加 API 反代规则
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={apiEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('api_proxy_enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{apiEnabled ? (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('api_proxy_path')}>匹配路径</Label>
|
||||
<Input
|
||||
id={fieldId('api_proxy_path')}
|
||||
{...form.register('api_proxy_path')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('api_proxy_pass')}>后端地址</Label>
|
||||
<Input
|
||||
id={fieldId('api_proxy_pass')}
|
||||
{...form.register('api_proxy_pass')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5 md:col-span-2'>
|
||||
<Label htmlFor={fieldId('api_proxy_rewrite')}>重写规则</Label>
|
||||
<Input
|
||||
id={fieldId('api_proxy_rewrite')}
|
||||
{...form.register('api_proxy_rewrite')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { type PagesProject, PagesService } from '@/lib/services/openflare';
|
||||
|
||||
import { projectsQueryKey } from '../../components/pages-utils';
|
||||
|
||||
interface DangerZoneCardProps {
|
||||
project: PagesProject;
|
||||
}
|
||||
|
||||
export function DangerZoneCard({ project }: DangerZoneCardProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: () => PagesService.deleteProject(project.id),
|
||||
onSuccess: async () => {
|
||||
toast.success('项目已删除');
|
||||
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
router.push('/pages');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='border-dashed border-destructive/30 bg-destructive/5 shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base text-destructive'>危险区域</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-sm font-medium'>删除项目</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
将永久删除 {project.name}({project.slug})及其全部部署。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
删除项目
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除 Pages 项目</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除项目 {project.name} 吗?此操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
deleteProjectMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{deleteProjectMutation.isPending ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : null}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -22,6 +22,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
@@ -31,6 +32,7 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { type PagesDeployment, PagesService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import { DeploymentUploadDialog } from '../../components/deployment-upload-dialog';
|
||||
import {
|
||||
deploymentFilesQueryKey,
|
||||
deploymentsQueryKey,
|
||||
@@ -58,6 +60,8 @@ const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
|
||||
interface DeploymentHistoryProps {
|
||||
projectId: number;
|
||||
activeDeploymentId?: number | null;
|
||||
rootDir?: string;
|
||||
entryFile?: string;
|
||||
}
|
||||
|
||||
type PendingAction = {
|
||||
@@ -78,8 +82,11 @@ function deploymentSnapshot(deployment: PagesDeployment) {
|
||||
export function DeploymentHistory({
|
||||
projectId,
|
||||
activeDeploymentId,
|
||||
rootDir = '',
|
||||
entryFile = 'index.html',
|
||||
}: DeploymentHistoryProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [expandedDeploymentId, setExpandedDeploymentId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
@@ -148,14 +155,25 @@ export function DeploymentHistory({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部署历史</CardTitle>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>部署历史</CardTitle>
|
||||
<CardDescription>
|
||||
部署记录不可变,来源信息是创建部署时的安全快照。
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
className='whitespace-nowrap'
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Upload data-icon='inline-start' />
|
||||
手动上传
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-3'>
|
||||
<CardContent className='space-y-3'>
|
||||
{deploymentsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder description='加载部署历史...' />
|
||||
) : deploymentsQuery.isError ? (
|
||||
@@ -182,7 +200,10 @@ export function DeploymentHistory({
|
||||
const expanded = expandedDeploymentId === deployment.id;
|
||||
|
||||
return (
|
||||
<div key={deployment.id} className='rounded-lg border'>
|
||||
<div
|
||||
key={deployment.id}
|
||||
className='rounded-lg border border-dashed'
|
||||
>
|
||||
<div className='flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex min-w-0 items-start gap-2'>
|
||||
<Button
|
||||
@@ -204,7 +225,7 @@ export function DeploymentHistory({
|
||||
部署 #{deployment.deployment_number}
|
||||
</span>
|
||||
<Badge variant={active ? 'default' : 'outline'}>
|
||||
{active ? '当前生产部署' : '历史部署'}
|
||||
{active ? 'Production' : '历史部署'}
|
||||
</Badge>
|
||||
<Badge variant='secondary'>
|
||||
{deploymentSnapshot(deployment)}
|
||||
@@ -295,6 +316,14 @@ export function DeploymentHistory({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<DeploymentUploadDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
projectId={projectId}
|
||||
rootDir={rootDir}
|
||||
entryFile={entryFile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Download,
|
||||
Github,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Download, Pencil, RefreshCw, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -27,7 +20,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
@@ -348,12 +340,12 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
|
||||
if (sourceQuery.isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部署源</CardTitle>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>部署源</CardTitle>
|
||||
<CardDescription>加载来源配置...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-3'>
|
||||
<CardContent className='space-y-3'>
|
||||
<Skeleton className='h-10 w-full' />
|
||||
<Skeleton className='h-20 w-full' />
|
||||
</CardContent>
|
||||
@@ -363,9 +355,9 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
|
||||
if (sourceQuery.isError || !source) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部署源</CardTitle>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>部署源</CardTitle>
|
||||
<CardDescription>来源配置与部署历史相互独立。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -401,24 +393,26 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部署源</CardTitle>
|
||||
<CardDescription>
|
||||
来源配置负责发现内容,发布结果记录在独立的部署历史中。
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>部署源</CardTitle>
|
||||
<CardDescription>
|
||||
配置远端来源并同步发布;发布结果见部署历史。
|
||||
</CardDescription>
|
||||
</div>
|
||||
{status ? (
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
) : (
|
||||
<Badge variant='outline'>手动部署</Badge>
|
||||
)}
|
||||
</CardAction>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex flex-col gap-4'>
|
||||
<CardContent className='space-y-4'>
|
||||
{source.source_type === 'manual' ? (
|
||||
<div className='rounded-lg border bg-muted/20 p-4'>
|
||||
<div className='rounded-lg border border-dashed bg-muted/20 p-4'>
|
||||
<p className='text-sm font-medium'>本地部署包</p>
|
||||
<p className='mt-1 text-sm text-muted-foreground'>
|
||||
当前没有持久化远端来源。上传部署包后,再从部署历史显式激活。
|
||||
@@ -450,7 +444,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
/>
|
||||
) : null}
|
||||
{actionTimedOut ? (
|
||||
<div className='flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex flex-col gap-2 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
自动等待已停止,任务可能仍在后台运行。
|
||||
</span>
|
||||
@@ -473,39 +467,23 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className='flex flex-wrap gap-2 border-t'>
|
||||
{source.source_type === 'manual' ? (
|
||||
<CardFooter className='flex flex-wrap gap-2 border-t border-dashed'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={source.source_type !== 'manual' && actionsDisabled}
|
||||
onClick={() => openSourceDialog(source.source_type)}
|
||||
>
|
||||
<Pencil data-icon='inline-start' />
|
||||
配置
|
||||
</Button>
|
||||
{source.source_type !== 'manual' ? (
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => openSourceDialog('remote_url')}
|
||||
>
|
||||
<Download data-icon='inline-start' />
|
||||
配置 Remote URL
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => openSourceDialog('github_release')}
|
||||
>
|
||||
<Github data-icon='inline-start' />
|
||||
配置 GitHub Release
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => openSourceDialog(source.source_type)}
|
||||
>
|
||||
<Pencil data-icon='inline-start' />
|
||||
编辑来源
|
||||
</Button>
|
||||
{source.source_type === 'github_release' ? (
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => {
|
||||
@@ -523,6 +501,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={
|
||||
actionsDisabled ||
|
||||
(source.sync_status === 'attention' && !attentionRevision)
|
||||
@@ -536,17 +515,8 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
)}
|
||||
同步并发布
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => openSourceDialog('manual')}
|
||||
>
|
||||
<RotateCcw data-icon='inline-start' />
|
||||
切换回手动
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
type PagesGitHubReleaseSource,
|
||||
type PagesRemoteURLSource,
|
||||
type PagesSourceRevision,
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { cn, formatDateTime } from '@/lib/utils';
|
||||
|
||||
function revisionSummary(revision?: PagesSourceRevision) {
|
||||
if (!revision) return '尚无记录';
|
||||
return `${revision.label} · ${revision.revision.slice(0, 12)}`;
|
||||
const label = revision.label?.trim();
|
||||
const short = revision.revision.slice(0, 12);
|
||||
return label ? `${label} · ${short}` : short;
|
||||
}
|
||||
|
||||
function formatOptionalTime(value?: string | null, empty = '—') {
|
||||
return value ? formatDateTime(value) : empty;
|
||||
}
|
||||
|
||||
function SourceMetaRow({
|
||||
label,
|
||||
children,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className='grid grid-cols-[4.75rem_minmax(0,1fr)] items-baseline gap-x-3 py-0.5 sm:grid-cols-[5.5rem_minmax(0,1fr)]'>
|
||||
<dt className='text-xs text-muted-foreground'>{label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'min-w-0 break-all text-sm leading-relaxed',
|
||||
mono && 'font-mono text-[13px]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RemoteSourceDetails({
|
||||
@@ -20,38 +52,27 @@ export function RemoteSourceDetails({
|
||||
source: PagesRemoteURLSource;
|
||||
}) {
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>脱敏地址</span>
|
||||
<code className='truncate text-sm'>{source.display_url}</code>
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-lg border border-dashed bg-muted/15 px-5 py-5'>
|
||||
<dl className='space-y-3.5'>
|
||||
<SourceMetaRow label='地址' mono>
|
||||
{source.remote_url || '—'}
|
||||
</SourceMetaRow>
|
||||
<div className='grid gap-3.5 sm:grid-cols-2'>
|
||||
<SourceMetaRow label='TLS'>
|
||||
{source.allow_insecure ? '允许不安全连接' : '校验证书'}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='最近同步'>
|
||||
{formatOptionalTime(source.last_synced_at, '尚未同步')}
|
||||
</SourceMetaRow>
|
||||
</div>
|
||||
<SourceMetaRow label='已应用' mono>
|
||||
{revisionSummary(source.last_applied)}
|
||||
</SourceMetaRow>
|
||||
</dl>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>网络策略</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.remote_network_policy === 'trusted_internal'
|
||||
? '受信内网模式'
|
||||
: '公网安全模式'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>最近同步</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.last_synced_at
|
||||
? formatDateTime(source.last_synced_at)
|
||||
: '尚未同步'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>已应用 revision</span>
|
||||
<span className='font-mono text-sm'>
|
||||
{revisionSummary(source.last_applied)}
|
||||
</span>
|
||||
</div>
|
||||
{source.last_error ? (
|
||||
<div className='md:col-span-2'>
|
||||
<ErrorInline message={source.last_error} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{source.last_error ? <ErrorInline message={source.last_error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,9 +84,13 @@ export function GitHubSourceDetails({
|
||||
}) {
|
||||
const attentionRevision =
|
||||
source.sync_status === 'attention' ? source.last_seen : undefined;
|
||||
const releaseLabel =
|
||||
source.release_selector === 'latest'
|
||||
? '最新 Release'
|
||||
: `固定 Tag · ${source.release_tag || '未提供'}`;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='space-y-4'>
|
||||
{attentionRevision ? (
|
||||
<Alert variant='destructive'>
|
||||
<TriangleAlert />
|
||||
@@ -81,83 +106,51 @@ export function GitHubSourceDetails({
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>GitHub 仓库</span>
|
||||
<code className='truncate text-sm'>{source.github_repository}</code>
|
||||
<div className='rounded-lg border border-dashed bg-muted/15 px-5 py-5'>
|
||||
<div className='mb-4 flex flex-wrap items-center gap-2 border-b border-dashed pb-4'>
|
||||
<code className='text-sm font-medium'>{source.github_repository}</code>
|
||||
<Badge variant='secondary' className='font-normal'>
|
||||
{source.asset_name}
|
||||
</Badge>
|
||||
<Badge variant='outline' className='font-normal'>
|
||||
{releaseLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>Release 选择</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.release_selector === 'latest'
|
||||
? '最新 Release'
|
||||
: `固定 Tag · ${source.release_tag ?? '未提供'}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>Release Asset</span>
|
||||
<code className='truncate text-sm'>{source.asset_name}</code>
|
||||
</div>
|
||||
{source.release_selector === 'latest' ? (
|
||||
<>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>自动更新</span>
|
||||
<span className='text-sm font-medium'>
|
||||
|
||||
<dl className='grid gap-x-8 gap-y-3.5 sm:grid-cols-2'>
|
||||
{source.release_selector === 'latest' ? (
|
||||
<>
|
||||
<SourceMetaRow label='自动更新'>
|
||||
{source.auto_update_enabled ? '已开启' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>检查间隔</span>
|
||||
<span className='text-sm font-medium'>
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='检查间隔'>
|
||||
{source.check_interval_minutes} 分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
下次检查时间
|
||||
</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.next_check_at
|
||||
? formatDateTime(source.next_check_at)
|
||||
: '等待调度'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>远端已发现</span>
|
||||
<span className='font-mono text-sm'>
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='下次检查'>
|
||||
{formatOptionalTime(source.next_check_at, '等待调度')}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='最近检查'>
|
||||
{formatOptionalTime(source.last_checked_at, '尚未检查')}
|
||||
</SourceMetaRow>
|
||||
</>
|
||||
) : (
|
||||
<SourceMetaRow label='最近检查'>
|
||||
{formatOptionalTime(source.last_checked_at, '尚未检查')}
|
||||
</SourceMetaRow>
|
||||
)}
|
||||
<SourceMetaRow label='远端' mono>
|
||||
{revisionSummary(source.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>当前已应用</span>
|
||||
<span className='font-mono text-sm'>
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='已应用' mono>
|
||||
{revisionSummary(source.last_applied)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>最近检查</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.last_checked_at
|
||||
? formatDateTime(source.last_checked_at)
|
||||
: '尚未检查'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>最近同步</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.last_synced_at
|
||||
? formatDateTime(source.last_synced_at)
|
||||
: '尚未同步'}
|
||||
</span>
|
||||
</div>
|
||||
{source.last_error ? (
|
||||
<div className='md:col-span-2'>
|
||||
<ErrorInline message={source.last_error} />
|
||||
</div>
|
||||
) : null}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='最近同步'>
|
||||
{formatOptionalTime(source.last_synced_at, '尚未同步')}
|
||||
</SourceMetaRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{source.last_error ? <ErrorInline message={source.last_error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -33,9 +32,9 @@ import {
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
type PagesRemoteNetworkPolicy,
|
||||
type PagesSource,
|
||||
type PagesSourceActionReceipt,
|
||||
type PagesSourceUpdatePayload,
|
||||
@@ -61,7 +60,7 @@ import {
|
||||
|
||||
export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release';
|
||||
|
||||
type Confirmation = 'trusted_internal' | 'manual' | null;
|
||||
type Confirmation = 'manual' | null;
|
||||
|
||||
interface PagesSourceDialogProps {
|
||||
open: boolean;
|
||||
@@ -96,11 +95,8 @@ export function PagesSourceDialog({
|
||||
}: PagesSourceDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [mode, setMode] = useState<PagesSourceMode>('manual');
|
||||
const [networkPolicy, setNetworkPolicy] =
|
||||
useState<PagesRemoteNetworkPolicy>('public');
|
||||
const [replaceURL, setReplaceURL] = useState(false);
|
||||
const [allowInsecure, setAllowInsecure] = useState(false);
|
||||
const [remoteURL, setRemoteURL] = useState('');
|
||||
const [revealRemoteURL, setRevealRemoteURL] = useState(false);
|
||||
const [urlError, setURLError] = useState('');
|
||||
const [githubForm, setGitHubForm] = useState<PagesGitHubSourceFormValue>({
|
||||
repositoryURL: '',
|
||||
@@ -118,8 +114,6 @@ export function PagesSourceDialog({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
initializedForOpen.current = false;
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
return;
|
||||
}
|
||||
// Runtime polling may replace the source view while the dialog is open.
|
||||
@@ -128,14 +122,12 @@ export function PagesSourceDialog({
|
||||
initializedForOpen.current = true;
|
||||
const nextMode = initialMode ?? source.source_type;
|
||||
setMode(nextMode);
|
||||
setNetworkPolicy(
|
||||
source.source_type === 'remote_url'
|
||||
? source.remote_network_policy
|
||||
: 'public',
|
||||
setAllowInsecure(
|
||||
source.source_type === 'remote_url' && Boolean(source.allow_insecure),
|
||||
);
|
||||
setRemoteURL(
|
||||
source.source_type === 'remote_url' ? (source.remote_url ?? '') : '',
|
||||
);
|
||||
setReplaceURL(source.source_type !== 'remote_url');
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
setGitHubForm({
|
||||
repositoryURL:
|
||||
@@ -192,8 +184,6 @@ export function PagesSourceDialog({
|
||||
toast.success('部署源已更新');
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
setConfirmation(null);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -208,8 +198,6 @@ export function PagesSourceDialog({
|
||||
await invalidateSourceState();
|
||||
toast.success('已切换回手动部署');
|
||||
setConfirmation(null);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -221,31 +209,24 @@ export function PagesSourceDialog({
|
||||
|
||||
const remotePayload = (): PagesSourceUpdatePayload => ({
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: replaceURL,
|
||||
remote_url: replaceURL ? remoteURL.trim() : '',
|
||||
remote_network_policy: networkPolicy,
|
||||
remote_url: remoteURL.trim(),
|
||||
allow_insecure: allowInsecure,
|
||||
});
|
||||
|
||||
const submitRemote = () => {
|
||||
if (replaceURL) {
|
||||
const value = remoteURL.trim();
|
||||
if (!value) {
|
||||
setURLError('请输入 Remote URL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
|
||||
} catch {
|
||||
setURLError('请输入有效的 HTTP(S) URL');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setURLError('');
|
||||
if (networkPolicy === 'trusted_internal') {
|
||||
setConfirmation('trusted_internal');
|
||||
const value = remoteURL.trim();
|
||||
if (!value) {
|
||||
setURLError('请输入 Remote URL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
|
||||
} catch {
|
||||
setURLError('请输入有效的 HTTP(S) URL');
|
||||
return;
|
||||
}
|
||||
setURLError('');
|
||||
updateMutation.mutate(remotePayload());
|
||||
};
|
||||
|
||||
@@ -347,7 +328,7 @@ export function PagesSourceDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>部署源设置</DialogTitle>
|
||||
<DialogDescription>
|
||||
手动部署、Remote URL 与 GitHub Release 使用独立配置。
|
||||
选择部署来源。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -368,9 +349,9 @@ export function PagesSourceDialog({
|
||||
) {
|
||||
setMode(value);
|
||||
if (value !== 'remote_url') {
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
} else if (source.source_type === 'remote_url') {
|
||||
setRemoteURL(source.remote_url ?? '');
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -385,14 +366,6 @@ export function PagesSourceDialog({
|
||||
GitHub Release
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
<span>
|
||||
远端来源只负责发现内容,发布结果始终保留为不可变部署。
|
||||
</span>
|
||||
<span className='block'>
|
||||
仓库源码构建将在后续作为独立来源类型提供。
|
||||
</span>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{mode === 'manual' ? (
|
||||
@@ -406,115 +379,37 @@ export function PagesSourceDialog({
|
||||
<>
|
||||
<Field data-invalid={Boolean(urlError)}>
|
||||
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
|
||||
{source.source_type === 'remote_url' && !replaceURL ? (
|
||||
<div className='flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<code className='min-w-0 truncate text-xs'>
|
||||
{source.display_url}
|
||||
</code>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setReplaceURL(true);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
}}
|
||||
>
|
||||
更换地址
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='pages-remote-url'
|
||||
type={revealRemoteURL ? 'url' : 'password'}
|
||||
placeholder='https://artifacts.example.com/site.zip?token=...'
|
||||
value={remoteURL}
|
||||
aria-invalid={Boolean(urlError)}
|
||||
autoComplete='off'
|
||||
className='min-w-0 flex-1'
|
||||
onChange={(event) => {
|
||||
setRemoteURL(event.target.value);
|
||||
setURLError('');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
aria-label={
|
||||
revealRemoteURL
|
||||
? '隐藏 Remote URL'
|
||||
: '显示 Remote URL'
|
||||
}
|
||||
title={
|
||||
revealRemoteURL
|
||||
? '隐藏 Remote URL'
|
||||
: '显示 Remote URL'
|
||||
}
|
||||
onClick={() =>
|
||||
setRevealRemoteURL((visible) => !visible)
|
||||
}
|
||||
>
|
||||
{revealRemoteURL ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
id='pages-remote-url'
|
||||
type='url'
|
||||
placeholder='https://artifacts.example.com/site.zip'
|
||||
value={remoteURL}
|
||||
aria-invalid={Boolean(urlError)}
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
setRemoteURL(event.target.value);
|
||||
setURLError('');
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{urlError ||
|
||||
(replaceURL
|
||||
? '保存后不会回显原始地址或 query token。'
|
||||
: '界面只显示脱敏地址;留空表示保留当前地址。')}
|
||||
{urlError || '填写可直接下载的部署包 HTTP(S) 地址。'}
|
||||
</FieldDescription>
|
||||
{source.source_type === 'remote_url' && replaceURL ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='self-start'
|
||||
onClick={() => {
|
||||
setReplaceURL(false);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
}}
|
||||
>
|
||||
保留当前地址
|
||||
</Button>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle id='pages-network-policy'>网络策略</FieldTitle>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
value={networkPolicy}
|
||||
aria-labelledby='pages-network-policy'
|
||||
className='grid w-full grid-cols-2'
|
||||
onValueChange={(value) => {
|
||||
if (value === 'public' || value === 'trusted_internal') {
|
||||
setNetworkPolicy(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='public' className='w-full'>
|
||||
公网安全模式
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value='trusted_internal'
|
||||
className='w-full'
|
||||
>
|
||||
受信内网模式
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
{networkPolicy === 'public'
|
||||
? '阻止内网地址、代理与不安全 TLS。'
|
||||
: '允许访问内网地址与自签名证书,仅用于可信来源。'}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
|
||||
<div className='space-y-1 pr-4'>
|
||||
<p className='text-sm font-medium'>允许不安全的连接</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
默认允许公网与内网地址;开启后跳过 TLS
|
||||
证书校验,适用于自签名或私有 CA。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={allowInsecure}
|
||||
onCheckedChange={setAllowInsecure}
|
||||
aria-label='允许不安全的连接'
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<PagesSourceGitHubFields
|
||||
@@ -532,11 +427,7 @@ export function PagesSourceDialog({
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
@@ -556,15 +447,9 @@ export function PagesSourceDialog({
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{confirmation === 'manual'
|
||||
? '切换回手动部署'
|
||||
: '启用受信内网模式'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogTitle>切换回手动部署</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{confirmation === 'manual'
|
||||
? '当前来源配置将被删除,但已有部署与当前生产版本会保留。'
|
||||
: '该模式允许访问私有网络并接受自签名证书。请确认此地址属于可信内部来源,且不会被非可信用户控制。'}
|
||||
当前来源配置将被删除,但已有部署与当前生产版本会保留。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -575,8 +460,6 @@ export function PagesSourceDialog({
|
||||
event.preventDefault();
|
||||
if (confirmation === 'manual') {
|
||||
deleteMutation.mutate();
|
||||
} else if (confirmation === 'trusted_internal') {
|
||||
updateMutation.mutate(remotePayload());
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -100,11 +100,6 @@ export function PagesSourceGitHubFields({
|
||||
固定 Tag
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
{value.releaseSelector === 'latest'
|
||||
? '定时检查最新 Release;是否自动发布由下方开关控制。'
|
||||
: '固定 Tag 仅支持管理员手动检查与同步。'}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{value.releaseSelector === 'tag' ? (
|
||||
@@ -199,9 +194,6 @@ export function PagesSourceGitHubFields({
|
||||
onErrorsChange({ ...errors, assetName: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-asset-description'>
|
||||
按文件名精确匹配已上传的 Release Asset。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-asset-error'>
|
||||
{errors.assetName}
|
||||
</FieldError>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { type PagesProject, PagesService } from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '../../components/pages-utils';
|
||||
import {
|
||||
buildProjectPayload,
|
||||
ProjectFormFields,
|
||||
toFormValues,
|
||||
usePagesProjectForm,
|
||||
} from '../../components/project-form';
|
||||
|
||||
interface ProjectSettingsCardProps {
|
||||
project: PagesProject;
|
||||
}
|
||||
|
||||
export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = usePagesProjectForm(project);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(toFormValues(project));
|
||||
}, [form, project]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Parameters<typeof buildProjectPayload>[0]) => {
|
||||
const payload = buildProjectPayload(values, project);
|
||||
return PagesService.updateProject(project.id, payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('项目已更新');
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectQueryKey(project.id),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(project.id),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>编辑 Pages 项目</CardTitle>
|
||||
<CardDescription>
|
||||
配置静态站点托管参数,保存后会同步到项目详情与代理引用。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex shrink-0 flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={!form.formState.isDirty || mutation.isPending}
|
||||
onClick={() => form.reset(toFormValues(project))}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
size='sm'
|
||||
form='pages-project-settings-form'
|
||||
disabled={!form.formState.isDirty || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1 size-4 animate-spin' />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
'保存修改'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<form
|
||||
id='pages-project-settings-form'
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
>
|
||||
<ProjectFormFields form={form} idPrefix='settings' />
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, Pencil, Trash2, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, FileText } from 'lucide-react';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { PagesService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
DeploymentUploadDialog,
|
||||
pagesEntryPath,
|
||||
} from '../components/deployment-upload-dialog';
|
||||
import { ProjectEditorDialog } from '../components/project-editor-dialog';
|
||||
import {
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '../components/pages-utils';
|
||||
import { DeploymentHistory } from './components/deployment-history';
|
||||
import { PagesSourceCard } from './components/pages-source-card';
|
||||
import { projectQueryKey } from '../components/pages-utils';
|
||||
import { DeploymentsTab } from './tabs/deployments-tab';
|
||||
import { SettingsTab } from './tabs/settings-tab';
|
||||
|
||||
type PagesDetailTab = 'deployments' | 'settings';
|
||||
|
||||
function resolveTab(value: string | null): PagesDetailTab {
|
||||
if (value === 'settings') return 'settings';
|
||||
return 'deployments';
|
||||
}
|
||||
|
||||
function PagesDetailPageFallback() {
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-6 px-1 py-6'>
|
||||
<Skeleton className='h-8 w-32' />
|
||||
<Skeleton className='h-12 w-full max-w-xl' />
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<Skeleton className='h-40 w-full' />
|
||||
<Skeleton className='h-40 w-full' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-64' />
|
||||
<Skeleton className='h-64 w-full' />
|
||||
</div>
|
||||
);
|
||||
@@ -65,15 +40,12 @@ function PagesDetailPageFallback() {
|
||||
function PagesDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteProjectOpen, setDeleteProjectOpen] = useState(false);
|
||||
|
||||
const rawProjectId = searchParams.get('id')?.trim() ?? '';
|
||||
const projectId = Number(rawProjectId);
|
||||
const validProjectId =
|
||||
rawProjectId !== '' && Number.isInteger(projectId) && projectId > 0;
|
||||
const tab = resolveTab(searchParams.get('tab'));
|
||||
|
||||
const projectQuery = useQuery({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
@@ -81,17 +53,15 @@ function PagesDetailRoute() {
|
||||
enabled: validProjectId,
|
||||
});
|
||||
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: () => PagesService.deleteProject(projectId),
|
||||
onSuccess: async () => {
|
||||
toast.success('项目已删除');
|
||||
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
router.push('/pages');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
},
|
||||
});
|
||||
const handleTabChange = (value: string) => {
|
||||
const next = resolveTab(value);
|
||||
const params = new URLSearchParams();
|
||||
params.set('id', String(projectId));
|
||||
if (next === 'settings') {
|
||||
params.set('tab', 'settings');
|
||||
}
|
||||
router.replace(`/pages/detail?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (!validProjectId) {
|
||||
return (
|
||||
@@ -141,9 +111,6 @@ function PagesDetailRoute() {
|
||||
);
|
||||
}
|
||||
|
||||
const activeDeployment = project.active_deployment;
|
||||
const entryPath = pagesEntryPath(project.root_dir ?? '', project.entry_file);
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-6 px-1 py-6'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
@@ -153,159 +120,55 @@ function PagesDetailRoute() {
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileText className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{project.name}
|
||||
</h1>
|
||||
<Badge variant={project.enabled ? 'secondary' : 'outline'}>
|
||||
{project.enabled ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{project.slug} · {project.deployment_count} 个部署
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
<Pencil data-icon='inline-start' />
|
||||
编辑项目
|
||||
</Button>
|
||||
<Button type='button' size='sm' onClick={() => setUploadOpen(true)}>
|
||||
<Upload data-icon='inline-start' />
|
||||
上传部署包
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setDeleteProjectOpen(true)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
删除项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>当前生产部署</CardTitle>
|
||||
<CardDescription>
|
||||
Agent 当前应拉取并提供服务的不可变部署。
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant={activeDeployment ? 'default' : 'outline'}>
|
||||
{activeDeployment ? '生产中' : '未发布'}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-2'>
|
||||
{activeDeployment ? (
|
||||
<>
|
||||
<p className='text-lg font-semibold'>
|
||||
部署 #{activeDeployment.deployment_number}
|
||||
</p>
|
||||
<p className='font-mono text-xs text-muted-foreground'>
|
||||
{activeDeployment.checksum.slice(0, 20)}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
激活于{' '}
|
||||
{activeDeployment.activated_at
|
||||
? formatDateTime(activeDeployment.activated_at)
|
||||
: '未知时间'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
尚无生产部署。上传或同步来源后,从部署历史激活一个版本。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={handleTabChange}
|
||||
className='w-full'
|
||||
>
|
||||
<TabsList variant='line' className='mb-6 inline-flex w-fit gap-8'>
|
||||
<TabsTrigger
|
||||
value='deployments'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
部署
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='settings'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>站点入口</CardTitle>
|
||||
<CardDescription>
|
||||
解包校验、发布快照与 Agent 切换共同使用此路径。
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant={project.enabled ? 'secondary' : 'outline'}>
|
||||
{project.enabled ? '项目已启用' : '项目已停用'}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-2'>
|
||||
<code className='rounded-md border bg-muted/20 px-3 py-2 text-sm'>
|
||||
{entryPath}
|
||||
</code>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
项目更新于 {formatDateTime(project.updated_at)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<TabsContent
|
||||
value='deployments'
|
||||
className='focus-visible:outline-none'
|
||||
>
|
||||
<DeploymentsTab project={project} />
|
||||
</TabsContent>
|
||||
|
||||
<PagesSourceCard key={`source-${projectId}`} projectId={projectId} />
|
||||
<DeploymentHistory
|
||||
key={`deployments-${projectId}`}
|
||||
projectId={projectId}
|
||||
activeDeploymentId={project.active_deployment_id}
|
||||
/>
|
||||
|
||||
<ProjectEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setEditorOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
});
|
||||
}
|
||||
}}
|
||||
project={project}
|
||||
/>
|
||||
<DeploymentUploadDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
projectId={projectId}
|
||||
rootDir={project.root_dir ?? ''}
|
||||
entryFile={project.entry_file}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteProjectOpen} onOpenChange={setDeleteProjectOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除 Pages 项目</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除项目 {project.name} 吗?此操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
deleteProjectMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{deleteProjectMutation.isPending ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : null}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<TabsContent value='settings' className='focus-visible:outline-none'>
|
||||
<SettingsTab project={project} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { type PagesProject } from '@/lib/services/openflare';
|
||||
|
||||
import { DeploymentHistory } from '../components/deployment-history';
|
||||
|
||||
interface DeploymentsTabProps {
|
||||
project: PagesProject;
|
||||
}
|
||||
|
||||
export function DeploymentsTab({ project }: DeploymentsTabProps) {
|
||||
return (
|
||||
<DeploymentHistory
|
||||
key={`deployments-${project.id}`}
|
||||
projectId={project.id}
|
||||
activeDeploymentId={project.active_deployment_id}
|
||||
rootDir={project.root_dir ?? ''}
|
||||
entryFile={project.entry_file}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { type PagesProject } from '@/lib/services/openflare';
|
||||
|
||||
import { DangerZoneCard } from '../components/danger-zone-card';
|
||||
import { PagesSourceCard } from '../components/pages-source-card';
|
||||
import { ProjectSettingsCard } from '../components/project-settings-card';
|
||||
|
||||
interface SettingsTabProps {
|
||||
project: PagesProject;
|
||||
}
|
||||
|
||||
export function SettingsTab({ project }: SettingsTabProps) {
|
||||
return (
|
||||
<div className='flex flex-col gap-6'>
|
||||
<ProjectSettingsCard project={project} />
|
||||
<PagesSourceCard key={`source-${project.id}`} projectId={project.id} />
|
||||
<DangerZoneCard project={project} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,6 @@ export type {
|
||||
PagesManualSource,
|
||||
PagesProject,
|
||||
PagesProjectPayload,
|
||||
PagesRemoteNetworkPolicy,
|
||||
PagesRemoteSourceUpdatePayload,
|
||||
PagesRemoteURLSource,
|
||||
PagesSource,
|
||||
|
||||
@@ -460,8 +460,6 @@ export interface PagesDeploymentUploadFromURLPayload {
|
||||
export type PagesSourceStatus =
|
||||
'idle' | 'checking' | 'update_available' | 'syncing' | 'failed' | 'attention';
|
||||
|
||||
export type PagesRemoteNetworkPolicy = 'public' | 'trusted_internal';
|
||||
|
||||
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
|
||||
|
||||
export interface PagesSourceRevision {
|
||||
@@ -487,9 +485,8 @@ export interface PagesManualSource {
|
||||
|
||||
export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
|
||||
source_type: 'remote_url';
|
||||
has_remote_url: boolean;
|
||||
display_url: string;
|
||||
remote_network_policy: PagesRemoteNetworkPolicy;
|
||||
remote_url: string;
|
||||
allow_insecure?: boolean;
|
||||
}
|
||||
|
||||
interface PagesGitHubReleaseSourceBase extends PagesSourceRuntimeView {
|
||||
@@ -524,9 +521,8 @@ export type PagesSource =
|
||||
|
||||
export interface PagesRemoteSourceUpdatePayload {
|
||||
source_type: 'remote_url';
|
||||
remote_url_set: boolean;
|
||||
remote_url: string;
|
||||
remote_network_policy: PagesRemoteNetworkPolicy;
|
||||
allow_insecure?: boolean;
|
||||
}
|
||||
|
||||
interface PagesGitHubSourceUpdateBase {
|
||||
|
||||
@@ -50,9 +50,8 @@ describe('PagesService', () => {
|
||||
await PagesService.getSource(12);
|
||||
await PagesService.updateSource(12, {
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: true,
|
||||
remote_url: 'https://example.com/site.zip?token=secret',
|
||||
remote_network_policy: 'public',
|
||||
remote_url: 'https://example.com/site.zip?token=secret',
|
||||
allow_insecure: false,
|
||||
});
|
||||
await PagesService.deleteSource(12);
|
||||
await PagesService.checkSource(12);
|
||||
@@ -67,8 +66,7 @@ describe('PagesService', () => {
|
||||
'/api/v1/d/pages/12/source/update',
|
||||
expect.objectContaining({
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: true,
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('Pages latest source automatic updates', () => {
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked();
|
||||
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(120);
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('Pages latest source automatic updates', () => {
|
||||
|
||||
expect(await screen.findByText('固定 Tag · v1.2.3')).toBeVisible();
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('下次检查')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('Pages latest source automatic updates', () => {
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
|
||||
await user.clear(intervalInput);
|
||||
await user.type(intervalInput, '4');
|
||||
@@ -162,7 +162,7 @@ describe('Pages latest source automatic updates', () => {
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
const assetInput = screen.getByLabelText('Release Asset 文件名');
|
||||
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
|
||||
await user.clear(assetInput);
|
||||
@@ -199,7 +199,7 @@ describe('Pages latest source automatic updates', () => {
|
||||
<PagesSourceCard key='source-9' projectId={9} />,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
|
||||
rerenderWithQuery(<PagesSourceCard key='source-10' projectId={10} />);
|
||||
|
||||
@@ -62,9 +62,8 @@ function renderWithQuery(ui: React.ReactNode) {
|
||||
|
||||
const remoteSource: PagesRemoteURLSource = {
|
||||
source_type: 'remote_url',
|
||||
has_remote_url: true,
|
||||
display_url: 'https://artifacts.example.com/site.zip?***',
|
||||
remote_network_policy: 'public',
|
||||
remote_url: 'https://artifacts.example.com/site.zip?token=secret',
|
||||
allow_insecure: false,
|
||||
sync_status: 'idle',
|
||||
last_applied: {
|
||||
revision: 'a'.repeat(64),
|
||||
@@ -159,28 +158,21 @@ describe('Pages source UI', () => {
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
expect(await screen.findByText('本地部署包')).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置 Remote URL' }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '配置' })).toBeVisible();
|
||||
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '配置' }));
|
||||
expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible();
|
||||
expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible();
|
||||
expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible();
|
||||
expect(
|
||||
screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked();
|
||||
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(60);
|
||||
expect(screen.getByRole('radio', { name: '手动部署' })).toBeChecked();
|
||||
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
|
||||
expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked();
|
||||
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(60);
|
||||
});
|
||||
|
||||
it('submits the GitHub latest automatic update settings', async () => {
|
||||
@@ -196,9 +188,8 @@ describe('Pages source UI', () => {
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
@@ -233,9 +224,8 @@ describe('Pages source UI', () => {
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
|
||||
const repositoryInput = screen.getByLabelText('GitHub 仓库 URL');
|
||||
await user.type(repositoryInput, 'https://github.com//openflare/site');
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
@@ -271,9 +261,8 @@ describe('Pages source UI', () => {
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
@@ -315,9 +304,8 @@ describe('Pages source UI', () => {
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
@@ -369,9 +357,8 @@ describe('Pages source UI', () => {
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
@@ -382,7 +369,9 @@ describe('Pages source UI', () => {
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
expect(screen.getByText('检查中')).toBeVisible();
|
||||
});
|
||||
const checkButton = screen.getByRole('button', { name: /检查更新/ });
|
||||
const checkButton = await screen.findByRole('button', {
|
||||
name: /检查更新/,
|
||||
});
|
||||
expect(checkButton).toBeDisabled();
|
||||
expect(within(checkButton).getByRole('status')).toBeVisible();
|
||||
expect(PagesService.checkSource).not.toHaveBeenCalled();
|
||||
@@ -398,7 +387,7 @@ describe('Pages source UI', () => {
|
||||
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
|
||||
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
|
||||
expect(screen.getByText('有可用更新')).toBeVisible();
|
||||
expect(screen.getByText('下次检查时间')).toBeVisible();
|
||||
expect(screen.getByText('下次检查')).toBeVisible();
|
||||
expect(screen.getByText('自动更新')).toBeVisible();
|
||||
expect(screen.getByText('已关闭')).toBeVisible();
|
||||
expect(screen.getByText('检查间隔')).toBeVisible();
|
||||
@@ -496,11 +485,14 @@ describe('Pages source UI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('never reuses the masked URL as an editable value', async () => {
|
||||
it('edits the remote URL as plain text', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: remoteSource,
|
||||
source: {
|
||||
...remoteSource,
|
||||
remote_url: 'https://new.example.com/site.zip?token=new',
|
||||
},
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
@@ -508,72 +500,55 @@ describe('Pages source UI', () => {
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText('https://artifacts.example.com/site.zip?***'),
|
||||
await screen.findByText(
|
||||
'https://artifacts.example.com/site.zip?token=secret',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText(/token=secret/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑来源' }));
|
||||
await user.click(screen.getByRole('button', { name: '更换地址' }));
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'https://artifacts.example.com/site.zip?token=...',
|
||||
);
|
||||
expect(input).toHaveValue('');
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
await user.type(input, 'https://new.example.com/site.zip?token=new');
|
||||
expect(
|
||||
screen.queryByText('https://new.example.com/site.zip?token=new'),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '显示 Remote URL' }));
|
||||
await user.click(screen.getByRole('button', { name: '配置' }));
|
||||
const input = screen.getByLabelText('Remote URL');
|
||||
expect(input).toHaveAttribute('type', 'url');
|
||||
expect(input).toHaveValue(
|
||||
'https://artifacts.example.com/site.zip?token=secret',
|
||||
);
|
||||
await user.clear(input);
|
||||
await user.type(input, 'https://new.example.com/site.zip?token=new');
|
||||
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: true,
|
||||
remote_url: 'https://new.example.com/site.zip?token=new',
|
||||
remote_network_policy: 'public',
|
||||
allow_insecure: false,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '编辑来源' }));
|
||||
await user.click(screen.getByRole('button', { name: '更换地址' }));
|
||||
const reopenedInput = screen.getByPlaceholderText(
|
||||
'https://artifacts.example.com/site.zip?token=...',
|
||||
);
|
||||
expect(reopenedInput).toHaveValue('');
|
||||
expect(reopenedInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('requires a second confirmation for trusted internal networking', async () => {
|
||||
it('saves the allow-insecure connection switch for remote sources', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: { ...remoteSource, remote_network_policy: 'trusted_internal' },
|
||||
source: { ...remoteSource, allow_insecure: true },
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
await user.click(screen.getByRole('radio', { name: '受信内网模式' }));
|
||||
await user.click(await screen.findByRole('button', { name: '配置' }));
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '允许不安全的连接' }),
|
||||
).not.toBeChecked();
|
||||
await user.click(
|
||||
screen.getByRole('switch', { name: '允许不安全的连接' }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
|
||||
|
||||
expect(await screen.findByText('启用受信内网模式')).toBeVisible();
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: false,
|
||||
remote_url: '',
|
||||
remote_network_policy: 'trusted_internal',
|
||||
remote_url: 'https://artifacts.example.com/site.zip?token=secret',
|
||||
allow_insecure: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -658,6 +633,6 @@ describe('Pages source UI', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible();
|
||||
expect(screen.getByText('当前生产部署')).toBeVisible();
|
||||
expect(screen.getByText('Production')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
)
|
||||
|
||||
// downloadPagesPackageFromURL is the deprecated one-shot URL adapter. It uses
|
||||
// the same bounded downloader as persisted sources, with the legacy trusted
|
||||
// network policy that permits operator-managed internal artifact services.
|
||||
// the same bounded downloader as persisted sources and allows insecure TLS for
|
||||
// operator-managed internal artifact services.
|
||||
func downloadPagesPackageFromURL(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
@@ -25,7 +25,7 @@ func downloadPagesPackageFromURL(
|
||||
}
|
||||
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
|
||||
URL: strings.TrimSpace(rawURL),
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: maxPackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -41,9 +41,7 @@ const (
|
||||
errPagesSourceTypeUnsupported = "pages 部署源类型不受支持"
|
||||
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
||||
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
||||
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
|
||||
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
||||
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
|
||||
errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置"
|
||||
errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}"
|
||||
errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效"
|
||||
|
||||
@@ -53,8 +53,7 @@ func validateGitHubSourceInput(input SourceUpdateInput) error {
|
||||
if strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if input.RemoteURLSet || strings.TrimSpace(input.RemoteURL) != "" ||
|
||||
strings.TrimSpace(input.RemoteNetworkPolicy) != "" {
|
||||
if strings.TrimSpace(input.RemoteURL) != "" || input.AllowInsecure {
|
||||
return errors.New(errPagesSourceGitHubFields)
|
||||
}
|
||||
if _, err := normalizeGitHubRepositoryURL(input.RepositoryURL); err != nil {
|
||||
@@ -275,7 +274,7 @@ func githubSourceUpdates(config githubSourceConfig, version int) map[string]any
|
||||
return map[string]any{
|
||||
"source_type": PagesSourceTypeGitHubRelease,
|
||||
"remote_url": "",
|
||||
"remote_network_policy": "",
|
||||
"allow_insecure": false,
|
||||
"github_repository": config.Repository,
|
||||
"release_selector": config.Selector,
|
||||
"release_tag": config.Tag,
|
||||
@@ -289,7 +288,7 @@ func githubSourceUpdates(config githubSourceConfig, version int) map[string]any
|
||||
|
||||
func githubSourceConfigChanged(existing *model.PagesProjectSource, config githubSourceConfig) bool {
|
||||
return existing.SourceType != PagesSourceTypeGitHubRelease || existing.RemoteURL != "" ||
|
||||
existing.RemoteNetworkPolicy != "" || existing.GitHubRepository != config.Repository ||
|
||||
existing.AllowInsecure || existing.GitHubRepository != config.Repository ||
|
||||
existing.ReleaseSelector != config.Selector || existing.ReleaseTag != config.Tag ||
|
||||
existing.AssetName != config.AssetName || existing.AutoUpdateEnabled != config.AutoUpdate ||
|
||||
existing.CheckIntervalMinutes != config.CheckInterval
|
||||
|
||||
@@ -150,9 +150,8 @@ func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
|
||||
secret := "provider-switch-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
|
||||
AllowInsecure: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(GitHub to Remote) error = %v, want nil", err)
|
||||
}
|
||||
@@ -165,8 +164,8 @@ func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
|
||||
t.Fatalf("UpdateSourceAs(Remote to GitHub) error = %v, want nil", err)
|
||||
}
|
||||
github, _ := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if github.RemoteURL != "" || github.RemoteNetworkPolicy != "" {
|
||||
t.Errorf("GitHub switched source retained Remote fields: URL=%q policy=%q", github.RemoteURL, github.RemoteNetworkPolicy)
|
||||
if github.RemoteURL != "" || github.AllowInsecure {
|
||||
t.Errorf("GitHub switched source retained Remote fields: URL=%q allow_insecure=%v", github.RemoteURL, github.AllowInsecure)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ func handleSourceLogicError(c *gin.Context, err error) bool {
|
||||
errPagesSourceTypeUnsupported,
|
||||
errPagesSourceRemoteFields,
|
||||
errPagesSourceRemoteURLRequired,
|
||||
errPagesSourceRemoteURLMode,
|
||||
errPagesSourceRemoteURLInvalid,
|
||||
errPagesSourceNetworkPolicy,
|
||||
errPagesSourceGitHubFields,
|
||||
errPagesSourceRepositoryInvalid,
|
||||
errPagesSourceSelectorInvalid,
|
||||
@@ -239,7 +237,7 @@ func DeleteProjectHandler(c *gin.Context) {
|
||||
|
||||
// GetSourceHandler 获取 Pages 项目的部署源。
|
||||
// @Summary 获取 Pages 部署源
|
||||
// @Description 返回脱敏后的项目部署源配置与运行状态,需要管理员权限
|
||||
// @Description 返回项目部署源配置与运行状态,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestPagesSourceHandlersReturnStableActionErrors(t *testing.T) {
|
||||
ctx,
|
||||
remoteProject.ID,
|
||||
"https://example.com/site.zip?token=handler-secret",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
code, envelope = performPagesSourceRequest(
|
||||
t,
|
||||
@@ -135,7 +135,7 @@ func TestPagesSourceHandlersReturnStableActionErrors(t *testing.T) {
|
||||
ctx,
|
||||
busyProject.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
future := time.Now().Add(time.Minute)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
@@ -172,7 +172,7 @@ func TestSyncSourceHandlerAcceptsEmptyBodyAndEmptyObject(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=dispatch-secret",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
path := fmt.Sprintf("/api/v1/d/pages/%d/source/sync", project.ID)
|
||||
|
||||
|
||||
@@ -45,12 +45,11 @@ const (
|
||||
|
||||
// SourceUpdateInput is the discriminated source configuration payload.
|
||||
// GitHub fields are accepted by the decoder so mode-incompatible values can be
|
||||
// rejected deterministically; GitHub itself is enabled in Phase 2.
|
||||
// rejected deterministically.
|
||||
type SourceUpdateInput struct {
|
||||
SourceType string `json:"source_type"`
|
||||
RemoteURLSet bool `json:"remote_url_set"`
|
||||
RemoteURL string `json:"remote_url"`
|
||||
RemoteNetworkPolicy string `json:"remote_network_policy"`
|
||||
AllowInsecure bool `json:"allow_insecure"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
ReleaseSelector string `json:"release_selector"`
|
||||
ReleaseTag string `json:"release_tag"`
|
||||
@@ -59,19 +58,18 @@ type SourceUpdateInput struct {
|
||||
CheckIntervalMinutes int `json:"check_interval_minutes"`
|
||||
}
|
||||
|
||||
// SourceRevisionView is a credential-free source cursor.
|
||||
// SourceRevisionView is a source cursor shown to the console.
|
||||
type SourceRevisionView struct {
|
||||
Revision string `json:"revision"`
|
||||
Label string `json:"label"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
}
|
||||
|
||||
// SourceView is the safe discriminated source view returned to the console.
|
||||
// SourceView is the discriminated source view returned to the console.
|
||||
type SourceView struct {
|
||||
SourceType string `json:"source_type"`
|
||||
HasRemoteURL bool `json:"has_remote_url,omitempty"`
|
||||
DisplayURL string `json:"display_url,omitempty"`
|
||||
RemoteNetworkPolicy string `json:"remote_network_policy,omitempty"`
|
||||
RemoteURL string `json:"remote_url,omitempty"`
|
||||
AllowInsecure bool `json:"allow_insecure,omitempty"`
|
||||
GitHubRepository string `json:"github_repository,omitempty"`
|
||||
ReleaseSelector string `json:"release_selector,omitempty"`
|
||||
ReleaseTag string `json:"release_tag,omitempty"`
|
||||
@@ -115,9 +113,9 @@ type sourceDetail struct {
|
||||
}
|
||||
|
||||
type remoteSourceConfig struct {
|
||||
URL string
|
||||
Policy string
|
||||
Identity string
|
||||
URL string
|
||||
AllowInsecure bool
|
||||
Identity string
|
||||
}
|
||||
|
||||
// GetSource returns the current persisted source or a manual discriminator.
|
||||
@@ -228,26 +226,22 @@ func loadProjectSourceForUpdate(tx *gorm.DB, projectID uint) (*model.PagesProjec
|
||||
}
|
||||
|
||||
func buildRemoteSourceConfig(
|
||||
existing *model.PagesProjectSource,
|
||||
hasExisting bool,
|
||||
_ *model.PagesProjectSource,
|
||||
_ bool,
|
||||
input SourceUpdateInput,
|
||||
) (remoteSourceConfig, error) {
|
||||
remoteURL, err := resolveUpdatedRemoteURL(existing, hasExisting, input)
|
||||
if err != nil {
|
||||
return remoteSourceConfig{}, err
|
||||
remoteURL := strings.TrimSpace(input.RemoteURL)
|
||||
if remoteURL == "" {
|
||||
return remoteSourceConfig{}, errors.New(errPagesSourceRemoteURLRequired)
|
||||
}
|
||||
parsedURL, err := parseRemoteSourceURL(remoteURL)
|
||||
if err != nil {
|
||||
return remoteSourceConfig{}, err
|
||||
}
|
||||
policy := strings.TrimSpace(input.RemoteNetworkPolicy)
|
||||
if policy == "" {
|
||||
policy = RemoteNetworkPolicyPublic
|
||||
}
|
||||
return remoteSourceConfig{
|
||||
URL: remoteURL,
|
||||
Policy: policy,
|
||||
Identity: remoteSourceIdentity(parsedURL),
|
||||
URL: remoteURL,
|
||||
AllowInsecure: input.AllowInsecure,
|
||||
Identity: remoteSourceIdentity(parsedURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -256,7 +250,7 @@ func createRemoteSourceTx(tx *gorm.DB, projectID uint, config remoteSourceConfig
|
||||
ProjectID: projectID,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: config.URL,
|
||||
RemoteNetworkPolicy: config.Policy,
|
||||
AllowInsecure: config.AllowInsecure,
|
||||
AutoUpdateEnabled: false,
|
||||
CheckIntervalMinutes: 0,
|
||||
ConfigVersion: 1,
|
||||
@@ -289,7 +283,7 @@ func updateExistingRemoteSourceTx(
|
||||
if err := tx.Model(existing).Updates(map[string]any{
|
||||
"source_type": PagesSourceTypeRemoteURL,
|
||||
"remote_url": config.URL,
|
||||
"remote_network_policy": config.Policy,
|
||||
"allow_insecure": config.AllowInsecure,
|
||||
"github_repository": "",
|
||||
"release_selector": "",
|
||||
"release_tag": "",
|
||||
@@ -307,7 +301,7 @@ func updateExistingRemoteSourceTx(
|
||||
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
|
||||
return existing.SourceType != PagesSourceTypeRemoteURL ||
|
||||
existing.RemoteURL != config.URL ||
|
||||
existing.RemoteNetworkPolicy != config.Policy ||
|
||||
existing.AllowInsecure != config.AllowInsecure ||
|
||||
existing.GitHubRepository != "" ||
|
||||
existing.ReleaseSelector != "" ||
|
||||
existing.ReleaseTag != "" ||
|
||||
@@ -363,14 +357,7 @@ func validateRemoteSourceInput(input SourceUpdateInput) error {
|
||||
input.AutoUpdateEnabled || input.CheckIntervalMinutes != 0 {
|
||||
return errors.New(errPagesSourceRemoteFields)
|
||||
}
|
||||
policy := strings.TrimSpace(input.RemoteNetworkPolicy)
|
||||
if policy != "" && policy != RemoteNetworkPolicyPublic && policy != RemoteNetworkPolicyTrustedInternal {
|
||||
return errors.New(errPagesSourceNetworkPolicy)
|
||||
}
|
||||
if !input.RemoteURLSet && strings.TrimSpace(input.RemoteURL) != "" {
|
||||
return errors.New(errPagesSourceRemoteURLMode)
|
||||
}
|
||||
if input.RemoteURLSet && strings.TrimSpace(input.RemoteURL) == "" {
|
||||
if strings.TrimSpace(input.RemoteURL) == "" {
|
||||
return errors.New(errPagesSourceRemoteURLRequired)
|
||||
}
|
||||
return nil
|
||||
@@ -389,16 +376,6 @@ func validateSourceUpdateInput(input SourceUpdateInput) error {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveUpdatedRemoteURL(existing *model.PagesProjectSource, hasExisting bool, input SourceUpdateInput) (string, error) {
|
||||
if input.RemoteURLSet {
|
||||
return strings.TrimSpace(input.RemoteURL), nil
|
||||
}
|
||||
if !hasExisting || existing.SourceType != PagesSourceTypeRemoteURL || strings.TrimSpace(existing.RemoteURL) == "" {
|
||||
return "", errors.New(errPagesSourceRemoteURLRequired)
|
||||
}
|
||||
return existing.RemoteURL, nil
|
||||
}
|
||||
|
||||
func parseRemoteSourceURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
@@ -436,20 +413,6 @@ func remoteSourceIdentity(parsed *url.URL) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func displayRemoteSourceURL(raw string) string {
|
||||
parsed, err := parseRemoteSourceURL(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
hadQuery := parsed.RawQuery != ""
|
||||
parsed.RawQuery = ""
|
||||
display := parsed.String()
|
||||
if hadQuery {
|
||||
display += "?***"
|
||||
}
|
||||
return display
|
||||
}
|
||||
|
||||
func loadSourceByProject(ctx context.Context, projectID uint) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime, error) {
|
||||
var source model.PagesProjectSource
|
||||
if err := db.DB(ctx).Where("project_id = ?", projectID).First(&source).Error; err != nil {
|
||||
@@ -477,9 +440,8 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
|
||||
}
|
||||
switch source.SourceType {
|
||||
case PagesSourceTypeRemoteURL:
|
||||
view.HasRemoteURL = strings.TrimSpace(source.RemoteURL) != ""
|
||||
view.DisplayURL = displayRemoteSourceURL(source.RemoteURL)
|
||||
view.RemoteNetworkPolicy = source.RemoteNetworkPolicy
|
||||
view.RemoteURL = source.RemoteURL
|
||||
view.AllowInsecure = source.AllowInsecure
|
||||
case PagesSourceTypeGitHubRelease:
|
||||
view.LastCheckedAt = runtime.LastCheckedAt
|
||||
view.NextCheckAt = runtime.NextCheckAt
|
||||
|
||||
@@ -26,13 +26,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// RemoteNetworkPolicyPublic only permits publicly routable targets and
|
||||
// performs DNS validation again for every connection.
|
||||
RemoteNetworkPolicyPublic = "public"
|
||||
// RemoteNetworkPolicyTrustedInternal permits private targets and self-signed
|
||||
// TLS certificates. It is an explicit administrator trust boundary.
|
||||
RemoteNetworkPolicyTrustedInternal = "trusted_internal"
|
||||
|
||||
remoteSourceDownloadTimeout = 10 * time.Minute
|
||||
remoteSourceResponseHeaderTimeout = 30 * time.Second
|
||||
remoteSourceDialTimeout = 30 * time.Second
|
||||
@@ -52,12 +45,9 @@ func (providerError remoteProviderError) Error() string {
|
||||
return string(providerError)
|
||||
}
|
||||
|
||||
const (
|
||||
errRemoteProviderInvalidPolicy remoteProviderError = "远程来源网络策略无效"
|
||||
errRemoteProviderInvalidLimit remoteProviderError = "远程来源部署包大小限制无效"
|
||||
errRemoteProviderBlockedAddress remoteProviderError = "远程来源 public 策略禁止访问非公网地址"
|
||||
errRemoteProviderResolveFailed remoteProviderError = "远程来源地址解析失败"
|
||||
errRemoteProviderRedirectLimit remoteProviderError = "远程来源重定向次数超过限制"
|
||||
const (
|
||||
errRemoteProviderInvalidLimit remoteProviderError = "远程来源部署包大小限制无效"
|
||||
errRemoteProviderRedirectLimit remoteProviderError = "远程来源重定向次数超过限制"
|
||||
errRemoteProviderDownloadFailed remoteProviderError = errPagesPackageURLDownloadFailed
|
||||
errRemoteProviderTooLarge remoteProviderError = errPagesPackageURLTooLarge
|
||||
errRemoteProviderEmpty remoteProviderError = errPagesPackageEmpty
|
||||
@@ -65,38 +55,10 @@ const (
|
||||
errRemoteProviderCleanupFailed remoteProviderError = "清理远程来源临时文件失败"
|
||||
)
|
||||
|
||||
var remoteSourceNonPublicPrefixes = []netip.Prefix{
|
||||
// IPv4 special-use, private, link-local, documentation, multicast and
|
||||
// reserved ranges. A conservative deny list is intentional for SSRF safety.
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
// IPv6 protocol-assignment, documentation and transition ranges that are
|
||||
// not acceptable as direct public artifact origins.
|
||||
netip.MustParsePrefix("2001::/23"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("3fff::/20"),
|
||||
}
|
||||
|
||||
var remoteSourcePublicIPv6Prefix = netip.MustParsePrefix("2000::/3")
|
||||
|
||||
// RemoteSourceRequest describes one immutable Remote URL package fetch.
|
||||
type RemoteSourceRequest struct {
|
||||
URL string
|
||||
NetworkPolicy string
|
||||
AllowInsecure bool
|
||||
MaxPackageBytes int64
|
||||
}
|
||||
|
||||
@@ -158,20 +120,16 @@ func fetchRemoteSource(ctx context.Context, request RemoteSourceRequest, depende
|
||||
if dependencies.dialContext == nil || dependencies.createTemp == nil {
|
||||
return nil, errRemoteProviderDownloadFailed
|
||||
}
|
||||
policy, err := normalizeRemoteNetworkPolicy(request.NetworkPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := parseRemoteSourceURL(request.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRemoteSourceTarget(ctx, parsed, policy, dependencies.resolver); err != nil {
|
||||
if err := validateRemoteSourceTarget(ctx, parsed); err != nil {
|
||||
return nil, sanitizeRemoteProviderError(ctx, err)
|
||||
}
|
||||
|
||||
safeLabel, namedFormat := remoteSourceLabel(parsed)
|
||||
client := newRemoteSourceClient(policy, dependencies)
|
||||
client := newRemoteSourceClient(request.AllowInsecure, dependencies)
|
||||
defer client.CloseIdleConnections()
|
||||
response, err := requestRemoteSource(ctx, client, parsed)
|
||||
if err != nil {
|
||||
@@ -211,33 +169,18 @@ func fetchRemoteSource(ctx context.Context, request RemoteSourceRequest, depende
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRemoteNetworkPolicy(policy string) (string, error) {
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "", RemoteNetworkPolicyPublic:
|
||||
return RemoteNetworkPolicyPublic, nil
|
||||
case RemoteNetworkPolicyTrustedInternal:
|
||||
return RemoteNetworkPolicyTrustedInternal, nil
|
||||
default:
|
||||
return "", errRemoteProviderInvalidPolicy
|
||||
}
|
||||
}
|
||||
|
||||
func newRemoteSourceClient(policy string, dependencies remoteSourceDependencies) *http.Client {
|
||||
func newRemoteSourceClient(allowInsecure bool, dependencies remoteSourceDependencies) *http.Client {
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
dialContext := dependencies.dialContext
|
||||
if policy == RemoteNetworkPolicyPublic {
|
||||
dialContext = newPublicRemoteSourceDialer(dependencies.resolver, dependencies.dialContext)
|
||||
} else {
|
||||
// trusted_internal is an explicit administrator-selected boundary for
|
||||
// private artifact services using an internal CA or self-signed cert.
|
||||
tlsConfig.InsecureSkipVerify = true //nolint:gosec // required trusted_internal semantics
|
||||
if allowInsecure {
|
||||
// Explicit administrator choice for self-signed or private CA endpoints.
|
||||
tlsConfig.InsecureSkipVerify = true //nolint:gosec // required allow_insecure semantics
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: remoteSourceDownloadTimeout,
|
||||
Transport: httppool.NewTransport(httppool.TransportOptions{
|
||||
Proxy: nil,
|
||||
DialContext: dialContext,
|
||||
DialContext: dependencies.dialContext,
|
||||
TLSClientConfig: tlsConfig,
|
||||
ResponseHeaderTimeout: remoteSourceResponseHeaderTimeout,
|
||||
TraceFilter: remoteSourceTraceFilter,
|
||||
@@ -248,7 +191,7 @@ func newRemoteSourceClient(policy string, dependencies remoteSourceDependencies)
|
||||
return errRemoteProviderRedirectLimit
|
||||
}
|
||||
stripRemoteSourceRedirectHeaders(next)
|
||||
if err := validateRemoteSourceTarget(next.Context(), next.URL, policy, dependencies.resolver); err != nil {
|
||||
if err := validateRemoteSourceTarget(next.Context(), next.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
applyRemoteSourceHeaders(next)
|
||||
@@ -293,7 +236,7 @@ func remoteSourceTraceFilter(request *http.Request) bool {
|
||||
return request.URL == nil || request.URL.RawQuery == ""
|
||||
}
|
||||
|
||||
func validateRemoteSourceTarget(ctx context.Context, target *url.URL, policy string, resolver remoteSourceResolver) error {
|
||||
func validateRemoteSourceTarget(_ context.Context, target *url.URL) error {
|
||||
if target == nil || target.User != nil || target.Fragment != "" || target.Opaque != "" {
|
||||
return errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
@@ -301,92 +244,7 @@ func validateRemoteSourceTarget(ctx context.Context, target *url.URL, policy str
|
||||
if (scheme != remoteSourceSchemeHTTP && scheme != remoteSourceSchemeHTTPS) || strings.TrimSpace(target.Hostname()) == "" {
|
||||
return errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
if policy != RemoteNetworkPolicyPublic {
|
||||
return nil
|
||||
}
|
||||
_, err := resolvePublicRemoteSourceIPs(ctx, resolver, target.Hostname())
|
||||
return err
|
||||
}
|
||||
|
||||
func newPublicRemoteSourceDialer(
|
||||
resolver remoteSourceResolver,
|
||||
directDial func(context.Context, string, string) (net.Conn, error),
|
||||
) func(context.Context, string, string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, errRemoteProviderDownloadFailed
|
||||
}
|
||||
addresses, err := resolvePublicRemoteSourceIPs(ctx, resolver, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if !remoteSourceIPMatchesNetwork(address, network) {
|
||||
continue
|
||||
}
|
||||
connection, dialErr := directDial(ctx, network, net.JoinHostPort(address.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
}
|
||||
return nil, errRemoteProviderDownloadFailed
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePublicRemoteSourceIPs(ctx context.Context, resolver remoteSourceResolver, host string) ([]netip.Addr, error) {
|
||||
if strings.Contains(host, "%") {
|
||||
return nil, errRemoteProviderBlockedAddress
|
||||
}
|
||||
if literal, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||
if !isPublicRemoteSourceIP(literal) {
|
||||
return nil, errRemoteProviderBlockedAddress
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
}
|
||||
if resolver == nil {
|
||||
return nil, errRemoteProviderResolveFailed
|
||||
}
|
||||
addresses, err := resolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return nil, errRemoteProviderResolveFailed
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if !isPublicRemoteSourceIP(address) {
|
||||
return nil, errRemoteProviderBlockedAddress
|
||||
}
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func isPublicRemoteSourceIP(address netip.Addr) bool {
|
||||
if !address.IsValid() || address.Zone() != "" {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
if !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
if address.Is6() && !remoteSourcePublicIPv6Prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range remoteSourceNonPublicPrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func remoteSourceIPMatchesNetwork(address netip.Addr, network string) bool {
|
||||
switch network {
|
||||
case "tcp4":
|
||||
return address.Unmap().Is4()
|
||||
case "tcp6":
|
||||
return address.Unmap().Is6()
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamRemoteSourcePackage(
|
||||
@@ -531,10 +389,7 @@ func sanitizeRemoteProviderError(ctx context.Context, err error) error {
|
||||
return fmt.Errorf("%w: %w", errRemoteProviderDownloadFailed, ctxErr)
|
||||
}
|
||||
for _, safeError := range []error{
|
||||
errRemoteProviderInvalidPolicy,
|
||||
errRemoteProviderInvalidLimit,
|
||||
errRemoteProviderBlockedAddress,
|
||||
errRemoteProviderResolveFailed,
|
||||
errRemoteProviderRedirectLimit,
|
||||
errRemoteProviderTooLarge,
|
||||
errRemoteProviderEmpty,
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestFetchRemoteSourceTrustedInternalSelfSignedAndSafeLabel(t *testing.T) {
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/original/site.zip?token=source-secret",
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -106,7 +106,7 @@ func TestFetchRemoteSourceKeepsOriginalLabelAcrossRedirect(t *testing.T) {
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/original/site.zip?token=initial-secret",
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -118,29 +118,28 @@ func TestFetchRemoteSourceKeepsOriginalLabelAcrossRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsNonPublicAddresses(t *testing.T) {
|
||||
tests := []string{
|
||||
"http://127.0.0.1/site.zip?token=loopback-secret",
|
||||
"http://[::1]/site.zip?token=ipv6-secret",
|
||||
"http://100.64.0.1/site.zip?token=cgnat-secret",
|
||||
"http://192.0.2.1/site.zip?token=documentation-secret",
|
||||
func TestFetchRemoteSourcePublicAllowsPrivateAddresses(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/site.zip?token=private-secret",
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
for _, rawURL := range tests {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
_, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
MaxPackageBytes: 1024,
|
||||
})
|
||||
if !errors.Is(err, errRemoteProviderBlockedAddress) {
|
||||
t.Fatalf("FetchRemoteSource() error = %v, want blocked address", err)
|
||||
}
|
||||
assertRemoteSourceErrorRedacted(t, err, rawURL, "secret", "token=")
|
||||
})
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if candidate.Format != "zip" {
|
||||
t.Fatalf("candidate format = %q, want zip", candidate.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicDialsValidatedIP(t *testing.T) {
|
||||
func TestFetchRemoteSourcePublicUsesDirectDialer(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
@@ -156,76 +155,44 @@ func TestFetchRemoteSourcePublicDialsValidatedIP(t *testing.T) {
|
||||
}
|
||||
candidate, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: "http://artifact.example/site.zip",
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRemoteSource() error = %v", err)
|
||||
}
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if dialedAddress != "93.184.216.34:80" {
|
||||
t.Fatalf("direct dial address = %q, want validated IP", dialedAddress)
|
||||
if dialedAddress != "artifact.example:80" {
|
||||
t.Fatalf("direct dial address = %q, want hostname dial", dialedAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsPrivateRedirect(t *testing.T) {
|
||||
var requestCount atomic.Int32
|
||||
func TestFetchRemoteSourcePublicAllowsPrivateRedirect(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
var privateServer *httptest.Server
|
||||
privateServer = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(privateServer.Close)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
requestCount.Add(1)
|
||||
writer.Header().Set("Location", "http://127.0.0.1/private.zip?token=redirect-secret")
|
||||
writer.Header().Set("Location", privateServer.URL+"/private.zip?token=redirect-secret")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
dependencies := mappedRemoteSourceDependencies(server.Listener.Addr().String(), staticPublicRemoteSourceResolver())
|
||||
rawURL := "http://artifact.example/start.zip?token=initial-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderBlockedAddress) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want blocked redirect", err)
|
||||
}
|
||||
if requestCount.Load() != 1 {
|
||||
t.Fatalf("request count = %d, private redirect must not be requested", requestCount.Load())
|
||||
}
|
||||
assertRemoteSourceErrorRedacted(t, err, rawURL, "initial-secret", "redirect-secret", "token=")
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsDNSRebinding(t *testing.T) {
|
||||
var lookupCount atomic.Int32
|
||||
resolver := remoteSourceResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
if lookupCount.Add(1) == 1 {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/start.zip?token=initial-secret",
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
var dialCount atomic.Int32
|
||||
dependencies := remoteSourceDependencies{
|
||||
resolver: resolver,
|
||||
dialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
dialCount.Add(1)
|
||||
return nil, errors.New("unexpected dial")
|
||||
},
|
||||
createTemp: os.CreateTemp,
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
rawURL := "http://rebind.example/site.zip?signature=dns-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderBlockedAddress) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want DNS rebinding rejection", err)
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if candidate.Format != "zip" {
|
||||
t.Fatalf("candidate format = %q, want zip", candidate.Format)
|
||||
}
|
||||
if lookupCount.Load() != 2 {
|
||||
t.Fatalf("DNS lookup count = %d, want preflight plus dial validation", lookupCount.Load())
|
||||
}
|
||||
if dialCount.Load() != 0 {
|
||||
t.Fatalf("direct dial count = %d, rebound address must not be dialed", dialCount.Load())
|
||||
}
|
||||
assertRemoteSourceErrorRedacted(t, err, rawURL, "dns-secret", "signature=")
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsSelfSignedTLS(t *testing.T) {
|
||||
@@ -240,7 +207,7 @@ func TestFetchRemoteSourcePublicRejectsSelfSignedTLS(t *testing.T) {
|
||||
rawURL := "https://artifact.example/site.zip?signature=tls-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderDownloadFailed) {
|
||||
@@ -268,7 +235,7 @@ func TestFetchRemoteSourceRejectsChunkedBodyOverLimitAndCleansTemp(t *testing.T)
|
||||
rawURL := server.URL + "/site.zip?token=chunk-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: maxPackageBytes,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderTooLarge) {
|
||||
@@ -293,7 +260,7 @@ func TestFetchRemoteSourceRejectsContentLengthBeforeCreatingTemp(t *testing.T) {
|
||||
}
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/site.zip",
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderTooLarge) {
|
||||
@@ -314,7 +281,7 @@ func TestFetchRemoteSourceSniffsAtLeast512BytesForTar(t *testing.T) {
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/download",
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -344,7 +311,7 @@ func TestFetchRemoteSourceRedactsURLHeadersAndBodyFromErrors(t *testing.T) {
|
||||
rawURL := server.URL + "/download?token=query-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderUnsupported) {
|
||||
@@ -380,7 +347,7 @@ func TestFetchRemoteSourceAllowsFiveRedirectsOnly(t *testing.T) {
|
||||
dependencies := mappedRemoteSourceDependencies(server.Listener.Addr().String(), staticPublicRemoteSourceResolver())
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: "http://artifact.example/0",
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderRedirectLimit) {
|
||||
|
||||
@@ -54,7 +54,7 @@ type sourceExecutionSnapshot struct {
|
||||
SourceType string
|
||||
SourceIdentity string
|
||||
RemoteURL string
|
||||
RemoteNetworkPolicy string
|
||||
AllowInsecure bool
|
||||
GitHubRepository string
|
||||
ReleaseSelector string
|
||||
ReleaseTag string
|
||||
@@ -176,7 +176,7 @@ func loadSourceExecutionSnapshot(
|
||||
SourceType: source.SourceType,
|
||||
SourceIdentity: source.SourceIdentity,
|
||||
RemoteURL: source.RemoteURL,
|
||||
RemoteNetworkPolicy: source.RemoteNetworkPolicy,
|
||||
AllowInsecure: source.AllowInsecure,
|
||||
GitHubRepository: source.GitHubRepository,
|
||||
ReleaseSelector: source.ReleaseSelector,
|
||||
ReleaseTag: source.ReleaseTag,
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestSourceLeaseHeartbeatRenewsAndCancelsOnOwnershipLoss(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil || outcome != sourceLeaseAcquired || snapshot == nil {
|
||||
@@ -83,7 +83,7 @@ func TestAcquireSourceLeaseConcurrentOnlyOneOwner(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
type leaseResult struct {
|
||||
snapshot *sourceExecutionSnapshot
|
||||
@@ -138,7 +138,7 @@ func TestAcquireSourceLeaseMutualExclusionExpiryAndTerminalOwnership(t *testing.
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
|
||||
first, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
@@ -234,7 +234,7 @@ func TestSourceConfigAndProjectContentChangesFenceLease(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=first",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
|
||||
configSnapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
@@ -243,9 +243,8 @@ func TestSourceConfigAndProjectContentChangesFenceLease(t *testing.T) {
|
||||
}
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip?token=second",
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
AllowInsecure: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(config fence) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ func prepareRemoteSource(
|
||||
task.AppendLog(ctx, "[download] 正在获取远程部署包")
|
||||
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
|
||||
URL: snapshot.RemoteURL,
|
||||
NetworkPolicy: snapshot.RemoteNetworkPolicy,
|
||||
AllowInsecure: snapshot.AllowInsecure,
|
||||
MaxPackageBytes: limits.PackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestSyncRemoteSourceAtomicallyActivatesAndReusesChecksum(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
|
||||
firstSnapshot := mustAcquireRemoteSyncLease(t, ctx, source)
|
||||
@@ -220,7 +220,7 @@ func TestSyncRemoteSourceDownloadFailureKeepsOldActive(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
|
||||
_, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:2")
|
||||
@@ -244,7 +244,7 @@ func TestSyncRemoteSourceArchiveFailureKeepsOldActive(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
|
||||
_, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:3")
|
||||
@@ -275,7 +275,7 @@ func TestSyncRemoteSourceFinalFenceCompensatesIngest(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token=final-fence-secret",
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
|
||||
outcome, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:4")
|
||||
@@ -345,7 +345,7 @@ func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip",
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
first, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:5")
|
||||
if err != nil || first == nil || first.Deployment == nil {
|
||||
@@ -427,7 +427,7 @@ func TestCompensateSourceIngestSurvivesCanceledParentContext(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "cancel-compensation"})
|
||||
packagePath := filepath.Join(t.TempDir(), "site.zip")
|
||||
@@ -517,7 +517,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip",
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
snapshot := mustAcquireRemoteSyncLease(t, ctx, source)
|
||||
|
||||
|
||||
@@ -278,7 +278,6 @@ func isPermanentSourceSyncError(err error) bool {
|
||||
strings.Contains(message, errPagesPackageFileTooLarge) ||
|
||||
strings.Contains(message, errPagesEntryFileMissing) ||
|
||||
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
|
||||
strings.Contains(message, errPagesSourceNetworkPolicy) ||
|
||||
strings.Contains(message, errPagesSourceReleaseNotFound) ||
|
||||
strings.Contains(message, errPagesSourceDigestInvalid) ||
|
||||
strings.Contains(message, errPagesSourceDigestMismatch) ||
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestRemoteCheckActionIsPermanentWithoutExposingURL(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token="+secret,
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
raw, err := json.Marshal(SourceActionPayload{
|
||||
SourceID: source.ID,
|
||||
|
||||
@@ -5,7 +5,6 @@ package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -80,14 +79,13 @@ func mustConfigureRemoteSource(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
remoteURL string,
|
||||
policy string,
|
||||
allowInsecure bool,
|
||||
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||
t.Helper()
|
||||
_, err := UpdateSource(ctx, projectID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: remoteURL,
|
||||
RemoteNetworkPolicy: policy,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: remoteURL,
|
||||
AllowInsecure: allowInsecure,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSource(%d, %q) error = %v, want nil", projectID, remoteURL, err)
|
||||
@@ -111,23 +109,14 @@ func TestValidateRemoteSourceInputRejectsModeIncompatibleFields(t *testing.T) {
|
||||
{
|
||||
name: "missing source type",
|
||||
input: SourceUpdateInput{
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "github type reserved for phase two",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/example/site",
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote rejects repository field",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RepositoryURL: "https://github.com/example/site",
|
||||
},
|
||||
},
|
||||
@@ -135,25 +124,14 @@ func TestValidateRemoteSourceInputRejectsModeIncompatibleFields(t *testing.T) {
|
||||
name: "remote rejects automatic updates",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
AutoUpdateEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "url value requires replacement flag",
|
||||
name: "missing remote url",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid network policy",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RemoteNetworkPolicy: "private",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -185,7 +163,7 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "remote-crud")
|
||||
firstURL := "https://Artifacts.Example.com:443/dist/site.zip?token=first-secret&expires=1"
|
||||
source, runtime := mustConfigureRemoteSource(t, ctx, project.ID, firstURL, RemoteNetworkPolicyPublic)
|
||||
source, runtime := mustConfigureRemoteSource(t, ctx, project.ID, firstURL, false)
|
||||
|
||||
if got, want := source.ConfigVersion, 1; got != want {
|
||||
t.Errorf("new source ConfigVersion = %d, want %d", got, want)
|
||||
@@ -197,21 +175,12 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if got, want := view.DisplayURL, "https://Artifacts.Example.com:443/dist/site.zip?***"; got != want {
|
||||
t.Errorf("GetSource(%d).DisplayURL = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
encodedView, err := json.Marshal(view)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(GetSource(%d)) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if strings.Contains(string(encodedView), "first-secret") || strings.Contains(string(encodedView), "expires=1") {
|
||||
t.Errorf("GetSource(%d) JSON = %s, want credential-free view", project.ID, encodedView)
|
||||
if got, want := view.RemoteURL, firstURL; got != want {
|
||||
t.Errorf("GetSource(%d).RemoteURL = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: firstURL,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: firstURL,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, no-op) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
@@ -240,12 +209,12 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
t.Fatalf("seed source runtime error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Omit the secret URL while changing policy. The stored URL and cursor must
|
||||
// Keep the same URL while enabling insecure TLS. The identity and cursor must
|
||||
// survive, while the in-flight lease is fenced.
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: false,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: firstURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, preserve URL) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
@@ -275,10 +244,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
// Replacing only the query secret keeps the canonical identity and cursors.
|
||||
queryReplacementURL := "https://artifacts.example.com/dist/site.zip?token=second-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: queryReplacementURL,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: queryReplacementURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, query replacement) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
@@ -296,10 +264,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
// Replacing the path changes identity and clears all remote cursors.
|
||||
pathReplacementURL := "https://artifacts.example.com/dist/other.zip?token=third-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: pathReplacementURL,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: pathReplacementURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, path replacement) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
@@ -320,14 +287,8 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource(%d) after path replacement error = %v, want nil", project.ID, err)
|
||||
}
|
||||
pathJSON, err := json.Marshal(pathView)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(path view) error = %v, want nil", err)
|
||||
}
|
||||
for _, secret := range []string{"first-secret", "second-secret", "third-secret"} {
|
||||
if strings.Contains(string(pathJSON), secret) {
|
||||
t.Errorf("path view JSON = %s, want no secret %q", pathJSON, secret)
|
||||
}
|
||||
if got, want := pathView.RemoteURL, pathReplacementURL; got != want {
|
||||
t.Errorf("GetSource(%d).RemoteURL = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +314,7 @@ func TestDeleteSourceIsIdempotentAndKeepsDeploymentState(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=delete-secret",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
deployment := &model.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS of_pages_project_sources (
|
||||
project_id BIGINT NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT '',
|
||||
remote_url TEXT NOT NULL DEFAULT '',
|
||||
remote_network_policy VARCHAR(32) NOT NULL DEFAULT '',
|
||||
allow_insecure BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
github_repository VARCHAR(255) NOT NULL DEFAULT '',
|
||||
release_selector VARCHAR(16) NOT NULL DEFAULT '',
|
||||
release_tag VARCHAR(255) NOT NULL DEFAULT '',
|
||||
|
||||
@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS of_pages_project_sources (
|
||||
project_id INTEGER NOT NULL,
|
||||
source_type TEXT NOT NULL DEFAULT '',
|
||||
remote_url TEXT NOT NULL DEFAULT '',
|
||||
remote_network_policy TEXT NOT NULL DEFAULT '',
|
||||
allow_insecure INTEGER NOT NULL DEFAULT 0,
|
||||
github_repository TEXT NOT NULL DEFAULT '',
|
||||
release_selector TEXT NOT NULL DEFAULT '',
|
||||
release_tag TEXT NOT NULL DEFAULT '',
|
||||
|
||||
@@ -175,7 +175,7 @@ func createMigrationSourceRuntime(t *testing.T, gormDB *gorm.DB) uint {
|
||||
ProjectID: pagesMigrationProjectID,
|
||||
SourceType: "remote_url",
|
||||
RemoteURL: "https://example.com/site.zip?token=secret",
|
||||
RemoteNetworkPolicy: "public",
|
||||
AllowInsecure: false,
|
||||
CheckIntervalMinutes: 0,
|
||||
ConfigVersion: 1,
|
||||
SourceIdentity: strings.Repeat("b", 64),
|
||||
|
||||
@@ -7,14 +7,13 @@ import "time"
|
||||
|
||||
// PagesProjectSource 保存 Pages 项目的持久部署源配置。
|
||||
//
|
||||
// RemoteURL 可能包含签名参数,禁止直接序列化 model;对外接口必须映射到
|
||||
// pages 包内的脱敏 source view。
|
||||
// 对外接口必须映射到 pages 包内的 source view,避免直接序列化 model。
|
||||
type PagesProjectSource struct {
|
||||
ID uint `json:"-" gorm:"primaryKey;autoIncrement"`
|
||||
ProjectID uint `json:"-" gorm:"not null;uniqueIndex:idx_of_pages_project_sources_project_id"`
|
||||
SourceType string `json:"-" gorm:"size:32;not null;default:''"`
|
||||
RemoteURL string `json:"-" gorm:"type:text;not null;default:''"`
|
||||
RemoteNetworkPolicy string `json:"-" gorm:"size:32;not null;default:''"`
|
||||
AllowInsecure bool `json:"-" gorm:"not null;default:false"`
|
||||
GitHubRepository string `json:"-" gorm:"column:github_repository;size:255;not null;default:''"`
|
||||
ReleaseSelector string `json:"-" gorm:"size:16;not null;default:''"`
|
||||
ReleaseTag string `json:"-" gorm:"size:255;not null;default:''"`
|
||||
|
||||
Reference in New Issue
Block a user