clone

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

relay: pages for notes and articles, and an atom feed

Dami committed 4838498
/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>
4 files changed, +271 -2
Msrc/landing.ts1+
Asrc/pages.ts265++++++++++++++++++++++++++++++
Msrc/relay.ts4+-
Msrc/ui.ts3+-
M src/landing.ts +1 -0
A src/pages.ts +265 -0
@@ -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+}
M src/relay.ts +3 -1
@@ -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 });
M src/ui.ts +2 -1