- commit
- bf7b437c8fb6825d2b8c2391a1c05da85aff9908
- parent
- 06f1328510294cd50eadbcf8a4dd7c0fd4b3552c
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 84 | ++------------------ |
| A | src/ | 124 | ++++++++++++++++++++++++++++++ |
@@ -11,35 +11,28 @@ import { discovery } from "./nip66.ts";
1111 import { Audit } from "./audit.ts";1212 import { ERR_DUPLICATE, ERR_TOO_BIG, Store, type Access } from "./store.ts";1313 import { Settings, isReplaceable, isProtected } from "./settings.ts";14-import { manage } from "./manage.ts";1514 import { Succession } from "./succession.ts";16-import { nip11 } from "./nip11.ts";15+import { isGated, route } from "./routes.ts";1716 import { guestPass, readGate, writeGate } from "./gates.ts";18-import { dashboard } from "./dashboard.ts";19-import { Fuel, fuelConfig, acceptReceipt, fuelInvoice, type Fetcher, type LnurlParams } from "./fuel.ts";17+import { Fuel, fuelConfig, acceptReceipt, type Fetcher, type LnurlParams } from "./fuel.ts";2018 import { Identity } from "./identity.ts";2119 import { Bucket } from "./ratelimit.ts";22-import { bridge } from "./bridge.ts";23-import { blossom, blobBytes, isBlobPath } from "./blossom.ts";24-import { nip96 } from "./nip96.ts";25-import { dumpBytes, dumpDue, dumpDownload, writeDump } from "./dumps.ts";26-import { importBytes, importUpload } from "./imports.ts";20+import { blobBytes } from "./blossom.ts";21+import { dumpBytes, dumpDue, writeDump } from "./dumps.ts";22+import { importBytes } from "./imports.ts";2723 import { DIGEST_DAYS, digestText } from "./notify.ts";28-import { claimFromProfile, nip05Document } from "./nip05.ts";24+import { claimFromProfile } from "./nip05.ts";2925 import { checkInvite, claimInviteRequest, invitePage, termsPage } from "./invites.ts";3026 import { verifyNIP98, whoAsks } from "./auth.ts";31-import { FAVICON_SVG } from "./ui.ts";3227 import type { PullFilter, PullJob, PullResult } from "./pull.ts";3328 import { leaseDays, leaseNames, validName } from "./names.ts";3429 import { MAX_JOBS, MAX_STANDING, checkJob, finishRun, newJobID, pruneFinished, pullView, runRound, startRun, type Job, type JobSpec } from "./jobs.ts";3530 import { Hostnames, forgetDomains } from "./domains.ts";3631 import { hostOf } from "./edge.ts";3732 import { groupFacts, handleGroupEvent, isGroupManagement, isNIP43Request } from "./groups.ts";38-import { SIGNER_JS } from "./gen/signer.ts";3933 import { isPagePath, pages } from "./pages.ts";4034 import { notify, fuelLow, fuelText } from "./notify.ts";41-import { card } from "./card.ts";42-import { markView, notePresence, serveView, viewsTick } from "./views.ts";35+import { markView, notePresence, viewsTick } from "./views.ts";4336 import type { Blob } from "./blossom.ts";4437 import { KIND_VANISH, KIND_AUTH, KIND_REPORT, KIND_NOSTR_CONNECT, KIND_GROUP_MEMBERS, KIND_GROUP_PINS, KIND_RELAY_DISCOVERY } from "./kinds.ts";4538 @@ -79,7 +72,6 @@ export interface Env {
7972 }8073 8174 export const MAX_MESSAGE = 1024 * 1024;82-const FAVICON = FAVICON_SVG;8375 const MAX_SYNC = 100_000;8476 // How long a leased relay keeps everything, so a claim inherits a bounded8577 // window until the owner resets the rules.@@ -745,70 +737,14 @@ export class Relay extends DurableObject<Env> {
745737 const url = new URL(req.url);746738 // Relays claimed before identities existed get theirs on first contact.747739 if (this.settings.policy.owner && !this.identity.pubkey) await this.publishMembership();748- // A blocked address gets no socket and no door that writes or reads749- // events or files. The page, NIP-11 and management stay reachable, so750- // an owner who blocked their own address can undo it.740+ // A blocked address gets no socket and no gated door (routes.ts).751741 const upgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";752- const door = upgrade || (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count" || url.pathname === "/api/invites/claim" || url.pathname === "/fuel/invoice")) || url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname === "/report" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname);753- if (door && this.settings.isIPBlocked(clientIP(req))) {742+ if ((upgrade || isGated(url, req)) && this.settings.isIPBlocked(clientIP(req))) {754743 const msg = "blocked: this address is blocked from this relay";755744 return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "content-type": "application/json", "x-reason": msg, "access-control-allow-origin": "*" } });756745 }757746 if (upgrade) return this.acceptWebSocket(req);758- if (req.headers.get("accept")?.includes("application/nostr+json")) {759- return Response.json(nip11(this, url.host), {760- headers: { "content-type": "application/nostr+json", "access-control-allow-origin": "*" },761- });762- }763- if (req.method === "POST" && req.headers.get("content-type")?.includes("application/nostr+json+rpc")) return manage(this, req);764- if (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count")) return bridge(this, req);765- if (url.pathname === "/.well-known/nostr.json") {766- const who = whoAsks(req, "", null);767- if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: { "access-control-allow-origin": "*" } });768- return Response.json(nip05Document(this.settings, url.searchParams.get("name"), this.relayURL(url.host), who.pubkeys), { headers: { "access-control-allow-origin": "*" } });769- }770- if (url.pathname === "/people" && req.method === "GET") {771- const p = this.settings.policy;772- const who = whoAsks(req, "", null);773- if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: { "access-control-allow-origin": "*" } });774- const listed = p.owner !== "" && this.settings.mayList(who.pubkeys) === "";775- const people = listed ? this.settings.members().map((m) => ({ pubkey: m.pubkey, role: m.role, name: m.name })) : [];776- return Response.json({ public: p.directoryPublic, self: this.identity.pubkey, host: url.host, people }, { headers: { "access-control-allow-origin": "*" } });777- }778- if (url.pathname === "/terms" && req.method === "GET") {779- const terms = this.settings.policy.joinTerms;780- if (!terms) return new Response("this relay has no terms", { status: 404 });781- return new Response(termsPage(this.settings.policy.name || this.slug, url.host, terms), { headers: { "content-type": "text/html; charset=utf-8" } });782- }783- if (url.pathname.startsWith("/invite/") && req.method === "GET") {784- const code = url.pathname.slice(8);785- const status = this.settings.policy.owner === "" ? "invite_invalid" : checkInvite(this.sql, code, now());786- return new Response(invitePage(this.settings.policy.name || this.slug, url.host, code, status, this.settings.policy.joinTerms), { headers: { "content-type": "text/html; charset=utf-8" } });787- }788- if (url.pathname === "/api/join-policy" && req.method === "GET") {789- return Response.json({ terms: this.settings.policy.joinTerms }, { headers: { "access-control-allow-origin": "*" } });790- }791- if (url.pathname === "/api/invites/claim" && req.method === "POST") return claimInviteRequest(this, req);792- if (url.pathname.startsWith("/dumps/") && req.method === "GET") return dumpDownload(this, req);793- if (url.pathname.startsWith("/view/") && req.method === "GET") return serveView(this, req, verifyNIP98);794- if (url.pathname === "/import" && req.method === "PUT") return importUpload(this, req);795- if (url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname === "/report" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname)) return blossom(this, req);796- if (url.pathname === "/.well-known/nostr/nip96.json" || url.pathname === "/nip96" || url.pathname.startsWith("/nip96/")) return nip96(this, req);797- if (url.pathname === "/fuel" && req.method === "GET") {798- // Meters and prices for anyone who might top up; who paid is the799- // owner's, in the stats method.800- return Response.json(this.fuelStatus(), { headers: { "access-control-allow-origin": "*" } });801- }802- if (url.pathname === "/fuel/invoice" && req.method === "POST") return fuelInvoice(this, req);803- if (url.pathname === "/card.json" || url.pathname === "/card.nostr" || url.pathname === "/card.svg" || url.pathname === "/qr.svg") return card(this, req);804- if (req.method === "OPTIONS") {805- return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, POST, OPTIONS" } });806- }807- if (isPagePath(url.pathname) && req.method === "GET") return pages(this, req);808- if (url.pathname === "/") return new Response(dashboard(),{ headers: { "content-type": "text/html; charset=utf-8" } });809- if (url.pathname === "/signer.js") return new Response(SIGNER_JS, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=604800, immutable" } });810- if (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") return new Response(FAVICON, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } });811- return new Response("not found", { status: 404 });747+ return route(this, req, url);812748 }813749 814750 connections(): number {@@ -0,0 +1,124 @@
1+// The HTTP doors of a relay, in the order they are tried. A door marked2+// gated writes or reads events or files, so a blocked address is refused at3+// it; the page, NIP-11 and management stay reachable, so an owner who4+// blocked their own address can undo it. The websocket upgrade is the one5+// door relay.ts answers itself, before this table.6+import type { Relay } from "./relay.ts";7+import { now } from "./event.ts";8+import { manage } from "./manage.ts";9+import { bridge } from "./bridge.ts";10+import { nip11 } from "./nip11.ts";11+import { nip05Document } from "./nip05.ts";12+import { verifyNIP98, whoAsks } from "./auth.ts";13+import { checkInvite, claimInviteRequest, invitePage, termsPage } from "./invites.ts";14+import { dumpDownload } from "./dumps.ts";15+import { serveView } from "./views.ts";16+import { importUpload } from "./imports.ts";17+import { blossom, isBlobPath } from "./blossom.ts";18+import { nip96 } from "./nip96.ts";19+import { fuelInvoice } from "./fuel.ts";20+import { card } from "./card.ts";21+import { isPagePath, pages } from "./pages.ts";22+import { dashboard } from "./dashboard.ts";23+import { SIGNER_JS } from "./gen/signer.ts";24+import { FAVICON_SVG } from "./ui.ts";25+26+export interface Route {27+ // when says whether this door answers the request.28+ when: (url: URL, req: Request) => boolean;29+ // A door that writes or reads events or files: refused to a blocked address.30+ gated?: true;31+ answer: (relay: Relay, req: Request, url: URL) => Response | Promise<Response>;32+}33+34+const CORS = { "access-control-allow-origin": "*" };35+const json = (body: unknown, status = 200) => Response.json(body, { status, headers: CORS });36+const html = (body: string) => new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });37+const is = (...paths: string[]) => (url: URL) => paths.includes(url.pathname);38+const under = (prefix: string) => (url: URL) => url.pathname.startsWith(prefix);39+const get = (when: (url: URL) => boolean) => (url: URL, req: Request) => req.method === "GET" && when(url);40+const post = (when: (url: URL) => boolean) => (url: URL, req: Request) => req.method === "POST" && when(url);41+42+export const ROUTES: Route[] = [43+ // NIP-11, on any path, by the accept header.44+ {45+ when: (_, req) => req.headers.get("accept")?.includes("application/nostr+json") ?? false,46+ answer: (relay, _, url) => Response.json(nip11(relay, url.host), { headers: { "content-type": "application/nostr+json", ...CORS } }),47+ },48+ // NIP-86 management, by the content type.49+ { when: (_, req) => req.method === "POST" && (req.headers.get("content-type")?.includes("application/nostr+json+rpc") ?? false), answer: manage },50+ // The HTTP bridge (bridge.ts).51+ { when: post(is("/events", "/query", "/count")), gated: true, answer: bridge },52+ // NIP-05 names, under the read rule for whoever asks.53+ {54+ when: is("/.well-known/nostr.json"),55+ answer: (relay, req, url) => {56+ const who = whoAsks(req, "", null);57+ if (typeof who === "string") return json({ error: who }, 401);58+ return json(nip05Document(relay.settings, url.searchParams.get("name"), relay.relayURL(url.host), who.pubkeys));59+ },60+ },61+ // The directory, for the console: names and roles when the asker may list them.62+ {63+ when: get(is("/people")),64+ answer: (relay, req, url) => {65+ const p = relay.settings.policy;66+ const who = whoAsks(req, "", null);67+ if (typeof who === "string") return json({ error: who }, 401);68+ const listed = p.owner !== "" && relay.settings.mayList(who.pubkeys) === "";69+ const people = listed ? relay.settings.members().map((m) => ({ pubkey: m.pubkey, role: m.role, name: m.name })) : [];70+ return json({ public: p.directoryPublic, self: relay.identity.pubkey, host: url.host, people });71+ },72+ },73+ // Invites (invites.ts): the terms, the invite page, the join policy, the claim.74+ {75+ when: get(is("/terms")),76+ answer: (relay, _, url) => {77+ const terms = relay.settings.policy.joinTerms;78+ if (!terms) return new Response("this relay has no terms", { status: 404 });79+ return html(termsPage(relay.settings.policy.name || relay.slug, url.host, terms));80+ },81+ },82+ {83+ when: get(under("/invite/")),84+ answer: (relay, _, url) => {85+ const code = url.pathname.slice(8);86+ const status = relay.settings.policy.owner === "" ? "invite_invalid" : checkInvite(relay.sql, code, now());87+ return html(invitePage(relay.settings.policy.name || relay.slug, url.host, code, status, relay.settings.policy.joinTerms));88+ },89+ },90+ { when: get(is("/api/join-policy")), answer: (relay) => json({ terms: relay.settings.policy.joinTerms }) },91+ { when: post(is("/api/invites/claim")), gated: true, answer: claimInviteRequest },92+ // Dumps, views, imports (dumps.ts, views.ts, imports.ts).93+ { when: get(under("/dumps/")), answer: dumpDownload },94+ { when: get(under("/view/")), answer: (relay, req) => serveView(relay, req, verifyNIP98) },95+ { when: (url, req) => req.method === "PUT" && url.pathname === "/import", answer: importUpload },96+ // Files: Blossom on its paths (blossom.ts), NIP-96 on its own (nip96.ts).97+ { when: (url) => is("/upload", "/mirror", "/report")(url) || under("/list/")(url) || isBlobPath(url.pathname), gated: true, answer: blossom },98+ { when: (url) => is("/.well-known/nostr/nip96.json", "/nip96")(url) || under("/nip96/")(url), answer: nip96 },99+ // Fuel: meters and prices for anyone who might top up (who paid is the100+ // owner's, in the stats method), and the invoice door (fuel.ts).101+ { when: get(is("/fuel")), answer: (relay) => json(relay.fuelStatus()) },102+ { when: post(is("/fuel/invoice")), gated: true, answer: fuelInvoice },103+ // The card (card.ts).104+ { when: is("/card.json", "/card.nostr", "/card.svg", "/qr.svg"), answer: card },105+ // CORS preflight for everything above.106+ { when: (_, req) => req.method === "OPTIONS", answer: () => new Response(null, { headers: { ...CORS, "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, POST, OPTIONS" } }) },107+ // Notes and articles as pages, the feed (pages.ts).108+ { when: (url, req) => req.method === "GET" && isPagePath(url.pathname), answer: pages },109+ // The relay's own page (dashboard.ts) and what it loads.110+ { when: is("/"), answer: () => html(dashboard()) },111+ { when: is("/signer.js"), answer: () => new Response(SIGNER_JS, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=604800, immutable" } }) },112+ { when: is("/favicon.svg", "/favicon.ico"), answer: () => new Response(FAVICON_SVG, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } }) },113+];114+115+// isGated says whether the request is at a door a blocked address may not use.116+export function isGated(url: URL, req: Request): boolean {117+ return ROUTES.some((r) => r.gated && r.when(url, req));118+}119+120+// route answers the request at the first door that takes it, or 404.121+export function route(relay: Relay, req: Request, url: URL): Response | Promise<Response> {122+ for (const r of ROUTES) if (r.when(url, req)) return r.answer(relay, req, url);123+ return new Response("not found", { status: 404 });124+}