aboutsummaryrefslogtreecommitdiffstats
path: root/feed/profile-page.js
blob: a30c2ababf9134b3a5a00279cc0394650ab4988e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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 });
}