1import {2 canonicalManifestBytes,3 formatAddress,4 isValidName,5 manifestHash,6 MAX_BLOB_BYTES,7 sha256hex,8 vaultSlotOf,9} from "../../../shared/mod.ts";10import type {11 CommitRequest,12 CommitResponse,13 Manifest,14 PrepareRequest,15 PrepareResponse,16 Target,17} from "../../../shared/mod.ts";18import { purgeSiteCache } from "../cache.ts";19import {20 allocateAddress,21 commitSite,22 getAppReservationByAddress,23 getAppReservationByAlias,24 getSiteByAddress,25 getVaultByName,26 getVaultBySlot,27 resolveTarget,28} from "../db.ts";29import { type Env, json, siteUrl } from "../env.ts";30import { ApiError, clientInput, readJson } from "./errors.ts";31import { HEX64, passwordHashFor, resolveCommitTtl } from "./common.ts";3233const HEAD_BATCH_SIZE = 6;3435async function missingBlobs(env: Env, manifest: Manifest): Promise<string[]> {36 const hashes = [37 ...new Set(Object.values(manifest.files).map((file) => file.h)),38 ];39 const missing: string[] = [];40 for (let i = 0; i < hashes.length; i += HEAD_BATCH_SIZE) {41 const batch = hashes.slice(i, i + HEAD_BATCH_SIZE);42 const heads = await Promise.all(43 batch.map((hash) => env.CONTENT.head(`blob/${hash}`)),44 );45 heads.forEach((head, index) => {46 if (!head) missing.push(batch[index]);47 });48 }49 return missing;50}5152export async function handlePrepare(53 request: Request,54 env: Env,55): Promise<Response> {56 const body = await readJson<PrepareRequest>(request);57 const hash = await clientInput(() => manifestHash(body.manifest));58 return json<PrepareResponse>({59 manifestHash: hash,60 missing: await missingBlobs(env, body.manifest),61 });62}6364export async function handleBlobPut(65 request: Request,66 env: Env,67 hash: string,68): Promise<Response> {69 if (!HEX64.test(hash)) throw new ApiError(400, "invalid blob hash");70 const contentLength = request.headers.get("content-length");71 if (contentLength === null) {72 throw new ApiError(411, "content-length required");73 }74 const declaredBytes = Number(contentLength);75 if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0) {76 throw new ApiError(400, "invalid content-length");77 }78 if (declaredBytes > MAX_BLOB_BYTES) throw new ApiError(413, "blob too large");79 const bytes = new Uint8Array(await request.arrayBuffer());80 if (bytes.length > MAX_BLOB_BYTES) throw new ApiError(413, "blob too large");81 if (bytes.length !== declaredBytes) {82 throw new ApiError(400, "content-length mismatch");83 }84 const actual = await sha256hex(bytes);85 if (actual !== hash) {86 throw new ApiError(400, `hash mismatch: body is ${actual}`);87 }88 await env.CONTENT.put(`blob/${hash}`, bytes);89 return json({ ok: true, hash });90}9192export async function handleCommit(93 request: Request,94 env: Env,95 ctx: ExecutionContext,96): Promise<Response> {97 const body = await readJson<CommitRequest>(request);98 const passwordHash = await passwordHashFor(body.password);99 const bytes = await clientInput(() => canonicalManifestBytes(body.manifest));100 const hash = await sha256hex(bytes);101 const missing = await missingBlobs(env, body.manifest);102 if (missing.length > 0) {103 throw new ApiError(104 409,105 `cannot commit: ${missing.length} blobs missing (run prepare again)`,106 );107 }108109 const target = body.target as Target;110 let address: number;111 let alias: string | null = null;112 let isNew: boolean;113 let existingSite: Awaited<ReturnType<typeof getSiteByAddress>> = null;114 let reservation: Awaited<ReturnType<typeof getAppReservationByAddress>> = null;115 let vault: Awaited<ReturnType<typeof getVaultBySlot>>;116 if ("address" in target) {117 existingSite = await getSiteByAddress(env, target.address);118 const slot = vaultSlotOf(target.address);119 vault = await getVaultBySlot(env, slot);120 if (!vault) {121 throw new ApiError(122 404,123 `vault slot 0x${slot.toString(16)} not registered`,124 );125 }126 reservation = await getAppReservationByAddress(env, target.address);127 address = target.address;128 alias = existingSite?.alias ?? reservation?.alias ?? null;129 isNew = !existingSite;130 } else {131 vault = await getVaultByName(env, target.vault);132 if (!vault) throw new ApiError(404, `unknown vault: ${target.vault}`);133 alias = target.alias ?? null;134 if (alias !== null && !isValidName(alias)) {135 throw new ApiError(400, `invalid alias: ${alias}`);136 }137 existingSite = alias === null ? null : await resolveTarget(env, { vault: target.vault, alias });138 if (existingSite) {139 address = existingSite.address;140 isNew = false;141 } else {142 reservation = alias === null ? null : await getAppReservationByAlias(env, vault.slot, alias);143 address = reservation?.address ?? await allocateAddress(env, vault.slot);144 isNew = true;145 }146 reservation ??= await getAppReservationByAddress(env, address);147 }148149 if (body.app !== undefined && reservation === null) {150 throw new ApiError(409, "app deployments require an initialized app reservation");151 }152 if (reservation !== null && body.app === undefined) {153 throw new ApiError(409, "reserved app origins must be deployed with nzip app deploy");154 }155 const contentSecurityPolicy = body.app === undefined156 ? undefined157 : body.app.contentSecurityPolicy ?? null;158 if (159 typeof contentSecurityPolicy === "string" &&160 (contentSecurityPolicy.length > 8192 || /[\r\n\0]/.test(contentSecurityPolicy))161 ) {162 throw new ApiError(400, "content security policy must be one line and at most 8192 characters");163 }164165 const resolvedTtl = resolveCommitTtl(166 body.ttl,167 existingSite?.expires_at,168 vault.default_ttl,169 );170 const protectedSite = passwordHash === undefined && existingSite171 ? existingSite.password_hash !== null172 : passwordHash !== null && passwordHash !== undefined;173174 if (!(await env.CONTENT.head(`manifest/${hash}`))) {175 await env.CONTENT.put(`manifest/${hash}`, bytes);176 }177 const seq = await commitSite(env, {178 address,179 vaultSlot: vaultSlotOf(address),180 alias,181 manifestHash: hash,182 expiresAt: resolvedTtl.expiresAt,183 passwordHash,184 contentSecurityPolicy,185 isNew,186 });187 const addressString = formatAddress(address);188 await purgeSiteCache(ctx, addressString);189 return json<CommitResponse>({190 address: addressString,191 url: siteUrl(env, addressString),192 alias,193 manifestHash: hash,194 expiresAt: resolvedTtl.expiresAt,195 ttl: resolvedTtl.ttl,196 ttlSource: resolvedTtl.ttlSource,197 protected: protectedSite,198 seq,199 });200}