clone

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

feat/site-feedback
nzip / worker / src / api / push.ts
push.ts 7.2K
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 { enforceVaultPolicy, HEX64, passwordHashFor, resolveCommitTtl } from "./common.ts";32import { enforceFeedbackPolicy, feedbackNeedsReset } from "../feedback.ts";3334const HEAD_BATCH_SIZE = 6;3536async function missingBlobs(env: Env, manifest: Manifest): Promise<string[]> {37 const hashes = [38 ...new Set(Object.values(manifest.files).map((file) => file.h)),39 ];40 const missing: string[] = [];41 for (let i = 0; i < hashes.length; i += HEAD_BATCH_SIZE) {42 const batch = hashes.slice(i, i + HEAD_BATCH_SIZE);43 const heads = await Promise.all(44 batch.map((hash) => env.CONTENT.head(`blob/${hash}`)),45 );46 heads.forEach((head, index) => {47 if (!head) missing.push(batch[index]);48 });49 }50 return missing;51}5253export async function handlePrepare(54 request: Request,55 env: Env,56): Promise<Response> {57 const body = await readJson<PrepareRequest>(request);58 const hash = await clientInput(() => manifestHash(body.manifest));59 return json<PrepareResponse>({60 manifestHash: hash,61 missing: await missingBlobs(env, body.manifest),62 });63}6465export async function handleBlobPut(66 request: Request,67 env: Env,68 hash: string,69): Promise<Response> {70 if (!HEX64.test(hash)) throw new ApiError(400, "invalid blob hash");71 const contentLength = request.headers.get("content-length");72 if (contentLength === null) {73 throw new ApiError(411, "content-length required");74 }75 const declaredBytes = Number(contentLength);76 if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0) {77 throw new ApiError(400, "invalid content-length");78 }79 if (declaredBytes > MAX_BLOB_BYTES) throw new ApiError(413, "blob too large");80 const bytes = new Uint8Array(await request.arrayBuffer());81 if (bytes.length > MAX_BLOB_BYTES) throw new ApiError(413, "blob too large");82 if (bytes.length !== declaredBytes) {83 throw new ApiError(400, "content-length mismatch");84 }85 const actual = await sha256hex(bytes);86 if (actual !== hash) {87 throw new ApiError(400, `hash mismatch: body is ${actual}`);88 }89 await env.CONTENT.put(`blob/${hash}`, bytes);90 return json({ ok: true, hash });91}9293export async function handleCommit(94 request: Request,95 env: Env,96 ctx: ExecutionContext,97): Promise<Response> {98 const body = await readJson<CommitRequest>(request);99 if (body.feedback !== undefined && typeof body.feedback !== "boolean") {100 throw new ApiError(400, "feedback must be a boolean");101 }102 let passwordHash = await passwordHashFor(body.password);103 const bytes = await clientInput(() => canonicalManifestBytes(body.manifest));104 const hash = await sha256hex(bytes);105 const missing = await missingBlobs(env, body.manifest);106 if (missing.length > 0) {107 throw new ApiError(108 409,109 `cannot commit: ${missing.length} blobs missing (run prepare again)`,110 );111 }112113 const target = body.target as Target;114 let address: number;115 let alias: string | null = null;116 let isNew: boolean;117 let existingSite: Awaited<ReturnType<typeof getSiteByAddress>> = null;118 let reservation: Awaited<ReturnType<typeof getAppReservationByAddress>> = null;119 let vault: Awaited<ReturnType<typeof getVaultBySlot>>;120 if ("address" in target) {121 existingSite = await getSiteByAddress(env, target.address);122 const slot = vaultSlotOf(target.address);123 vault = await getVaultBySlot(env, slot);124 if (!vault) {125 throw new ApiError(126 404,127 `vault slot 0x${slot.toString(16)} not registered`,128 );129 }130 reservation = await getAppReservationByAddress(env, target.address);131 address = target.address;132 alias = existingSite?.alias ?? reservation?.alias ?? null;133 isNew = !existingSite;134 } else {135 vault = await getVaultByName(env, target.vault);136 if (!vault) throw new ApiError(404, `unknown vault: ${target.vault}`);137 alias = target.alias ?? null;138 if (alias !== null && !isValidName(alias)) {139 throw new ApiError(400, `invalid alias: ${alias}`);140 }141 existingSite = alias === null ? null : await resolveTarget(env, { vault: target.vault, alias });142 if (existingSite) {143 address = existingSite.address;144 isNew = false;145 } else {146 reservation = alias === null ? null : await getAppReservationByAlias(env, vault.slot, alias);147 address = reservation?.address ?? await allocateAddress(env, vault.slot);148 isNew = true;149 }150 reservation ??= await getAppReservationByAddress(env, address);151 }152153 if (body.app !== undefined && reservation === null) {154 throw new ApiError(409, "app deployments require an initialized app reservation");155 }156 if (reservation !== null && body.app === undefined) {157 throw new ApiError(409, "reserved app origins must be deployed with nzip app deploy");158 }159 const contentSecurityPolicy = body.app === undefined160 ? undefined161 : body.app.contentSecurityPolicy ?? null;162 if (163 typeof contentSecurityPolicy === "string" &&164 (contentSecurityPolicy.length > 8192 || /[\r\n\0]/.test(contentSecurityPolicy))165 ) {166 throw new ApiError(400, "content security policy must be one line and at most 8192 characters");167 }168169 const resolvedTtl = resolveCommitTtl(170 body.ttl,171 existingSite?.expires_at,172 vault.default_ttl,173 );174 if (passwordHash === undefined && !existingSite) {175 passwordHash = vault.default_password_hash ?? null;176 }177 enforceVaultPolicy(178 vault,179 resolvedTtl.expiresAt,180 passwordHash === undefined ? existingSite?.password_hash ?? null : passwordHash,181 );182 const protectedSite = passwordHash === undefined && existingSite183 ? existingSite.password_hash !== null184 : passwordHash !== null && passwordHash !== undefined;185 const feedback = body.feedback ?? existingSite?.feedback === 1;186 enforceFeedbackPolicy(187 feedback,188 passwordHash === undefined ? existingSite?.password_hash ?? null : passwordHash,189 );190191 if (!(await env.CONTENT.head(`manifest/${hash}`))) {192 await env.CONTENT.put(`manifest/${hash}`, bytes);193 }194 const seq = await commitSite(env, {195 address,196 vaultSlot: vaultSlotOf(address),197 alias,198 manifestHash: hash,199 expiresAt: resolvedTtl.expiresAt,200 passwordHash,201 contentSecurityPolicy,202 feedback: body.feedback,203 resetFeedback: existingSite !== null &&204 await feedbackNeedsReset(existingSite, body.password, body.feedback),205 isNew,206 });207 const addressString = formatAddress(address);208 await purgeSiteCache(ctx, addressString);209 return json<CommitResponse>({210 address: addressString,211 url: siteUrl(env, addressString),212 alias,213 manifestHash: hash,214 expiresAt: resolvedTtl.expiresAt,215 ttl: resolvedTtl.ttl,216 ttlSource: resolvedTtl.ttlSource,217 protected: protectedSite,218 feedback,219 seq,220 });221}