fix(submit): 审阅绑定上下文 sha256,批准须带摘要,构建只从内部 API 取上下文并校验字节
This commit is contained in:
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": "撤回提交",
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user