diff options
Diffstat (limited to 'feed')
| -rw-r--r-- | feed/note-page.js | 4 | ||||
| -rw-r--r-- | feed/profile-page.js | 214 | ||||
| -rw-r--r-- | feed/server.js | 19 |
3 files changed, 231 insertions, 6 deletions
diff --git a/feed/note-page.js b/feed/note-page.js index f4dc08e..3145f14 100644 --- a/feed/note-page.js +++ b/feed/note-page.js @@ -117,9 +117,9 @@ export function createThreadCache({ build, now = Date.now }) { return { get, refresh }; } -const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // JSON inside a script tag: nothing in it may close the tag or open a comment -const scriptJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); +export const scriptJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); function nameOf(data, pk) { const p = (data.profiles || []).find((e) => e.pubkey === pk); diff --git a/feed/profile-page.js b/feed/profile-page.js new file mode 100644 index 0000000..a30c2ab --- /dev/null +++ b/feed/profile-page.js @@ -0,0 +1,214 @@ +// /npub1…, /nprofile1… and /<coinos name> rendered with the profile already +// in the page — the same trick note-page.js plays for shared posts. +// +// A profile link opened in a fresh browser showed a punk, "–" for the follow +// counts, and no posts for nine seconds: the page looked up the author's +// relay list, waited on every relay, then held the first screen for its +// media. This server does that once, keeps the answer, and inlines it — +// kind 0, latest posts, follow counts, and the faces of anyone the posts +// mention — so the page paints whole on its first frame. Stale answers are +// served while a refresh runs; a cold one gets BUDGET_MS before the plain +// page goes out (and the build carries on into the cache). +import { decode } from 'nostr-tools/nip19'; +import { verifyEvent } from 'nostr-tools/pure'; +import { NOTE_RELAYS, esc, scriptJson } from './note-page.js'; + +export const PROFILE_PATH = /^\/((?:npub1|nprofile1)[a-z0-9]+|[A-Za-z0-9._-]{1,30})\/?$/; +// single-segment app routes and files that are never a person +const RESERVED = new Set(['chat', 'pos', 'feed', 'flash', 'index.html', 'sw.js', 'manifest.webmanifest', 'favicon.ico', 'robots.txt']); +const INDEX_RELAYS = ['wss://purplepag.es', 'wss://user.kindpag.es']; +// relays that keep history (the big ones prune within days) +const ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr21.com', 'wss://offchain.pub']; +const PROFILE_KINDS = [1, 1068, 30023]; +const MAX_NOTES = 30; +const MAX_FACES = 30; +const MAX_EVENT = 32_000; +export const PROFILE_BUDGET_MS = 2000; +const IDLE_MS = 400; + +const okEvent = (e) => e && typeof e.id === 'string' && JSON.stringify(e).length <= MAX_EVENT; +const newest = (evs, kind, pk) => (evs || []).filter((e) => e.kind === kind && e.pubkey === pk && okEvent(e)) + .sort((a, b) => b.created_at - a.created_at)[0] || null; + +// What the path names: a key outright, or a coinos name for the registrar. +export function parseProfileRef(path) { + const m = PROFILE_PATH.exec(path); + if (!m || RESERVED.has(m[1].toLowerCase()) || /^(note|nevent|naddr)1/i.test(m[1])) return null; + const seg = m[1]; + if (/^(npub1|nprofile1)/i.test(seg)) { + try { + const d = decode(seg.toLowerCase()); + if (d.type === 'npub') return { pk: d.data, relays: [], path: seg }; + if (d.type === 'nprofile') { + const relays = (d.data.relays || []).filter((u) => /^wss:\/\/[^\s]{1,200}$/.test(u)).slice(0, 3); + return { pk: d.data.pubkey, relays, path: seg }; + } + } catch {} + return null; + } + // a dotted segment is a file the server didn't have, not a name + if (seg.includes('.')) return null; + return { name: seg.toLowerCase(), relays: [], path: seg }; +} + +// Everything the profile page shows first. +export async function buildProfile(ref, { query: ask, primal = async () => null }) { + const pk = ref.pk; + // answers settle once they stop coming (see server.js's query) + const query = (relays, filter, wait) => ask(relays, filter, wait, { idle: IDLE_MS }); + const home = [...new Set([...ref.relays, ...NOTE_RELAYS])]; + const notesFrom = (relays, wait) => query(relays, { kinds: PROFILE_KINDS, authors: [pk], limit: MAX_NOTES }, wait).catch(() => []); + // their own write relays join in as soon as their list turns up + const theirs = query([...home, ...INDEX_RELAYS], { kinds: [10002], authors: [pk] }, 1500).catch(() => []).then((evs) => { + const list = newest(evs, 10002, pk); + const own = list ? [...new Set(list.tags.filter((t) => t[0] === 'r' && /^wss:\/\//.test(t[1] || '') && t[2] !== 'read') + .map((t) => t[1].replace(/\/$/, '')))].filter((u) => !home.includes(u)).slice(0, 4) : []; + return own.length ? notesFrom(own, 2500) : []; + }); + // faces the posts mention, so names read right on the first paint — asked + // as soon as the first batch of posts lands, beside the author's own relays + const mentionsOf = (evs) => { + const out = new Set(); + for (const e of evs) { + if (e.pubkey !== pk) continue; + for (const t of e.tags || []) if (t[0] === 'p' && /^[0-9a-f]{64}$/.test(t[1] || '')) out.add(t[1]); + for (const m of String(e.content || '').matchAll(/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g)) { + try { const d = decode(m[1]); out.add(d.type === 'npub' ? d.data : d.data.pubkey); } catch {} + } + } + out.delete(pk); + return [...out].slice(0, MAX_FACES); + }; + const first = notesFrom([...home, ...ARCHIVE_RELAYS], 2500); + const facesP = first.then((evs) => { + const want = mentionsOf(evs); + return want.length + ? query([...NOTE_RELAYS, ...INDEX_RELAYS], { kinds: [0], authors: want }, 1500).catch(() => []) + .then((faces) => want.map((x) => newest(faces, 0, x)).filter(Boolean)) + : []; + }); + const [kind0s, stats, a, b, profiles] = await Promise.all([ + query([...home, ...INDEX_RELAYS], { kinds: [0], authors: [pk] }, 2000).catch(() => []), + primal(pk).catch(() => null), + first, + theirs, + facesP, + ]); + const profile = newest([...kind0s, ...(stats?.profile ? [stats.profile] : [])], 0, pk); + // an edited article is a new event at the same address: keep the newest + const byId = new Map(); + const articles = new Map(); + for (const e of [...a, ...b]) { + if (e.pubkey !== pk || !PROFILE_KINDS.includes(e.kind) || !okEvent(e)) continue; + if (e.kind === 30023) { + const d = (e.tags.find((t) => t[0] === 'd') || [])[1] || ''; + const cur = articles.get(d); + if (cur && cur.created_at >= e.created_at) continue; + if (cur) byId.delete(cur.id); + articles.set(d, e); + } + byId.set(e.id, e); + } + const notes = [...byId.values()].sort((x, y) => y.created_at - x.created_at).slice(0, MAX_NOTES); + if (!profile && !notes.length) return null; // nobody there: the client looks for itself + const counts = stats && (stats.follows != null || stats.followers != null) + ? { follows: stats.follows, followers: stats.followers } : null; + return { v: 1, pk, profile, notes, counts, profiles }; +} + +// Primal's cache answers a profile and its follow counts in one request +// (what the app's own social line asks). Its events are signature-checked. +export function createPrimal({ url = 'wss://cache2.primal.net/v1', timeoutMs = 2000, WS = globalThis.WebSocket } = {}) { + let n = 0; + return (pk) => new Promise((resolve) => { + const sid = 'pp' + ++n; + const evs = []; + let ws; + const done = () => { + clearTimeout(timer); + try { ws.close(); } catch {} + const stats = (() => { try { return JSON.parse(evs.find((e) => e.kind === 10000105)?.content || 'null'); } catch { return null; } })(); + const num = (v) => (Number.isFinite(v) && v >= 0 ? v : null); + const profile = evs.find((e) => e.kind === 0 && e.pubkey === pk && verifyEvent(e)) || null; + resolve(stats || profile ? { profile, follows: num(stats?.follows_count), followers: num(stats?.followers_count) } : null); + }; + const timer = setTimeout(done, timeoutMs); + try { + ws = new WS(url); + ws.onopen = () => ws.send(JSON.stringify(['REQ', sid, { cache: ['user_profile', { pubkey: pk }] }])); + ws.onmessage = (m) => { + try { + const d = JSON.parse(m.data); + if (d[1] !== sid) return; + if (d[0] === 'EVENT' && d[2]) evs.push(d[2]); + if (d[0] === 'EOSE' || d[0] === 'CLOSED') done(); + } catch {} + }; + ws.onerror = done; + } catch { done(); } + }); +} + +// coinos names resolve through the registrar, remembered for a while +export function createNameResolver({ url = 'https://names.coinos.io', domain = 'coinos.io', ttl = 600_000, now = Date.now } = {}) { + const known = new Map(); // name -> { pk, at } + return async (name) => { + const k = known.get(name); + if (k && now() - k.at < ttl) return k.pk; + let pk = null; + try { + const j = await fetch(`${url}/.well-known/nostr.json?name=${encodeURIComponent(name)}&domain=${domain}`, + { signal: AbortSignal.timeout(1000) }).then((r) => r.json()); + const v = (j.names || {})[name]; + pk = /^[0-9a-f]{64}$/.test(v || '') ? v : null; + } catch { return k ? k.pk : null; } + known.set(name, { pk, at: now() }); + if (known.size > 2000) known.delete(known.keys().next().value); + return pk; + }; +} + +const fields = (ev) => { try { const m = JSON.parse(ev.content); return m && typeof m === 'object' ? m : {}; } catch { return {}; } }; + +export function renderProfilePage(html, data, ref, origin) { + const m = data.profile ? fields(data.profile) : {}; + const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null); + const name = str(m.display_name, 80) || str(m.name, 80) || ref.name || null; + const picture = /^https:\/\//.test(m.picture || '') ? m.picture : null; + const banner = /^https:\/\//.test(m.banner || '') ? m.banner : null; + const about = (str(m.about, 2000) || '').replace(/\s+/g, ' '); + const desc = about.length > 200 ? about.slice(0, 199) + '…' : about; + const title = (name || 'A profile') + ' on Coinos'; + const head = [ + `<meta property="og:type" content="profile">`, + `<meta property="og:site_name" content="Coinos">`, + `<meta property="og:title" content="${esc(title)}">`, + desc ? `<meta property="og:description" content="${esc(desc)}">` : '', + desc ? `<meta name="description" content="${esc(desc)}">` : '', + origin ? `<meta property="og:url" content="${esc(origin + '/' + ref.path)}">` : '', + picture ? `<meta property="og:image" content="${esc(picture)}">` : '', + `<meta name="twitter:card" content="summary">`, + // the face and the cover start downloading with the page + picture ? `<link rel="preload" as="image" href="${esc(picture)}">` : '', + banner ? `<link rel="preload" as="image" href="${esc(banner)}">` : '', + `<script type="application/json" id="boot-profile">${scriptJson({ ...data, path: ref.path })}</script>`, + ].filter(Boolean).join('\n'); + let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`); + out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out; + return out; +} + +export async function profilePageResponse(req, { index, profiles, resolveName }) { + const u = new URL(req.url); + const ref = parseProfileRef(u.pathname); + const html = await index(); + const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, must-revalidate' }; + if (!ref) return new Response(html, { headers }); + if (!ref.pk) ref.pk = await resolveName(ref.name); + if (!ref.pk) return new Response(html, { headers }); + ref.id = ref.pk; // the cache's key + const data = await profiles.get(ref, PROFILE_BUDGET_MS).catch(() => null); + const proto = req.headers.get('x-forwarded-proto') || 'https'; + const host = req.headers.get('x-forwarded-host') || req.headers.get('host'); + return new Response(data ? renderProfilePage(html, data, ref, host ? proto + '://' + host : '') : html, { headers }); +} diff --git a/feed/server.js b/feed/server.js index 12ff850..9566447 100644 --- a/feed/server.js +++ b/feed/server.js @@ -2,26 +2,34 @@ import { SimplePool } from 'nostr-tools/pool'; import { startFeedCache } from './service.js'; import { snapshotResponse } from './cache.js'; import { NOTE_PATH, buildThread, createThreadCache, createIndexSource, notePageResponse } from './note-page.js'; +import { PROFILE_PATH, buildProfile, createPrimal, createNameResolver, profilePageResponse } from './profile-page.js'; const cache = await startFeedCache(); // SimplePool verifies signatures and matches filters before accepting events. const pool = new SimplePool(); // Every relay's answer, or maxWait — but an ask by id is done the moment -// every id has turned up, rather than waiting out the slowest relay. -const query = (relays, filter, maxWait) => new Promise((resolve) => { +// every id has turned up, rather than waiting out the slowest relay. With +// `idle`, the answer is also done once events have been arriving and then +// stop for that long: one relay that never says EOSE no longer holds a +// profile page for the whole maxWait. +const query = (relays, filter, maxWait, { idle = 0 } = {}) => new Promise((resolve) => { const got = new Map(); - let done = false, sub = null; - const finish = () => { if (done) return; done = true; try { sub && sub.close(); } catch {} resolve([...got.values()]); }; + let done = false, sub = null, quiet = null; + const finish = () => { if (done) return; done = true; clearTimeout(quiet); try { sub && sub.close(); } catch {} resolve([...got.values()]); }; sub = pool.subscribeManyEose(relays, filter, { maxWait, onevent(e) { got.set(e.id, e); if (filter.ids && filter.ids.every((id) => got.has(id))) finish(); + if (idle) { clearTimeout(quiet); quiet = setTimeout(finish, idle); } }, onclose: finish, }); if (done) try { sub.close(); } catch {} }); const threads = createThreadCache({ build: (ref) => buildThread(ref, query) }); +const primal = createPrimal(); +const profiles = createThreadCache({ build: (ref) => buildProfile(ref, { query, primal }) }); +const resolveName = createNameResolver({ url: process.env.NAMES_URL || 'https://names.coinos.io' }); const index = createIndexSource(process.env.INDEX_URL || 'http://bitcoin-wallet/index.html'); const server = Bun.serve({ hostname: process.env.HOST || '127.0.0.1', @@ -33,6 +41,9 @@ const server = Bun.serve({ if (NOTE_PATH.test(path) && ['GET', 'HEAD'].includes(req.method)) { try { return await notePageResponse(req, { index, threads }); } catch { return new Response('Unavailable', { status: 502 }); } } + if (PROFILE_PATH.test(path) && ['GET', 'HEAD'].includes(req.method)) { + try { return await profilePageResponse(req, { index, profiles, resolveName }); } catch { return new Response('Unavailable', { status: 502 }); } + } return new Response('Not found', { status: 404 }); }, }); |
