clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

codex/agent-interface
nzip / worker / src / api.ts
api.ts 10.4K
1import {2 canonicalManifestBytes,3 formatAddress,4 isValidName,5 manifestHash,6 parseTarget,7 sha256hex,8 VAULT_SLOTS,9 vaultSlotOf,10} from "../../shared/mod.ts";11import type {12 CommitRequest,13 CommitResponse,14 Manifest,15 PatchSiteRequest,16 PrepareRequest,17 PrepareResponse,18 RevertRequest,19 StatusResponse,20 Target,21} from "../../shared/mod.ts";22import {23 allocateAddress,24 commitSite,25 createVault,26 getSiteByAddress,27 getVaultByName,28 getVaultBySlot,29 listSites,30 listVaults,31 resolveTarget,32 siteHistory,33 siteRowToDetail,34 siteRowToInfo,35 type SiteRow,36} from "./db.ts";37import { type Env, err, json, siteUrl } from "./env.ts";38import { hashPassword } from "./password.ts";3940const HEX64 = /^[0-9a-f]{64}$/;41const DEFAULT_TTL_DAYS = 14;42const MAX_BLOB_BYTES = 50 * 1024 * 1024;43const HEAD_CHUNK = 40; // stay under Worker subrequest limits on big bundles4445function ttlToExpiry(ttl: number | "forever" | undefined): number | null {46 if (ttl === "forever") return null;47 const days = ttl ?? DEFAULT_TTL_DAYS;48 if (!Number.isFinite(days) || days <= 0 || days > 3650) {49 throw new Error("ttl must be 1-3650 days or \"forever\"");50 }51 return Math.floor(Date.now() / 1000) + Math.round(days * 86400);52}5354/** Which blob hashes referenced by the manifest are absent from R2? */55async function missingBlobs(env: Env, manifest: Manifest): Promise<string[]> {56 const hashes = [...new Set(Object.values(manifest.files).map((f) => f.h))];57 const missing: string[] = [];58 for (let i = 0; i < hashes.length; i += HEAD_CHUNK) {59 const chunk = hashes.slice(i, i + HEAD_CHUNK);60 const heads = await Promise.all(chunk.map((h) => env.CONTENT.head(`blob/${h}`)));61 heads.forEach((head, j) => {62 if (!head) missing.push(chunk[j]);63 });64 }65 return missing;66}6768async function handlePrepare(req: Request, env: Env): Promise<Response> {69 const body = await req.json<PrepareRequest>();70 const hash = await manifestHash(body.manifest); // validates too71 return json<PrepareResponse>({ manifestHash: hash, missing: await missingBlobs(env, body.manifest) });72}7374async function handleBlobPut(req: Request, env: Env, hash: string): Promise<Response> {75 if (!HEX64.test(hash)) return err("invalid blob hash", 400);76 const bytes = new Uint8Array(await req.arrayBuffer());77 if (bytes.length > MAX_BLOB_BYTES) return err("blob too large", 413);78 const actual = await sha256hex(bytes);79 if (actual !== hash) return err(`hash mismatch: body is ${actual}`, 400);80 await env.CONTENT.put(`blob/${hash}`, bytes);81 return json({ ok: true, hash });82}8384async function handleCommit(req: Request, env: Env): Promise<Response> {85 const body = await req.json<CommitRequest>();86 const bytes = canonicalManifestBytes(body.manifest);87 const hash = await sha256hex(bytes);8889 // Guard against crashed uploads: every referenced blob must exist before commit.90 const missing = await missingBlobs(env, body.manifest);91 if (missing.length > 0) {92 return err(`cannot commit: ${missing.length} blobs missing (run prepare again)`, 409);93 }9495 let expiresAt: number | null;96 try {97 expiresAt = ttlToExpiry(body.ttl);98 } catch (e) {99 return err((e as Error).message, 400);100 }101102 // Resolve target → (address, vaultSlot, alias, isNew)103 const t = body.target as Target;104 let address: number;105 let alias: string | null = null;106 let isNew: boolean;107 if ("address" in t) {108 const existing = await getSiteByAddress(env, t.address);109 const slot = vaultSlotOf(t.address);110 if (!(await getVaultBySlot(env, slot))) return err(`vault slot 0x${slot.toString(16)} not registered`, 404);111 address = t.address;112 alias = existing?.alias ?? null;113 isNew = !existing;114 } else {115 const vault = await getVaultByName(env, t.vault);116 if (!vault) return err(`unknown vault: ${t.vault}`, 404);117 alias = t.alias ?? null;118 if (alias !== null && !isValidName(alias)) return err(`invalid alias: ${alias}`, 400);119 const existing = alias === null ? null : await resolveTarget(env, { vault: t.vault, alias });120 if (existing) {121 address = existing.address;122 isNew = false;123 } else {124 address = await allocateAddress(env, vault.slot);125 isNew = true;126 }127 }128129 if (!(await env.CONTENT.head(`manifest/${hash}`))) {130 await env.CONTENT.put(`manifest/${hash}`, bytes);131 }132 const seq = await commitSite(env, {133 address,134 vaultSlot: vaultSlotOf(address),135 alias,136 manifestHash: hash,137 expiresAt,138 isNew,139 });140141 const addressStr = formatAddress(address);142 return json<CommitResponse>({143 address: addressStr,144 url: siteUrl(env, addressStr),145 alias,146 manifestHash: hash,147 expiresAt,148 seq,149 });150}151152/** Resolve a URL path target segment ("2a3f" | "work:demo") to a site row. */153async function resolvePathTarget(env: Env, segment: string): Promise<SiteRow | null> {154 const parsed = parseTarget(decodeURIComponent(segment));155 if (parsed.kind === "address") return await resolveTarget(env, { address: parsed.address });156 if (parsed.kind === "vaultAlias") {157 return await resolveTarget(env, { vault: parsed.vault, alias: parsed.alias });158 }159 return null; // bare alias needs a default vault — the CLI resolves that client-side160}161162async function handleStatus(env: Env): Promise<Response> {163 const vaults = await listVaults(env);164 const sites = await listSites(env);165 const soon = Math.floor(Date.now() / 1000) + 48 * 3600;166 return json<StatusResponse>({167 ok: true,168 version: "0.1.0",169 vaults,170 siteCount: sites.length,171 expiringSoon: sites.filter((s) => s.expires_at !== null && s.expires_at < soon).length,172 });173}174175export async function api(req: Request, env: Env, url: URL): Promise<Response> {176 const parts = url.pathname.split("/").filter(Boolean); // ["api", ...]177 const method = req.method;178179 try {180 // push protocol181 if (parts[1] === "push" && parts[2] === "prepare" && method === "POST") {182 return await handlePrepare(req, env);183 }184 if (parts[1] === "push" && parts[2] === "commit" && method === "POST") {185 return await handleCommit(req, env);186 }187 if (parts[1] === "blob" && parts.length === 3 && method === "PUT") {188 return await handleBlobPut(req, env, parts[2]);189 }190191 // status192 if (parts[1] === "status" && method === "GET") return await handleStatus(env);193194 // vaults195 if (parts[1] === "vaults" && parts.length === 2) {196 if (method === "GET") return json(await listVaults(env));197 if (method === "POST") {198 const body = await req.json<{ name: string; slot?: number }>();199 if (!isValidName(body.name)) return err("invalid vault name", 400);200 if (body.slot !== undefined && (body.slot < 0 || body.slot >= VAULT_SLOTS)) {201 return err("slot must be 0-15", 400);202 }203 try {204 const v = await createVault(env, body.name, body.slot);205 return json({ slot: v.slot, name: v.name, createdAt: v.created_at }, 201);206 } catch (e) {207 return err((e as Error).message, 409);208 }209 }210 }211212 // sites collection213 if (parts[1] === "sites" && parts.length === 2 && method === "GET") {214 const vaultName = url.searchParams.get("vault");215 let slot: number | undefined;216 if (vaultName) {217 const vault = await getVaultByName(env, vaultName);218 if (!vault) return err(`unknown vault: ${vaultName}`, 404);219 slot = vault.slot;220 }221 const rows = await listSites(env, slot);222 return json(rows.map((r) => siteRowToInfo(env, r, r.vault_name)));223 }224225 // sites/{target}[/revert]226 if (parts[1] === "sites" && parts.length >= 3) {227 const site = await resolvePathTarget(env, parts[2]);228 if (!site) return err("site not found", 404);229230 if (parts.length === 3 && method === "GET") {231 return json(await siteRowToDetail(env, site));232 }233 if (parts.length === 3 && method === "DELETE") {234 await env.DB.prepare("DELETE FROM sites WHERE address = ?").bind(site.address).run();235 return json({ ok: true, address: formatAddress(site.address) });236 }237 if (parts.length === 3 && method === "PATCH") {238 const body = await req.json<PatchSiteRequest>();239 const now = Math.floor(Date.now() / 1000);240 if (body.ttl !== undefined) {241 const expiresAt = ttlToExpiry(body.ttl);242 await env.DB.prepare("UPDATE sites SET expires_at = ?, updated_at = ? WHERE address = ?")243 .bind(expiresAt, now, site.address).run();244 }245 if (body.password !== undefined) {246 if (body.password !== null && body.password.length < 4) {247 return err("password must be at least 4 characters", 400);248 }249 const hash = body.password === null ? null : await hashPassword(body.password);250 await env.DB.prepare("UPDATE sites SET password_hash = ?, updated_at = ? WHERE address = ?")251 .bind(hash, now, site.address).run();252 }253 const updated = await getSiteByAddress(env, site.address);254 return json(await siteRowToDetail(env, updated!));255 }256 if (parts.length === 4 && parts[3] === "revert" && method === "POST") {257 return await handleRevert(req, env, site);258 }259 }260261 return err("not found", 404);262 } catch (e) {263 const msg = (e as Error).message ?? "internal error";264 // Validation errors from shared/ read as client mistakes.265 const status = /invalid|unsupported|too many|no files|must be/.test(msg) ? 400 : 500;266 return err(msg, status);267 }268}269270async function handleRevert(req: Request, env: Env, site: SiteRow): Promise<Response> {271 const body = await req.json<RevertRequest>().catch(() => ({} as RevertRequest));272 const history = await siteHistory(env, site.address); // newest first273274 let target;275 if (body.toSeq !== undefined) {276 target = history.find((h) => h.seq === body.toSeq);277 if (!target) return err(`no push #${body.toSeq} in history`, 404);278 } else {279 // Default: most recent push whose manifest differs from what's live now.280 target = history.find((h) => h.manifestHash !== site.current_manifest);281 if (!target) return err("nothing to revert to", 409);282 }283284 const seq = await commitSite(env, {285 address: site.address,286 vaultSlot: site.vault_slot,287 alias: site.alias,288 manifestHash: target.manifestHash,289 expiresAt: site.expires_at,290 isNew: false,291 note: `revert to #${target.seq}`,292 });293294 const addressStr = formatAddress(site.address);295 return json({296 address: addressStr,297 url: siteUrl(env, addressStr),298 alias: site.alias,299 manifestHash: target.manifestHash,300 revertedTo: target.seq,301 seq,302 });303}