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:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
data/
|
||||
.DS_Store
|
||||
+37
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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]+$": {}
|
||||
Generated
+3313
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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!),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
'<a href="x">&'</a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 & Show</title>');
|
||||
expect(xml).toContain('<title>Episode "One"</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>`);
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user