aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 22:53:39 -0700
committerAdam Soltys <[email protected]>2026-10-18 22:53:39 -0700
commit31ef5c01527e110ed8a4faa500c72a1773586b50 (patch)
treeea4f2bac0dffd74c574fc5309f46c5b0f085ad22
parent15f3b1f80a5ed99e7d2db17e46318f4abe3797ce (diff)
downloadcoinosv3-31ef5c01527e110ed8a4faa500c72a1773586b50.tar.gz
coinosv3-31ef5c01527e110ed8a4faa500c72a1773586b50.zip
Sign media uploads with the user's Nostr login identity
-rw-r--r--src/app.js9
-rw-r--r--src/i18n.js1
-rw-r--r--src/media-upload.js13
-rw-r--r--tools/media-upload-identity-test.js63
4 files changed, 82 insertions, 4 deletions
diff --git a/src/app.js b/src/app.js
index 81718b1..eb51e2f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -12,7 +12,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec
import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
import { nip98Header } from './nip98.js';
-import { uploadPublicMedia, uploadStream, MediaUploadError } from './media-upload.js';
+import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
@@ -3895,6 +3895,7 @@ function mediaProgress(stage, fraction) {
}
async function publicMediaUpload(file) {
try {
+ const signEvent = await mediaUploadSigner(wallet, featureHook);
// a phone video is shrunk on the device first (see video-compress.js);
// a long one becomes an HLS stream, its master playlist the address
if (/^video\//.test(file?.type || '')) {
@@ -3903,15 +3904,17 @@ async function publicMediaUpload(file) {
const stream = await streamVideo(file, (p) => mediaProgress('shrink', p));
if (stream) {
mediaProgress('upload', 0);
- const url = await uploadStream(stream, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ const url = await uploadStream(stream, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) };
}
mediaProgress('shrink', 0);
file = await compressVideo(file, (p) => mediaProgress('shrink', p));
}
mediaProgress('upload', 0);
- return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ return await uploadPublicMedia(file, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
} catch (error) {
+ if (error instanceof MediaUploadError && error.code === 'no-identity') throw new Error(t('msgNoIdentity'));
+ if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized'));
if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig'));
if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported'));
throw new Error(t('msgUploadFailed'));
diff --git a/src/i18n.js b/src/i18n.js
index 562043d..998d233 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -252,6 +252,7 @@ const STR = {
msgUploadFailed: 'Upload failed. Check your connection and try again',
mediaUploadTooBig: 'That file is too big for the available media hosts',
mediaUploadUnsupported: 'That file format is not supported',
+ mediaUploadUnauthorized: 'The media server denied this upload. Check that your Nostr identity is allowed to upload there.',
msgPhoto: 'Photo',
searchPeopleFor: 'Search people for {q}',
msgEmojiSearch: 'Search emoji…',
diff --git a/src/media-upload.js b/src/media-upload.js
index d7a99e1..f851943 100644
--- a/src/media-upload.js
+++ b/src/media-upload.js
@@ -20,6 +20,16 @@ export class MediaUploadError extends Error {
}
}
+// 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;
@@ -64,7 +74,7 @@ function putWithProgress(url, { body, headers, signal }, onProgress) {
}
// BUD-02 upload. `signEvent` is deliberately injected: the app can use its
-// always-available wallet identity, while tests need no secret key.
+// signed-in Nostr identity, while tests need no secret key.
export async function uploadPublicMedia(file, signEvent, {
servers = mediaServers(PUBLIC_MEDIA_SERVERS),
fetcher = globalThis.fetch,
@@ -112,6 +122,7 @@ export async function uploadPublicMedia(file, signEvent, {
}
}
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);
diff --git a/tools/media-upload-identity-test.js b/tools/media-upload-identity-test.js
new file mode 100644
index 0000000..6d7e023
--- /dev/null
+++ b/tools/media-upload-identity-test.js
@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict';
+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools/pure';
+import { mediaUploadSigner, uploadPublicMedia, uploadStream, MediaUploadError } from '../src/media-upload.js';
+
+const walletKey = generateSecretKey(), loginKey = generateSecretKey();
+let walletSigns = 0, loginSigns = 0, resumes = 0;
+const wallet = { nostrSign: event => { walletSigns++; return finalizeEvent(event, walletKey); } };
+const signer = { signEvent(event) { loginSigns++; return finalizeEvent(event, loginKey); } };
+const login = {pubkey: getPublicKey(loginKey), signer};
+let id = login, resumed = signer;
+const hook = name => name === 'nostrLoginIdentity' ? id : (resumes++, resumed);
+const file = new File(['image'], 'test.png', {type: 'image/png'});
+const seen = [];
+// Haven's owner/allowlist rule: only this login key can upload.
+const fetcher = async (url, init) => {
+ const event = JSON.parse(atob(init.headers.authorization.slice(6)));
+ assert(verifyEvent(event));
+ seen.push(event.pubkey);
+ if (event.pubkey !== login.pubkey) return new Response('', {status:403, headers:{'x-reason':'only media signed by whitelisted pubkeys are allowed'}});
+ return Response.json({url:'https://haven.example/' + event.tags.find(t => t[0] === 'x')[1]});
+};
+const options = {servers:['https://haven.example'], fetcher};
+await assert.rejects(uploadPublicMedia(file, event => wallet.nostrSign(event), options), e => e instanceof MediaUploadError && e.code === 'unauthorized');
+const walletBefore = walletSigns;
+await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options);
+assert.equal(walletSigns, walletBefore);
+assert.equal(loginSigns, 1);
+assert.equal(resumes, 0);
+id = {...login, signer:null};
+await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options);
+assert.equal(resumes, 1);
+resumed = null;
+await assert.rejects(mediaUploadSigner(wallet, hook), e => e.code === 'no-identity');
+assert.equal(walletSigns, walletBefore, 'an unavailable login signer must never fall back to the wallet');
+id = null;
+const nativeEvent = await (await mediaUploadSigner(wallet, hook))({kind:24242, created_at:1, tags:[], content:''});
+assert.equal(nativeEvent.pubkey, getPublicKey(walletKey));
+id = login;
+await uploadStream({master:'master.m3u8', files:new Map([
+ ['clip.mp4', new File(['clip'], 'clip.mp4', {type:'video/mp4'})],
+ ['master.m3u8', new File(['#EXTM3U\nclip.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})],
+])}, await mediaUploadSigner(wallet, hook), options);
+assert(seen.slice(1).every(pk => pk === login.pubkey));
+
+// Exercise the app's actual routing for both regular files and HLS videos.
+const app = await Bun.file('src/app.js').text();
+const start = app.indexOf('async function publicMediaUpload(file) {');
+const end = app.indexOf('// Whether the wizard', start);
+const body = app.slice(start, end).replace("await import('./video-compress.js')", 'video');
+const stream = {dim:'10x10',seconds:2};
+const signs = [];
+const ui = {};
+const upload = new Function('wallet','featureHook','mediaUploadSigner','uploadPublicMedia','uploadStream','MediaUploadError','mediaProgress','ui','t','video', body + '\nreturn publicMediaUpload;')(
+ wallet, hook, mediaUploadSigner,
+ async (file, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'photo'; },
+ async (stream, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'video'; },
+ MediaUploadError, () => {}, ui, key => key,
+ {streamVideo: async () => stream});
+assert.equal(await upload(file), 'photo');
+assert.equal((await upload(new File(['video'], 'clip.mp4', {type:'video/mp4'}))).url, 'video');
+assert(signs.every(e => e.pubkey === login.pubkey));
+assert.equal(ui.mediaProgress, null);
+console.log('✓ Haven-style allowlist: login identity, signer resume, no wallet fallback, native identity and app file/HLS routing');