Skip to content

Commit 25230df

Browse files
beacon: search + graph metrics + network stats (UI items 1,3,4)
- Search: /api/search by name/nip05 (content text index), npub (bech32 decode) or hex pubkey; results ranked by followers so prominent accounts surface. - Graph metrics: follower (in-degree, via follows_1 index) + following counts on /api/did, /api/profiles and /api/search (batched enrichCounts). - Network stats: /api/stats (profiles/follows/relays, 60s cache). - UI: search box + live results, network stats in hero, follower/following counts on cards and the profile header.
1 parent 5b12a13 commit 25230df

3 files changed

Lines changed: 182 additions & 22 deletions

File tree

public/index.html

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@
7373
footer{text-align:center;color:var(--faint);font-size:.8rem;margin-top:3rem}
7474
footer a{color:var(--accent)}
7575
.loading{text-align:center;color:var(--faint);padding:3rem;font-size:.9rem}
76+
77+
/* search */
78+
.searchwrap{margin:1.9rem auto .2rem;max-width:31rem}
79+
.searchwrap input{width:100%;font-family:var(--sans);font-size:1rem;color:var(--ink);background:#fff;
80+
border:1px solid var(--line);border-radius:999px;padding:.82rem 1.25rem;outline:none;
81+
box-shadow:0 1px 2px rgba(20,20,30,.04);transition:border-color .15s,box-shadow .15s}
82+
.searchwrap input:focus{border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)}
83+
.searchwrap input::placeholder{color:var(--faint)}
84+
.hero .stat strong{color:var(--muted);font-weight:700}
85+
/* counts + section label */
86+
.card .counts{font-size:.8rem;color:var(--muted);margin-top:.5rem}
87+
.card .counts strong{color:var(--ink);font-weight:600}
88+
.seclabel{font-size:.74rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin:.4rem 0 1rem}
89+
.chip strong{font-weight:700}
7690
</style>
7791
</head>
7892
<body>
@@ -81,6 +95,7 @@
8195
<span class="mark"><span class="dot"></span> Beacon</span>
8296
<h1>A directory of <em style="font-style:italic">did:nostr</em> identities</h1>
8397
<p>Profiles, the follow graph, and a verifiable DID document for every key &mdash; indexed live from Nostr.</p>
98+
<div class="searchwrap"><input id="q" type="search" placeholder="Search by name, nip05, npub, or hex pubkey…" autocomplete="off" spellcheck="false"></div>
8499
<div class="stat" id="stat"></div>
85100
</div>
86101
<div id="view"></div>
@@ -108,32 +123,61 @@ <h1>A directory of <em style="font-style:italic">did:nostr</em> identities</h1>
108123
.replace(/: (&quot;[^&]*?&quot;)/g, ': <span class="s">$1</span>');
109124
}
110125

126+
const fmt = (n) => {
127+
n = Number(n) || 0;
128+
if (n >= 1e6) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0) + 'M';
129+
if (n >= 1e3) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0) + 'k';
130+
return String(n);
131+
};
132+
133+
function cardHtml(p) {
134+
const c = content(p);
135+
const ct = p._counts || {};
136+
return `<div class="card" data-pk="${p.pubkey}">
137+
${avatar(c, p.pubkey)}
138+
<div class="name">${esc(c.name || c.display_name || short(p.pubkey))}</div>
139+
${c.nip05 ? `<div class="nip05">${esc(c.nip05)}</div>` : ''}
140+
<div class="counts"><strong>${fmt(ct.followers)}</strong> followers · <strong>${fmt(ct.following)}</strong> following</div>
141+
${c.about ? `<div class="about">${esc(c.about)}</div>` : ''}
142+
<div class="did">did:nostr:${short(p.pubkey)}</div>
143+
</div>`;
144+
}
145+
146+
function renderGrid(profiles, label) {
147+
view.innerHTML = `${label ? `<div class="seclabel">${label}</div>` : ''}<div class="grid">${profiles.map(cardHtml).join('')}</div>`;
148+
view.querySelectorAll('.card').forEach((el) => { el.onclick = () => { location.hash = el.dataset.pk; }; });
149+
}
150+
151+
async function loadStats() {
152+
const s = await fetch('/api/stats').then((r) => r.json()).catch(() => null);
153+
if (s) $('#stat').innerHTML = `<strong>${fmt(s.profiles)}</strong> identities · <strong>${fmt(s.follows)}</strong> follow lists · <strong>${fmt(s.relays)}</strong> relays`;
154+
}
155+
111156
async function home() {
112-
location.hash = '';
113-
view.innerHTML = '<div class="loading">loading recent identities…</div>';
157+
if ($('#q')) $('#q').value = '';
158+
view.innerHTML = '<div class="loading">loading identities…</div>';
114159
const profiles = await fetch('/api/profiles?limit=36').then((r) => r.json()).catch(() => []);
115-
$('#stat').textContent = profiles.length ? `${profiles.length} recently active identities` : '';
116160
if (!profiles.length) { view.innerHTML = '<div class="loading">No profiles indexed yet. Start the indexer and refresh.</div>'; return; }
117-
view.innerHTML = `<div class="grid">${profiles.map((p) => {
118-
const c = content(p);
119-
return `<div class="card" data-pk="${p.pubkey}">
120-
${avatar(c, p.pubkey)}
121-
<div class="name">${esc(c.name || c.display_name || short(p.pubkey))}</div>
122-
${c.nip05 ? `<div class="nip05">${esc(c.nip05)}</div>` : ''}
123-
${c.about ? `<div class="about">${esc(c.about)}</div>` : ''}
124-
<div class="did">did:nostr:${short(p.pubkey)}</div>
125-
</div>`;
126-
}).join('')}</div>`;
127-
view.querySelectorAll('.card').forEach((el) => el.onclick = () => { location.hash = el.dataset.pk; });
161+
renderGrid(profiles, 'Recently active');
128162
}
129163

