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 / sites.ts
sites.ts 7.1K
1import { formatAddress } from "../../../shared/mod.ts";2import type { PatchSiteRequest, RevertRequest, SourceResponse } from "../../../shared/mod.ts";3import { purgeSiteCache } from "../cache.ts";4import {5 commitSite,6 feedbackResetStatements,7 getSiteByAddress,8 getVaultByName,9 getVaultBySlot,10 listSites,11 siteHistory,12 type SiteRow,13 siteRowToDetail,14 siteRowToInfo,15} from "../db.ts";16import { type Env, json, siteUrl } from "../env.ts";17import {18 enforceVaultPolicy,19 HEX64,20 passwordHashFor,21 resolveCommitTtl,22 resolvePathTarget,23 sourceManifest,24 ttlToExpiry,25} from "./common.ts";26import { ApiError, readJson } from "./errors.ts";27import {28 enforceFeedbackPolicy,29 feedbackNeedsReset,30 handleFeedbackGet,31 handleFeedbackRequest,32} from "../feedback.ts";3334export async function handleSiteList(url: URL, env: Env): Promise<Response> {35 const vaultName = url.searchParams.get("vault");36 let slot: number | undefined;37 if (vaultName) {38 const vault = await getVaultByName(env, vaultName);39 if (!vault) throw new ApiError(404, `unknown vault: ${vaultName}`);40 slot = vault.slot;41 }42 const rows = await listSites(env, slot);43 return json(rows.map((row) => siteRowToInfo(env, row, row.vault_name)));44}4546export async function handleSite(47 request: Request,48 env: Env,49 parts: string[],50 ctx: ExecutionContext,51): Promise<Response> {52 const site = await resolvePathTarget(env, parts[2]);53 if (!site) throw new ApiError(404, "site not found");5455 if (parts.length === 4 && parts[3] === "source" && request.method === "GET") {56 const manifest = await sourceManifest(env, site);57 return json<SourceResponse>({58 address: formatAddress(site.address),59 manifestHash: site.current_manifest,60 manifest,61 });62 }63 if (parts.length === 5 && parts[3] === "source" && request.method === "GET") {64 return await handleSourceBlob(env, site, parts[4]);65 }66 if (parts.length === 3 && request.method === "GET") {67 return json(await siteRowToDetail(env, site));68 }69 if (parts.length === 3 && request.method === "DELETE") {70 await env.DB.prepare("DELETE FROM sites WHERE address = ?").bind(71 site.address,72 ).run();73 const address = formatAddress(site.address);74 await purgeSiteCache(ctx, address);75 return json({ ok: true, address });76 }77 if (parts.length === 3 && request.method === "PATCH") {78 return await handlePatch(request, env, site, ctx);79 }80 if (81 parts.length === 4 && parts[3] === "revert" && request.method === "POST"82 ) {83 return await handleRevert(request, env, site, ctx);84 }85 if (parts.length === 4 && parts[3] === "feedback" && request.method === "GET") {86 return await handleFeedbackGet(env, site);87 }88 if (89 parts.length === 5 && parts[3] === "feedback" && parts[4] === "request" &&90 request.method === "POST"91 ) {92 return await handleFeedbackRequest(request, env, site, ctx);93 }94 throw new ApiError(404, "not found");95}9697async function handleSourceBlob(98 env: Env,99 site: SiteRow,100 hash: string,101): Promise<Response> {102 if (!HEX64.test(hash)) throw new ApiError(400, "invalid blob hash");103 const manifest = await sourceManifest(env, site);104 const entry = Object.values(manifest.files).find((file) => file.h === hash);105 if (!entry) throw new ApiError(404, "source blob not found");106 const blob = await env.CONTENT.get(`blob/${hash}`);107 if (!blob) throw new ApiError(404, "source blob not found");108 return new Response(blob.body, {109 headers: {110 "content-type": "application/octet-stream",111 "content-length": String(entry.s),112 "cache-control": "no-store",113 },114 });115}116117async function handlePatch(118 request: Request,119 env: Env,120 site: SiteRow,121 ctx: ExecutionContext,122): Promise<Response> {123 const body = await readJson<PatchSiteRequest>(request);124 if (body.feedback !== undefined && typeof body.feedback !== "boolean") {125 throw new ApiError(400, "feedback must be a boolean");126 }127 const expiresAt = body.ttl === undefined ? undefined : ttlToExpiry(body.ttl);128 const passwordHash = await passwordHashFor(body.password);129 const vault = await getVaultBySlot(env, site.vault_slot);130 if (!vault) throw new ApiError(404, "vault not found");131 const effectivePassword = passwordHash === undefined ? site.password_hash : passwordHash;132 enforceVaultPolicy(133 vault,134 expiresAt === undefined ? site.expires_at : expiresAt,135 effectivePassword,136 );137 enforceFeedbackPolicy(body.feedback ?? site.feedback === 1, effectivePassword);138 const updates: string[] = [];139 const values: (string | number | null)[] = [];140 if (expiresAt !== undefined) {141 updates.push("expires_at = ?");142 values.push(expiresAt);143 }144 if (passwordHash !== undefined) {145 updates.push("password_hash = ?", "auth_version = auth_version + 1");146 values.push(passwordHash);147 }148 if (body.feedback !== undefined) {149 updates.push("feedback = ?");150 values.push(body.feedback ? 1 : 0);151 }152 if (updates.length > 0) {153 updates.push("updated_at = ?");154 values.push(Math.floor(Date.now() / 1000), site.address);155 const reset = await feedbackNeedsReset(site, body.password, body.feedback);156 await env.DB.batch([157 env.DB.prepare(`UPDATE sites SET ${updates.join(", ")} WHERE address = ?`).bind(...values),158 ...(reset ? feedbackResetStatements(env, site.address) : []),159 ]);160 }161 const updated = await getSiteByAddress(env, site.address);162 if (!updated) throw new ApiError(404, "site not found");163 await purgeSiteCache(ctx, formatAddress(site.address));164 return json(await siteRowToDetail(env, updated));165}166167async function handleRevert(168 request: Request,169 env: Env,170 site: SiteRow,171 ctx: ExecutionContext,172): Promise<Response> {173 const body = await readJson<RevertRequest>(request).catch((error) => {174 if ((request.headers.get("content-length") ?? "0") === "0") {175 return {} as RevertRequest;176 }177 throw error;178 });179 const history = await siteHistory(env, site.address);180 const target = body.toSeq !== undefined181 ? history.find((entry) => entry.seq === body.toSeq)182 : history.find((entry) => entry.manifestHash !== site.current_manifest);183 if (!target) {184 if (body.toSeq !== undefined) {185 throw new ApiError(404, `no push #${body.toSeq} in history`);186 }187 throw new ApiError(409, "nothing to revert to");188 }189 const vault = await getVaultBySlot(env, site.vault_slot);190 if (!vault) throw new ApiError(404, "vault not found");191 enforceVaultPolicy(vault, site.expires_at, site.password_hash);192 const seq = await commitSite(env, {193 address: site.address,194 vaultSlot: site.vault_slot,195 alias: site.alias,196 manifestHash: target.manifestHash,197 expiresAt: site.expires_at,198 isNew: false,199 note: `revert to #${target.seq}`,200 });201 const address = formatAddress(site.address);202 await purgeSiteCache(ctx, address);203 const resolvedTtl = resolveCommitTtl(undefined, site.expires_at, null);204 return json({205 address,206 url: siteUrl(env, address),207 alias: site.alias,208 manifestHash: target.manifestHash,209 expiresAt: site.expires_at,210 ttl: resolvedTtl.ttl,211 ttlSource: resolvedTtl.ttlSource,212 protected: site.password_hash !== null,213 feedback: site.feedback === 1,214 revertedTo: target.seq,215 seq,216 });217}