1// A name is a site as well as a relay. Notes render at /e/<id>, articles at2// /a/<d> (the owner's) or /a/<author>/<d>, njump style, with Open Graph3// tags so links unfurl, and /feed.xml is an Atom feed of both. Only when4// anyone may read: a members-only relay renders nothing here.5import * as nip19 from "nostr-tools/nip19";6import { escapeHTML, page } from "./ui.ts";7import { now, tagValues, type Event } from "./event.ts";8import type { Filter } from "./filter.ts";9import type { Access } from "./store.ts";10import type { Relay } from "./relay.ts";1112const PUBLIC: Access = { pubkeys: [] }; // never private kinds13const KINDS = [1, 30023];14const FEED_LIMIT = 50;15const HEX64 = /^[0-9a-f]{64}$/;16const IMAGE_RE = /\.(png|jpe?g|gif|webp|avif|svg)(\?.*)?$/i;17const CACHE = "public, max-age=300";1819export function isPagePath(path: string): boolean {20 return path === "/feed.xml" || path.startsWith("/e/") || path.startsWith("/a/");21}2223export function pages(relay: Relay, req: Request): Response {24 const url = new URL(req.url);25 const origin = relay.relayURL(url.host).replace(/^ws/, "http");26 // Public relays, leased ones included, have pages; unclaimed ones have nothing to show.27 if (relay.settings.isUnclaimed()) return nothing(relay, "Nobody has claimed this relay yet.");28 if (relay.settings.policy.reads !== "open") return nothing(relay, "This relay's events are for its members.");29 if (url.pathname === "/feed.xml") return feed(relay, url, origin);30 const e = pageEvent(relay, url.pathname);31 if (!e) return nothing(relay, "Nothing by that name here.");32 return new Response(render(relay, e, origin), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } });33}3435// pageEvent selects the same live, public event for HTML and web addresses.36export function pageEvent(relay: Relay, path: string): Event | null {37 if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now()) || relay.settings.policy.reads !== "open") return null;38 const parts = path.split("/").filter(Boolean);39 let e: Event | null = null;40 // A malformed escape names no page, rather than raising a URIError.41 try {42 if (parts[0] === "e" && parts.length === 2 && HEX64.test(parts[1])) e = one(relay, { ids: [parts[1]], kinds: KINDS, tags: {} });43 else if (parts[0] === "a" && parts.length === 2) e = one(relay, { kinds: [30023], authors: [relay.settings.policy.owner], tags: { d: [decodeURIComponent(parts[1])] } });44 else if (parts[0] === "a" && parts.length === 3) {45 const author = pubkeyOf(parts[1]);46 if (author) e = one(relay, { kinds: [30023], authors: [author], tags: { d: [decodeURIComponent(parts[2])] } });47 }48 } catch { return null; }49 return e;50}5152function nothing(relay: Relay, why: string): Response {53 const body = `<main><h1>Not here</h1><p class="lead">${escapeHTML(why)}</p><footer class="pg"><p><a href="/">${escapeHTML(relay.settings.policy.name || relay.slug)}</a></p></footer></main>`;54 return new Response(page("not here", body, PAGE_CSS), { status: 404, headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } });55}5657function one(relay: Relay, f: Filter): Event | null {58 const r = relay.store.query({ ...f, limit: 1 }, PUBLIC, 1, now());59 return r.rows[0] ? (JSON.parse(r.rows[0]) as Event) : null;60}6162function list(relay: Relay, f: Filter, limit: number): Event[] {63 return relay.store.query({ ...f, limit }, PUBLIC, limit, now()).rows.map((r) => JSON.parse(r) as Event);64}6566function pubkeyOf(s: string): string {67 if (HEX64.test(s)) return s;68 try {69 const d = nip19.decode(s);70 if (d.type === "npub") return d.data;71 if (d.type === "nprofile") return d.data.pubkey;72 } catch {73 /* not bech32 */74 }75 return "";76}7778// ---- what a thing is called ----7980function firstLine(s: string): string {81 return s.trim().split("\n")[0].trim();82}8384function clip(s: string, n: number): string {85 const t = s.replace(/\s+/g, " ").trim();86 return t.length > n ? t.slice(0, n - 1).trimEnd() + "…" : t;87}8889export function titleOf(e: Event): string {90 if (e.kind === 30023) return tagValues(e, "title")[0] || firstLine(e.content) || "untitled";91 return clip(firstLine(e.content) || "a note", 80);92}9394export function summaryOf(e: Event): string {95 if (e.kind === 30023) {96 const s = tagValues(e, "summary")[0];97 if (s) return clip(s, 200);98 }99 return clip(e.content.replace(/https?:\/\/\S+/g, "").replace(/nostr:\S+/g, ""), 200);100}101102function imageOf(e: Event): string {103 if (e.kind === 30023) {104 const i = tagValues(e, "image")[0];105 if (i) return i;106 }107 const m = /https?:\/\/[^\s<>"']+/g;108 let x: RegExpExecArray | null;109 while ((x = m.exec(e.content))) if (IMAGE_RE.test(x[0])) return x[0];110 return "";111}112113function authorOf(relay: Relay, pubkey: string): string {114 const k0 = one(relay, { kinds: [0], authors: [pubkey], tags: {} });115 if (k0) {116 try {117 const p = JSON.parse(k0.content) as { name?: unknown; display_name?: unknown };118 const n = (typeof p.display_name === "string" && p.display_name.trim()) || (typeof p.name === "string" && p.name.trim());119 if (n) return n;120 } catch {121 /* bad profile */122 }123 }124 const m = relay.settings.member(pubkey);125 if (m?.name) return m.name;126 return nip19.npubEncode(pubkey).slice(0, 12) + "…";127}128129function pathOf(relay: Relay, e: Event): string {130 if (e.kind === 30023) {131 const d = tagValues(e, "d")[0] ?? "";132 return e.pubkey === relay.settings.policy.owner ? `/a/${encodeURIComponent(d)}` : `/a/${nip19.npubEncode(e.pubkey)}/${encodeURIComponent(d)}`;133 }134 return `/e/${e.id}`;135}136137function nostrURI(relay: Relay, e: Event, wsURL: string): string {138 if (e.kind === 30023) return "nostr:" + nip19.naddrEncode({ kind: e.kind, pubkey: e.pubkey, identifier: tagValues(e, "d")[0] ?? "", relays: [wsURL] });139 return "nostr:" + nip19.neventEncode({ id: e.id, author: e.pubkey, kind: e.kind, relays: [wsURL] });140}141142// ---- content ----143144// renderText escapes, links URLs, inlines images, and turns nostr:145// references into links here when the target is on this relay.146export function renderText(relay: Relay, text: string): string {147 const re = /(https?:\/\/[^\s<>"']+)|nostr:([a-z0-9]+)/g;148 let out = "";149 let last = 0;150 let m: RegExpExecArray | null;151 while ((m = re.exec(text))) {152 out += escapeHTML(text.slice(last, m.index));153 if (m[1]) {154 const u = m[1].replace(/[.,;:!?)\]]+$/, "");155 const trail = m[1].slice(u.length);156 out += IMAGE_RE.test(u) ? `<img src="${escapeHTML(u)}" alt="" loading="lazy">` : `<a href="${escapeHTML(u)}" rel="noopener nofollow">${escapeHTML(u)}</a>`;157 out += escapeHTML(trail);158 } else out += nostrLink(relay, m[2]);159 last = re.lastIndex;160 }161 out += escapeHTML(text.slice(last));162 return out163 .split(/\n{2,}/)164 .map((p) => "<p>" + p.replace(/\n/g, "<br>") + "</p>")165 .join("");166}167168function nostrLink(relay: Relay, bech: string): string {169 const uri = "nostr:" + bech;170 const label = escapeHTML(bech.slice(0, 12) + "…");171 try {172 const d = nip19.decode(bech);173 if (d.type === "note" || d.type === "nevent") {174 const id = d.type === "note" ? d.data : d.data.id;175 const e = one(relay, { ids: [id], kinds: KINDS, tags: {} });176 if (e) return `<a href="${pathOf(relay, e)}">${escapeHTML(titleOf(e))}</a>`;177 } else if (d.type === "naddr" && d.data.kind === 30023) {178 const e = one(relay, { kinds: [30023], authors: [d.data.pubkey], tags: { d: [d.data.identifier] } });179 if (e) return `<a href="${pathOf(relay, e)}">${escapeHTML(titleOf(e))}</a>`;180 } else if (d.type === "npub" || d.type === "nprofile") {181 const pk = d.type === "npub" ? d.data : d.data.pubkey;182 return `<a href="${escapeHTML(uri)}">@${escapeHTML(authorOf(relay, pk))}</a>`;183 }184 } catch {185 /* not a reference */186 }187 return `<a href="${escapeHTML(uri)}">${label}</a>`;188}189190const PAGE_CSS = `191main { max-width: 44rem; padding-top: 6vh; }192.who { display: flex; align-items: center; gap: .6rem; color: var(--ink-2); margin-bottom: 1rem; } .who b { color: var(--ink); }193h1 { font-size: clamp(2rem, 6vw, 3.2rem); margin: .2rem 0 1rem; }194article { font-size: 1.05rem; line-height: 1.6; } article p { margin: 0 0 1rem; overflow-wrap: anywhere; }195article img { max-width: 100%; border-radius: 12px; border: 2px solid var(--ink); box-shadow: 4px 4px 0 var(--ink); display: block; margin: .6rem 0; }196.hero { max-width: 100%; border-radius: 14px; border: 2px solid var(--ink); box-shadow: 4px 4px 0 var(--ink); margin: 0 0 1.2rem; display: block; }197pre { white-space: pre-wrap; word-break: break-all; font: 12px var(--mono); background: var(--butter); border: 2px solid var(--ink); border-radius: 10px; padding: .8rem; }198.open { display: inline-flex; gap: .5rem; margin: 1.2rem 0; }199`;200201function render(relay: Relay, e: Event, origin: string): string {202 const host = new URL(origin).host;203 const wsURL = relay.relayURL(host);204 const title = titleOf(e);205 const summary = summaryOf(e);206 const image = imageOf(e);207 const author = authorOf(relay, e.pubkey);208 const canonical = origin + pathOf(relay, e);209 const site = relay.settings.policy.name || relay.slug;210 const when = new Date(e.created_at * 1000).toISOString().slice(0, 10);211 const uri = nostrURI(relay, e, wsURL);212 const head = [213 `<meta property="og:site_name" content="${escapeHTML(site)}">`,214 `<meta property="og:type" content="article">`,215 `<meta property="og:title" content="${escapeHTML(title)}">`,216 `<meta property="og:description" content="${escapeHTML(summary)}">`,217 `<meta property="og:url" content="${escapeHTML(canonical)}">`,218 image ? `<meta property="og:image" content="${escapeHTML(image)}">` : "",219 `<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}">`,220 `<meta name="twitter:title" content="${escapeHTML(title)}">`,221 `<meta name="twitter:description" content="${escapeHTML(summary)}">`,222 image ? `<meta name="twitter:image" content="${escapeHTML(image)}">` : "",223 `<meta name="author" content="${escapeHTML(author)}">`,224 `<link rel="canonical" href="${escapeHTML(canonical)}">`,225 `<link rel="alternate" type="application/atom+xml" title="${escapeHTML(site)}" href="${escapeHTML(origin)}/feed.xml">`,226 ]227 .filter(Boolean)228 .join("\n");229 const hue = (parseInt(e.pubkey.slice(0, 2), 16) * 360) / 256;230 const heading = e.kind === 30023 ? `<h1>${escapeHTML(title)}</h1>` + (image ? `<img class="hero" src="${escapeHTML(image)}" alt="">` : "") : "";231 const body = `<main class="post">232 <header class="who"><i class="av" style="--h:${hue.toFixed(0)}deg"></i><b>${escapeHTML(author)}</b><span>${when}</span><span class="muted">on ${escapeHTML(site)}</span></header>233 ${heading}234 <article>${renderText(relay, e.content)}</article>235 <p class="open"><a class="btn" href="${escapeHTML(uri)}">Open in a nostr client</a></p>236 <details class="disclosure"><summary>Raw event</summary><pre>${escapeHTML(JSON.stringify(e, null, 2))}</pre></details>237 <footer class="pg"><p><a href="/">${escapeHTML(site)}</a></p><a href="/feed.xml">feed</a></footer>238</main>`;239 return page(`${title} - ${site}`, body, PAGE_CSS, head);240}241242// ---- feed ----243244function feed(relay: Relay, url: URL, origin: string): Response {245 const kindsParam = url.searchParams.get("kinds");246 const kinds = kindsParam ? kindsParam.split(",").map((k) => parseInt(k, 10)).filter((k) => KINDS.includes(k)) : KINDS;247 const author = url.searchParams.get("author") ?? "";248 const f: Filter = { kinds: kinds.length ? kinds : KINDS, tags: {} };249 if (HEX64.test(author)) f.authors = [author];250 const events = list(relay, f, FEED_LIMIT);251 const site = relay.settings.policy.name || relay.slug;252 const iso = (t: number) => new Date(t * 1000).toISOString();253 const entries = events.map((e) => {254 const link = origin + pathOf(relay, e);255 const id = e.kind === 30023 ? `${e.kind}:${e.pubkey}:${tagValues(e, "d")[0] ?? ""}` : e.id;256 return `<entry>257 <id>urn:nostr:${escapeHTML(id)}</id>258 <title>${escapeHTML(titleOf(e))}</title>259 <link href="${escapeHTML(link)}"/>260 <updated>${iso(e.created_at)}</updated>261 <author><name>${escapeHTML(authorOf(relay, e.pubkey))}</name></author>262 <summary>${escapeHTML(summaryOf(e))}</summary>263 <content type="text">${escapeHTML(e.content)}</content>264</entry>`;265 });266 const xml = `<?xml version="1.0" encoding="utf-8"?>267<feed xmlns="http://www.w3.org/2005/Atom">268<title>${escapeHTML(site)}</title>269<subtitle>${escapeHTML(relay.settings.policy.description)}</subtitle>270<id>${escapeHTML(origin)}/</id>271<link href="${escapeHTML(origin)}/"/>272<link rel="self" href="${escapeHTML(origin + url.pathname + url.search)}"/>273<updated>${iso(events[0]?.created_at ?? now())}</updated>274${entries.join("\n")}275</feed>`;276 return new Response(xml, { headers: { "content-type": "application/atom+xml; charset=utf-8", "cache-control": CACHE } });277}