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 / serve.ts
serve.ts 13.9K
1import { parseAddress, parseManifest } from "../../shared/mod.ts";2import { getAppReservationByAddress, getSiteByAddress, type SiteRow } from "./db.ts";3import { siteCacheTag } from "./cache.ts";4import type { Env } from "./env.ts";5import { siteUrl } from "./env.ts";6import { FAVICON_PNG } from "./favicon.ts";7import { hasValidUnlockCookie, makeUnlockCookie, verifyPassword } from "./password.ts";8import { notifyLandingPage } from "./notify_ui.ts";9import { serveSiteFeedback } from "./feedback.ts";1011const GONE_PAGE = `<!doctype html><meta charset="utf-8"><title>expired — nzip</title>12<style>body{background:#121110;color:#8a8172;font-family:ui-monospace,monospace;display:grid;place-items:center;height:100vh;margin:0}div{text-align:center}b{color:#ffb347;font-size:32px;display:block;margin-bottom:8px}</style>13<div><b>410</b>this share expired</div>`;1415const NOT_FOUND_PAGE = `<!doctype html><meta charset="utf-8"><title>not found — nzip</title>16<style>body{background:#121110;color:#8a8172;font-family:ui-monospace,monospace;display:grid;place-items:center;height:100vh;margin:0}div{text-align:center}b{color:#ffb347;font-size:32px;display:block;margin-bottom:8px}</style>17<div><b>404</b>nothing here</div>`;1819const NOT_DEPLOYED_PAGE =20 `<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>not deployed — nzip</title>21<style>body{background:#121110;color:#8a8172;font-family:ui-monospace,monospace;display:grid;place-items:center;height:100vh;margin:0}div{text-align:center}b{color:#ffb347;font-size:32px;display:block;margin-bottom:8px}</style>22<div><b>reserved</b>this app is not deployed yet</div>`;2324const MAX_UNLOCK_BODY_BYTES = 4096;25const MAX_PASSWORD_LENGTH = 256;26const ARTIFACT_SECURITY_HEADERS = {27 "origin-agent-cluster": "?1",28} as const;2930function artifactSecurityHeaders(site?: SiteRow): HeadersInit {31 return {32 ...ARTIFACT_SECURITY_HEADERS,33 ...(site?.content_security_policy34 ? { "content-security-policy": site.content_security_policy }35 : {}),36 };37}3839function htmlResponse(40 body: string,41 status: number,42 headers: HeadersInit = {},43): Response {44 return new Response(body, {45 status,46 headers: {47 "content-type": "text/html; charset=utf-8",48 "cache-control": "no-store",49 ...headers,50 },51 });52}5354function publicSiteHeaders(address: string): HeadersInit {55 return {56 "cache-control": "public, max-age=60",57 "cache-tag": siteCacheTag(address),58 ...ARTIFACT_SECURITY_HEADERS,59 };60}6162function publicSiteRedirect(63 env: Env,64 pathname: string,65 search: string,66 address: string,67 status = 302,68): Response {69 return new Response(null, {70 status,71 headers: {72 location: siteUrl(env, address, pathname, search),73 ...publicSiteHeaders(address),74 },75 });76}7778function escapeHtmlAttribute(value: string): string {79 return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");80}8182function unlockForm(address: string, returnTo: string, error?: string): string {83 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"><meta name="robots" content="noindex"><title>unlock — nzip</title>84<style>*{box-sizing:border-box}body{background:#121110;color:#d6cfc2;font-family:ui-monospace,monospace;display:grid;place-items:center;min-height:100vh;min-height:100dvh;margin:0;padding:max(24px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}form{width:min(100%,420px);text-align:center}b{color:#ffb347;font-size:24px;display:block;margin-bottom:20px}.field{display:flex;width:100%}input{background:#1a1815;border:1px solid #2e2a24;color:#d6cfc2;min-width:0;flex:1;padding:12px 14px;font:inherit;font-size:16px;line-height:20px;outline:none;border-radius:0}input:focus{border-color:#ffb347}button{background:#ffb347;border:0;color:#121110;min-height:46px;padding:12px 18px;font:inherit;font-weight:700;cursor:pointer;border-radius:0}.e{color:#f7768e;margin-top:12px;font-size:13px}@media(max-width:360px){.field{display:grid;gap:10px}button{width:100%}}</style>85<form method="post" action="/__unlock"><input type="hidden" name="return_to" value="${86 escapeHtmlAttribute(returnTo)87 }"><b>&#128274; ${address}</b><div class="field"><input type="password" name="password" aria-label="password" placeholder="password" maxlength="256" autofocus autocomplete="current-password"><button>unlock</button></div>${88 error ? `<div class="e">${error}</div>` : ""89 }</form>`;90}9192function unlockReturnTarget(93 req: Request,94 returnTo: string | null,95): string {96 const fallback = "/";97 if (!returnTo) return fallback;9899 try {100 const requestUrl = new URL(req.url);101 const target = new URL(returnTo, requestUrl);102 if (103 target.origin !== requestUrl.origin ||104 target.pathname === "/__unlock"105 ) {106 return fallback;107 }108 return `${target.pathname}${target.search}`;109 } catch {110 return fallback;111 }112}113114async function handleUnlock(115 req: Request,116 env: Env,117 site: SiteRow,118 address: string,119): Promise<Response> {120 const securityHeaders = artifactSecurityHeaders(site);121 const fallbackTarget = "/";122 const contentLength = req.headers.get("content-length");123 if (contentLength === null) {124 return htmlResponse(125 unlockForm(address, fallbackTarget, "request size required"),126 411,127 securityHeaders,128 );129 }130 const declaredBytes = Number(contentLength);131 if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0) {132 return htmlResponse(133 unlockForm(address, fallbackTarget, "invalid request size"),134 400,135 securityHeaders,136 );137 }138 if (declaredBytes > MAX_UNLOCK_BODY_BYTES) {139 return htmlResponse(140 unlockForm(address, fallbackTarget, "request too large"),141 413,142 securityHeaders,143 );144 }145 const bytes = new Uint8Array(await req.arrayBuffer());146 if (bytes.length > MAX_UNLOCK_BODY_BYTES) {147 return htmlResponse(148 unlockForm(address, fallbackTarget, "request too large"),149 413,150 securityHeaders,151 );152 }153 if (bytes.length !== declaredBytes) {154 return htmlResponse(155 unlockForm(address, fallbackTarget, "request size mismatch"),156 400,157 securityHeaders,158 );159 }160 const contentType = req.headers.get("content-type")?.split(";", 1)[0].trim()161 .toLowerCase();162 if (contentType !== "application/x-www-form-urlencoded") {163 return htmlResponse(164 unlockForm(address, fallbackTarget, "unsupported form"),165 415,166 securityHeaders,167 );168 }169 const form = new URLSearchParams(new TextDecoder().decode(bytes));170 const password = form.get("password");171 const returnTarget = unlockReturnTarget(req, form.get("return_to"));172 if (password !== null && password.length > MAX_PASSWORD_LENGTH) {173 return htmlResponse(174 unlockForm(address, returnTarget, "password too long"),175 400,176 securityHeaders,177 );178 }179 if (180 typeof password !== "string" || !site.password_hash ||181 !(await verifyPassword(password, site.password_hash))182 ) {183 return htmlResponse(184 unlockForm(address, returnTarget, "wrong password"),185 401,186 securityHeaders,187 );188 }189 return new Response(null, {190 status: 303,191 headers: {192 location: returnTarget,193 "set-cookie": await makeUnlockCookie(env, address, site.auth_version),194 ...securityHeaders,195 },196 });197}198199async function serveControlOrigin(200 env: Env,201 url: URL,202): Promise<Response> {203 const path = url.pathname;204205 if (path === "/") {206 return htmlResponse(notifyLandingPage(env), 200, {207 "cache-control": "public, max-age=3600",208 "content-security-policy": "frame-ancestors 'none'",209 "x-frame-options": "DENY",210 });211 }212 // Served for the browsers' default probe; pushed sites under /{4hex}/ still213 // control their own icons through their HTML. PNG bytes at the .ico path are214 // accepted everywhere.215 if (path === "/favicon.ico") {216 return new Response(FAVICON_PNG, {217 headers: {218 "content-type": "image/png",219 "cache-control": "public, max-age=86400",220 },221 });222 }223 if (path === "/robots.txt") {224 return new Response("User-agent: *\nAllow: /\n", {225 headers: {226 "content-type": "text/plain",227 "cache-control": "public, max-age=86400",228 },229 });230 }231232 // Legacy share URLs remain stable, but artifact bytes never execute on the233 // control origin. A 308 preserves method and body for stale unlock forms.234 const m = path.match(/^\/([0-9a-f]{4})(\/.*)?$/);235 if (!m) return htmlResponse(NOT_FOUND_PAGE, 404);236 const addressStr = m[1];237238 const address = parseAddress(addressStr);239 const site = await getSiteByAddress(env, address);240 if (!site && !(await getAppReservationByAddress(env, address))) {241 return htmlResponse(NOT_FOUND_PAGE, 404);242 }243 return publicSiteRedirect(244 env,245 m[2] ?? "/",246 url.search,247 addressStr,248 308,249 );250}251252async function serveSiteOrigin(253 req: Request,254 env: Env,255 url: URL,256 addressStr: string,257 ctx: ExecutionContext,258): Promise<Response> {259 const path = url.pathname;260 const address = parseAddress(addressStr);261 const site = await getSiteByAddress(env, address);262 if (!site) {263 const reservation = await getAppReservationByAddress(env, address);264 return reservation265 ? htmlResponse(NOT_DEPLOYED_PAGE, 200, ARTIFACT_SECURITY_HEADERS)266 : htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);267 }268 const securityHeaders = artifactSecurityHeaders(site);269 const now = Math.floor(Date.now() / 1000);270 if (site.expires_at !== null && site.expires_at < now) {271 return htmlResponse(GONE_PAGE, 410, securityHeaders);272 }273274 // Password gate.275 if (path === "/__unlock" && req.method === "POST") {276 return await handleUnlock(req, env, site, addressStr);277 }278 if (279 site.password_hash &&280 !(await hasValidUnlockCookie(req, env, addressStr, site.auth_version))281 ) {282 return htmlResponse(283 unlockForm(addressStr, `${url.pathname}${url.search}`),284 401,285 securityHeaders,286 );287 }288289 // Reserved feedback routes sit behind the password gate and shadow same-named files.290 const feedback = await serveSiteFeedback(req, env, url, site, ctx);291 if (feedback) return feedback;292293 const manifestObj = await env.CONTENT.get(294 `manifest/${site.current_manifest}`,295 );296 if (!manifestObj) {297 return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);298 }299 const manifest = parseManifest(300 new Uint8Array(await manifestObj.arrayBuffer()),301 );302 const filePaths = Object.keys(manifest.files);303304 let assetPath: string;305 let explicitIndexPath = false;306 if (path === "/") {307 assetPath = manifest.files["index.html"]308 ? "index.html"309 : filePaths.length === 1310 ? filePaths[0]311 : "index.html";312 } else {313 try {314 assetPath = decodeURIComponent(path.slice(1));315 } catch {316 return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);317 }318 explicitIndexPath = /(^|\/)index\.html$/.test(assetPath);319 if (assetPath === "" || assetPath.endsWith("/")) assetPath += "index.html";320 }321322 // Sites built for the former /{address}/ deployment base may contain absolute323 // asset URLs with that prefix. Prefer a real file at the requested path, then324 // fall back to stripping exactly this site's address so existing artifacts325 // remain usable after moving to their own origin.326 let entry = manifest.files[assetPath];327 if (!entry) {328 const legacyPrefix = `${addressStr}/`;329 const legacyPath = assetPath === addressStr330 ? "index.html"331 : assetPath.startsWith(legacyPrefix)332 ? assetPath.slice(legacyPrefix.length) || "index.html"333 : null;334 if (legacyPath !== null) entry = manifest.files[legacyPath];335 }336337 // Keep directory index filenames out of public URLs. Static generators often338 // emit same-page and anchor links as `index.html` or `index.html#section`;339 // browsers preserve the fragment while following this redirect.340 if (explicitIndexPath && entry) {341 const canonicalPath = assetPath === "index.html" ? "/" : path.slice(0, -"index.html".length);342 return publicSiteRedirect(env, canonicalPath, url.search, addressStr);343 }344345 let directoryIndex = manifest.files[`${assetPath}/index.html`];346 if (!entry && !directoryIndex && assetPath.startsWith(`${addressStr}/`)) {347 const legacyDirectory = assetPath.slice(addressStr.length + 1);348 directoryIndex = manifest.files[`${legacyDirectory}/index.html`];349 }350 if (!entry && directoryIndex) {351 // Directory hit without trailing slash: redirect so relative URLs resolve.352 return publicSiteRedirect(env, `${path}/`, url.search, addressStr);353 }354 if (!entry) {355 return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);356 }357358 const etag = `"${entry.h}"`;359 const protectedSite = site.password_hash !== null;360 const cacheControl = protectedSite ? "private, no-store" : "public, max-age=60";361 // Never validate a previously cached protected response with 304: doing so362 // would let the browser reuse its stored body after the site was locked.363 if (!protectedSite && req.headers.get("if-none-match") === etag) {364 return new Response(null, {365 status: 304,366 headers: { etag, ...publicSiteHeaders(addressStr), ...securityHeaders },367 });368 }369370 const blob = await env.CONTENT.get(`blob/${entry.h}`);371 if (!blob) {372 return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);373 }374375 return new Response(blob.body, {376 headers: {377 "content-type": entry.ct,378 "content-length": String(entry.s),379 etag,380 // Public addresses are mutable on re-push. Protected content must never381 // survive the password check in a browser or intermediary cache.382 "cache-control": cacheControl,383 ...(protectedSite ? {} : { "cache-tag": siteCacheTag(addressStr) }),384 ...securityHeaders,385 },386 });387}388389export async function serve(390 req: Request,391 env: Env,392 url: URL,393 siteAddress: string | undefined,394 ctx: ExecutionContext,395): Promise<Response> {396 return siteAddress === undefined397 ? await serveControlOrigin(env, url)398 : await serveSiteOrigin(req, env, url, siteAddress, ctx);399}