feat(api,panel): reviewer context download for submissions; dockerfile field documented as audit-only (audit #45)

This commit is contained in:
Lemon-miaow committed 2026-09-23 16:41:55 +08:00
1 parent edd9d63f5e
commit 168a37542b
9 files changed
+213 -9

No files matched your search

@@ -18,8 +18,11 @@
"trigger_build_btn": "Start Build",
"dockerfile_label": "Dockerfile Content",
"dockerfile_placeholder": "FROM library/postgres:15\nRUN echo 'setup'",
"dockerfile_audit_hint": "Archived on the build row for audit. Kaniko executes the `Dockerfile` inside the context tarball — this text is never executed.",
"context_ref_label": "Context Reference",
"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.",
"base_image_label": "Base Image",
"base_image_placeholder": "e.g. library/postgres:15",
"view_logs_btn": "Logs",
@@ -18,8 +18,11 @@
"trigger_build_btn": "开始构建",
"dockerfile_label": "Dockerfile 内容",
"dockerfile_placeholder": "FROM library/postgres:15\nRUN echo 'setup'",
"dockerfile_audit_hint": "仅存档到构建记录用于审计。实际执行的是构建上下文压缩包内的 `Dockerfile`——此处内容不会被执行。",
"context_ref_label": "构建上下文引用",
"context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
"download_context_btn": "下载上下文",
"download_context_hint": "下载上传的构建上下文 (.tar.gz)——其中包含将被实际执行的 Dockerfile。",
"base_image_label": "基础镜像",
"base_image_placeholder": "例如: library/postgres:15",
"view_logs_btn": "日志",
+34
View File
@@ -473,6 +473,40 @@ export const api = {
rejectSubmission: (id: string, reason: string) =>
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
// The reviewer's read path to the uploaded build context: the executed
// 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> => {
const { apiBase } = await loadConfig();
const res = await fetch(`${apiBase}/submissions/${id}/context`, {
method: "GET",
credentials: "include",
});
if (!res.ok) {
let code = "error";
let message = res.statusText;
try {
const parsed = JSON.parse(await res.text()) as unknown;
if (isApiError(parsed)) {
code = parsed.error.code;
message = parsed.error.message;
}
} catch {
/* non-JSON error body (e.g. an ingress page): keep the status line */
}
const err: ApiError = { status: res.status, code, message };
throw err;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${id}-context.tar.gz`;
link.click();
URL.revokeObjectURL(url);
},
listMySubmissions: () =>
request<{ submissions: Submission[] }>("GET", "/me/submissions").then((r) => r.submissions ?? []),
+1
View File
@@ -405,6 +405,7 @@ export function ImageBuildPage() {
rows={8}
className="w-full rounded-md border border-input bg-zinc-950 px-3 py-2 text-xs font-mono text-zinc-200 shadow-sm placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
/>
<p className="text-[10px] text-muted-foreground/80 leading-relaxed">{t("dockerfile_audit_hint")}</p>
</div>
{triggerError && <MessageLine kind="error" message={triggerError} compact />}
+35 -3
View File
@@ -1,5 +1,5 @@
import { useState, useMemo } from "react";
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react";
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { StatCard } from "@/components/StatCard";
@@ -44,6 +44,7 @@ export function SubmissionsPage() {
// Pending actions (for button spinners)
const [busyId, setBusyId] = useState<string | null>(null);
const [busyType, setBusyType] = useState<"approve" | "reject" | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
// Search & Filtering State
const [search, setSearch] = useState("");
@@ -137,6 +138,20 @@ export function SubmissionsPage() {
}
}
// The reviewer downloads the uploaded context before approving: the executed
// Dockerfile lives inside it, so this is the only way to see the recipe.
const handleDownloadContext = async (sub: Submission) => {
setActionError(null);
setDownloadingId(sub.id);
try {
await api.downloadSubmissionContext(sub.id);
} catch (err) {
setActionError(humanizeError(err));
} finally {
setDownloadingId(null);
}
};
return (
<div className="space-y-6">
<PageHeader icon={ClipboardCheck} title={t("submissions_title")} subtitle={t("submissions_subtitle")} />
@@ -338,7 +353,24 @@ export function SubmissionsPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="font-semibold text-foreground mb-1">{t("context_ref_label")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
<div className="flex items-center gap-2">
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all flex-1 min-w-0">{sub.context_ref}</pre>
<Button
size="sm"
variant="outline"
className="h-7 shrink-0 px-2 text-[10px]"
onClick={() => handleDownloadContext(sub)}
disabled={!!downloadingId}
title={t("download_context_hint")}
>
{downloadingId === sub.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
<span className="ml-1">{t("download_context_btn")}</span>
</Button>
</div>
</div>
{sub.image_ref && (
<div>
@@ -444,4 +476,4 @@ export function SubmissionsPage() {
</Dialog>
</div>
);
}
}