// /npub1…, /nprofile1… and / 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 = [ ``, ``, ``, desc ? `` : '', desc ? `` : '', origin ? `` : '', picture ? `` : '', ``, // the face and the cover start downloading with the page picture ? `` : '', banner ? `` : '', ``, ].filter(Boolean).join('\n'); let out = html.replace(/[^<]*<\/title>/i, `<title>${esc(title)}`); out = out.includes('') ? out.replace('', head + '\n') : 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 }); }