Repository Public MIT
1"use client";2import { useRouter } from "next/navigation";3import { useState } from "react";4import type { Finding } from "@skills/registry-core";56type Phase = "idle" | "hashing" | "begin" | "upload" | "finalize" | "done" | "error";78/**9 * Browser release upload: hash locally (WebCrypto), begin, PUT with progress, finalize.10 * Uses the session cookie plus the per-session CSRF token; no API token ever reaches the browser.11 */12export function Uploader({ artifactId, csrf, maxBytes }: { artifactId: string; csrf: string; maxBytes: number }) {13 const router = useRouter();14 const [phase, setPhase] = useState<Phase>("idle");15 const [progress, setProgress] = useState(0);16 const [message, setMessage] = useState("");17 const [findings, setFindings] = useState<Finding[]>([]);1819 async function api(method: string, path: string, body?: unknown) {20 const res = await fetch(`/api/v1${path}`, { method, headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: body === undefined ? undefined : JSON.stringify(body), credentials: "same-origin" });21 const json = await res.json().catch(() => ({}));22 if (!res.ok) throw Object.assign(new Error(json?.error?.message ?? `Request failed (${res.status})`), { details: json?.error?.details });23 return json;24 }2526 async function onSubmit(e: React.FormEvent<HTMLFormElement>) {27 e.preventDefault();28 const form = new FormData(e.currentTarget);29 const file = form.get("archive") as File | null;30 const version = String(form.get("version") ?? "").trim();31 setFindings([]);32 setMessage("");33 if (!file || file.size === 0) return;34 if (file.size > maxBytes) {35 setPhase("error");36 setMessage(`The archive is larger than ${Math.round(maxBytes / 1024 / 1024)} MB.`);37 return;38 }39 try {40 setPhase("hashing");41 const buf = await file.arrayBuffer();42 const digest = await crypto.subtle.digest("SHA-256", buf);43 const sha256 = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");44 setPhase("begin");45 const up = await api("POST", `/artifacts/${artifactId}/releases`, { version, sha256, sizeBytes: file.size, releaseNotes: String(form.get("releaseNotes") ?? "") || undefined });46 setPhase("upload");47 await new Promise<void>((resolve, reject) => {48 const xhr = new XMLHttpRequest();49 xhr.open("PUT", `/api/v1/uploads/${up.uploadId}`);50 xhr.setRequestHeader("Content-Type", "application/gzip");51 xhr.setRequestHeader("X-CSRF-Token", csrf);52 xhr.upload.onprogress = (ev) => ev.lengthComputable && setProgress(Math.round((ev.loaded / ev.total) * 100));53 xhr.onload = () => {54 if (xhr.status >= 200 && xhr.status < 300) return resolve();55 let msg = `Upload failed (${xhr.status})`;56 try { msg = JSON.parse(xhr.responseText).error.message; } catch { /* keep default */ }57 reject(new Error(msg));58 };59 xhr.onerror = () => reject(new Error("Network error during upload."));60 xhr.send(buf);61 });62 setPhase("finalize");63 const fin = await api("POST", `/uploads/${up.uploadId}/finalize`, { sha256 });64 setFindings(fin.findings ?? []);65 setPhase("done");66 setMessage(fin.release.state === "quarantined" ? `Version ${fin.release.version} was uploaded and is quarantined for security review.` : `Version ${fin.release.version} is published and immutable. SHA-256 ${sha256.slice(0, 16)}…`);67 router.refresh();68 } catch (err) {69 const e2 = err as Error & { details?: { findings?: Finding[] } };70 setFindings(e2.details?.findings ?? []);71 setPhase("error");72 setMessage(e2.message);73 }74 }75 const busy = phase !== "idle" && phase !== "done" && phase !== "error";76 const label: Record<Phase, string> = { idle: "", hashing: "Computing SHA-256 locally…", begin: "Opening upload session…", upload: `Uploading… ${progress}%`, finalize: "Validating and scanning on the server…", done: "", error: "" };7778 return (79 <form onSubmit={onSubmit} className="card space-y-4 p-6">80 <div className="grid gap-4 sm:grid-cols-[10rem_minmax(0,1fr)]">81 <div>82 <label htmlFor="up-version" className="label">Version</label>83 <input id="up-version" name="version" required placeholder="1.0.0" pattern="\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?" className="input font-mono" autoComplete="off" />84 </div>85 <div>86 <label htmlFor="up-archive" className="label">Release archive (.tar.gz from <code className="font-mono">skills pack</code>)</label>87 <input id="up-archive" name="archive" type="file" required accept=".gz,.tgz,application/gzip" className="input py-2 file:mr-3 file:rounded file:border-0 file:bg-raised file:px-2 file:py-1 file:text-text" />88 </div>89 </div>90 <div>91 <label htmlFor="up-notes" className="label">Release notes (optional, Markdown)</label>92 <textarea id="up-notes" name="releaseNotes" rows={3} maxLength={20000} className="input py-2" />93 </div>94 <p className="text-xs text-muted">The manifest inside the archive must carry the same name and version. A published version can never be changed or replaced.</p>95 <div className="flex flex-wrap items-center gap-3">96 <button type="submit" disabled={busy} className="btn btn-primary">{busy ? "Working…" : "Upload and publish"}</button>97 {busy && (98 <div className="flex min-w-48 flex-1 items-center gap-3">99 <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-raised" role="progressbar" aria-label="Upload progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={phase === "upload" ? progress : undefined}>100 <div className="h-full bg-accent transition-[width]" style={{ width: `${phase === "upload" ? progress : phase === "finalize" ? 100 : 8}%` }} />101 </div>102 <span className="font-mono text-xs text-muted">{label[phase]}</span>103 </div>104 )}105 </div>106 <p aria-live="polite" role={phase === "error" ? "alert" : "status"} className={`text-sm ${phase === "error" ? "text-danger" : "text-ok"}`}>{message}</p>107 {findings.length > 0 && (108 <ul className="divide-y divide-line rounded-lg border border-line text-sm" aria-label="Validation findings">109 {findings.filter((f) => f.blocking || f.severity !== "info").map((f, i) => (110 <li key={i} className="flex flex-wrap items-baseline gap-x-3 px-3 py-2">111 <span className={`w-20 shrink-0 font-mono text-[11px] uppercase ${f.blocking ? "text-danger" : "text-warn"}`}>{f.blocking ? "blocking" : f.severity}</span>112 <span className="min-w-0 flex-1 text-muted">{f.message}</span>113 {f.path && <code className="font-mono text-[11px] text-faint">{f.path}{f.line ? `:${f.line}` : ""}</code>}114 </li>115 ))}116 </ul>117 )}118 </form>119 );120}121