aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--nwcpush/server.js94
1 files changed, 94 insertions, 0 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js
index 1ad0ad2..b8c1ecd 100644
--- a/nwcpush/server.js
+++ b/nwcpush/server.js
@@ -393,6 +393,55 @@ async function wake(servicePk, eventId, ev) {
}
// ---------------------------------------------------------------------------
+// scheduled posts
+// ---------------------------------------------------------------------------
+// A post written now to go out later. The app signs it now, while the user
+// (and their signer) is there, with created_at set to the time it should
+// appear — so it is exactly the note it will be, and we can't change a word
+// of it. Relays refuse notes from the future, so we hold it and publish it
+// at that time to the relays the author asked for. Cancelling is a kind 5
+// deletion signed by the same key; we drop the note and it never reaches a
+// relay. What this learns: a public post a little before everyone else.
+
+const SCHED_FILE = join(dirname(STATE), 'scheduled.json');
+const SCHED_MAX_PER_PK = 50;
+const SCHED_MAX_AHEAD = 90 * 86400;
+const SCHED_GIVE_UP = 86400; // a note no relay would take for a day is dropped
+let scheduled = {}; // { [eventId]: { ev, relays, tries } }
+try { scheduled = JSON.parse(readFileSync(SCHED_FILE, 'utf8')) || {}; } catch {}
+function persistScheduled() {
+ mkdirSync(dirname(SCHED_FILE), { recursive: true });
+ writeFileSync(SCHED_FILE + '.tmp', JSON.stringify(scheduled));
+ renameSync(SCHED_FILE + '.tmp', SCHED_FILE);
+}
+const okRelay = (u) => typeof u === 'string' && u.length < 200 && /^wss:\/\/[a-z0-9.-]+(:\d+)?(\/[\w./-]*)?$/i.test(u);
+let schedBusy = false;
+async function publishDue() {
+ if (schedBusy) return;
+ schedBusy = true;
+ try {
+ const now = Math.floor(Date.now() / 1000);
+ for (const [id, s] of Object.entries(scheduled)) {
+ if (s.ev.created_at > now || (s.next || 0) > now) continue;
+ const relays = [...new Set([...(s.relays || []), ...RELAYS])];
+ let sent = 0;
+ try { sent = (await Promise.allSettled(pool.publish(relays, s.ev))).filter((r) => r.status === 'fulfilled').length; } catch {}
+ if (sent) {
+ delete scheduled[id];
+ log(`published scheduled post ${id.slice(0, 12)} by ${s.ev.pubkey.slice(0, 12)} to ${sent}/${relays.length} relays`);
+ } else if (now - s.ev.created_at > SCHED_GIVE_UP) {
+ delete scheduled[id];
+ log(`gave up on scheduled post ${id.slice(0, 12)}: no relay took it in a day`);
+ } else {
+ s.tries = (s.tries || 0) + 1;
+ s.next = now + Math.min(3600, 30 * 2 ** s.tries);
+ }
+ persistScheduled();
+ }
+ } finally { schedBusy = false; }
+}
+
+// ---------------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------------
@@ -448,6 +497,48 @@ const server = Bun.serve({
return json({ ok: true });
}
+ // Scheduled posts (see above): hold a signed kind-1 note until its
+ // created_at, then publish it; status by id (the id is the capability:
+ // only its author knows it before it goes out); cancel with a kind 5.
+ if (url.pathname === '/schedule' && req.method === 'POST') {
+ const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
+ if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
+ const body = await req.json().catch(() => null);
+ const ev = body?.event;
+ if (!ev || ev.kind !== 1) return json({ error: 'kind 1 notes only' }, 400);
+ if (JSON.stringify(ev).length > 65536) return json({ error: 'note too large' }, 400);
+ const now = Math.floor(Date.now() / 1000);
+ if (!(ev.created_at > now + 30)) return json({ error: 'that time has passed' }, 400);
+ if (ev.created_at > now + SCHED_MAX_AHEAD) return json({ error: 'at most 90 days ahead' }, 400);
+ if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400);
+ if (!scheduled[ev.id] && Object.values(scheduled).filter((s) => s.ev.pubkey === ev.pubkey).length >= SCHED_MAX_PER_PK) {
+ return json({ error: `at most ${SCHED_MAX_PER_PK} scheduled posts` }, 400);
+ }
+ const relays = [...new Set((Array.isArray(body.relays) ? body.relays : []).filter(okRelay))].slice(0, 20);
+ scheduled[ev.id] = { ev, relays, tries: 0 };
+ persistScheduled();
+ log(`scheduled post ${ev.id.slice(0, 12)} by ${ev.pubkey.slice(0, 12)} for ${new Date(ev.created_at * 1000).toISOString()}`);
+ return json({ ok: true, id: ev.id, at: ev.created_at });
+ }
+ if (url.pathname === '/schedule/status' && req.method === 'GET') {
+ const ids = (url.searchParams.get('ids') || '').split(',').filter((x) => /^[0-9a-f]{64}$/.test(x)).slice(0, 100);
+ return json(Object.fromEntries(ids.map((id) => [id, scheduled[id] ? 'pending' : 'gone'])));
+ }
+ if (url.pathname === '/schedule/cancel' && req.method === 'POST') {
+ const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
+ if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
+ const body = await req.json().catch(() => null);
+ const del = body?.event;
+ if (!del || del.kind !== 5 || JSON.stringify(del).length > 8192) return json({ error: 'a kind 5 deletion' }, 400);
+ if (!verifyEvent(del)) return json({ error: 'bad signature' }, 400);
+ let n = 0;
+ for (const [k, id] of del.tags || []) {
+ if (k === 'e' && scheduled[id] && scheduled[id].ev.pubkey === del.pubkey) { delete scheduled[id]; n++; }
+ }
+ if (n) { persistScheduled(); log(`cancelled ${n} scheduled post(s) by ${del.pubkey.slice(0, 12)}`); }
+ return json({ ok: true, cancelled: n });
+ }
+
// Server-to-server: the names registrar reports a settled receive for a
// pubkey. Token-gated; pushes 'payment received' to that user's devices.
if (url.pathname === '/notify' && req.method === 'POST') {
@@ -596,5 +687,8 @@ log(` relays: ${RELAYS.join(' ')}`);
log(` watching ${watchedPubkeys().length} service pubkey(s)`);
resubscribe();
setInterval(prune, 3600_000);
+setInterval(publishDue, 15_000);
+publishDue();
+log(` holding ${Object.keys(scheduled).length} scheduled post(s)`);
// relays drop long-lived subs; re-arm periodically
setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000);