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; } export class BlossomUnreachableError extends Error {} /** File extension blossom stores/serves blobs under, derived from the episode's mime type. */ export function extForMime(mime: string): string { return mime === 'audio/mpeg' ? 'mp3' : mime === 'audio/mp4' ? 'm4a' : 'mp4'; } /** 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 { let res: Response; try { res = await fetch(`${blossomUrl.replace(/\/+$/, '')}/${sha256}`, { method: 'HEAD' }); } catch (err) { throw new BlossomUnreachableError( `blossom server ${blossomUrl} unreachable: ${(err as Error).message}`, ); } if (!res.ok) return { exists: false, size: null }; const len = res.headers.get('content-length'); return { exists: true, size: len ? Number(len) : null }; } /** 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 { 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}` }; }