refactor(pages): 精简部署源模型并重构详情页交互

将 Remote 网络策略收敛为 allow_insecure,去掉脱敏与无用字段;
Pages 详情拆为部署/设置 Tab,统一卡片样式与来源信息展示。
This commit is contained in:
ryan
2026-07-19 20:23:31 +08:00
parent f0b51a99b3
commit abe8fb8268
40 changed files with 1050 additions and 1314 deletions
+21 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+58 -195
View File
@@ -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>
);
}
-1
View File
@@ -117,7 +117,6 @@ export type {
PagesManualSource,
PagesProject,
PagesProjectPayload,
PagesRemoteNetworkPolicy,
PagesRemoteSourceUpdatePayload,
PagesRemoteURLSource,
PagesSource,
+3 -7
View File
@@ -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 {
-2
View File
@@ -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)
}
}
+1 -3
View File
@@ -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)
+23 -61
View File
@@ -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
+14 -159
View File
@@ -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)
}
+1 -1
View File
@@ -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,
+26 -65
View File
@@ -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),
+2 -3
View File
@@ -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:''"`