diff --git a/blossom/config.yml b/blossom/config.yml index c730aa2..f06520e 100644 --- a/blossom/config.yml +++ b/blossom/config.yml @@ -1,5 +1,5 @@ -# blossom-server configuration for podpuddle. -# Uploads require a signed nostr auth event (BUD-01/BUD-02, kind 24242); +# blossom-server (v4.x) configuration for podpuddle. +# Uploads require a signed nostr auth event (BUD-02, kind 24242); # reads are public so podcast apps can fetch enclosures. publicDomain: "" @@ -22,22 +22,22 @@ storage: local: dir: ./data/blobs removeWhenNoOwners: false + # NOTE: "expiration" is time since a blob was last accessed — unaccessed + # blobs get pruned after this. Podcast media should effectively never + # expire, so keep this long. + rules: + - type: "*" + expiration: 10 years upload: enabled: true requireAuth: true requirePubkeyInRule: false -media: - enabled: false - list: requireAuth: false allowListOthers: true tor: enabled: false - -rules: - - type: "*" - expiration: 10 years + proxy: "" diff --git a/docker-compose.yml b/docker-compose.yml index 2e0e63a..cef1a99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: MEDIAMTX_WHIP_PUBLIC: ${MEDIAMTX_WHIP_PUBLIC:-http://localhost:8889} MEDIAMTX_HLS_PUBLIC: ${MEDIAMTX_HLS_PUBLIC:-http://localhost:8890} BLOSSOM_URL_DEFAULT: ${BLOSSOM_URL_DEFAULT:-http://localhost:8098} + BLOSSOM_URL_INTERNAL: http://blossom:3000 NOSTR_RELAYS: ${NOSTR_RELAYS:-wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band} volumes: - podpuddle-data:/data diff --git a/mediamtx/mediamtx.yml b/mediamtx/mediamtx.yml index 712e19d..bbefb8a 100644 --- a/mediamtx/mediamtx.yml +++ b/mediamtx/mediamtx.yml @@ -18,6 +18,7 @@ authHTTPExclude: # ---- protocols ----------------------------------------------------------- rtsp: no srt: no +moq: no rtmp: yes rtmpAddress: :1935 @@ -26,12 +27,12 @@ hls: yes hlsAddress: :8888 hlsVariant: lowLatency hlsAlwaysRemux: yes -hlsAllowOrigin: "*" +hlsAllowOrigins: ["*"] webrtc: yes webrtcAddress: :8889 webrtcLocalUDPAddress: :8189 -webrtcAllowOrigin: "*" +webrtcAllowOrigins: ["*"] # ---- recording ----------------------------------------------------------- pathDefaults: diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs new file mode 100644 index 0000000..d1ab31e --- /dev/null +++ b/scripts/e2e.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// End-to-end check against a running `docker compose up` stack. +// Exercises: NIP-98 login, settings, podcast creation, BUD-02 blossom upload, +// episode registration, RSS feed, stream keys + MediaMTX auth. RTMP publishing +// is driven separately (see scripts/e2e.sh) since it needs ffmpeg. +// +// Usage: node scripts/e2e.mjs (uses nostr-tools from server/node_modules) + +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { finalizeEvent, generateSecretKey } from '../server/node_modules/nostr-tools/lib/esm/pure.js'; + +const BASE = process.env.PODPUDDLE_URL ?? 'http://localhost:8095'; +const BLOSSOM = process.env.BLOSSOM_URL ?? 'http://localhost:8098'; + +// Persist the test identity across runs — the first pubkey to log in becomes +// admin, so reruns must reuse it for the settings test to pass. +const KEY_FILE = '/tmp/podpuddle-e2e-key'; +let sk; +if (existsSync(KEY_FILE)) { + sk = Uint8Array.from(Buffer.from(readFileSync(KEY_FILE, 'utf8').trim(), 'hex')); +} else { + sk = generateSecretKey(); + writeFileSync(KEY_FILE, Buffer.from(sk).toString('hex'), { mode: 0o600 }); +} +let cookie = ''; +let failures = 0; + +function ok(name, cond, extra = '') { + const pass = !!cond; + console.log(`${pass ? 'ok' : 'NOT OK'} - ${name}${extra ? ` (${extra})` : ''}`); + if (!pass) failures++; + return pass; +} + +function nip98(url, method) { + const event = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]], + }, + sk, + ); + return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; +} + +async function api(method, path, body, headers = {}) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + ...(cookie ? { cookie } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + const setCookie = res.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]; + let json = null; + try { json = await res.json(); } catch { /* non-JSON */ } + return { status: res.status, json }; +} + +// ---- login ---- +const login = await api('POST', '/api/auth/login', undefined, { + authorization: nip98(`${BASE}/api/auth/login`, 'POST'), +}); +ok('NIP-98 login', login.status === 200 && login.json.pubkey, `pubkey ${login.json?.pubkey?.slice(0, 8)}…`); + +const badLogin = await api('POST', '/api/auth/login', undefined, { + authorization: nip98(`http://evil.example/api/auth/login`, 'POST'), +}); +ok('login with wrong signed URL rejected', badLogin.status === 401); + +// ---- settings: silence relays so tests never publish to public nostr relays ---- +const settings = await api('PUT', '/api/settings', { relays: [] }); +ok('admin can clear relay list', settings.status === 200 && settings.json.relays.length === 0); + +// ---- podcast ---- +const podcast = await api('POST', '/api/podcasts', { + title: 'E2E Test Show', + description: 'Automated test podcast', + author: 'e2e', + lightning_address: 'tester@getalby.com', +}); +ok('podcast created', podcast.status === 201 && podcast.json.id); +const podcastId = podcast.json.id; + +// ---- blossom upload (BUD-02, signed like the browser would) ---- +const media = readFileSync(process.env.TEST_MP4 ?? '/tmp/podpuddle-test.mp4'); +const sha256 = createHash('sha256').update(media).digest('hex'); +const now = Math.floor(Date.now() / 1000); +const auth24242 = finalizeEvent( + { + kind: 24242, + created_at: now, + content: 'Upload e2e test', + tags: [['t', 'upload'], ['x', sha256], ['expiration', String(now + 600)]], + }, + sk, +); +const up = await fetch(`${BLOSSOM}/upload`, { + method: 'PUT', + headers: { + authorization: `Nostr ${Buffer.from(JSON.stringify(auth24242)).toString('base64')}`, + 'content-type': 'video/mp4', + }, + body: media, +}); +ok('blossom BUD-02 upload', up.ok, `status ${up.status}`); + +// NOTE: blossom-server 4.x does not support range requests; check plain serving. +const head = await fetch(`${BLOSSOM}/${sha256}.mp4`, { method: 'HEAD' }); +ok('blossom serves blob by hash.ext', head.ok); + +// ---- episode registration + feed ---- +const episode = await api('POST', `/api/podcasts/${podcastId}/episodes`, { + title: 'E2E Episode', + description: 'From the e2e script', + sha256, + size: media.length, + mime: 'video/mp4', + duration_secs: 5, +}); +ok('episode registered', episode.status === 201, episode.json?.error); + +const feed = await fetch(`${BASE}/feeds/${podcastId}/feed.xml`); +const xml = await feed.text(); +ok('feed served as rss+xml', feed.ok && feed.headers.get('content-type').includes('application/rss+xml')); +ok('feed has lnaddress value block', xml.includes('method="lnaddress"') && xml.includes('tester@getalby.com')); +ok('feed has enclosure', xml.includes(`${sha256}.mp4`)); +ok('feed has podcast:guid', /[0-9a-f-]{36}<\/podcast:guid>/.test(xml)); + +// ---- stream + mediamtx auth ---- +const stream = await api('POST', '/api/streams', { title: 'E2E Live', hashtags: ['test'] }); +ok('stream created', stream.status === 201 && stream.json.streamKey); +const { id: streamId, whipBearer } = stream.json; + +const authOk = await api('POST', '/api/mediamtx/auth', { + action: 'publish', path: `live/${streamId}`, query: `key=${whipBearer}`, protocol: 'rtmp', +}); +ok('mediamtx auth accepts right key', authOk.status === 200); + +const authBad = await api('POST', '/api/mediamtx/auth', { + action: 'publish', path: `live/${streamId}`, query: 'key=wrong', protocol: 'rtmp', +}); +ok('mediamtx auth rejects wrong key', authBad.status === 401); + +console.log(JSON.stringify({ streamId, streamKey: stream.json.streamKey, podcastId })); +process.exit(failures === 0 ? 0 : 1); diff --git a/server/src/config.ts b/server/src/config.ts index addcba4..90631a0 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -12,6 +12,10 @@ const envSchema = z.object({ MEDIAMTX_WHIP_PUBLIC: z.string().url().default('http://localhost:8889'), MEDIAMTX_HLS_PUBLIC: z.string().url().default('http://localhost:8890'), BLOSSOM_URL_DEFAULT: z.string().url().default('http://localhost:8098'), + // In-network address of the bundled blossom container. Used server-side when + // the active blossom URL is the bundled default (browsers reach it via + // BLOSSOM_URL_DEFAULT, which is not resolvable inside the container). + BLOSSOM_URL_INTERNAL: z.string().url().optional(), NOSTR_RELAYS: z.string().default('wss://relay.damus.io,wss://nos.lol'), NIP98_MAX_SKEW_SECS: z.coerce.number().default(60), SESSION_TTL_DAYS: z.coerce.number().default(30), diff --git a/server/src/routes/podcasts.ts b/server/src/routes/podcasts.ts index 38c6844..9f12eb4 100644 --- a/server/src/routes/podcasts.ts +++ b/server/src/routes/podcasts.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { podcastGuidForFeedUrl } from '../services/rss.js'; -import { checkBlob } from '../services/blossom.js'; +import { checkBlob, BlossomUnreachableError } from '../services/blossom.js'; import type { Episode, Podcast } from '../types.js'; function nowSecs(): number { @@ -119,8 +119,18 @@ export default async function podcastRoutes(app: FastifyInstance) { if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); const d = parsed.data; + // Enclosure URLs use the public blossom address; verification happens over + // the container network when the bundled server is active. const blossomUrl = d.blossomUrl ?? settings.all().blossom_url; - const blob = await checkBlob(blossomUrl, d.sha256); + let blob; + try { + blob = await checkBlob(settings.blossomServerSideUrl(blossomUrl), d.sha256); + } catch (err) { + if (err instanceof BlossomUnreachableError) { + return reply.code(502).send({ error: err.message }); + } + throw err; + } if (!blob.exists) { return reply.code(422).send({ error: `blob ${d.sha256} not found on ${blossomUrl}` }); } diff --git a/server/src/routes/streams.ts b/server/src/routes/streams.ts index c59235d..ab1a1e6 100644 --- a/server/src/routes/streams.ts +++ b/server/src/routes/streams.ts @@ -163,8 +163,13 @@ export default async function streamRoutes(app: FastifyInstance) { try { await remuxToMp4(source, tmpOut); const duration = await probeDurationSecs(tmpOut).catch(() => null); - const blossomUrl = settings.all().blossom_url; - const uploaded = await uploadFile(blossomUrl, tmpOut, 'video/mp4', app.ctx.serverKey); + const publicBlossom = settings.all().blossom_url.replace(/\/+$/, ''); + const uploaded = await uploadFile( + settings.blossomServerSideUrl(publicBlossom), + tmpOut, + 'video/mp4', + app.ctx.serverKey, + ); const eid = randomUUID(); db.prepare(` @@ -172,7 +177,7 @@ export default async function streamRoutes(app: FastifyInstance) { enclosure_length, enclosure_type, duration_secs, source, pub_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'video/mp4', ?, 'recording', ?, ?) `).run(eid, d.podcast_id, d.title, d.description, uploaded.sha256, - `${uploaded.url}.mp4`, uploaded.size, duration, nowSecs(), nowSecs()); + `${publicBlossom}/${uploaded.sha256}.mp4`, uploaded.size, duration, nowSecs(), nowSecs()); db.prepare('UPDATE podcasts SET updated_at = ? WHERE id = ?').run(nowSecs(), d.podcast_id); return reply.code(201).send( db.prepare('SELECT * FROM episodes WHERE id = ?').get(eid) as Episode, diff --git a/server/src/services/blossom.ts b/server/src/services/blossom.ts index 127b892..4ec1686 100644 --- a/server/src/services/blossom.ts +++ b/server/src/services/blossom.ts @@ -7,9 +7,18 @@ export interface BlobCheck { size: number | null; } +export class BlossomUnreachableError extends Error {} + /** HEAD a blob on a blossom server to confirm it exists and matches the claimed size. */ export async function checkBlob(blossomUrl: string, sha256: string): Promise { - const res = await fetch(`${blossomUrl.replace(/\/+$/, '')}/${sha256}`, { method: 'HEAD' }); + let res: Response; + try { + res = await fetch(`${blossomUrl.replace(/\/+$/, '')}/${sha256}`, { method: 'HEAD' }); + } catch (err) { + throw new BlossomUnreachableError( + `blossom server ${blossomUrl} unreachable: ${(err as Error).message}`, + ); + } if (!res.ok) return { exists: false, size: null }; const len = res.headers.get('content-length'); return { exists: true, size: len ? Number(len) : null }; diff --git a/server/src/services/settings.ts b/server/src/services/settings.ts index 1e6029e..b55d1b1 100644 --- a/server/src/services/settings.ts +++ b/server/src/services/settings.ts @@ -33,6 +33,21 @@ export class SettingsService { }; } + /** + * Blossom URL for server-side requests (blob verification, recording uploads). + * The bundled server is published to browsers on the host port, which is not + * reachable from inside the container — use the compose-network URL for it. + */ + blossomServerSideUrl(publicBlossomUrl: string): string { + if ( + this.config.BLOSSOM_URL_INTERNAL && + publicBlossomUrl.replace(/\/+$/, '') === this.config.BLOSSOM_URL_DEFAULT.replace(/\/+$/, '') + ) { + return this.config.BLOSSOM_URL_INTERNAL; + } + return publicBlossomUrl; + } + /** First pubkey to ever log in becomes the admin. */ claimAdminIfUnset(pubkey: string): void { if (!this.get('admin_pubkey')) this.set('admin_pubkey', pubkey);