clone

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

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / src / nipad.ts
nipad.ts 3.9K
1// NIP-AD web addresses: a browser path names a live Nostr event on this2// relay. Discovery only reads local state; no supplied path becomes a fetch.3// Proposal revision: b82a9bf66ec9757149b5aa3b3cd36190dd4e6fed (PR 2406).4import { whoAsks, denyStatus } from "./auth.ts";5import { isAddressable, isReplaceable, now, tag, type Event } from "./event.ts";6import { KIND_GROUP_METADATA } from "./kinds.ts";7import { pageEvent } from "./pages.ts";8import type { Relay } from "./relay.ts";9import { featureOn } from "./settings.ts";1011// A name lookup retains NIP-05 semantics even when a path is also supplied.12export const isWebAddressRequest = (url: URL): boolean => url.pathname === "/.well-known/nostr.json" && url.searchParams.has("path") && !url.searchParams.has("name");1314// requestedPath keeps the original escaped spelling as the response key.15// Full URLs, queries, fragments, dot segments and ambiguous separators are16// not pathnames. One decoding pass matches the browser page and site doors.17export function requestedPath(url: URL): string | null {18 const paths = url.searchParams.getAll("path");19 if (paths.length !== 1) return null;20 const path = paths[0];21 if (path.length > 4096 || !path.startsWith("/") || path.startsWith("//") || /[\\?#\s\x00-\x1f\x7f]/.test(path)) return null;22 let decoded: string;23 try { decoded = decodeURIComponent(path); } catch { return null; }24 if (/[\\\x00-\x1f\x7f]/.test(decoded) || decoded.startsWith("//") || decoded.split("/").some((s) => s === "." || s === "..")) return null;25 return path;26}2728export function eventFilter(e: Event): Record<string, unknown> {29 if (isAddressable(e.kind)) return { kinds: [e.kind], authors: [e.pubkey], "#d": [tag(e, "d")], limit: 1 };30 if (isReplaceable(e.kind)) return { kinds: [e.kind], authors: [e.pubkey], limit: 1 };31 return { ids: [e.id], limit: 1 };32}3334export function webAddressResponse(req: Request, path: string | null, filter: Record<string, unknown> | null, relayURL: string, status = 200): Response {35 const headers = {36 "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store",37 "access-control-allow-origin": "*", "access-control-allow-methods": "GET, HEAD, OPTIONS",38 "access-control-allow-headers": "authorization, accept", "x-content-type-options": "nosniff",39 ...(status === 405 ? { allow: "GET, HEAD, OPTIONS" } : {}),40 };41 const body = path !== null && filter ? { [path]: { filter, relays: [relayURL] } } : {};42 return new Response(req.method === "HEAD" || req.method === "OPTIONS" ? null : JSON.stringify(body), { status, headers });43}4445export function webAddress(relay: Relay, req: Request, url: URL): Response {46 const relayURL = relay.relayURL(url.host);47 const reply = (path: string | null, filter: Record<string, unknown> | null = null, status = 200) => webAddressResponse(req, path, filter, relayURL, status);48 if (req.method === "OPTIONS") return reply(null);49 if (req.method !== "GET" && req.method !== "HEAD") return reply(null, null, 405);50 const path = requestedPath(url);51 if (path === null) return reply(null, null, 400);52 const who = whoAsks(req, "", null);53 if (typeof who === "string") return reply(null, null, 401);54 if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return reply(null);55 if (path === "/") {56 const gate = relay.settings.mayRead(who.pubkeys);57 if (gate) return reply(null, null, denyStatus(gate));58 const authors = [relay.identity.pubkey];59 const kinds = [KIND_GROUP_METADATA];60 const raw = relay.store.query({ authors, kinds, tags: { d: [relay.slug] }, limit: 1 }, who, 1, now()).rows[0];61 return reply(path, raw ? eventFilter(JSON.parse(raw) as Event) : null);62 }63 const e = featureOn(relay.settings.policy, "pages") ? pageEvent(relay, path) : null;64 // An /e URL always names that exact version, including an article version.65 return reply(path, e ? (path.startsWith("/e/") ? { ids: [e.id], limit: 1 } : eventFilter(e)) : null);66}