fix: blossom v4 config schema, container-internal blossom URL, e2e suite

- blossom-server 4.4.1 expects rules nested under storage: — the top-level
  rules list was ignored, leaving an empty ruleset that rejected all uploads
- podpuddle now verifies blobs and uploads recordings via BLOSSOM_URL_INTERNAL
  (http://blossom:3000) while feeds keep the browser-facing URL; unreachable
  blossom now returns 502 instead of a 500
- mediamtx: fix deprecated allow-origin params, disable MoQ
- scripts/e2e.mjs: 14 API checks (login, replay/URL rejection, upload,
  episode, feed, stream keys, mediamtx auth) — all green, plus verified
  live: RTMP publish with key → HLS 200, wrong key refused, status
  planned→live→ended via poller, recording remuxed + published as episode,
  feed XML well-formed with 2 items

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 19:21:03 +00:00
co-authored by Claude Fable 5
parent 5216c6451e
commit 6dd541b1a4
9 changed files with 214 additions and 17 deletions
+9 -9
View File
@@ -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: ""
+1
View File
@@ -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
+3 -2
View File
@@ -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:
+152
View File
@@ -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', /<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);
+4
View File
@@ -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),
+12 -2
View File
@@ -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}` });
}
+8 -3
View File
@@ -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,
+10 -1
View File
@@ -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<BlobCheck> {
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 };
+15
View File
@@ -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);