From 5bbc130102de0a3c0eff6727241d0f8029ab6572 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 19 Oct 2026 11:15:11 -0700 Subject: live: a streamer can delete their own recording (POST /live//delete, NIP-98), ending the stream if it's on. Its files go, and stream.json stays as 'deleted' so the address answers 410 until the trim takes it. A stream someone reported is taken aside instead (.removed/, kept for reporting), so deleting can't destroy what was reported. Removed and deleted streams no longer count as live (toward MAX_STREAMS, or as exempt from the trim). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- live/server.js | 42 ++++++++++++++++++++++++++++++++++-------- tools/live-ingest-test.js | 14 +++++++++++++- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/live/server.js b/live/server.js index dfe122e..d246c58 100644 --- a/live/server.js +++ b/live/server.js @@ -132,12 +132,12 @@ function load(id) { try { const s = JSON.parse(readFileSync(metaPath(id), 'utf8')); s.viewers = new Map(); - if (s.state !== 'ended') { s.state = 'ended'; s.ended = s.ended || Date.now(); save(s); } // a restart ended it + if (s.state !== 'ended' && s.state !== 'deleted') { s.state = 'ended'; s.ended = s.ended || Date.now(); save(s); } // a restart ended it streams.set(id, s); return s; } catch { return null; } } -const live = () => [...streams.values()].filter((s) => s.state !== 'ended'); +const live = () => [...streams.values()].filter((s) => s.state === 'live' || s.state === 'waiting'); // A run's playlist as ffmpeg wrote it: [{dur, uri}] function runSegments(s, run) { @@ -247,7 +247,7 @@ function trim() { const info = dirs.map((id) => { let bytes = 0, at = 0; try { for (const f of readdirSync(`${DATA}/${id}`)) { const st = statSync(`${DATA}/${id}/${f}`); bytes += st.size; at = Math.max(at, st.mtimeMs); } } catch {} - return { id, bytes, at, live: streams.get(id) && streams.get(id).state !== 'ended' }; + return { id, bytes, at, live: ['live', 'waiting'].includes(streams.get(id)?.state) }; }).sort((a, b) => a.at - b.at); let total = info.reduce((n, x) => n + x.bytes, 0); for (const x of info) { @@ -334,7 +334,7 @@ export async function handle(req, server) { const rtc = /^\/([0-9a-f]{16})\/rtc\/(publish|watch|answer)$/.exec(path); if (rtc && 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 (rtc && req.method === 'POST') return rtcRoute(rtc[1], rtc[2], req, url, server); - const act = /^\/([0-9a-f]{16})\/(report|remove)$/.exec(path); + const act = /^\/([0-9a-f]{16})\/(report|remove|delete)$/.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'); @@ -342,13 +342,13 @@ export async function handle(req, server) { 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); + return act[2] === 'report' ? report(s, a.pubkey, body, ipOf(req, server)) : act[2] === 'delete' ? deleteOwn(s, a.pubkey) : 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 (s.state === 'removed' || s.state === 'deleted') return json({ error: s.state, state: s.state }, 410); if (m[2] === 'stats') { // a viewer on WebRTC never asks for the playlist: it says it's watching here if (url.searchParams.has('watching') && s.state !== 'ended') s.viewers.set(ipOf(req, server), Date.now()); @@ -372,7 +372,7 @@ export async function handle(req, server) { async function rtcRoute(id, what, req, url, server) { if (!SFU) return json({ error: 'no webrtc here' }, 501); const s = load(id); - if (!s || s.state === 'removed' || s.state === 'ended') return json({ error: 'not live' }, 404); + if (!s || s.state !== 'live' && s.state !== 'waiting') return json({ error: 'not live' }, 404); const body = await req.json().catch(() => ({})); try { if (what === 'publish') { @@ -456,7 +456,7 @@ async function alertAdmins(text) { 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 }); + if (s.state === 'removed' || s.state === 'deleted') 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); @@ -491,6 +491,32 @@ function remove(s, by, body) { return json({ ok: true, removed: true, banned: !!(body && body.ban) }); } +// The streamer deletes their own recording (ending the stream if it's on). +// Its files go; stream.json stays as 'deleted' so the address says gone (410) +// until the trim takes it. A stream someone reported isn't destroyed: it's +// taken aside as an admin's takedown would (kept for reporting). +function deleteOwn(s, by) { + if (s.state === 'removed' || s.state === 'deleted') return json({ ok: true, deleted: true }); + if (by !== s.pubkey) return json({ error: 'not your stream' }, 403); + if (s.ws) { try { s.ws.close(4004, 'deleted'); } catch {} } + end(s, 'deleted by its sender'); + if (s.reports) { + s.removed = { at: Date.now(), by, self: true }; + save(s); + try { renameSync(`${DATA}/${s.id}`, `${REMOVED}/${s.id}`); } catch (e) { log('delete: move failed', e.message); } + streams.set(s.id, { id: s.id, state: 'removed', viewers: new Map() }); + log('deleted (reported, kept aside)', s.id); + return json({ ok: true, deleted: true }); + } + // after ffmpeg lets go (endRun gives it up to 8 s to finish) + const wipe = () => { try { for (const f of readdirSync(`${DATA}/${s.id}`)) if (f !== 'stream.json') rmSync(`${DATA}/${s.id}/${f}`, { force: true }); } catch {} }; + s.state = 'deleted'; s.deleted = Date.now(); + save(s); + wipe(); setTimeout(wipe, 9000); + log('deleted', s.id); + return json({ ok: true, deleted: true }); +} + export const websocket = { maxPayloadLength: 8 * 1024 * 1024, idleTimeout: 60, diff --git a/tools/live-ingest-test.js b/tools/live-ingest-test.js index d1b15e0..dc24ff8 100644 --- a/tools/live-ingest-test.js +++ b/tools/live-ingest-test.js @@ -4,7 +4,7 @@ // discontinuity between); the end closes the playlist; nobody else may send. // Run: bun tools/live-ingest-test.js (needs ffmpeg) import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; -import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; @@ -122,6 +122,18 @@ try { check('once ended, the owner can go live again', !!c.hello?.id && c.hello.id !== id); c.ws.send('end'); await sleep(500); + + console.log('\n[the streamer deletes a recording]'); + const cid = c.hello.id; + check('someone else can\'t delete it', (await post(OTHER, `/live/${cid}/delete`, {})).status === 403); + const del = await (await post(SK, `/live/${cid}/delete`, {})).json(); + check('its streamer can', del.deleted === true, JSON.stringify(del)); + check('...its playlist is gone (410), said to be deleted', (await fetch(`${HTTP}/${cid}/index.m3u8`)).status === 410 && (await (await fetch(`${HTTP}/${cid}/stats`)).json()).state === 'deleted'); + check('...its files with it (only a note of it left)', readdirSync(`${dir}/data/${cid}`).join() === 'stream.json'); + check('...and the owner can still go live', (await (await fetch(`${HTTP}/allowed?name=alice`, { headers: { authorization: nip98(SK, '/live/allowed') } })).json()).ok === true); + const kept = await (await post(SK, `/live/${id}/delete`, {})).json(); + check('a reported one, deleted by its streamer, is gone from view but kept aside for reporting', kept.deleted === true + && (await fetch(`${HTTP}/${id}/index.m3u8`)).status === 410 && existsSync(`${dir}/data/.removed/${id}`) && !existsSync(`${dir}/data/${id}`)); const c0 = await connect(`auth=${auth(SK)}&name=alice`); const tid = c0.hello.id; await send(c0.ws, 0, Math.floor(webm.length * 0.3)); -- cgit v1.2.3