Files
podsteadr/frontend/src/lib/blossom.ts
T
ssmithx 2d61abc40c chore: rename project from podpuddle to podsteadr
Renames the repo directory and every podpuddle/PODPUDDLE reference
across code, config, and docs to podsteadr/PODSTEADR (package names,
Docker Compose project/service/volume names, env var names, UI/RSS
strings). Existing Docker volume data (uploaded blobs, mediamtx
recordings, the server's sqlite DB and its nostr identity key) was
migrated to new podsteadr_-prefixed volumes with matching filenames
so it isn't orphaned by the rename.
2026-07-25 01:11:42 +00:00

77 lines
2.5 KiB
TypeScript

// Browser-direct Blossom (BUD-02) upload: hash locally, sign the kind-24242
// authorization with the user's NIP-07 extension, PUT straight to the blossom
// server. The file never transits podsteadr.
import { nip07 } from './nip07';
export async function sha256File(file: File, onProgress?: (frac: number) => void): Promise<string> {
// crypto.subtle needs the whole buffer; fine for podcast-sized mp4s (~2 GB cap).
const buf = await file.arrayBuffer();
onProgress?.(0.5);
const digest = await crypto.subtle.digest('SHA-256', buf);
onProgress?.(1);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export interface BlossomUpload {
sha256: string;
size: number;
url: string;
}
export async function uploadToBlossom(
blossomUrl: string,
file: File,
sha256: string,
onProgress?: (frac: number) => void,
): Promise<BlossomUpload> {
const now = Math.floor(Date.now() / 1000);
const auth = await nip07().signEvent({
kind: 24242,
created_at: now,
content: `Upload ${file.name}`,
tags: [
['t', 'upload'],
['x', sha256],
['expiration', String(now + 600)],
],
});
const base = blossomUrl.replace(/\/+$/, '');
// XHR instead of fetch for upload progress events.
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', `${base}/upload`);
xhr.setRequestHeader('authorization', `Nostr ${btoa(JSON.stringify(auth))}`);
xhr.setRequestHeader('content-type', file.type || 'video/mp4');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress?.(e.loaded / e.total);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`blossom upload failed: ${xhr.status} ${xhr.responseText.slice(0, 200)}`));
};
xhr.onerror = () => reject(new Error('blossom upload failed: network error'));
xhr.send(file);
});
return { sha256, size: file.size, url: `${base}/${sha256}` };
}
/** Read the media duration (seconds) from a local file, for itunes:duration. */
export function probeDuration(file: File): Promise<number | null> {
return new Promise((resolve) => {
const el = document.createElement('video');
el.preload = 'metadata';
el.onloadedmetadata = () => {
URL.revokeObjectURL(el.src);
resolve(Number.isFinite(el.duration) ? Math.round(el.duration) : null);
};
el.onerror = () => {
URL.revokeObjectURL(el.src);
resolve(null);
};
el.src = URL.createObjectURL(file);
});
}