clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/github_actions/actions-9b61906d8b
bindws / src / sites.ts
sites.ts 16.4K
1// NIP-5A manifests and their single-label addresses. The routing index is2// an outbox: event mutations and pending KV writes commit together in SQL.3import { sha256 } from "@noble/hashes/sha2.js";4import { decode, npubEncode } from "nostr-tools/nip19";5import { expiration, now, tag, type Event } from "./event.ts";6import { bytesToHex } from "./negentropy.ts";7import { KIND_SITE, KIND_NAMED_SITE, KIND_SITE_SNAPSHOT } from "./kinds.ts";8import type { Filter } from "./filter.ts";9import type { Relay } from "./relay.ts";10import { SITE_AUTH_PATH, siteIdentity } from "./site-auth.ts";11import { denyStatus } from "./auth.ts";12import { featureOn } from "./settings.ts";13import { remoteSiteBlob } from "./site-mirror.ts";14import { blobBlocked, type Blob } from "./blossom.ts";15import { eventFilter, isWebAddressRequest, requestedPath, webAddressResponse } from "./nipad.ts";1617export const SITE_KINDS = [KIND_SITE, KIND_NAMED_SITE, KIND_SITE_SNAPSHOT];18const HEX = /^[0-9a-f]{64}$/;19export const SITE_NAME = /^[a-z0-9-]{1,13}(?<!-)$/;20export type Site = { kind: typeof KIND_SITE; pubkey: string } | { kind: typeof KIND_NAMED_SITE; pubkey: string; d: string } | { kind: typeof KIND_SITE_SNAPSHOT; id: string };2122// base36 preserves all 32 bytes at a fixed width, including leading zeros. The23// draft's "no padding" and "always exactly 50" conflict for small integers;24// the parser and DNS boundary require the latter (see docs/20).25export function base36(hex: string): string {26 if (!HEX.test(hex)) throw new Error("expected 32 bytes in lowercase hex");27 return BigInt("0x" + hex).toString(36).padStart(50, "0");28}29export function unbase36(s: string): string | null {30 if (!/^[0-9a-z]{50}$/.test(s)) return null;31 let n = 0n;32 for (const c of s) n = n * 36n + BigInt(parseInt(c, 36));33 return n < 1n << 256n ? n.toString(16).padStart(64, "0") : null;34}35export function parseSite(label: string): Site | null {36 if (label.startsWith("npub1")) {37 try {38 const p = decode(label);39 if (p.type === "npub" && npubEncode(p.data) === label) return { kind: KIND_SITE, pubkey: p.data };40 } catch { /* continue in the draft's specified order */ }41 }42 if (/^v[0-9a-z]{50}$/.test(label)) {43 const id = unbase36(label.slice(1));44 return id ? { kind: KIND_SITE_SNAPSHOT, id } : null;45 }46 if (!/^[0-9a-z]{50}[a-z0-9-]{1,13}$/.test(label) || label.endsWith("-")) return null;47 const pubkey = unbase36(label.slice(0, 50));48 return pubkey ? { kind: KIND_NAMED_SITE, pubkey, d: label.slice(50) } : null;49}50export function siteLabel(e: Event): string {51 if (e.kind === KIND_SITE) return npubEncode(e.pubkey);52 if (e.kind === KIND_NAMED_SITE) return base36(e.pubkey) + tag(e, "d");53 if (e.kind === KIND_SITE_SNAPSHOT) return "v" + base36(e.id);54 return "";55}56export const siteKey = (label: string) => "nsite:" + label;57export const sitePaths = (e: Event) => e.tags.filter((t) => t[0] === "path");58export function aggregate(e: Pick<Event, "tags">): string {59 const lines = e.tags.filter((t) => t[0] === "path").map((t) => `${t[2]} ${t[1]}\n`).sort().join("");60 return bytesToHex(sha256(new TextEncoder().encode(lines)));61}62function reference(s: string): boolean {63 const [k, pubkey, d, extra] = s.split(":");64 return extra === undefined && HEX.test(pubkey ?? "") && (k === String(KIND_SITE) ? d === "" : k === String(KIND_NAMED_SITE) && SITE_NAME.test(d ?? ""));65}66export function checkSite(e: Event): string {67 if (!SITE_KINDS.includes(e.kind)) return "";68 const ds = e.tags.filter((t) => t[0] === "d");69 if (e.kind === KIND_NAMED_SITE ? ds.length !== 1 || !SITE_NAME.test(ds[0][1] ?? "") : ds.length !== 0) return "invalid: site d tag must be a 1-13 character named-site identifier; root sites and snapshots have none";70 const paths = sitePaths(e);71 if (!paths.length) return "invalid: a site needs at least one path tag";72 const seen = new Set<string>();73 for (const t of paths) {74 const p = t[1] ?? "";75 if (t.length !== 3 || !/^\/(?:[^/]+\/)*[^/]+\.[^/.]+$/.test(p) || /[\\?#\x00-\x1f\x7f]/.test(p) || p.split("/").some((s) => s === "." || s === "..") || !HEX.test(t[2])) return "invalid: site paths must map absolute filenames with extensions to lowercase sha256 hashes";76 if (seen.has(p)) return "invalid: duplicate site path";77 seen.add(p);78 }79 const xs = e.tags.filter((t) => t[0] === "x");80 if (xs.length > 1 || (e.kind === KIND_SITE_SNAPSHOT && xs.length !== 1) || xs.some((t) => t.length !== 3 || t[2] !== "aggregate" || t[1] !== aggregate(e))) return "invalid: site x tag must match the aggregate hash";81 const a = e.tags.filter((t) => t[0] === "a");82 const A = e.tags.filter((t) => t[0] === "A");83 if (a.length > 1 || A.length > 1 || [...a, ...A].some((t) => !reference(t[1] ?? ""))) return "invalid: site lineage must reference a root or named site";84 if (e.kind === KIND_SITE_SNAPSHOT ? a.length !== 1 : a.length !== A.length) return "invalid: snapshot needs a source a tag; copies need a and A tags";85 return "";86}8788export function manifest(relay: Relay, site: Site): Event | null {89 const f: Filter = site.kind === KIND_SITE_SNAPSHOT ? { ids: [site.id], kinds: [site.kind], tags: {} } : { authors: [site.pubkey], kinds: [site.kind], tags: site.kind === KIND_NAMED_SITE ? { d: [site.d] } : {} };90 const raw = relay.store.query(f, { pubkeys: [] }, 1, now()).rows[0];91 if (!raw) return null;92 const e = JSON.parse(raw) as Event;93 return checkSite(e) ? null : e;94}9596export const SITE_SCHEMA = `97CREATE TABLE IF NOT EXISTS site_mirror_queue (event_id TEXT PRIMARY KEY);98CREATE TABLE IF NOT EXISTS site_outbox (seq INTEGER PRIMARY KEY AUTOINCREMENT, raw TEXT NOT NULL, removed INTEGER NOT NULL);99CREATE TRIGGER IF NOT EXISTS site_added AFTER INSERT ON events WHEN new.kind IN (${SITE_KINDS.join(",")}) BEGIN100 INSERT INTO site_outbox(raw,removed) VALUES(new.raw,0);101 INSERT OR IGNORE INTO site_mirror_queue(event_id) VALUES(new.id);102END;103CREATE TRIGGER IF NOT EXISTS site_removed AFTER DELETE ON events WHEN old.kind IN (${SITE_KINDS.join(",")}) BEGIN104 INSERT INTO site_outbox(raw,removed) VALUES(old.raw,1);105 DELETE FROM site_mirror_queue WHERE event_id=old.id;106END;107`;108109// syncSiteIndex coalesces replacements into one KV write per label, keeping110// the old route usable if a write is throttled. Failed groups stay queued.111export async function syncSiteIndex(relay: Relay): Promise<boolean> {112 if (!relay.hosts || !relay.slug) return false;113 const rows = relay.sql.exec<{ seq: number; raw: string; removed: number }>(`SELECT * FROM site_outbox ORDER BY seq LIMIT 100`).toArray();114 const groups = new Map<string, { seq: number; e: Event; removed: number }[]>();115 for (const row of rows) {116 const e = JSON.parse(row.raw) as Event;117 if (checkSite(e)) { relay.sql.exec(`DELETE FROM site_outbox WHERE seq=?`, row.seq); continue; }118 const label = siteLabel(e);119 const group = groups.get(label) ?? [];120 group.push({ seq: row.seq, e, removed: row.removed });121 groups.set(label, group);122 }123 for (const [label, group] of groups) {124 const last = group[group.length - 1];125 const removed = last.removed || (expiration(last.e) > 0 && expiration(last.e) <= now());126 // A batch can end between a replacement's removal and insertion. A127 // different live version keeps the route; teardown removes the same128 // version explicitly even while it still exists in the events table.129 const current = manifest(relay, parseSite(label)!);130 const target = removed ? current && current.id !== last.e.id ? current : null : current;131 const key = siteKey(label);132 const existing = await relay.hosts.get(key);133 if (target) {134 const value = JSON.stringify({ name: relay.slug, event: target.id });135 if (existing !== value) await relay.hosts.put(key, value);136 } else if (group.some(({ e }) => existing === JSON.stringify({ name: relay.slug, event: e.id }))) {137 await relay.hosts.delete(key);138 }139 for (const row of group) relay.sql.exec(`DELETE FROM site_outbox WHERE seq=?`, row.seq);140 }141 return relay.sql.exec(`SELECT 1 FROM site_outbox LIMIT 1`).toArray().length > 0;142}143144export async function forgetSites(relay: Relay): Promise<void> {145 if (!relay.hosts) return;146 relay.sql.exec(`INSERT INTO site_outbox(raw,removed) SELECT raw,1 FROM events WHERE kind IN (${SITE_KINDS.join(",")})`);147 do { await relay.syncSites(); } while (relay.sql.exec(`SELECT 1 FROM site_outbox LIMIT 1`).toArray().length);148}149150const SITE_TYPES: Record<string, string> = {151 html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8",152 json: "application/json", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",153 txt: "text/plain; charset=utf-8", xml: "application/xml", wasm: "application/wasm", woff: "font/woff", woff2: "font/woff2", pdf: "application/pdf",154};155export const siteType = (path: string) => SITE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";156const siteError = (req: Request, status: number, message: string) => new Response(req.method === "HEAD" ? null : message, { status, headers: { "cache-control": "private, no-store", "content-type": "text/plain; charset=utf-8" } });157const discoveryError = (req: Request, status: number, message: string) => {158 const response = siteError(req, status, message);159 const headers = new Headers(response.headers);160 headers.set("access-control-allow-origin", "*");161 return new Response(response.body, { status: response.status, headers });162};163164// siteRelayURL gives a discovery document the relay address behind a hosted165// site. A custom site hostname must never become the relay hint.166const siteRelayURL = (relay: Relay, url: URL): string => {167 const host = url.hostname.endsWith(".localhost")168 ? `${relay.slug}.localhost${url.port ? ":" + url.port : ""}`169 : `${relay.slug}.${relay.domain}`;170 return relay.relayURL(host);171};172173// sitePath selects the browser's exact escaped filename before its decoded174// counterpart. Discovery follows a directory's redirect to its index page.175function sitePath(e: Event, pathname: string): { redirect: boolean; mapping: string[] | undefined } | null {176 let decoded: string;177 try { decoded = decodeURIComponent(pathname); } catch { return null; }178 const directory = !/[^/]+\.[^/.]+$/.test(decoded);179 const redirect = directory && !decoded.endsWith("/");180 if (redirect) { pathname += "/"; decoded += "/"; }181 if (directory) decoded += "index.html";182 const paths = sitePaths(e);183 return { redirect, mapping: paths.find((t) => t[1] === pathname) ?? paths.find((t) => t[1] === decoded) };184}185186// serveSite answers the site origin, under the read rule. Relay handlers187// and content negotiation never run here, even on paths the relay uses.188export async function serveSite(relay: Relay, req: Request, label: string): Promise<Response> {189 const site = parseSite(label);190 const url = new URL(req.url);191 const discovery = isWebAddressRequest(url);192 const fail = (status: number, message: string) => discovery ? discoveryError(req, status, message) : siteError(req, status, message);193 const host = url.hostname;194 const canonical = host === `${label}.${relay.domain.toLowerCase()}` || host === `${label}.localhost`;195 if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return fail(404, "Not found");196 if (!site || !featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return fail(404, "Not found");197 if (discovery && req.method === "OPTIONS") {198 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, HEAD, OPTIONS", "cache-control": "no-store" } });199 }200 if (req.headers.get("upgrade") || (!["GET", "HEAD"].includes(req.method) && !(url.pathname === SITE_AUTH_PATH && req.method === "POST"))) {201 return discovery ? webAddressResponse(req, null, null, siteRelayURL(relay, url), 405) : siteError(req, 405, "Method not allowed");202 }203 const who = await siteIdentity(relay, req, label);204 if (who instanceof Response) return discovery ? (() => { const headers = new Headers(who.headers); headers.set("access-control-allow-origin", "*"); return new Response(who.body, { status: who.status, headers }); })() : who;205 const gate = relay.settings.mayRead(who.pubkeys);206 if (gate) return fail(denyStatus(gate), gate);207 const e = manifest(relay, site);208 if (!e) return discovery ? webAddressResponse(req, requestedPath(url), null, siteRelayURL(relay, url), 404) : siteError(req, 404, "Not found");209 if (discovery) {210 if (!featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return fail(404, "Not found");211 if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return fail(404, "Not found");212 const path = requestedPath(url);213 if (path === null) return webAddressResponse(req, null, null, siteRelayURL(relay, url), 400);214 const mapping = sitePath(e, path)?.mapping;215 return webAddressResponse(req, path, mapping && !blobBlocked(relay, mapping[2]) ? eventFilter(e) : null, siteRelayURL(relay, url));216 }217 const selected = sitePath(e, url.pathname);218 if (!selected) return siteError(req, 404, "Not found");219 if (selected.redirect) {220 url.pathname += "/";221 return new Response(null, { status: 308, headers: { location: url.href, "cache-control": "private, no-store" } });222 }223 const paths = sitePaths(e);224 let mapping = selected.mapping;225 let status = 200;226 if (!mapping) { mapping = paths.find((t) => t[1] === "/404.html"); status = 404; }227 if (!mapping || blobBlocked(relay, mapping[2])) return siteError(req, 404, "Not found");228 const sha = mapping[2];229 const blob = relay.sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, sha).toArray()[0];230 const obj = blob ? await relay.media.get(`${relay.slug}/${sha}`) : null;231 let remote: Awaited<ReturnType<typeof remoteSiteBlob>> = null;232 if (obj) {233 // Verify in a streaming pass, then serve the same immutable R2 version.234 const hash = sha256.create();235 const reader = obj.body.getReader();236 for (;;) {237 const { value, done } = await reader.read();238 if (done) break;239 hash.update(value);240 }241 if (bytesToHex(hash.digest()) !== sha) return siteError(req, 404, "Not found");242 } else {243 remote = await remoteSiteBlob(relay, e, mapping);244 if (!remote) return siteError(req, 404, "Not found");245 }246 // Every asynchronous fetch is followed by the live policy and manifest247 // check, including HEAD and conditional responses.248 if (!featureOn(relay.settings.policy, "sites") || blobBlocked(relay, sha) || manifest(relay, site)?.id !== e.id || relay.settings.mayRead(who.pubkeys)) return siteError(req, 404, "Not found");249 const headers = new Headers({250 etag: `"${sha}"`,251 // Revalidation respects policy changes and expiry, including snapshots.252 // Edge transforms must preserve the bytes named by the signed manifest.253 "cache-control": relay.settings.policy.reads === "open" ? "public, no-cache, must-revalidate, no-transform" : "private, no-store, no-transform",254 "x-content-type-options": "nosniff", "referrer-policy": "same-origin",255 });256 if (obj && blob) {257 headers.set("content-type", blob.type || siteType(mapping[1]));258 headers.set("content-length", String(obj.size));259 } else if (remote) {260 if (remote.type) headers.set("content-type", remote.type);261 if (remote.length !== null) headers.set("content-length", remote.length);262 }263 if (status === 200 && req.headers.get("if-none-match")?.split(",").some((v) => v.trim().replace(/^W\//, "") === headers.get("etag") || v.trim() === "*")) {264 headers.delete("content-length");265 return new Response(null, { status: 304, headers });266 }267 if (req.method === "HEAD") return new Response(null, { status, headers });268 if (remote) {269 relay.meterBytes(0, remote.bytes.length);270 return new Response(remote.bytes, { status, headers });271 }272 if (!obj) return siteError(req, 404, "Not found");273 const verified = await relay.media.get(`${relay.slug}/${sha}`, { onlyIf: { etagMatches: obj.etag } });274 if (!verified || !("body" in verified) || !featureOn(relay.settings.policy, "sites") || relay.settings.leaseExpired(now()) || blobBlocked(relay, sha) || manifest(relay, site)?.id !== e.id || relay.settings.mayRead(who.pubkeys)) return siteError(req, 404, "Not found");275 relay.meterBytes(0, verified.size);276 return new Response(verified.body, { status, headers });277}