Paid episodes: - server/services/cashu.ts: self-custodied Cashu wallet against a configured mint (NUT-04 mint quote -> bolt11 invoice -> mint/store proofs -> LNURL-pay payout on withdraw). Buyers pay a plain Lightning invoice, no Cashu wallet needed on their end. - routes/marketplace.ts: purchase/confirm flow, download-url paywall gate, reseller certification/revocation, earnings ledger + withdraw. - services/marketplace.ts: producer + certified-reseller source resolution, with a naive per-seller sales-count reputation signal. Discovery: - services/rss.ts: <podsteadr:source> RSS tag on priced episodes (producer + resellers, price, sales count, url) so pricing/sources are discoverable straight from the feed, not just a separate API call. Locked episodes point their <enclosure> at an info page instead of the raw file. - routes/feeds.ts: /catalog.opml lists every podcast this instance hosts, for peer podsteadr servers or any OPML-aware crawler to discover without a central directory. Frontend: episode wizard price/reseller controls, sources display, earnings dashboard. Also fixes CORS and a container-image reference: - app.ts: register @fastify/cors, open on the catalog/feed/sources endpoints (already deliberately public/crawlable) and on purchase/confirm (already accept stateless NIP-98 header auth for exactly this case). Credentials stay off, so the session cookie never crosses origins — cookie-authed admin routes stay same-origin-only. Needed so external clients like podsteadr-player can browse/buy/play from a different origin. - docker-compose.yml: fully qualify the mediamtx image reference (docker.io/bluenviron/mediamtx:1.19.2) — some Podman hosts have no unqualified-search registry configured and fail to resolve short names. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
85 lines
2.6 KiB
TypeScript
85 lines
2.6 KiB
TypeScript
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<BlobCheck> {
|
|
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<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}` };
|
|
}
|