1import { parseAddress, parseManifest } from "../../shared/mod.ts";2import { getSiteByAddress, type SiteRow } from "./db.ts";3import type { Env } from "./env.ts";4import { hasValidUnlockCookie, makeUnlockCookie, verifyPassword } from "./password.ts";56const GONE_PAGE = `<!doctype html><meta charset="utf-8"><title>expired — nzip</title>7<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>8<div><b>410</b>this share expired</div>`;910const NOT_FOUND_PAGE = `<!doctype html><meta charset="utf-8"><title>not found — 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>404</b>nothing here</div>`;1314const escapeHtml = (s: string): string =>15 s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]!));1617// The landing shows the deployment's own hostname, derived from PUBLIC_BASE, so18// the committed source carries no operator-specific branding. The first label19// is plain; the rest (from the first dot) is accented.20function landingPage(env: Env): string {21 const host = new URL(env.PUBLIC_BASE).host;22 const dot = host.indexOf(".");23 const head = escapeHtml(dot === -1 ? host : host.slice(0, dot));24 const tail = dot === -1 ? "" : escapeHtml(host.slice(dot));25 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(host)}</title>26<style>body{background:#1e1810;color:#e7dbc5;font-family:ui-monospace,monospace;margin:0;min-height:100dvh;display:flex;flex-direction:column}main{flex:1;display:grid;place-items:center;text-align:center}b{color:#d99a5b}footer{text-align:center;padding:14px 16px calc(14px + env(safe-area-inset-bottom))}a{color:#6b6355;font-size:12px;text-decoration:underline}a:hover{color:#d99a5b}</style>27<main><div>${head}${tail ? `<b>${tail}</b>` : ""}</div></main><footer><a href="https://args.io/cat/nzip">args</a></footer>`;28}2930function htmlResponse(body: string, status: number, headers: HeadersInit = {}): Response {31 return new Response(body, {32 status,33 headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...headers },34 });35}3637function unlockForm(address: string, error?: string): string {38 return `<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>unlock — nzip</title>39<style>body{background:#121110;color:#d6cfc2;font-family:ui-monospace,monospace;display:grid;place-items:center;height:100vh;margin:0}form{text-align:center}b{color:#ffb347;font-size:24px;display:block;margin-bottom:16px}input{background:#1a1815;border:1px solid #2e2a24;color:#d6cfc2;padding:10px 14px;font:inherit;outline:none}input:focus{border-color:#ffb347}button{background:#ffb347;border:0;color:#121110;padding:10px 18px;font:inherit;font-weight:700;cursor:pointer}.e{color:#f7768e;margin-top:12px;font-size:13px}</style>40<form method="post" action="/${address}/__unlock"><b>🔒 ${address}</b><input type="password" name="password" placeholder="password" autofocus autocomplete="current-password"><button>unlock</button>${41 error ? `<div class="e">${error}</div>` : ""42 }</form>`;43}4445async function handleUnlock(req: Request, env: Env, site: SiteRow, address: string): Promise<Response> {46 const form = await req.formData().catch(() => null);47 const password = form?.get("password");48 if (typeof password !== "string" || !site.password_hash ||49 !(await verifyPassword(password, site.password_hash))) {50 return htmlResponse(unlockForm(address, "wrong password"), 401);51 }52 return new Response(null, {53 status: 303,54 headers: {55 // Bare address: single-file sites serve there directly, multi-file ones56 // bounce through the trailing-slash redirect.57 location: `/${address}`,58 "set-cookie": await makeUnlockCookie(env, address),59 },60 });61}6263export async function serve(req: Request, env: Env, url: URL): Promise<Response> {64 const path = url.pathname;6566 if (path === "/") return htmlResponse(landingPage(env), 200);67 if (path === "/favicon.ico") return new Response(null, { status: 204 });68 if (path === "/robots.txt") {69 return new Response("User-agent: *\nAllow: /\n", {70 headers: { "content-type": "text/plain" },71 });72 }7374 // /{4hex} or /{4hex}/{asset-path}75 const m = path.match(/^\/([0-9a-f]{4})(\/.*)?$/);76 if (!m) return htmlResponse(NOT_FOUND_PAGE, 404);77 const addressStr = m[1];7879 const site = await getSiteByAddress(env, parseAddress(addressStr));80 if (!site) return htmlResponse(NOT_FOUND_PAGE, 404);81 const now = Math.floor(Date.now() / 1000);82 if (site.expires_at !== null && site.expires_at < now) return htmlResponse(GONE_PAGE, 410);8384 // Password gate.85 if (m[2] === "/__unlock" && req.method === "POST") {86 return await handleUnlock(req, env, site, addressStr);87 }88 if (site.password_hash && !(await hasValidUnlockCookie(req, env, addressStr))) {89 return htmlResponse(unlockForm(addressStr), 401);90 }9192 const manifestObj = await env.CONTENT.get(`manifest/${site.current_manifest}`);93 if (!manifestObj) return htmlResponse(NOT_FOUND_PAGE, 404);94 const manifest = parseManifest(new Uint8Array(await manifestObj.arrayBuffer()));95 const filePaths = Object.keys(manifest.files);9697 // Bare address: single-file sites serve directly (URL stays /{4hex} — nothing98 // relative to break); multi-file bundles redirect to the trailing slash so99 // relative asset URLs resolve.100 let assetPath: string;101 if (m[2] === undefined) {102 if (filePaths.length !== 1) {103 return Response.redirect(new URL(`/${addressStr}/`, url).toString(), 302);104 }105 assetPath = filePaths[0];106 } else {107 assetPath = decodeURIComponent(m[2].slice(1)); // strip leading /108 if (assetPath === "" || assetPath.endsWith("/")) assetPath += "index.html";109 }110111 let entry = manifest.files[assetPath];112 if (!entry && manifest.files[`${assetPath}/index.html`]) {113 // Directory hit without trailing slash: redirect so relative URLs resolve.114 return Response.redirect(new URL(`${path}/`, url).toString(), 302);115 }116 if (!entry) return htmlResponse(NOT_FOUND_PAGE, 404);117118 const etag = `"${entry.h}"`;119 if (req.headers.get("if-none-match") === etag) {120 return new Response(null, { status: 304, headers: { etag } });121 }122123 const blob = await env.CONTENT.get(`blob/${entry.h}`);124 if (!blob) return htmlResponse(NOT_FOUND_PAGE, 404);125126 return new Response(blob.body, {127 headers: {128 "content-type": entry.ct,129 "content-length": String(entry.s),130 etag,131 // Short max-age: addresses are mutable on re-push. ETag makes revalidation free.132 "cache-control": "public, max-age=60",133 },134 });135}