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 /tools | |
| 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 'tools')
| -rw-r--r-- | tools/live-ingest-test.js | 34 |
1 files changed, 31 insertions, 3 deletions
diff --git a/tools/live-ingest-test.js b/tools/live-ingest-test.js index 7bb2489..c760679 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 } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; @@ -16,11 +16,15 @@ Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'tests '-t', '12', '-c:v', 'libvpx', '-deadline', 'realtime', '-b:v', '1M', '-c:a', 'libopus', '-f', 'webm', dir + '/cam.webm']); const webm = readFileSync(dir + '/cam.webm'); -const SK = generateSecretKey(), PK = getPublicKey(SK), OTHER = generateSecretKey(); +const SK = generateSecretKey(), PK = getPublicKey(SK), OTHER = generateSecretKey(), ADMIN = generateSecretKey(); +const NEWBIE = generateSecretKey(); const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => { const n = new URL(req.url).pathname.split('/').pop(); - return Response.json(n === 'alice' ? { taken: true, pubkey: PK, manager: null } : { taken: false }); + return Response.json(n === 'alice' ? { taken: true, pubkey: PK, manager: null, created: Date.now() - 3 * 86400_000 } + : n === 'newbie' ? { taken: true, pubkey: getPublicKey(NEWBIE), created: Date.now() - 3600_000 } : { taken: false }); } }); +writeFileSync(dir + '/config.json', JSON.stringify({ admins: [getPublicKey(ADMIN)] })); +process.env.LIVE_CONFIG = dir + '/config.json'; process.env.LIVE_DATA = dir + '/data'; process.env.NAMES_URL = names.url.origin; process.env.LIVE_BASE = 'http://x/live'; @@ -50,6 +54,12 @@ try { check('a stranger to the name is refused', (await connect(`auth=${auth(OTHER)}&name=alice`)).error === true); check('...as is a name nobody owns', (await connect(`auth=${auth(SK)}&name=bob`)).error === true); check('...and a forged auth', (await connect(`auth=${auth(SK).slice(0, -8)}AAAAAAAA&name=alice`)).error === true); + const nip98 = (sk, path, method = 'GET') => 'Nostr ' + Buffer.from(JSON.stringify(finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['u', 'https://v3.coinos.io' + path], ['method', method]] }, sk))).toString('base64'); + check('...and a name claimed an hour ago (a day first)', (await connect(`auth=${auth(NEWBIE)}&name=newbie`)).error === true); + const al = await (await fetch(`${HTTP}/allowed?name=newbie`, { headers: { authorization: nip98(NEWBIE, '/live/allowed') } })).json(); + check('...and asked, the server says when it can', al.ok === false && al.reason === 'new-name' && al.until > Date.now() + 22 * 3600_000, JSON.stringify(al)); + check('a day-old name\'s owner may', (await (await fetch(`${HTTP}/allowed?name=alice`, { headers: { authorization: nip98(SK, '/live/allowed') } })).json()).ok === true); console.log('\n[TURN for calls]'); const turnAuth = (sk, path = '/live/turn') => 'Nostr ' + Buffer.from(JSON.stringify(finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', @@ -89,6 +99,13 @@ try { check('the whole playlist joins the two with a discontinuity', /#EXT-X-DISCONTINUITY\n/.test(full.body) && segs(full.body).some((x) => x.startsWith('0-')) && segs(full.body).some((x) => x.startsWith('1-')), segs(full.body).length + ' segments'); check('...as an EVENT playlist while live', /PLAYLIST-TYPE:EVENT/.test(full.body) && !/ENDLIST/.test(full.body)); + console.log('\n[reports and taking down]'); + const post = (sk, path, body) => fetch(`http://127.0.0.1:${srv.port}${path}`, { method: 'POST', headers: { authorization: nip98(sk, path, 'POST'), 'content-type': 'application/json' }, body: JSON.stringify(body) }); + check('a viewer reports the stream', (await post(OTHER, `/live/${id}/report`, { reason: 'sexual', text: 'look' })).ok); + const reps = readFileSync(dir + '/data/.reports.jsonl', 'utf8').trim().split('\n').map((l) => JSON.parse(l)); + check('...kept, with who and why', reps.length === 1 && reps[0].stream === id && reps[0].reason === 'sexual' && reps[0].by === getPublicKey(OTHER)); + check('...unsigned, refused', (await fetch(`${HTTP}/${id}/report`, { method: 'POST', body: '{}' })).status === 401); + check('only an admin can take it down', (await post(OTHER, `/live/${id}/remove`, { ban: true })).status === 403); console.log('\n[the end]'); b2.ws.send('end'); await sleep(1500); @@ -101,6 +118,17 @@ try { const c = await connect(`auth=${auth(SK)}&name=alice`); check('once ended, the owner can go live again', !!c.hello?.id && c.hello.id !== id); c.ws.send('end'); + await sleep(500); + 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)); + await sleep(1500); + const rm = await (await post(ADMIN, `/live/${tid}/remove`, { ban: true })).json(); + check('the admin takes a stream down, banning its sender', rm.removed === true && rm.banned === true, JSON.stringify(rm)); + check('...its playlists are gone (410), its segments too', (await fetch(`${HTTP}/${tid}/live.m3u8`)).status === 410 && (await fetch(`${HTTP}/${tid}/0-00000.ts`)).status === 410); + check('...kept aside for reporting, not deleted', existsSync(`${dir}/data/.removed/${tid}`) && !existsSync(`${dir}/data/${tid}`)); + check('...and its sender can\'t go live again', (await connect(`auth=${auth(SK)}&name=alice`)).error === true + && (await (await fetch(`${HTTP}/allowed?name=alice`, { headers: { authorization: nip98(SK, '/live/allowed') } })).json()).reason === 'banned'); } catch (e) { console.error(e); ok = false; } finally { srv.stop(true); names.stop(true); await sleep(300); |
