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