clone

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

feat/source-stable-push
nzip / worker / src / serve.ts
serve.ts 13.6K
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";910const GONE_PAGE = `<!doctype html><meta charset="utf-8"><title>expired — nzip</title>11<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>12<div><b>410</b>this share expired</div>`;1314const NOT_FOUND_PAGE = `<!doctype html><meta charset="utf-8"><title>not found — nzip</title>15<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>16<div><b>404</b>nothing here</div>`;1718const NOT_DEPLOYED_PAGE =19 `<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>not deployed — nzip</title>20<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>21<div><b>reserved</b>this app is not deployed yet</div>`;2223const MAX_UNLOCK_BODY_BYTES = 4096;24const MAX_PASSWORD_LENGTH = 256;25const ARTIFACT_SECURITY_HEADERS = {26 "origin-agent-cluster": "?1",27} as const;2829function artifactSecurityHeaders(site?: SiteRow): HeadersInit {30 return {31 ...ARTIFACT_SECURITY_HEADERS,32 ...(site?.content_security_policy33 ? { "content-security-policy": site.content_security_policy }34 : {}),35 };36}3738function htmlResponse(39 body: string,40 status: number,41 headers: HeadersInit = {},42): Response {43 return new Response(body, {44 status,45 headers: {46 "content-type": "text/html; charset=utf-8",47 "cache-control": "no-store",48 ...headers,49 },50 });51}5253function publicSiteHeaders(address: string): HeadersInit {54 return {55 "cache-control": "public, max-age=60",56 "cache-tag": siteCacheTag(address),57 ...ARTIFACT_SECURITY_HEADERS,58 };59}6061function publicSiteRedirect(62 env: Env,63 pathname: string,64 search: string,65 address: string,66 status = 302,67): Response {68 return new Response(null, {69 status,70 headers: {71 location: siteUrl(env, address, pathname, search),72 ...publicSiteHeaders(address),73 },74 });75}7677function escapeHtmlAttribute(value: string): string {78 return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");79}8081function unlockForm(address: string, returnTo: string, error?: string): string {82 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>83<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>84<form method="post" action="/__unlock"><input type="hidden" name="return_to" value="${85 escapeHtmlAttribute(returnTo)86 }"><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>${87 error ? `<div class="e">${error}</div>` : ""88 }</form>`;89}9091function unlockReturnTarget(92 req: Request,93 returnTo: string | null,94): string {95 const fallback = "/";96 if (!returnTo) return fallback;9798 try {99 const requestUrl = new URL(req.url);100 const target = new URL(returnTo, requestUrl);101 if (102 target.origin !== requestUrl.origin ||103 target.pathname === "/__unlock"104 ) {105 return fallback;106 }107 return `${target.pathname}${target.search}`;108 } catch {109 return fallback;110 }111}112113async function handleUnlock(114 req: Request,115 env: Env,116 site: SiteRow,117 address: string,118): Promise<Response> {119 const securityHeaders = artifactSecurityHeaders(site);120 const fallbackTarget = "/";121 const contentLength = req.headers.get("content-length");122 if (contentLength === null) {123 return htmlResponse(124 unlockForm(address, fallbackTarget, "request size required"),125 411,126 securityHeaders,127 );128 }129 const declaredBytes = Number(contentLength);130 if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0) {131 return htmlResponse(132 unlockForm(address, fallbackTarget, "invalid request size"),133 400,134 securityHeaders,135 );136 }137 if (declaredBytes > MAX_UNLOCK_BODY_BYTES) {138 return htmlResponse(139 unlockForm(address, fallbackTarget, "request too large"),140 413,141 securityHeaders,142 );143 }144 const bytes = new Uint8Array(await req.arrayBuffer());145 if (bytes.length > MAX_UNLOCK_BODY_BYTES) {146 return htmlResponse(147 unlockForm(address, fallbackTarget, "request too large"),148 413,149 securityHeaders,150 );151 }152 if (bytes.length !== declaredBytes) {153 return htmlResponse(154 unlockForm(address, fallbackTarget, "request size mismatch"),155 400,156 securityHeaders,157 );158 }159 const contentType = req.headers.get("content-type")?.split(";", 1)[0].trim()160 .toLowerCase();161 if (contentType !== "application/x-www-form-urlencoded") {162 return htmlResponse(163 unlockForm(address, fallbackTarget, "unsupported form"),164 415,165 securityHeaders,166 );167 }168 const form = new URLSearchParams(new TextDecoder().decode(bytes));169 const password = form.get("password");170 const returnTarget = unlockReturnTarget(req, form.get("return_to"));171 if (password !== null && password.length > MAX_PASSWORD_LENGTH) {172 return htmlResponse(173 unlockForm(address, returnTarget, "password too long"),174 400,175 securityHeaders,176 );177 }178 if (179 typeof password !== "string" || !site.password_hash ||180 !(await verifyPassword(password, site.password_hash))181 ) {182 return htmlResponse(183 unlockForm(address, returnTarget, "wrong password"),184 401,185 securityHeaders,186 );187 }188 return new Response(null, {189 status: 303,190 headers: {191 location: returnTarget,192 "set-cookie": await makeUnlockCookie(env, address, site.auth_version),193 ...securityHeaders,194 },195 });196}197198async function serveControlOrigin(199 env: Env,200 url: URL,201): Promise<Response> {202 const path = url.pathname;203204 if (path === "/") {205 return htmlResponse(notifyLandingPage(env), 200, {206 "cache-control": "public, max-age=3600",207 "content-security-policy": "frame-ancestors 'none'",208 "x-frame-options": "DENY",209 });210 }211 // Served for the browsers' default probe; pushed sites under /{4hex}/ still212 // control their own icons through their HTML. PNG bytes at the .ico path are213 // accepted everywhere.214 if (path === "/favicon.ico") {215 return new Response(FAVICON_PNG, {216 headers: {217 "content-type": "image/png",218 "cache-control": "public, max-age=86400",219 },220 });221 }222 if (path === "/robots.txt") {223 return new Response("User-agent: *\nAllow: /\n", {224 headers: {225 "content-type": "text/plain",226 "cache-control": "public, max-age=86400",227 },228 });229 }230231 // Legacy share URLs remain stable, but artifact bytes never execute on the232 // control origin. A 308 preserves method and body for stale unlock forms.233 const m = path.match(/^\/([0-9a-f]{4})(\/.*)?$/);234 if (!m) return htmlResponse(NOT_FOUND_PAGE, 404);235 const addressStr = m[1];236237 const address = parseAddress(addressStr);238 const site = await getSiteByAddress(env, address);239 if (!site && !(await getAppReservationByAddress(env, address))) {240 return htmlResponse(NOT_FOUND_PAGE, 404);241 }242 return publicSiteRedirect(243 env,244 m[2] ?? "/",245 url.search,246 addressStr,247 308,248 );249}250251async function serveSiteOrigin(252 req: Request,253 env: Env,254 url: URL,255 addressStr: string,256): Promise<Response> {257 const path = url.pathname;258 const address = parseAddress(addressStr);259 const site = await getSiteByAddress(env, address);260 if (!site) {261 const reservation = await getAppReservationByAddress(env, address);262 return reservation263 ? htmlResponse(NOT_DEPLOYED_PAGE, 200, ARTIFACT_SECURITY_HEADERS)264 : htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);265 }266 const securityHeaders = artifactSecurityHeaders(site);267 const now = Math.floor(Date.now() / 1000);268 if (site.expires_at !== null && site.expires_at < now) {269 return htmlResponse(GONE_PAGE, 410, securityHeaders);270 }271272 // Password gate.273 if (path === "/__unlock" && req.method === "POST") {274 return await handleUnlock(req, env, site, addressStr);275 }276 if (277 site.password_hash &&278 !(await hasValidUnlockCookie(req, env, addressStr, site.auth_version))279 ) {280 return htmlResponse(281 unlockForm(addressStr, `${url.pathname}${url.search}`),282 401,283 securityHeaders,284 );285 }286287 const manifestObj = await env.CONTENT.get(288 `manifest/${site.current_manifest}`,289 );290 if (!manifestObj) {291 return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);292 }293 const manifest = parseManifest(294 new Uint8Array(await manifestObj.arrayBuffer()),295 );296 const filePaths = Object.keys(manifest.files);297298 let assetPath: string;299 let explicitIndexPath = false;300 if (path === "/") {301 assetPath = manifest.files["index.html"]302 ? "index.html"303 : filePaths.length === 1304 ? filePaths[0]305 : "index.html";306 } else {307 try {308 assetPath = decodeURIComponent(path.slice(1));309 } catch {310 return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);311 }312 explicitIndexPath = /(^|\/)index\.html$/.test(assetPath);313 if (assetPath === "" || assetPath.endsWith("/")) assetPath += "index.html";314 }315316 // Sites built for the former /{address}/ deployment base may contain absolute317 // asset URLs with that prefix. Prefer a real file at the requested path, then318 // fall back to stripping exactly this site's address so existing artifacts319 // remain usable after moving to their own origin.320 let entry = manifest.files[assetPath];321 if (!entry) {322 const legacyPrefix = `${addressStr}/`;323 const legacyPath = assetPath === addressStr324 ? "index.html"325 : assetPath.startsWith(legacyPrefix)326 ? assetPath.slice(legacyPrefix.length) || "index.html"327 : null;328 if (legacyPath !== null) entry = manifest.files[legacyPath];329 }330331 // Keep directory index filenames out of public URLs. Static generators often332 // emit same-page and anchor links as `index.html` or `index.html#section`;333 // browsers preserve the fragment while following this redirect.334 if (explicitIndexPath && entry) {335 const canonicalPath = assetPath === "index.html" ? "/" : path.slice(0, -"index.html".length);336 return publicSiteRedirect(env, canonicalPath, url.search, addressStr);337 }338339 let directoryIndex = manifest.files[`${assetPath}/index.html`];340 if (!entry && !directoryIndex && assetPath.startsWith(`${addressStr}/`)) {341 const legacyDirectory = assetPath.slice(addressStr.length + 1);342 directoryIndex = manifest.files[`${legacyDirectory}/index.html`];343 }344 if (!entry && directoryIndex) {345 // Directory hit without trailing slash: redirect so relative URLs resolve.346 return publicSiteRedirect(env, `${path}/`, url.search, addressStr);347 }348 if (!entry) {349 return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);350 }351352 const etag = `"${entry.h}"`;353 const protectedSite = site.password_hash !== null;354 const cacheControl = protectedSite ? "private, no-store" : "public, max-age=60";355 // Never validate a previously cached protected response with 304: doing so356 // would let the browser reuse its stored body after the site was locked.357 if (!protectedSite && req.headers.get("if-none-match") === etag) {358 return new Response(null, {359 status: 304,360 headers: { etag, ...publicSiteHeaders(addressStr), ...securityHeaders },361 });362 }363364 const blob = await env.CONTENT.get(`blob/${entry.h}`);365 if (!blob) {366 return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);367 }368369 return new Response(blob.body, {370 headers: {371 "content-type": entry.ct,372 "content-length": String(entry.s),373 etag,374 // Public addresses are mutable on re-push. Protected content must never375 // survive the password check in a browser or intermediary cache.376 "cache-control": cacheControl,377 ...(protectedSite ? {} : { "cache-tag": siteCacheTag(addressStr) }),378 ...securityHeaders,379 },380 });381}382383export async function serve(384 req: Request,385 env: Env,386 url: URL,387 siteAddress?: string,388): Promise<Response> {389 return siteAddress === undefined390 ? await serveControlOrigin(env, url)391 : await serveSiteOrigin(req, env, url, siteAddress);392}