From c39a3edcc35d4b040380dd808683ac01abb78da1 Mon Sep 17 00:00:00 2001 From: deqiying Date: Sun, 19 Jul 2026 18:31:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(pages):=20=E6=94=AF=E6=8C=81=20GitHub=20Re?= =?UTF-8?q?lease=20=E9=83=A8=E7=BD=B2=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。 --- docs/changelog/index.md | 1 + docs/docs.go | 4 +- docs/plan/20260719-pages-source-sync-v2.md | 9 +- docs/swagger.json | 4 +- docs/swagger.yaml | 4 +- .../detail/components/deployment-history.tsx | 4 +- .../detail/components/pages-source-card.tsx | 282 ++++-- .../components/pages-source-details.tsx | 137 +++ .../detail/components/pages-source-dialog.tsx | 273 +++++- .../components/pages-source-github-fields.tsx | 130 +++ .../components/pages-source-validation.ts | 68 ++ frontend/components/ui/alert.tsx | 66 ++ frontend/lib/services/openflare/index.ts | 5 + .../lib/services/openflare/pages.service.ts | 4 +- frontend/lib/services/openflare/types.ts | 41 +- .../tests/openflare/pages-service.test.ts | 68 ++ .../tests/openflare/pages-source-ui.test.tsx | 467 ++++++++- .../openflare/pages-source-validation.test.ts | 100 ++ internal/apps/openflare/pages/errs.go | 106 ++- .../apps/openflare/pages/github_source.go | 343 +++++++ .../openflare/pages/github_source_action.go | 724 ++++++++++++++ .../pages/github_source_identity_test.go | 111 +++ .../openflare/pages/github_source_test.go | 888 ++++++++++++++++++ internal/apps/openflare/pages/logics.go | 8 +- internal/apps/openflare/pages/routers.go | 21 +- internal/apps/openflare/pages/source.go | 141 ++- .../apps/openflare/pages/source_runtime.go | 24 + internal/apps/openflare/pages/source_sync.go | 38 +- .../apps/openflare/pages/source_sync_test.go | 12 +- internal/apps/openflare/pages/source_tasks.go | 130 ++- internal/apps/openflare/pages/source_test.go | 31 +- internal/integration/githubrelease/client.go | 725 ++++++++++++++ .../integration/githubrelease/client_test.go | 748 +++++++++++++++ .../integration/githubrelease/transport.go | 312 ++++++ 34 files changed, 5751 insertions(+), 278 deletions(-) create mode 100644 frontend/app/(main)/pages/detail/components/pages-source-details.tsx create mode 100644 frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx create mode 100644 frontend/app/(main)/pages/detail/components/pages-source-validation.ts create mode 100644 frontend/components/ui/alert.tsx create mode 100644 frontend/tests/openflare/pages-source-validation.test.ts create mode 100644 internal/apps/openflare/pages/github_source.go create mode 100644 internal/apps/openflare/pages/github_source_action.go create mode 100644 internal/apps/openflare/pages/github_source_identity_test.go create mode 100644 internal/apps/openflare/pages/github_source_test.go create mode 100644 internal/integration/githubrelease/client.go create mode 100644 internal/integration/githubrelease/client_test.go create mode 100644 internal/integration/githubrelease/transport.go diff --git a/docs/changelog/index.md b/docs/changelog/index.md index db529121..9dc94e9d 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -23,6 +23,7 @@ sidebar: false ### 新增 +- Pages 项目新增持久部署源,可配置 Remote URL 或公开 GitHub Release,并支持手动检查、同步发布、来源状态查看与同一 Release 资源替换确认;部署历史会保留安全的来源快照。 - WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。 ### 改进 diff --git a/docs/docs.go b/docs/docs.go index 6947b49a..2f5ca21a 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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" ], diff --git a/docs/plan/20260719-pages-source-sync-v2.md b/docs/plan/20260719-pages-source-sync-v2.md index 47859802..03978901 100644 --- a/docs/plan/20260719-pages-source-sync-v2.md +++ b/docs/plan/20260719-pages-source-sync-v2.md @@ -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 diff --git a/docs/swagger.json b/docs/swagger.json index 5c81cc5e..cc0f02c5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" ], diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b0e074d9..edce34e0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/frontend/app/(main)/pages/detail/components/deployment-history.tsx b/frontend/app/(main)/pages/detail/components/deployment-history.tsx index bd046500..7fe01105 100644 --- a/frontend/app/(main)/pages/detail/components/deployment-history.tsx +++ b/frontend/app/(main)/pages/detail/components/deployment-history.tsx @@ -45,7 +45,7 @@ const SOURCE_LABELS: Record = { manual_upload: '本地上传', manual_url: 'URL 导入', remote_url: 'Remote URL', - github_release: 'GitHub Release', + github_release: 'GitHub', }; const TRIGGER_LABELS: Record = { @@ -172,7 +172,7 @@ export function DeploymentHistory({ ) : deployments.length === 0 ? ( ) : ( deployments.map((deployment) => { diff --git a/frontend/app/(main)/pages/detail/components/pages-source-card.tsx b/frontend/app/(main)/pages/detail/components/pages-source-card.tsx index eced9522..9e101436 100644 --- a/frontend/app/(main)/pages/detail/components/pages-source-card.tsx +++ b/frontend/app/(main)/pages/detail/components/pages-source-card.tsx @@ -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(null); const sourcePollingStartedAt = useRef(null); const [dialogOpen, setDialogOpen] = useState(false); - const [dialogMode, setDialogMode] = useState<'manual' | 'remote_url'>( - 'manual', - ); + const [dialogMode, setDialogMode] = useState('manual'); const [activeAction, setActiveAction] = useState(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 ( @@ -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 }) {

) : source.source_type === 'remote_url' ? ( -
-
- 脱敏地址 - {source.display_url} -
-
- 网络策略 - - {source.remote_network_policy === 'trusted_internal' - ? '受信内网模式' - : '公网安全模式'} - -
-
- 最近同步 - - {source.last_synced_at - ? formatDateTime(source.last_synced_at) - : '尚未同步'} - -
-
- - 已应用 revision - - - {revisionSummary(source)} - -
- {source.last_error ? ( -
- -
- ) : null} -
+ ) : ( -
- 当前版本暂不提供该来源类型的编辑界面。 -
+ )} + {dispatchError ? ( + + ) : null} {executionQuery.isError ? ( {source.source_type === 'manual' ? ( - - ) : source.source_type === 'remote_url' ? ( + <> + + + + ) : ( <> + {source.source_type === 'github_release' ? ( + + ) : null} - ) : null} + )} ) : ( - { - setRemoteURL(event.target.value); - setURLError(''); - }} - /> +
+ { + setRemoteURL(event.target.value); + setURLError(''); + }} + /> + +
)} {urlError || @@ -274,6 +446,7 @@ export function PagesSourceDialog({ onClick={() => { setReplaceURL(false); setRemoteURL(''); + setRevealRemoteURL(false); setURLError(''); }} > @@ -313,6 +486,14 @@ export function PagesSourceDialog({ + ) : ( + )} @@ -321,13 +502,17 @@ export function PagesSourceDialog({ type='button' variant='outline' disabled={isPending} - onClick={() => onOpenChange(false)} + onClick={() => { + setRemoteURL(''); + setRevealRemoteURL(false); + onOpenChange(false); + }} > 取消 @@ -361,7 +546,7 @@ export function PagesSourceDialog({ if (confirmation === 'manual') { deleteMutation.mutate(); } else if (confirmation === 'trusted_internal') { - updateMutation.mutate(); + updateMutation.mutate(remotePayload()); } }} > diff --git a/frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx b/frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx new file mode 100644 index 00000000..7a8d3422 --- /dev/null +++ b/frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx @@ -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 ( + <> + + + GitHub 仓库 URL + + { + onChange({ ...value, repositoryURL: event.target.value }); + onErrorsChange({ ...errors, repository: '' }); + }} + /> + + {errors.repository || '仅支持公开 github.com 仓库。'} + + + + + Release 选择 + { + if (selector === 'latest' || selector === 'tag') { + onChange({ ...value, releaseSelector: selector }); + onErrorsChange({ ...errors, releaseTag: '' }); + } + }} + > + + 最新 Release + + + 固定 Tag + + + + 当前阶段由管理员手动检查并决定是否发布。 + + + + {value.releaseSelector === 'tag' ? ( + + Release tag + { + onChange({ ...value, releaseTag: event.target.value }); + onErrorsChange({ ...errors, releaseTag: '' }); + }} + /> + + {errors.releaseTag || '精确检查并同步指定 tag。'} + + + ) : null} + + + + Release Asset 文件名 + + { + onChange({ ...value, assetName: event.target.value }); + onErrorsChange({ ...errors, assetName: '' }); + }} + /> + + {errors.assetName || '按文件名精确匹配已上传的 Release Asset。'} + + + + ); +} diff --git a/frontend/app/(main)/pages/detail/components/pages-source-validation.ts b/frontend/app/(main)/pages/detail/components/pages-source-validation.ts new file mode 100644 index 00000000..b497b369 --- /dev/null +++ b/frontend/app/(main)/pages/detail/components/pages-source-validation.ts @@ -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'), + ) + ); +} diff --git a/frontend/components/ui/alert.tsx b/frontend/components/ui/alert.tsx new file mode 100644 index 00000000..a7f3eac0 --- /dev/null +++ b/frontend/components/ui/alert.tsx @@ -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) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { Alert, AlertDescription, AlertTitle }; diff --git a/frontend/lib/services/openflare/index.ts b/frontend/lib/services/openflare/index.ts index 7bca6843..52ec0746 100644 --- a/frontend/lib/services/openflare/index.ts +++ b/frontend/lib/services/openflare/index.ts @@ -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, diff --git a/frontend/lib/services/openflare/pages.service.ts b/frontend/lib/services/openflare/pages.service.ts index e45b0543..a666ef6b 100644 --- a/frontend/lib/services/openflare/pages.service.ts +++ b/frontend/lib/services/openflare/pages.service.ts @@ -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 { return this.post( `/${projectId}/source/update`, diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 0b9be41c..66eace1c 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -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; } diff --git a/frontend/tests/openflare/pages-service.test.ts b/frontend/tests/openflare/pages-service.test.ts index 516e5596..3aafe780 100644 --- a/frontend/tests/openflare/pages-service.test.ts +++ b/frontend/tests/openflare/pages-service.test.ts @@ -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', { diff --git a/frontend/tests/openflare/pages-source-ui.test.tsx b/frontend/tests/openflare/pages-source-ui.test.tsx index b9773638..bdf61cc8 100644 --- a/frontend/tests/openflare/pages-source-ui.test.tsx +++ b/frontend/tests/openflare/pages-source-ui.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); @@ -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( + , + ); + + expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible(); + expect(screen.getByText('当前生产部署')).toBeVisible(); + }); }); diff --git a/frontend/tests/openflare/pages-source-validation.test.ts b/frontend/tests/openflare/pages-source-validation.test.ts new file mode 100644 index 00000000..e19d3ece --- /dev/null +++ b/frontend/tests/openflare/pages-source-validation.test.ts @@ -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); + } + }); +}); diff --git a/internal/apps/openflare/pages/errs.go b/internal/apps/openflare/pages/errs.go index 15b2c270..64fc9a25 100644 --- a/internal/apps/openflare/pages/errs.go +++ b/internal/apps/openflare/pages/errs.go @@ -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 部署源操作失败,请稍后重试" ) diff --git a/internal/apps/openflare/pages/github_source.go b/internal/apps/openflare/pages/github_source.go new file mode 100644 index 00000000..d2e8fa66 --- /dev/null +++ b/internal/apps/openflare/pages/github_source.go @@ -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) + } +} diff --git a/internal/apps/openflare/pages/github_source_action.go b/internal/apps/openflare/pages/github_source_action.go new file mode 100644 index 00000000..d4fc1e89 --- /dev/null +++ b/internal/apps/openflare/pages/github_source_action.go @@ -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) +} diff --git a/internal/apps/openflare/pages/github_source_identity_test.go b/internal/apps/openflare/pages/github_source_identity_test.go new file mode 100644 index 00000000..c577dc41 --- /dev/null +++ b/internal/apps/openflare/pages/github_source_identity_test.go @@ -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, + ) + } +} diff --git a/internal/apps/openflare/pages/github_source_test.go b/internal/apps/openflare/pages/github_source_test.go new file mode 100644 index 00000000..6d823319 --- /dev/null +++ b/internal/apps/openflare/pages/github_source_test.go @@ -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) + } +} diff --git a/internal/apps/openflare/pages/logics.go b/internal/apps/openflare/pages/logics.go index 3a0f0a49..b154456b 100644 --- a/internal/apps/openflare/pages/logics.go +++ b/internal/apps/openflare/pages/logics.go @@ -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 } diff --git a/internal/apps/openflare/pages/routers.go b/internal/apps/openflare/pages/routers.go index 4ad4c862..50f93e50 100644 --- a/internal/apps/openflare/pages/routers.go +++ b/internal/apps/openflare/pages/routers.go @@ -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 diff --git a/internal/apps/openflare/pages/source.go b/internal/apps/openflare/pages/source.go index 9bc46217..37312a09 100644 --- a/internal/apps/openflare/pages/source.go +++ b/internal/apps/openflare/pages/source.go @@ -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 diff --git a/internal/apps/openflare/pages/source_runtime.go b/internal/apps/openflare/pages/source_runtime.go index 0dd8fc0f..98801661 100644 --- a/internal/apps/openflare/pages/source_runtime.go +++ b/internal/apps/openflare/pages/source_runtime.go @@ -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, diff --git a/internal/apps/openflare/pages/source_sync.go b/internal/apps/openflare/pages/source_sync.go index 78430cb9..4ef52431 100644 --- a/internal/apps/openflare/pages/source_sync.go +++ b/internal/apps/openflare/pages/source_sync.go @@ -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, }) diff --git a/internal/apps/openflare/pages/source_sync_test.go b/internal/apps/openflare/pages/source_sync_test.go index 870446a8..0569e3d2 100644 --- a/internal/apps/openflare/pages/source_sync_test.go +++ b/internal/apps/openflare/pages/source_sync_test.go @@ -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) diff --git a/internal/apps/openflare/pages/source_tasks.go b/internal/apps/openflare/pages/source_tasks.go index 5fef7d5b..5e72b383 100644 --- a/internal/apps/openflare/pages/source_tasks.go +++ b/internal/apps/openflare/pages/source_tasks.go @@ -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) diff --git a/internal/apps/openflare/pages/source_test.go b/internal/apps/openflare/pages/source_test.go index 61f9dabe..8fd52aad 100644 --- a/internal/apps/openflare/pages/source_test.go +++ b/internal/apps/openflare/pages/source_test.go @@ -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, diff --git a/internal/integration/githubrelease/client.go b/internal/integration/githubrelease/client.go new file mode 100644 index 00000000..83373ea8 --- /dev/null +++ b/internal/integration/githubrelease/client.go @@ -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 = "" + } + 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 +} diff --git a/internal/integration/githubrelease/client_test.go b/internal/integration/githubrelease/client_test.go new file mode 100644 index 00000000..04a93b7a --- /dev/null +++ b/internal/integration/githubrelease/client_test.go @@ -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) + } +} diff --git a/internal/integration/githubrelease/transport.go b/internal/integration/githubrelease/transport.go new file mode 100644 index 00000000..16a82471 --- /dev/null +++ b/internal/integration/githubrelease/transport.go @@ -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 +}