diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 23:03:57 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 23:03:57 -0700 |
| commit | 07fb145efbc32cdfe2dc488c65d8708260f6f648 (patch) | |
| tree | e75dffaabdf9012a2c71628c6ba643146392fa9f /live | |
| parent | 7b29bcbf37adb6680531827eeb9442c1960e4e79 (diff) | |
| download | coinosv3-server-07fb145efbc32cdfe2dc488c65d8708260f6f648.tar.gz coinosv3-server-07fb145efbc32cdfe2dc488c65d8708260f6f648.zip | |
live: a name streams a day after it's claimed (the registrar now records when; older records count from their last update), never if banned; GET /live/allowed says which and until when. Viewers can report a stream (POST /live/<id>/report, NIP-98): kept in .reports.jsonl and DM'd to the admins from the welcome key. An admin takes a stream down (POST /live/<id>/remove {ban}): it ends, its playlists and segments answer 410, its files move to .removed/ (kept for reporting, never trimmed), and a ban stops its sender streaming.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'live')
| -rw-r--r-- | live/server.js | 129 |
1 files changed, 116 insertions, 13 deletions
diff --git a/live/server.js b/live/server.js index 02286d4..3238574 100644 --- a/live/server.js +++ b/live/server.js @@ -22,12 +22,25 @@ // GET /live/ping (WebSocket) says pong: sockets get through // GET /live/turn ICE servers for a call (Cloudflare TURN, a few hours' // credentials), Authorization: Nostr <NIP-98 for GET /live/turn> +// GET /live/allowed?name= may this key go live? {ok} or {ok:false, reason, until} +// (Authorization: Nostr <NIP-98 for GET /live/allowed>) +// POST /live/<id>/report {reason, text}: a viewer reports the stream; the admins +// get a DM (Authorization: Nostr <NIP-98 for POST /live/<id>/report>) +// POST /live/<id>/remove {ban}: an admin ends the stream and takes it down // // Who may send: a coinos name's owner (its nostr key or the wallet key that -// manages it), checked with the names registrar. One stream per person, -// MAX_STREAMS at once, MAX_HOURS each. +// manages it), checked with the names registrar, once the name is a day old +// (a throwaway name can't stream the minute it's made), and not banned. One +// stream per person, MAX_STREAMS at once, MAX_HOURS each. +// +// A removed stream is not deleted: it moves to $LIVE_DATA/.removed/<id>, +// served to nobody and never trimmed, so it's there to report (Canada's +// mandatory reporting law expects it kept); an admin deletes it by hand. import { verifyEvent } from 'nostr-tools/pure'; -import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { SimplePool } from 'nostr-tools/pool'; +import { wrapManyEvents } from 'nostr-tools/nip17'; +import { naddrEncode, npubEncode } from 'nostr-tools/nip19'; +import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, rmSync, statSync, renameSync, appendFileSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; const DATA = process.env.LIVE_DATA || '/data'; @@ -65,10 +78,20 @@ async function turnServers() { const j = await r.json(); return Array.isArray(j.iceServers) ? j.iceServers : [j.iceServers].filter(Boolean); } +// The admins (the remove button, report DMs); the DMs come from config.alertSk +// (the registrar's welcome key) to the admins' inbox relays. +const ADMINS = Array.isArray(CONFIG.admins) ? CONFIG.admins : ['98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7']; +const ALERT_SK = /^[0-9a-f]{64}$/.test(CONFIG.alertSk || '') ? Uint8Array.from(Buffer.from(CONFIG.alertSk, 'hex')) : null; +const NAME_MIN_AGE = +(process.env.LIVE_NAME_MIN_AGE_H || 24) * 3600_000; +const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol']; const WINDOW = 8; // segments in the live playlist const SEG = 2; // seconds a segment mkdirSync(DATA, { recursive: true }); +const REMOVED = `${DATA}/.removed`; +mkdirSync(REMOVED, { recursive: true }); +const BANNED_FILE = `${DATA}/.banned.json`; +const banned = (() => { try { return JSON.parse(readFileSync(BANNED_FILE, 'utf8')); } catch { return {}; } })(); // pubkey -> {at, by, stream} const streams = new Map(); // id -> stream const log = (...a) => console.log(new Date().toISOString(), ...a); const idOk = (id) => /^[0-9a-f]{16}$/.test(id || ''); @@ -81,6 +104,7 @@ function save(s) { } function load(id) { if (streams.has(id)) return streams.get(id); + if (idOk(id) && existsSync(`${REMOVED}/${id}`)) return { id, state: 'removed', viewers: new Map() }; if (!idOk(id) || !existsSync(metaPath(id))) return null; try { const s = JSON.parse(readFileSync(metaPath(id), 'utf8')); @@ -214,15 +238,18 @@ function trim() { setInterval(trim, 10 * 60_000).unref?.(); // ------------------------------------------------------------------ auth -async function ownsName(name, pubkey) { - if (!/^[a-z0-9._-]{1,30}$/.test(name || '')) return false; - try { - const r = await fetch(`${NAMES}/name/${name}`, { signal: AbortSignal.timeout(5000) }); - const j = await r.json(); - return !!j.taken && (j.pubkey === pubkey || j.manager === pubkey); - } catch { return false; } +// May this key go live under this name? {ok} or {ok:false, reason[, until]} +async function mayStream(name, pubkey) { + if (banned[pubkey]) return { ok: false, reason: 'banned' }; + if (!/^[a-z0-9._-]{1,30}$/.test(name || '')) return { ok: false, reason: 'no-name' }; + let j; + try { j = await (await fetch(`${NAMES}/name/${name}`, { signal: AbortSignal.timeout(5000) })).json(); } catch { return { ok: false, reason: 'no-name' }; } + if (!j.taken || (j.pubkey !== pubkey && j.manager !== pubkey)) return { ok: false, reason: 'no-name' }; + if (j.created && Date.now() - j.created < NAME_MIN_AGE) return { ok: false, reason: 'new-name', until: j.created + NAME_MIN_AGE }; + return { ok: true }; } -function checkAuth(url, req, path = '/live/ingest') { +const REFUSALS = { banned: 'you can\'t go live here', 'no-name': 'only a coinos name\'s owner can go live', 'new-name': 'a name can go live a day after it\'s claimed' }; +function checkAuth(url, req, path = '/live/ingest', method = 'GET') { let evt; const header = /^Nostr\s+(.+)$/i.exec((req && req.headers.get('authorization')) || ''); try { evt = JSON.parse(Buffer.from((header && header[1]) || url.searchParams.get('auth') || '', 'base64').toString('utf8')); } catch { return { error: 'bad auth' }; } @@ -230,7 +257,7 @@ function checkAuth(url, req, path = '/live/ingest') { if (Math.abs(Date.now() / 1000 - (evt.created_at || 0)) > 60) return { error: 'auth expired' }; if (!verifyEvent(evt)) return { error: 'bad signature' }; const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1] || ''; - if (tag('method').toUpperCase() !== 'GET') return { error: 'method mismatch' }; + if (tag('method').toUpperCase() !== method) return { error: 'method mismatch' }; let u; try { u = new URL(tag('u')); } catch { return { error: 'bad url tag' }; } if (u.pathname !== path) return { error: 'url mismatch' }; return { pubkey: evt.pubkey }; @@ -263,7 +290,8 @@ export async function handle(req, server) { const a = checkAuth(url, req); if (a.error) return json({ error: a.error }, 401); const name = (url.searchParams.get('name') || '').toLowerCase(); - if (!(await ownsName(name, a.pubkey))) return json({ error: 'only a coinos name\'s owner can go live' }, 403); + const may = await mayStream(name, a.pubkey); + if (!may.ok) return json({ error: REFUSALS[may.reason], reason: may.reason, until: may.until }, 403); const resume = url.searchParams.get('resume'); let s = resume ? load(resume) : null; if (s && (s.pubkey !== a.pubkey || s.state !== 'waiting')) s = null; @@ -274,10 +302,26 @@ export async function handle(req, server) { const ok = server.upgrade(req, { data: { pubkey: a.pubkey, name, resume: s ? s.id : null } }); return ok ? undefined : json({ error: 'websocket expected' }, 426); } + if (path === '/allowed' && req.method === 'GET') { + const a = checkAuth(url, req, '/live/allowed'); + if (a.error) return json({ error: a.error }, 401); + return json(await mayStream((url.searchParams.get('name') || '').toLowerCase(), a.pubkey)); + } + const act = /^\/([0-9a-f]{16})\/(report|remove)$/.exec(path); + if (act && req.method === 'OPTIONS') return new Response(null, { headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'authorization, content-type', 'access-control-allow-methods': 'POST' } }); + if (act && req.method === 'POST') { + const a = checkAuth(url, req, `/live/${act[1]}/${act[2]}`, 'POST'); + if (a.error) return json({ error: a.error }, 401); + const s = load(act[1]); + if (!s) return json({ error: 'no such stream' }, 404); + const body = await req.json().catch(() => ({})); + return act[2] === 'report' ? report(s, a.pubkey, body, ipOf(req, server)) : remove(s, a.pubkey, body); + } const m = /^\/([0-9a-f]{16})\/(live\.m3u8|index\.m3u8|stats|\d+-\d{5}\.ts)$/.exec(path); if (!m || req.method !== 'GET') return json({ error: 'not found' }, 404); const s = load(m[1]); if (!s) return json({ error: 'no such stream' }, 404); + if (s.state === 'removed') return json({ error: 'removed', state: 'removed' }, 410); if (m[2] === 'stats') return json({ state: s.state, viewers: viewersOf(s), started: s.started, seconds: Math.round(((s.ended || Date.now()) - s.started) / 1000) }); if (m[2].endsWith('.m3u8')) { if (s.state !== 'ended') s.viewers.set(ipOf(req, server), Date.now()); @@ -288,6 +332,65 @@ export async function handle(req, server) { return new Response(f, { headers: { 'content-type': 'video/mp2t', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=86400, immutable' } }); } +// ----------------------------------------------------------- moderation +const pool = new SimplePool(); +async function inboxOf(pk) { + try { + const evs = await pool.querySync(DM_RELAYS, { kinds: [10050], authors: [pk] }, { maxWait: 4000 }); + const ev = evs.sort((a, b) => b.created_at - a.created_at)[0]; + const r = ev ? ev.tags.filter((t) => t[0] === 'relay' && /^wss:\/\//.test(t[1] || '')).map((t) => t[1]) : []; + return r.slice(0, 4); + } catch { return []; } +} +// a DM to each admin (NIP-17, the recipient's copy only) +async function alertAdmins(text) { + if (!ALERT_SK) { log('alert (no alertSk):', text); return; } + for (const pk of ADMINS) { + try { + const [, wrap] = wrapManyEvents(ALERT_SK, [{ publicKey: pk }], text); + const relays = [...new Set([...(await inboxOf(pk)), ...DM_RELAYS])]; + await Promise.any(pool.publish(relays, wrap)).catch(() => log('alert to', pk.slice(0, 12), 'not delivered')); + } catch (e) { log('alert failed', e.message); } + } +} +const streamLink = (s) => 'https://v3.coinos.io/' + naddrEncode({ kind: 30311, pubkey: s.pubkey, identifier: s.id }); +const reportsBy = new Map(); // reporter or ip -> [times] +function report(s, pubkey, body, ip) { + if (s.state === 'removed') return json({ ok: true }); + const now = Date.now(); + for (const k of ['pk:' + pubkey, 'ip:' + ip]) { + const list = (reportsBy.get(k) || []).filter((t) => now - t < 3600_000); + if (list.length >= 10) return json({ error: 'rate limited' }, 429); + list.push(now); reportsBy.set(k, list); + } + const reason = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other'].includes(body.reason) ? body.reason : 'other'; + const text = String(body.text || '').slice(0, 500); + const rec = { at: now, stream: s.id, host: s.pubkey, name: s.name, by: pubkey, ip, reason, text }; + try { appendFileSync(`${DATA}/.reports.jsonl`, JSON.stringify(rec) + '\n'); } catch {} + s.reports = (s.reports || 0) + 1; + if (streams.has(s.id)) save(s); + log('report', s.id, reason, 'by', pubkey.slice(0, 12)); + alertAdmins(`Live stream reported (${reason}): ${s.name} — ${npubEncode(s.pubkey)}\n${text ? '"' + text + '"\n' : ''}` + + `${s.reports} report${s.reports === 1 ? '' : 's'} so far. Open it to take it down: ${streamLink(s)}`); + return json({ ok: true }); +} +function remove(s, by, body) { + if (!ADMINS.includes(by)) return json({ error: 'admins only' }, 403); + if (s.state === 'removed') return json({ ok: true, removed: true }); + if (s.ws) { try { s.ws.close(4003, 'removed'); } catch {} } + end(s, 'removed by ' + by.slice(0, 12)); + s.removed = { at: Date.now(), by }; + save(s); + try { renameSync(`${DATA}/${s.id}`, `${REMOVED}/${s.id}`); } catch (e) { log('remove: move failed', e.message); } + streams.set(s.id, { id: s.id, state: 'removed', viewers: new Map() }); + if (body && body.ban && s.pubkey) { + banned[s.pubkey] = { at: Date.now(), by, stream: s.id }; + try { writeFileSync(BANNED_FILE, JSON.stringify(banned)); } catch {} + } + log('removed', s.id, body && body.ban ? '(and banned ' + s.pubkey.slice(0, 12) + ')' : ''); + return json({ ok: true, removed: true, banned: !!(body && body.ban) }); +} + export const websocket = { maxPayloadLength: 8 * 1024 * 1024, idleTimeout: 60, |
