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
+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);