1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
|
import { mediaServers } from './media-servers.js';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
// Independent public hosts: a phone upload should not fail just because one
// provider is down or rejects a particular container. Order from a
// 2026-09-30 benchmark (4 MB mp4 / 110 KB jpeg, uploaded then fetched):
// ditto took the mp4 in ~0.6 s and serves in ~120 ms; primal ~1.5 s; nostr.
// download ~1-2 s but refuses a JPEG with bytes after its end marker (motion
// photos); blossom.nostr.build ~5 s. The first to accept the file wins.
export const PUBLIC_MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://blossom.primal.net',
'https://nostr.download', 'https://blossom.nostr.build'];
export class MediaUploadError extends Error {
constructor(code, details = '') {
super(code);
this.name = 'MediaUploadError';
this.code = code;
this.details = details;
}
}
// Private Blossom hosts authorize the user's Nostr identity, which can differ
// from the wallet's sync key. Resolve once for the whole upload (including HLS).
export async function mediaUploadSigner(wallet, hook) {
const id = hook('nostrLoginIdentity');
if (!id) return (event) => wallet.nostrSign(event);
const signer = id.signer || await hook('nostrLoginResume');
if (!signer) throw new MediaUploadError('no-identity');
return (event) => signer.signEvent(event);
}
async function fileHash(file) {
const hash = sha256.create();
const chunk = 4 * 1024 * 1024;
for (let at = 0; at < file.size; at += chunk)
hash.update(new Uint8Array(await file.slice(at, at + chunk).arrayBuffer()));
return bytesToHex(hash.digest());
}
const responseUrl = (body) => body?.url
|| body?.data?.[0]?.url
|| (body?.tags || []).find((x) => x?.[0] === 'url')?.[1]
|| null;
function mediaUrl(url, server, file) {
const out = new URL(url, server);
const tail = out.pathname.split('/').at(-1) || '';
if (/^[0-9a-f]{64}$/i.test(tail)) {
const named = String(file.name || '').match(/\.([a-z0-9]{1,8})$/i)?.[1];
const byType = { 'video/quicktime': 'mov', 'video/mp4': 'mp4', 'video/webm': 'webm',
'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }[file.type];
const ext = (named || byType || '').toLowerCase();
if (ext) out.pathname += '.' + ext;
}
return out.href;
}
// A PUT that reports how much of the body has gone out: fetch() can't, so in
// the browser (with the real fetch) it's XMLHttpRequest. Answers like fetch.
function putWithProgress(url, { body, headers, signal }, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
xhr.onload = () => resolve(new Response(xhr.responseText, {
status: xhr.status, headers: { 'x-reason': xhr.getResponseHeader('x-reason') || '' } }));
xhr.onerror = () => reject(new Error('network error'));
xhr.ontimeout = () => reject(new Error('timeout'));
if (signal) signal.addEventListener('abort', () => { xhr.abort(); reject(new Error('timeout')); });
xhr.send(body);
});
}
// BUD-02 upload. `signEvent` is deliberately injected: the app can use its
// signed-in Nostr identity, while tests need no secret key.
export async function uploadPublicMedia(file, signEvent, {
servers = mediaServers(PUBLIC_MEDIA_SERVERS),
fetcher = globalThis.fetch,
timeoutMs = 5 * 60_000,
onProgress = null, // (fraction 0..1) as the bytes go out
} = {}) {
const tracked = onProgress && typeof XMLHttpRequest !== 'undefined' && fetcher === globalThis.fetch;
if (!file || !file.size || typeof signEvent !== 'function') throw new MediaUploadError('failed');
const x = await fileHash(file);
const now = Math.floor(Date.now() / 1000);
const auth = await signEvent({
kind: 24242, created_at: now - 5, content: 'upload',
tags: [['t', 'upload'], ['x', x], ['expiration', String(now + Math.ceil(timeoutMs / 1000) + 120)]],
});
if (!auth) throw new MediaUploadError('failed');
const authorization = 'Nostr ' + btoa(JSON.stringify(auth));
const failures = [];
for (const [i, server] of servers.entries()) {
// A host that doesn't answer at all costs a few seconds, not the whole
// upload timeout. Any HTTP answer, even an error, means it's there.
if (i < servers.length - 1) {
try { await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'HEAD', signal: AbortSignal.timeout(5000) }); }
catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; }
}
try {
const init = {
method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs),
headers: {
authorization,
'content-type': file.type || 'application/octet-stream',
'x-sha-256': x,
'x-content-length': String(file.size),
},
};
if (onProgress) onProgress(0);
const target = server.replace(/\/$/, '') + '/upload';
const response = tracked ? await putWithProgress(target, init, onProgress) : await fetcher(target, init);
let body = null;
try { body = await response.json(); } catch {}
const url = response.ok && responseUrl(body);
if (url) return mediaUrl(url, server, file);
failures.push({ status: response.status, reason: response.headers.get('x-reason') || body?.message || '' });
} catch (error) {
failures.push({ status: 0, reason: error?.message || '' });
}
}
const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; ');
if (failures.length && failures.every((x) => x.status === 401 || x.status === 403)) throw new MediaUploadError('unauthorized', details);
if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details);
if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details);
throw new MediaUploadError('failed', details);
}
// An HLS stream (streamVideo) onto one host: the media files first, then
// each playlist with its files' addresses written in, then the master
// playlist naming those — its address is the post's. One host for the lot,
// and one that serves a playlist as one (ditto and nostr.download do;
// primal's says text/plain, nostr.build won't take it); a host that refuses
// any piece is passed over for the next. `files` maps each name the
// playlists use to its File.
export const STREAM_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net'];
export function rewritePlaylist(text, urls) {
const to = (p) => urls.get(p) || p;
return text.split('\n').map((line) => {
const l = line.trim();
if (l && !l.startsWith('#')) return to(l);
return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`);
}).join('\n');
}
export async function uploadStream({ files, master }, signEvent, { servers = mediaServers(STREAM_SERVERS), fetcher, onProgress = null } = {}) {
const media = [...files.keys()].filter((p) => !/\.m3u8$/.test(p));
const lists = [...files.keys()].filter((p) => /\.m3u8$/.test(p) && p !== master);
const total = [...files.values()].reduce((n, f) => n + f.size, 0) || 1;
let lastError = null;
for (const server of servers) {
let done = 0;
const put = async (file) => {
const url = await uploadPublicMedia(file, signEvent, {
servers: [server], ...(fetcher ? { fetcher } : {}),
onProgress: onProgress && ((p) => onProgress(Math.min(1, (done + p * file.size) / total))),
});
done += file.size;
if (onProgress) onProgress(Math.min(1, done / total)); // (the playlists grow a little with the addresses in)
return url;
};
try {
const urls = new Map();
for (const p of media) urls.set(p, await put(files.get(p)));
for (const p of lists) {
const text = rewritePlaylist(await files.get(p).text(), urls);
urls.set(p, await put(new File([text], p, { type: 'application/vnd.apple.mpegurl' })));
}
const text = rewritePlaylist(await files.get(master).text(), urls);
return await put(new File([text], master, { type: 'application/vnd.apple.mpegurl' }));
} catch (error) { lastError = error; }
}
throw lastError || new MediaUploadError('failed');
}
|