diff options
| -rw-r--r-- | nwcpush/server.js | 94 |
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); |
