fix(submit): 审阅绑定上下文 sha256,批准须带摘要,构建只从内部 API 取上下文并校验字节

This commit is contained in:
Lemon-miaow committed 2026-09-24 22:24:50 +08:00
1 parent 13b65e19ec
commit e521cf5976
30 files changed
+910 -98

No files matched your search

@@ -23,6 +23,12 @@
"context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz",
"download_context_btn": "Download context",
"download_context_hint": "Downloads the uploaded context (.tar.gz) — it contains the Dockerfile that will actually be executed.",
"context_sha256_label": "Context SHA-256",
"context_sha256_hint": "Approving binds this digest: if the submitter uploads again, the build refuses to run. When reviewing offline, compare it with sha256sum of your download.",
"context_sha256_missing": "No build context uploaded yet, or it was uploaded before digests were recorded and must be uploaded again.",
"context_sha256_downloaded": "Matches the file you just downloaded; approving binds exactly these bytes.",
"context_sha256_stale": "The copy you downloaded ({{digest}}…) was replaced by a newer upload; download and review it again.",
"approve_needs_context": "Nothing to approve: the context has not been uploaded, or must be uploaded again",
"base_image_label": "Base Image",
"base_image_placeholder": "e.g. library/postgres:15",
"view_logs_btn": "Logs",
@@ -52,6 +52,7 @@
"build_unavailable": "Image builds aren't available right now.",
"build_logs_unavailable": "Build logs aren't available right now.",
"already_reviewed": "This submission has already been reviewed.",
"context_changed": "The submitter uploaded the build context again after you reviewed it. Download and review the new upload before approving.",
"submission_quota_exceeded": "Your submission quota is full: too many pending reviews, or your stored uploads are at the limit.",
"submission_cooldown": "Too many submission requests — try again shortly.",
"submissions_unavailable": "Submissions aren't available right now.",
@@ -35,6 +35,8 @@
"error_file_required": "Build context file is required.",
"field_context_ref": "Context Reference",
"field_image_ref": "Image Reference",
"field_context_sha256": "Context SHA-256",
"field_context_sha256_hint": "The digest of the file the platform received; compare it with sha256sum of your local file. Approval binds exactly this content.",
"clear_btn": "Clear",
"file_hint": "Supports .tar.gz (max 1GB)",
"withdraw_btn": "Withdraw",
@@ -23,6 +23,12 @@
"context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
"download_context_btn": "下载上下文",
"download_context_hint": "下载上传的构建上下文 (.tar.gz)——其中包含将被实际执行的 Dockerfile。",
"context_sha256_label": "上下文 SHA-256",
"context_sha256_hint": "通过会绑定这个摘要:提交者之后再上传,构建会拒绝执行。离线审阅时请用 sha256sum 核对下载的文件。",
"context_sha256_missing": "尚未上传构建上下文,或上传早于摘要记录,需要提交者重新上传。",
"context_sha256_downloaded": "与你刚下载的文件一致,通过会绑定这份内容。",
"context_sha256_stale": "你下载的版本({{digest}}…)已被新的上传替换,请重新下载审阅。",
"approve_needs_context": "没有可通过的上下文:提交者尚未上传或需要重新上传",
"base_image_label": "基础镜像",
"base_image_placeholder": "例如: library/postgres:15",
"view_logs_btn": "日志",
@@ -52,6 +52,7 @@
"build_unavailable": "构建功能当前不可用。",
"build_logs_unavailable": "构建日志暂时不可用。",
"already_reviewed": "该提交已经审核过了。",
"context_changed": "提交者在你审阅之后重新上传了构建上下文。请重新下载并审阅新的上传再通过。",
"submission_quota_exceeded": "你的提交配额已满:待审核提交过多,或已存上传总量达到上限。",
"submission_cooldown": "操作太频繁——请稍后再试。",
"submissions_unavailable": "提交流程当前不可用。",
@@ -35,6 +35,8 @@
"error_file_required": "必须上传构建上下文文件。",
"field_context_ref": "构建上下文引用",
"field_image_ref": "目标镜像引用",
"field_context_sha256": "上下文 SHA-256",
"field_context_sha256_hint": "平台收到的文件摘要,可用 sha256sum 与本地文件核对。审核通过的就是这份内容。",
"clear_btn": "清除",
"file_hint": "支持 .tar.gz 格式 (最大 1GB)",
"withdraw_btn": "撤回提交",
+24 -1
View File
@@ -473,11 +473,34 @@ describe("image whitelist and builds wire shapes", () => {
const sub = { id: "sub-1", status: "approved" };
const fetchSpy = fakeFetch(sub);
vi.stubGlobal("fetch", fetchSpy);
const res = await api.approveSubmission("sub-1");
const digest = "a".repeat(64);
const res = await api.approveSubmission("sub-1", digest);
expect(res).toEqual(sub);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/submissions/sub-1/approve");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ expected_digest: digest });
});
it("downloadSubmissionContext resolves to the digest the API streamed", async () => {
const digest = "b".repeat(64);
const fetchSpy = vi.fn(async () => ({
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "X-Felis-Context-Sha256": digest.toUpperCase() }),
blob: async () => new Blob(["ctx"]),
})) as unknown as typeof fetch;
vi.stubGlobal("fetch", fetchSpy);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:ctx");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const link = { href: "", download: "", click: vi.fn() };
vi.stubGlobal("document", { createElement: () => link });
await expect(api.downloadSubmissionContext("sub-1")).resolves.toBe(digest);
expect(link.click).toHaveBeenCalledOnce();
expect(link.download).toBe("sub-1-context.tar.gz");
const [url] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/submissions/sub-1/context");
});
it("rejectSubmission POSTs {reason} to /submissions/{id}/reject", async () => {
+11 -3
View File
@@ -492,8 +492,10 @@ export const api = {
listSubmissions: () =>
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
approveSubmission: (id: string) =>
request<Submission>("POST", `/submissions/${id}/approve`),
// expectedDigest is the sha256 of the context the reviewer looked at; the API
// refuses the approval (409 context_changed) when the upload has since changed.
approveSubmission: (id: string, expectedDigest: string) =>
request<Submission>("POST", `/submissions/${id}/approve`, { expected_digest: expectedDigest }),
rejectSubmission: (id: string, reason: string) =>
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
@@ -506,7 +508,9 @@ export const api = {
// Dockerfile lives inside the tarball, so approving without this would be
// blind. The body is the attacker-supplied archive — download it, never
// render it — which the API's attachment disposition enforces.
downloadSubmissionContext: async (id: string): Promise<void> => {
// Resolves to the sha256 the API vouched for while streaming these bytes (it
// aborts the transfer on a mismatch), so the approval can name what was read.
downloadSubmissionContext: async (id: string): Promise<string | null> => {
const { apiBase } = await loadConfig();
const res = await fetch(`${apiBase}/submissions/${id}/context`, {
method: "GET",
@@ -528,6 +532,7 @@ export const api = {
announceSetupRequired(err);
throw err;
}
const digest = res.headers.get("X-Felis-Context-Sha256")?.trim().toLowerCase() || null;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -535,6 +540,7 @@ export const api = {
link.download = `${id}-context.tar.gz`;
link.click();
URL.revokeObjectURL(url);
return digest;
},
listMySubmissions: () =>
@@ -788,6 +794,8 @@ export function humanizeError(e: unknown): string {
return t("build_logs_unavailable");
case "already_reviewed":
return t("already_reviewed");
case "context_changed":
return t("context_changed");
case "submission_quota_exceeded":
return t("submission_quota_exceeded");
case "submission_cooldown":
+2
View File
@@ -290,6 +290,8 @@ export interface Submission {
reject_reason?: string;
created_at: string;
reviewed_at?: string;
/** sha256 of the uploaded context; approval must name it (migration 0025). */
context_sha256?: string;
}
export interface UpdateWindow {
+6
View File
@@ -430,6 +430,12 @@ export function MySubmissionsPage() {
<p className="font-semibold text-foreground mb-1">{t("field_context_ref")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
</div>
{sub.context_sha256 && (
<div>
<p className="font-semibold text-foreground mb-1">{t("field_context_sha256")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all" title={t("field_context_sha256_hint")}>{sub.context_sha256}</pre>
</div>
)}
{sub.image_ref && (
<div>
<p className="font-semibold text-foreground mb-1">{t("field_image_ref")}</p>
+52 -10
View File
@@ -1,5 +1,5 @@
import { useState, useMemo } from "react";
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download, Trash2 } from "lucide-react";
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download, Trash2, ShieldCheck, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { StatCard } from "@/components/StatCard";
@@ -24,7 +24,7 @@ import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatRelative, formatAbsolute } from "@/lib/format";
import type { Submission, SubmissionStatus } from "@/lib/types";
import type { ApiError, Submission, SubmissionStatus } from "@/lib/types";
const PAGE_SIZE = 10;
@@ -46,6 +46,10 @@ export function SubmissionsPage() {
const [busyId, setBusyId] = useState<string | null>(null);
const [busyType, setBusyType] = useState<"approve" | "reject" | "delete" | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
// The sha256 of each context this reviewer downloaded in this session. An
// approval names the digest of what was actually read; the listed digest
// stands in when the review happened elsewhere (the CLI, an earlier session).
const [reviewedDigests, setReviewedDigests] = useState<Record<string, string>>({});
// Delete arms the row (trash → confirm/cancel) before it fires; a row gone on
// one stray click would take its uploaded context with it.
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null);
@@ -101,16 +105,23 @@ export function SubmissionsPage() {
return filteredSubmissions.slice(start, start + PAGE_SIZE);
}, [filteredSubmissions, page]);
async function handleApprove(id: string) {
if (busyId) return;
setBusyId(id);
async function handleApprove(sub: Submission) {
const digest = reviewedDigests[sub.id] ?? sub.context_sha256;
if (busyId || !digest) return;
setBusyId(sub.id);
setBusyType("approve");
setActionError(null);
try {
await api.approveSubmission(id);
await api.approveSubmission(sub.id, digest);
reload();
} catch (err) {
setActionError(humanizeError(err));
// A newer upload replaced what was reviewed: forget the stale download and
// show the new digest, so the next approval has to be a fresh review.
if ((err as Partial<ApiError>).code === "context_changed") {
setReviewedDigests(({ [sub.id]: _stale, ...rest }) => rest);
reload();
}
} finally {
setBusyId(null);
setBusyType(null);
@@ -168,7 +179,13 @@ export function SubmissionsPage() {
setActionError(null);
setDownloadingId(sub.id);
try {
await api.downloadSubmissionContext(sub.id);
const digest = await api.downloadSubmissionContext(sub.id);
if (digest) {
setReviewedDigests((prev) => ({ ...prev, [sub.id]: digest }));
// The list predates a re-upload: refresh it so the page shows what was
// just downloaded.
if (digest !== sub.context_sha256) reload();
}
} catch (err) {
setActionError(humanizeError(err));
} finally {
@@ -291,6 +308,8 @@ export function SubmissionsPage() {
const isBusyApprove = busyId === sub.id && busyType === "approve";
const isBusyReject = busyId === sub.id && busyType === "reject";
const isBusyDelete = busyId === sub.id && busyType === "delete";
const reviewedDigest = reviewedDigests[sub.id];
const approveDigest = reviewedDigest ?? sub.context_sha256;
return (
<div key={sub.id} className="flex flex-col">
<div
@@ -342,9 +361,9 @@ export function SubmissionsPage() {
size="icon"
variant="outline"
className="h-7 w-7 text-emerald-500 hover:text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/10 focus-visible:ring-emerald-500"
onClick={() => handleApprove(sub.id)}
disabled={!!busyId}
title={t("approve_btn")}
onClick={() => handleApprove(sub)}
disabled={!!busyId || !approveDigest}
title={approveDigest ? t("approve_btn") : t("approve_needs_context")}
>
{isBusyApprove ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -423,6 +442,29 @@ export function SubmissionsPage() {
</Button>
</div>
</div>
<div>
<p className="font-semibold text-foreground mb-1">{t("context_sha256_label")}</p>
{sub.context_sha256 ? (
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all" title={t("context_sha256_hint")}>{sub.context_sha256}</pre>
) : (
<p className="text-amber-600 dark:text-amber-400">{t("context_sha256_missing")}</p>
)}
{reviewedDigest && reviewedDigest === sub.context_sha256 && (
<p className="mt-1 flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
{t("context_sha256_downloaded")}
</p>
)}
{reviewedDigest && sub.context_sha256 && reviewedDigest !== sub.context_sha256 && (
<p className="mt-1 flex items-start gap-1 text-amber-600 dark:text-amber-400">
<TriangleAlert className="h-3.5 w-3.5 shrink-0 mt-px" />
<span>{t("context_sha256_stale", { digest: reviewedDigest.slice(0, 12) })}</span>
</p>
)}
{sub.status === "pending_review" && sub.context_sha256 && !reviewedDigest && (
<p className="mt-1 text-muted-foreground/80">{t("context_sha256_hint")}</p>
)}
</div>
{sub.image_ref && (
<div>
<p className="font-semibold text-foreground mb-1">{t("image_ref_label")}</p>