SPB Git

Repository Public MIT

uploader.tsx 6.8 KB 121 lines tsx

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
Files main
Menu

Theme