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:
+9
-9
@@ -1,5 +1,5 @@
|
|||||||
# blossom-server configuration for podpuddle.
|
# blossom-server (v4.x) configuration for podpuddle.
|
||||||
# Uploads require a signed nostr auth event (BUD-01/BUD-02, kind 24242);
|
# Uploads require a signed nostr auth event (BUD-02, kind 24242);
|
||||||
# reads are public so podcast apps can fetch enclosures.
|
# reads are public so podcast apps can fetch enclosures.
|
||||||
|
|
||||||
publicDomain: ""
|
publicDomain: ""
|
||||||
@@ -22,22 +22,22 @@ storage:
|
|||||||
local:
|
local:
|
||||||
dir: ./data/blobs
|
dir: ./data/blobs
|
||||||
removeWhenNoOwners: false
|
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:
|
upload:
|
||||||
enabled: true
|
enabled: true
|
||||||
requireAuth: true
|
requireAuth: true
|
||||||
requirePubkeyInRule: false
|
requirePubkeyInRule: false
|
||||||
|
|
||||||
media:
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
list:
|
list:
|
||||||
requireAuth: false
|
requireAuth: false
|
||||||
allowListOthers: true
|
allowListOthers: true
|
||||||
|
|
||||||
tor:
|
tor:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
proxy: ""
|
||||||
rules:
|
|
||||||
- type: "*"
|
|
||||||
expiration: 10 years
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ services:
|
|||||||
MEDIAMTX_WHIP_PUBLIC: ${MEDIAMTX_WHIP_PUBLIC:-http://localhost:8889}
|
MEDIAMTX_WHIP_PUBLIC: ${MEDIAMTX_WHIP_PUBLIC:-http://localhost:8889}
|
||||||
MEDIAMTX_HLS_PUBLIC: ${MEDIAMTX_HLS_PUBLIC:-http://localhost:8890}
|
MEDIAMTX_HLS_PUBLIC: ${MEDIAMTX_HLS_PUBLIC:-http://localhost:8890}
|
||||||
BLOSSOM_URL_DEFAULT: ${BLOSSOM_URL_DEFAULT:-http://localhost:8098}
|
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}
|
NOSTR_RELAYS: ${NOSTR_RELAYS:-wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band}
|
||||||
volumes:
|
volumes:
|
||||||
- podpuddle-data:/data
|
- podpuddle-data:/data
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ authHTTPExclude:
|
|||||||
# ---- protocols -----------------------------------------------------------
|
# ---- protocols -----------------------------------------------------------
|
||||||
rtsp: no
|
rtsp: no
|
||||||
srt: no
|
srt: no
|
||||||
|
moq: no
|
||||||
|
|
||||||
rtmp: yes
|
rtmp: yes
|
||||||
rtmpAddress: :1935
|
rtmpAddress: :1935
|
||||||
@@ -26,12 +27,12 @@ hls: yes
|
|||||||
hlsAddress: :8888
|
hlsAddress: :8888
|
||||||
hlsVariant: lowLatency
|
hlsVariant: lowLatency
|
||||||
hlsAlwaysRemux: yes
|
hlsAlwaysRemux: yes
|
||||||
hlsAllowOrigin: "*"
|
hlsAllowOrigins: ["*"]
|
||||||
|
|
||||||
webrtc: yes
|
webrtc: yes
|
||||||
webrtcAddress: :8889
|
webrtcAddress: :8889
|
||||||
webrtcLocalUDPAddress: :8189
|
webrtcLocalUDPAddress: :8189
|
||||||
webrtcAllowOrigin: "*"
|
webrtcAllowOrigins: ["*"]
|
||||||
|
|
||||||
# ---- recording -----------------------------------------------------------
|
# ---- recording -----------------------------------------------------------
|
||||||
pathDefaults:
|
pathDefaults:
|
||||||
|
|||||||
+152
@@ -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);
|
||||||
@@ -12,6 +12,10 @@ const envSchema = z.object({
|
|||||||
MEDIAMTX_WHIP_PUBLIC: z.string().url().default('http://localhost:8889'),
|
MEDIAMTX_WHIP_PUBLIC: z.string().url().default('http://localhost:8889'),
|
||||||
MEDIAMTX_HLS_PUBLIC: z.string().url().default('http://localhost:8890'),
|
MEDIAMTX_HLS_PUBLIC: z.string().url().default('http://localhost:8890'),
|
||||||
BLOSSOM_URL_DEFAULT: z.string().url().default('http://localhost:8098'),
|
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'),
|
NOSTR_RELAYS: z.string().default('wss://relay.damus.io,wss://nos.lol'),
|
||||||
NIP98_MAX_SKEW_SECS: z.coerce.number().default(60),
|
NIP98_MAX_SKEW_SECS: z.coerce.number().default(60),
|
||||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { podcastGuidForFeedUrl } from '../services/rss.js';
|
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';
|
import type { Episode, Podcast } from '../types.js';
|
||||||
|
|
||||||
function nowSecs(): number {
|
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 });
|
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||||
const d = parsed.data;
|
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 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) {
|
if (!blob.exists) {
|
||||||
return reply.code(422).send({ error: `blob ${d.sha256} not found on ${blossomUrl}` });
|
return reply.code(422).send({ error: `blob ${d.sha256} not found on ${blossomUrl}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,8 +163,13 @@ export default async function streamRoutes(app: FastifyInstance) {
|
|||||||
try {
|
try {
|
||||||
await remuxToMp4(source, tmpOut);
|
await remuxToMp4(source, tmpOut);
|
||||||
const duration = await probeDurationSecs(tmpOut).catch(() => null);
|
const duration = await probeDurationSecs(tmpOut).catch(() => null);
|
||||||
const blossomUrl = settings.all().blossom_url;
|
const publicBlossom = settings.all().blossom_url.replace(/\/+$/, '');
|
||||||
const uploaded = await uploadFile(blossomUrl, tmpOut, 'video/mp4', app.ctx.serverKey);
|
const uploaded = await uploadFile(
|
||||||
|
settings.blossomServerSideUrl(publicBlossom),
|
||||||
|
tmpOut,
|
||||||
|
'video/mp4',
|
||||||
|
app.ctx.serverKey,
|
||||||
|
);
|
||||||
|
|
||||||
const eid = randomUUID();
|
const eid = randomUUID();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
@@ -172,7 +177,7 @@ export default async function streamRoutes(app: FastifyInstance) {
|
|||||||
enclosure_length, enclosure_type, duration_secs, source, pub_date, created_at)
|
enclosure_length, enclosure_type, duration_secs, source, pub_date, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'video/mp4', ?, 'recording', ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'video/mp4', ?, 'recording', ?, ?)
|
||||||
`).run(eid, d.podcast_id, d.title, d.description, uploaded.sha256,
|
`).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);
|
db.prepare('UPDATE podcasts SET updated_at = ? WHERE id = ?').run(nowSecs(), d.podcast_id);
|
||||||
return reply.code(201).send(
|
return reply.code(201).send(
|
||||||
db.prepare('SELECT * FROM episodes WHERE id = ?').get(eid) as Episode,
|
db.prepare('SELECT * FROM episodes WHERE id = ?').get(eid) as Episode,
|
||||||
|
|||||||
@@ -7,9 +7,18 @@ export interface BlobCheck {
|
|||||||
size: number | null;
|
size: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BlossomUnreachableError extends Error {}
|
||||||
|
|
||||||
/** HEAD a blob on a blossom server to confirm it exists and matches the claimed size. */
|
/** 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> {
|
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 };
|
if (!res.ok) return { exists: false, size: null };
|
||||||
const len = res.headers.get('content-length');
|
const len = res.headers.get('content-length');
|
||||||
return { exists: true, size: len ? Number(len) : null };
|
return { exists: true, size: len ? Number(len) : null };
|
||||||
|
|||||||
@@ -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. */
|
/** First pubkey to ever log in becomes the admin. */
|
||||||
claimAdminIfUnset(pubkey: string): void {
|
claimAdminIfUnset(pubkey: string): void {
|
||||||
if (!this.get('admin_pubkey')) this.set('admin_pubkey', pubkey);
|
if (!this.get('admin_pubkey')) this.set('admin_pubkey', pubkey);
|
||||||
|
|||||||
Reference in New Issue
Block a user