164+
async function searchView(q) {
165+
view.innerHTML = '<div class="loading">searching…</div>';
166+
const results = await fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json()).catch(() => []);
167+
if (!Array.isArray(results) || !results.length) { view.innerHTML = `<div class="loading">No matches for &ldquo;${esc(q)}&rdquo;.</div>`; return; }
168+
renderGrid(results, `${results.length} result${results.length > 1 ? 's' : ''} for &ldquo;${esc(q)}&rdquo;`);
169+
}
170+
171+
// strip our UI-only count fields so the displayed JSON is the pure DID document
172+
const didOnly = ({ followersCount, followingCount, ...d }) => d;
173+
130174
async function profile(pk) {
131175
view.innerHTML = '<div class="loading">resolving DID document…</div>';
132-
$('#stat').textContent = '';
133176
const doc = await fetch(`/api/did/${pk}`).then((r) => r.json()).catch(() => null);
134177
if (!doc || doc.error) { view.innerHTML = '<div class="loading">Could not resolve this identity.</div>'; return; }
135178
const c = doc.profile || {};
136179
const follows = doc.follows || [];
180+
const followers = doc.followersCount, following = doc.followingCount ?? follows.length;
137181
view.innerHTML = `<div class="detail">
138182
<div class="back" id="back">← all identities</div>
139183
<div class="phead">
@@ -143,30 +187,41 @@ <h2>${esc(c.name || short(pk))}</h2>
143187
${c.nip05 ? `<div class="nip05">${esc(c.nip05)}</div>` : ''}
144188
${c.about ? `<div class="about">${esc(c.about)}</div>` : ''}
145189
<div class="chips">
190+
<span class="chip"><strong>${fmt(followers)}</strong> followers</span>
191+
<span class="chip"><strong>${fmt(following)}</strong> following</span>
192+
${doc.service?.length ? `<span class="chip">${doc.service.length} relay${doc.service.length > 1 ? 's' : ''}</span>` : ''}
146193
${c.website ? `<a class="chip" href="${esc(c.website)}" target="_blank" rel="noopener">${esc(c.website.replace(/^https?:\/\//, ''))}</a>` : ''}
147194
${c.lud16 ? `<span class="chip">⚡ ${esc(c.lud16)}</span>` : ''}
148-
${doc.service?.length ? `<span class="chip">${doc.service.length} relay${doc.service.length > 1 ? 's' : ''}</span>` : ''}
149-
<span class="chip">${follows.length} following</span>
150195
</div>
151196
</div>
152197
</div>
153198
<a class="didline" href="/.well-known/did/nostr/${pk}.json" target="_blank" rel="noopener" title="Resolve the DID document (application/did+json)"><span>${esc(doc.id)}</span><span class="resolve">resolve ↗</span></a>
154199
<div class="sections">
155-
<div class="panel"><h3>DID document</h3><pre>${highlight(doc)}</pre></div>
200+
<div class="panel"><h3>DID document</h3><pre>${highlight(didOnly(doc))}</pre></div>
156201
<div class="panel"><h3>Following · ${follows.length}</h3>
157202
${follows.length ? `<div class="follows">${follows.slice(0, 96).map((d) => { const fk = d.replace('did:nostr:', ''); return `<a class="fav" style="${gradStyle(fk)}" title="${esc(fk)}" href="#${fk}"></a>`; }).join('')}</div>`
158203
: '<div style="color:var(--faint);font-size:.9rem">No follow list indexed.</div>'}
159204
</div>
160205
</div>
161206
</div>`;
162-
$('#back').onclick = home;
207+
$('#back').onclick = () => { location.hash = ''; };
208+
}
209+
210+
let searchTimer;
211+
function onSearch() {
212+
const q = $('#q').value.trim();
213+
clearTimeout(searchTimer);
214+
searchTimer = setTimeout(() => { if (!q) route(); else searchView(q); }, 220);
163215
}
164216

165217
function route() {
166218
const pk = location.hash.slice(1);
167219
if (/^[0-9a-f]{64}$/.test(pk)) profile(pk); else home();
168220
}
221+
222+
$('#q').addEventListener('input', onSearch);
169223
window.addEventListener('hashchange', route);
224+
loadStats();
170225
route();
171226
</script>
172227
</body>

src/db.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,70 @@ export const getRelays = (pubkey) => one(10002, pubkey);
6464
export async function recentProfiles(limit = 10) {
6565
return (await connect()).collection(COLLECTIONS[0]).find().sort({ created_at: -1 }).limit(limit).toArray();
6666
}
67+
68+
// ---- search ----------------------------------------------------------------
69+
70+
const HEX64 = /^[0-9a-f]{64}$/;
71+
const BECH32 = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
72+
73+
// Minimal bech32 decode (npub -> 32-byte hex). No checksum check: a bad key
74+
// simply finds nothing on lookup.
75+
function npubToHex(str) {
76+
const s = String(str).toLowerCase();
77+
const pos = s.lastIndexOf('1');
78+
if (pos < 1) return null;
79+
const words = [];
80+
for (const ch of s.slice(pos + 1)) { const v = BECH32.indexOf(ch); if (v === -1) return null; words.push(v); }
81+
let acc = 0, bits = 0; const bytes = [];
82+
for (const w of words.slice(0, -6)) { acc = (acc << 5) | w; bits += 5; while (bits >= 8) { bits -= 8; bytes.push((acc >> bits) & 0xff); } }
83+
return bytes.length === 32 ? bytes.map((b) => b.toString(16).padStart(2, '0')).join('') : null;
84+
}
85+
86+
/** Resolve a search term to a 64-hex pubkey if it is one (hex or npub), else null. */
87+
export function toPubkey(q) {
88+
const s = String(q || '').trim().toLowerCase();
89+
if (HEX64.test(s)) return s;
90+
if (s.startsWith('npub1')) return npubToHex(s);
91+
return null;
92+
}
93+
94+
/** Search profiles: exact key (hex/npub) or full-text over profile content. */
95+
export async function searchProfiles(q, limit = 24) {
96+
const query = String(q || '').trim();
97+
if (!query) return [];
98+
const col = (await connect()).collection(COLLECTIONS[0]);
99+
const hex = toPubkey(query);
100+
if (hex) { const p = await col.findOne({ pubkey: hex }); return p ? [p] : []; }
101+
return col.find({ $text: { $search: query } }, { projection: { score: { $meta: 'textScore' } } })
102+
.sort({ score: { $meta: 'textScore' } })
103+
.limit(Math.min(limit, 50)).toArray();
104+
}
105+
106+
// ---- graph metrics ---------------------------------------------------------
107+
108+
export async function stats() {
109+
const db = await connect();
110+
const [profiles, follows, relays] = await Promise.all([
111+
db.collection(COLLECTIONS[0]).estimatedDocumentCount(),
112+
db.collection(COLLECTIONS[3]).estimatedDocumentCount(),
113+
db.collection(COLLECTIONS[10002]).estimatedDocumentCount(),
114+
]);
115+
return { profiles, follows, relays };
116+
}
117+
118+
/** In-degree: how many follow-lists include this pubkey (uses the follows_1 index). */
119+
export async function followerCount(pubkey) {
120+
return (await connect()).collection(COLLECTIONS[3]).countDocuments({ follows: pubkey });
121+
}
122+
123+
/** Batched { pk: { followers, following } } for a set of pubkeys (for grids). */
124+
export async function enrichCounts(pubkeys) {
125+
const fc = (await connect()).collection(COLLECTIONS[3]);
126+
const ids = [...new Set(pubkeys)].filter(Boolean);
127+
const out = Object.fromEntries(ids.map((p) => [p, { followers: 0, following: 0 }]));
128+
if (!ids.length) return out;
129+
const lists = await fc.find({ pubkey: { $in: ids } }, { projection: { pubkey: 1, count: 1, follows: 1 } }).toArray();
130+
for (const l of lists) if (out[l.pubkey]) out[l.pubkey].following = l.count ?? (l.follows?.length || 0);
131+
await Promise.all(ids.map(async (pk) => { out[pk].followers = await fc.countDocuments({ follows: pk }); }));
132+
return out;
133+
}

src/server.js

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import express from 'express';
33
import path from 'node:path';
44
import { fileURLToPath } from 'node:url';
5-
import { getProfile, getFollows, getRelays, recentProfiles, connect } from './db.js';
5+
import { getProfile, getFollows, getRelays, recentProfiles, connect, stats, searchProfiles, enrichCounts, followerCount } from './db.js';
66
import { buildDidDocument } from './diddoc.js';
77
import 'dotenv/config';
88

@@ -11,10 +11,45 @@ const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'pu
1111
export async function startServer(port = process.env.PORT || 3000) {
1212
await connect();
1313
const app = express();
14+
// Public read-only resolver: allow cross-origin fetches so browser-based
15+
// did:nostr resolvers can read the documents.
16+
app.use((_req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); });
1417
app.use(express.static(PUBLIC));
1518

19+
// Liveness/readiness: confirms Mongo is reachable (for haproxy httpchk + monitoring).
20+
app.get('/healthz', async (_req, res) => {
21+
try { await (await connect()).command({ ping: 1 }); res.json({ ok: true }); }
22+
catch (e) { res.status(503).json({ ok: false, error: e.message }); }
23+
});
24+
25+
// attach { followers, following } to each profile under `_counts`
26+
const withCounts = async (profiles) => {
27+
const counts = await enrichCounts(profiles.map((p) => p.pubkey));
28+
return profiles.map((p) => ({ ...p, _counts: counts[p.pubkey] }));
29+
};
30+
31+
// network stats, cached 60s (estimatedDocumentCount is cheap but stable)
32+
let statsCache = { at: 0, val: null };
33+
app.get('/api/stats', async (_req, res, next) => {
34+
try {
35+
if (!statsCache.val || Date.now() - statsCache.at > 60_000) statsCache = { at: Date.now(), val: await stats() };
36+
res.json(statsCache.val);
37+
} catch (e) { next(e); }
38+
});
39+
40+
// search by name / nip05 / npub / hex pubkey; rank matches by followers so
41+
// prominent accounts surface above same-named namesakes.
42+
app.get('/api/search', async (req, res, next) => {
43+
try {
44+
const limit = Math.min(Number(req.query.limit) || 24, 50);
45+
const pool = await withCounts(await searchProfiles(req.query.q, Math.max(limit, 40)));
46+
pool.sort((a, b) => (b._counts?.followers || 0) - (a._counts?.followers || 0));
47+
res.json(pool.slice(0, limit));
48+
} catch (e) { next(e); }
49+
});
50+
1651
app.get('/api/profiles', async (req, res, next) => {
17-
try { res.json(await recentProfiles(Math.min(Number(req.query.limit) || 30, 100))); } catch (e) { next(e); }
52+
try { res.json(await withCounts(await recentProfiles(Math.min(Number(req.query.limit) || 30, 100)))); } catch (e) { next(e); }
1853
});
1954
app.get('/api/profile/:pubkey', async (req, res, next) => {
2055
try { res.json(await getProfile(req.params.pubkey)); } catch (e) { next(e); }
@@ -41,11 +76,14 @@ export async function startServer(port = process.env.PORT || 3000) {
4176
res.json(doc);
4277
} catch (e) { next(e); }
4378
});
79+
// UI-facing resolution: the conformant doc + graph counts (followers = in-degree).
80+
// The .well-known endpoint above stays the pure conformant document.
4481
app.get('/api/did/:pubkey', async (req, res, next) => {
4582
try {
4683
const doc = await resolve(req.params.pubkey);
4784
if (!doc) return res.status(400).json({ error: 'invalid did:nostr pubkey (expect 64-hex)' });
48-
res.json(doc);
85+
const followers = await followerCount(req.params.pubkey.toLowerCase());
86+
res.json({ ...doc, followersCount: followers, followingCount: doc.follows?.length || 0 });
4987
} catch (e) { next(e); }
5088
});
5189

0 commit comments

Comments
 (0)