feat(server): podpuddle scaffold + Fastify backend (nostr auth, RSS, streams)

- docker-compose stack: podpuddle + MediaMTX (RTMP/WHIP/HLS) + blossom-server
- NIP-98 nostr-only login with session cookies, replay guard, clock-skew window
- podcasts/episodes CRUD; episodes register browser-uploaded blossom blobs
- RSS 2.0 + itunes + podcast namespace feeds with lnaddress value blocks
  (podcast:guid UUIDv5 verified against the spec vector)
- streams API with hashed stream keys; MediaMTX http-auth webhook
  (query/password/bearer forms); API poller flips live/ended status
- NIP-53 kind 30311 live events published with the server's nostr identity
- recordings: ffmpeg remux + server-key blossom upload → podcast episode
- 35 vitest tests green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 19:01:38 +00:00
co-authored by Claude Fable 5
commit 594a9a8783
35 changed files with 5655 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Hostname or IP your users will reach this box on (no scheme, no port)
PUBLIC_HOST=localhost
# Where the podpuddle UI/API is reachable from browsers
PUBLIC_URL=http://${PUBLIC_HOST}:8095
# Public endpoints handed out for streaming / playback / uploads
MEDIAMTX_RTMP_PUBLIC=rtmp://${PUBLIC_HOST}:1935
MEDIAMTX_WHIP_PUBLIC=http://${PUBLIC_HOST}:8889
MEDIAMTX_HLS_PUBLIC=http://${PUBLIC_HOST}:8890
BLOSSOM_URL_DEFAULT=http://${PUBLIC_HOST}:8098
# Default nostr relays for NIP-53 live-event announcements (comma separated,
# changeable at runtime in Settings)
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.env
data/
.DS_Store
+37
View File
@@ -0,0 +1,37 @@
# ---- frontend ----
FROM node:22-bookworm-slim AS frontend-build
WORKDIR /build/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ---- server ----
FROM node:22-bookworm-slim AS server-build
WORKDIR /build/server
COPY server/package*.json ./
RUN npm ci
COPY server/ ./
RUN npm run build && npm prune --omit=dev
# ---- runtime ----
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=server-build /build/server/node_modules ./node_modules
COPY --from=server-build /build/server/package.json ./package.json
COPY --from=server-build /build/server/dist ./dist
COPY --from=frontend-build /build/frontend/dist ./public
# Named volumes inherit ownership from the image path: keep /data writable by node
RUN mkdir -p /data && chown node:node /data
USER node
ENV NODE_ENV=production \
PORT=8095 \
DATA_DIR=/data \
STATIC_DIR=/app/public
EXPOSE 8095
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://localhost:8095/api/health || exit 1
CMD ["node", "dist/index.js"]
+59
View File
@@ -0,0 +1,59 @@
# podpuddle
Self-hosted, nostr-native podcast publishing and livestreaming.
- **Log in with nostr** — NIP-07 browser extension (Alby, nos2x, …); the server verifies
NIP-98 signed requests. No passwords, no email.
- **Upload an mp4** → it is stored on a [Blossom](https://github.com/hzrd149/blossom) server
(bundled, or point at any external one) and published in an RSS 2.0 podcast feed with
Podcasting 2.0 `<podcast:value>` lightning-address payment info.
- **Go live** — stream from OBS (RTMP) or straight from the browser (WebRTC/WHIP) through
MediaMTX; viewers watch via HLS and the stream is announced on nostr as a NIP-53
(kind 30311) live event.
- **Publish recordings** — live streams are recorded; one click remuxes and publishes a
recording as a podcast episode.
## Quick start
```sh
cp .env.example .env # edit PUBLIC_HOST if not localhost
docker compose up --build -d
```
Then open http://localhost:8095, log in with a NIP-07 extension, and follow the wizard.
## Services / ports
| Service | Host port | Purpose |
|---|---|---|
| podpuddle | 8095 | Web UI + API + RSS feeds |
| MediaMTX | 1935 | RTMP ingest (OBS) |
| MediaMTX | 8889 (+8189/udp) | WebRTC/WHIP ingest (browser) |
| MediaMTX | 8890 | HLS playback |
| blossom-server | 8098 | Media blob storage (sha256-addressed) |
## Streaming with OBS
Create a stream in the UI; it gives you:
- Server: `rtmp://<host>:1935/live`
- Stream key: `<streamId>?key=<secret>`
The HLS playback URL (`http://<host>:8890/live/<streamId>/index.m3u8`) is public and never
contains the secret.
## Development
```sh
docker compose up mediamtx blossom -d # backends
cd server && npm install && npm run dev # API on :8095
cd frontend && npm install && npm run dev # Vite on :5173, proxies /api + /feeds
```
Tests: `cd server && npm test`.
## Data
Everything lives in named docker volumes: `podpuddle-data` (SQLite, server nostr key,
covers), `mediamtx-recordings` (stream recordings, 7-day retention), `blossom-data`
(media blobs).
+43
View File
@@ -0,0 +1,43 @@
# blossom-server configuration for podpuddle.
# Uploads require a signed nostr auth event (BUD-01/BUD-02, kind 24242);
# reads are public so podcast apps can fetch enclosures.
publicDomain: ""
databasePath: data/sqlite.db
dashboard:
enabled: false
discovery:
nostr:
enabled: false
relays: []
upstream:
enabled: false
domains: []
storage:
backend: local
local:
dir: ./data/blobs
removeWhenNoOwners: false
upload:
enabled: true
requireAuth: true
requirePubkeyInRule: false
media:
enabled: false
list:
requireAuth: false
allowListOthers: true
tor:
enabled: false
rules:
- type: "*"
expiration: 10 years
+58
View File
@@ -0,0 +1,58 @@
name: podpuddle
services:
podpuddle:
build: .
container_name: podpuddle
restart: unless-stopped
ports:
- "8095:8095"
environment:
NODE_ENV: production
PORT: "8095"
DATA_DIR: /data
RECORDINGS_DIR: /recordings
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8095}
MEDIAMTX_API_URL: http://mediamtx:9997
MEDIAMTX_RTMP_PUBLIC: ${MEDIAMTX_RTMP_PUBLIC:-rtmp://localhost:1935}
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}
NOSTR_RELAYS: ${NOSTR_RELAYS:-wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band}
volumes:
- podpuddle-data:/data
- mediamtx-recordings:/recordings:ro
depends_on:
- mediamtx
- blossom
mediamtx:
image: bluenviron/mediamtx:1.19.2
container_name: podpuddle-mediamtx
restart: unless-stopped
ports:
- "1935:1935" # RTMP ingest
- "8889:8889" # WebRTC / WHIP
- "8189:8189/udp" # WebRTC ICE
- "8890:8888" # HLS (host 8890; 8888 kept free for other apps)
environment:
# Browsers need a reachable ICE host candidate; set PUBLIC_HOST in .env
MTX_WEBRTCADDITIONALHOSTS: ${PUBLIC_HOST:-localhost}
volumes:
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
- mediamtx-recordings:/recordings
blossom:
image: ghcr.io/hzrd149/blossom-server:4
container_name: podpuddle-blossom
restart: unless-stopped
ports:
- "8098:3000"
volumes:
- ./blossom/config.yml:/app/config.yml:ro
- blossom-data:/app/data
volumes:
podpuddle-data:
mediamtx-recordings:
blossom-data:
+48
View File
@@ -0,0 +1,48 @@
# MediaMTX configuration for podpuddle.
# Ingest: RTMP (OBS) + WebRTC/WHIP (browser). Output: HLS. Publish auth is
# delegated to podpuddle via HTTP; stream status is polled from the API.
logLevel: info
api: yes
apiAddress: :9997
# ---- authentication ------------------------------------------------------
authMethod: http
authHTTPAddress: http://podpuddle:8095/api/mediamtx/auth
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
# ---- protocols -----------------------------------------------------------
rtsp: no
srt: no
rtmp: yes
rtmpAddress: :1935
hls: yes
hlsAddress: :8888
hlsVariant: lowLatency
hlsAlwaysRemux: yes
hlsAllowOrigin: "*"
webrtc: yes
webrtcAddress: :8889
webrtcLocalUDPAddress: :8189
webrtcAllowOrigin: "*"
# ---- recording -----------------------------------------------------------
pathDefaults:
record: yes
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
recordFormat: fmp4
recordPartDuration: 1s
recordSegmentDuration: 1h
recordDeleteAfter: 168h
paths:
# Streams live at live/<streamId>; publish requires the stream secret,
# which podpuddle checks in the auth webhook.
"~^live/[A-Za-z0-9]+$": {}
+3313
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "podpuddle-server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.1.1",
"better-sqlite3": "^12.2.0",
"fastify": "^5.4.0",
"fastify-plugin": "^5.1.0",
"nostr-tools": "^2.15.0",
"ws": "^8.18.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.0",
"@types/ws": "^8.18.0",
"tsx": "^4.20.0",
"typescript": "^5.9.0",
"vitest": "^3.2.0"
}
}
+298
View File
@@ -0,0 +1,298 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import type { FastifyInstance } from 'fastify';
import { buildApp } from './app.js';
import { loadConfig } from './config.js';
const sk = generateSecretKey();
const pk = getPublicKey(sk);
let app: FastifyInstance;
let dataDir: string;
let cookie: string;
function nip98Header(url: string, method: string): string {
const event = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
// nonce keeps event ids unique when tests sign several events in one second
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
},
sk,
);
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
}
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'podpuddle-test-'));
const config = loadConfig({
DATA_DIR: dataDir,
PUBLIC_URL: 'http://localhost:8095',
NOSTR_RELAYS: '', // no relay publishing in tests
} as NodeJS.ProcessEnv);
app = await buildApp({ config, dbPath: ':memory:', logger: false });
});
afterAll(async () => {
await app.close();
rmSync(dataDir, { recursive: true, force: true });
});
describe('auth', () => {
it('rejects unauthenticated /api/auth/me', async () => {
const res = await app.inject({ method: 'GET', url: '/api/auth/me' });
expect(res.statusCode).toBe(401);
});
it('logs in with a NIP-98 header and sets a session cookie', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') },
});
expect(res.statusCode).toBe(200);
expect(res.json().pubkey).toBe(pk);
expect(res.json().isAdmin).toBe(true); // first login claims admin
const setCookie = res.headers['set-cookie'] as string;
expect(setCookie).toContain('podpuddle_session=');
cookie = setCookie.split(';')[0];
});
it('rejects a replayed login header', async () => {
const header = nip98Header('http://localhost:8095/api/auth/login', 'POST');
const first = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: header } });
expect(first.statusCode).toBe(200);
const second = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: header } });
expect(second.statusCode).toBe(401);
expect(second.json().error).toMatch(/already used/);
});
it('serves /api/auth/me with the session cookie', async () => {
const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } });
expect(res.statusCode).toBe(200);
expect(res.json().pubkey).toBe(pk);
});
});
describe('podcasts, episodes, feed', () => {
let podcastId: string;
const sha = 'c'.repeat(64);
it('creates a podcast', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/podcasts',
headers: { cookie },
payload: {
title: 'My Show',
description: 'About things',
author: 'Tester',
lightning_address: 'tester@getalby.com',
},
});
expect(res.statusCode).toBe(201);
podcastId = res.json().id;
expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/);
});
it('registers an episode after verifying the blob on blossom', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(null, { status: 200, headers: { 'content-length': '1000' } }),
),
);
try {
const res = await app.inject({
method: 'POST',
url: `/api/podcasts/${podcastId}/episodes`,
headers: { cookie },
payload: { title: 'Ep 1', sha256: sha, size: 1000, mime: 'video/mp4' },
});
expect(res.statusCode).toBe(201);
expect(res.json().enclosure_url).toContain(`${sha}.mp4`);
} finally {
vi.unstubAllGlobals();
}
});
it('rejects an episode whose blob size mismatches', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(null, { status: 200, headers: { 'content-length': '999' } }),
),
);
try {
const res = await app.inject({
method: 'POST',
url: `/api/podcasts/${podcastId}/episodes`,
headers: { cookie },
payload: { title: 'Bad', sha256: 'd'.repeat(64), size: 1000 },
});
expect(res.statusCode).toBe(422);
} finally {
vi.unstubAllGlobals();
}
});
it('serves a valid feed with the lnaddress value block', async () => {
const res = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('application/rss+xml');
expect(res.body).toContain('method="lnaddress"');
expect(res.body).toContain('tester@getalby.com');
expect(res.body).toContain(`${sha}.mp4`);
const etag = res.headers.etag as string;
const cached = await app.inject({
method: 'GET',
url: `/feeds/${podcastId}/feed.xml`,
headers: { 'if-none-match': etag },
});
expect(cached.statusCode).toBe(304);
});
});
describe('streams + mediamtx auth webhook', () => {
let streamId: string;
let streamKey: string;
it('creates a stream and returns the key once', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/streams',
headers: { cookie },
payload: { title: 'Live Test', summary: 'hi', hashtags: ['podpuddle'] },
});
expect(res.statusCode).toBe(201);
const body = res.json();
streamId = body.id;
streamKey = body.whipBearer;
expect(body.streamKey).toBe(`${streamId}?key=${streamKey}`);
expect(body.hlsUrl).toContain(`/live/${streamId}/index.m3u8`);
expect(body.stream_key_hash).toBeUndefined(); // never leak the hash
});
it('allows publish with the right key (query form, as OBS sends it)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, query: `key=${streamKey}`, protocol: 'rtmp' },
});
expect(res.statusCode).toBe(200);
});
it('allows publish with the key as WHIP bearer password', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, password: streamKey, protocol: 'webrtc' },
});
expect(res.statusCode).toBe(200);
});
it('denies publish with a wrong key', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, query: 'key=wrong', protocol: 'rtmp' },
});
expect(res.statusCode).toBe(401);
});
it('denies publish to an unknown path', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: 'live/nosuchstream', query: 'key=x' },
});
expect(res.statusCode).toBe(401);
});
it('allows reads without a key', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'read', path: `live/${streamId}` },
});
expect(res.statusCode).toBe(200);
});
it('rotating the key invalidates the old one', async () => {
const rot = await app.inject({
method: 'POST',
url: `/api/streams/${streamId}/rotate-key`,
headers: { cookie },
});
expect(rot.statusCode).toBe(200);
const oldKey = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, query: `key=${streamKey}` },
});
expect(oldKey.statusCode).toBe(401);
const newKey = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, query: `key=${rot.json().whipBearer}` },
});
expect(newKey.statusCode).toBe(200);
});
});
describe('settings', () => {
it('exposes public settings without auth', async () => {
const res = await app.inject({ method: 'GET', url: '/api/settings/public' });
expect(res.statusCode).toBe(200);
expect(res.json().blossomUrl).toBeTruthy();
expect(res.json().serverPubkey).toMatch(/^[0-9a-f]{64}$/);
});
it('lets the admin switch to an external blossom server', async () => {
const res = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie },
payload: { blossom_url: 'https://blossom.example.com' },
});
expect(res.statusCode).toBe(200);
expect(res.json().blossom_url).toBe('https://blossom.example.com');
const pub = await app.inject({ method: 'GET', url: '/api/settings/public' });
expect(pub.json().blossomUrl).toBe('https://blossom.example.com');
});
it('blocks settings changes from non-admin users', async () => {
const sk2 = generateSecretKey();
const event = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [['u', 'http://localhost:8095/api/auth/login'], ['method', 'POST']],
},
sk2,
);
const login = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` },
});
expect(login.statusCode).toBe(200);
expect(login.json().isAdmin).toBe(false);
const cookie2 = (login.headers['set-cookie'] as string).split(';')[0];
const res = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie: cookie2 },
payload: { blossom_url: 'https://evil.example.com' },
});
expect(res.statusCode).toBe(403);
});
});
+91
View File
@@ -0,0 +1,91 @@
import Fastify, { type FastifyInstance } from 'fastify';
import cookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Config } from './config.js';
import { openDatabase, type DB } from './db/database.js';
import nostrAuth from './plugins/nostr-auth.js';
import { SettingsService } from './services/settings.js';
import { NostrPublisher, loadServerKey } from './services/nostr.js';
import { MediamtxClient } from './services/mediamtx.js';
import authRoutes from './routes/auth.js';
import settingsRoutes from './routes/settings.js';
import podcastRoutes from './routes/podcasts.js';
import feedRoutes from './routes/feeds.js';
import streamRoutes from './routes/streams.js';
import mediamtxRoutes from './routes/mediamtx.js';
export interface AppContext {
config: Config;
db: DB;
settings: SettingsService;
publisher: NostrPublisher;
mediamtx: MediamtxClient;
serverKey: Uint8Array;
}
declare module 'fastify' {
interface FastifyInstance {
ctx: AppContext;
}
}
export interface BuildAppOptions {
config: Config;
dbPath?: string; // override for tests (':memory:')
logger?: boolean;
}
export async function buildApp(opts: BuildAppOptions): Promise<FastifyInstance> {
const { config } = opts;
const db = openDatabase(opts.dbPath ?? join(config.DATA_DIR, 'podpuddle.sqlite3'));
const settings = new SettingsService(db, config);
const serverKey = loadServerKey(config.DATA_DIR);
const app = Fastify({ logger: opts.logger ?? true, bodyLimit: 10 * 1024 * 1024 });
const ctx: AppContext = {
config,
db,
settings,
publisher: new NostrPublisher(serverKey, {
info: (m) => app.log.info(m),
warn: (m) => app.log.warn(m),
}),
mediamtx: new MediamtxClient(config.MEDIAMTX_API_URL),
serverKey,
};
app.decorate('ctx', ctx);
await app.register(cookie);
await app.register(nostrAuth, { db, config });
app.get('/api/health', async () => ({ status: 'ok' }));
await app.register(authRoutes);
await app.register(settingsRoutes);
await app.register(podcastRoutes);
await app.register(feedRoutes);
await app.register(streamRoutes);
await app.register(mediamtxRoutes);
// Serve the built frontend (SPA fallback for client-side routes).
const staticDir = config.STATIC_DIR;
if (staticDir && existsSync(staticDir)) {
await app.register(fastifyStatic, { root: staticDir });
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/api/') || req.raw.url?.startsWith('/feeds/')) {
return reply.code(404).send({ error: 'not found' });
}
return reply.sendFile('index.html');
});
}
app.addHook('onClose', async () => {
ctx.publisher.close(settings.all().relays);
db.close();
});
return app;
}
+31
View File
@@ -0,0 +1,31 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(8095),
HOST: z.string().default('0.0.0.0'),
DATA_DIR: z.string().default('./data'),
RECORDINGS_DIR: z.string().default('/recordings'),
STATIC_DIR: z.string().optional(),
PUBLIC_URL: z.string().url().default('http://localhost:8095'),
MEDIAMTX_API_URL: z.string().url().default('http://mediamtx:9997'),
MEDIAMTX_RTMP_PUBLIC: z.string().default('rtmp://localhost:1935'),
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'),
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),
MEDIAMTX_POLL_INTERVAL_MS: z.coerce.number().default(3000),
});
export type Config = z.infer<typeof envSchema> & { defaultRelays: string[] };
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const parsed = envSchema.parse(env);
return {
...parsed,
defaultRelays: parsed.NOSTR_RELAYS.split(',')
.map((r) => r.trim())
.filter(Boolean),
};
}
+30
View File
@@ -0,0 +1,30 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { migrations } from './migrations.js';
export type DB = Database.Database;
export function openDatabase(path: string): DB {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
migrate(db);
return db;
}
function migrate(db: DB): void {
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)');
const applied = new Set(
db.prepare('SELECT id FROM schema_migrations').all().map((r) => (r as { id: number }).id),
);
const record = db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)');
for (const m of migrations) {
if (applied.has(m.id)) continue;
db.transaction(() => {
db.exec(m.sql);
record.run(m.id, Math.floor(Date.now() / 1000));
})();
}
}
+95
View File
@@ -0,0 +1,95 @@
export interface Migration {
id: number;
sql: string;
}
export const migrations: Migration[] = [
{
id: 1,
sql: `
CREATE TABLE users (
pubkey TEXT PRIMARY KEY,
display_name TEXT,
lud16 TEXT,
created_at INTEGER NOT NULL,
last_login_at INTEGER NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL REFERENCES users(pubkey),
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE podcasts (
id TEXT PRIMARY KEY,
owner_pubkey TEXT NOT NULL REFERENCES users(pubkey),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
image_url TEXT,
language TEXT NOT NULL DEFAULT 'en',
category TEXT NOT NULL DEFAULT 'Technology',
explicit INTEGER NOT NULL DEFAULT 0,
lightning_address TEXT,
keysend_node TEXT,
value_suggested TEXT,
podcast_guid TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE episodes (
id TEXT PRIMARY KEY,
podcast_id TEXT NOT NULL REFERENCES podcasts(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
sha256 TEXT NOT NULL,
enclosure_url TEXT NOT NULL,
enclosure_length INTEGER NOT NULL,
enclosure_type TEXT NOT NULL DEFAULT 'video/mp4',
duration_secs INTEGER,
season INTEGER,
episode_no INTEGER,
source TEXT NOT NULL DEFAULT 'upload' CHECK (source IN ('upload','recording')),
pub_date INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_episodes_podcast ON episodes(podcast_id, pub_date DESC);
CREATE TABLE streams (
id TEXT PRIMARY KEY,
owner_pubkey TEXT NOT NULL REFERENCES users(pubkey),
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
image_url TEXT,
hashtags TEXT NOT NULL DEFAULT '[]',
stream_key_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned','live','ended')),
starts_at INTEGER,
ended_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE recordings (
id TEXT PRIMARY KEY,
stream_id TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
segment_path TEXT NOT NULL UNIQUE,
started_at INTEGER NOT NULL,
duration_secs INTEGER,
published_episode_id TEXT REFERENCES episodes(id)
);
CREATE TABLE auth_events (
event_id TEXT PRIMARY KEY,
seen_at INTEGER NOT NULL
);
`,
},
];
+50
View File
@@ -0,0 +1,50 @@
import { loadConfig } from './config.js';
import { buildApp } from './app.js';
import { startStatusPoller } from './services/mediamtx.js';
import type { Stream } from './types.js';
const config = loadConfig();
const app = await buildApp({ config });
const { db, publisher, settings, mediamtx } = app.ctx;
const getStream = db.prepare('SELECT * FROM streams WHERE id = ?');
const liveIds = db.prepare("SELECT id FROM streams WHERE status = 'live'");
const stopPoller = startStatusPoller(
mediamtx,
config.MEDIAMTX_POLL_INTERVAL_MS,
() => (liveIds.all() as { id: string }[]).map((r) => r.id),
({ streamId, live }) => {
const stream = getStream.get(streamId) as Stream | undefined;
if (!stream) return;
const now = Math.floor(Date.now() / 1000);
if (live && stream.status !== 'live') {
db.prepare("UPDATE streams SET status = 'live', starts_at = COALESCE(starts_at, ?) WHERE id = ?")
.run(now, streamId);
} else if (!live && stream.status === 'live') {
db.prepare("UPDATE streams SET status = 'ended', ended_at = ? WHERE id = ?").run(now, streamId);
} else {
return;
}
const updated = getStream.get(streamId) as Stream;
const hlsUrl = `${config.MEDIAMTX_HLS_PUBLIC}/live/${streamId}/index.m3u8`;
void publisher
.publishLiveEvent(settings.all().relays, {
stream: updated,
status: live ? 'live' : 'ended',
hlsUrl,
})
.catch((err) => app.log.warn(`30311 publish failed: ${err.message}`));
app.log.info(`stream ${streamId} is now ${live ? 'live' : 'ended'}`);
},
{ warn: (m) => app.log.debug(m) },
);
app.addHook('onClose', async () => stopPoller());
try {
await app.listen({ port: config.PORT, host: config.HOST });
} catch (err) {
app.log.error(err);
process.exit(1);
}
+114
View File
@@ -0,0 +1,114 @@
import { randomBytes } from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import type { DB } from '../db/database.js';
import type { Config } from '../config.js';
import { Nip98Error, verifyNip98 } from '../services/nip98.js';
export const SESSION_COOKIE = 'podpuddle_session';
declare module 'fastify' {
interface FastifyInstance {
requireAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
verifyNip98Request: (req: FastifyRequest) => string;
createSession: (pubkey: string, reply: FastifyReply) => void;
destroySession: (req: FastifyRequest, reply: FastifyReply) => void;
}
}
function nowSecs(): number {
return Math.floor(Date.now() / 1000);
}
export interface NostrAuthOptions {
db: DB;
config: Config;
}
export default fp(async function nostrAuth(app: FastifyInstance, opts: NostrAuthOptions) {
const { db, config } = opts;
const insertAuthEvent = db.prepare('INSERT OR IGNORE INTO auth_events (event_id, seen_at) VALUES (?, ?)');
const pruneAuthEvents = db.prepare('DELETE FROM auth_events WHERE seen_at < ?');
const insertSession = db.prepare('INSERT INTO sessions (id, pubkey, created_at, expires_at) VALUES (?, ?, ?, ?)');
const selectSession = db.prepare('SELECT pubkey, expires_at FROM sessions WHERE id = ?');
const deleteSession = db.prepare('DELETE FROM sessions WHERE id = ?');
const pruneSessions = db.prepare('DELETE FROM sessions WHERE expires_at < ?');
app.decorateRequest('userPubkey', null);
/** Candidate URLs the client may have signed: configured public URL + the Host header it used. */
function candidateUrls(req: FastifyRequest): string[] {
const publicOrigin = new URL(config.PUBLIC_URL).origin;
const urls = [`${publicOrigin}${req.raw.url}`];
const host = req.headers['x-forwarded-host'] ?? req.headers.host;
if (host) {
const proto = (req.headers['x-forwarded-proto'] as string | undefined) ?? 'http';
urls.push(`${proto}://${host}${req.raw.url}`);
}
return urls;
}
function verifyNip98Request(req: FastifyRequest): string {
const body = req.body != null && typeof req.body === 'object'
? Buffer.from(JSON.stringify(req.body))
: null;
const pubkey = verifyNip98(req.headers.authorization, {
allowedUrls: candidateUrls(req),
method: req.method,
body,
maxSkewSecs: config.NIP98_MAX_SKEW_SECS,
isReplay: (eventId) => {
pruneAuthEvents.run(nowSecs() - config.NIP98_MAX_SKEW_SECS * 4);
const inserted = insertAuthEvent.run(eventId, nowSecs()).changes;
return inserted === 0;
},
});
return pubkey;
}
app.decorate('verifyNip98Request', verifyNip98Request);
app.decorate('createSession', (pubkey: string, reply: FastifyReply) => {
pruneSessions.run(nowSecs());
const id = randomBytes(32).toString('hex');
insertSession.run(id, pubkey, nowSecs(), nowSecs() + config.SESSION_TTL_DAYS * 86400);
reply.setCookie(SESSION_COOKIE, id, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: config.SESSION_TTL_DAYS * 86400,
});
});
app.decorate('destroySession', (req: FastifyRequest, reply: FastifyReply) => {
const id = req.cookies[SESSION_COOKIE];
if (id) deleteSession.run(id);
reply.clearCookie(SESSION_COOKIE, { path: '/' });
});
app.decorate('requireAuth', async (req: FastifyRequest, reply: FastifyReply) => {
// Session cookie first (the normal browser path)…
const sessionId = req.cookies[SESSION_COOKIE];
if (sessionId) {
const row = selectSession.get(sessionId) as { pubkey: string; expires_at: number } | undefined;
if (row && row.expires_at > nowSecs()) {
req.userPubkey = row.pubkey;
return;
}
}
// …then a NIP-98 header (CLI / scripting path).
if (req.headers.authorization?.startsWith('Nostr ')) {
try {
req.userPubkey = verifyNip98Request(req);
return;
} catch (err) {
if (err instanceof Nip98Error) {
return reply.code(401).send({ error: err.message });
}
throw err;
}
}
return reply.code(401).send({ error: 'not authenticated' });
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { FastifyInstance } from 'fastify';
import { Nip98Error } from '../services/nip98.js';
import type { User } from '../types.js';
function nowSecs(): number {
return Math.floor(Date.now() / 1000);
}
export default async function authRoutes(app: FastifyInstance) {
const { db, settings } = app.ctx;
const upsertUser = db.prepare(`
INSERT INTO users (pubkey, created_at, last_login_at) VALUES (?, ?, ?)
ON CONFLICT(pubkey) DO UPDATE SET last_login_at = excluded.last_login_at
`);
const selectUser = db.prepare('SELECT * FROM users WHERE pubkey = ?');
const updateProfile = db.prepare('UPDATE users SET display_name = ?, lud16 = ? WHERE pubkey = ?');
app.post('/api/auth/login', async (req, reply) => {
let pubkey: string;
try {
pubkey = app.verifyNip98Request(req);
} catch (err) {
if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message });
throw err;
}
upsertUser.run(pubkey, nowSecs(), nowSecs());
settings.claimAdminIfUnset(pubkey);
// Optional profile hints from the client (it has the user's kind-0 metadata).
const body = req.body as { displayName?: string; lud16?: string } | null;
if (body && (body.displayName || body.lud16)) {
const existing = selectUser.get(pubkey) as User;
updateProfile.run(
body.displayName ?? existing.display_name,
body.lud16 ?? existing.lud16,
pubkey,
);
}
app.createSession(pubkey, reply);
return { pubkey, isAdmin: settings.isAdmin(pubkey) };
});
app.post('/api/auth/logout', async (req, reply) => {
app.destroySession(req, reply);
return { ok: true };
});
app.get('/api/auth/me', { preHandler: app.requireAuth }, async (req) => {
const user = selectUser.get(req.userPubkey) as User | undefined;
return {
pubkey: req.userPubkey,
displayName: user?.display_name ?? null,
lud16: user?.lud16 ?? null,
isAdmin: settings.isAdmin(req.userPubkey!),
};
});
}
+28
View File
@@ -0,0 +1,28 @@
import { createHash } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { buildFeedXml } from '../services/rss.js';
import type { Episode, Podcast } from '../types.js';
export default async function feedRoutes(app: FastifyInstance) {
const { db, settings } = app.ctx;
const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?');
const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? ORDER BY pub_date DESC');
app.get('/feeds/:id/feed.xml', async (req, reply) => {
const { id } = req.params as { id: string };
const podcast = getPodcast.get(id) as Podcast | undefined;
if (!podcast) return reply.code(404).send({ error: 'feed not found' });
const episodes = listEpisodes.all(id) as Episode[];
const xml = buildFeedXml(podcast, episodes, { publicUrl: settings.all().public_url });
const etag = `"${createHash('sha256').update(xml).digest('hex').slice(0, 16)}"`;
if (req.headers['if-none-match'] === etag) return reply.code(304).send();
return reply
.header('content-type', 'application/rss+xml; charset=utf-8')
.header('etag', etag)
.header('cache-control', 'public, max-age=60')
.send(xml);
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import { verifyStreamKey } from '../services/streamkeys.js';
import type { Stream } from '../types.js';
interface AuthRequest {
user?: string;
password?: string;
token?: string;
ip?: string;
action?: string;
path?: string;
protocol?: string;
query?: string;
}
/**
* MediaMTX `authMethod: http` webhook. 2xx allows the connection, anything else denies.
* Publishing to live/<streamId> requires the stream secret (query `?key=`, password,
* or WHIP bearer token); reading (HLS/WebRTC playback) is public.
*/
export default async function mediamtxRoutes(app: FastifyInstance) {
const { db } = app.ctx;
const getStream = db.prepare('SELECT * FROM streams WHERE id = ?');
app.post('/api/mediamtx/auth', async (req, reply) => {
const body = (req.body ?? {}) as AuthRequest;
if (body.action !== 'publish') return reply.code(200).send({});
const match = body.path?.match(/^live\/([A-Za-z0-9]+)$/);
if (!match) {
app.log.warn(`mediamtx publish denied: unexpected path ${body.path}`);
return reply.code(401).send({ error: 'unknown path' });
}
const stream = getStream.get(match[1]) as Stream | undefined;
if (!stream) {
app.log.warn(`mediamtx publish denied: no stream ${match[1]}`);
return reply.code(401).send({ error: 'unknown stream' });
}
const queryKey = new URLSearchParams(body.query ?? '').get('key');
const candidates = [queryKey, body.password, body.token, body.user].filter(
(k): k is string => !!k,
);
if (candidates.some((k) => verifyStreamKey(k, stream.stream_key_hash))) {
return reply.code(200).send({});
}
app.log.warn(`mediamtx publish denied: bad key for stream ${match[1]} from ${body.ip}`);
return reply.code(401).send({ error: 'invalid stream key' });
});
}
+169
View File
@@ -0,0 +1,169 @@
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 type { Episode, Podcast } from '../types.js';
function nowSecs(): number {
return Math.floor(Date.now() / 1000);
}
const podcastSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).default(''),
author: z.string().max(200).default(''),
image_url: z.string().url().nullish(),
language: z.string().max(10).default('en'),
category: z.string().max(100).default('Technology'),
explicit: z.boolean().default(false),
lightning_address: z.string().regex(/^[\w.+-]+@[\w.-]+$/, 'expected name@domain').nullish(),
keysend_node: z.string().regex(/^[0-9a-f]{66}$/i, 'expected 33-byte hex node pubkey').nullish(),
value_suggested: z.string().max(20).nullish(),
});
const episodeSchema = z.object({
title: z.string().min(1).max(300),
description: z.string().max(10000).default(''),
sha256: z.string().regex(/^[0-9a-f]{64}$/),
size: z.number().int().positive(),
mime: z.string().default('video/mp4'),
blossomUrl: z.string().url().optional(),
duration_secs: z.number().int().positive().nullish(),
season: z.number().int().positive().nullish(),
episode_no: z.number().int().positive().nullish(),
pub_date: z.number().int().positive().optional(),
});
export default async function podcastRoutes(app: FastifyInstance) {
const { db, settings } = app.ctx;
const listPodcasts = db.prepare('SELECT * FROM podcasts WHERE owner_pubkey = ? ORDER BY created_at');
const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?');
const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? ORDER BY pub_date DESC');
const getEpisode = db.prepare('SELECT * FROM episodes WHERE id = ? AND podcast_id = ?');
function ownedPodcast(id: string, pubkey: string): Podcast | null {
const p = getPodcast.get(id) as Podcast | undefined;
return p && p.owner_pubkey === pubkey ? p : null;
}
app.get('/api/podcasts', { preHandler: app.requireAuth }, async (req) => {
const podcasts = listPodcasts.all(req.userPubkey) as Podcast[];
return podcasts.map((p) => ({
...p,
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
}));
});
app.post('/api/podcasts', { preHandler: app.requireAuth }, async (req, reply) => {
const parsed = podcastSchema.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const d = parsed.data;
const id = randomUUID();
const feedUrl = `${settings.all().public_url}/feeds/${id}/feed.xml`;
db.prepare(`
INSERT INTO podcasts (id, owner_pubkey, title, description, author, image_url, language,
category, explicit, lightning_address, keysend_node, value_suggested, podcast_guid,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, req.userPubkey, d.title, d.description, d.author, d.image_url ?? null, d.language,
d.category, d.explicit ? 1 : 0, d.lightning_address ?? null, d.keysend_node ?? null,
d.value_suggested ?? null, podcastGuidForFeedUrl(feedUrl), nowSecs(), nowSecs(),
);
return reply.code(201).send({ ...(getPodcast.get(id) as Podcast), feed_url: feedUrl });
});
app.get('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
const p = ownedPodcast(id, req.userPubkey!);
if (!p) return reply.code(404).send({ error: 'podcast not found' });
return {
...p,
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
episodes: listEpisodes.all(id) as Episode[],
};
});
app.put('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
const p = ownedPodcast(id, req.userPubkey!);
if (!p) return reply.code(404).send({ error: 'podcast not found' });
const parsed = podcastSchema.partial().safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const d = { ...p, ...parsed.data, explicit: (parsed.data.explicit ?? !!p.explicit) ? 1 : 0 };
db.prepare(`
UPDATE podcasts SET title=?, description=?, author=?, image_url=?, language=?, category=?,
explicit=?, lightning_address=?, keysend_node=?, value_suggested=?, updated_at=?
WHERE id=?
`).run(
d.title, d.description, d.author, d.image_url ?? null, d.language, d.category, d.explicit,
d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null, nowSecs(), id,
);
return getPodcast.get(id) as Podcast;
});
app.delete('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
if (!ownedPodcast(id, req.userPubkey!)) return reply.code(404).send({ error: 'podcast not found' });
db.prepare('DELETE FROM podcasts WHERE id = ?').run(id);
return { ok: true };
});
// Register a blob the browser already uploaded to blossom as a new episode.
app.post('/api/podcasts/:id/episodes', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
if (!ownedPodcast(id, req.userPubkey!)) return reply.code(404).send({ error: 'podcast not found' });
const parsed = episodeSchema.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const d = parsed.data;
const blossomUrl = d.blossomUrl ?? settings.all().blossom_url;
const blob = await checkBlob(blossomUrl, d.sha256);
if (!blob.exists) {
return reply.code(422).send({ error: `blob ${d.sha256} not found on ${blossomUrl}` });
}
if (blob.size != null && blob.size !== d.size) {
return reply.code(422).send({ error: `blob size mismatch (server has ${blob.size}, claimed ${d.size})` });
}
const ext = d.mime === 'audio/mpeg' ? 'mp3' : d.mime === 'audio/mp4' ? 'm4a' : 'mp4';
const enclosureUrl = `${blossomUrl.replace(/\/+$/, '')}/${d.sha256}.${ext}`;
const eid = randomUUID();
db.prepare(`
INSERT INTO episodes (id, podcast_id, title, description, sha256, enclosure_url,
enclosure_length, enclosure_type, duration_secs, season, episode_no, source, pub_date, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)
`).run(
eid, id, d.title, d.description, d.sha256, enclosureUrl, d.size, d.mime,
d.duration_secs ?? null, d.season ?? null, d.episode_no ?? null,
d.pub_date ?? nowSecs(), nowSecs(),
);
db.prepare('UPDATE podcasts SET updated_at = ? WHERE id = ?').run(nowSecs(), id);
return reply.code(201).send(getEpisode.get(eid, id) as Episode);
});
app.put('/api/podcasts/:id/episodes/:eid', { preHandler: app.requireAuth }, async (req, reply) => {
const { id, eid } = req.params as { id: string; eid: string };
if (!ownedPodcast(id, req.userPubkey!)) return reply.code(404).send({ error: 'podcast not found' });
const episode = getEpisode.get(eid, id) as Episode | undefined;
if (!episode) return reply.code(404).send({ error: 'episode not found' });
const parsed = episodeSchema.partial().safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const d = { ...episode, ...parsed.data };
db.prepare(`
UPDATE episodes SET title=?, description=?, duration_secs=?, season=?, episode_no=?, pub_date=?
WHERE id=?
`).run(d.title, d.description, d.duration_secs ?? null, d.season ?? null,
d.episode_no ?? null, d.pub_date, eid);
return getEpisode.get(eid, id) as Episode;
});
app.delete('/api/podcasts/:id/episodes/:eid', { preHandler: app.requireAuth }, async (req, reply) => {
const { id, eid } = req.params as { id: string; eid: string };
if (!ownedPodcast(id, req.userPubkey!)) return reply.code(404).send({ error: 'podcast not found' });
db.prepare('DELETE FROM episodes WHERE id = ? AND podcast_id = ?').run(eid, id);
return { ok: true };
});
}
+41
View File
@@ -0,0 +1,41 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
const updateSchema = z.object({
blossom_url: z.string().url().optional(),
relays: z.array(z.string().regex(/^wss?:\/\//)).optional(),
public_url: z.string().url().optional(),
});
export default async function settingsRoutes(app: FastifyInstance) {
const { settings, config, publisher } = app.ctx;
// Unauthenticated bootstrap info the wizard needs before/at login.
app.get('/api/settings/public', async () => {
const s = settings.all();
return {
blossomUrl: s.blossom_url,
relays: s.relays,
publicUrl: s.public_url,
serverPubkey: publisher.pubkey,
rtmpPublic: config.MEDIAMTX_RTMP_PUBLIC,
whipPublic: config.MEDIAMTX_WHIP_PUBLIC,
hlsPublic: config.MEDIAMTX_HLS_PUBLIC,
};
});
app.get('/api/settings', { preHandler: app.requireAuth }, async () => settings.all());
app.put('/api/settings', { preHandler: app.requireAuth }, async (req, reply) => {
if (!settings.isAdmin(req.userPubkey!)) {
return reply.code(403).send({ error: 'only the admin can change settings' });
}
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const { blossom_url, relays, public_url } = parsed.data;
if (blossom_url !== undefined) settings.set('blossom_url', blossom_url);
if (relays !== undefined) settings.set('relays', JSON.stringify(relays));
if (public_url !== undefined) settings.set('public_url', public_url);
return settings.all();
});
}
+184
View File
@@ -0,0 +1,184 @@
import { randomUUID } from 'node:crypto';
import { readdir, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { generateStreamId, generateStreamKey, hashStreamKey } from '../services/streamkeys.js';
import { remuxToMp4, probeDurationSecs } from '../services/ffmpeg.js';
import { uploadFile } from '../services/blossom.js';
import type { Episode, Podcast, Stream } from '../types.js';
function nowSecs(): number {
return Math.floor(Date.now() / 1000);
}
const streamSchema = z.object({
title: z.string().min(1).max(300),
summary: z.string().max(5000).default(''),
image_url: z.string().url().nullish(),
hashtags: z.array(z.string().max(50)).max(20).default([]),
});
const publishRecordingSchema = z.object({
file: z.string().min(1),
podcast_id: z.string().uuid(),
title: z.string().min(1).max(300),
description: z.string().max(10000).default(''),
});
export default async function streamRoutes(app: FastifyInstance) {
const { db, config, settings, publisher } = app.ctx;
const getStream = db.prepare('SELECT * FROM streams WHERE id = ?');
const listStreams = db.prepare('SELECT * FROM streams WHERE owner_pubkey = ? ORDER BY created_at DESC');
const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?');
function ownedStream(id: string, pubkey: string): Stream | null {
const s = getStream.get(id) as Stream | undefined;
return s && s.owner_pubkey === pubkey ? s : null;
}
function streamUrls(id: string) {
return {
rtmpUrl: `${config.MEDIAMTX_RTMP_PUBLIC}/live`,
whipUrl: `${config.MEDIAMTX_WHIP_PUBLIC}/live/${id}/whip`,
hlsUrl: `${config.MEDIAMTX_HLS_PUBLIC}/live/${id}/index.m3u8`,
};
}
function publicStream(s: Stream) {
const { stream_key_hash: _, ...rest } = s;
return { ...rest, hashtags: JSON.parse(s.hashtags), ...streamUrls(s.id) };
}
app.post('/api/streams', { preHandler: app.requireAuth }, async (req, reply) => {
const parsed = streamSchema.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const d = parsed.data;
const id = generateStreamId();
const key = generateStreamKey();
db.prepare(`
INSERT INTO streams (id, owner_pubkey, title, summary, image_url, hashtags, stream_key_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.userPubkey, d.title, d.summary, d.image_url ?? null,
JSON.stringify(d.hashtags), hashStreamKey(key), nowSecs());
const s = getStream.get(id) as Stream;
return reply.code(201).send({
...publicStream(s),
// Shown once — OBS wants "streamId?key=secret" as the stream key.
streamKey: `${id}?key=${key}`,
whipBearer: key,
});
});
app.get('/api/streams', { preHandler: app.requireAuth }, async (req) => {
return (listStreams.all(req.userPubkey) as Stream[]).map(publicStream);
});
app.get('/api/streams/:id', { preHandler: app.requireAuth }, async (req, reply) => {
const s = ownedStream((req.params as { id: string }).id, req.userPubkey!);
if (!s) return reply.code(404).send({ error: 'stream not found' });
return publicStream(s);
});
app.put('/api/streams/:id', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
const s = ownedStream(id, req.userPubkey!);
if (!s) return reply.code(404).send({ error: 'stream not found' });
const parsed = streamSchema.partial().safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const d = { ...s, ...parsed.data, hashtags: JSON.stringify(parsed.data.hashtags ?? JSON.parse(s.hashtags)) };
db.prepare('UPDATE streams SET title=?, summary=?, image_url=?, hashtags=? WHERE id=?')
.run(d.title, d.summary, d.image_url ?? null, d.hashtags, id);
const updated = getStream.get(id) as Stream;
if (updated.status === 'live') {
await publisher.publishLiveEvent(settings.all().relays, {
stream: updated, status: 'live', hlsUrl: streamUrls(id).hlsUrl,
});
}
return publicStream(updated);
});
app.post('/api/streams/:id/rotate-key', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
if (!ownedStream(id, req.userPubkey!)) return reply.code(404).send({ error: 'stream not found' });
const key = generateStreamKey();
db.prepare('UPDATE streams SET stream_key_hash = ? WHERE id = ?').run(hashStreamKey(key), id);
return { streamKey: `${id}?key=${key}`, whipBearer: key };
});
app.post('/api/streams/:id/end', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
const s = ownedStream(id, req.userPubkey!);
if (!s) return reply.code(404).send({ error: 'stream not found' });
db.prepare("UPDATE streams SET status = 'ended', ended_at = ? WHERE id = ?").run(nowSecs(), id);
const updated = getStream.get(id) as Stream;
await publisher.publishLiveEvent(settings.all().relays, {
stream: updated, status: 'ended', hlsUrl: streamUrls(id).hlsUrl,
});
return publicStream(updated);
});
app.delete('/api/streams/:id', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
if (!ownedStream(id, req.userPubkey!)) return reply.code(404).send({ error: 'stream not found' });
db.prepare('DELETE FROM streams WHERE id = ?').run(id);
return { ok: true };
});
// Recorded segments for a stream, straight from the shared recordings volume.
app.get('/api/streams/:id/recordings', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
if (!ownedStream(id, req.userPubkey!)) return reply.code(404).send({ error: 'stream not found' });
const dir = join(config.RECORDINGS_DIR, 'live', id);
let files: string[] = [];
try {
files = (await readdir(dir)).filter((f) => f.endsWith('.mp4')).sort();
} catch {
// no recordings yet
}
return files.map((f) => ({ file: f, path: `live/${id}/${f}` }));
});
// Remux a recording, upload it to blossom with the server's key, publish as an episode.
app.post('/api/streams/:id/recordings/publish', { preHandler: app.requireAuth }, async (req, reply) => {
const { id } = req.params as { id: string };
if (!ownedStream(id, req.userPubkey!)) return reply.code(404).send({ error: 'stream not found' });
const parsed = publishRecordingSchema.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const d = parsed.data;
const podcast = getPodcast.get(d.podcast_id) as Podcast | undefined;
if (!podcast || podcast.owner_pubkey !== req.userPubkey) {
return reply.code(404).send({ error: 'podcast not found' });
}
if (d.file.includes('/') || d.file.includes('..')) {
return reply.code(400).send({ error: 'invalid file name' });
}
const source = join(config.RECORDINGS_DIR, 'live', id, d.file);
const tmpDir = join(config.DATA_DIR, 'tmp');
await mkdir(tmpDir, { recursive: true });
const tmpOut = join(tmpDir, `${randomUUID()}.mp4`);
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 eid = randomUUID();
db.prepare(`
INSERT INTO episodes (id, podcast_id, title, description, sha256, enclosure_url,
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());
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,
);
} finally {
await rm(tmpOut, { force: true });
}
});
}
+70
View File
@@ -0,0 +1,70 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { finalizeEvent } from 'nostr-tools/pure';
export interface BlobCheck {
exists: boolean;
size: number | null;
}
/** 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' });
if (!res.ok) return { exists: false, size: null };
const len = res.headers.get('content-length');
return { exists: true, size: len ? Number(len) : null };
}
/** Build a BUD-02 upload authorization event (kind 24242) signed with the given secret key. */
export function buildUploadAuth(secretKey: Uint8Array, sha256: string, description: string) {
const now = Math.floor(Date.now() / 1000);
return finalizeEvent(
{
kind: 24242,
created_at: now,
content: description,
tags: [
['t', 'upload'],
['x', sha256],
['expiration', String(now + 600)],
],
},
secretKey,
);
}
export interface UploadResult {
sha256: string;
size: number;
url: string;
}
/**
* Server-side blossom upload (used for publishing stream recordings, which live on
* the server and are signed with the server's own nostr key).
*/
export async function uploadFile(
blossomUrl: string,
filePath: string,
mime: string,
secretKey: Uint8Array,
): Promise<UploadResult> {
const data = await readFile(filePath);
const sha256 = createHash('sha256').update(data).digest('hex');
const auth = buildUploadAuth(secretKey, sha256, `Upload ${sha256}`);
const base = blossomUrl.replace(/\/+$/, '');
const res = await fetch(`${base}/upload`, {
method: 'PUT',
headers: {
authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString('base64')}`,
'content-type': mime,
'content-length': String(data.length),
},
body: data,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`blossom upload failed: ${res.status} ${text.slice(0, 200)}`);
}
return { sha256, size: data.length, url: `${base}/${sha256}` };
}
+20
View File
@@ -0,0 +1,20 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
/** Remux an fmp4 recording segment into a plain faststart mp4 (no re-encode). */
export async function remuxToMp4(input: string, output: string): Promise<void> {
await run('ffmpeg', ['-y', '-i', input, '-c', 'copy', '-movflags', '+faststart', output], {
timeout: 10 * 60 * 1000,
});
}
export async function probeDurationSecs(file: string): Promise<number> {
const { stdout } = await run('ffprobe', [
'-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file,
]);
const secs = Number.parseFloat(stdout.trim());
if (!Number.isFinite(secs)) throw new Error(`could not probe duration of ${file}`);
return Math.round(secs);
}
+86
View File
@@ -0,0 +1,86 @@
export interface MediamtxPath {
name: string;
ready: boolean;
}
export interface RecordingSegment {
start: string; // ISO timestamp
}
export interface RecordingEntry {
name: string; // path name, e.g. live/abc123
segments: RecordingSegment[];
}
export class MediamtxClient {
constructor(private apiUrl: string) {}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${this.apiUrl}${path}`);
if (!res.ok) throw new Error(`mediamtx api ${path}: ${res.status}`);
return (await res.json()) as T;
}
/** Names of paths that currently have a publisher connected and ready. */
async readyPaths(): Promise<Set<string>> {
const data = await this.get<{ items: MediamtxPath[] }>('/v3/paths/list?itemsPerPage=500');
return new Set(data.items.filter((p) => p.ready).map((p) => p.name));
}
async recordings(pathName: string): Promise<RecordingEntry | null> {
try {
return await this.get<RecordingEntry>(`/v3/recordings/get/${encodeURIComponent(pathName)}`);
} catch {
return null;
}
}
}
export type StatusChange = { streamId: string; live: boolean };
/**
* Polls the MediaMTX API and reports live/ended transitions for `live/<id>` paths.
* (The stock MediaMTX image has no shell, so webhook-style runOn* hooks are not an
* option polling the API is the reliable path.)
*/
export function startStatusPoller(
client: MediamtxClient,
intervalMs: number,
getLiveStreamIds: () => string[],
onChange: (change: StatusChange) => void,
log: { warn: (msg: string) => void },
): () => void {
let stopped = false;
const known = new Set<string>(); // stream ids we've seen ready
async function tick() {
if (stopped) return;
try {
const ready = await client.readyPaths();
const readyIds = new Set(
[...ready].filter((n) => n.startsWith('live/')).map((n) => n.slice('live/'.length)),
);
for (const id of readyIds) {
if (!known.has(id)) {
known.add(id);
onChange({ streamId: id, live: true });
}
}
for (const id of [...known, ...getLiveStreamIds()]) {
if (!readyIds.has(id)) {
known.delete(id);
onChange({ streamId: id, live: false });
}
}
} catch (err) {
log.warn(`mediamtx poll failed: ${(err as Error).message}`);
}
if (!stopped) timer = setTimeout(tick, intervalMs);
}
let timer = setTimeout(tick, intervalMs);
return () => {
stopped = true;
clearTimeout(timer);
};
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { verifyNip98, NIP98_KIND, Nip98Error } from './nip98.js';
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const URL_ = 'http://localhost:8095/api/auth/login';
function makeHeader(overrides: Partial<{ kind: number; created_at: number; url: string; method: string }> = {}) {
const event = finalizeEvent(
{
kind: overrides.kind ?? NIP98_KIND,
created_at: overrides.created_at ?? Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', overrides.url ?? URL_],
['method', overrides.method ?? 'POST'],
],
},
sk,
);
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
}
const baseOpts = { allowedUrls: [URL_], method: 'POST', maxSkewSecs: 60 };
describe('verifyNip98', () => {
it('accepts a valid header and returns the pubkey', () => {
expect(verifyNip98(makeHeader(), baseOpts)).toBe(pk);
});
it('rejects missing header', () => {
expect(() => verifyNip98(undefined, baseOpts)).toThrow(Nip98Error);
});
it('rejects wrong kind', () => {
expect(() => verifyNip98(makeHeader({ kind: 1 }), baseOpts)).toThrow(/kind/);
});
it('rejects clock skew beyond the window', () => {
const old = Math.floor(Date.now() / 1000) - 120;
expect(() => verifyNip98(makeHeader({ created_at: old }), baseOpts)).toThrow(/clock/);
});
it('rejects a mismatched URL', () => {
expect(() =>
verifyNip98(makeHeader({ url: 'http://evil.example/api/auth/login' }), baseOpts),
).toThrow(/u tag/);
});
it('accepts equivalent URLs with trailing slash differences', () => {
expect(verifyNip98(makeHeader({ url: URL_ + '/' }), baseOpts)).toBe(pk);
});
it('rejects a mismatched method', () => {
expect(() => verifyNip98(makeHeader({ method: 'GET' }), baseOpts)).toThrow(/method/);
});
it('rejects tampered events (bad signature)', () => {
const event = JSON.parse(
Buffer.from(makeHeader().slice(6), 'base64').toString('utf8'),
);
event.tags.push(['t', 'tampered']);
const header = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
expect(() => verifyNip98(header, baseOpts)).toThrow(/signature|u tag|method/);
});
it('rejects replayed events', () => {
const header = makeHeader();
const seen = new Set<string>();
const opts = {
...baseOpts,
isReplay: (id: string) => {
if (seen.has(id)) return true;
seen.add(id);
return false;
},
};
expect(verifyNip98(header, opts)).toBe(pk);
expect(() => verifyNip98(header, opts)).toThrow(/already used/);
});
it('rejects payload hash mismatch', () => {
const now = Math.floor(Date.now() / 1000);
const event = finalizeEvent(
{
kind: NIP98_KIND,
created_at: now,
content: '',
tags: [['u', URL_], ['method', 'POST'], ['payload', 'deadbeef']],
},
sk,
);
const header = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
expect(() => verifyNip98(header, { ...baseOpts, body: Buffer.from('{"a":1}') })).toThrow(/payload/);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { createHash } from 'node:crypto';
import { verifyEvent, type Event } from 'nostr-tools/pure';
export const NIP98_KIND = 27235;
export class Nip98Error extends Error {}
export interface Nip98Options {
/** Full URLs the signed `u` tag is allowed to match (same request via different hosts). */
allowedUrls: string[];
method: string;
body?: Buffer | null;
maxSkewSecs: number;
now?: number;
/** Returns true if the event id was already used (replay). */
isReplay?: (eventId: string) => boolean;
}
function tag(event: Event, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
}
function normalizeUrl(u: string): string {
try {
const url = new URL(u);
// Normalise default ports and trailing slashes so signers and verifiers agree.
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '') || '/'}${url.search}`;
} catch {
return u;
}
}
/** Verify a NIP-98 `Authorization: Nostr <base64 event>` header. Returns the signer pubkey. */
export function verifyNip98(header: string | undefined, opts: Nip98Options): string {
if (!header?.startsWith('Nostr ')) throw new Nip98Error('missing Nostr authorization header');
let event: Event;
try {
event = JSON.parse(Buffer.from(header.slice(6).trim(), 'base64').toString('utf8'));
} catch {
throw new Nip98Error('malformed authorization event');
}
if (event.kind !== NIP98_KIND) throw new Nip98Error(`wrong event kind (expected ${NIP98_KIND})`);
const now = opts.now ?? Math.floor(Date.now() / 1000);
if (Math.abs(now - event.created_at) > opts.maxSkewSecs) {
throw new Nip98Error('authorization event expired or clock skew too large — check your clock');
}
const u = tag(event, 'u');
if (!u || !opts.allowedUrls.some((a) => normalizeUrl(a) === normalizeUrl(u))) {
throw new Nip98Error('u tag does not match the request URL');
}
const method = tag(event, 'method');
if (!method || method.toUpperCase() !== opts.method.toUpperCase()) {
throw new Nip98Error('method tag does not match the request method');
}
if (opts.body && opts.body.length > 0) {
const payload = tag(event, 'payload');
const digest = createHash('sha256').update(opts.body).digest('hex');
if (payload && payload !== digest) throw new Nip98Error('payload hash mismatch');
}
if (!verifyEvent(event)) throw new Nip98Error('invalid event signature');
if (opts.isReplay?.(event.id)) throw new Nip98Error('authorization event already used');
return event.pubkey;
}
+77
View File
@@ -0,0 +1,77 @@
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from 'nostr-tools/pure';
import { SimplePool, useWebSocketImplementation } from 'nostr-tools/pool';
import { hexToBytes, bytesToHex } from 'nostr-tools/utils';
import WebSocket from 'ws';
import type { Stream } from '../types.js';
useWebSocketImplementation(WebSocket);
/** Load the server's nostr identity, generating one on first boot. */
export function loadServerKey(dataDir: string): Uint8Array {
const dir = join(dataDir, 'config');
const file = join(dir, 'server-nostr-key');
if (existsSync(file)) {
return hexToBytes(readFileSync(file, 'utf8').trim());
}
mkdirSync(dir, { recursive: true });
const key = generateSecretKey();
writeFileSync(file, bytesToHex(key), { mode: 0o600 });
return key;
}
export interface LiveEventInput {
stream: Stream;
status: 'planned' | 'live' | 'ended';
hlsUrl: string;
}
/** Build the NIP-53 kind 30311 live event, signed by the server's key. */
export function buildLiveEvent(secretKey: Uint8Array, input: LiveEventInput): Event {
const { stream, status, hlsUrl } = input;
const tags: string[][] = [
['d', stream.id],
['title', stream.title],
['summary', stream.summary],
['streaming', hlsUrl],
['status', status],
['p', stream.owner_pubkey, '', 'host'],
];
if (stream.image_url) tags.push(['image', stream.image_url]);
if (stream.starts_at) tags.push(['starts', String(stream.starts_at)]);
if (status === 'ended' && stream.ended_at) tags.push(['ends', String(stream.ended_at)]);
for (const t of JSON.parse(stream.hashtags) as string[]) tags.push(['t', t]);
return finalizeEvent(
{ kind: 30311, created_at: Math.floor(Date.now() / 1000), content: '', tags },
secretKey,
);
}
export class NostrPublisher {
private pool = new SimplePool();
constructor(private secretKey: Uint8Array, private log: { info: (m: string) => void; warn: (m: string) => void }) {}
get pubkey(): string {
return getPublicKey(this.secretKey);
}
/** Publish (or update — 30311 is addressable/replaceable) the live event. Never throws. */
async publishLiveEvent(relays: string[], input: LiveEventInput): Promise<Event> {
const event = buildLiveEvent(this.secretKey, input);
if (relays.length === 0) return event;
const results = await Promise.allSettled(this.pool.publish(relays, event));
const ok = results.filter((r) => r.status === 'fulfilled').length;
if (ok === 0) {
this.log.warn(`30311 (${input.status}) for ${input.stream.id}: no relay accepted the event`);
} else {
this.log.info(`30311 (${input.status}) for ${input.stream.id}: accepted by ${ok}/${relays.length} relays`);
}
return event;
}
close(relays: string[]): void {
this.pool.close(relays);
}
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { buildFeedXml, podcastGuidForFeedUrl, xmlEscape } from './rss.js';
import type { Episode, Podcast } from '../types.js';
const podcast: Podcast = {
id: 'p1',
owner_pubkey: 'abc',
title: 'Test & Show',
description: 'A <great> show',
author: 'Alice',
image_url: 'http://host/cover.jpg',
language: 'en',
category: 'Technology',
explicit: 0,
lightning_address: 'alice@getalby.com',
keysend_node: null,
value_suggested: '0.00000005000',
podcast_guid: '917393e3-1b1e-5cef-ace4-edaa54e1f810',
created_at: 1700000000,
updated_at: 1700000000,
};
const episode: Episode = {
id: 'e1',
podcast_id: 'p1',
title: 'Episode "One"',
description: 'First!',
sha256: 'a'.repeat(64),
enclosure_url: `http://host:8098/${'a'.repeat(64)}.mp4`,
enclosure_length: 12345,
enclosure_type: 'video/mp4',
duration_secs: 3725,
season: null,
episode_no: 1,
source: 'upload',
pub_date: 1700000100,
created_at: 1700000100,
};
describe('podcastGuidForFeedUrl', () => {
it('matches the podcast-namespace spec vector', () => {
// From the podcast:guid spec: feed url mp3s.nashownotes.com/pc20rss.xml
expect(podcastGuidForFeedUrl('https://mp3s.nashownotes.com/pc20rss.xml')).toBe(
'917393e3-1b1e-5cef-ace4-edaa54e1f810',
);
});
});
describe('xmlEscape', () => {
it('escapes all XML special characters', () => {
expect(xmlEscape(`<a href="x">&'</a>`)).toBe(
'&lt;a href=&quot;x&quot;&gt;&amp;&apos;&lt;/a&gt;',
);
});
});
describe('buildFeedXml', () => {
const xml = buildFeedXml(podcast, [episode], { publicUrl: 'http://host:8095' });
it('produces an RSS 2.0 document with the podcast namespace', () => {
expect(xml).toContain('<rss version="2.0"');
expect(xml).toContain('xmlns:podcast="https://podcastindex.org/namespace/1.0"');
expect(xml).toContain('<podcast:guid>917393e3-1b1e-5cef-ace4-edaa54e1f810</podcast:guid>');
});
it('emits an lnaddress value block', () => {
expect(xml).toContain('<podcast:value type="lightning" method="lnaddress" suggested="0.00000005000">');
expect(xml).toContain('type="lnaddress" address="alice@getalby.com" split="100"');
});
it('falls back to a keysend block when only a node pubkey is set', () => {
const p2 = { ...podcast, lightning_address: null, keysend_node: '02' + 'b'.repeat(64) };
const xml2 = buildFeedXml(p2, [], { publicUrl: 'http://host:8095' });
expect(xml2).toContain('method="keysend"');
expect(xml2).toContain(`type="node" address="02${'b'.repeat(64)}"`);
});
it('omits the value block when no payment info is set', () => {
const p3 = { ...podcast, lightning_address: null, keysend_node: null };
expect(buildFeedXml(p3, [], { publicUrl: 'http://host:8095' })).not.toContain('<podcast:value');
});
it('escapes user content and renders the enclosure + duration', () => {
expect(xml).toContain('<title>Test &amp; Show</title>');
expect(xml).toContain('<title>Episode &quot;One&quot;</title>');
expect(xml).toContain(`<enclosure url="http://host:8098/${'a'.repeat(64)}.mp4" length="12345" type="video/mp4"/>`);
expect(xml).toContain('<itunes:duration>01:02:05</itunes:duration>');
expect(xml).toContain(`<guid isPermaLink="false">${'a'.repeat(64)}</guid>`);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { createHash } from 'node:crypto';
import type { Episode, Podcast } from '../types.js';
/** Namespace UUID for podcast:guid, fixed by the podcast-namespace spec. */
const PODCAST_GUID_NAMESPACE = 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6';
export function xmlEscape(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** RFC 4122 UUIDv5 (sha1) — used for podcast:guid over the feed URL. */
export function uuidv5(name: string, namespace: string = PODCAST_GUID_NAMESPACE): string {
const ns = Buffer.from(namespace.replace(/-/g, ''), 'hex');
const hash = createHash('sha1').update(ns).update(name, 'utf8').digest();
hash[6] = (hash[6] & 0x0f) | 0x50; // version 5
hash[8] = (hash[8] & 0x3f) | 0x80; // variant
const hex = hash.subarray(0, 16).toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
/** podcast:guid is the UUIDv5 of the feed URL with protocol scheme and trailing slashes removed. */
export function podcastGuidForFeedUrl(feedUrl: string): string {
const stripped = feedUrl.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
return uuidv5(stripped);
}
function rfc2822(epochSecs: number): string {
return new Date(epochSecs * 1000).toUTCString();
}
function itunesDuration(secs: number): string {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return [h, m, s].map((n) => String(n).padStart(2, '0')).join(':');
}
function valueBlock(p: Podcast): string {
const recipients: string[] = [];
if (p.lightning_address) {
recipients.push(
` <podcast:valueRecipient name="${xmlEscape(p.author || p.title)}" type="lnaddress" address="${xmlEscape(p.lightning_address)}" split="100"/>`,
);
}
if (recipients.length === 0 && p.keysend_node) {
recipients.push(
` <podcast:valueRecipient name="${xmlEscape(p.author || p.title)}" type="node" address="${xmlEscape(p.keysend_node)}" split="100"/>`,
);
}
if (recipients.length === 0) return '';
const method = p.lightning_address ? 'lnaddress' : 'keysend';
const suggested = p.value_suggested ? ` suggested="${xmlEscape(p.value_suggested)}"` : '';
return [
` <podcast:value type="lightning" method="${method}"${suggested}>`,
...recipients,
' </podcast:value>',
].join('\n');
}
export interface FeedContext {
publicUrl: string; // e.g. http://host:8095
}
export function buildFeedXml(podcast: Podcast, episodes: Episode[], ctx: FeedContext): string {
const feedUrl = `${ctx.publicUrl}/feeds/${podcast.id}/feed.xml`;
const link = `${ctx.publicUrl}/podcasts/${podcast.id}`;
const lastPub = episodes[0]?.pub_date ?? podcast.updated_at;
const items = episodes.map((e) => {
const lines = [
' <item>',
` <title>${xmlEscape(e.title)}</title>`,
` <description>${xmlEscape(e.description)}</description>`,
` <guid isPermaLink="false">${xmlEscape(e.sha256)}</guid>`,
` <pubDate>${rfc2822(e.pub_date)}</pubDate>`,
` <enclosure url="${xmlEscape(e.enclosure_url)}" length="${e.enclosure_length}" type="${xmlEscape(e.enclosure_type)}"/>`,
];
if (e.duration_secs != null) lines.push(` <itunes:duration>${itunesDuration(e.duration_secs)}</itunes:duration>`);
if (e.season != null) lines.push(` <podcast:season>${e.season}</podcast:season>`);
if (e.episode_no != null) lines.push(` <podcast:episode>${e.episode_no}</podcast:episode>`);
lines.push(' </item>');
return lines.join('\n');
});
const channel = [
` <title>${xmlEscape(podcast.title)}</title>`,
` <link>${xmlEscape(link)}</link>`,
` <description>${xmlEscape(podcast.description)}</description>`,
` <language>${xmlEscape(podcast.language)}</language>`,
` <generator>podpuddle</generator>`,
` <lastBuildDate>${rfc2822(lastPub)}</lastBuildDate>`,
` <atom:link href="${xmlEscape(feedUrl)}" rel="self" type="application/rss+xml"/>`,
` <podcast:guid>${podcast.podcast_guid}</podcast:guid>`,
` <podcast:medium>podcast</podcast:medium>`,
` <itunes:author>${xmlEscape(podcast.author || podcast.title)}</itunes:author>`,
` <itunes:explicit>${podcast.explicit ? 'true' : 'false'}</itunes:explicit>`,
` <itunes:category text="${xmlEscape(podcast.category)}"/>`,
];
if (podcast.image_url) {
channel.push(` <itunes:image href="${xmlEscape(podcast.image_url)}"/>`);
channel.push(
' <image>',
` <url>${xmlEscape(podcast.image_url)}</url>`,
` <title>${xmlEscape(podcast.title)}</title>`,
` <link>${xmlEscape(link)}</link>`,
' </image>',
);
}
const value = valueBlock(podcast);
if (value) channel.push(value);
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0"',
' xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"',
' xmlns:podcast="https://podcastindex.org/namespace/1.0"',
' xmlns:atom="http://www.w3.org/2005/Atom">',
' <channel>',
...channel,
...items,
' </channel>',
'</rss>',
'',
].join('\n');
}
+44
View File
@@ -0,0 +1,44 @@
import type { DB } from '../db/database.js';
import type { Config } from '../config.js';
export interface Settings {
blossom_url: string;
relays: string[];
public_url: string;
admin_pubkey: string | null;
}
export class SettingsService {
constructor(private db: DB, private config: Config) {}
get(key: string): string | null {
const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as
| { value: string }
| undefined;
return row?.value ?? null;
}
set(key: string, value: string): void {
this.db
.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(key, value);
}
all(): Settings {
return {
blossom_url: this.get('blossom_url') ?? this.config.BLOSSOM_URL_DEFAULT,
relays: JSON.parse(this.get('relays') ?? 'null') ?? this.config.defaultRelays,
public_url: this.get('public_url') ?? this.config.PUBLIC_URL,
admin_pubkey: this.get('admin_pubkey'),
};
}
/** First pubkey to ever log in becomes the admin. */
claimAdminIfUnset(pubkey: string): void {
if (!this.get('admin_pubkey')) this.set('admin_pubkey', pubkey);
}
isAdmin(pubkey: string): boolean {
return this.get('admin_pubkey') === pubkey;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
/** Public stream id — safe to appear in HLS URLs and nostr events. */
export function generateStreamId(): string {
return randomBytes(9).toString('base64url').replace(/[-_]/g, 'a').toLowerCase().slice(0, 12);
}
/** Secret publish key — only ever shown once, stored hashed. */
export function generateStreamKey(): string {
return randomBytes(24).toString('hex');
}
export function hashStreamKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
export function verifyStreamKey(key: string, hash: string): boolean {
const a = Buffer.from(hashStreamKey(key), 'hex');
const b = Buffer.from(hash, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
+71
View File
@@ -0,0 +1,71 @@
export interface User {
pubkey: string;
display_name: string | null;
lud16: string | null;
created_at: number;
last_login_at: number;
}
export interface Podcast {
id: string;
owner_pubkey: string;
title: string;
description: string;
author: string;
image_url: string | null;
language: string;
category: string;
explicit: number;
lightning_address: string | null;
keysend_node: string | null;
value_suggested: string | null;
podcast_guid: string;
created_at: number;
updated_at: number;
}
export interface Episode {
id: string;
podcast_id: string;
title: string;
description: string;
sha256: string;
enclosure_url: string;
enclosure_length: number;
enclosure_type: string;
duration_secs: number | null;
season: number | null;
episode_no: number | null;
source: 'upload' | 'recording';
pub_date: number;
created_at: number;
}
export interface Stream {
id: string;
owner_pubkey: string;
title: string;
summary: string;
image_url: string | null;
hashtags: string; // JSON array
stream_key_hash: string;
status: 'planned' | 'live' | 'ended';
starts_at: number | null;
ended_at: number | null;
created_at: number;
}
export interface Recording {
id: string;
stream_id: string;
segment_path: string;
started_at: number;
duration_secs: number | null;
published_episode_id: string | null;
}
declare module 'fastify' {
interface FastifyRequest {
userPubkey: string | null;
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": false
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});