aboutsummaryrefslogtreecommitdiffstats
path: root/feed
diff options
context:
space:
mode:
Diffstat (limited to 'feed')
-rw-r--r--feed/note-page.js4
-rw-r--r--feed/profile-page.js214
-rw-r--r--feed/server.js19
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 });
},
});