mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
feat(pages): 支持 GitHub Release 部署源
增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
This commit is contained in:
@@ -23,6 +23,7 @@ sidebar: false
|
||||
|
||||
### 新增
|
||||
|
||||
- Pages 项目新增持久部署源,可配置 Remote URL 或公开 GitHub Release,并支持手动检查、同步发布、来源状态查看与同一 Release 资源替换确认;部署历史会保留安全的来源快照。
|
||||
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
|
||||
|
||||
### 改进
|
||||
|
||||
+2
-2
@@ -8879,7 +8879,7 @@ const docTemplate = `{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约",
|
||||
"description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -9112,7 +9112,7 @@ const docTemplate = `{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回",
|
||||
"description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
||||
@@ -211,9 +211,16 @@ of_pages_projects
|
||||
GitHub:
|
||||
|
||||
```text
|
||||
SHA-256("github|" + owner_repo + "|" + selector + "|" + tag + "|" + asset_name)
|
||||
LP(value) = uint64be(byte_length(UTF8(value))) || UTF8(value)
|
||||
|
||||
SHA-256(
|
||||
"openflare:pages:github-release:v2" ||
|
||||
LP(owner_repo) || LP(selector) || LP(tag) || LP(asset_name)
|
||||
)
|
||||
```
|
||||
|
||||
GitHub identity 对每个 UTF-8 字段使用无歧义的长度前缀编码,不能使用分隔符直接拼接;自动更新开关和检查间隔不参与 identity。
|
||||
|
||||
Remote:
|
||||
|
||||
```text
|
||||
|
||||
+2
-2
@@ -8872,7 +8872,7 @@
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约",
|
||||
"description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -9105,7 +9105,7 @@
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回",
|
||||
"description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
||||
+2
-2
@@ -9468,7 +9468,7 @@ paths:
|
||||
- openflare-pages
|
||||
/api/v1/d/pages/{id}/source/check:
|
||||
post:
|
||||
description: Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
|
||||
description: 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
|
||||
parameters:
|
||||
- description: 项目 ID
|
||||
in: path
|
||||
@@ -9611,7 +9611,7 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
|
||||
description: 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
|
||||
parameters:
|
||||
- description: 项目 ID
|
||||
in: path
|
||||
|
||||
@@ -45,7 +45,7 @@ const SOURCE_LABELS: Record<PagesDeployment['source_type'], string> = {
|
||||
manual_upload: '本地上传',
|
||||
manual_url: 'URL 导入',
|
||||
remote_url: 'Remote URL',
|
||||
github_release: 'GitHub Release',
|
||||
github_release: 'GitHub',
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
|
||||
@@ -172,7 +172,7 @@ export function DeploymentHistory({
|
||||
) : deployments.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无部署'
|
||||
description='上传本地部署包,或配置 Remote URL 来源后同步发布。'
|
||||
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
|
||||
/>
|
||||
) : (
|
||||
deployments.map((deployment) => {
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Pencil, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import {
|
||||
Download,
|
||||
Github,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
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 {
|
||||
@@ -21,12 +38,11 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { AdminTaskService } from '@/lib/services/admin';
|
||||
import {
|
||||
type PagesSource,
|
||||
type PagesSourceActionPayload,
|
||||
type PagesSourceActionReceipt,
|
||||
type PagesSourceStatus,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
deploymentsQueryKey,
|
||||
@@ -34,7 +50,11 @@ import {
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '../../components/pages-utils';
|
||||
import { PagesSourceDialog } from './pages-source-dialog';
|
||||
import { type PagesSourceMode, PagesSourceDialog } from './pages-source-dialog';
|
||||
import {
|
||||
GitHubSourceDetails,
|
||||
RemoteSourceDetails,
|
||||
} from './pages-source-details';
|
||||
|
||||
const ACTION_POLL_INTERVAL = 2_000;
|
||||
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
|
||||
@@ -59,10 +79,8 @@ interface ActiveAction {
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
function revisionSummary(source: PagesSource) {
|
||||
if (source.source_type === 'manual' || !source.last_applied)
|
||||
return '尚未应用';
|
||||
return `${source.last_applied.label} · ${source.last_applied.revision.slice(0, 12)}`;
|
||||
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
|
||||
return action === 'check' ? '检查' : '同步并发布';
|
||||
}
|
||||
|
||||
export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
@@ -70,11 +88,10 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
const handledExecutionID = useRef<string | null>(null);
|
||||
const sourcePollingStartedAt = useRef<number | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'manual' | 'remote_url'>(
|
||||
'manual',
|
||||
);
|
||||
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
|
||||
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
|
||||
const [actionTimedOut, setActionTimedOut] = useState(false);
|
||||
const [attentionDialogOpen, setAttentionDialogOpen] = useState(false);
|
||||
|
||||
const sourceQuery = useQuery({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
@@ -102,8 +119,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
'task-execution',
|
||||
activeAction?.receipt.execution_id ?? '',
|
||||
],
|
||||
queryFn: () =>
|
||||
AdminTaskService.getTaskExecution(activeAction!.receipt.execution_id),
|
||||
queryFn: () => {
|
||||
const executionID = activeAction?.receipt.execution_id;
|
||||
if (!executionID) throw new Error('缺少任务执行 ID');
|
||||
return AdminTaskService.getTaskExecution(executionID);
|
||||
},
|
||||
enabled: Boolean(activeAction) && !actionTimedOut,
|
||||
refetchInterval: (query) => {
|
||||
if (actionTimedOut) return false;
|
||||
@@ -114,6 +134,15 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
},
|
||||
});
|
||||
|
||||
const beginActionPolling = useCallback(
|
||||
(receipt: PagesSourceActionReceipt) => {
|
||||
handledExecutionID.current = null;
|
||||
setActiveAction({ receipt, startedAt: Date.now() });
|
||||
setActionTimedOut(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAction || actionTimedOut) return;
|
||||
const elapsed = Date.now() - activeAction.startedAt;
|
||||
@@ -124,7 +153,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
|
||||
useEffect(() => {
|
||||
const execution = executionQuery.data;
|
||||
if (!execution || !['succeeded', 'failed'].includes(execution.status)) {
|
||||
if (
|
||||
!activeAction ||
|
||||
!execution ||
|
||||
!['succeeded', 'failed'].includes(execution.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (handledExecutionID.current === execution.id) return;
|
||||
@@ -142,21 +175,36 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]);
|
||||
|
||||
const actionLabel = sourceActionLabel(activeAction.receipt.action);
|
||||
if (execution.status === 'succeeded') {
|
||||
toast.success('部署源同步并发布完成');
|
||||
toast.success(`部署源${actionLabel}完成`);
|
||||
} else {
|
||||
toast.error(execution.error_message || '部署源同步失败');
|
||||
toast.error(execution.error_message || `部署源${actionLabel}失败`);
|
||||
}
|
||||
setActiveAction(null);
|
||||
setActionTimedOut(false);
|
||||
}, [executionQuery.data, projectId, queryClient]);
|
||||
}, [activeAction, executionQuery.data, projectId, queryClient]);
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: () => PagesService.checkSource(projectId),
|
||||
onSuccess: async (receipt) => {
|
||||
beginActionPolling(receipt);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
});
|
||||
toast.success('检查任务已提交');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '检查任务提交失败');
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => PagesService.syncSource(projectId, {}),
|
||||
mutationFn: (payload: PagesSourceActionPayload) =>
|
||||
PagesService.syncSource(projectId, payload),
|
||||
onSuccess: async (receipt) => {
|
||||
handledExecutionID.current = null;
|
||||
setActiveAction({ receipt, startedAt: Date.now() });
|
||||
setActionTimedOut(false);
|
||||
setAttentionDialogOpen(false);
|
||||
beginActionPolling(receipt);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
});
|
||||
@@ -176,13 +224,38 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
const sourceBusy =
|
||||
source?.source_type !== 'manual' &&
|
||||
(source?.sync_status === 'checking' || source?.sync_status === 'syncing');
|
||||
const actionsDisabled = syncMutation.isPending || executionBusy || sourceBusy;
|
||||
const actionsDisabled =
|
||||
checkMutation.isPending ||
|
||||
syncMutation.isPending ||
|
||||
executionBusy ||
|
||||
sourceBusy;
|
||||
const checkBusy =
|
||||
checkMutation.isPending ||
|
||||
(executionBusy && activeAction?.receipt.action === 'check') ||
|
||||
(source?.source_type !== 'manual' && source?.sync_status === 'checking');
|
||||
const syncBusy =
|
||||
syncMutation.isPending ||
|
||||
(executionBusy && activeAction?.receipt.action === 'sync') ||
|
||||
(source?.source_type !== 'manual' && source?.sync_status === 'syncing');
|
||||
const dispatchError = checkMutation.error ?? syncMutation.error;
|
||||
|
||||
const openSourceDialog = (mode: 'manual' | 'remote_url') => {
|
||||
const openSourceDialog = (mode: PagesSourceMode) => {
|
||||
setDialogMode(mode);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const dispatchSync = () => {
|
||||
checkMutation.reset();
|
||||
if (
|
||||
source?.source_type === 'github_release' &&
|
||||
source.sync_status === 'attention'
|
||||
) {
|
||||
setAttentionDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
syncMutation.mutate({});
|
||||
};
|
||||
|
||||
if (sourceQuery.isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -219,10 +292,22 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveSourceStatus = executionBusy
|
||||
? activeAction?.receipt.action === 'check'
|
||||
? 'checking'
|
||||
: 'syncing'
|
||||
: source.source_type === 'manual'
|
||||
? undefined
|
||||
: (source.sync_status ?? 'idle');
|
||||
const status =
|
||||
source.source_type === 'manual'
|
||||
? null
|
||||
: SOURCE_STATUS[source.sync_status ?? 'idle'];
|
||||
: SOURCE_STATUS[effectiveSourceStatus ?? 'idle'];
|
||||
const attentionRevision =
|
||||
source.source_type === 'github_release' &&
|
||||
source.sync_status === 'attention'
|
||||
? source.last_seen
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -250,47 +335,20 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
</p>
|
||||
</div>
|
||||
) : source.source_type === 'remote_url' ? (
|
||||
<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>
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
{source.last_error ? (
|
||||
<div className='md:col-span-2'>
|
||||
<ErrorInline message={source.last_error} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<RemoteSourceDetails source={source} />
|
||||
) : (
|
||||
<div className='rounded-lg border p-4 text-sm text-muted-foreground'>
|
||||
当前版本暂不提供该来源类型的编辑界面。
|
||||
</div>
|
||||
<GitHubSourceDetails source={source} />
|
||||
)}
|
||||
|
||||
{dispatchError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
dispatchError instanceof Error
|
||||
? dispatchError.message
|
||||
: '来源任务提交失败'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{executionQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
@@ -327,30 +385,61 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
|
||||
<CardFooter className='flex flex-wrap gap-2 border-t'>
|
||||
{source.source_type === 'manual' ? (
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => openSourceDialog('remote_url')}
|
||||
>
|
||||
<Download data-icon='inline-start' />
|
||||
配置 Remote URL
|
||||
</Button>
|
||||
) : source.source_type === 'remote_url' ? (
|
||||
<>
|
||||
<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('remote_url')}
|
||||
onClick={() => openSourceDialog(source.source_type)}
|
||||
>
|
||||
<Pencil data-icon='inline-start' />
|
||||
编辑来源
|
||||
</Button>
|
||||
{source.source_type === 'github_release' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => {
|
||||
syncMutation.reset();
|
||||
checkMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{checkBusy ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : (
|
||||
<Search data-icon='inline-start' />
|
||||
)}
|
||||
检查更新
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={
|
||||
actionsDisabled ||
|
||||
(source.sync_status === 'attention' && !attentionRevision)
|
||||
}
|
||||
onClick={dispatchSync}
|
||||
>
|
||||
{actionsDisabled ? (
|
||||
{syncBusy ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : (
|
||||
<Download data-icon='inline-start' />
|
||||
@@ -367,7 +456,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
切换回手动
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
@@ -387,7 +476,48 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
projectId={projectId}
|
||||
source={source}
|
||||
initialMode={dialogMode}
|
||||
onActionDispatched={beginActionPolling}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={attentionDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!syncMutation.isPending) setAttentionDialogOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认发布当前 GitHub revision</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span>这将发布卡片当前显示的精确 revision:</span>{' '}
|
||||
<span className='break-all font-mono'>
|
||||
{attentionRevision?.revision ??
|
||||
'当前 revision 已不可用,请刷新后重试'}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={syncMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={syncMutation.isPending || !attentionRevision}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (!attentionRevision) return;
|
||||
syncMutation.mutate({
|
||||
confirmed_revision: attentionRevision.revision,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{syncMutation.isPending ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : null}
|
||||
确认并发布
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
type PagesGitHubReleaseSource,
|
||||
type PagesRemoteURLSource,
|
||||
type PagesSourceRevision,
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
function revisionSummary(revision?: PagesSourceRevision) {
|
||||
if (!revision) return '尚无记录';
|
||||
return `${revision.label} · ${revision.revision.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function RemoteSourceDetails({
|
||||
source,
|
||||
}: {
|
||||
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>
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubSourceDetails({
|
||||
source,
|
||||
}: {
|
||||
source: PagesGitHubReleaseSource;
|
||||
}) {
|
||||
const attentionRevision =
|
||||
source.sync_status === 'attention' ? source.last_seen : undefined;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{attentionRevision ? (
|
||||
<Alert variant='destructive'>
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Release Asset 发生变化,需要显式确认</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
当前远端 revision
|
||||
与已发布内容不一致。请核对版本和资源后,再确认发布这一精确
|
||||
revision。
|
||||
</p>
|
||||
<code className='break-all'>{attentionRevision.revision}</code>
|
||||
</AlertDescription>
|
||||
</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>
|
||||
<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>
|
||||
<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'>
|
||||
{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'>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -36,6 +37,8 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
type PagesRemoteNetworkPolicy,
|
||||
type PagesSource,
|
||||
type PagesSourceActionReceipt,
|
||||
type PagesSourceUpdatePayload,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
@@ -45,8 +48,19 @@ import {
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '../../components/pages-utils';
|
||||
import {
|
||||
type PagesGitHubSourceFormErrors,
|
||||
type PagesGitHubSourceFormValue,
|
||||
PagesSourceGitHubFields,
|
||||
} from './pages-source-github-fields';
|
||||
import {
|
||||
validGitHubAssetName,
|
||||
validGitHubReleaseTag,
|
||||
validGitHubRepositoryURL,
|
||||
} from './pages-source-validation';
|
||||
|
||||
export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release';
|
||||
|
||||
type SourceMode = 'manual' | 'remote_url';
|
||||
type Confirmation = 'trusted_internal' | 'manual' | null;
|
||||
|
||||
interface PagesSourceDialogProps {
|
||||
@@ -54,7 +68,21 @@ interface PagesSourceDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: number;
|
||||
source: PagesSource;
|
||||
initialMode?: SourceMode;
|
||||
initialMode?: PagesSourceMode;
|
||||
onActionDispatched?: (receipt: PagesSourceActionReceipt) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_GITHUB_ASSET = 'dist.zip';
|
||||
const DEFAULT_GITHUB_CHECK_INTERVAL = 60;
|
||||
const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = {
|
||||
repository: '',
|
||||
releaseTag: '',
|
||||
assetName: '',
|
||||
};
|
||||
|
||||
function githubRepositoryURL(repository: string) {
|
||||
const value = repository.trim();
|
||||
return value ? `https://github.com/${value}` : '';
|
||||
}
|
||||
|
||||
export function PagesSourceDialog({
|
||||
@@ -63,21 +91,33 @@ export function PagesSourceDialog({
|
||||
projectId,
|
||||
source,
|
||||
initialMode,
|
||||
onActionDispatched,
|
||||
}: PagesSourceDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [mode, setMode] = useState<SourceMode>('manual');
|
||||
const [mode, setMode] = useState<PagesSourceMode>('manual');
|
||||
const [networkPolicy, setNetworkPolicy] =
|
||||
useState<PagesRemoteNetworkPolicy>('public');
|
||||
const [replaceURL, setReplaceURL] = useState(false);
|
||||
const [remoteURL, setRemoteURL] = useState('');
|
||||
const [revealRemoteURL, setRevealRemoteURL] = useState(false);
|
||||
const [urlError, setURLError] = useState('');
|
||||
const [githubForm, setGitHubForm] = useState<PagesGitHubSourceFormValue>({
|
||||
repositoryURL: '',
|
||||
releaseSelector: 'latest',
|
||||
releaseTag: '',
|
||||
assetName: DEFAULT_GITHUB_ASSET,
|
||||
});
|
||||
const [githubErrors, setGitHubErrors] =
|
||||
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
|
||||
const [confirmation, setConfirmation] = useState<Confirmation>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const nextMode =
|
||||
initialMode ??
|
||||
(source.source_type === 'remote_url' ? 'remote_url' : 'manual');
|
||||
if (!open) {
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
return;
|
||||
}
|
||||
const nextMode = initialMode ?? source.source_type;
|
||||
setMode(nextMode);
|
||||
setNetworkPolicy(
|
||||
source.source_type === 'remote_url'
|
||||
@@ -86,7 +126,27 @@ export function PagesSourceDialog({
|
||||
);
|
||||
setReplaceURL(source.source_type !== 'remote_url');
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
setGitHubForm({
|
||||
repositoryURL:
|
||||
source.source_type === 'github_release'
|
||||
? githubRepositoryURL(source.github_repository)
|
||||
: '',
|
||||
releaseSelector:
|
||||
source.source_type === 'github_release'
|
||||
? source.release_selector
|
||||
: 'latest',
|
||||
releaseTag:
|
||||
source.source_type === 'github_release'
|
||||
? (source.release_tag ?? '')
|
||||
: '',
|
||||
assetName:
|
||||
source.source_type === 'github_release'
|
||||
? source.asset_name
|
||||
: DEFAULT_GITHUB_ASSET,
|
||||
});
|
||||
setGitHubErrors(EMPTY_GITHUB_ERRORS);
|
||||
setConfirmation(null);
|
||||
}, [initialMode, open, source]);
|
||||
|
||||
@@ -102,19 +162,17 @@ export function PagesSourceDialog({
|
||||
};
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
PagesService.updateSource(projectId, {
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: replaceURL,
|
||||
remote_url: replaceURL ? remoteURL.trim() : '',
|
||||
remote_network_policy: networkPolicy,
|
||||
}),
|
||||
mutationFn: (payload: PagesSourceUpdatePayload) =>
|
||||
PagesService.updateSource(projectId, payload),
|
||||
onSuccess: async (result) => {
|
||||
queryClient.setQueryData(sourceQueryKey(projectId), result.source);
|
||||
if (result.check_task) onActionDispatched?.(result.check_task);
|
||||
await invalidateSourceState();
|
||||
toast.success('部署源已更新');
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
setConfirmation(null);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -129,6 +187,8 @@ export function PagesSourceDialog({
|
||||
await invalidateSourceState();
|
||||
toast.success('已切换回手动部署');
|
||||
setConfirmation(null);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -138,6 +198,13 @@ export function PagesSourceDialog({
|
||||
|
||||
const isPending = updateMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
const remotePayload = (): PagesSourceUpdatePayload => ({
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: replaceURL,
|
||||
remote_url: replaceURL ? remoteURL.trim() : '',
|
||||
remote_network_policy: networkPolicy,
|
||||
});
|
||||
|
||||
const submitRemote = () => {
|
||||
if (replaceURL) {
|
||||
const value = remoteURL.trim();
|
||||
@@ -158,21 +225,86 @@ export function PagesSourceDialog({
|
||||
setConfirmation('trusted_internal');
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate();
|
||||
updateMutation.mutate(remotePayload());
|
||||
};
|
||||
|
||||
const submitGitHub = () => {
|
||||
const normalizedRepositoryURL = githubForm.repositoryURL.trim();
|
||||
const nextRepositoryError = validGitHubRepositoryURL(
|
||||
normalizedRepositoryURL,
|
||||
)
|
||||
? ''
|
||||
: '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址';
|
||||
const nextReleaseTagError =
|
||||
githubForm.releaseSelector === 'tag' &&
|
||||
!validGitHubReleaseTag(githubForm.releaseTag)
|
||||
? 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)'
|
||||
: '';
|
||||
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
|
||||
? ''
|
||||
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
|
||||
|
||||
setGitHubErrors({
|
||||
repository: nextRepositoryError,
|
||||
releaseTag: nextReleaseTagError,
|
||||
assetName: nextAssetNameError,
|
||||
});
|
||||
if (nextRepositoryError || nextReleaseTagError || nextAssetNameError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: PagesSourceUpdatePayload =
|
||||
githubForm.releaseSelector === 'latest'
|
||||
? {
|
||||
source_type: 'github_release',
|
||||
repository_url: normalizedRepositoryURL,
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: githubForm.assetName,
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes:
|
||||
source.source_type === 'github_release' &&
|
||||
source.release_selector === 'latest' &&
|
||||
source.check_interval_minutes
|
||||
? source.check_interval_minutes
|
||||
: DEFAULT_GITHUB_CHECK_INTERVAL,
|
||||
}
|
||||
: {
|
||||
source_type: 'github_release',
|
||||
repository_url: normalizedRepositoryURL,
|
||||
release_selector: 'tag',
|
||||
release_tag: githubForm.releaseTag,
|
||||
asset_name: githubForm.assetName,
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
};
|
||||
updateMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (mode === 'manual') {
|
||||
if (source.source_type === 'manual') {
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setConfirmation('manual');
|
||||
}
|
||||
return;
|
||||
switch (mode) {
|
||||
case 'manual':
|
||||
if (source.source_type === 'manual') {
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setConfirmation('manual');
|
||||
}
|
||||
return;
|
||||
case 'remote_url':
|
||||
submitRemote();
|
||||
return;
|
||||
case 'github_release':
|
||||
submitGitHub();
|
||||
}
|
||||
submitRemote();
|
||||
};
|
||||
|
||||
const submitLabel =
|
||||
mode === 'manual'
|
||||
? '使用手动部署'
|
||||
: mode === 'remote_url'
|
||||
? '保存 Remote 来源'
|
||||
: '保存 GitHub 来源';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
@@ -185,8 +317,7 @@ export function PagesSourceDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>部署源设置</DialogTitle>
|
||||
<DialogDescription>
|
||||
手动部署与 Remote URL
|
||||
各自保持独立配置;后续仓库构建来源会作为新的来源类型接入。
|
||||
手动部署、Remote URL 与 GitHub Release 使用独立配置。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -198,10 +329,19 @@ export function PagesSourceDialog({
|
||||
variant='outline'
|
||||
value={mode}
|
||||
aria-labelledby='pages-source-mode'
|
||||
className='grid w-full grid-cols-2'
|
||||
className='grid w-full grid-cols-1 sm:grid-cols-3'
|
||||
onValueChange={(value) => {
|
||||
if (value === 'manual' || value === 'remote_url') {
|
||||
if (
|
||||
value === 'manual' ||
|
||||
value === 'remote_url' ||
|
||||
value === 'github_release'
|
||||
) {
|
||||
setMode(value);
|
||||
if (value !== 'remote_url') {
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -211,9 +351,17 @@ export function PagesSourceDialog({
|
||||
<ToggleGroupItem value='remote_url' className='w-full'>
|
||||
Remote URL
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='github_release' className='w-full'>
|
||||
GitHub Release
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
手动部署由管理员上传本地包;Remote URL 通过显式同步下载并发布。
|
||||
<span>
|
||||
远端来源只负责发现内容,发布结果始终保留为不可变部署。
|
||||
</span>
|
||||
<span className='block'>
|
||||
仓库源码构建将在后续作为独立来源类型提供。
|
||||
</span>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
@@ -224,7 +372,7 @@ export function PagesSourceDialog({
|
||||
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
) : mode === 'remote_url' ? (
|
||||
<>
|
||||
<Field data-invalid={Boolean(urlError)}>
|
||||
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
|
||||
@@ -240,24 +388,48 @@ export function PagesSourceDialog({
|
||||
onClick={() => {
|
||||
setReplaceURL(true);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
}}
|
||||
>
|
||||
更换地址
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id='pages-remote-url'
|
||||
type='url'
|
||||
placeholder='https://artifacts.example.com/site.zip?token=...'
|
||||
value={remoteURL}
|
||||
aria-invalid={Boolean(urlError)}
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
setRemoteURL(event.target.value);
|
||||
setURLError('');
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
<FieldDescription>
|
||||
{urlError ||
|
||||
@@ -274,6 +446,7 @@ export function PagesSourceDialog({
|
||||
onClick={() => {
|
||||
setReplaceURL(false);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
}}
|
||||
>
|
||||
@@ -313,6 +486,14 @@ export function PagesSourceDialog({
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<PagesSourceGitHubFields
|
||||
value={githubForm}
|
||||
errors={githubErrors}
|
||||
defaultAssetName={DEFAULT_GITHUB_ASSET}
|
||||
onChange={setGitHubForm}
|
||||
onErrorsChange={setGitHubErrors}
|
||||
/>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -321,13 +502,17 @@ export function PagesSourceDialog({
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={() => {
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type='button' disabled={isPending} onClick={handleSubmit}>
|
||||
{isPending ? <Spinner data-icon='inline-start' /> : null}
|
||||
{mode === 'manual' ? '使用手动部署' : '保存 Remote 来源'}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -361,7 +546,7 @@ export function PagesSourceDialog({
|
||||
if (confirmation === 'manual') {
|
||||
deleteMutation.mutate();
|
||||
} else if (confirmation === 'trusted_internal') {
|
||||
updateMutation.mutate();
|
||||
updateMutation.mutate(remotePayload());
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
|
||||
|
||||
export interface PagesGitHubSourceFormValue {
|
||||
repositoryURL: string;
|
||||
releaseSelector: PagesGitHubReleaseSelector;
|
||||
releaseTag: string;
|
||||
assetName: string;
|
||||
}
|
||||
|
||||
export interface PagesGitHubSourceFormErrors {
|
||||
repository: string;
|
||||
releaseTag: string;
|
||||
assetName: string;
|
||||
}
|
||||
|
||||
interface PagesSourceGitHubFieldsProps {
|
||||
value: PagesGitHubSourceFormValue;
|
||||
errors: PagesGitHubSourceFormErrors;
|
||||
defaultAssetName: string;
|
||||
onChange: (value: PagesGitHubSourceFormValue) => void;
|
||||
onErrorsChange: (errors: PagesGitHubSourceFormErrors) => void;
|
||||
}
|
||||
|
||||
export function PagesSourceGitHubFields({
|
||||
value,
|
||||
errors,
|
||||
defaultAssetName,
|
||||
onChange,
|
||||
onErrorsChange,
|
||||
}: PagesSourceGitHubFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.repository)}>
|
||||
<FieldLabel htmlFor='pages-github-repository'>
|
||||
GitHub 仓库 URL
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-repository'
|
||||
type='url'
|
||||
placeholder='https://github.com/owner/repo'
|
||||
value={value.repositoryURL}
|
||||
aria-invalid={Boolean(errors.repository)}
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, repositoryURL: event.target.value });
|
||||
onErrorsChange({ ...errors, repository: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{errors.repository || '仅支持公开 github.com 仓库。'}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle id='pages-github-selector'>Release 选择</FieldTitle>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
value={value.releaseSelector}
|
||||
aria-labelledby='pages-github-selector'
|
||||
className='grid w-full grid-cols-2'
|
||||
onValueChange={(selector) => {
|
||||
if (selector === 'latest' || selector === 'tag') {
|
||||
onChange({ ...value, releaseSelector: selector });
|
||||
onErrorsChange({ ...errors, releaseTag: '' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='latest' className='w-full'>
|
||||
最新 Release
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='tag' className='w-full'>
|
||||
固定 Tag
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
当前阶段由管理员手动检查并决定是否发布。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{value.releaseSelector === 'tag' ? (
|
||||
<Field data-invalid={Boolean(errors.releaseTag)}>
|
||||
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-tag'
|
||||
placeholder='v1.2.3'
|
||||
value={value.releaseTag}
|
||||
aria-invalid={Boolean(errors.releaseTag)}
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, releaseTag: event.target.value });
|
||||
onErrorsChange({ ...errors, releaseTag: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{errors.releaseTag || '精确检查并同步指定 tag。'}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field data-invalid={Boolean(errors.assetName)}>
|
||||
<FieldLabel htmlFor='pages-github-asset'>
|
||||
Release Asset 文件名
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-asset'
|
||||
placeholder={defaultAssetName}
|
||||
value={value.assetName}
|
||||
aria-invalid={Boolean(errors.assetName)}
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, assetName: event.target.value });
|
||||
onErrorsChange({ ...errors, assetName: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{errors.assetName || '按文件名精确匹配已上传的 Release Asset。'}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
function hasWellFormedUnicode(value: string) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
||||
index += 1;
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validGitHubSafeText(value: string) {
|
||||
return (
|
||||
value !== '' &&
|
||||
new TextEncoder().encode(value).byteLength <= 255 &&
|
||||
!/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(
|
||||
value,
|
||||
) &&
|
||||
hasWellFormedUnicode(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function validGitHubRepositoryURL(raw: string) {
|
||||
const match = /^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/u.exec(raw);
|
||||
if (!match) return false;
|
||||
|
||||
const [, host, owner, rawRepository] = match;
|
||||
const repository = rawRepository.replace(/\.git$/u, '');
|
||||
return (
|
||||
host.toLowerCase() === 'github.com' &&
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/iu.test(owner) &&
|
||||
/^[a-z0-9._-]+$/iu.test(repository) &&
|
||||
repository.length <= 100 &&
|
||||
!['.', '..'].includes(repository)
|
||||
);
|
||||
}
|
||||
|
||||
export function validGitHubAssetName(value: string) {
|
||||
return (
|
||||
validGitHubSafeText(value) &&
|
||||
value !== '.' &&
|
||||
value !== '..' &&
|
||||
!value.includes('/') &&
|
||||
!value.includes('\\')
|
||||
);
|
||||
}
|
||||
|
||||
export function validGitHubReleaseTag(value: string) {
|
||||
const components = value.split('/');
|
||||
return (
|
||||
validGitHubSafeText(value) &&
|
||||
!value.endsWith('.') &&
|
||||
!value.includes('..') &&
|
||||
!value.includes('@{') &&
|
||||
![' ', '~', '^', ':', '?', '*', '[', '\\'].some((character) =>
|
||||
value.includes(character),
|
||||
) &&
|
||||
components.every(
|
||||
(component) =>
|
||||
component !== '' &&
|
||||
!component.startsWith('.') &&
|
||||
!component.endsWith('.lock'),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert'
|
||||
role='alert'
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-title'
|
||||
className={cn(
|
||||
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-description'
|
||||
className={cn(
|
||||
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
@@ -109,7 +109,11 @@ export type {
|
||||
OriginMutationPayload,
|
||||
PagesDeployment,
|
||||
PagesDeploymentFile,
|
||||
PagesGitHubLatestSourceUpdatePayload,
|
||||
PagesGitHubReleaseSelector,
|
||||
PagesGitHubReleaseSource,
|
||||
PagesGitHubSourceUpdatePayload,
|
||||
PagesGitHubTagSourceUpdatePayload,
|
||||
PagesManualSource,
|
||||
PagesProject,
|
||||
PagesProjectPayload,
|
||||
@@ -121,6 +125,7 @@ export type {
|
||||
PagesSourceActionReceipt,
|
||||
PagesSourceRevision,
|
||||
PagesSourceStatus,
|
||||
PagesSourceUpdatePayload,
|
||||
PagesSourceUpdateResult,
|
||||
AcmeAccountItem,
|
||||
DnsAccountItem,
|
||||
|
||||
@@ -12,10 +12,10 @@ import type {
|
||||
PagesDeploymentUploadPayload,
|
||||
PagesProject,
|
||||
PagesProjectPayload,
|
||||
PagesRemoteSourceUpdatePayload,
|
||||
PagesSource,
|
||||
PagesSourceActionPayload,
|
||||
PagesSourceActionReceipt,
|
||||
PagesSourceUpdatePayload,
|
||||
PagesSourceUpdateResult,
|
||||
} from './types';
|
||||
|
||||
@@ -51,7 +51,7 @@ export class PagesService extends OpenFlareBaseService {
|
||||
|
||||
static updateSource(
|
||||
projectId: number,
|
||||
payload: PagesRemoteSourceUpdatePayload,
|
||||
payload: PagesSourceUpdatePayload,
|
||||
): Promise<PagesSourceUpdateResult> {
|
||||
return this.post<PagesSourceUpdateResult>(
|
||||
`/${projectId}/source/update`,
|
||||
|
||||
@@ -462,6 +462,8 @@ export type PagesSourceStatus =
|
||||
|
||||
export type PagesRemoteNetworkPolicy = 'public' | 'trusted_internal';
|
||||
|
||||
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
|
||||
|
||||
export interface PagesSourceRevision {
|
||||
revision: string;
|
||||
label: string;
|
||||
@@ -493,15 +495,15 @@ export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
|
||||
export interface PagesGitHubReleaseSource extends PagesSourceRuntimeView {
|
||||
source_type: 'github_release';
|
||||
github_repository: string;
|
||||
release_selector: 'latest' | 'tag';
|
||||
release_tag: string;
|
||||
release_selector: PagesGitHubReleaseSelector;
|
||||
release_tag?: string;
|
||||
asset_name: string;
|
||||
auto_update_enabled: boolean;
|
||||
check_interval_minutes: number;
|
||||
auto_update_enabled?: false;
|
||||
check_interval_minutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署源使用判别联合,后续仓库构建来源只需增加独立 variant,
|
||||
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
|
||||
* 不需要向 Remote 或 GitHub Release 填入构建字段。
|
||||
*/
|
||||
export type PagesSource =
|
||||
@@ -514,6 +516,35 @@ export interface PagesRemoteSourceUpdatePayload {
|
||||
remote_network_policy: PagesRemoteNetworkPolicy;
|
||||
}
|
||||
|
||||
interface PagesGitHubSourceUpdateBase {
|
||||
source_type: 'github_release';
|
||||
repository_url: string;
|
||||
asset_name: string;
|
||||
auto_update_enabled: false;
|
||||
}
|
||||
|
||||
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
||||
release_selector: 'latest';
|
||||
release_tag: '';
|
||||
check_interval_minutes: number;
|
||||
}
|
||||
|
||||
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
||||
release_selector: 'tag';
|
||||
release_tag: string;
|
||||
check_interval_minutes: 0;
|
||||
}
|
||||
|
||||
export type PagesGitHubSourceUpdatePayload =
|
||||
PagesGitHubLatestSourceUpdatePayload | PagesGitHubTagSourceUpdatePayload;
|
||||
|
||||
/**
|
||||
* Source 更新请求保持 Provider 判别联合;未来仓库拉取构建使用独立 git_repository variant,
|
||||
* 不向 Remote URL 或 GitHub Release payload 混入构建字段。
|
||||
*/
|
||||
export type PagesSourceUpdatePayload =
|
||||
PagesRemoteSourceUpdatePayload | PagesGitHubSourceUpdatePayload;
|
||||
|
||||
export interface PagesSourceActionPayload {
|
||||
confirmed_revision?: string;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,74 @@ describe('PagesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the complete GitHub latest discriminator payload', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(
|
||||
response({
|
||||
source: { source_type: 'manual' },
|
||||
check_task: null,
|
||||
warning: '',
|
||||
}),
|
||||
);
|
||||
|
||||
await PagesService.updateSource(12, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/12/source/update',
|
||||
{
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends safe disabled defaults with the GitHub tag discriminator', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(
|
||||
response({
|
||||
source: { source_type: 'manual' },
|
||||
check_task: null,
|
||||
warning: '',
|
||||
}),
|
||||
);
|
||||
|
||||
await PagesService.updateSource(12, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
asset_name: 'site.tar.gz',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/12/source/update',
|
||||
{
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
asset_name: 'site.tar.gz',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads only the package multipart field', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
||||
const file = new File(['site'], 'site.zip', {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
|
||||
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
|
||||
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
|
||||
import { AdminTaskService } from '@/lib/services/admin';
|
||||
import {
|
||||
AdminTaskService,
|
||||
type TaskExecution,
|
||||
type TaskExecutionStatus,
|
||||
} from '@/lib/services/admin';
|
||||
import {
|
||||
type PagesDeployment,
|
||||
type PagesGitHubReleaseSource,
|
||||
type PagesRemoteURLSource,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
@@ -21,6 +27,7 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
getSource: vi.fn(),
|
||||
updateSource: vi.fn(),
|
||||
deleteSource: vi.fn(),
|
||||
checkSource: vi.fn(),
|
||||
syncSource: vi.fn(),
|
||||
listDeployments: vi.fn(),
|
||||
listDeploymentFiles: vi.fn(),
|
||||
@@ -67,11 +74,73 @@ const remoteSource: PagesRemoteURLSource = {
|
||||
last_error: '',
|
||||
};
|
||||
|
||||
const githubLatestSource: PagesGitHubReleaseSource = {
|
||||
source_type: 'github_release',
|
||||
github_repository: 'openflare/site',
|
||||
release_selector: 'latest',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
sync_status: 'update_available',
|
||||
update_available: true,
|
||||
last_seen: {
|
||||
revision: 'b'.repeat(64),
|
||||
label: 'v1.2.3',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_applied: {
|
||||
revision: 'a'.repeat(64),
|
||||
label: 'v1.2.2',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_checked_at: '2026-07-19T10:00:00Z',
|
||||
last_synced_at: '2026-07-18T10:00:00Z',
|
||||
next_check_at: '2026-07-19T11:00:00Z',
|
||||
last_error: '',
|
||||
};
|
||||
|
||||
const githubAttentionRevision = 'c'.repeat(64);
|
||||
const githubAttentionSource: PagesGitHubReleaseSource = {
|
||||
...githubLatestSource,
|
||||
sync_status: 'attention',
|
||||
update_available: true,
|
||||
last_seen: {
|
||||
revision: githubAttentionRevision,
|
||||
label: 'v1.2.3',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
};
|
||||
|
||||
function taskExecution(
|
||||
status: TaskExecutionStatus,
|
||||
errorMessage = '',
|
||||
): TaskExecution {
|
||||
return {
|
||||
id: '42',
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
task_type: 'of_pages_source_action',
|
||||
task_name: 'Pages 来源动作',
|
||||
status,
|
||||
retryable: false,
|
||||
max_retry: 0,
|
||||
retry_count: 0,
|
||||
log: '',
|
||||
error_message: errorMessage,
|
||||
result: '',
|
||||
duration: 1,
|
||||
payload: '',
|
||||
triggered_by: 'admin:1',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
updated_at: '2026-07-19T10:00:01Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Pages source UI', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(PagesService.getSource).mockReset();
|
||||
vi.mocked(PagesService.updateSource).mockReset();
|
||||
vi.mocked(PagesService.deleteSource).mockReset();
|
||||
vi.mocked(PagesService.checkSource).mockReset();
|
||||
vi.mocked(PagesService.syncSource).mockReset();
|
||||
vi.mocked(PagesService.listDeployments).mockReset();
|
||||
vi.mocked(PagesService.listDeploymentFiles).mockReset();
|
||||
@@ -81,7 +150,8 @@ describe('Pages source UI', () => {
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
|
||||
});
|
||||
|
||||
it('keeps Phase 1 manual source focused on upload and Remote URL', async () => {
|
||||
it('offers the three Phase 2 source types without future repository build controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
@@ -92,8 +162,327 @@ describe('Pages source UI', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置 Remote URL' }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
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.queryByText('构建命令')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the GitHub latest payload while keeping automatic controls hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: githubLatestSource,
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
|
||||
'dist.zip',
|
||||
);
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-canonical GitHub repository URL paths', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { 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 来源' }));
|
||||
expect(
|
||||
screen.getByText(
|
||||
'请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await user.clear(repositoryInput);
|
||||
await user.type(repositoryInput, 'https://github.com/openflare/site/');
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits the GitHub tag discriminator with safe disabled defaults', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: {
|
||||
...githubLatestSource,
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
check_interval_minutes: undefined,
|
||||
next_check_at: null,
|
||||
},
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||
await user.type(
|
||||
screen.getByLabelText('Release tag'),
|
||||
'release/candidate#1&channel=stable',
|
||||
);
|
||||
await user.clear(screen.getByLabelText('Release Asset 文件名'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Release Asset 文件名'),
|
||||
' site?arch=amd64#stable.zip ',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'release/candidate#1&channel=stable',
|
||||
asset_name: ' site?arch=amd64#stable.zip ',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsafe GitHub tag and asset values before saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||
await user.type(screen.getByLabelText('Release tag'), 'release//candidate');
|
||||
await user.clear(screen.getByLabelText('Release Asset 文件名'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Release Asset 文件名'),
|
||||
'../dist.zip',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls the initial check receipt and represents the queued window locally', async () => {
|
||||
const user = userEvent.setup();
|
||||
const idleGitHubSource: PagesGitHubReleaseSource = {
|
||||
...githubLatestSource,
|
||||
sync_status: 'idle',
|
||||
update_available: false,
|
||||
};
|
||||
vi.mocked(PagesService.getSource)
|
||||
.mockResolvedValueOnce({ source_type: 'manual' })
|
||||
.mockResolvedValue(idleGitHubSource);
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: idleGitHubSource,
|
||||
check_task: {
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
},
|
||||
warning: '',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('pending'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
expect(screen.getByText('检查中')).toBeVisible();
|
||||
});
|
||||
const checkButton = screen.getByRole('button', { name: /检查更新/ });
|
||||
expect(checkButton).toBeDisabled();
|
||||
expect(within(checkButton).getByRole('status')).toBeVisible();
|
||||
expect(PagesService.checkSource).not.toHaveBeenCalled();
|
||||
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows GitHub release state and hides Phase 3 controls', async () => {
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
expect(await screen.findByText('openflare/site')).toBeVisible();
|
||||
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
|
||||
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
|
||||
expect(screen.getByText('有可用更新')).toBeVisible();
|
||||
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('succeeded'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.checkSource).toHaveBeenCalledWith(9);
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a GitHub check dispatch error', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockRejectedValue(
|
||||
new Error('GitHub API 暂不可用'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
expect(await screen.findByText('GitHub API 暂不可用')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a TaskExecution polling error with an explicit retry', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockRejectedValue(
|
||||
new Error('任务状态暂时不可读取'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
expect(await screen.findByText('任务状态暂时不可读取')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('requires the exact currently displayed revision for attention sync', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubAttentionSource);
|
||||
vi.mocked(PagesService.syncSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'sync',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('succeeded'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(
|
||||
within(alert).getByText('Release Asset 发生变化,需要显式确认'),
|
||||
).toBeVisible();
|
||||
expect(within(alert).getByText(githubAttentionRevision)).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '同步并发布' }));
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(within(dialog).getByText(githubAttentionRevision)).toBeVisible();
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: '确认并发布' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {
|
||||
confirmed_revision: githubAttentionRevision,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('never reuses the masked URL as an editable value', async () => {
|
||||
@@ -119,7 +508,13 @@ describe('Pages source UI', () => {
|
||||
'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' }));
|
||||
expect(input).toHaveAttribute('type', 'url');
|
||||
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -130,6 +525,17 @@ describe('Pages source UI', () => {
|
||||
remote_network_policy: 'public',
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -169,24 +575,9 @@ describe('Pages source UI', () => {
|
||||
execution_id: '42',
|
||||
action: 'sync',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue({
|
||||
id: '42',
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
task_type: 'of_pages_source_action',
|
||||
task_name: 'Pages 来源动作',
|
||||
status: 'succeeded',
|
||||
retryable: false,
|
||||
max_retry: 0,
|
||||
retry_count: 0,
|
||||
log: '',
|
||||
error_message: '',
|
||||
result: '',
|
||||
duration: 1,
|
||||
payload: '',
|
||||
triggered_by: 'admin:1',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
updated_at: '2026-07-19T10:00:01Z',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution)
|
||||
.mockResolvedValueOnce(taskExecution('pending'))
|
||||
.mockResolvedValue(taskExecution('succeeded'));
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
@@ -196,6 +587,14 @@ describe('Pages source UI', () => {
|
||||
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /同步并发布/ })).toBeDisabled();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ timeout: 3_500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the actual project entry and no one-off URL upload tab', () => {
|
||||
@@ -224,4 +623,30 @@ describe('Pages source UI', () => {
|
||||
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
|
||||
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the immutable GitHub deployment provenance in Chinese', async () => {
|
||||
const deployment: PagesDeployment = {
|
||||
id: 51,
|
||||
project_id: 9,
|
||||
deployment_number: 7,
|
||||
checksum: 'd'.repeat(64),
|
||||
status: 'active',
|
||||
file_count: 12,
|
||||
total_size: 4_096,
|
||||
created_by: 'user:1',
|
||||
source_type: 'github_release',
|
||||
source_label: 'v1.2.3',
|
||||
trigger_type: 'manual_sync',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
activated_at: '2026-07-19T10:00:01Z',
|
||||
};
|
||||
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
|
||||
|
||||
renderWithQuery(
|
||||
<DeploymentHistory projectId={9} activeDeploymentId={51} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible();
|
||||
expect(screen.getByText('当前生产部署')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
validGitHubAssetName,
|
||||
validGitHubReleaseTag,
|
||||
validGitHubRepositoryURL,
|
||||
} from '@/app/(main)/pages/detail/components/pages-source-validation';
|
||||
|
||||
describe('Pages GitHub source validation', () => {
|
||||
it('accepts only canonical public GitHub repository URLs', () => {
|
||||
expect(validGitHubRepositoryURL('https://github.com/acme/site')).toBe(true);
|
||||
expect(validGitHubRepositoryURL('https://GitHub.com/acme/site.git')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const invalid = [
|
||||
'http://github.com/acme/site',
|
||||
'https://github.com//acme/site',
|
||||
'https://github.com/acme/site/',
|
||||
'https://github.com/acme/site/extra',
|
||||
'https://github.com/acme/./site',
|
||||
'https://github.com/acme/../site',
|
||||
'https://github.com/acme/%73ite',
|
||||
'https://github.com/acme/site?token=secret',
|
||||
'https://github.com:443/acme/site',
|
||||
String.raw`https://github.com/acme\site`,
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubRepositoryURL(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('mirrors Git ref rules while preserving legal release tag characters', () => {
|
||||
const valid = [
|
||||
'@',
|
||||
'release/v1#stable&channel=prod',
|
||||
'foo.LOCK',
|
||||
'中文/发布=稳定',
|
||||
];
|
||||
for (const value of valid) {
|
||||
expect(validGitHubReleaseTag(value), value).toBe(true);
|
||||
}
|
||||
|
||||
const invalid = [
|
||||
'',
|
||||
'release v1',
|
||||
'release~v1',
|
||||
'release^v1',
|
||||
'release:v1',
|
||||
'release?v1',
|
||||
'release*v1',
|
||||
'release[v1',
|
||||
String.raw`release\v1`,
|
||||
'release..v1',
|
||||
'release@{v1',
|
||||
'release//v1',
|
||||
'/release',
|
||||
'release/',
|
||||
'release.',
|
||||
'.release',
|
||||
'release/.candidate',
|
||||
'release/v1.lock',
|
||||
'release\nsecret',
|
||||
'release\u2028secret',
|
||||
'release\u202esecret',
|
||||
'a'.repeat(256),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubReleaseTag(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves exact legal asset names and rejects path or display controls', () => {
|
||||
const valid = [
|
||||
'dist.zip',
|
||||
'dist?channel=stable&part#1.zip',
|
||||
' dist.zip ',
|
||||
'build=production.zip',
|
||||
];
|
||||
for (const value of valid) {
|
||||
expect(validGitHubAssetName(value), value).toBe(true);
|
||||
}
|
||||
|
||||
const invalid = [
|
||||
'',
|
||||
'.',
|
||||
'..',
|
||||
'../dist.zip',
|
||||
String.raw`dir\dist.zip`,
|
||||
'dist\n.zip',
|
||||
'dist\u2028.zip',
|
||||
'dist\u202e.zip',
|
||||
'a'.repeat(256),
|
||||
'\ud800',
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubAssetName(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,51 +5,63 @@
|
||||
package pages
|
||||
|
||||
const (
|
||||
errPagesProjectNotFound = "pages 项目不存在"
|
||||
errPagesSlugExists = "pages 项目标识已存在"
|
||||
errPagesNameRequired = "pages 项目名称不能为空"
|
||||
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
|
||||
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
|
||||
errPagesDeploymentNotFound = "pages 部署不存在"
|
||||
errPagesDeploymentMismatch = "pages 部署不属于该项目"
|
||||
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
|
||||
errPagesPackageMissing = "缺少 Pages 部署包"
|
||||
errPagesPackageURLRequired = "请填写部署包下载链接"
|
||||
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
|
||||
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
|
||||
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
|
||||
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
|
||||
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
|
||||
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
|
||||
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
|
||||
errPagesPackageEmpty = "pages 部署包不能为空"
|
||||
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
|
||||
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
|
||||
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
|
||||
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
|
||||
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesPackagePathEmpty = "pages 部署包路径为空"
|
||||
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
|
||||
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
|
||||
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
|
||||
errPagesInvalidSnapshotFormat = "配置快照格式无效"
|
||||
errPagesActorMissing = "无法识别当前用户"
|
||||
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
|
||||
errPagesSourceNotFound = "pages 部署源不存在"
|
||||
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
|
||||
errPagesSourceTypeUnsupported = "当前阶段仅支持远程地址部署源"
|
||||
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
||||
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
||||
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
|
||||
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
||||
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
|
||||
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
|
||||
errPagesSourceActionBusy = "pages 部署源任务正在执行"
|
||||
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
|
||||
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
|
||||
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
|
||||
errPagesSourceSyncFailed = "pages 部署源同步失败"
|
||||
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
|
||||
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
|
||||
errPagesProjectNotFound = "pages 项目不存在"
|
||||
errPagesSlugExists = "pages 项目标识已存在"
|
||||
errPagesNameRequired = "pages 项目名称不能为空"
|
||||
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
|
||||
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
|
||||
errPagesDeploymentNotFound = "pages 部署不存在"
|
||||
errPagesDeploymentMismatch = "pages 部署不属于该项目"
|
||||
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
|
||||
errPagesPackageMissing = "缺少 Pages 部署包"
|
||||
errPagesPackageURLRequired = "请填写部署包下载链接"
|
||||
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
|
||||
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
|
||||
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
|
||||
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
|
||||
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
|
||||
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
|
||||
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
|
||||
errPagesPackageEmpty = "pages 部署包不能为空"
|
||||
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
|
||||
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
|
||||
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
|
||||
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
|
||||
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesPackagePathEmpty = "pages 部署包路径为空"
|
||||
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
|
||||
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
|
||||
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
|
||||
errPagesInvalidSnapshotFormat = "配置快照格式无效"
|
||||
errPagesActorMissing = "无法识别当前用户"
|
||||
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
|
||||
errPagesSourceNotFound = "pages 部署源不存在"
|
||||
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
|
||||
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 选择方式无效"
|
||||
errPagesSourceAssetNameInvalid = "GitHub Release 资源名称必须是安全的文件名"
|
||||
errPagesSourceCheckInterval = "GitHub latest 检查间隔必须在 5 到 1440 分钟之间"
|
||||
errPagesSourceAutoNotAvailable = "自动更新将在后续阶段开放,当前必须保持关闭"
|
||||
errPagesSourceReleaseNotFound = "未找到符合配置的 GitHub Release 资源"
|
||||
errPagesSourceDigestInvalid = "GitHub Release 资源摘要格式无效"
|
||||
errPagesSourceDigestMismatch = "GitHub Release 资源摘要校验失败"
|
||||
errPagesSourceConfirmationNeeded = "检测到同一 Release 的资源已被替换,请刷新并确认当前版本"
|
||||
errPagesSourceConfirmationStale = "确认的版本已变化,请刷新后重新确认"
|
||||
errPagesSourceInitialCheckWarning = "部署源已保存,但首次检查任务入队失败,请稍后手动检查"
|
||||
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
|
||||
errPagesSourceActionBusy = "pages 部署源任务正在执行"
|
||||
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
|
||||
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
|
||||
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
|
||||
errPagesSourceSyncFailed = "pages 部署源同步失败"
|
||||
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
|
||||
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
githubReleaseSelectorLatest = "latest"
|
||||
githubReleaseSelectorTag = "tag"
|
||||
githubSourceIdentityDomain = "openflare:pages:github-release:v2"
|
||||
initialCheckRetryDelay = 5 * time.Minute
|
||||
githubRepositoryPathParts = 2
|
||||
githubCheckJitterRange = 301
|
||||
githubCheckJitterCenter = 150
|
||||
)
|
||||
|
||||
var (
|
||||
githubOwnerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
|
||||
githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
)
|
||||
|
||||
type githubSourceConfig struct {
|
||||
Repository string
|
||||
Selector string
|
||||
Tag string
|
||||
AssetName string
|
||||
CheckInterval int
|
||||
SourceIdentity string
|
||||
}
|
||||
|
||||
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) != "" {
|
||||
return errors.New(errPagesSourceGitHubFields)
|
||||
}
|
||||
if input.AutoUpdateEnabled {
|
||||
return errors.New(errPagesSourceAutoNotAvailable)
|
||||
}
|
||||
if _, err := normalizeGitHubRepositoryURL(input.RepositoryURL); err != nil {
|
||||
return err
|
||||
}
|
||||
selector := strings.TrimSpace(input.ReleaseSelector)
|
||||
if selector == "" {
|
||||
selector = githubReleaseSelectorLatest
|
||||
}
|
||||
assetName := input.AssetName
|
||||
if assetName == "" {
|
||||
assetName = defaultGitHubAssetName
|
||||
}
|
||||
if !validGitHubAssetName(assetName) {
|
||||
return errors.New(errPagesSourceAssetNameInvalid)
|
||||
}
|
||||
switch selector {
|
||||
case githubReleaseSelectorLatest:
|
||||
if input.ReleaseTag != "" {
|
||||
return errors.New(errPagesSourceSelectorInvalid)
|
||||
}
|
||||
interval := input.CheckIntervalMinutes
|
||||
if interval != 0 && (interval < minimumCheckInterval || interval > maximumCheckInterval) {
|
||||
return errors.New(errPagesSourceCheckInterval)
|
||||
}
|
||||
case githubReleaseSelectorTag:
|
||||
if !validGitHubReleaseTagConfig(input.ReleaseTag) || input.CheckIntervalMinutes != 0 {
|
||||
return errors.New(errPagesSourceSelectorInvalid)
|
||||
}
|
||||
default:
|
||||
return errors.New(errPagesSourceSelectorInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildGitHubSourceConfig(input SourceUpdateInput) (githubSourceConfig, error) {
|
||||
repository, err := normalizeGitHubRepositoryURL(input.RepositoryURL)
|
||||
if err != nil {
|
||||
return githubSourceConfig{}, err
|
||||
}
|
||||
selector := strings.TrimSpace(input.ReleaseSelector)
|
||||
if selector == "" {
|
||||
selector = githubReleaseSelectorLatest
|
||||
}
|
||||
tag := input.ReleaseTag
|
||||
assetName := input.AssetName
|
||||
if assetName == "" {
|
||||
assetName = defaultGitHubAssetName
|
||||
}
|
||||
interval := input.CheckIntervalMinutes
|
||||
if selector == githubReleaseSelectorLatest && interval == 0 {
|
||||
interval = defaultCheckInterval
|
||||
}
|
||||
return githubSourceConfig{
|
||||
Repository: repository,
|
||||
Selector: selector,
|
||||
Tag: tag,
|
||||
AssetName: assetName,
|
||||
CheckInterval: interval,
|
||||
SourceIdentity: buildGitHubSourceIdentity(repository, selector, tag, assetName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildGitHubSourceIdentity(repository, selector, tag, assetName string) string {
|
||||
fields := [...]string{repository, selector, tag, assetName}
|
||||
encoded := make([]byte, 0, len(githubSourceIdentityDomain)+len(fields)*8+
|
||||
len(repository)+len(selector)+len(tag)+len(assetName))
|
||||
encoded = append(encoded, githubSourceIdentityDomain...)
|
||||
var fieldLength [8]byte
|
||||
for _, field := range fields {
|
||||
// Go strings hold the validated UTF-8 bytes used by GitHub. Prefixing each
|
||||
// field with its byte length prevents delimiter characters from creating
|
||||
// ambiguous identities across field boundaries.
|
||||
binary.BigEndian.PutUint64(fieldLength[:], uint64(len(field)))
|
||||
encoded = append(encoded, fieldLength[:]...)
|
||||
encoded = append(encoded, field...)
|
||||
}
|
||||
identityHash := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(identityHash[:])
|
||||
}
|
||||
|
||||
func normalizeGitHubRepositoryURL(raw string) (string, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, "github.com") ||
|
||||
parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" ||
|
||||
strings.Contains(raw, "#") ||
|
||||
parsed.EscapedPath() != parsed.Path || !strings.HasPrefix(parsed.Path, "/") ||
|
||||
strings.HasPrefix(parsed.Path, "//") || strings.HasSuffix(parsed.Path, "/") {
|
||||
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
|
||||
if len(parts) != githubRepositoryPathParts {
|
||||
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||
}
|
||||
owner := parts[0]
|
||||
repository := parts[1]
|
||||
repository = strings.TrimSuffix(repository, ".git")
|
||||
if !githubOwnerPattern.MatchString(owner) || !githubRepoPattern.MatchString(repository) ||
|
||||
len(repository) > 100 || repository == "." || repository == ".." {
|
||||
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||
}
|
||||
return owner + "/" + repository, nil
|
||||
}
|
||||
|
||||
func validGitHubReleaseTagConfig(value string) bool {
|
||||
if !validGitHubReleaseDisplayTag(value) ||
|
||||
strings.ContainsAny(value, " ~^:?*[\\") || strings.HasPrefix(value, "/") ||
|
||||
strings.HasSuffix(value, "/") || strings.HasSuffix(value, ".") ||
|
||||
strings.Contains(value, "//") || strings.Contains(value, "..") || strings.Contains(value, "@{") {
|
||||
return false
|
||||
}
|
||||
for component := range strings.SplitSeq(value, "/") {
|
||||
if strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validGitHubReleaseDisplayTag(value string) bool {
|
||||
if value == "" || len(value) > 255 || !utf8.ValidString(value) {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if unsafeGitHubInputRune(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validGitHubAssetName(value string) bool {
|
||||
if value == "" || len(value) > 255 || !utf8.ValidString(value) ||
|
||||
path.Base(value) != value || strings.Contains(value, "\\") ||
|
||||
value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if unsafeGitHubInputRune(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func unsafeGitHubInputRune(character rune) bool {
|
||||
return unicode.IsControl(character) || character == '\u2028' || character == '\u2029' ||
|
||||
character == '\u061c' || character == '\u200e' || character == '\u200f' ||
|
||||
(character >= '\u202a' && character <= '\u202e') ||
|
||||
(character >= '\u2066' && character <= '\u2069')
|
||||
}
|
||||
|
||||
func updateGitHubSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
|
||||
var project model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
config, err := buildGitHubSourceConfig(input)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !hasExisting {
|
||||
return true, createGitHubSourceTx(tx, projectID, config)
|
||||
}
|
||||
if !githubSourceConfigChanged(existing, config) {
|
||||
return false, nil
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("source_id = ?", existing.ID).First(&runtime).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
identityChanged := existing.SourceIdentity != config.SourceIdentity
|
||||
if err := tx.Model(existing).Updates(githubSourceUpdates(config, existing.ConfigVersion+1)).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := resetRuntimeAfterGitHubUpdate(tx, &runtime, config, identityChanged); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func createGitHubSourceTx(tx *gorm.DB, projectID uint, config githubSourceConfig) error {
|
||||
source := &model.PagesProjectSource{
|
||||
ProjectID: projectID,
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
GitHubRepository: config.Repository,
|
||||
ReleaseSelector: config.Selector,
|
||||
ReleaseTag: config.Tag,
|
||||
AssetName: config.AssetName,
|
||||
AutoUpdateEnabled: false,
|
||||
CheckIntervalMinutes: config.CheckInterval,
|
||||
ConfigVersion: 1,
|
||||
SourceIdentity: config.SourceIdentity,
|
||||
}
|
||||
if err := tx.Create(source).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
runtime := &model.PagesProjectSourceRuntime{SourceID: source.ID, SyncStatus: pagesSourceStatusIdle}
|
||||
if config.Selector == githubReleaseSelectorLatest {
|
||||
next := nextGitHubCheckAt(time.Now(), source.ID, config.CheckInterval)
|
||||
runtime.NextCheckAt = &next
|
||||
}
|
||||
return tx.Create(runtime).Error
|
||||
}
|
||||
|
||||
func githubSourceUpdates(config githubSourceConfig, version int) map[string]any {
|
||||
return map[string]any{
|
||||
"source_type": PagesSourceTypeGitHubRelease,
|
||||
"remote_url": "",
|
||||
"remote_network_policy": "",
|
||||
"github_repository": config.Repository,
|
||||
"release_selector": config.Selector,
|
||||
"release_tag": config.Tag,
|
||||
"asset_name": config.AssetName,
|
||||
sourceColumnAutoUpdateEnabled: false,
|
||||
"check_interval_minutes": config.CheckInterval,
|
||||
sourceColumnConfigVersion: version,
|
||||
"source_identity": config.SourceIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
func githubSourceConfigChanged(existing *model.PagesProjectSource, config githubSourceConfig) bool {
|
||||
return existing.SourceType != PagesSourceTypeGitHubRelease || existing.RemoteURL != "" ||
|
||||
existing.RemoteNetworkPolicy != "" || existing.GitHubRepository != config.Repository ||
|
||||
existing.ReleaseSelector != config.Selector || existing.ReleaseTag != config.Tag ||
|
||||
existing.AssetName != config.AssetName || existing.AutoUpdateEnabled ||
|
||||
existing.CheckIntervalMinutes != config.CheckInterval
|
||||
}
|
||||
|
||||
func resetRuntimeAfterGitHubUpdate(
|
||||
tx *gorm.DB,
|
||||
runtime *model.PagesProjectSourceRuntime,
|
||||
config githubSourceConfig,
|
||||
identityChanged bool,
|
||||
) error {
|
||||
if err := resetRuntimeAfterSourceUpdate(tx, runtime, identityChanged); err != nil {
|
||||
return err
|
||||
}
|
||||
var nextCheckAt any
|
||||
if config.Selector == githubReleaseSelectorLatest {
|
||||
next := nextGitHubCheckAt(time.Now(), runtime.SourceID, config.CheckInterval)
|
||||
nextCheckAt = &next
|
||||
}
|
||||
return tx.Model(runtime).Update("next_check_at", nextCheckAt).Error
|
||||
}
|
||||
|
||||
func nextGitHubCheckAt(now time.Time, sourceID uint, intervalMinutes int) time.Time {
|
||||
// A stable, bounded offset avoids a thundering herd without persisting
|
||||
// another scheduling field. Scanner Phase 3 reuses this calculation.
|
||||
jitterSeconds := int64(sourceID%githubCheckJitterRange) - githubCheckJitterCenter
|
||||
return now.Add(time.Duration(intervalMinutes)*time.Minute + time.Duration(jitterSeconds)*time.Second)
|
||||
}
|
||||
|
||||
func markInitialCheckDispatchFailed(ctx context.Context, sourceID uint, configVersion int) {
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||
sourceRuntimeColumnLastError: errPagesSourceInitialCheckWarning,
|
||||
}
|
||||
var source model.PagesProjectSource
|
||||
if err := db.DB(ctx).Where("id = ? AND config_version = ?", sourceID, configVersion).First(&source).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.ErrorF(ctx, "[PagesSource] load initial check source snapshot failed: source_id=%d error=%v", sourceID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if source.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
next := time.Now().Add(initialCheckRetryDelay)
|
||||
updates["next_check_at"] = &next
|
||||
}
|
||||
now := time.Now()
|
||||
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", sourceID).
|
||||
Where("lease_expires_at IS NULL OR lease_expires_at <= ?", now).
|
||||
Where("EXISTS (SELECT 1 FROM of_pages_project_sources source WHERE source.id = ? AND source.config_version = ?)", sourceID, configVersion).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] mark initial check dispatch failure: source_id=%d error=%v", sourceID, result.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/task"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const githubSourceDetailProvider = "github"
|
||||
|
||||
var githubDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
type githubSourceProviderDomainError struct {
|
||||
message string
|
||||
permanent bool
|
||||
retryAt *time.Time
|
||||
}
|
||||
|
||||
func (domainError *githubSourceProviderDomainError) Error() string {
|
||||
return domainError.message
|
||||
}
|
||||
|
||||
type githubReleaseAPI interface {
|
||||
Resolve(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
|
||||
Download(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
|
||||
}
|
||||
|
||||
var newGitHubReleaseClient = func() githubReleaseAPI {
|
||||
return githubrelease.NewClient()
|
||||
}
|
||||
|
||||
type githubSourceTarget struct {
|
||||
Revision string
|
||||
Detail sourceDetail
|
||||
DetailJSON string
|
||||
Release githubrelease.Release
|
||||
Asset githubrelease.Asset
|
||||
RetryAt *time.Time
|
||||
}
|
||||
|
||||
type githubCheckTaskResult struct {
|
||||
Message string
|
||||
Detail string
|
||||
Stale bool
|
||||
}
|
||||
|
||||
type preparedGitHubSource struct {
|
||||
target *githubSourceTarget
|
||||
download *githubrelease.DownloadResult
|
||||
format pagesarchive.Format
|
||||
manifest *deploymentManifest
|
||||
ingestState *sourceIngestState
|
||||
limits pagesLimits
|
||||
}
|
||||
|
||||
func checkGitHubSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
) (*githubCheckTaskResult, error) {
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease {
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
task.AppendLog(ctx, "[check] 正在检查 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
client := newGitHubReleaseClient()
|
||||
result, err := client.Resolve(ctx, githubrelease.ResolveRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
|
||||
Tag: snapshot.ReleaseTag,
|
||||
AssetName: snapshot.AssetName,
|
||||
ETag: snapshot.ETag,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
|
||||
retryAt, _ := githubrelease.RetryAt(err)
|
||||
domainErr := githubSourceDomainError(err)
|
||||
if failErr := failGitHubCheckLease(ctx, snapshot, domainErr.Error(), retryAt); failErr != nil {
|
||||
if errors.Is(failErr, errSourceFinalFence) {
|
||||
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||
}
|
||||
return nil, failErr
|
||||
}
|
||||
return nil, domainErr
|
||||
}
|
||||
if result.NotModified {
|
||||
if err := finishGitHubCheckNotModified(ctx, snapshot, result); err != nil {
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &githubCheckTaskResult{Message: "GitHub Release 检查完成,内容未变化"}, nil
|
||||
}
|
||||
target, err := buildGitHubSourceTarget(result.Release, result.Asset, result.RetryAt)
|
||||
if err != nil {
|
||||
retryAt := time.Time{}
|
||||
if result.RetryAt != nil {
|
||||
retryAt = result.RetryAt.UTC()
|
||||
}
|
||||
if failErr := failGitHubCheckLease(ctx, snapshot, err.Error(), retryAt); failErr != nil {
|
||||
if errors.Is(failErr, errSourceFinalFence) {
|
||||
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||
}
|
||||
return nil, failErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
status, err := finishGitHubCheckTarget(ctx, snapshot, result, target)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]string{"revision": target.Revision, pagesDeploymentColumnStatus: status})
|
||||
message := "GitHub Release 检查完成"
|
||||
switch status {
|
||||
case pagesSourceStatusUpdateAvailable:
|
||||
message = "发现新的 GitHub Release 部署包"
|
||||
case pagesSourceStatusAttention:
|
||||
message = "检测到同一 Release 的资源被替换,需要确认"
|
||||
}
|
||||
return &githubCheckTaskResult{Message: message, Detail: string(detail)}, nil
|
||||
}
|
||||
|
||||
func buildGitHubSourceTarget(
|
||||
release githubrelease.Release,
|
||||
asset githubrelease.Asset,
|
||||
retryAt *time.Time,
|
||||
) (*githubSourceTarget, error) {
|
||||
digest := strings.ToLower(strings.TrimSpace(asset.Digest))
|
||||
if digest != "" && !githubDigestPattern.MatchString(digest) {
|
||||
return nil, errors.New(errPagesSourceDigestInvalid)
|
||||
}
|
||||
if strings.TrimSpace(release.ID) == "" || strings.TrimSpace(asset.ID) == "" ||
|
||||
!validGitHubReleaseDisplayTag(release.Tag) || !validGitHubAssetName(asset.Name) ||
|
||||
asset.State != "uploaded" || asset.UpdatedAt.IsZero() {
|
||||
return nil, errors.New(errPagesSourceReleaseNotFound)
|
||||
}
|
||||
updatedAt := asset.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
rawRevision := "github:" + release.ID + ":" + asset.ID + ":" + updatedAt + ":" + digest
|
||||
sum := sha256.Sum256([]byte(rawRevision))
|
||||
detail := sourceDetail{
|
||||
Provider: githubSourceDetailProvider,
|
||||
Tag: release.Tag,
|
||||
AssetName: asset.Name,
|
||||
ReleaseID: release.ID,
|
||||
AssetID: asset.ID,
|
||||
AssetUpdatedAt: updatedAt,
|
||||
Digest: digest,
|
||||
}
|
||||
detailJSON, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
return &githubSourceTarget{
|
||||
Revision: hex.EncodeToString(sum[:]),
|
||||
Detail: detail,
|
||||
DetailJSON: string(detailJSON),
|
||||
Release: release,
|
||||
Asset: asset,
|
||||
RetryAt: retryAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func finishGitHubCheckNotModified(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
result githubrelease.ResolveResult,
|
||||
) error {
|
||||
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
|
||||
updates["etag"] = result.ETag
|
||||
updates[sourceRuntimeColumnSyncStatus] = normalizedSourceRuntimeStatus(runtime)
|
||||
return tx.Model(runtime).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
|
||||
func finishGitHubCheckTarget(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
result githubrelease.ResolveResult,
|
||||
target *githubSourceTarget,
|
||||
) (string, error) {
|
||||
status := pagesSourceStatusIdle
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status = targetRuntimeStatus(target, runtime.LastAppliedRevision, runtime.LastAppliedDetail)
|
||||
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
|
||||
updates["etag"] = result.ETag
|
||||
updates["last_seen_revision"] = target.Revision
|
||||
updates["last_seen_detail"] = target.DetailJSON
|
||||
updates[sourceRuntimeColumnSyncStatus] = status
|
||||
return tx.Model(runtime).Updates(updates).Error
|
||||
})
|
||||
return status, err
|
||||
}
|
||||
|
||||
func githubCheckTerminalUpdates(
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
now time.Time,
|
||||
retryAt *time.Time,
|
||||
) map[string]any {
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnLastError: "",
|
||||
sourceRuntimeColumnLastCheckedAt: &now,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
}
|
||||
updates["next_check_at"] = nextCheckAfterGitHubResponse(snapshot, now, retryAt)
|
||||
return updates
|
||||
}
|
||||
|
||||
func nextCheckAfterGitHubResponse(
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
now time.Time,
|
||||
retryAt *time.Time,
|
||||
) any {
|
||||
if snapshot.ReleaseSelector != githubReleaseSelectorLatest {
|
||||
return nil
|
||||
}
|
||||
next := nextGitHubCheckAt(now, snapshot.SourceID, snapshot.CheckIntervalMinutes)
|
||||
if retryAt != nil && retryAt.After(next) {
|
||||
next = retryAt.UTC()
|
||||
}
|
||||
return &next
|
||||
}
|
||||
|
||||
func lockOwnedSourceRuntime(
|
||||
tx *gorm.DB,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
) (*model.PagesProjectSourceRuntime, time.Time, error) {
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("source_id = ?", snapshot.SourceID).First(&runtime).Error; err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
if runtime.LeaseToken != snapshot.LeaseToken || runtime.LeaseExpiresAt == nil ||
|
||||
!runtime.LeaseExpiresAt.After(now) {
|
||||
return nil, time.Time{}, errSourceFinalFence
|
||||
}
|
||||
return &runtime, now, nil
|
||||
}
|
||||
|
||||
func failGitHubCheckLease(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
message string,
|
||||
retryAt time.Time,
|
||||
) error {
|
||||
now := time.Now()
|
||||
next := now.Add(initialCheckRetryDelay)
|
||||
if retryAt.After(next) {
|
||||
next = retryAt.UTC()
|
||||
}
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||
sourceRuntimeColumnLastError: safeSourceRuntimeError(message),
|
||||
sourceRuntimeColumnLastCheckedAt: &now,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
}
|
||||
if snapshot.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
updates["next_check_at"] = &next
|
||||
} else {
|
||||
updates["next_check_at"] = nil
|
||||
}
|
||||
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func targetRuntimeStatus(
|
||||
target *githubSourceTarget,
|
||||
appliedRevision string,
|
||||
appliedDetail string,
|
||||
) string {
|
||||
if target == nil || target.Revision == appliedRevision {
|
||||
return pagesSourceStatusIdle
|
||||
}
|
||||
applied := sourceDetail{}
|
||||
if unmarshalSourceDetail(appliedDetail, &applied) == nil && target.Detail.ReleaseID != "" &&
|
||||
target.Detail.ReleaseID == applied.ReleaseID {
|
||||
return pagesSourceStatusAttention
|
||||
}
|
||||
return pagesSourceStatusUpdateAvailable
|
||||
}
|
||||
|
||||
func preflightGitHubSyncConfirmation(ctx context.Context, sourceID uint, confirmedRevision string) error {
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", sourceID).First(&runtime).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
replacement := sourceHasSameReleaseReplacement(&runtime)
|
||||
if replacement && confirmedRevision == "" {
|
||||
return errors.New(errPagesSourceConfirmationNeeded)
|
||||
}
|
||||
if confirmedRevision != "" && (!replacement || confirmedRevision != runtime.LastSeenRevision) {
|
||||
return errors.New(errPagesSourceConfirmationStale)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncGitHubSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (outcome *sourceSyncOutcome, resultErr error) {
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease || !validPagesSourceActor(actor) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
defer func() {
|
||||
resultErr = finalizeGitHubSyncFailure(ctx, snapshot, resultErr)
|
||||
}()
|
||||
workCtx, heartbeat, err := startSourceLeaseHeartbeat(
|
||||
ctx, snapshot, pagesSourceSyncLeaseDuration, pagesSourceHeartbeatInterval,
|
||||
)
|
||||
if err != nil {
|
||||
return sourceHeartbeatOutcome(err)
|
||||
}
|
||||
defer func() { _ = heartbeat.stop() }()
|
||||
|
||||
client := newGitHubReleaseClient()
|
||||
target, guardedOutcome, err := resolveAndGuardGitHubSync(
|
||||
workCtx, client, snapshot, targetRevision, confirmedRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if guardedOutcome != nil {
|
||||
return guardedOutcome, nil
|
||||
}
|
||||
prepared, err := prepareGitHubSyncPackage(workCtx, client, snapshot, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := prepared.download.Cleanup(); cleanupErr != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
|
||||
}
|
||||
}()
|
||||
defer compensateSourceIngest(ctx, snapshot, prepared.ingestState)
|
||||
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
|
||||
return sourceHeartbeatOutcome(heartbeatErr)
|
||||
}
|
||||
renewed, err := renewSourceLease(ctx, snapshot, pagesSourceSyncLeaseDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !renewed {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
return activatePreparedGitHubSource(ctx, snapshot, actor, prepared)
|
||||
}
|
||||
|
||||
func finalizeGitHubSyncFailure(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
resultErr error,
|
||||
) error {
|
||||
if resultErr == nil {
|
||||
return nil
|
||||
}
|
||||
cleanupCtx, cancel := sourceCleanupContext(ctx)
|
||||
defer cancel()
|
||||
finalizerErr := persistGitHubSyncFailure(cleanupCtx, snapshot, resultErr)
|
||||
if finalizerErr == nil {
|
||||
return resultErr
|
||||
}
|
||||
logger.WarnF(
|
||||
cleanupCtx,
|
||||
"[PagesSource] finalize GitHub sync failure failed: source_id=%d source_error=%s error=%v",
|
||||
snapshot.SourceID, safeGitHubSourceError(resultErr), finalizerErr,
|
||||
)
|
||||
// final fence 丢失表示已有新任务接管 runtime,不应覆盖;数据库
|
||||
// finalizer 失败则保持可重试,避免继承永久错误或 provider deadline 分类。
|
||||
if errors.Is(finalizerErr, errSourceFinalFence) {
|
||||
return resultErr
|
||||
}
|
||||
return errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
|
||||
func persistGitHubSyncFailure(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
resultErr error,
|
||||
) error {
|
||||
var domainError *githubSourceProviderDomainError
|
||||
if errors.As(resultErr, &domainError) && domainError.retryAt != nil {
|
||||
return failGitHubCheckLease(ctx, snapshot, domainError.message, *domainError.retryAt)
|
||||
}
|
||||
return failSourceLease(ctx, snapshot, safeGitHubSourceError(resultErr))
|
||||
}
|
||||
|
||||
func activatePreparedGitHubSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
prepared *preparedGitHubSource,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署")
|
||||
deployment, reused, referenced, err := commitSourceDeployment(
|
||||
ctx, snapshot, prepared.target.Revision, prepared.download.SHA256,
|
||||
prepared.target.Detail, prepared.target.DetailJSON, actor, prepared.manifest,
|
||||
prepared.ingestState.Result, prepared.ingestState.HasIngest, prepared.target.RetryAt,
|
||||
)
|
||||
prepared.ingestState.Referenced = referenced
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prepared.ingestState.Referenced = prepared.ingestState.HasIngest && deployment.UploadID == prepared.ingestState.Result.Upload.ID
|
||||
if pruneErr := pruneProjectDeploymentHistory(ctx, snapshot.ProjectID, prepared.limits.HistoryCount, 0); pruneErr != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] strict prune failed after GitHub sync: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, pruneErr)
|
||||
}
|
||||
view := buildDeploymentView(deployment)
|
||||
return &sourceSyncOutcome{Deployment: &view, Reused: reused}, nil
|
||||
}
|
||||
|
||||
func resolveAndGuardGitHubSync(
|
||||
ctx context.Context,
|
||||
client githubReleaseAPI,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (*githubSourceTarget, *sourceSyncOutcome, error) {
|
||||
task.AppendLog(ctx, "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
resolved, err := client.Resolve(ctx, githubrelease.ResolveRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
|
||||
Tag: snapshot.ReleaseTag,
|
||||
AssetName: snapshot.AssetName,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
|
||||
return nil, nil, githubSourceDomainError(err)
|
||||
}
|
||||
if resolved.NotModified {
|
||||
return nil, nil, errors.New(errPagesSourceReleaseNotFound)
|
||||
}
|
||||
target, err := buildGitHubSourceTarget(resolved.Release, resolved.Asset, resolved.RetryAt)
|
||||
if err != nil {
|
||||
return nil, nil, &githubSourceProviderDomainError{
|
||||
message: safeGitHubSourceError(err),
|
||||
permanent: isPermanentSourceSyncError(err),
|
||||
retryAt: resolved.RetryAt,
|
||||
}
|
||||
}
|
||||
guardedOutcome, err := guardGitHubSyncTarget(ctx, snapshot, target, targetRevision, confirmedRevision)
|
||||
return target, guardedOutcome, err
|
||||
}
|
||||
|
||||
func guardGitHubSyncTarget(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
status := targetRuntimeStatus(target, snapshot.LastAppliedRevision, snapshot.LastAppliedDetail)
|
||||
if targetRevision != "" && targetRevision != target.Revision {
|
||||
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, "", true, true)
|
||||
}
|
||||
if confirmedRevision != "" && (confirmedRevision != snapshot.LastSeenRevision || confirmedRevision != target.Revision) {
|
||||
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationStale, false, false)
|
||||
}
|
||||
if status == pagesSourceStatusAttention && confirmedRevision != target.Revision {
|
||||
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationNeeded, false, false)
|
||||
}
|
||||
if confirmedRevision != "" && status != pagesSourceStatusAttention {
|
||||
return nil, errors.New(errPagesSourceConfirmationStale)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func releaseGuardedGitHubTarget(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
status string,
|
||||
lastError string,
|
||||
expedite bool,
|
||||
staleSuccess bool,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
err := releaseGitHubSyncWithoutActivation(ctx, snapshot, target, status, lastError, expedite, target.RetryAt)
|
||||
if errors.Is(err, errSourceFinalFence) || (err == nil && staleSuccess) {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New(lastError)
|
||||
}
|
||||
|
||||
func prepareGitHubSyncPackage(
|
||||
ctx context.Context,
|
||||
client githubReleaseAPI,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
) (*preparedGitHubSource, error) {
|
||||
limits := resolvePagesLimits(ctx)
|
||||
task.AppendLog(ctx, "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
download, err := client.Download(ctx, githubrelease.DownloadRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
Asset: target.Asset,
|
||||
MaxBytes: limits.PackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] GitHub download failed: source_id=%d repo=%s asset=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, snapshot.AssetName, err)
|
||||
return nil, githubSourceDomainError(err)
|
||||
}
|
||||
prepared, err := inspectAndIngestGitHubPackage(ctx, snapshot, target, download, limits)
|
||||
if err != nil {
|
||||
if cleanupErr := download.Cleanup(); cleanupErr != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package after preparation failure failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func inspectAndIngestGitHubPackage(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
download *githubrelease.DownloadResult,
|
||||
limits pagesLimits,
|
||||
) (*preparedGitHubSource, error) {
|
||||
if download.SHA256 == "" || download.Path == "" {
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if target.Detail.Digest != "" && "sha256:"+download.SHA256 != target.Detail.Digest {
|
||||
return nil, errors.New(errPagesSourceDigestMismatch)
|
||||
}
|
||||
format, ok := pagesarchive.DetectFormatFromName(target.Asset.Name)
|
||||
var err error
|
||||
if !ok {
|
||||
format, _, err = detectRemoteSourceFormat(download.Path, target.Asset.Name, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rootDir, err := validateAndNormalizePagesRootDir(snapshot.RootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(snapshot.EntryFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[verify] 正在校验 GitHub Release 归档与入口")
|
||||
manifest, err := inspectPagesPackage(download.Path, format, rootDir, entryFile, limits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ingestState, err := resolveGitHubSourceIngest(ctx, snapshot, target, download, format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &preparedGitHubSource{
|
||||
target: target, download: download, format: format,
|
||||
manifest: manifest, ingestState: ingestState, limits: limits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveGitHubSourceIngest(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
download *githubrelease.DownloadResult,
|
||||
format pagesarchive.Format,
|
||||
) (*sourceIngestState, error) {
|
||||
if _, err := findSourceDeployment(ctx, snapshot.ProjectID, snapshot.SourceIdentity, target.Revision); err == nil {
|
||||
return &sourceIngestState{}, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[ingest] 正在保存 GitHub Release 部署包")
|
||||
result, err := ingestPagesDeploymentPackageWithSource(
|
||||
ctx, download.Path, download.SHA256, snapshot.ProjectID, snapshot.SourceID, target.Asset.Name, format,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sourceIngestState{Result: result, HasIngest: true}, nil
|
||||
}
|
||||
|
||||
func releaseGitHubSyncWithoutActivation(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
status string,
|
||||
lastError string,
|
||||
expedite bool,
|
||||
retryAt *time.Time,
|
||||
) error {
|
||||
now := time.Now()
|
||||
nextCheckAt := nextCheckAfterGitHubResponse(snapshot, now, retryAt)
|
||||
if expedite && snapshot.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
next := now.Add(initialCheckRetryDelay)
|
||||
if retryAt != nil && retryAt.After(next) {
|
||||
next = retryAt.UTC()
|
||||
}
|
||||
nextCheckAt = &next
|
||||
}
|
||||
updates := map[string]any{
|
||||
"last_seen_revision": target.Revision,
|
||||
"last_seen_detail": target.DetailJSON,
|
||||
sourceRuntimeColumnSyncStatus: status,
|
||||
sourceRuntimeColumnLastError: lastError,
|
||||
sourceRuntimeColumnLastCheckedAt: &now,
|
||||
"next_check_at": nextCheckAt,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
}
|
||||
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeGitHubSourceError(err error) string {
|
||||
if err == nil {
|
||||
return errPagesSourceSyncFailed
|
||||
}
|
||||
message := strings.TrimSpace(err.Error())
|
||||
for _, safeMessage := range []string{
|
||||
errPagesSourceSyncFailed,
|
||||
errPagesSourceReleaseNotFound,
|
||||
errPagesSourceDigestInvalid,
|
||||
errPagesSourceDigestMismatch,
|
||||
errPagesSourceConfirmationNeeded,
|
||||
errPagesSourceConfirmationStale,
|
||||
errPagesPackageURLTooLarge,
|
||||
errPagesPackageEmpty,
|
||||
errPagesPackageUnsupported,
|
||||
errPagesPackageInvalid,
|
||||
errPagesPackageExtractedTooLarge,
|
||||
errPagesPackageFileTooLarge,
|
||||
errPagesEntryFileMissing,
|
||||
} {
|
||||
if message == safeMessage {
|
||||
return safeMessage
|
||||
}
|
||||
}
|
||||
return errPagesSourceSyncFailed
|
||||
}
|
||||
|
||||
func githubSourceDomainError(err error) error {
|
||||
message := errPagesSourceSyncFailed
|
||||
retryAt, hasRetryAt := githubrelease.RetryAt(err)
|
||||
var retryDeadline *time.Time
|
||||
if hasRetryAt {
|
||||
retryDeadline = &retryAt
|
||||
}
|
||||
if err == nil {
|
||||
return &githubSourceProviderDomainError{message: message, permanent: false}
|
||||
}
|
||||
if githubrelease.IsDigestError(err) {
|
||||
message = errPagesSourceDigestMismatch
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||
}
|
||||
if githubrelease.IsNotFound(err) {
|
||||
message = errPagesSourceReleaseNotFound
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||
}
|
||||
if errors.Is(err, githubrelease.ErrAssetTooLarge) {
|
||||
message = errPagesPackageURLTooLarge
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||
}
|
||||
if errors.Is(err, githubrelease.ErrEmptyAsset) {
|
||||
message = errPagesPackageEmpty
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||
}
|
||||
return &githubSourceProviderDomainError{
|
||||
message: message, permanent: !githubrelease.IsRetryable(err), retryAt: retryDeadline,
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipGitHubActionRetry(err error) bool {
|
||||
var domainError *githubSourceProviderDomainError
|
||||
return errors.As(err, &domainError) && (domainError.permanent || domainError.retryAt != nil)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
func TestGitHubSourceIdentityLengthPrefixesFieldsAndResetsRuntime(t *testing.T) {
|
||||
firstInput := SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/OpenFlare/site",
|
||||
ReleaseSelector: githubReleaseSelectorTag,
|
||||
ReleaseTag: "release|foo",
|
||||
AssetName: "bar.zip",
|
||||
}
|
||||
secondInput := SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/OpenFlare/site",
|
||||
ReleaseSelector: githubReleaseSelectorTag,
|
||||
ReleaseTag: "release",
|
||||
AssetName: "foo|bar.zip",
|
||||
}
|
||||
firstConfig, err := buildGitHubSourceConfig(firstInput)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGitHubSourceConfig(first) error = %v, want nil", err)
|
||||
}
|
||||
secondConfig, err := buildGitHubSourceConfig(secondInput)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGitHubSourceConfig(second) error = %v, want nil", err)
|
||||
}
|
||||
legacyIdentityInput := func(config githubSourceConfig) string {
|
||||
return "github|" + config.Repository + "|" + config.Selector + "|" +
|
||||
config.Tag + "|" + config.AssetName
|
||||
}
|
||||
if firstLegacy, secondLegacy := legacyIdentityInput(firstConfig), legacyIdentityInput(secondConfig); firstLegacy != secondLegacy {
|
||||
t.Fatalf("legacy identity inputs differ: %q != %q; collision fixture is invalid", firstLegacy, secondLegacy)
|
||||
}
|
||||
if firstConfig.SourceIdentity == secondConfig.SourceIdentity {
|
||||
t.Fatalf("length-prefixed identities collide: %q", firstConfig.SourceIdentity)
|
||||
}
|
||||
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-identity-collision")
|
||||
firstSource, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, firstInput)
|
||||
if got, want := firstSource.SourceIdentity, firstConfig.SourceIdentity; got != want {
|
||||
t.Fatalf("first source identity = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
checkedAt := time.Now().Add(-time.Minute)
|
||||
syncedAt := time.Now().Add(-30 * time.Second)
|
||||
nextCheckAt := time.Now().Add(time.Hour)
|
||||
leaseExpiresAt := time.Now().Add(time.Minute)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", firstSource.ID).
|
||||
Updates(map[string]any{
|
||||
"etag": `"old-etag"`,
|
||||
"last_seen_revision": strings.Repeat("a", 64),
|
||||
"last_seen_detail": `{"provider":"github_release","tag":"release|foo"}`,
|
||||
"last_applied_revision": strings.Repeat("b", 64),
|
||||
"last_applied_detail": `{"provider":"github_release","tag":"older"}`,
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"last_error": "old error",
|
||||
"last_checked_at": &checkedAt,
|
||||
"last_synced_at": &syncedAt,
|
||||
"next_check_at": &nextCheckAt,
|
||||
"lease_expires_at": &leaseExpiresAt,
|
||||
"lease_token": "old-lease",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed runtime cursors error = %v, want nil", err)
|
||||
}
|
||||
|
||||
secondSource, runtime := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, secondInput)
|
||||
if secondSource.ID != firstSource.ID {
|
||||
t.Errorf("updated source ID = %d, want unchanged %d", secondSource.ID, firstSource.ID)
|
||||
}
|
||||
if got, want := secondSource.SourceIdentity, secondConfig.SourceIdentity; got != want {
|
||||
t.Errorf("updated source identity = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := secondSource.ConfigVersion, firstSource.ConfigVersion+1; got != want {
|
||||
t.Errorf("updated source config version = %d, want %d", got, want)
|
||||
}
|
||||
if runtime.ETag != "" || runtime.LastSeenRevision != "" || runtime.LastSeenDetail != "" ||
|
||||
runtime.LastAppliedRevision != "" || runtime.LastAppliedDetail != "" {
|
||||
t.Errorf("identity change retained runtime cursors: %+v", runtime)
|
||||
}
|
||||
if runtime.LastCheckedAt != nil || runtime.LastSyncedAt != nil || runtime.NextCheckAt != nil {
|
||||
t.Errorf(
|
||||
"identity change retained runtime timestamps: checked=%v synced=%v next=%v",
|
||||
runtime.LastCheckedAt,
|
||||
runtime.LastSyncedAt,
|
||||
runtime.NextCheckAt,
|
||||
)
|
||||
}
|
||||
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" ||
|
||||
runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
|
||||
t.Errorf(
|
||||
"identity change retained runtime state: status=%q error=%q lease=(%q, %v)",
|
||||
runtime.SyncStatus,
|
||||
runtime.LastError,
|
||||
runtime.LeaseToken,
|
||||
runtime.LeaseExpiresAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/hibiken/asynq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeGitHubReleaseClient struct {
|
||||
resolve func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
|
||||
download func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
|
||||
}
|
||||
|
||||
func (client *fakeGitHubReleaseClient) Resolve(
|
||||
ctx context.Context,
|
||||
request githubrelease.ResolveRequest,
|
||||
) (githubrelease.ResolveResult, error) {
|
||||
return client.resolve(ctx, request)
|
||||
}
|
||||
|
||||
func (client *fakeGitHubReleaseClient) Download(
|
||||
ctx context.Context,
|
||||
request githubrelease.DownloadRequest,
|
||||
) (*githubrelease.DownloadResult, error) {
|
||||
return client.download(ctx, request)
|
||||
}
|
||||
|
||||
func useFakeGitHubReleaseClient(t *testing.T, client githubReleaseAPI) {
|
||||
t.Helper()
|
||||
previous := newGitHubReleaseClient
|
||||
newGitHubReleaseClient = func() githubReleaseAPI { return client }
|
||||
t.Cleanup(func() { newGitHubReleaseClient = previous })
|
||||
}
|
||||
|
||||
func mustConfigureGitHubSourceWithoutDispatch(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
input SourceUpdateInput,
|
||||
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||
t.Helper()
|
||||
if err := validateGitHubSourceInput(input); err != nil {
|
||||
t.Fatalf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
|
||||
}
|
||||
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
_, err := updateGitHubSourceTx(tx, projectID, input)
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("updateGitHubSourceTx(project=%d) error = %v, want nil", projectID, err)
|
||||
}
|
||||
return mustLoadPagesSource(t, ctx, projectID)
|
||||
}
|
||||
|
||||
func mustLoadPagesSource(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||
t.Helper()
|
||||
var source model.PagesProjectSource
|
||||
if err := db.DB(ctx).Where("project_id = ?", projectID).First(&source).Error; err != nil {
|
||||
t.Fatalf("load source for project %d error = %v, want nil", projectID, err)
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
|
||||
t.Fatalf("load runtime for source %d error = %v, want nil", source.ID, err)
|
||||
}
|
||||
return &source, &runtime
|
||||
}
|
||||
|
||||
func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
setupPagesSourceDispatchTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-config")
|
||||
input := SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/OpenFlare/site.git",
|
||||
}
|
||||
result, err := UpdateSourceAs(ctx, project.ID, input, "user:42")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSourceAs(GitHub) error = %v, want nil", err)
|
||||
}
|
||||
if result.CheckTask == nil || result.CheckTask.Action != sourceActionCheck || result.Warning != "" {
|
||||
t.Errorf("UpdateSourceAs(GitHub) result = %+v, want initial check receipt without warning", result)
|
||||
}
|
||||
execution, err := model.GetTaskExecutionByTaskID(ctx, result.CheckTask.TaskID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskExecutionByTaskID(%q) error = %v, want nil", result.CheckTask.TaskID, err)
|
||||
}
|
||||
var actionPayload SourceActionPayload
|
||||
if err := json.Unmarshal([]byte(execution.Payload), &actionPayload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(initial check payload) error = %v, want nil", err)
|
||||
}
|
||||
if actionPayload.Actor != "user:42" || actionPayload.Action != sourceActionCheck ||
|
||||
actionPayload.TargetRevision != "" || actionPayload.ConfirmedRevision != "" {
|
||||
t.Errorf("initial check payload = %+v, want real actor and credential-free check", actionPayload)
|
||||
}
|
||||
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if got, want := source.GitHubRepository, "OpenFlare/site"; got != want {
|
||||
t.Errorf("GitHubRepository = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := source.ReleaseSelector, githubReleaseSelectorLatest; got != want {
|
||||
t.Errorf("ReleaseSelector = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := source.AssetName, defaultGitHubAssetName; got != want {
|
||||
t.Errorf("AssetName = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := source.CheckIntervalMinutes, defaultCheckInterval; got != want {
|
||||
t.Errorf("CheckIntervalMinutes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := source.SourceIdentity, "dbbd25307aaa3b88bc25353476940a049428655bd8421ac63045fdcb5fb23c9d"; got != want {
|
||||
t.Errorf("SourceIdentity = %q, want %q", got, want)
|
||||
}
|
||||
if runtime.NextCheckAt == nil {
|
||||
t.Error("GitHub latest NextCheckAt = nil, want scheduled value")
|
||||
}
|
||||
|
||||
var taskCount int64
|
||||
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&taskCount).Error; err != nil {
|
||||
t.Fatalf("count initial checks error = %v, want nil", err)
|
||||
}
|
||||
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
|
||||
t.Fatalf("UpdateSourceAs(GitHub no-op) error = %v, want nil", err)
|
||||
}
|
||||
var noOpTaskCount int64
|
||||
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&noOpTaskCount).Error; err != nil {
|
||||
t.Fatalf("count no-op checks error = %v, want nil", err)
|
||||
}
|
||||
if noOpTaskCount != taskCount {
|
||||
t.Errorf("no-op initial check count = %d, want unchanged %d", noOpTaskCount, taskCount)
|
||||
}
|
||||
|
||||
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,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(GitHub to Remote) error = %v, want nil", err)
|
||||
}
|
||||
remote, _ := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if remote.GitHubRepository != "" || remote.ReleaseSelector != "" || remote.AssetName != "" ||
|
||||
remote.AutoUpdateEnabled || remote.CheckIntervalMinutes != 0 {
|
||||
t.Errorf("Remote switched source retained GitHub fields: %+v", remote)
|
||||
}
|
||||
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSourceSaveSurvivesInitialCheckDispatchFailure(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-dispatch-warning")
|
||||
result, err := UpdateSourceAs(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
}, "user:9")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSourceAs(dispatch failure) error = %v, want saved source with warning", err)
|
||||
}
|
||||
if result.CheckTask != nil || result.Warning != errPagesSourceInitialCheckWarning {
|
||||
t.Errorf("UpdateSourceAs(dispatch failure) result = %+v, want warning and nil check task", result)
|
||||
}
|
||||
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if source.GitHubRepository != "a/b" || runtime.SyncStatus != pagesSourceStatusFailed ||
|
||||
runtime.LastError != errPagesSourceInitialCheckWarning {
|
||||
t.Errorf("saved source/runtime = repo:%q status:%q error:%q", source.GitHubRepository, runtime.SyncStatus, runtime.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSourceRejectsUnsafeOrPhaseThreeFields(t *testing.T) {
|
||||
tests := []SourceUpdateInput{
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "http://github.com/a/b"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a%20b/repo"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/extra"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com//a/b"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b?"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b#"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\n.zip"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\u202e.zip"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dir/dist.zip"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AutoUpdateEnabled: true},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1", CheckIntervalMinutes: 60},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: " v1"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\n"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\u2028draft"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: `v1\draft`},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release//v1"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/.draft"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/v1.lock"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "latest", ReleaseTag: "v1"},
|
||||
}
|
||||
for _, input := range tests {
|
||||
if err := validateGitHubSourceInput(input); err == nil {
|
||||
t.Errorf("validateGitHubSourceInput(%+v) error = nil, want non-nil", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSourceAcceptsLegalAssetAndTagCharacters(t *testing.T) {
|
||||
tests := []SourceUpdateInput{
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
AssetName: "dist?channel=stable&part#1.zip",
|
||||
},
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
AssetName: " dist.zip ",
|
||||
},
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
ReleaseSelector: "tag",
|
||||
ReleaseTag: "release/v1#stable&build=1",
|
||||
AssetName: "dist.zip",
|
||||
},
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b.git",
|
||||
ReleaseSelector: "tag",
|
||||
ReleaseTag: "@",
|
||||
AssetName: "dist.zip",
|
||||
},
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
ReleaseSelector: "tag",
|
||||
ReleaseTag: "release/v1.LOCK",
|
||||
AssetName: "dist.zip",
|
||||
},
|
||||
}
|
||||
for _, input := range tests {
|
||||
if err := validateGitHubSourceInput(input); err != nil {
|
||||
t.Errorf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialCheckFailureUsesExactConfigFence(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-initial-fence")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
staleVersion := source.ConfigVersion
|
||||
if err := db.DB(ctx).Model(source).Update("config_version", staleVersion+1).Error; err != nil {
|
||||
t.Fatalf("increment source config version error = %v, want nil", err)
|
||||
}
|
||||
markInitialCheckDispatchFailed(ctx, source.ID, staleVersion)
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" {
|
||||
t.Errorf("stale initial failure runtime = status:%q error:%q, want unchanged idle", runtime.SyncStatus, runtime.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckUsesETagAndDetectsSameReleaseReplacement(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-check")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
appliedRevision := strings.Repeat("a", 64)
|
||||
appliedDetail := `{"provider":"github","release_id":"100","asset_id":"1","tag":"release/v1","asset_name":"dist.zip"}`
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||
"etag": `"old-etag"`,
|
||||
"last_applied_revision": appliedRevision,
|
||||
"last_applied_detail": appliedDetail,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed GitHub runtime error = %v, want nil", err)
|
||||
}
|
||||
updatedAt := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)
|
||||
var gotETag string
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
gotETag = request.ETag
|
||||
return githubrelease.ResolveResult{
|
||||
ETag: `"new-etag"`,
|
||||
Release: githubrelease.Release{ID: "100", Tag: "release/v1"},
|
||||
Asset: githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt},
|
||||
}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called during check, want resolve only")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
if err != nil || outcome != sourceLeaseAcquired {
|
||||
t.Fatalf("acquire check lease = (%+v, %q, %v), want acquired", snapshot, outcome, err)
|
||||
}
|
||||
result, err := checkGitHubSource(ctx, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("checkGitHubSource() error = %v, want nil", err)
|
||||
}
|
||||
if result.Stale {
|
||||
t.Error("checkGitHubSource() stale = true, want false")
|
||||
}
|
||||
if got, want := gotETag, `"old-etag"`; got != want {
|
||||
t.Errorf("Resolve ETag = %q, want %q", got, want)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusAttention || runtime.LastSeenRevision == "" {
|
||||
t.Errorf("replacement runtime = status:%q seen:%q, want attention with revision", runtime.SyncStatus, runtime.LastSeenRevision)
|
||||
}
|
||||
view, err := GetSource(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource() error = %v, want nil", err)
|
||||
}
|
||||
if view.LastSeen == nil || view.LastSeen.Label != "release/v1" {
|
||||
t.Errorf("LastSeen = %+v, want full tag with slash", view.LastSeen)
|
||||
}
|
||||
if err := preflightGitHubSyncConfirmation(ctx, source.ID, ""); err == nil || err.Error() != errPagesSourceConfirmationNeeded {
|
||||
t.Errorf("preflight without confirmation error = %v, want %q", err, errPagesSourceConfirmationNeeded)
|
||||
}
|
||||
if err := preflightGitHubSyncConfirmation(ctx, source.ID, runtime.LastSeenRevision); err != nil {
|
||||
t.Errorf("preflight exact confirmation error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckNotModifiedRefreshesRuntimeWithoutDeployment(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-304")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{NotModified: true, ETag: `"same"`}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called for 304 check")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
if _, err := checkGitHubSource(ctx, snapshot); err != nil {
|
||||
t.Fatalf("checkGitHubSource(304) error = %v, want nil", err)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.ETag != `"same"` || runtime.LastCheckedAt == nil || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
|
||||
t.Errorf("304 runtime = %+v, want refreshed timestamps/etag and released lease", runtime)
|
||||
}
|
||||
var deployments int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
|
||||
t.Fatalf("count deployments error = %v, want nil", err)
|
||||
}
|
||||
if deployments != 0 {
|
||||
t.Errorf("deployments after check = %d, want 0", deployments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubTargetMismatchPreservesAttentionAndExpeditesRecheck(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-target-mismatch")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
appliedRevision := strings.Repeat("a", 64)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||
"last_applied_revision": appliedRevision,
|
||||
"last_applied_detail": `{"provider":"github","release_id":"100","asset_id":"1","tag":"v1","asset_name":"dist.zip"}`,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed applied runtime error = %v, want nil", err)
|
||||
}
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{
|
||||
Release: githubrelease.Release{ID: "100", Tag: "v1"},
|
||||
Asset: githubrelease.Asset{
|
||||
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||
UpdatedAt: time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
RetryAt: &retryAt,
|
||||
}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called after target mismatch")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
outcome, err := syncGitHubSource(ctx, snapshot, pagesSourceCreatedBySystem, strings.Repeat("b", 64), "")
|
||||
if err != nil {
|
||||
t.Fatalf("syncGitHubSource(target mismatch) error = %v, want nil stale outcome", err)
|
||||
}
|
||||
if outcome == nil || !outcome.Stale {
|
||||
t.Errorf("syncGitHubSource(target mismatch) = %+v, want stale", outcome)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusAttention {
|
||||
t.Errorf("target mismatch SyncStatus = %q, want %q", runtime.SyncStatus, pagesSourceStatusAttention)
|
||||
}
|
||||
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
|
||||
t.Errorf("target mismatch NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
|
||||
}
|
||||
var deployments int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
|
||||
t.Fatalf("count mismatch deployments error = %v, want nil", err)
|
||||
}
|
||||
if deployments != 0 {
|
||||
t.Errorf("target mismatch deployments = %d, want 0", deployments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckLostLeaseReturnsStaleWithoutOverwritingRuntime(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-check-fence")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{}, errors.New("transient provider failure")
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
return nil, errors.New("unexpected")
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||
"lease_token": "new-owner",
|
||||
"lease_expires_at": time.Now().Add(time.Minute),
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"last_error": "new-owner-state",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("replace lease owner error = %v, want nil", err)
|
||||
}
|
||||
result, err := checkGitHubSource(ctx, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("checkGitHubSource(lost lease) error = %v, want stale no-op", err)
|
||||
}
|
||||
if result == nil || !result.Stale {
|
||||
t.Errorf("checkGitHubSource(lost lease) = %+v, want stale", result)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.LeaseToken != "new-owner" || runtime.LastError != "new-owner-state" || runtime.SyncStatus != pagesSourceStatusSyncing {
|
||||
t.Errorf("lost lease runtime = token:%q error:%q status:%q, want new owner state", runtime.LeaseToken, runtime.LastError, runtime.SyncStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckRateLimitUsesServerDeadlineAndSuppressesFastRetry(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-rate-limit")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
||||
Kind: githubrelease.ErrMetadata, StatusCode: 429, RetryAt: &retryAt,
|
||||
}
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
return nil, errors.New("unexpected")
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
_, err := checkGitHubSource(ctx, snapshot)
|
||||
if err == nil || err.Error() != errPagesSourceSyncFailed {
|
||||
t.Fatalf("checkGitHubSource(rate limit) error = %v, want safe sync failure", err)
|
||||
}
|
||||
if !shouldSkipGitHubActionRetry(err) {
|
||||
t.Error("shouldSkipGitHubActionRetry(rate limit) = false, want true")
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.SyncStatus != pagesSourceStatusFailed {
|
||||
t.Errorf("rate limit runtime = next:%v status:%q, want deadline >= %v and failed", runtime.NextCheckAt, runtime.SyncStatus, retryAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-check-target")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{
|
||||
Release: githubrelease.Release{ID: "1", Tag: "v1"},
|
||||
Asset: githubrelease.Asset{
|
||||
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
|
||||
},
|
||||
RetryAt: &retryAt,
|
||||
}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called after invalid check target")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
_, err := checkGitHubSource(ctx, snapshot)
|
||||
if err == nil || err.Error() != errPagesSourceDigestInvalid {
|
||||
t.Fatalf("checkGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
|
||||
}
|
||||
if !isPermanentSourceSyncError(err) {
|
||||
t.Error("invalid check target classification = retryable, want permanent")
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
|
||||
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
|
||||
t.Errorf(
|
||||
"invalid check target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
|
||||
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
|
||||
errPagesSourceDigestInvalid, retryAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSyncInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-sync-target")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{
|
||||
Release: githubrelease.Release{ID: "1", Tag: "v1"},
|
||||
Asset: githubrelease.Asset{
|
||||
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
|
||||
},
|
||||
RetryAt: &retryAt,
|
||||
}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called after invalid sync target")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
_, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
|
||||
if err == nil || err.Error() != errPagesSourceDigestInvalid {
|
||||
t.Fatalf("syncGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
|
||||
}
|
||||
if !isPermanentSourceSyncError(err) {
|
||||
t.Error("invalid sync target classification = retryable, want permanent")
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
|
||||
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
|
||||
t.Errorf(
|
||||
"invalid sync target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
|
||||
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
|
||||
errPagesSourceDigestInvalid, retryAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckHandlerSkipsProviderFastRetry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
retryDate bool
|
||||
}{
|
||||
{name: "bad request", status: http.StatusBadRequest},
|
||||
{name: "rate limited forbidden", status: http.StatusForbidden, retryDate: true},
|
||||
{name: "too many requests", status: http.StatusTooManyRequests, retryDate: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-handler-"+strings.ReplaceAll(test.name, " ", "-"))
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
var retryAt *time.Time
|
||||
if test.retryDate {
|
||||
deadline := time.Now().Add(2 * time.Hour).UTC()
|
||||
retryAt = &deadline
|
||||
}
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
||||
Kind: githubrelease.ErrMetadata, StatusCode: test.status, RetryAt: retryAt,
|
||||
}
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called after provider check failure")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
raw, err := json.Marshal(SourceActionPayload{
|
||||
SourceID: source.ID, ConfigVersion: source.ConfigVersion,
|
||||
Action: sourceActionCheck, Actor: "user:7",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(check payload) error = %v, want nil", err)
|
||||
}
|
||||
result, err := (&SourceActionHandler{}).Execute(ctx, raw)
|
||||
if result != nil || err == nil || !errors.Is(err, asynq.SkipRetry) {
|
||||
t.Fatalf("SourceActionHandler.Execute(status %d) = result:%+v error:%v, want SkipRetry", test.status, result, err)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
|
||||
t.Errorf("provider failure runtime = status:%q next:%v lease:%q", runtime.SyncStatus, runtime.NextCheckAt, runtime.LeaseToken)
|
||||
}
|
||||
if retryAt != nil && (runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(*retryAt)) {
|
||||
t.Errorf("provider failure NextCheckAt = %v, want deadline >= %v", runtime.NextCheckAt, *retryAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSyncActivatesWithMetadataRevisionAndPackageChecksum(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-sync")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
AssetName: "dist.zip",
|
||||
})
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "github-v1"})
|
||||
packageHash := sha256.Sum256(packageBytes)
|
||||
updatedAt := time.Date(2026, 7, 19, 11, 0, 0, 0, time.UTC)
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
release := githubrelease.Release{ID: "200", Tag: "release/v2"}
|
||||
asset := githubrelease.Asset{ID: "10", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt}
|
||||
client := &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{Release: release, Asset: asset, RetryAt: &retryAt}, nil
|
||||
},
|
||||
download: func(_ context.Context, request githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
path := filepath.Join(t.TempDir(), "download")
|
||||
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile(download) error = %v, want nil", err)
|
||||
}
|
||||
return &githubrelease.DownloadResult{
|
||||
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
useFakeGitHubReleaseClient(t, client)
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
outcome, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("syncGitHubSource() error = %v, want nil", err)
|
||||
}
|
||||
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
|
||||
t.Fatalf("syncGitHubSource() = %+v, want active deployment", outcome)
|
||||
}
|
||||
deployment, err := model.GetPagesDeploymentByID(ctx, outcome.Deployment.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", outcome.Deployment.ID, err)
|
||||
}
|
||||
if got, want := deployment.Checksum, hex.EncodeToString(packageHash[:]); got != want {
|
||||
t.Errorf("deployment Checksum = %q, want package hash %q", got, want)
|
||||
}
|
||||
if deployment.SourceRevision == nil || *deployment.SourceRevision == deployment.Checksum {
|
||||
t.Errorf("deployment SourceRevision = %v, want metadata revision distinct from package checksum", deployment.SourceRevision)
|
||||
}
|
||||
if got, want := deployment.SourceLabel, "release/v2"; got != want {
|
||||
t.Errorf("deployment SourceLabel = %q, want %q", got, want)
|
||||
}
|
||||
if deployment.SourceType != PagesSourceTypeGitHubRelease || deployment.TriggerType != pagesSourceTriggerManualSync ||
|
||||
deployment.CreatedBy != "user:7" {
|
||||
t.Errorf("deployment provenance = type:%q trigger:%q actor:%q", deployment.SourceType, deployment.TriggerType, deployment.CreatedBy)
|
||||
}
|
||||
if strings.Contains(deployment.SourceMeta, "http") || strings.Contains(deployment.SourceMeta, "token") {
|
||||
t.Errorf("deployment SourceMeta = %q, want no URL or token", deployment.SourceMeta)
|
||||
}
|
||||
if !strings.Contains(deployment.SourceMeta, `"tag":"release/v2"`) || strings.Contains(deployment.SourceMeta, `"label"`) {
|
||||
t.Errorf("deployment SourceMeta = %q, want provider-specific tag field", deployment.SourceMeta)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
|
||||
t.Errorf("sync runtime NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
|
||||
}
|
||||
|
||||
secondSnapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
second, err := syncGitHubSource(ctx, secondSnapshot, "user:7", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("syncGitHubSource(idempotent) error = %v, want nil", err)
|
||||
}
|
||||
if second == nil || !second.Reused || second.Deployment == nil || second.Deployment.ID != outcome.Deployment.ID {
|
||||
t.Errorf("syncGitHubSource(idempotent) = %+v, want reused deployment %d", second, outcome.Deployment.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSyncActivatesExactConfirmedReplacement(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-replacement")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
AssetName: "dist.zip",
|
||||
})
|
||||
release := githubrelease.Release{ID: "300", Tag: "v3"}
|
||||
asset := githubrelease.Asset{
|
||||
ID: "12", Name: "dist.zip", State: "uploaded",
|
||||
UpdatedAt: time.Date(2026, 7, 19, 13, 0, 0, 0, time.UTC),
|
||||
}
|
||||
target, err := buildGitHubSourceTarget(release, asset, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGitHubSourceTarget() error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||
"last_seen_revision": target.Revision,
|
||||
"last_seen_detail": target.DetailJSON,
|
||||
"last_applied_revision": strings.Repeat("a", 64),
|
||||
"last_applied_detail": `{"provider":"github","release_id":"300","asset_id":"11","tag":"v3","asset_name":"dist.zip"}`,
|
||||
"sync_status": pagesSourceStatusAttention,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed replacement cursor error = %v, want nil", err)
|
||||
}
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "confirmed-v3"})
|
||||
packageHash := sha256.Sum256(packageBytes)
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{Release: release, Asset: asset}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
path := filepath.Join(t.TempDir(), "confirmed.zip")
|
||||
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile(confirmed package) error = %v, want nil", err)
|
||||
}
|
||||
return &githubrelease.DownloadResult{
|
||||
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
outcome, err := syncGitHubSource(ctx, snapshot, "user:9", "", target.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("syncGitHubSource(confirmed replacement) error = %v, want nil", err)
|
||||
}
|
||||
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
|
||||
t.Fatalf("syncGitHubSource(confirmed replacement) = %+v, want active deployment", outcome)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastAppliedRevision != target.Revision {
|
||||
t.Errorf("confirmed replacement runtime = status:%q applied:%q, want idle/%q", runtime.SyncStatus, runtime.LastAppliedRevision, target.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceActionPayloadSeparatesSystemTargetAndUserConfirmation(t *testing.T) {
|
||||
handler := &SourceActionHandler{}
|
||||
revision := strings.Repeat("a", 64)
|
||||
invalid := []SourceActionPayload{
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", TargetRevision: revision},
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, ConfirmedRevision: revision},
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision, ConfirmedRevision: revision},
|
||||
}
|
||||
for _, payload := range invalid {
|
||||
raw, _ := json.Marshal(payload)
|
||||
if normalized, err := handler.ValidatePayload(raw); err == nil {
|
||||
t.Errorf("ValidatePayload(%+v) = %s, nil; want error", payload, normalized)
|
||||
}
|
||||
}
|
||||
valid := []SourceActionPayload{
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision},
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", ConfirmedRevision: revision},
|
||||
}
|
||||
for _, payload := range valid {
|
||||
raw, _ := json.Marshal(payload)
|
||||
if _, err := handler.ValidatePayload(raw); err != nil {
|
||||
t.Errorf("ValidatePayload(%+v) error = %v, want nil", payload, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubProviderErrorsMapToSafeRetryClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider error
|
||||
want string
|
||||
permanent bool
|
||||
skipRetry bool
|
||||
}{
|
||||
{
|
||||
name: "asset missing", provider: &githubrelease.Error{Kind: githubrelease.ErrAssetNotFound, StatusCode: 200},
|
||||
want: errPagesSourceReleaseNotFound, permanent: true, skipRetry: true,
|
||||
},
|
||||
{
|
||||
name: "digest mismatch", provider: &githubrelease.Error{Kind: githubrelease.ErrDigestMismatch, StatusCode: 200},
|
||||
want: errPagesSourceDigestMismatch, permanent: true, skipRetry: true,
|
||||
},
|
||||
{
|
||||
name: "rate limit", provider: &githubrelease.Error{
|
||||
Kind: githubrelease.ErrMetadata, StatusCode: 429,
|
||||
RetryAt: func() *time.Time { value := time.Now().Add(time.Hour); return &value }(),
|
||||
},
|
||||
want: errPagesSourceSyncFailed, permanent: false, skipRetry: true,
|
||||
},
|
||||
{
|
||||
name: "network", provider: &githubrelease.Error{Kind: githubrelease.ErrDownload},
|
||||
want: errPagesSourceSyncFailed, permanent: false, skipRetry: false,
|
||||
},
|
||||
{
|
||||
name: "forbidden without retry", provider: &githubrelease.Error{Kind: githubrelease.ErrMetadata, StatusCode: 403},
|
||||
want: errPagesSourceSyncFailed, permanent: true, skipRetry: true,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
domainErr := githubSourceDomainError(test.provider)
|
||||
if got := domainErr.Error(); got != test.want {
|
||||
t.Errorf("githubSourceDomainError() = %q, want %q", got, test.want)
|
||||
}
|
||||
var typedDomainError *githubSourceProviderDomainError
|
||||
if !errors.As(domainErr, &typedDomainError) {
|
||||
t.Fatalf("githubSourceDomainError() type = %T, want *githubSourceProviderDomainError", domainErr)
|
||||
}
|
||||
if got := typedDomainError.permanent; got != test.permanent {
|
||||
t.Errorf("githubSourceProviderDomainError.permanent = %t, want %t", got, test.permanent)
|
||||
}
|
||||
if got := shouldSkipGitHubActionRetry(domainErr); got != test.skipRetry {
|
||||
t.Errorf("shouldSkipGitHubActionRetry() = %t, want %t", got, test.skipRetry)
|
||||
}
|
||||
if strings.Contains(domainErr.Error(), "status=") || strings.Contains(domainErr.Error(), "repo=") {
|
||||
t.Errorf("githubSourceDomainError() = %q, want stable Pages message", domainErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSyncRejectsStaleConfirmationWithoutChangingActive(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-stale")
|
||||
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
asset := githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: time.Now().UTC()}
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{Release: githubrelease.Release{ID: "1", Tag: "v1"}, Asset: asset}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called for stale confirmation")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
_, err := syncGitHubSource(ctx, snapshot, "user:1", "", strings.Repeat("f", 64))
|
||||
if err == nil || err.Error() != errPagesSourceConfirmationStale {
|
||||
t.Errorf("syncGitHubSource(stale confirmation) error = %v, want %q", err, errPagesSourceConfirmationStale)
|
||||
}
|
||||
storedProject, loadErr := model.GetPagesProjectByID(ctx, project.ID)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("GetPagesProjectByID() error = %v, want nil", loadErr)
|
||||
}
|
||||
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != oldActive.ID {
|
||||
t.Errorf("ActiveDeploymentID = %v, want old active %d", storedProject.ActiveDeploymentID, oldActive.ID)
|
||||
}
|
||||
}
|
||||
@@ -822,8 +822,8 @@ func fenceDeploymentActivationSource(
|
||||
audit.SourceIdentity = state.Source.SourceIdentity
|
||||
audit.AutoDisabled = state.Source.AutoUpdateEnabled
|
||||
if err := tx.Model(state.Source).Updates(map[string]any{
|
||||
"config_version": state.Source.ConfigVersion + 1,
|
||||
"auto_update_enabled": false,
|
||||
sourceColumnConfigVersion: state.Source.ConfigVersion + 1,
|
||||
sourceColumnAutoUpdateEnabled: false,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -856,8 +856,8 @@ func switchActiveDeploymentTx(
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(deployment).Updates(map[string]any{
|
||||
"status": model.PagesDeploymentStatusActive,
|
||||
"activated_at": &now,
|
||||
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
|
||||
"activated_at": &now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -45,6 +45,17 @@ func handleSourceLogicError(c *gin.Context, err error) bool {
|
||||
errPagesSourceRemoteURLMode,
|
||||
errPagesSourceRemoteURLInvalid,
|
||||
errPagesSourceNetworkPolicy,
|
||||
errPagesSourceGitHubFields,
|
||||
errPagesSourceRepositoryInvalid,
|
||||
errPagesSourceSelectorInvalid,
|
||||
errPagesSourceAssetNameInvalid,
|
||||
errPagesSourceCheckInterval,
|
||||
errPagesSourceAutoNotAvailable,
|
||||
errPagesSourceReleaseNotFound,
|
||||
errPagesSourceDigestInvalid,
|
||||
errPagesSourceDigestMismatch,
|
||||
errPagesSourceConfirmationNeeded,
|
||||
errPagesSourceConfirmationStale,
|
||||
errPagesSourceCheckUnsupported,
|
||||
errPagesSourceActionInvalid:
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
@@ -253,7 +264,7 @@ func GetSourceHandler(c *gin.Context) {
|
||||
|
||||
// UpdateSourceHandler 创建或更新 Pages 项目部署源。
|
||||
// @Summary 更新 Pages 部署源
|
||||
// @Description Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
|
||||
// @Description 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
|
||||
// @Tags openflare-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -275,7 +286,11 @@ func UpdateSourceHandler(c *gin.Context) {
|
||||
if !decodeStrictJSON(c, &input, false) {
|
||||
return
|
||||
}
|
||||
result, err := UpdateSource(c.Request.Context(), projectID, input)
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := UpdateSourceAs(c.Request.Context(), projectID, input, actor)
|
||||
if handleSourceLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
@@ -309,7 +324,7 @@ func DeleteSourceHandler(c *gin.Context) {
|
||||
|
||||
// CheckSourceHandler 请求检查 Pages 部署源。
|
||||
// @Summary 检查 Pages 部署源
|
||||
// @Description Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
|
||||
// @Description 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
PagesSourceTypeManual = "manual"
|
||||
// PagesSourceTypeRemoteURL represents a persisted artifact URL.
|
||||
PagesSourceTypeRemoteURL = "remote_url"
|
||||
// PagesSourceTypeGitHubRelease is reserved for Phase 2.
|
||||
// PagesSourceTypeGitHubRelease represents a public GitHub Release asset.
|
||||
PagesSourceTypeGitHubRelease = "github_release"
|
||||
|
||||
pagesSourceStatusIdle = "idle"
|
||||
@@ -37,6 +37,10 @@ const (
|
||||
pagesSourceStatusAttention = "attention"
|
||||
|
||||
defaultRemoteAssetLabel = "pages-package"
|
||||
defaultGitHubAssetName = "dist.zip"
|
||||
defaultCheckInterval = 60
|
||||
minimumCheckInterval = 5
|
||||
maximumCheckInterval = 1440
|
||||
)
|
||||
|
||||
// SourceUpdateInput is the discriminated source configuration payload.
|
||||
@@ -72,7 +76,7 @@ type SourceView struct {
|
||||
ReleaseSelector string `json:"release_selector,omitempty"`
|
||||
ReleaseTag string `json:"release_tag,omitempty"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled,omitempty"`
|
||||
AutoUpdateEnabled *bool `json:"auto_update_enabled,omitempty"`
|
||||
CheckIntervalMinutes int `json:"check_interval_minutes,omitempty"`
|
||||
SyncStatus string `json:"sync_status,omitempty"`
|
||||
UpdateAvailable bool `json:"update_available,omitempty"`
|
||||
@@ -99,10 +103,15 @@ type SourceUpdateResult struct {
|
||||
}
|
||||
|
||||
type sourceDetail struct {
|
||||
Provider string `json:"provider"`
|
||||
Label string `json:"label"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
ReleaseID string `json:"release_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
LegacyLabel string `json:"label,omitempty"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
ReleaseID string `json:"release_id,omitempty"`
|
||||
AssetID string `json:"asset_id,omitempty"`
|
||||
AssetUpdatedAt string `json:"asset_updated_at,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
}
|
||||
|
||||
type remoteSourceConfig struct {
|
||||
@@ -126,14 +135,43 @@ func GetSource(ctx context.Context, projectID uint) (*SourceView, error) {
|
||||
return buildSourceView(source, runtime)
|
||||
}
|
||||
|
||||
// UpdateSource creates or updates a Remote URL source and its 1:1 runtime row.
|
||||
// UpdateSource creates or updates a source. Direct callers use the system actor;
|
||||
// HTTP handlers should call UpdateSourceAs so the initial check is auditable.
|
||||
func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput) (*SourceUpdateResult, error) {
|
||||
if err := validateRemoteSourceInput(input); err != nil {
|
||||
return UpdateSourceAs(ctx, projectID, input, pagesSourceCreatedBySystem)
|
||||
}
|
||||
|
||||
// UpdateSourceAs persists source configuration and queues the first GitHub check
|
||||
// after commit when the GitHub configuration was materially changed.
|
||||
func UpdateSourceAs(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
input SourceUpdateInput,
|
||||
actor string,
|
||||
) (*SourceUpdateResult, error) {
|
||||
if !validPagesSourceActor(actor) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if err := validateSourceUpdateInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changed := false
|
||||
var persistedSource model.PagesProjectSource
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return updateRemoteSourceTx(tx, projectID, input)
|
||||
var err error
|
||||
switch strings.TrimSpace(input.SourceType) {
|
||||
case PagesSourceTypeRemoteURL:
|
||||
changed, err = updateRemoteSourceTx(tx, projectID, input)
|
||||
case PagesSourceTypeGitHubRelease:
|
||||
changed, err = updateGitHubSourceTx(tx, projectID, input)
|
||||
default:
|
||||
err = errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if err != nil || !changed || strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
||||
return err
|
||||
}
|
||||
return tx.Where("project_id = ?", projectID).First(&persistedSource).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -143,24 +181,34 @@ func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SourceUpdateResult{Source: view, Warning: ""}, nil
|
||||
result := &SourceUpdateResult{Source: view, Warning: ""}
|
||||
if changed && strings.TrimSpace(input.SourceType) == PagesSourceTypeGitHubRelease {
|
||||
receipt, dispatchErr := dispatchSourceActionSnapshot(ctx, persistedSource, sourceActionCheck, actor, "", "", "manual")
|
||||
if dispatchErr != nil {
|
||||
result.Warning = errPagesSourceInitialCheckWarning
|
||||
markInitialCheckDispatchFailed(ctx, persistedSource.ID, persistedSource.ConfigVersion)
|
||||
} else {
|
||||
result.CheckTask = receipt
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) error {
|
||||
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
|
||||
var project model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
config, err := buildRemoteSourceConfig(existing, hasExisting, input)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if !hasExisting {
|
||||
return createRemoteSourceTx(tx, projectID, config)
|
||||
return true, createRemoteSourceTx(tx, projectID, config)
|
||||
}
|
||||
return updateExistingRemoteSourceTx(tx, existing, config)
|
||||
}
|
||||
@@ -227,33 +275,33 @@ func updateExistingRemoteSourceTx(
|
||||
tx *gorm.DB,
|
||||
existing *model.PagesProjectSource,
|
||||
config remoteSourceConfig,
|
||||
) error {
|
||||
) (bool, error) {
|
||||
if !remoteSourceConfigChanged(existing, config) {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("source_id = ?", existing.ID).
|
||||
First(&runtime).Error; err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
identityChanged := existing.SourceIdentity != config.Identity
|
||||
if err := tx.Model(existing).Updates(map[string]any{
|
||||
"source_type": PagesSourceTypeRemoteURL,
|
||||
"remote_url": config.URL,
|
||||
"remote_network_policy": config.Policy,
|
||||
"github_repository": "",
|
||||
"release_selector": "",
|
||||
"release_tag": "",
|
||||
"asset_name": "",
|
||||
"auto_update_enabled": false,
|
||||
"check_interval_minutes": 0,
|
||||
"config_version": existing.ConfigVersion + 1,
|
||||
"source_identity": config.Identity,
|
||||
"source_type": PagesSourceTypeRemoteURL,
|
||||
"remote_url": config.URL,
|
||||
"remote_network_policy": config.Policy,
|
||||
"github_repository": "",
|
||||
"release_selector": "",
|
||||
"release_tag": "",
|
||||
"asset_name": "",
|
||||
sourceColumnAutoUpdateEnabled: false,
|
||||
"check_interval_minutes": 0,
|
||||
sourceColumnConfigVersion: existing.ConfigVersion + 1,
|
||||
"source_identity": config.Identity,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
return resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
|
||||
return true, resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
|
||||
}
|
||||
|
||||
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
|
||||
@@ -328,6 +376,19 @@ func validateRemoteSourceInput(input SourceUpdateInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSourceUpdateInput(input SourceUpdateInput) error {
|
||||
switch strings.TrimSpace(input.SourceType) {
|
||||
case PagesSourceTypeRemoteURL:
|
||||
return validateRemoteSourceInput(input)
|
||||
case PagesSourceTypeGitHubRelease:
|
||||
return validateGitHubSourceInput(input)
|
||||
case "":
|
||||
return errors.New(errPagesSourceTypeRequired)
|
||||
default:
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveUpdatedRemoteURL(existing *model.PagesProjectSource, hasExisting bool, input SourceUpdateInput) (string, error) {
|
||||
if input.RemoteURLSet {
|
||||
return strings.TrimSpace(input.RemoteURL), nil
|
||||
@@ -430,7 +491,8 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
|
||||
view.ReleaseSelector = source.ReleaseSelector
|
||||
view.ReleaseTag = source.ReleaseTag
|
||||
view.AssetName = source.AssetName
|
||||
view.AutoUpdateEnabled = source.AutoUpdateEnabled
|
||||
autoUpdateEnabled := source.AutoUpdateEnabled
|
||||
view.AutoUpdateEnabled = &autoUpdateEnabled
|
||||
view.CheckIntervalMinutes = source.CheckIntervalMinutes
|
||||
default:
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
@@ -441,7 +503,7 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
|
||||
func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
||||
detail := sourceDetail{}
|
||||
_ = unmarshalSourceDetail(detailJSON, &detail)
|
||||
label := strings.TrimSpace(detail.Label)
|
||||
label := sourceDetailLabel(detail)
|
||||
if label == "" {
|
||||
label = defaultRemoteAssetLabel
|
||||
}
|
||||
@@ -452,6 +514,19 @@ func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceDetailLabel(detail sourceDetail) string {
|
||||
if detail.Provider == githubSourceDetailProvider {
|
||||
if label := strings.TrimSpace(detail.Tag); label != "" {
|
||||
return label
|
||||
}
|
||||
return strings.TrimSpace(detail.LegacyLabel)
|
||||
}
|
||||
if label := strings.TrimSpace(detail.DisplayName); label != "" {
|
||||
return label
|
||||
}
|
||||
return strings.TrimSpace(detail.LegacyLabel)
|
||||
}
|
||||
|
||||
func unmarshalSourceDetail(raw string, detail *sourceDetail) error {
|
||||
if detail == nil || strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
|
||||
@@ -24,10 +24,14 @@ const (
|
||||
sourceRuntimeErrorMaxBytes = 512
|
||||
sourceRevisionHexLength = 64
|
||||
|
||||
sourceColumnAutoUpdateEnabled = "auto_update_enabled"
|
||||
sourceColumnConfigVersion = "config_version"
|
||||
sourceRuntimeColumnSyncStatus = "sync_status"
|
||||
sourceRuntimeColumnLastError = "last_error"
|
||||
sourceRuntimeColumnLastCheckedAt = "last_checked_at"
|
||||
sourceRuntimeColumnLeaseToken = "lease_token"
|
||||
sourceRuntimeColumnLeaseExpiresAt = "lease_expires_at"
|
||||
pagesDeploymentColumnStatus = "status"
|
||||
)
|
||||
|
||||
type sourceLeaseOutcome string
|
||||
@@ -50,6 +54,16 @@ type sourceExecutionSnapshot struct {
|
||||
SourceIdentity string
|
||||
RemoteURL string
|
||||
RemoteNetworkPolicy string
|
||||
GitHubRepository string
|
||||
ReleaseSelector string
|
||||
ReleaseTag string
|
||||
AssetName string
|
||||
CheckIntervalMinutes int
|
||||
ETag string
|
||||
LastSeenRevision string
|
||||
LastSeenDetail string
|
||||
LastAppliedRevision string
|
||||
LastAppliedDetail string
|
||||
RootDir string
|
||||
EntryFile string
|
||||
LeaseToken string
|
||||
@@ -161,6 +175,16 @@ func loadSourceExecutionSnapshot(
|
||||
SourceIdentity: source.SourceIdentity,
|
||||
RemoteURL: source.RemoteURL,
|
||||
RemoteNetworkPolicy: source.RemoteNetworkPolicy,
|
||||
GitHubRepository: source.GitHubRepository,
|
||||
ReleaseSelector: source.ReleaseSelector,
|
||||
ReleaseTag: source.ReleaseTag,
|
||||
AssetName: source.AssetName,
|
||||
CheckIntervalMinutes: source.CheckIntervalMinutes,
|
||||
ETag: runtime.ETag,
|
||||
LastSeenRevision: runtime.LastSeenRevision,
|
||||
LastSeenDetail: runtime.LastSeenDetail,
|
||||
LastAppliedRevision: runtime.LastAppliedRevision,
|
||||
LastAppliedDetail: runtime.LastAppliedDetail,
|
||||
RootDir: project.RootDir,
|
||||
EntryFile: project.EntryFile,
|
||||
LeaseToken: token,
|
||||
|
||||
@@ -226,12 +226,14 @@ func syncRemoteSource(
|
||||
ctx,
|
||||
snapshot,
|
||||
prepared.Candidate.Checksum,
|
||||
prepared.Candidate.Checksum,
|
||||
prepared.Detail,
|
||||
prepared.DetailJSON,
|
||||
actor,
|
||||
prepared.Manifest,
|
||||
ingestState.Result,
|
||||
ingestState.HasIngest,
|
||||
nil,
|
||||
)
|
||||
ingestState.Referenced = referenced
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
@@ -288,7 +290,7 @@ func prepareRemoteSource(
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: safeRemoteSourceLabel(candidate.SafeLabel)}
|
||||
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: safeRemoteSourceLabel(candidate.SafeLabel)}
|
||||
detailJSON, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
@@ -336,7 +338,7 @@ func resolveSourceIngest(
|
||||
prepared.Candidate.Checksum,
|
||||
snapshot.ProjectID,
|
||||
snapshot.SourceID,
|
||||
prepared.Detail.Label,
|
||||
sourceDetailLabel(prepared.Detail),
|
||||
prepared.Candidate.Format,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -384,12 +386,14 @@ func commitSourceDeployment(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult upload.IngestResult,
|
||||
hasIngest bool,
|
||||
nextCheckNotBefore *time.Time,
|
||||
) (*model.PagesDeployment, bool, bool, error) {
|
||||
if snapshot == nil || manifest == nil {
|
||||
return nil, false, false, errors.New(errPagesSourceSyncFailed)
|
||||
@@ -403,7 +407,7 @@ func commitSourceDeployment(
|
||||
return err
|
||||
}
|
||||
target, targetReused, err := resolveSourceDeploymentTx(
|
||||
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
|
||||
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -417,7 +421,7 @@ func commitSourceDeployment(
|
||||
if err := refreshSourceCommitLease(state, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON); err != nil {
|
||||
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON, nextCheckNotBefore); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = *target
|
||||
@@ -491,6 +495,7 @@ func resolveSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
@@ -515,7 +520,7 @@ func resolveSourceDeploymentTx(
|
||||
return nil, false, errSourceFinalFence
|
||||
}
|
||||
return createSourceDeploymentTx(
|
||||
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult,
|
||||
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -523,6 +528,7 @@ func createSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
@@ -541,7 +547,7 @@ func createSourceDeploymentTx(
|
||||
target := &model.PagesDeployment{
|
||||
ProjectID: state.Project.ID,
|
||||
DeploymentNumber: maxNumber + 1,
|
||||
Checksum: revision,
|
||||
Checksum: packageChecksum,
|
||||
Status: model.PagesDeploymentStatusUploaded,
|
||||
UploadID: ingestResult.Upload.ID,
|
||||
FileCount: manifest.FileCount,
|
||||
@@ -550,7 +556,7 @@ func createSourceDeploymentTx(
|
||||
SourceType: state.Source.SourceType,
|
||||
SourceIdentity: &identity,
|
||||
SourceRevision: &revisionValue,
|
||||
SourceLabel: detail.Label,
|
||||
SourceLabel: sourceDetailLabel(detail),
|
||||
SourceMeta: detailJSON,
|
||||
TriggerType: pagesSourceTriggerManualSync,
|
||||
}
|
||||
@@ -629,6 +635,7 @@ func activateSourceDeploymentTx(
|
||||
target *model.PagesDeployment,
|
||||
revision string,
|
||||
detailJSON string,
|
||||
nextCheckNotBefore *time.Time,
|
||||
) error {
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", state.Project.ID).
|
||||
@@ -636,8 +643,8 @@ func activateSourceDeploymentTx(
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(target).Updates(map[string]any{
|
||||
"status": model.PagesDeploymentStatusActive,
|
||||
"activated_at": &state.Now,
|
||||
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
|
||||
"activated_at": &state.Now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -645,6 +652,15 @@ func activateSourceDeploymentTx(
|
||||
return err
|
||||
}
|
||||
finishedAt := sourceCommitNow()
|
||||
var nextCheckAt any
|
||||
if state.Source.SourceType == PagesSourceTypeGitHubRelease &&
|
||||
state.Source.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
next := nextGitHubCheckAt(finishedAt, state.Source.ID, state.Source.CheckIntervalMinutes)
|
||||
if nextCheckNotBefore != nil && nextCheckNotBefore.After(next) {
|
||||
next = nextCheckNotBefore.UTC()
|
||||
}
|
||||
nextCheckAt = &next
|
||||
}
|
||||
result := tx.Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?",
|
||||
state.Runtime.SourceID,
|
||||
@@ -658,9 +674,9 @@ func activateSourceDeploymentTx(
|
||||
"last_applied_detail": detailJSON,
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
|
||||
sourceRuntimeColumnLastError: "",
|
||||
"last_checked_at": &finishedAt,
|
||||
sourceRuntimeColumnLastCheckedAt: &finishedAt,
|
||||
"last_synced_at": &finishedAt,
|
||||
"next_check_at": nil,
|
||||
"next_check_at": nextCheckAt,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
})
|
||||
|
||||
@@ -388,12 +388,14 @@ func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) {
|
||||
ctx,
|
||||
snapshot,
|
||||
deployment.Checksum,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: deployment.SourceLabel},
|
||||
deployment.Checksum,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: deployment.SourceLabel},
|
||||
deployment.SourceMeta,
|
||||
"user:5",
|
||||
&deploymentManifest{},
|
||||
upload.IngestResult{},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if !errors.Is(err, errSourceFinalFence) {
|
||||
t.Fatalf("commitSourceDeployment(expired after upload lock) error = %v, want %v", err, errSourceFinalFence)
|
||||
@@ -552,7 +554,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
||||
SourceIdentity: &identity,
|
||||
SourceRevision: &revision,
|
||||
SourceLabel: "deleted.zip",
|
||||
SourceMeta: `{"provider":"remote_url","label":"deleted.zip"}`,
|
||||
SourceMeta: `{"provider":"remote_url","display_name":"deleted.zip"}`,
|
||||
TriggerType: pagesSourceTriggerManualSync,
|
||||
}
|
||||
if err := db.DB(ctx).Create(deployment).Error; err != nil {
|
||||
@@ -575,12 +577,14 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
||||
ctx,
|
||||
snapshot,
|
||||
revision,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: "deleted.zip"},
|
||||
`{"provider":"remote_url","label":"deleted.zip"}`,
|
||||
revision,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: "deleted.zip"},
|
||||
`{"provider":"remote_url","display_name":"deleted.zip"}`,
|
||||
"user:1",
|
||||
manifest,
|
||||
upload.IngestResult{},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if !errors.Is(err, errSourceFinalFence) {
|
||||
t.Errorf("commitSourceDeployment(deleted upload) error = %v, want %v", err, errSourceFinalFence)
|
||||
|
||||
@@ -77,7 +77,11 @@ func (h *SourceActionHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
(input.Action != sourceActionCheck && input.Action != sourceActionSync) ||
|
||||
!validPagesSourceActor(input.Actor) ||
|
||||
!validOptionalSourceRevision(input.TargetRevision) ||
|
||||
!validOptionalSourceRevision(input.ConfirmedRevision) {
|
||||
!validOptionalSourceRevision(input.ConfirmedRevision) ||
|
||||
(input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "")) ||
|
||||
(input.TargetRevision != "" && input.ConfirmedRevision != "") ||
|
||||
(input.TargetRevision != "" && input.Actor != pagesSourceCreatedBySystem) ||
|
||||
(input.ConfirmedRevision != "" && !strings.HasPrefix(input.Actor, "user:")) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
return json.Marshal(input)
|
||||
@@ -110,10 +114,13 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
||||
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
||||
return nil, task.PermanentError(errPagesSourceCheckUnsupported)
|
||||
}
|
||||
if source.SourceType != PagesSourceTypeRemoteURL {
|
||||
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
||||
return nil, task.PermanentError(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if input.TargetRevision != "" || input.ConfirmedRevision != "" {
|
||||
if source.SourceType == PagesSourceTypeRemoteURL && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
if input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
|
||||
@@ -132,10 +139,43 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
|
||||
result, err := syncRemoteSource(ctx, snapshot, input.Actor)
|
||||
if input.Action == sourceActionCheck {
|
||||
return executeGitHubCheckAction(ctx, snapshot)
|
||||
}
|
||||
return executeSourceSyncAction(ctx, &source, snapshot, input)
|
||||
}
|
||||
|
||||
func executeGitHubCheckAction(ctx context.Context, snapshot *sourceExecutionSnapshot) (*task.TaskResult, error) {
|
||||
checkResult, checkErr := checkGitHubSource(ctx, snapshot)
|
||||
if checkErr != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] check failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, checkErr)
|
||||
if isPermanentSourceSyncError(checkErr) || shouldSkipGitHubActionRetry(checkErr) {
|
||||
return nil, task.PermanentError(checkErr.Error())
|
||||
}
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if checkResult == nil || checkResult.Stale {
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
return &task.TaskResult{Message: checkResult.Message, Detail: checkResult.Detail}, nil
|
||||
}
|
||||
|
||||
func executeSourceSyncAction(
|
||||
ctx context.Context,
|
||||
source *model.PagesProjectSource,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
input SourceActionPayload,
|
||||
) (*task.TaskResult, error) {
|
||||
var result *sourceSyncOutcome
|
||||
var err error
|
||||
if source.SourceType == PagesSourceTypeGitHubRelease {
|
||||
result, err = syncGitHubSource(ctx, snapshot, input.Actor, input.TargetRevision, input.ConfirmedRevision)
|
||||
} else {
|
||||
result, err = syncRemoteSource(ctx, snapshot, input.Actor)
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] sync failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, err)
|
||||
if isPermanentSourceSyncError(err) {
|
||||
if isPermanentSourceSyncError(err) || shouldSkipGitHubActionRetry(err) {
|
||||
return nil, task.PermanentError(errPagesSourceSyncFailed)
|
||||
}
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
@@ -191,13 +231,19 @@ func isPermanentSourceSyncError(err error) bool {
|
||||
}
|
||||
message := err.Error()
|
||||
return strings.Contains(message, errPagesPackageUnsupported) ||
|
||||
strings.Contains(message, errPagesPackageURLTooLarge) ||
|
||||
strings.Contains(message, errPagesPackageInvalid) ||
|
||||
strings.Contains(message, errPagesPackageEmpty) ||
|
||||
strings.Contains(message, errPagesPackageExtractedTooLarge) ||
|
||||
strings.Contains(message, errPagesPackageFileTooLarge) ||
|
||||
strings.Contains(message, errPagesEntryFileMissing) ||
|
||||
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
|
||||
strings.Contains(message, errPagesSourceNetworkPolicy)
|
||||
strings.Contains(message, errPagesSourceNetworkPolicy) ||
|
||||
strings.Contains(message, errPagesSourceReleaseNotFound) ||
|
||||
strings.Contains(message, errPagesSourceDigestInvalid) ||
|
||||
strings.Contains(message, errPagesSourceDigestMismatch) ||
|
||||
strings.Contains(message, errPagesSourceConfirmationNeeded) ||
|
||||
strings.Contains(message, errPagesSourceConfirmationStale)
|
||||
}
|
||||
|
||||
// DispatchSourceAction performs API preflight and enqueues a credential-free action.
|
||||
@@ -207,8 +253,20 @@ func DispatchSourceAction(
|
||||
action string,
|
||||
actor string,
|
||||
confirmedRevision string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
return dispatchSourceActionByProject(ctx, projectID, action, actor, "", confirmedRevision)
|
||||
}
|
||||
|
||||
func dispatchSourceActionByProject(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
action string,
|
||||
actor string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
action = strings.TrimSpace(action)
|
||||
targetRevision = strings.TrimSpace(targetRevision)
|
||||
confirmedRevision = strings.TrimSpace(confirmedRevision)
|
||||
if action != sourceActionCheck && action != sourceActionSync {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
@@ -224,14 +282,8 @@ func DispatchSourceAction(
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if source.SourceType != PagesSourceTypeRemoteURL {
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if action == sourceActionCheck {
|
||||
return nil, errors.New(errPagesSourceCheckUnsupported)
|
||||
}
|
||||
if confirmedRevision != "" {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
if err := validateSourceActionPreflight(ctx, &source, action, targetRevision, confirmedRevision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
busy, err := sourceLeaseIsBusy(ctx, source.ID)
|
||||
if err != nil {
|
||||
@@ -240,14 +292,58 @@ func DispatchSourceAction(
|
||||
if busy {
|
||||
return nil, errors.New(errPagesSourceActionBusy)
|
||||
}
|
||||
return dispatchSourceActionSnapshot(ctx, source, action, actor, targetRevision, confirmedRevision, "manual")
|
||||
}
|
||||
|
||||
func validateSourceActionPreflight(
|
||||
ctx context.Context,
|
||||
source *model.PagesProjectSource,
|
||||
action string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) error {
|
||||
if source == nil {
|
||||
return errors.New(errPagesSourceNotFound)
|
||||
}
|
||||
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
||||
return errors.New(errPagesSourceCheckUnsupported)
|
||||
}
|
||||
if source.SourceType == PagesSourceTypeRemoteURL && (targetRevision != "" || confirmedRevision != "") {
|
||||
return errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if action == sourceActionCheck && (targetRevision != "" || confirmedRevision != "") {
|
||||
return errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if source.SourceType == PagesSourceTypeGitHubRelease && action == sourceActionSync {
|
||||
if err := preflightGitHubSyncConfirmation(ctx, source.ID, confirmedRevision); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dispatchSourceActionSnapshot(
|
||||
ctx context.Context,
|
||||
source model.PagesProjectSource,
|
||||
action string,
|
||||
actor string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
triggeredBy string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
if task.AsynqClient == nil {
|
||||
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
||||
}
|
||||
handler := &SourceActionHandler{}
|
||||
rawPayload, err := json.Marshal(SourceActionPayload{
|
||||
SourceID: source.ID,
|
||||
ConfigVersion: source.ConfigVersion,
|
||||
Action: action,
|
||||
Actor: actor,
|
||||
TargetRevision: "",
|
||||
TargetRevision: targetRevision,
|
||||
ConfirmedRevision: confirmedRevision,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -257,9 +353,9 @@ func DispatchSourceAction(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, "manual")
|
||||
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, triggeredBy)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", projectID, source.ID, action, err)
|
||||
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", source.ProjectID, source.ID, action, err)
|
||||
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
||||
}
|
||||
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
|
||||
|
||||
@@ -30,6 +30,33 @@ func setupPagesSourceTest(t *testing.T) context.Context {
|
||||
return t.Context()
|
||||
}
|
||||
|
||||
func TestRevisionViewReadsLegacySourceDetailLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
detail string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "remote",
|
||||
detail: `{"provider":"remote_url","label":"legacy.zip"}`,
|
||||
want: "legacy.zip",
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
detail: `{"provider":"github","label":"v1.2.3","asset_name":"dist.zip"}`,
|
||||
want: "v1.2.3",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
view := revisionView(strings.Repeat("a", 64), test.detail)
|
||||
if view.Label != test.want {
|
||||
t.Errorf("revisionView(%s).Label = %q, want %q", test.name, view.Label, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustCreatePagesSourceProject(t *testing.T, ctx context.Context, slug string) *model.PagesProject {
|
||||
t.Helper()
|
||||
view, err := CreateProject(ctx, Input{
|
||||
@@ -203,9 +230,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
Where("source_id = ?", source.ID).
|
||||
Updates(map[string]any{
|
||||
"last_seen_revision": seenRevision,
|
||||
"last_seen_detail": `{"provider":"remote_url","label":"new.zip"}`,
|
||||
"last_seen_detail": `{"provider":"remote_url","display_name":"new.zip"}`,
|
||||
"last_applied_revision": appliedRevision,
|
||||
"last_applied_detail": `{"provider":"remote_url","label":"old.zip"}`,
|
||||
"last_applied_detail": `{"provider":"remote_url","display_name":"old.zip"}`,
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"lease_token": "in-flight",
|
||||
"lease_expires_at": &future,
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package githubrelease resolves and downloads public GitHub Release assets.
|
||||
// It deliberately does not know about Pages projects, deployments or runtime
|
||||
// state so other callers can reuse the same constrained HTTP contract.
|
||||
package githubrelease
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// APIVersion is the GitHub REST API contract used by this package.
|
||||
APIVersion = "2026-03-10"
|
||||
|
||||
// SelectorLatest uses GitHub's repository latest-release endpoint.
|
||||
SelectorLatest Selector = "latest"
|
||||
// SelectorTag resolves one exact GitHub release tag.
|
||||
SelectorTag Selector = "tag"
|
||||
|
||||
defaultAPIBaseURL = "https://api.github.com"
|
||||
defaultUserAgent = "OpenFlare-GitHubRelease/1.0"
|
||||
metadataAccept = "application/vnd.github+json"
|
||||
assetAccept = "application/octet-stream"
|
||||
maxMetadataBytes = 4 << 20
|
||||
maxAssetErrorNames = 10
|
||||
maxSafeTextBytes = 255
|
||||
maxSafeAssetNameLen = 96
|
||||
maxDigestBytes = 96
|
||||
maxETagBytes = 512
|
||||
safePartsCapacity = 6
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidRequest = errors.New("GitHub Release 请求参数无效")
|
||||
errMetadata = errors.New("GitHub Release 元数据响应无效")
|
||||
errAssetMissing = errors.New("GitHub Release 中未找到指定的已上传 asset")
|
||||
errDownload = errors.New("GitHub Release asset 下载失败")
|
||||
errTooLarge = errors.New("GitHub Release asset 超过大小限制")
|
||||
errEmptyAsset = errors.New("GitHub Release asset 内容为空")
|
||||
errDigest = errors.New("GitHub Release asset digest 无效或校验失败")
|
||||
errCleanup = errors.New("GitHub Release 临时文件清理失败")
|
||||
|
||||
ownerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
|
||||
repoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
hexPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidRequest identifies caller configuration errors.
|
||||
ErrInvalidRequest = errInvalidRequest
|
||||
// ErrMetadata identifies malformed, unavailable or failed Release metadata requests.
|
||||
ErrMetadata = errMetadata
|
||||
// ErrAssetNotFound identifies an otherwise valid Release without the exact uploaded asset.
|
||||
ErrAssetNotFound = errAssetMissing
|
||||
// ErrDownload identifies network or HTTP failures while downloading an asset.
|
||||
ErrDownload = errDownload
|
||||
// ErrAssetTooLarge identifies assets that exceed the caller's hard byte limit.
|
||||
ErrAssetTooLarge = errTooLarge
|
||||
// ErrEmptyAsset identifies an empty downloaded asset.
|
||||
ErrEmptyAsset = errEmptyAsset
|
||||
// ErrDigestMismatch identifies malformed or mismatched declared SHA-256 digests.
|
||||
ErrDigestMismatch = errDigest
|
||||
)
|
||||
|
||||
// Selector identifies GitHub's own latest endpoint or one exact tag.
|
||||
type Selector string
|
||||
|
||||
// ResolveRequest describes one public repository release asset lookup.
|
||||
type ResolveRequest struct {
|
||||
Repository string
|
||||
Selector Selector
|
||||
Tag string
|
||||
AssetName string
|
||||
ETag string
|
||||
}
|
||||
|
||||
// Release contains only metadata safe and necessary for source resolution.
|
||||
type Release struct {
|
||||
ID string `json:"release_id"`
|
||||
Tag string `json:"tag"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
// Asset contains the immutable target metadata returned by a resolve call.
|
||||
type Asset struct {
|
||||
ID string `json:"asset_id"`
|
||||
Name string `json:"asset_name"`
|
||||
State string `json:"state"`
|
||||
Size int64 `json:"size"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveResult is either a selected uploaded asset or a not-modified marker.
|
||||
type ResolveResult struct {
|
||||
NotModified bool `json:"not_modified"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
Release Release `json:"release,omitempty"`
|
||||
Asset Asset `json:"asset,omitempty"`
|
||||
RetryAt *time.Time `json:"retry_at,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadRequest identifies an already resolved asset. Asset IDs never come
|
||||
// from an untrusted URL and the download endpoint is built locally.
|
||||
type DownloadRequest struct {
|
||||
Repository string
|
||||
Asset Asset
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
// DownloadResult owns a temporary file. Call Cleanup after ingestion.
|
||||
type DownloadResult struct {
|
||||
Path string
|
||||
Size int64
|
||||
SHA256 string
|
||||
DeclaredDigest string
|
||||
}
|
||||
|
||||
// Cleanup removes the temporary file and is safe to call more than once.
|
||||
func (result *DownloadResult) Cleanup() error {
|
||||
if result == nil || result.Path == "" {
|
||||
return nil
|
||||
}
|
||||
name := result.Path
|
||||
err := os.Remove(name)
|
||||
if err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
result.Path = ""
|
||||
return nil
|
||||
}
|
||||
return errCleanup
|
||||
}
|
||||
|
||||
// Error is a safe provider error. It never retains a response body, request
|
||||
// URL, redirect location or request headers.
|
||||
type Error struct {
|
||||
Kind error
|
||||
StatusCode int
|
||||
RequestID string
|
||||
Repository string
|
||||
Tag string
|
||||
AssetName string
|
||||
AvailableAssets []string
|
||||
RetryAt *time.Time
|
||||
}
|
||||
|
||||
func (providerError *Error) Error() string {
|
||||
if providerError == nil {
|
||||
return "GitHub Release 请求失败"
|
||||
}
|
||||
message := "GitHub Release 请求失败"
|
||||
if providerError.Kind != nil {
|
||||
message = providerError.Kind.Error()
|
||||
}
|
||||
parts := make([]string, 0, safePartsCapacity)
|
||||
if providerError.StatusCode != 0 {
|
||||
parts = append(parts, "status="+strconv.Itoa(providerError.StatusCode))
|
||||
}
|
||||
if providerError.RequestID != "" {
|
||||
parts = append(parts, "request_id="+providerError.RequestID)
|
||||
}
|
||||
if providerError.Repository != "" {
|
||||
parts = append(parts, "repo="+providerError.Repository)
|
||||
}
|
||||
if providerError.Tag != "" {
|
||||
parts = append(parts, "tag="+providerError.Tag)
|
||||
}
|
||||
if providerError.AssetName != "" {
|
||||
parts = append(parts, "asset="+providerError.AssetName)
|
||||
}
|
||||
if len(providerError.AvailableAssets) > 0 {
|
||||
parts = append(parts, "available="+strings.Join(providerError.AvailableAssets, ","))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return message
|
||||
}
|
||||
return message + " (" + strings.Join(parts, " ") + ")"
|
||||
}
|
||||
|
||||
func (providerError *Error) Unwrap() error {
|
||||
if providerError == nil {
|
||||
return nil
|
||||
}
|
||||
return providerError.Kind
|
||||
}
|
||||
|
||||
// RetryAt extracts the server-directed retry deadline from an error.
|
||||
func RetryAt(err error) (time.Time, bool) {
|
||||
var providerError *Error
|
||||
if !errors.As(err, &providerError) || providerError.RetryAt == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return *providerError.RetryAt, true
|
||||
}
|
||||
|
||||
// RetryTime is retained as a compatibility alias for early callers.
|
||||
//
|
||||
// Deprecated: use RetryAt.
|
||||
func RetryTime(err error) (time.Time, bool) {
|
||||
return RetryAt(err)
|
||||
}
|
||||
|
||||
// IsNotFound reports both a missing Release endpoint and a Release that lacks
|
||||
// the exact uploaded asset requested by the caller.
|
||||
func IsNotFound(err error) bool {
|
||||
if errors.Is(err, ErrAssetNotFound) {
|
||||
return true
|
||||
}
|
||||
var providerError *Error
|
||||
return errors.As(err, &providerError) && providerError.StatusCode == http.StatusNotFound
|
||||
}
|
||||
|
||||
// IsDigestError reports malformed or mismatched declared asset digests.
|
||||
func IsDigestError(err error) bool {
|
||||
return errors.Is(err, ErrDigestMismatch)
|
||||
}
|
||||
|
||||
// IsRetryable classifies provider failures without relying on localized error
|
||||
// strings. Configuration, not-found, size, empty-content and digest failures
|
||||
// are permanent. Network failures, 408/425/429 and 5xx responses are retryable.
|
||||
func IsRetryable(err error) bool {
|
||||
if err == nil || errors.Is(err, ErrInvalidRequest) || IsNotFound(err) ||
|
||||
errors.Is(err, ErrAssetTooLarge) || errors.Is(err, ErrEmptyAsset) || IsDigestError(err) {
|
||||
return false
|
||||
}
|
||||
var providerError *Error
|
||||
if !errors.As(err, &providerError) {
|
||||
return false
|
||||
}
|
||||
if providerError.StatusCode == 0 {
|
||||
return errors.Is(err, ErrMetadata) || errors.Is(err, ErrDownload) || errors.Is(err, errCleanup)
|
||||
}
|
||||
if providerError.StatusCode < http.StatusBadRequest {
|
||||
return errors.Is(err, ErrMetadata) || errors.Is(err, ErrDownload) || errors.Is(err, errCleanup)
|
||||
}
|
||||
if providerError.RetryAt != nil {
|
||||
return true
|
||||
}
|
||||
return providerError.StatusCode == http.StatusRequestTimeout ||
|
||||
providerError.StatusCode == http.StatusTooEarly ||
|
||||
providerError.StatusCode == http.StatusTooManyRequests ||
|
||||
providerError.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// Client accesses public GitHub Releases using a fixed, constrained transport.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
createTemp func(string, string) (*os.File, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewClient constructs a production client for api.github.com. Public
|
||||
// repositories do not require or send a token.
|
||||
func NewClient() *Client {
|
||||
return newClient(defaultClientOptions())
|
||||
}
|
||||
|
||||
// Resolve calls GitHub's latest or exact-tag endpoint and selects one exact,
|
||||
// case-sensitive uploaded asset. It never falls back to source archives.
|
||||
func (client *Client) Resolve(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
|
||||
repository, tag, endpoint, err := normalizeResolveRequest(client.baseURL, request)
|
||||
if err != nil {
|
||||
return ResolveResult{}, safeError(errInvalidRequest, 0, "", repository, tag, validErrorAssetName(request.AssetName), nil, nil)
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return ResolveResult{}, safeError(errInvalidRequest, 0, "", repository, tag, validErrorAssetName(request.AssetName), nil, nil)
|
||||
}
|
||||
applyMetadataHeaders(httpRequest, request.ETag)
|
||||
response, err := client.httpClient.Do(httpRequest) //nolint:gosec // endpoint and every dial target are constrained
|
||||
if err != nil {
|
||||
return ResolveResult{}, safeError(errMetadata, 0, "", repository, tag, request.AssetName, nil, nil)
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
retryAt := responseRetryAt(response, client.now())
|
||||
etag := safeETag(response.Header.Get("ETag"))
|
||||
if response.StatusCode == http.StatusNotModified {
|
||||
if etag == "" {
|
||||
etag = safeETag(request.ETag)
|
||||
}
|
||||
return ResolveResult{NotModified: true, ETag: etag, RetryAt: retryAt}, nil
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, maxMetadataBytes+1))
|
||||
if readErr != nil || len(body) > maxMetadataBytes || !utf8.Valid(body) {
|
||||
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||
}
|
||||
var payload releasePayload
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||
}
|
||||
if err := ensureJSONEOF(decoder); err != nil {
|
||||
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||
}
|
||||
release, assets, err := convertRelease(payload)
|
||||
if err != nil {
|
||||
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if asset.State == "uploaded" && asset.Name == request.AssetName {
|
||||
return ResolveResult{
|
||||
ETag: etag,
|
||||
Release: release,
|
||||
Asset: asset,
|
||||
RetryAt: retryAt,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
available := safeAssetNames(assets)
|
||||
return ResolveResult{}, safeError(
|
||||
errAssetMissing,
|
||||
response.StatusCode,
|
||||
response.Header.Get("X-GitHub-Request-Id"),
|
||||
repository,
|
||||
release.Tag,
|
||||
request.AssetName,
|
||||
available,
|
||||
retryAt,
|
||||
)
|
||||
}
|
||||
|
||||
// Download streams an asset into a package-owned temporary file while
|
||||
// enforcing a hard byte limit and verifying GitHub's declared sha256 digest.
|
||||
func (client *Client) Download(ctx context.Context, request DownloadRequest) (*DownloadResult, error) {
|
||||
repository, err := normalizeRepository(request.Repository)
|
||||
if err != nil || request.MaxBytes <= 0 || !validPositiveID(request.Asset.ID) ||
|
||||
!validAssetName(request.Asset.Name) || request.Asset.Size < 0 {
|
||||
return nil, safeError(errInvalidRequest, 0, "", repository, "", validErrorAssetName(request.Asset.Name), nil, nil)
|
||||
}
|
||||
if request.Asset.Size > request.MaxBytes {
|
||||
return nil, safeError(errTooLarge, 0, "", repository, "", validErrorAssetName(request.Asset.Name), nil, nil)
|
||||
}
|
||||
endpoint := strings.TrimRight(client.baseURL, "/") + "/repos/" + repository + "/releases/assets/" + request.Asset.ID
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, safeError(errInvalidRequest, 0, "", repository, "", request.Asset.Name, nil, nil)
|
||||
}
|
||||
applyAssetHeaders(httpRequest)
|
||||
response, err := client.httpClient.Do(httpRequest) //nolint:gosec // endpoint and every dial target are constrained
|
||||
if err != nil {
|
||||
return nil, safeError(errDownload, 0, "", repository, "", request.Asset.Name, nil, nil)
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
retryAt := responseRetryAt(response, client.now())
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, safeHTTPError(errDownload, response, repository, "", request.Asset.Name, retryAt)
|
||||
}
|
||||
if response.ContentLength > request.MaxBytes {
|
||||
return nil, safeHTTPError(errTooLarge, response, repository, "", request.Asset.Name, retryAt)
|
||||
}
|
||||
|
||||
result, err := client.streamAsset(response.Body, request.MaxBytes, request.Asset.Digest)
|
||||
if err != nil {
|
||||
return nil, safeError(err, response.StatusCode, response.Header.Get("X-GitHub-Request-Id"), repository, "", request.Asset.Name, nil, retryAt)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (client *Client) streamAsset(body io.Reader, maxBytes int64, declaredDigest string) (result *DownloadResult, resultErr error) {
|
||||
tempFile, err := client.createTemp("", "openflare-github-release-*")
|
||||
if err != nil {
|
||||
return nil, errDownload
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
defer func() {
|
||||
closeErr := tempFile.Close()
|
||||
if resultErr == nil && closeErr != nil {
|
||||
resultErr = errDownload
|
||||
}
|
||||
if resultErr != nil {
|
||||
if removeErr := os.Remove(tempPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||||
resultErr = errCleanup
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
readLimit := maxBytes
|
||||
if readLimit < math.MaxInt64 {
|
||||
readLimit++
|
||||
}
|
||||
size, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(body, readLimit))
|
||||
if err != nil {
|
||||
return nil, errDownload
|
||||
}
|
||||
if size > maxBytes {
|
||||
return nil, errTooLarge
|
||||
}
|
||||
if size == 0 {
|
||||
return nil, errEmptyAsset
|
||||
}
|
||||
checksum := hex.EncodeToString(hasher.Sum(nil))
|
||||
if err := verifyDeclaredDigest(declaredDigest, checksum); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DownloadResult{
|
||||
Path: tempPath,
|
||||
Size: size,
|
||||
SHA256: checksum,
|
||||
DeclaredDigest: strings.ToLower(strings.TrimSpace(declaredDigest)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type releasePayload struct {
|
||||
ID json.Number `json:"id"`
|
||||
Tag string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Assets []assetPayload `json:"assets"`
|
||||
}
|
||||
|
||||
type assetPayload struct {
|
||||
ID json.Number `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Size int64 `json:"size"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
func convertRelease(payload releasePayload) (Release, []Asset, error) {
|
||||
releaseID, err := positiveJSONID(payload.ID)
|
||||
if err != nil {
|
||||
return Release{}, nil, err
|
||||
}
|
||||
if !validReleaseDisplayTag(payload.Tag) {
|
||||
return Release{}, nil, errMetadata
|
||||
}
|
||||
publishedAt, err := parseOptionalTime(payload.PublishedAt)
|
||||
if err != nil {
|
||||
return Release{}, nil, err
|
||||
}
|
||||
release := Release{
|
||||
ID: releaseID,
|
||||
Tag: payload.Tag,
|
||||
Name: safeText(payload.Name, maxSafeTextBytes),
|
||||
Draft: payload.Draft,
|
||||
Prerelease: payload.Prerelease,
|
||||
PublishedAt: publishedAt,
|
||||
}
|
||||
assets := make([]Asset, 0, len(payload.Assets))
|
||||
for _, rawAsset := range payload.Assets {
|
||||
assetID, assetErr := positiveJSONID(rawAsset.ID)
|
||||
if assetErr != nil || rawAsset.Size < 0 {
|
||||
return Release{}, nil, errMetadata
|
||||
}
|
||||
updatedAt, assetErr := parseOptionalTime(rawAsset.UpdatedAt)
|
||||
if assetErr != nil {
|
||||
return Release{}, nil, errMetadata
|
||||
}
|
||||
assets = append(assets, Asset{
|
||||
ID: assetID,
|
||||
Name: rawAsset.Name,
|
||||
State: rawAsset.State,
|
||||
Size: rawAsset.Size,
|
||||
UpdatedAt: updatedAt,
|
||||
Digest: safeText(rawAsset.Digest, maxDigestBytes),
|
||||
})
|
||||
}
|
||||
return release, assets, nil
|
||||
}
|
||||
|
||||
func normalizeResolveRequest(baseURL string, request ResolveRequest) (string, string, string, error) {
|
||||
repository, err := normalizeRepository(request.Repository)
|
||||
if err != nil || !validAssetName(request.AssetName) {
|
||||
return repository, validErrorTag(request.Tag), "", errInvalidRequest
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
switch request.Selector {
|
||||
case SelectorLatest:
|
||||
if strings.TrimSpace(request.Tag) != "" {
|
||||
return repository, "", "", errInvalidRequest
|
||||
}
|
||||
return repository, "latest", baseURL + "/repos/" + repository + "/releases/latest", nil
|
||||
case SelectorTag:
|
||||
if !validTag(request.Tag) {
|
||||
return repository, validErrorTag(request.Tag), "", errInvalidRequest
|
||||
}
|
||||
return repository, request.Tag, baseURL + "/repos/" + repository + "/releases/tags/" + url.PathEscape(request.Tag), nil
|
||||
default:
|
||||
return repository, validErrorTag(request.Tag), "", errInvalidRequest
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRepository(repository string) (string, error) {
|
||||
repository = strings.TrimSpace(repository)
|
||||
parts := strings.Split(repository, "/")
|
||||
if len(parts) != 2 || !ownerPattern.MatchString(parts[0]) || !repoPattern.MatchString(parts[1]) ||
|
||||
len(parts[1]) > 100 || parts[1] == "." || parts[1] == ".." {
|
||||
return "", errInvalidRequest
|
||||
}
|
||||
return parts[0] + "/" + parts[1], nil
|
||||
}
|
||||
|
||||
func validAssetName(assetName string) bool {
|
||||
return validLogText(assetName, maxSafeTextBytes, false) && path.Base(assetName) == assetName &&
|
||||
assetName != "." && assetName != ".." && !strings.ContainsAny(assetName, `/\`)
|
||||
}
|
||||
|
||||
func validTag(tag string) bool {
|
||||
if !validLogText(tag, maxSafeTextBytes, false) || strings.ContainsAny(tag, " ~^:?*[\\") ||
|
||||
strings.Contains(tag, "..") || strings.Contains(tag, "@{") || strings.Contains(tag, "//") ||
|
||||
strings.HasPrefix(tag, "/") || strings.HasSuffix(tag, "/") || strings.HasSuffix(tag, ".") {
|
||||
return false
|
||||
}
|
||||
for _, component := range strings.Split(tag, "/") {
|
||||
if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validReleaseDisplayTag(tag string) bool {
|
||||
return validLogText(tag, maxSafeTextBytes, false)
|
||||
}
|
||||
|
||||
func validLogText(value string, maxBytes int, allowEmpty bool) bool {
|
||||
if (!allowEmpty && value == "") || len(value) > maxBytes || !utf8.ValidString(value) {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if isLogControl(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isLogControl(character rune) bool {
|
||||
if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' {
|
||||
return true
|
||||
}
|
||||
switch character {
|
||||
case '\u061c', '\u200e', '\u200f',
|
||||
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
|
||||
'\u2066', '\u2067', '\u2068', '\u2069':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validErrorTag(tag string) string {
|
||||
if !validTag(tag) || containsSecretDelimiter(tag) {
|
||||
return ""
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
func validErrorAssetName(assetName string) string {
|
||||
if !validAssetName(assetName) || containsSecretDelimiter(assetName) {
|
||||
return ""
|
||||
}
|
||||
return assetName
|
||||
}
|
||||
|
||||
func containsSecretDelimiter(value string) bool {
|
||||
return strings.ContainsAny(value, "?&=#") || strings.Contains(value, "://")
|
||||
}
|
||||
|
||||
func validPositiveID(id string) bool {
|
||||
parsed, err := strconv.ParseInt(id, 10, 64)
|
||||
return err == nil && parsed > 0 && strconv.FormatInt(parsed, 10) == id
|
||||
}
|
||||
|
||||
func positiveJSONID(id json.Number) (string, error) {
|
||||
parsed, err := strconv.ParseInt(id.String(), 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return "", errMetadata
|
||||
}
|
||||
return strconv.FormatInt(parsed, 10), nil
|
||||
}
|
||||
|
||||
func parseOptionalTime(value string) (time.Time, error) {
|
||||
if value == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return time.Time{}, errMetadata
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func ensureJSONEOF(decoder *json.Decoder) error {
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return errMetadata
|
||||
}
|
||||
|
||||
func verifyDeclaredDigest(declaredDigest string, checksum string) error {
|
||||
declaredDigest = strings.TrimSpace(declaredDigest)
|
||||
if declaredDigest == "" {
|
||||
return nil
|
||||
}
|
||||
algorithm, digest, ok := strings.Cut(declaredDigest, ":")
|
||||
if !ok || !strings.EqualFold(algorithm, "sha256") || !hexPattern.MatchString(digest) ||
|
||||
!strings.EqualFold(digest, checksum) {
|
||||
return errDigest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeAssetNames(assets []Asset) []string {
|
||||
count := len(assets)
|
||||
if count > maxAssetErrorNames {
|
||||
count = maxAssetErrorNames
|
||||
}
|
||||
names := make([]string, 0, count)
|
||||
for _, asset := range assets[:count] {
|
||||
name := safeText(asset.Name, maxSafeAssetNameLen)
|
||||
if containsSecretDelimiter(name) {
|
||||
name = "<redacted>"
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func safeText(value string, maxBytes int) string {
|
||||
var builder strings.Builder
|
||||
for _, character := range value {
|
||||
if isLogControl(character) {
|
||||
builder.WriteByte('?')
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(character)
|
||||
if builder.Len() >= maxBytes {
|
||||
break
|
||||
}
|
||||
}
|
||||
result := builder.String()
|
||||
for len(result) > maxBytes {
|
||||
_, size := utf8.DecodeLastRuneInString(result)
|
||||
result = result[:len(result)-size]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func safeETag(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > maxETagBytes || safeText(value, maxETagBytes) != value {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func safeHTTPError(kind error, response *http.Response, repository string, tag string, assetName string, retryAt *time.Time) error {
|
||||
return safeError(
|
||||
kind,
|
||||
response.StatusCode,
|
||||
response.Header.Get("X-GitHub-Request-Id"),
|
||||
repository,
|
||||
tag,
|
||||
assetName,
|
||||
nil,
|
||||
retryAt,
|
||||
)
|
||||
}
|
||||
|
||||
func safeError(
|
||||
kind error,
|
||||
statusCode int,
|
||||
requestID string,
|
||||
repository string,
|
||||
tag string,
|
||||
assetName string,
|
||||
availableAssets []string,
|
||||
retryAt *time.Time,
|
||||
) error {
|
||||
return &Error{
|
||||
Kind: kind,
|
||||
StatusCode: statusCode,
|
||||
RequestID: safeErrorToken(requestID, maxSafeTextBytes),
|
||||
Repository: safeErrorToken(repository, maxSafeTextBytes),
|
||||
Tag: safeErrorToken(tag, maxSafeTextBytes),
|
||||
AssetName: safeErrorToken(assetName, maxSafeAssetNameLen),
|
||||
AvailableAssets: availableAssets,
|
||||
RetryAt: retryAt,
|
||||
}
|
||||
}
|
||||
|
||||
func safeErrorToken(value string, maxBytes int) string {
|
||||
if !validLogText(value, maxBytes, true) {
|
||||
return ""
|
||||
}
|
||||
value = safeText(value, maxBytes)
|
||||
if containsSecretDelimiter(value) {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package githubrelease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type resolverFunc func(context.Context, string, string) ([]netip.Addr, error)
|
||||
|
||||
func (resolve resolverFunc) LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error) {
|
||||
return resolve(ctx, network, host)
|
||||
}
|
||||
|
||||
func TestResolveLatestUsesGitHubContractAndSelectsUploadedAsset(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/repos/acme/site/releases/latest" {
|
||||
t.Errorf("path = %q", request.URL.Path)
|
||||
}
|
||||
assertHeader(t, request, "Accept", metadataAccept)
|
||||
assertHeader(t, request, "User-Agent", defaultUserAgent)
|
||||
assertHeader(t, request, "X-GitHub-Api-Version", APIVersion)
|
||||
assertHeader(t, request, "If-None-Match", `W/"old"`)
|
||||
writer.Header().Set("ETag", `W/"new"`)
|
||||
writer.Header().Set("X-RateLimit-Remaining", "0")
|
||||
writer.Header().Set("X-RateLimit-Reset", "1800000000")
|
||||
_, _ = writer.Write([]byte(`{
|
||||
"id": 9007199254740991,
|
||||
"tag_name": "v1.2.3",
|
||||
"name": "Stable",
|
||||
"published_at": "2026-07-18T12:00:00Z",
|
||||
"assets": [
|
||||
{"id": 11, "name": "dist.zip", "state": "new", "size": 1},
|
||||
{"id": 9007199254740990, "name": "dist.zip", "state": "uploaded", "size": 42,
|
||||
"updated_at": "2026-07-18T12:10:00Z", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
result, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site",
|
||||
Selector: SelectorLatest,
|
||||
AssetName: "dist.zip",
|
||||
ETag: `W/"old"`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if result.Release.ID != "9007199254740991" || result.Asset.ID != "9007199254740990" {
|
||||
t.Fatalf("IDs lost precision: release=%q asset=%q", result.Release.ID, result.Asset.ID)
|
||||
}
|
||||
if result.ETag != `W/"new"` || result.Asset.Name != "dist.zip" || result.Asset.State != "uploaded" {
|
||||
t.Fatalf("Resolve() = %+v", result)
|
||||
}
|
||||
if result.RetryAt == nil || result.RetryAt.Unix() != 1800000000 {
|
||||
t.Fatalf("RetryAt = %v", result.RetryAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTagEscapesPathAndHandlesNotModified(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.RequestURI != "/repos/acme/site/releases/tags/release%2Fcandidate" {
|
||||
t.Errorf("RequestURI = %q", request.RequestURI)
|
||||
}
|
||||
writer.WriteHeader(http.StatusNotModified)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
result, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site",
|
||||
Selector: SelectorTag,
|
||||
Tag: "release/candidate",
|
||||
AssetName: "dist.zip",
|
||||
ETag: `"cached"`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if !result.NotModified || result.ETag != `"cached"` {
|
||||
t.Fatalf("Resolve() = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAssetMissingTruncatesSafeNamesAndNeverIncludesBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
assets := make([]string, 0, 12)
|
||||
for index := 0; index < 12; index++ {
|
||||
assets = append(assets, fmt.Sprintf(`{"id":%d,"name":"asset-%02d.zip","state":"uploaded","size":1}`, index+1, index))
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","message":"body-token","assets":[` + strings.Join(assets, ",") + `]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
|
||||
})
|
||||
if !errors.Is(err, errAssetMissing) {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, "asset-00.zip") || !strings.Contains(message, "asset-09.zip") {
|
||||
t.Fatalf("error misses safe truncated names: %s", message)
|
||||
}
|
||||
if strings.Contains(message, "asset-10.zip") || strings.Contains(message, "asset-11.zip") || strings.Contains(message, "body-token") {
|
||||
t.Fatalf("error leaked/truncation failed: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHTTPErrorParsesRateLimitWithoutBodyLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, time.July, 19, 10, 0, 0, 0, time.UTC)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Retry-After", "90")
|
||||
writer.Header().Set("X-GitHub-Request-Id", "request-123")
|
||||
writer.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = writer.Write([]byte(`{"message":"signed_url=https://secret.example/a?token=hidden"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, func(options *clientOptions) { options.now = func() time.Time { return now } })
|
||||
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "status=429") || !strings.Contains(err.Error(), "request_id=request-123") {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret.example") || strings.Contains(err.Error(), "hidden") {
|
||||
t.Fatalf("error leaked body: %s", err)
|
||||
}
|
||||
retryAt, ok := RetryTime(err)
|
||||
if !ok || !retryAt.Equal(now.Add(90*time.Second)) {
|
||||
t.Fatalf("RetryTime() = %v, %v", retryAt, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadStreamsVerifiesDigestAndCleansUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := []byte("package bytes")
|
||||
digest := sha256.Sum256(payload)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/repos/acme/site/releases/assets/42" {
|
||||
t.Errorf("path = %q", request.URL.Path)
|
||||
}
|
||||
assertHeader(t, request, "Accept", assetAccept)
|
||||
assertHeader(t, request, "Accept-Encoding", "identity")
|
||||
_, _ = writer.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
result, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site",
|
||||
Asset: Asset{
|
||||
ID: "42", Name: "dist.zip", Digest: "sha256:" + hex.EncodeToString(digest[:]),
|
||||
},
|
||||
MaxBytes: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download() error = %v", err)
|
||||
}
|
||||
if result.Size != int64(len(payload)) || result.SHA256 != hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("Download() = %+v", result)
|
||||
}
|
||||
if _, err := os.Stat(result.Path); err != nil {
|
||||
t.Fatalf("temp file stat: %v", err)
|
||||
}
|
||||
if err := result.Cleanup(); err != nil {
|
||||
t.Fatalf("Cleanup() error = %v", err)
|
||||
}
|
||||
if err := result.Cleanup(); err != nil {
|
||||
t.Fatalf("second Cleanup() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFollows302AndStripsCrossHostSensitiveHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
var targetHost string
|
||||
target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
for _, header := range []string{"Authorization", "Cookie", "Proxy-Authorization", "Referer", "If-None-Match", "If-Modified-Since", "X-GitHub-Api-Version"} {
|
||||
if value := request.Header.Get(header); value != "" {
|
||||
t.Errorf("redirect leaked %s=%q", header, value)
|
||||
}
|
||||
}
|
||||
_, _ = writer.Write([]byte("redirected package"))
|
||||
}))
|
||||
defer target.Close()
|
||||
targetURL, _ := url.Parse(target.URL)
|
||||
targetHost = "asset.example.test:" + targetURL.Port()
|
||||
|
||||
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Location", "http://"+targetHost+"/signed/package.zip?token=must-not-leak")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer api.Close()
|
||||
apiURL, _ := url.Parse(api.URL)
|
||||
baseURL := "http://api.example.test:" + apiURL.Port()
|
||||
client := newMappedTestClient(t, baseURL, nil)
|
||||
result, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site", Asset: Asset{ID: "42", Name: "dist.zip"}, MaxBytes: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download() redirect error = %v", err)
|
||||
}
|
||||
if cleanupErr := result.Cleanup(); cleanupErr != nil {
|
||||
t.Fatalf("Cleanup() error = %v", cleanupErr)
|
||||
}
|
||||
|
||||
request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/repos/acme/site/releases/assets/42", nil)
|
||||
applyAssetHeaders(request)
|
||||
request.Header.Set("Authorization", "Bearer secret")
|
||||
request.Header.Set("Cookie", "session=secret")
|
||||
request.Header.Set("Proxy-Authorization", "proxy-secret")
|
||||
request.Header.Set("Referer", "https://secret.example/path?token=x")
|
||||
request.Header.Set("If-None-Match", `"secret-etag"`)
|
||||
request.Header.Set("If-Modified-Since", time.Now().Format(http.TimeFormat))
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Do() error = %v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
|
||||
func TestRedirectSSRFAndDNSRebindingAreRejectedWithoutURLLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("literal private redirect", func(t *testing.T) {
|
||||
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Location", "http://127.0.0.1/private?token=secret-query")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer api.Close()
|
||||
client := newTestClient(t, api.URL, nil)
|
||||
_, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
|
||||
})
|
||||
if err == nil || strings.Contains(err.Error(), "secret-query") || strings.Contains(err.Error(), "127.0.0.1") {
|
||||
t.Fatalf("Download() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DNS rebind between redirect and dial", func(t *testing.T) {
|
||||
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Location", "http://rebind.example.test/package.zip")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer api.Close()
|
||||
var lock sync.Mutex
|
||||
calls := map[string]int{}
|
||||
resolve := resolverFunc(func(_ context.Context, _ string, host string) ([]netip.Addr, error) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
calls[host]++
|
||||
if host == "rebind.example.test" && calls[host] > 1 {
|
||||
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
|
||||
})
|
||||
client := newTestClient(t, api.URL, func(options *clientOptions) { options.resolver = resolve })
|
||||
_, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Download() error = nil")
|
||||
}
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if calls["rebind.example.test"] != 2 {
|
||||
t.Fatalf("rebind lookup calls = %d", calls["rebind.example.test"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDownloadFailureRemovesTemporaryFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := []byte("package bytes")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
tempDir := t.TempDir()
|
||||
client := newTestClient(t, server.URL, func(options *clientOptions) {
|
||||
options.createTemp = func(_ string, pattern string) (*os.File, error) {
|
||||
return os.CreateTemp(tempDir, pattern)
|
||||
}
|
||||
})
|
||||
_, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site",
|
||||
Asset: Asset{
|
||||
ID: "42", Name: "dist.zip", Digest: "sha256:" + strings.Repeat("0", 64),
|
||||
},
|
||||
MaxBytes: 1024,
|
||||
})
|
||||
if !errors.Is(err, errDigest) {
|
||||
t.Fatalf("Download() error = %v", err)
|
||||
}
|
||||
files, readErr := filepath.Glob(filepath.Join(tempDir, "*"))
|
||||
if readErr != nil || len(files) != 0 {
|
||||
t.Fatalf("temporary files after failure = %v, err=%v", files, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsInvalidRepositoryAndAssetWithoutRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := NewClient()
|
||||
for _, request := range []ResolveRequest{
|
||||
{Repository: "https://github.com/acme/site", Selector: SelectorLatest, AssetName: "dist.zip"},
|
||||
{Repository: "acme/site/extra", Selector: SelectorLatest, AssetName: "dist.zip"},
|
||||
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "../dist.zip"},
|
||||
{Repository: "acme/site", Selector: SelectorLatest, AssetName: `dir\dist.zip`},
|
||||
{Repository: "acme/site", Selector: SelectorLatest, AssetName: string([]byte{'d', 'i', 's', 't', 0xff})},
|
||||
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\nsecret.zip"},
|
||||
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\u2028secret.zip"},
|
||||
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\u202esecret.zip"},
|
||||
{Repository: "acme/site", Selector: SelectorTag, AssetName: "dist.zip"},
|
||||
} {
|
||||
_, err := client.Resolve(context.Background(), request)
|
||||
if !errors.Is(err, errInvalidRequest) {
|
||||
t.Errorf("Resolve(%+v) error = %v", request, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndDownloadAssetNameWithDelimiters(t *testing.T) {
|
||||
t.Parallel()
|
||||
assetName := "dist?channel=stable#1&x.zip"
|
||||
payload := []byte("package with delimiter name")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/repos/acme/site/releases/latest":
|
||||
if request.Header.Get("If-None-Match") == "missing" {
|
||||
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","assets":[]}`))
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":"release/v1","assets":[{"id":42,"name":%q,"state":"uploaded","size":%d}]}`,
|
||||
assetName, len(payload))
|
||||
case "/repos/acme/site/releases/assets/42":
|
||||
_, _ = writer.Write(payload)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
|
||||
resolved, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site", Selector: SelectorLatest, AssetName: assetName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Asset.Name != assetName || resolved.Release.Tag != "release/v1" {
|
||||
t.Fatalf("Resolve() = %+v", resolved)
|
||||
}
|
||||
download, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site", Asset: resolved.Asset, MaxBytes: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download() error = %v", err)
|
||||
}
|
||||
if cleanupErr := download.Cleanup(); cleanupErr != nil {
|
||||
t.Fatalf("Cleanup() error = %v", cleanupErr)
|
||||
}
|
||||
|
||||
_, err = client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site", Selector: SelectorLatest, AssetName: assetName, ETag: "missing",
|
||||
})
|
||||
if !errors.Is(err, ErrAssetNotFound) {
|
||||
t.Fatalf("missing Resolve() error = %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), assetName) || strings.Contains(err.Error(), "channel=stable") {
|
||||
t.Fatalf("missing error leaked delimiter-bearing name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedTagGitRefRulesAndEscaping(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := []string{"@", "release/v1#stable&channel=prod", "foo.LOCK", "中文/发布=稳定"}
|
||||
for _, tag := range valid {
|
||||
if !validTag(tag) {
|
||||
t.Errorf("validTag(%q) = false", tag)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"", "release v1", "release~v1", "release^v1", "release:v1", "release?v1", "release*v1",
|
||||
"release[v1", `release\v1`, "release..v1", "release@{v1", "release//v1", "/release", "release/",
|
||||
"release.", ".release", "release/.candidate", "release.lock", "release/v1.lock", "release\nsecret",
|
||||
"release\u2028secret", "release\u202esecret", string([]byte{'v', '1', 0xff}), strings.Repeat("a", 256),
|
||||
}
|
||||
for _, tag := range invalid {
|
||||
if validTag(tag) {
|
||||
t.Errorf("validTag(%q) = true", tag)
|
||||
}
|
||||
}
|
||||
|
||||
tag := "release/v1#stable&channel=prod"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
wantURI := "/repos/acme/site/releases/tags/" + url.PathEscape(tag)
|
||||
if request.RequestURI != wantURI || request.URL.RawQuery != "" || request.URL.Fragment != "" {
|
||||
t.Errorf("tag request = %q query=%q fragment=%q, want %q", request.RequestURI, request.URL.RawQuery, request.URL.Fragment, wantURI)
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":%q,"assets":[{"id":2,"name":"dist.zip","state":"uploaded","size":1}]}`, tag)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
result, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site", Selector: SelectorTag, Tag: tag, AssetName: "dist.zip",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if result.Release.Tag != tag {
|
||||
t.Fatalf("Release.Tag = %q", result.Release.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDoesNotMatchSanitizedRemoteAssetName(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
remote string
|
||||
requested string
|
||||
}{
|
||||
{name: "unicode line separator", remote: "dist\u2028.zip", requested: "dist?.zip"},
|
||||
{name: "overlong", remote: strings.Repeat("a", 256), requested: strings.Repeat("a", 255)},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":"release/v1","assets":[{"id":2,"name":%q,"state":"uploaded","size":1}]}`, test.remote)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site", Selector: SelectorLatest, AssetName: test.requested,
|
||||
})
|
||||
if !errors.Is(err, ErrAssetNotFound) {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDisplayTagValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tag := range []string{"release/v1", "release v1", "release/v1#stable&channel=prod"} {
|
||||
if !validReleaseDisplayTag(tag) {
|
||||
t.Errorf("validReleaseDisplayTag(%q) = false", tag)
|
||||
}
|
||||
}
|
||||
for _, tag := range []string{"", strings.Repeat("a", 256), "release\nsecret", "release\u2028secret", "release\u202esecret"} {
|
||||
if validReleaseDisplayTag(tag) {
|
||||
t.Errorf("validReleaseDisplayTag(%q) = true", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsInvalidUTF8Metadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(append([]byte(`{"id":1,"tag_name":"v1","assets":[{"id":2,"name":"dist`),
|
||||
append([]byte{0xff}, []byte(`.zip","state":"uploaded","size":1}]}`)...)...))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist�.zip",
|
||||
})
|
||||
if !errors.Is(err, ErrMetadata) {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadRejectsImpossibleMetadataBeforeNetwork(t *testing.T) {
|
||||
t.Parallel()
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
tests := []struct {
|
||||
name string
|
||||
size int64
|
||||
kind error
|
||||
limit int64
|
||||
}{
|
||||
{name: "negative", size: -1, kind: ErrInvalidRequest, limit: 100},
|
||||
{name: "declared too large", size: 101, kind: ErrAssetTooLarge, limit: 100},
|
||||
}
|
||||
for _, test := range tests {
|
||||
_, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site",
|
||||
Asset: Asset{ID: "1", Name: "dist?token=hidden#asset.zip", Size: test.size},
|
||||
MaxBytes: test.limit,
|
||||
})
|
||||
if !errors.Is(err, test.kind) {
|
||||
t.Errorf("%s Download() error = %v", test.name, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "token=hidden") {
|
||||
t.Errorf("%s error leaked asset name: %v", test.name, err)
|
||||
}
|
||||
}
|
||||
if got := requests.Load(); got != 0 {
|
||||
t.Fatalf("HTTP requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogControlCharactersNeverEnterSafeErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
controls := []string{"\u2028", "\u2029", "\u061c", "\u200e", "\u200f", "\u202e", "\u2066", "\u2069"}
|
||||
for _, control := range controls {
|
||||
secret := "before" + control + "after"
|
||||
err := safeError(errInvalidRequest, 0, secret, secret, secret, secret, nil, nil)
|
||||
message := err.Error()
|
||||
if strings.Contains(message, secret) || strings.Contains(message, control) || strings.Contains(message, "before") {
|
||||
t.Errorf("safe error retained control %U: %q", []rune(control)[0], message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsMetadataOverHardLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","assets":[]}` + strings.Repeat(" ", maxMetadataBytes)))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
|
||||
})
|
||||
if !errors.Is(err, ErrMetadata) {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionTransportRejectsSelfSignedTLS(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write([]byte("package"))
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, _ := url.Parse(server.URL)
|
||||
dialer := &net.Dialer{Timeout: time.Second}
|
||||
client := newClient(clientOptions{
|
||||
baseURL: "https://api.example.test:" + parsed.Port(),
|
||||
resolver: resolverFunc(func(_ context.Context, _ string, _ string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
|
||||
}),
|
||||
dialContext: func(ctx context.Context, network string, _ string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, server.Listener.Addr().String())
|
||||
},
|
||||
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
createTemp: os.CreateTemp,
|
||||
now: time.Now,
|
||||
clientTimeout: 5 * time.Second,
|
||||
})
|
||||
_, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
|
||||
})
|
||||
if !errors.Is(err, ErrDownload) {
|
||||
t.Fatalf("Download() error = %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "api.example.test") || strings.Contains(err.Error(), server.URL) {
|
||||
t.Fatalf("TLS error leaked URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableErrorClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Now()
|
||||
assetMissing := safeError(errAssetMissing, http.StatusOK, "", "acme/site", "v1", "dist.zip", nil, nil)
|
||||
if !IsNotFound(assetMissing) || IsRetryable(assetMissing) {
|
||||
t.Fatalf("asset missing classification failed: %v", assetMissing)
|
||||
}
|
||||
metadata404 := safeError(errMetadata, http.StatusNotFound, "", "acme/site", "v1", "dist.zip", nil, nil)
|
||||
if !IsNotFound(metadata404) || IsRetryable(metadata404) {
|
||||
t.Fatalf("metadata 404 classification failed: %v", metadata404)
|
||||
}
|
||||
digest := safeError(errDigest, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil)
|
||||
if !IsDigestError(digest) || IsRetryable(digest) {
|
||||
t.Fatalf("digest classification failed: %v", digest)
|
||||
}
|
||||
for _, retryable := range []error{
|
||||
safeError(errMetadata, 0, "", "acme/site", "", "dist.zip", nil, nil),
|
||||
safeError(errMetadata, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil),
|
||||
safeError(errDownload, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil),
|
||||
safeError(errMetadata, http.StatusInternalServerError, "", "acme/site", "", "dist.zip", nil, nil),
|
||||
safeError(errMetadata, http.StatusForbidden, "", "acme/site", "", "dist.zip", nil, &now),
|
||||
} {
|
||||
if !IsRetryable(retryable) {
|
||||
t.Errorf("IsRetryable(%v) = false", retryable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadRedirectLimitIsSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
step, _ := strconv.Atoi(request.URL.Query().Get("step"))
|
||||
writer.Header().Set("Location", fmt.Sprintf("/repos/acme/site/releases/assets/1?step=%d&token=redirect-secret", step+1))
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
_, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Download() error = nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), "redirect-secret") || strings.Contains(err.Error(), "step=") {
|
||||
t.Fatalf("redirect error leaked Location: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidRequestDoesNotEchoURLQueryTagOrAsset(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := NewClient()
|
||||
requests := []ResolveRequest{
|
||||
{Repository: "https://github.com/acme/site?token=repo-secret", Selector: SelectorLatest, AssetName: "dist.zip"},
|
||||
{Repository: "acme/site", Selector: SelectorTag, Tag: "?token=tag-secret", AssetName: "dist.zip"},
|
||||
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "../dist.zip?token=asset-secret"},
|
||||
}
|
||||
for _, request := range requests {
|
||||
_, err := client.Resolve(context.Background(), request)
|
||||
if err == nil {
|
||||
t.Fatalf("Resolve(%+v) error = nil", request)
|
||||
}
|
||||
for _, secret := range []string{"repo-secret", "tag-secret", "asset-secret", "https://github.com"} {
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("Resolve(%+v) leaked %q: %v", request, secret, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, rawBaseURL string, customize func(*clientOptions)) *Client {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseURL := "http://api.example.test:" + parsed.Port()
|
||||
return newMappedTestClient(t, baseURL, customize)
|
||||
}
|
||||
|
||||
func newMappedTestClient(t *testing.T, baseURL string, customize func(*clientOptions)) *Client {
|
||||
t.Helper()
|
||||
resolve := resolverFunc(func(_ context.Context, _ string, _ string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
|
||||
})
|
||||
dialer := &net.Dialer{Timeout: time.Second}
|
||||
options := clientOptions{
|
||||
baseURL: baseURL,
|
||||
resolver: resolve,
|
||||
allowHTTP: true,
|
||||
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
dialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
_, port, splitErr := net.SplitHostPort(address)
|
||||
if splitErr != nil {
|
||||
return nil, splitErr
|
||||
}
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port))
|
||||
},
|
||||
createTemp: os.CreateTemp,
|
||||
now: time.Now,
|
||||
clientTimeout: 5 * time.Second,
|
||||
}
|
||||
if customize != nil {
|
||||
customize(&options)
|
||||
}
|
||||
return newClient(options)
|
||||
}
|
||||
|
||||
func assertHeader(t *testing.T, request *http.Request, name string, expected string) {
|
||||
t.Helper()
|
||||
if actual := request.Header.Get(name); actual != expected {
|
||||
t.Errorf("%s = %q, want %q", name, actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRetryAtHTTPDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := time.Date(2026, time.July, 19, 12, 30, 0, 0, time.UTC)
|
||||
response := &http.Response{Header: make(http.Header)}
|
||||
response.Header.Set("Retry-After", want.Format(http.TimeFormat))
|
||||
if got := responseRetryAt(response, time.Time{}); got == nil || !got.Equal(want) {
|
||||
t.Fatalf("responseRetryAt() = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRetryAtRejectsDurationOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
response := &http.Response{Header: make(http.Header)}
|
||||
response.Header.Set("Retry-After", strconv.FormatInt(maxRetryAfterSeconds+1, 10))
|
||||
if got := responseRetryAt(response, time.Now()); got != nil {
|
||||
t.Fatalf("responseRetryAt(overflow) = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeETagDropsOversizedOrControlValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := safeETag(strings.Repeat("x", 513)); got != "" {
|
||||
t.Fatalf("safeETag(overlong) = %q", got)
|
||||
}
|
||||
if got := safeETag("ok\nsecret"); got != "" {
|
||||
t.Fatalf("safeETag(control) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSizeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(20))
|
||||
_, _ = writer.Write([]byte(strings.Repeat("x", 20)))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, nil)
|
||||
_, err := client.Download(context.Background(), DownloadRequest{
|
||||
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 10,
|
||||
})
|
||||
if !errors.Is(err, errTooLarge) {
|
||||
t.Fatalf("Download() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package githubrelease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/httppool"
|
||||
)
|
||||
|
||||
const (
|
||||
clientTimeout = 10 * time.Minute
|
||||
dialTimeout = 30 * time.Second
|
||||
dialKeepAlive = 30 * time.Second
|
||||
responseHeaderTimeout = 30 * time.Second
|
||||
maxRedirects = 5
|
||||
maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||
)
|
||||
|
||||
var (
|
||||
errBlockedTarget = errors.New("GitHub Release 请求目标不是公网地址")
|
||||
errResolveTarget = errors.New("GitHub Release 请求目标解析失败")
|
||||
errRedirectLimit = errors.New("GitHub Release asset 重定向次数过多")
|
||||
|
||||
publicIPv6Prefix = netip.MustParsePrefix("2000::/3")
|
||||
nonPublicPrefixes = []netip.Prefix{
|
||||
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.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"),
|
||||
netip.MustParsePrefix("::/128"),
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("::ffff:0:0/96"),
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
}
|
||||
)
|
||||
|
||||
type resolver interface {
|
||||
LookupNetIP(context.Context, string, string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
type clientOptions struct {
|
||||
baseURL string
|
||||
resolver resolver
|
||||
dialContext func(context.Context, string, string) (net.Conn, error)
|
||||
tlsConfig *tls.Config
|
||||
allowHTTP bool
|
||||
createTemp func(string, string) (*os.File, error)
|
||||
now func() time.Time
|
||||
clientTimeout time.Duration
|
||||
}
|
||||
|
||||
func defaultClientOptions() clientOptions {
|
||||
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
|
||||
return clientOptions{
|
||||
baseURL: defaultAPIBaseURL,
|
||||
resolver: net.DefaultResolver,
|
||||
dialContext: dialer.DialContext,
|
||||
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
createTemp: os.CreateTemp,
|
||||
now: time.Now,
|
||||
clientTimeout: clientTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func newClient(options clientOptions) *Client {
|
||||
if options.baseURL == "" {
|
||||
options.baseURL = defaultAPIBaseURL
|
||||
}
|
||||
if options.resolver == nil {
|
||||
options.resolver = net.DefaultResolver
|
||||
}
|
||||
if options.dialContext == nil {
|
||||
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
|
||||
options.dialContext = dialer.DialContext
|
||||
}
|
||||
if options.tlsConfig == nil {
|
||||
options.tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
if options.createTemp == nil {
|
||||
options.createTemp = os.CreateTemp
|
||||
}
|
||||
if options.now == nil {
|
||||
options.now = time.Now
|
||||
}
|
||||
if options.clientTimeout <= 0 {
|
||||
options.clientTimeout = clientTimeout
|
||||
}
|
||||
|
||||
secureDial := publicDialer(options.resolver, options.dialContext)
|
||||
transport := httppool.NewTransport(httppool.TransportOptions{
|
||||
Proxy: nil,
|
||||
DialContext: secureDial,
|
||||
TLSClientConfig: options.tlsConfig,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
TraceFilter: func(request *http.Request) bool {
|
||||
return request.URL == nil || request.URL.RawQuery == ""
|
||||
},
|
||||
})
|
||||
httpClient := &http.Client{Timeout: options.clientTimeout, Transport: transport}
|
||||
httpClient.CheckRedirect = func(next *http.Request, previous []*http.Request) error {
|
||||
if len(previous) > maxRedirects {
|
||||
return errRedirectLimit
|
||||
}
|
||||
if err := validateTarget(next.Context(), next.URL, options.resolver, options.allowHTTP); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(previous) > 0 && !sameHost(previous[len(previous)-1].URL, next.URL) {
|
||||
stripCrossHostHeaders(next)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &Client{
|
||||
httpClient: httpClient,
|
||||
baseURL: strings.TrimRight(options.baseURL, "/"),
|
||||
createTemp: options.createTemp,
|
||||
now: options.now,
|
||||
}
|
||||
}
|
||||
|
||||
func applyMetadataHeaders(request *http.Request, etag string) {
|
||||
request.Header.Set("Accept", metadataAccept)
|
||||
request.Header.Set("User-Agent", defaultUserAgent)
|
||||
request.Header.Set("X-GitHub-Api-Version", APIVersion)
|
||||
if etag = safeETag(etag); etag != "" {
|
||||
request.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
}
|
||||
|
||||
func applyAssetHeaders(request *http.Request) {
|
||||
request.Header.Set("Accept", assetAccept)
|
||||
request.Header.Set("Accept-Encoding", "identity")
|
||||
request.Header.Set("User-Agent", defaultUserAgent)
|
||||
request.Header.Set("X-GitHub-Api-Version", APIVersion)
|
||||
}
|
||||
|
||||
func stripCrossHostHeaders(request *http.Request) {
|
||||
for _, header := range []string{
|
||||
"Authorization",
|
||||
"Cookie",
|
||||
"Proxy-Authorization",
|
||||
"Referer",
|
||||
"If-None-Match",
|
||||
"If-Modified-Since",
|
||||
"X-GitHub-Api-Version",
|
||||
} {
|
||||
request.Header.Del(header)
|
||||
}
|
||||
}
|
||||
|
||||
func sameHost(left *url.URL, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Hostname(), right.Hostname()) && effectivePort(left) == effectivePort(right)
|
||||
}
|
||||
|
||||
func effectivePort(target *url.URL) string {
|
||||
if port := target.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
if strings.EqualFold(target.Scheme, "https") {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
func validateTarget(ctx context.Context, target *url.URL, targetResolver resolver, allowHTTP bool) error {
|
||||
if target == nil || target.User != nil || target.Fragment != "" || target.Opaque != "" || target.Hostname() == "" {
|
||||
return errBlockedTarget
|
||||
}
|
||||
isHTTPS := strings.EqualFold(target.Scheme, "https")
|
||||
isAllowedHTTP := allowHTTP && strings.EqualFold(target.Scheme, "http")
|
||||
if !isHTTPS && !isAllowedHTTP {
|
||||
return errBlockedTarget
|
||||
}
|
||||
_, err := resolvePublicIPs(ctx, targetResolver, target.Hostname())
|
||||
return err
|
||||
}
|
||||
|
||||
func publicDialer(
|
||||
targetResolver resolver,
|
||||
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, errResolveTarget
|
||||
}
|
||||
addresses, err := resolvePublicIPs(ctx, targetResolver, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resolved := range addresses {
|
||||
if !ipMatchesNetwork(resolved, network) {
|
||||
continue
|
||||
}
|
||||
connection, dialErr := directDial(ctx, network, net.JoinHostPort(resolved.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
}
|
||||
return nil, errDownload
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePublicIPs(ctx context.Context, targetResolver resolver, host string) ([]netip.Addr, error) {
|
||||
if strings.Contains(host, "%") {
|
||||
return nil, errBlockedTarget
|
||||
}
|
||||
if literal, err := netip.ParseAddr(host); err == nil {
|
||||
if !isPublicIP(literal) {
|
||||
return nil, errBlockedTarget
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
}
|
||||
if targetResolver == nil {
|
||||
return nil, errResolveTarget
|
||||
}
|
||||
addresses, err := targetResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return nil, errResolveTarget
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if !isPublicIP(address) {
|
||||
return nil, errBlockedTarget
|
||||
}
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func isPublicIP(address netip.Addr) bool {
|
||||
if !address.IsValid() || address.Zone() != "" {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
if !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
if address.Is6() && !publicIPv6Prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range nonPublicPrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ipMatchesNetwork(address netip.Addr, network string) bool {
|
||||
switch network {
|
||||
case "tcp4":
|
||||
return address.Unmap().Is4()
|
||||
case "tcp6":
|
||||
return address.Unmap().Is6()
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func responseRetryAt(response *http.Response, now time.Time) *time.Time {
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
if retryAfter := strings.TrimSpace(response.Header.Get("Retry-After")); retryAfter != "" {
|
||||
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 && seconds <= maxRetryAfterSeconds {
|
||||
retryAt := now.Add(time.Duration(seconds) * time.Second)
|
||||
return &retryAt
|
||||
}
|
||||
if retryAt, err := http.ParseTime(retryAfter); err == nil {
|
||||
retryAt = retryAt.UTC()
|
||||
return &retryAt
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(response.Header.Get("X-RateLimit-Remaining")) != "0" {
|
||||
return nil
|
||||
}
|
||||
reset, err := strconv.ParseInt(strings.TrimSpace(response.Header.Get("X-RateLimit-Reset")), 10, 64)
|
||||
if err != nil || reset <= 0 {
|
||||
return nil
|
||||
}
|
||||
retryAt := time.Unix(reset, 0).UTC()
|
||||
return &retryAt
|
||||
}
|
||||
Reference in New Issue
Block a user