scripts/archive-relay.mjs snapshots the app's own content off goskatespots.nostr1.com to gzipped JSONL of the raw signed events, so a restore can republish them verbatim to any relay. scripts/restore-relay.mjs re-verifies signatures and publishes to an explicit --to (no default, to avoid replaying a snapshot onto the live relay). scripts/backup-relay.sh wraps it and pushes with restic when a repo is configured; a weekly systemd user timer runs it. Scoped on purpose: that relay is PUBLIC and carries general traffic (15.7k kind-0 profiles, 2k kind-30078 app-data from other clients, 408 foreign NIP-99 listings), so the archive keeps only the app's own kinds/tags plus contributor profiles, SkateSpots labels, and reports that reference archived content. ~114 events / 63 KiB. Not configured yet: offsite (restic.env) and a full restore drill.
86 lines
2.8 KiB
JavaScript
Executable file
86 lines
2.8 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
// Restore a SkateSpots archive (from scripts/archive-relay.mjs) to a relay.
|
|
//
|
|
// Publishes the RAW archived events verbatim — signatures are re-verified first,
|
|
// so anything tampered with is dropped. Addressable events are safe to republish:
|
|
// the newest `created_at` wins, so replaying a snapshot converges to the same
|
|
// state. This is the "prove you can get it back" half of the backup.
|
|
//
|
|
// node scripts/restore-relay.mjs <relay-*.jsonl.gz> --dry-run
|
|
// node scripts/restore-relay.mjs <relay-*.jsonl.gz> --to wss://your.relay
|
|
//
|
|
// There is deliberately NO default target: restoring is a deliberate act, and
|
|
// the common mistake is publishing a snapshot back to the live relay. Use
|
|
// --dry-run to preview, then point --to at the relay you actually want.
|
|
import { readFileSync } from 'node:fs';
|
|
import { gunzipSync } from 'node:zlib';
|
|
import { SimplePool, verifyEvent } from 'nostr-tools';
|
|
|
|
const args = process.argv.slice(2);
|
|
const file = args.find((a) => !a.startsWith('--'));
|
|
const toIndex = args.indexOf('--to');
|
|
const TO = toIndex >= 0 ? args[toIndex + 1] : null;
|
|
const DRY = args.includes('--dry-run');
|
|
|
|
if (!file) {
|
|
console.error('usage: node scripts/restore-relay.mjs <archive.jsonl.gz> [--dry-run | --to wss://relay]');
|
|
process.exit(2);
|
|
}
|
|
if (!TO && !DRY) {
|
|
console.error('[restore] refusing to publish without an explicit --to <relay> (use --dry-run to preview)');
|
|
process.exit(2);
|
|
}
|
|
|
|
const lines = gunzipSync(readFileSync(file)).toString('utf8').split('\n').filter(Boolean);
|
|
const events = [];
|
|
let invalid = 0;
|
|
for (const line of lines) {
|
|
try {
|
|
const ev = JSON.parse(line);
|
|
if (verifyEvent(ev)) events.push(ev);
|
|
else invalid += 1;
|
|
} catch {
|
|
invalid += 1;
|
|
}
|
|
}
|
|
|
|
const byKind = {};
|
|
for (const e of events) byKind[e.kind] = (byKind[e.kind] || 0) + 1;
|
|
console.log(`[restore] ${events.length} valid events (${invalid} invalid/ignored) from ${file}`);
|
|
console.log(
|
|
'[restore] kinds:',
|
|
Object.entries(byKind)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([k, n]) => `${k}:${n}`)
|
|
.join(' '),
|
|
);
|
|
|
|
if (DRY) {
|
|
console.log('[restore] dry run — nothing published');
|
|
process.exit(0);
|
|
}
|
|
|
|
const pool = new SimplePool();
|
|
let ok = 0;
|
|
let fail = 0;
|
|
let index = 0;
|
|
const CONCURRENCY = 10;
|
|
|
|
async function worker() {
|
|
while (index < events.length) {
|
|
const ev = events[index++];
|
|
try {
|
|
const results = await Promise.allSettled(pool.publish([TO], ev));
|
|
if (results.some((r) => r.status === 'fulfilled')) ok += 1;
|
|
else fail += 1;
|
|
} catch {
|
|
fail += 1;
|
|
}
|
|
if ((ok + fail) % 100 === 0) process.stdout.write(`\r[restore] ${ok + fail}/${events.length}…`);
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, events.length) }, worker));
|
|
pool.close([TO]);
|
|
process.stdout.write(`\r[restore] done — ${ok} accepted, ${fail} failed \n`);
|
|
if (fail) process.exit(1);
|