SPB Git

Repository Public MIT

publish.ts 23.3 KB 334 lines typescript

1import { createHash } from "node:crypto";2import semver from "semver";3import {4  ArchiveError, LIMITS, SCANNER_VERSION, normalizeCompatibility, parseName, readArchive, securityStatus, validatePackage, versionSchema, type Finding,5} from "@skills/registry-core";6import { audit } from "./audit";7import { isPlatformAdmin, requireScope } from "./auth";8import { userOf, type Ctx } from "./context";9import { verifier } from "./crypto";10import { ApiError, badRequest, conflict, forbidden, notFound, unprocessable } from "./errors";11import { env } from "./env";12import { getArtifactRow, getRelease, recomputeLatest, releaseDto, writableArtifact, type ArtifactRow, type ReleaseRow } from "./registry";13import { discardStaging, openBlob, promoteStaging, readStaging, receiveToStaging } from "./storage";1415const UPLOAD_TTL_MIN = 60;16/** Findings that send a release to human review instead of publishing it directly. */17const REVIEW_CODES = new Set(["exec-pipe-to-shell", "exec-encoded-payload", "exec-destructive", "exec-eval-remote", "cred-access", "prompt-exfiltration", "prompt-concealment", "prompt-override", "text-bidi-control"]);1819interface UploadRow {20  id: string;21  artifact_id: string;22  version: string;23  declared_sha256: string;24  declared_size: string;25  release_notes: string | null;26  source_commit: string | null;27  state: "pending" | "uploaded" | "validating" | "security_review" | "published" | "rejected" | "quarantined" | "expired";28  received_sha256: string | null;29  release_id: string | null;30  rejection: { findings: Finding[] } | null;31  created_by: string;32  created_by_token: string | null;33  expires_at: Date;34}3536export function uploadDto(u: UploadRow) {37  return {38    uploadId: u.id, state: u.state, version: u.version, sha256: u.declared_sha256, sizeBytes: Number(u.declared_size), expiresAt: u.expires_at,39    uploadUrl: `${env.publicUrl}/api/v1/uploads/${u.id}`, uploadMethod: "PUT", uploadContentType: "application/gzip",40    finalizeUrl: `${env.publicUrl}/api/v1/uploads/${u.id}/finalize`, releaseId: u.release_id, findings: u.rejection?.findings ?? undefined,41  };42}4344export interface BeginUploadInput {45  version: string;46  sha256: string;47  sizeBytes: number;48  releaseNotes?: string;49  sourceCommit?: string;50}5152export async function beginUpload(ctx: Ctx, artifactId: string, input: BeginUploadInput) {53  const { a } = await writableArtifact(ctx, artifactId, "uploads:create");54  const v = versionSchema.safeParse(input.version);55  if (!v.success) throw badRequest("version must be a strict semantic version such as 1.2.0 (no build metadata).");56  if (typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.sha256)) throw badRequest("sha256 must be the lowercase hex SHA-256 of the archive.");57  if (!Number.isInteger(input.sizeBytes) || input.sizeBytes < 64) throw badRequest("sizeBytes must be the archive size in bytes.");58  if (input.sizeBytes > LIMITS.maxArchiveBytes) throw new ApiError(413, "payload_too_large", `Archives are limited to ${LIMITS.maxArchiveBytes} bytes.`);59  if (input.sourceCommit !== undefined && !/^[0-9a-f]{7,64}$/.test(input.sourceCommit)) throw badRequest("sourceCommit must be a hexadecimal commit id.");60  if (a.state === "archived" || a.state === "taken_down") throw forbidden(`@${a.namespace}/${a.slug} is ${a.state.replace("_", " ")}; new releases are not accepted.`, "artifact_closed");61  const [exists] = await ctx.sql`SELECT 1 FROM releases WHERE artifact_id = ${a.id} AND version = ${input.version}`;62  if (exists) throw conflict(`Version ${input.version} of @${a.namespace}/${a.slug} already exists. Releases are immutable; publish a new version.`, "version_exists");63  const p = ctx.principal;64  const user = userOf(p)!;65  const [u] = await ctx.sql<UploadRow[]>`66    INSERT INTO uploads (artifact_id, version, declared_sha256, declared_size, release_notes, source_commit, created_by, created_by_token, expires_at)67    VALUES (${a.id}, ${input.version}, ${input.sha256}, ${input.sizeBytes}, ${input.releaseNotes?.slice(0, 20000) ?? null}, ${input.sourceCommit ?? null},68            ${user.id}, ${p.kind === "token" ? p.token.id : null}, ${new Date(Date.now() + UPLOAD_TTL_MIN * 60_000)}) RETURNING *`;69  await audit(ctx, { action: "upload.begin", targetType: "artifact", targetId: a.id, targetLabel: `@${a.namespace}/${a.slug}@${input.version}`, detail: { uploadId: u!.id, sha256: input.sha256, sizeBytes: input.sizeBytes } });70  return uploadDto(u!);71}7273/** Uploads belong to the credential that began them; another token of the same user cannot hijack one. */74async function ownUpload(ctx: Ctx, uploadId: string, scope: "uploads:create" | "releases:publish"): Promise<{ u: UploadRow; a: ArtifactRow }> {75  requireScope(ctx.principal, scope);76  if (!/^[0-9a-f-]{36}$/.test(uploadId)) throw notFound("Upload");77  const [u] = await ctx.sql<UploadRow[]>`SELECT * FROM uploads WHERE id = ${uploadId}`;78  const user = userOf(ctx.principal)!;79  if (!u || u.created_by !== user.id) throw notFound("Upload");80  const { a } = await writableArtifact(ctx, u.artifact_id, scope);81  if (u.state === "pending" && u.expires_at.getTime() < Date.now()) {82    await ctx.sql`UPDATE uploads SET state = 'expired', updated_at = now() WHERE id = ${u.id}`;83    discardStaging(u.id);84    throw new ApiError(410, "upload_expired", "This upload session expired. Begin a new one.");85  }86  return { u, a };87}8889export async function receiveUpload(ctx: Ctx, uploadId: string, body: ReadableStream<Uint8Array> | null, contentLength: number | null) {90  const { u, a } = await ownUpload(ctx, uploadId, "uploads:create");91  if (u.state !== "pending") throw conflict(`This upload is already ${u.state}.`, "upload_state");92  const declared = Number(u.declared_size);93  if (contentLength !== null && contentLength !== declared) throw badRequest(`Content-Length ${contentLength} does not match the declared size ${declared}.`);94  const got = await receiveToStaging(u.id, body, declared);95  if (got.size !== declared || got.sha256 !== u.declared_sha256) {96    discardStaging(u.id);97    await audit(ctx, { action: "upload.receive", result: "denied", targetType: "artifact", targetId: a.id, detail: { uploadId: u.id, reason: "checksum_or_size_mismatch", receivedSize: got.size } });98    throw unprocessable("The received bytes do not match the declared size and SHA-256. Nothing was stored.", "checksum_mismatch", { expectedSha256: u.declared_sha256, receivedSha256: got.sha256, expectedSize: declared, receivedSize: got.size });99  }100  const [n] = await ctx.sql<UploadRow[]>`UPDATE uploads SET state = 'uploaded', staging_key = ${u.id}, received_sha256 = ${got.sha256}, received_size = ${got.size}, updated_at = now() WHERE id = ${u.id} AND state = 'pending' RETURNING *`;101  if (!n) throw conflict("This upload changed state concurrently.", "upload_state");102  await audit(ctx, { action: "upload.receive", targetType: "artifact", targetId: a.id, targetLabel: `@${a.namespace}/${a.slug}@${u.version}`, detail: { uploadId: u.id, sha256: got.sha256 } });103  return uploadDto(n);104}105106/**107 * uploaded → validating → (rejected | security_review → published | quarantined).108 * The archive is parsed in memory and statically inspected. It is never extracted to disk and never executed.109 */110export async function finalizeUpload(ctx: Ctx, uploadId: string, input: { sha256: string }) {111  const { u, a } = await ownUpload(ctx, uploadId, "releases:publish");112  if (u.state === "published" || u.state === "quarantined") {113    // Idempotent replay.114    const r = await getRelease({ ...ctx }, await getArtifactRow(ctx, a.namespace, a.slug), u.version);115    return { upload: uploadDto(u), release: releaseDto(a, r), findings: [] as Finding[], replayed: true };116  }117  if (u.state !== "uploaded") throw conflict(u.state === "pending" ? "Send the archive bytes (PUT uploadUrl) before finalizing." : `This upload is ${u.state}.`, "upload_state", u.rejection ?? undefined);118  if (input.sha256 !== u.declared_sha256) throw unprocessable("sha256 does not match the uploaded archive.", "checksum_mismatch");119  const [claimed] = await ctx.sql<UploadRow[]>`UPDATE uploads SET state = 'validating', updated_at = now() WHERE id = ${u.id} AND state = 'uploaded' RETURNING *`;120  if (!claimed) throw conflict("This upload is already being finalized.", "upload_state");121  const label = `@${a.namespace}/${a.slug}@${u.version}`;122123  const reject = async (findings: Finding[]): Promise<never> => {124    discardStaging(u.id);125    await ctx.sql`UPDATE uploads SET state = 'rejected', rejection = ${ctx.sql.json({ findings } as never)}, updated_at = now() WHERE id = ${u.id}`;126    await ctx.sql`INSERT INTO scan_results (upload_id, scanner_version, findings, max_severity, blocking_count)127                  VALUES (${u.id}, ${SCANNER_VERSION}, ${ctx.sql.json(findings as never)}, ${findings[0]?.severity ?? null}, ${findings.filter((f) => f.blocking).length})`;128    await audit(ctx, { action: "release.reject", result: "denied", targetType: "artifact", targetId: a.id, targetLabel: label, detail: { uploadId: u.id, codes: [...new Set(findings.filter((f) => f.blocking).map((f) => f.code))] } });129    throw unprocessable(`Release rejected: ${findings.filter((f) => f.blocking).length} blocking finding(s). Nothing was published.`, "release_rejected", { findings });130  };131132  let bytes: Buffer;133  try {134    bytes = readStaging(u.id);135  } catch {136    await ctx.sql`UPDATE uploads SET state = 'expired', updated_at = now() WHERE id = ${u.id}`;137    throw new ApiError(410, "upload_expired", "The staged archive is no longer available. Begin a new upload.");138  }139  if (createHash("sha256").update(bytes).digest("hex") !== u.declared_sha256) return reject([{ code: "archive-checksum", severity: "critical", message: "staged archive does not match its checksum", blocking: true }]);140141  let files;142  try {143    files = readArchive(bytes);144  } catch (e) {145    if (e instanceof ArchiveError) return reject([{ code: e.code, severity: "critical", message: e.message, blocking: true }]);146    throw e;147  }148  const result = validatePackage(files, { expectedName: `@${a.namespace}/${a.slug}`, expectedVersion: u.version });149  const findings = [...result.findings];150  const m = result.manifest;151  if (m && m.type !== a.type) findings.unshift({ code: "manifest-type-mismatch", severity: "high", message: `manifest type ${m.type} does not match the artifact type ${a.type}; the type of an artifact is immutable`, path: "agent-registry.json", blocking: true });152153  // Dependency references must resolve to visible artifacts (and, for bundles, to existing versions).154  const deps = m?.dependencies ?? [];155  for (const d of deps) {156    const id = parseName(d.name)!;157    const rows = await ctx.sql<{ version: string; visibility: string }[]>`158      SELECT r.version, x.visibility FROM artifacts x LEFT JOIN releases r ON r.artifact_id = x.id AND r.state = 'published'159      WHERE x.namespace = ${id.namespace} AND x.slug = ${id.slug} AND x.state IN ('active','deprecated') AND (x.visibility <> 'private' OR x.namespace = ${a.namespace})`;160    const versions = rows.map((r) => r.version).filter(Boolean);161    if (rows.length === 0) {162      findings.push({ code: "dependency-unknown", severity: d.optional ? "low" : "high", message: `dependency ${d.name} does not exist in this registry`, path: "agent-registry.json", blocking: !d.optional });163    } else if (!versions.some((ver) => semver.satisfies(ver, d.version, { includePrerelease: true }))) {164      findings.push({ code: "dependency-unsatisfied", severity: d.optional ? "low" : "high", message: `no published version of ${d.name} satisfies "${d.version}"`, path: "agent-registry.json", blocking: !d.optional });165    }166  }167  if (!m || findings.some((f) => f.blocking)) return reject(findings);168169  await ctx.sql`UPDATE uploads SET state = 'security_review', updated_at = now() WHERE id = ${u.id}`;170  const needsReview = findings.some((f) => REVIEW_CODES.has(f.code));171  const state = needsReview ? "quarantined" : "published";172  const status = securityStatus(findings);173  const sv = semver.parse(u.version)!;174  const user = userOf(ctx.principal)!;175  const key = promoteStaging(u.id, u.declared_sha256);176177  const release = await ctx.sql.begin(async (tx) => {178    const [r] = await tx<ReleaseRow[]>`179      INSERT INTO releases (artifact_id, version, v_major, v_minor, v_patch, v_prerelease, manifest_json, archive_object_key, sha256, size_bytes, unpacked_bytes, file_count,180                            source_commit, release_notes, readme, published_by, published_by_token, state, security_status)181      VALUES (${a.id}, ${u.version}, ${sv.major}, ${sv.minor}, ${sv.patch}, ${sv.prerelease.length ? sv.prerelease.join(".") : null}, ${tx.json(m as never)}, ${key},182              ${u.declared_sha256}, ${bytes.length}, ${result.totalBytes}, ${result.files.length}, ${u.source_commit ?? m.sourceCommit ?? null}, ${u.release_notes},183              ${result.readme}, ${user.id}, ${u.created_by_token}, ${state}, ${status})184      RETURNING *`;185    const byPath = new Map(files.map((f) => [f.path, f]));186    const fileRows = result.files.map((f) => ({187      release_id: r!.id, path: f.path, size_bytes: f.size, sha256: f.sha256, mime: f.mime, mode: f.mode, is_text: f.isText,188      content_text: f.isText && f.size <= LIMITS.maxPreviewBytes ? byPath.get(f.path)!.data.toString("utf8").replace(/\u0000/g, "") : null,189    }));190    for (let i = 0; i < fileRows.length; i += 200) await tx`INSERT INTO release_files ${tx(fileRows.slice(i, i + 200))}`;191    for (const d of deps) {192      const id = parseName(d.name)!;193      await tx`INSERT INTO release_dependencies (release_id, dep_namespace, dep_slug, version_range, optional) VALUES (${r!.id}, ${id.namespace}, ${id.slug}, ${d.version}, ${d.optional ?? false})`;194    }195    for (const c of normalizeCompatibility(m)) {196      await tx`INSERT INTO release_compatibility (release_id, client, platform, min_version, install_strategy) VALUES (${r!.id}, ${c.client}, ${c.platform}, ${c.minVersion}, ${c.installStrategy})197               ON CONFLICT DO NOTHING`;198    }199    await tx`INSERT INTO scan_results (upload_id, release_id, scanner_version, findings, max_severity, blocking_count)200             VALUES (${u.id}, ${r!.id}, ${SCANNER_VERSION}, ${tx.json(findings as never)}, ${result.maxSeverity}, 0)`;201    await tx`UPDATE uploads SET state = ${state}, release_id = ${r!.id}, updated_at = now() WHERE id = ${u.id}`;202    if (state === "published") {203      await recomputeLatest(tx as never, a.id);204      const [art] = await tx<{ latest_release_id: string | null }[]>`SELECT latest_release_id FROM artifacts WHERE id = ${a.id}`;205      if (art!.latest_release_id === r!.id) {206        // Listing metadata follows the newest release's manifest; history stays in each release row.207        await tx`UPDATE artifacts SET summary = ${m.description}, license = ${m.license}, keywords = ${m.keywords ?? []}, capabilities = ${result.capabilities},208                   description = ${result.readme}, repository_url = coalesce(${m.repository ?? null}, repository_url), homepage_url = coalesce(${m.homepage ?? null}, homepage_url),209                   issues_url = coalesce(${m.issues ?? null}, issues_url),210                   source_verified = CASE WHEN repository_url IS DISTINCT FROM coalesce(${m.repository ?? null}, repository_url) THEN false ELSE source_verified END,211                   updated_at = now() WHERE id = ${a.id}`;212      }213    }214    await audit({ ...ctx, sql: tx as never }, {215      action: state === "published" ? "release.publish" : "release.quarantine", targetType: "release", targetId: r!.id, targetLabel: label,216      detail: { uploadId: u.id, sha256: r!.sha256, sizeBytes: bytes.length, files: result.files.length, securityStatus: status, advisoryCodes: [...new Set(findings.map((f) => f.code))] },217    });218    return r!;219  });220  const [uNow] = await ctx.sql<UploadRow[]>`SELECT * FROM uploads WHERE id = ${u.id}`;221  return { upload: uploadDto(uNow!), release: releaseDto(a, release), findings, replayed: false };222}223224export async function getUpload(ctx: Ctx, uploadId: string) {225  const { u } = await ownUpload(ctx, uploadId, "uploads:create");226  return uploadDto(u);227}228229async function releaseById(ctx: Ctx, id: string): Promise<ReleaseRow> {230  if (!/^[0-9a-f-]{36}$/.test(id)) throw notFound("Release");231  const [r] = await ctx.sql<ReleaseRow[]>`SELECT * FROM releases WHERE id = ${id}`;232  if (!r) throw notFound("Release");233  return r;234}235236/** Revocation is the takedown state for a compromised version. The record and its audit trail remain. */237export async function revokeRelease(ctx: Ctx, releaseId: string, input: { reason?: string; confirm?: boolean }) {238  requireScope(ctx.principal, "releases:revoke");239  const r0 = await releaseById(ctx, releaseId);240  const p = ctx.principal;241  const user = userOf(p)!;242  const moderator = isPlatformAdmin(user) && (p.kind !== "token" || (p.token.elevated && p.token.scopes.includes("admin:moderate")));243  const a = moderator ? await getArtifactRow({ ...ctx, principal: { kind: "local", user } }, ...(await nameOf(ctx, r0.artifact_id))) : (await writableArtifact(ctx, r0.artifact_id, "releases:revoke")).a;244  const reason = (input.reason ?? "").trim().slice(0, 1000);245  if (reason.length < 5) throw badRequest("A revocation reason (5+ characters) is required; it is shown publicly.");246  if (input.confirm !== true) throw new ApiError(428, "confirmation_required", `Revoking @${a.namespace}/${a.slug}@${r0.version} blocks downloads permanently. Resend with confirm: true.`);247  if (r0.state === "revoked") return releaseDto(a, r0);248  const r = await ctx.sql.begin(async (tx) => {249    const [x] = await tx<ReleaseRow[]>`UPDATE releases SET state = 'revoked', revoked_at = now(), revoked_by = ${user.id}, revocation_reason = ${reason} WHERE id = ${r0.id} RETURNING *`;250    await recomputeLatest(tx as never, a.id);251    await audit({ ...ctx, sql: tx as never }, { action: "release.revoke", targetType: "release", targetId: r0.id, targetLabel: `@${a.namespace}/${a.slug}@${r0.version}`, detail: { reason, asModerator: moderator } });252    return x!;253  });254  return releaseDto(a, r);255}256257async function nameOf(ctx: Ctx, artifactId: string): Promise<[string, string]> {258  const [x] = await ctx.sql<{ namespace: string; slug: string }[]>`SELECT namespace, slug FROM artifacts WHERE id = ${artifactId}`;259  if (!x) throw notFound("Artifact");260  return [x.namespace, x.slug];261}262263/** releases:update — release notes only. Content, manifest, version and checksum are frozen by a database trigger. */264export async function updateReleaseNotes(ctx: Ctx, releaseId: string, patch: Record<string, unknown>) {265  requireScope(ctx.principal, "releases:update");266  const r0 = await releaseById(ctx, releaseId);267  const { a } = await writableArtifact(ctx, r0.artifact_id, "releases:update");268  const keys = Object.keys(patch);269  if (keys.length !== 1 || keys[0] !== "releaseNotes") throw new ApiError(409, "release_immutable", "Published releases are immutable. Only `releaseNotes` can be edited; publish a new version to change content.");270  const notes = patch.releaseNotes === null ? null : String(patch.releaseNotes).slice(0, 20000);271  const [r] = await ctx.sql<ReleaseRow[]>`UPDATE releases SET release_notes = ${notes} WHERE id = ${r0.id} RETURNING *`;272  await audit(ctx, { action: "release.update_notes", targetType: "release", targetId: r0.id, targetLabel: `@${a.namespace}/${a.slug}@${r0.version}`, detail: { previousLength: r0.release_notes?.length ?? 0, newLength: notes?.length ?? 0 } });273  return releaseDto(a, r!);274}275276/** Moderator decision on a quarantined release. */277export async function reviewRelease(ctx: Ctx, releaseId: string, input: { decision: string; reason?: string }) {278  requireScope(ctx.principal, "admin:moderate");279  const p = ctx.principal;280  if (p.kind === "token" && !p.token.elevated) throw forbidden("Moderation requires an elevated token.", "admin_required");281  const r0 = await releaseById(ctx, releaseId);282  const user = userOf(p)!;283  const [ns, slug] = await nameOf(ctx, r0.artifact_id);284  if (r0.state !== "quarantined") throw conflict(`This release is ${r0.state}, not quarantined.`, "release_state");285  if (input.decision !== "approve" && input.decision !== "reject") throw badRequest("decision must be approve or reject.");286  const reason = (input.reason ?? "").trim().slice(0, 1000);287  if (reason.length < 5) throw badRequest("A review note (5+ characters) is required.");288  await ctx.sql.begin(async (tx) => {289    if (input.decision === "approve") await tx`UPDATE releases SET state = 'published' WHERE id = ${r0.id}`;290    else await tx`UPDATE releases SET state = 'revoked', revoked_at = now(), revoked_by = ${user.id}, revocation_reason = ${`Rejected in security review: ${reason}`} WHERE id = ${r0.id}`;291    await tx`UPDATE uploads SET state = ${input.decision === "approve" ? "published" : "rejected"}, updated_at = now() WHERE release_id = ${r0.id}`;292    await recomputeLatest(tx as never, r0.artifact_id);293    await tx`INSERT INTO moderation_actions (actor_user_id, action, target_type, target_id, reason) VALUES (${user.id}, ${`release.review.${input.decision}`}, 'release', ${r0.id}, ${reason})`;294    await audit({ ...ctx, sql: tx as never }, { action: `release.review.${input.decision}`, targetType: "release", targetId: r0.id, targetLabel: `@${ns}/${slug}@${r0.version}`, detail: { reason } });295  });296  const a = await getArtifactRow({ ...ctx, principal: { kind: "local", user } }, ns, slug);297  return releaseDto(a, await releaseById(ctx, r0.id));298}299300/* ───────────────────────── downloads ───────────────────────── */301302const BOT_UA = /bot|crawl|spider|slurp|preview|monitor|uptime|headless|scan|python-urllib|go-http-client\/1|facebookexternalhit|curl\/0/i;303304/** Daily aggregate, one count per (release, visitor-hash, day). The hash is salted per day and purged after two days. */305export async function countDownload(ctx: Ctx, releaseId: string) {306  if (!ctx.userAgent || BOT_UA.test(ctx.userAgent)) return;307  const day = new Date().toISOString().slice(0, 10);308  const hash = verifier("download", `${day}\0${releaseId}\0${ctx.ip}\0${ctx.userAgent}`).slice(0, 32);309  const ins = await ctx.sql`INSERT INTO download_dedupe (day, hash) VALUES (${day}, ${hash}) ON CONFLICT DO NOTHING`;310  if (ins.count === 0) return;311  await ctx.sql`INSERT INTO download_daily (release_id, day, count) VALUES (${releaseId}, ${day}, 1) ON CONFLICT (release_id, day) DO UPDATE SET count = download_daily.count + 1`;312  if (Math.random() < 0.01) await ctx.sql`DELETE FROM download_dedupe WHERE day < current_date - 2`;313}314315export async function downloadRelease(ctx: Ctx, a: ArtifactRow, r: ReleaseRow, ifNoneMatch: string | null): Promise<Response> {316  if (r.state === "revoked") throw new ApiError(410, "release_revoked", `@${a.namespace}/${a.slug}@${r.version} was revoked: ${r.revocation_reason ?? "no reason recorded"}.`);317  const etag = `"sha256-${r.sha256}"`;318  const isPublic = a.visibility !== "private" && r.state === "published";319  const headers: Record<string, string> = {320    ETag: etag,321    "Cache-Control": isPublic ? "public, max-age=31536000, immutable" : "private, no-store",322    "X-Checksum-Sha256": r.sha256,323    "Repr-Digest": `sha-256=:${Buffer.from(r.sha256, "hex").toString("base64")}:`,324    "X-Content-Type-Options": "nosniff",325  };326  if (ifNoneMatch && ifNoneMatch.split(",").map((s) => s.trim()).includes(etag)) return new Response(null, { status: 304, headers });327  const { stream, size } = openBlob(r.archive_object_key);328  await countDownload(ctx, r.id);329  return new Response(stream, {330    status: 200,331    headers: { ...headers, "Content-Type": "application/gzip", "Content-Length": String(size), "Content-Disposition": `attachment; filename="${a.slug}-${r.version}.tar.gz"` },332  });333}334
Files main
Menu

Theme