aboutsummaryrefslogtreecommitdiffstats
path: root/live
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 23:03:57 -0700
committerAdam Soltys <[email protected]>2026-10-18 23:03:57 -0700
commit07fb145efbc32cdfe2dc488c65d8708260f6f648 (patch)
treee75dffaabdf9012a2c71628c6ba643146392fa9f /live
parent7b29bcbf37adb6680531827eeb9442c1960e4e79 (diff)
downloadcoinosv3-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.js129
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,