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:
@@ -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),
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user