Skip to content

Commit 1d94dbd

Browse files
Merge pull request #30 from JavaScriptSolidServer/issue-29-relay-sweep
scripts: relay-directory data sweep (bad/dup/host-spam/renorm/stale)
2 parents 4435820 + 382e3c5 commit 1d94dbd

4 files changed

Lines changed: 164 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"index": "node -e \"import('./src/indexer.js').then(m => m.runIndexer())\"",
1313
"serve": "node -e \"import('./src/server.js').then(m => m.startServer())\"",
1414
"probe": "node probe.js",
15-
"clean:relays": "node scripts/clean-relays.js"
15+
"clean:relays": "node scripts/clean-relays.js",
16+
"sweep:relays": "node scripts/sweep-relays.js"
1617
},
1718
"dependencies": {
1819
"@noble/curves": "^2.2.0",

scripts/sweep-relays.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Relay-directory data sweep: drops bad URLs, de-dups, trims per-host
2+
// path-variant flooding, normalizes survivors, and (optionally) prunes stale.
3+
// Pure logic lives in planRelaySweep (src/hoses/relays.js); this is just I/O.
4+
//
5+
// node scripts/sweep-relays.js # dry run (report only)
6+
// node scripts/sweep-relays.js --apply # execute
7+
// node scripts/sweep-relays.js --max-per-host=3 --stale=7 --apply
8+
import { connect, close } from '../src/db.js';
9+
import { planRelaySweep } from '../src/hoses/relays.js';
10+
import 'dotenv/config';
11+
12+
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
13+
const APPLY = process.argv.includes('--apply');
14+
const numArg = (name, dflt) => { const a = process.argv.find((x) => x.startsWith(`--${name}=`)); return a ? Number(a.split('=')[1]) : dflt; };
15+
const maxPerHost = numArg('max-per-host', 3);
16+
const staleDays = numArg('stale', 0);
17+
18+
const db = await connect();
19+
const col = db.collection(RELAY_DIRECTORY);
20+
const docs = await col.find({}, { projection: { relay: 1, checksTotal: 1, lastChecked: 1 } }).toArray();
21+
22+
const plan = planRelaySweep(docs, { maxPerHost, staleDays, now: Date.now() });
23+
const delIds = [...new Set([...plan.bad, ...plan.dups, ...plan.hostSpam, ...(staleDays ? plan.stale : [])].map((d) => d._id))];
24+
25+
console.log(`[sweep] ${docs.length} relays | bad ${plan.bad.length} | dup ${plan.dups.length} | host-spam ${plan.hostSpam.length} | renorm ${plan.renames.length}`
26+
+ (staleDays ? ` | stale>${staleDays}d ${plan.stale.length}` : '') + ` | keep ${plan.kept}`);
27+
console.log(`[sweep] maxPerHost=${maxPerHost} -> would delete ${delIds.length}, normalize ${plan.renames.length} (${APPLY ? 'APPLYING' : 'dry run — pass --apply'})`);
28+
29+
if (APPLY) {
30+
for (let i = 0; i < delIds.length; i += 1000) await col.deleteMany({ _id: { $in: delIds.slice(i, i + 1000) } });
31+
for (const r of plan.renames) await col.updateOne({ _id: r.doc._id }, { $set: { relay: r.canonical } }).catch(() => {});
32+
console.log(`[sweep] deleted ${delIds.length}, normalized ${plan.renames.length}; ${await col.estimatedDocumentCount()} remain`);
33+
}
34+
await close();

src/hoses/relays.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,65 @@ export function safeRelayUrl(url) {
5454
// Back-compat alias (older callers/tests): same screening as safeRelayUrl.
5555
export const canonicalizeRelayUrl = safeRelayUrl;
5656

57+
const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };
58+
const isOrigin = (u) => { try { const x = new URL(u); return x.pathname === '/' ? 1 : 0; } catch { return 0; } };
59+
const tms = (x) => (x ? new Date(x).getTime() || 0 : 0);
60+
61+
/**
62+
* Plan a relay-directory cleanup (pure — pass docs + options, no I/O). Returns
63+
* docs to delete by category plus URL normalizations. Re-runnable / idempotent.
64+
*
65+
* - bad: fails safeRelayUrl (malformed / SSRF / non-ws)
66+
* - dups: multiple docs share a canonical URL — keep the richest
67+
* - hostSpam: a host has > maxPerHost distinct URLs (path-variant flooding,
68+
* e.g. a bomb) — keep the origin + richest few, drop the rest
69+
* - renames: a surviving doc's stored `relay` ≠ its canonical form
70+
* - stale: (if staleDays) a survivor not checked within staleDays
71+
*/
72+
export function planRelaySweep(docs, { maxPerHost = 3, staleDays = 0, now = 0 } = {}) {
73+
const rich = (a, b) => (b.checksTotal || 0) - (a.checksTotal || 0) || tms(b.lastChecked) - tms(a.lastChecked);
74+
// 1. screen + canonicalize, group by canonical URL
75+
const byCanon = new Map();
76+
const bad = [];
77+
for (const d of docs) {
78+
const c = d.relay && safeRelayUrl(d.relay);
79+
if (!c) { bad.push(d); continue; }
80+
(byCanon.get(c) || byCanon.set(c, []).get(c)).push(d);
81+
}
82+
// 2. dedup: one survivor per canonical (richest history wins)
83+
const dups = [];
84+
let survivors = [];
85+
for (const [canon, ds] of byCanon) {
86+
ds.sort(rich);
87+
ds[0]._canon = canon;
88+
survivors.push(ds[0]);
89+
dups.push(...ds.slice(1));
90+
}
91+
// 3. host cap: trim path-variant flooding per host
92+
const hostSpam = [];
93+
if (maxPerHost > 0) {
94+
const byHost = new Map();
95+
for (const s of survivors) { const h = hostOf(s._canon); (byHost.get(h) || byHost.set(h, []).get(h)).push(s); }
96+
const kept = [];
97+
for (const [, ss] of byHost) {
98+
if (ss.length <= maxPerHost) { kept.push(...ss); continue; }
99+
ss.sort((a, b) => isOrigin(b._canon) - isOrigin(a._canon) || rich(a, b)); // origin first, then richest
100+
kept.push(...ss.slice(0, maxPerHost));
101+
hostSpam.push(...ss.slice(maxPerHost));
102+
}
103+
survivors = kept;
104+
}
105+
// 4. normalizations + stale among survivors
106+
const renames = [];
107+
const stale = [];
108+
const cutoff = staleDays && now ? now - staleDays * 864e5 : null;
109+
for (const s of survivors) {
110+
if (s.relay !== s._canon) renames.push({ doc: s, canonical: s._canon });
111+
if (cutoff && tms(s.lastChecked) < cutoff) stale.push(s);
112+
}
113+
return { bad, dups, hostSpam, renames, stale, kept: survivors.length };
114+
}
115+
57116
export async function ensureRelayDirectoryIndex(db) {
58117
await db.collection(RELAY_DIRECTORY).createIndex({ relay: 1 })
59118
.catch((e) => { if (e?.code !== 85 && e?.code !== 86) throw e; });

test/relay-sweep.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// planRelaySweep: categorization for the relay-directory data sweep.
2+
import test from 'node:test';
3+
import assert from 'node:assert/strict';
4+
import { planRelaySweep } from '../src/hoses/relays.js';
5+
6+
const ids = (arr) => arr.map((d) => d._id).sort();
7+
8+
test('bad URLs are dropped', () => {
9+
const docs = [
10+
{ _id: 1, relay: 'wss://relay.damus.io/' },
11+
{ _id: 2, relay: 'ws://127.0.0.1/' }, // SSRF
12+
{ _id: 3, relay: 'garbage' }, // malformed
13+
{ _id: 4, relay: 'wss://nostr.wine/inbox' },
14+
];
15+
const p = planRelaySweep(docs, {});
16+
assert.deepEqual(ids(p.bad), [2, 3]);
17+
});
18+
19+
test('dedup keeps the richest of a canonical group', () => {
20+
const docs = [
21+
{ _id: 1, relay: 'wss://nos.lol', checksTotal: 2 }, // canonicalizes to wss://nos.lol/
22+
{ _id: 2, relay: 'wss://nos.lol/', checksTotal: 9 }, // richest -> keep
23+
{ _id: 3, relay: 'wss://nos.lol/', checksTotal: 0 },
24+
];
25+
const p = planRelaySweep(docs, {});
26+
assert.deepEqual(ids(p.dups), [1, 3]); // two dropped
27+
assert.equal(p.kept, 1);
28+
});
29+
30+
test('host-spam: trims path-variant flooding, keeps origin + richest few', () => {
31+
const docs = [
32+
{ _id: 0, relay: 'wss://nostr.wine/', checksTotal: 1 }, // origin — always kept
33+
{ _id: 1, relay: 'wss://nostr.wine/echo-zulu', checksTotal: 5 },
34+
{ _id: 2, relay: 'wss://nostr.wine/flint-cipher', checksTotal: 4 },
35+
{ _id: 3, relay: 'wss://nostr.wine/raven-warden', checksTotal: 3 },
36+
{ _id: 4, relay: 'wss://nostr.wine/juliet-jade', checksTotal: 2 },
37+
];
38+
const p = planRelaySweep(docs, { maxPerHost: 3 });
39+
assert.equal(p.kept, 3);
40+
// origin (0) kept; then richest paths (1,2); spam = the rest (3,4)
41+
assert.deepEqual(ids(p.hostSpam), [3, 4]);
42+
});
43+
44+
test('a host under the cap is untouched', () => {
45+
const docs = [
46+
{ _id: 1, relay: 'wss://relay.example.com/inbox' },
47+
{ _id: 2, relay: 'wss://relay.example.com/outbox' },
48+
];
49+
const p = planRelaySweep(docs, { maxPerHost: 3 });
50+
assert.deepEqual(p.hostSpam, []);
51+
assert.equal(p.kept, 2);
52+
});
53+
54+
test('renorm flags survivors whose stored URL is not canonical', () => {
55+
const docs = [{ _id: 1, relay: 'wss://nos.lol' }]; // -> wss://nos.lol/
56+
const p = planRelaySweep(docs, {});
57+
assert.equal(p.renames.length, 1);
58+
assert.equal(p.renames[0].canonical, 'wss://nos.lol/');
59+
});
60+
61+
test('stale prune by age (only when staleDays set)', () => {
62+
const now = 1_000_000_000_000;
63+
const docs = [
64+
{ _id: 1, relay: 'wss://a.example.com/', lastChecked: new Date(now - 2 * 864e5).toISOString() }, // 2d old
65+
{ _id: 2, relay: 'wss://b.example.com/', lastChecked: new Date(now - 10 * 864e5).toISOString() }, // 10d old
66+
];
67+
assert.deepEqual(ids(planRelaySweep(docs, { staleDays: 7, now }).stale), [2]);
68+
assert.deepEqual(planRelaySweep(docs, { now }).stale, []); // no staleDays -> none
69+
});

0 commit comments

Comments
 (0)