/e/<id> and /a/<d> (the owner's) or /a/<author>/<d> render kind 1 and 30023 njump style with Open Graph and Twitter card tags, the author's name from their kind 0 here, a nostr: link and the raw event behind a disclosure. nostr: references become links here when the target is on this relay. /feed.xml is Atom of both, filterable by kind and author. Every page path answers 404 unless reads are open, and the store access carries no pubkeys, so private kinds can never appear.
- commit
- 4838498768235f7c167a6724deb63a2b4b91767e
- parent
- f779fefc1c5b17d4bc864cf36f813d7376c54803
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 1 | + |
| A | src/ | 265 | ++++++++++++++++++++++++++++++ |
| M | src/ | 4 | +- |
| M | src/ | 3 | +- |
@@ -118,6 +118,7 @@ export function landing(req: Request, env: Env): Response {
118118 ["PUT /upload, GET /<sha256>", "Blossom", "kind 24242", "Stored in R2. Counted as files. HEAD /upload checks first; PUT /mirror copies from a URL; PUT /report files a report."],119119 ["POST /nip96, GET /.well-known/nostr/nip96.json", "NIP-96", "NIP-98", "Same bucket and file list as Blossom. NIP-94 tags in every answer."],120120 ["GET /.well-known/nostr.json", "NIP-05", "none", "Members that have a name."],121+ ["GET /e/<id>, /a/<d>, /feed.xml", "pages, Atom", "none", "Notes and articles as pages with Open Graph tags, and a feed. Only when anyone may read."],121122 ["GET /fuel, POST /fuel/invoice", "NIP-57", "none", "Gauges are public. The invoice comes from a signed zap request."],122123 ]);123124 const limits = table(["Parameter", "Default", "Range"], [@@ -0,0 +1,265 @@
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.5+import * as nip19 from "nostr-tools/nip19";6+import { escapeHTML, page } from "./ui.ts";7+import { now, tagValues, type Event } from "./event.ts";8+import type { Filter } from "./filter.ts";9+import type { Access } from "./store.ts";10+import type { Relay } from "./relay.ts";11+12+const PUBLIC: Access = { pubkeys: [] }; // never private kinds13+const KINDS = [1, 30023];14+const FEED_LIMIT = 50;15+const HEX64 = /^[0-9a-f]{64}$/;16+const IMAGE_RE = /\.(png|jpe?g|gif|webp|avif|svg)(\?.*)?$/i;17+const CACHE = "public, max-age=300";18+19+export function isPagePath(path: string): boolean {20+ return path === "/feed.xml" || path.startsWith("/e/") || path.startsWith("/a/");21+}22+23+export 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+ if (relay.settings.policy.reads !== "open" || relay.settings.policy.owner === "") return nothing(relay, "This relay's events are for its members.");27+ if (url.pathname === "/feed.xml") return feed(relay, url, origin);28+ const parts = url.pathname.split("/").filter(Boolean);29+ let e: Event | null = null;30+ if (parts[0] === "e" && parts.length === 2 && HEX64.test(parts[1])) e = one(relay, { ids: [parts[1]], kinds: KINDS, tags: {} });31+ else if (parts[0] === "a" && parts.length === 2) e = one(relay, { kinds: [30023], authors: [relay.settings.policy.owner], tags: { d: [decodeURIComponent(parts[1])] } });32+ else if (parts[0] === "a" && parts.length === 3) {33+ const author = pubkeyOf(parts[1]);34+ if (author) e = one(relay, { kinds: [30023], authors: [author], tags: { d: [decodeURIComponent(parts[2])] } });35+ }36+ if (!e) return nothing(relay, "Nothing by that name here.");37+ return new Response(render(relay, e, origin), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } });38+}39+40+function nothing(relay: Relay, why: string): Response {41+ 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>`;42+ return new Response(page("not here", body, PAGE_CSS), { status: 404, headers: { "content-type": "text/html; charset=utf-8", "cache-control": CACHE } });43+}44+45+function one(relay: Relay, f: Filter): Event | null {46+ const r = relay.store.query({ ...f, limit: 1 }, PUBLIC, 1, now());47+ return r.rows[0] ? (JSON.parse(r.rows[0]) as Event) : null;48+}49+50+function list(relay: Relay, f: Filter, limit: number): Event[] {51+ return relay.store.query({ ...f, limit }, PUBLIC, limit, now()).rows.map((r) => JSON.parse(r) as Event);52+}53+54+function pubkeyOf(s: string): string {55+ if (HEX64.test(s)) return s;56+ try {57+ const d = nip19.decode(s);58+ if (d.type === "npub") return d.data;59+ if (d.type === "nprofile") return d.data.pubkey;60+ } catch {61+ /* not bech32 */62+ }63+ return "";64+}65+66+// ---- what a thing is called ----67+68+function firstLine(s: string): string {69+ return s.trim().split("\n")[0].trim();70+}71+72+function clip(s: string, n: number): string {73+ const t = s.replace(/\s+/g, " ").trim();74+ return t.length > n ? t.slice(0, n - 1).trimEnd() + "…" : t;75+}76+77+export function titleOf(e: Event): string {78+ if (e.kind === 30023) return tagValues(e, "title")[0] || firstLine(e.content) || "untitled";79+ return clip(firstLine(e.content) || "a note", 80);80+}81+82+export function summaryOf(e: Event): string {83+ if (e.kind === 30023) {84+ const s = tagValues(e, "summary")[0];85+ if (s) return clip(s, 200);86+ }87+ return clip(e.content.replace(/https?:\/\/\S+/g, "").replace(/nostr:\S+/g, ""), 200);88+}89+90+function imageOf(e: Event): string {91+ if (e.kind === 30023) {92+ const i = tagValues(e, "image")[0];93+ if (i) return i;94+ }95+ const m = /https?:\/\/[^\s<>"']+/g;96+ let x: RegExpExecArray | null;97+ while ((x = m.exec(e.content))) if (IMAGE_RE.test(x[0])) return x[0];98+ return "";99+}100+101+function authorOf(relay: Relay, pubkey: string): string {102+ const k0 = one(relay, { kinds: [0], authors: [pubkey], tags: {} });103+ if (k0) {104+ try {105+ const p = JSON.parse(k0.content) as { name?: unknown; display_name?: unknown };106+ const n = (typeof p.display_name === "string" && p.display_name.trim()) || (typeof p.name === "string" && p.name.trim());107+ if (n) return n;108+ } catch {109+ /* bad profile */110+ }111+ }112+ const m = relay.settings.member(pubkey);113+ if (m?.name) return m.name;114+ return nip19.npubEncode(pubkey).slice(0, 12) + "…";115+}116+117+function pathOf(relay: Relay, e: Event): string {118+ if (e.kind === 30023) {119+ const d = tagValues(e, "d")[0] ?? "";120+ return e.pubkey === relay.settings.policy.owner ? `/a/${encodeURIComponent(d)}` : `/a/${nip19.npubEncode(e.pubkey)}/${encodeURIComponent(d)}`;121+ }122+ return `/e/${e.id}`;123+}124+125+function nostrURI(relay: Relay, e: Event, wsURL: string): string {126+ if (e.kind === 30023) return "nostr:" + nip19.naddrEncode({ kind: e.kind, pubkey: e.pubkey, identifier: tagValues(e, "d")[0] ?? "", relays: [wsURL] });127+ return "nostr:" + nip19.neventEncode({ id: e.id, author: e.pubkey, kind: e.kind, relays: [wsURL] });128+}129+130+// ---- content ----131+132+// renderText escapes, links URLs, inlines images, and turns nostr:133+// references into links here when the target is on this relay.134+export function renderText(relay: Relay, text: string): string {135+ const re = /(https?:\/\/[^\s<>"']+)|nostr:([a-z0-9]+)/g;136+ let out = "";137+ let last = 0;138+ let m: RegExpExecArray | null;139+ while ((m = re.exec(text))) {140+ out += escapeHTML(text.slice(last, m.index));141+ if (m[1]) {142+ const u = m[1].replace(/[.,;:!?)\]]+$/, "");143+ const trail = m[1].slice(u.length);144+ out += IMAGE_RE.test(u) ? `<img src="${escapeHTML(u)}" alt="" loading="lazy">` : `<a href="${escapeHTML(u)}" rel="noopener nofollow">${escapeHTML(u)}</a>`;145+ out += escapeHTML(trail);146+ } else out += nostrLink(relay, m[2]);147+ last = re.lastIndex;148+ }149+ out += escapeHTML(text.slice(last));150+ return out151+ .split(/\n{2,}/)152+ .map((p) => "<p>" + p.replace(/\n/g, "<br>") + "</p>")153+ .join("");154+}155+156+function nostrLink(relay: Relay, bech: string): string {157+ const uri = "nostr:" + bech;158+ const label = escapeHTML(bech.slice(0, 12) + "…");159+ try {160+ const d = nip19.decode(bech);161+ if (d.type === "note" || d.type === "nevent") {162+ const id = d.type === "note" ? d.data : d.data.id;163+ const e = one(relay, { ids: [id], kinds: KINDS, tags: {} });164+ if (e) return `<a href="${pathOf(relay, e)}">${escapeHTML(titleOf(e))}</a>`;165+ } else if (d.type === "naddr" && d.data.kind === 30023) {166+ const e = one(relay, { kinds: [30023], authors: [d.data.pubkey], tags: { d: [d.data.identifier] } });167+ if (e) return `<a href="${pathOf(relay, e)}">${escapeHTML(titleOf(e))}</a>`;168+ } else if (d.type === "npub" || d.type === "nprofile") {169+ const pk = d.type === "npub" ? d.data : d.data.pubkey;170+ return `<a href="${escapeHTML(uri)}">@${escapeHTML(authorOf(relay, pk))}</a>`;171+ }172+ } catch {173+ /* not a reference */174+ }175+ return `<a href="${escapeHTML(uri)}">${label}</a>`;176+}177+178+const PAGE_CSS = `179+main { max-width: 44rem; padding-top: 6vh; }180+.who { display: flex; align-items: center; gap: .6rem; color: var(--ink-2); margin-bottom: 1rem; } .who b { color: var(--ink); }181+h1 { font-size: clamp(2rem, 6vw, 3.2rem); margin: .2rem 0 1rem; }182+article { font-size: 1.05rem; line-height: 1.6; } article p { margin: 0 0 1rem; overflow-wrap: anywhere; }183+article img { max-width: 100%; border-radius: 12px; border: 2px solid var(--ink); box-shadow: 4px 4px 0 var(--ink); display: block; margin: .6rem 0; }184+.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; }185+pre { 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; }186+.open { display: inline-flex; gap: .5rem; margin: 1.2rem 0; }187+`;188+189+function render(relay: Relay, e: Event, origin: string): string {190+ const host = new URL(origin).host;191+ const wsURL = relay.relayURL(host);192+ const title = titleOf(e);193+ const summary = summaryOf(e);194+ const image = imageOf(e);195+ const author = authorOf(relay, e.pubkey);196+ const canonical = origin + pathOf(relay, e);197+ const site = relay.settings.policy.name || relay.slug;198+ const when = new Date(e.created_at * 1000).toISOString().slice(0, 10);199+ const uri = nostrURI(relay, e, wsURL);200+ const head = [201+ `<meta property="og:site_name" content="${escapeHTML(site)}">`,202+ `<meta property="og:type" content="article">`,203+ `<meta property="og:title" content="${escapeHTML(title)}">`,204+ `<meta property="og:description" content="${escapeHTML(summary)}">`,205+ `<meta property="og:url" content="${escapeHTML(canonical)}">`,206+ image ? `<meta property="og:image" content="${escapeHTML(image)}">` : "",207+ `<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}">`,208+ `<meta name="twitter:title" content="${escapeHTML(title)}">`,209+ `<meta name="twitter:description" content="${escapeHTML(summary)}">`,210+ image ? `<meta name="twitter:image" content="${escapeHTML(image)}">` : "",211+ `<meta name="author" content="${escapeHTML(author)}">`,212+ `<link rel="canonical" href="${escapeHTML(canonical)}">`,213+ `<link rel="alternate" type="application/atom+xml" title="${escapeHTML(site)}" href="${escapeHTML(origin)}/feed.xml">`,214+ ]215+ .filter(Boolean)216+ .join("\n");217+ const hue = (parseInt(e.pubkey.slice(0, 2), 16) * 360) / 256;218+ const heading = e.kind === 30023 ? `<h1>${escapeHTML(title)}</h1>` + (image ? `<img class="hero" src="${escapeHTML(image)}" alt="">` : "") : "";219+ const body = `<main class="post">220+ <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>221+ ${heading}222+ <article>${renderText(relay, e.content)}</article>223+ <p class="open"><a class="btn" href="${escapeHTML(uri)}">Open in a nostr client</a></p>224+ <details class="disclosure"><summary>Raw event</summary><pre>${escapeHTML(JSON.stringify(e, null, 2))}</pre></details>225+ <footer class="pg"><p><a href="/">${escapeHTML(site)}</a></p><a href="/feed.xml">feed</a></footer>226+</main>`;227+ return page(`${title} - ${site}`, body, PAGE_CSS, head);228+}229+230+// ---- feed ----231+232+function feed(relay: Relay, url: URL, origin: string): Response {233+ const kindsParam = url.searchParams.get("kinds");234+ const kinds = kindsParam ? kindsParam.split(",").map((k) => parseInt(k, 10)).filter((k) => KINDS.includes(k)) : KINDS;235+ const author = url.searchParams.get("author") ?? "";236+ const f: Filter = { kinds: kinds.length ? kinds : KINDS, tags: {} };237+ if (HEX64.test(author)) f.authors = [author];238+ const events = list(relay, f, FEED_LIMIT);239+ const site = relay.settings.policy.name || relay.slug;240+ const iso = (t: number) => new Date(t * 1000).toISOString();241+ const entries = events.map((e) => {242+ const link = origin + pathOf(relay, e);243+ const id = e.kind === 30023 ? `${e.kind}:${e.pubkey}:${tagValues(e, "d")[0] ?? ""}` : e.id;244+ return `<entry>245+ <id>urn:nostr:${escapeHTML(id)}</id>246+ <title>${escapeHTML(titleOf(e))}</title>247+ <link href="${escapeHTML(link)}"/>248+ <updated>${iso(e.created_at)}</updated>249+ <author><name>${escapeHTML(authorOf(relay, e.pubkey))}</name></author>250+ <summary>${escapeHTML(summaryOf(e))}</summary>251+ <content type="text">${escapeHTML(e.content)}</content>252+</entry>`;253+ });254+ const xml = `<?xml version="1.0" encoding="utf-8"?>255+<feed xmlns="http://www.w3.org/2005/Atom">256+<title>${escapeHTML(site)}</title>257+<subtitle>${escapeHTML(relay.settings.policy.description)}</subtitle>258+<id>${escapeHTML(origin)}/</id>259+<link href="${escapeHTML(origin)}/"/>260+<link rel="self" href="${escapeHTML(origin + url.pathname + url.search)}"/>261+<updated>${iso(events[0]?.created_at ?? now())}</updated>262+${entries.join("\n")}263+</feed>`;264+ return new Response(xml, { headers: { "content-type": "application/atom+xml; charset=utf-8", "cache-control": CACHE } });265+}@@ -26,6 +26,7 @@ import { MAX_JOBS, MAX_STANDING, checkJob, finishRun, newJobID, pruneFinished, p
2626 import { groupFacts, handleGroupEvent, isGroupManagement, isGroupState, isNIP43Request } from "./groups.ts";2727 import { KIND_GROUP_MEMBERS } from "./identity.ts";2828 import { SIGNER_JS } from "./gen/signer.ts";29+import { isPagePath, pages } from "./pages.ts";2930 import type { Blob } from "./blossom.ts";3031 3132 export interface Env {@@ -655,7 +656,8 @@ export class Relay extends DurableObject<Env> {
655656 if (req.method === "OPTIONS") {656657 return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, POST, OPTIONS" } });657658 }658- if (url.pathname === "/") return new Response(dashboard(), { headers: { "content-type": "text/html; charset=utf-8" } });659+ if (isPagePath(url.pathname) && req.method === "GET") return pages(this, req);660+ if (url.pathname === "/") return new Response(dashboard(),{ headers: { "content-type": "text/html; charset=utf-8" } });659661 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" } });660662 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" } });661663 return new Response("not found", { status: 404 });@@ -69,11 +69,12 @@ footer.pg p { margin: 0; } footer.pg a { font-family: var(--mono); font-size: 12
6969 `;7070 7171 // page wraps a body in the shared shell.72-export function page(title: string, body: string, extraCSS = ""): string {72+export function page(title: string, body: string, extraCSS = "", head = ""): string {7373 return `<!doctype html>7474 <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">7575 <title>${escapeHTML(title)}</title>7676 <link rel="icon" href="/favicon.svg" type="image/svg+xml">77+${head}7778 ${FONTS}7879 <style>${BASE_CSS}${extraCSS}</style>7980 </head><body>${body}<div id="toast"></div></body></html>`;