aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js23
-rw-r--r--src/features/messages.js5
-rw-r--r--src/i18n.js6
-rw-r--r--src/media-servers.js27
-rw-r--r--src/media-upload.js5
-rw-r--r--tools/composer-media-test.js21
-rw-r--r--tools/media-servers-test.js52
7 files changed, 133 insertions, 6 deletions
diff --git a/src/app.js b/src/app.js
index 88dfb76..416fd59 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,4 @@
+import { customMediaServers, saveMediaServers, resetMediaServers } from './media-servers.js';
// Bitcoin Wallet — UI controller (vanilla DOM, no framework).
//
// State lives in `ui` + the singleton `wallet`. Mutating handlers call render(),
@@ -3030,11 +3031,33 @@ function registerLinkHandler(scheme) {
toast(t('linkHandlerAsked'));
} catch (e) { toast(t('linkHandlerRefused')); }
}
+function mediaServersCard() {
+ if (ui.mediaServersDraft == null) ui.mediaServersDraft = (customMediaServers() || []).join('\n');
+ const save = (reset = false) => {
+ try {
+ if (reset) { resetMediaServers(); ui.mediaServersDraft = ''; }
+ else ui.mediaServersDraft = saveMediaServers(ui.mediaServersDraft).join('\n');
+ toast(t('mediaServersSaved'));
+ render();
+ } catch (e) { toast(t(e.message === 'invalid' ? 'mediaServersInvalid' : 'mediaServersSaveFailed')); }
+ };
+ return h('div', { class: 'card col', 'data-key': 'media-servers' },
+ h('h3', {}, t('mediaServersTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('mediaServersHow')),
+ h('textarea', { rows: 3, style: 'font-size:12px', 'aria-label': t('mediaServersTitle'),
+ placeholder: t('mediaServersDefault'), value: ui.mediaServersDraft, spellcheck: 'false',
+ onInput: (e) => { ui.mediaServersDraft = e.target.value; } }),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost small', onClick: () => save() }, t('syncStorageSave')),
+ h('button', { class: 'btn-ghost small', onClick: () => save(true) }, t('syncStorageReset'))));
+}
+
function nostrSettingsView() {
return h(
'div',
{ class: 'col', style: 'gap:16px' },
...featureAll('nostrSettingsCards'),
+ mediaServersCard(),
// The browser's own "open these links here" registry: njump's "Your
// default web app" is a web+nostr: link, and it opens whichever site
// asked for that scheme last. Needs a tap (the browser asks).
diff --git a/src/features/messages.js b/src/features/messages.js
index 57acc3b..ec564e8 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,3 +1,4 @@
+import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
// Communities are end-to-end private: there is no global discovery in
@@ -9446,7 +9447,7 @@ export function messagesFeature(ctx) {
// key and 16-byte nonce per file, the ciphertext uploaded as an opaque
// blob, the key riding inside the (already encrypted) message. The media
// host never sees a picture. coinos's own Blossom server stores only sync
- // envelopes, so these go to public hosts that take anonymous blobs.
+ // envelopes, so these go to the configured media hosts or public defaults.
const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com'];
const MEDIA_MAX = 20 * 1024 * 1024;
async function encryptFile(file) {
@@ -9473,7 +9474,7 @@ export function messagesFeature(ctx) {
const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt);
const header = 'Nostr ' + btoa(JSON.stringify(auth));
const urls = [];
- for (const server of MEDIA_SERVERS) {
+ for (const server of mediaServers(MEDIA_SERVERS)) {
try {
const r = await fetch(server + '/upload', {
method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000),
diff --git a/src/i18n.js b/src/i18n.js
index f8b0cca..586a7e6 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -857,6 +857,12 @@ const STR = {
msgBadInvite: 'Not a valid invite link',
msgInviteLoading: 'Fetching invite…',
msgInviteNotFound: 'Invite not found on its relays',
+ mediaServersTitle: 'Media servers',
+ mediaServersHow: 'Blossom servers for chat attachments and post media, including videos. Enter up to 6 HTTPS server addresses, one per line. Custom servers replace the public upload defaults. Saved on this device; existing uploads stay where they are.',
+ mediaServersDefault: 'Using public media servers',
+ mediaServersSaved: 'Media servers saved',
+ mediaServersInvalid: 'Enter 1–6 HTTPS server addresses without paths, credentials, queries or fragments.',
+ mediaServersSaveFailed: 'Could not save media servers on this device.',
syncStorageTitle: 'Sync storage',
syncStorageHow: 'Wallet state too large for a relay event is encrypted and stored as a blob on these Blossom servers (one per line). Any Blossom server works. Run your own or use another provider.',
syncStorageSave: 'Save servers',
diff --git a/src/media-servers.js b/src/media-servers.js
new file mode 100644
index 0000000..e2220d7
--- /dev/null
+++ b/src/media-servers.js
@@ -0,0 +1,27 @@
+// Upload destinations are a device preference, separate from wallet sync storage.
+const KEY = 'btc-wallet-media-servers';
+export function parseMediaServers(text) {
+ const entries = String(text).trim().split(/\s+/).filter(Boolean);
+ if (!entries.length) throw new Error('invalid');
+ const servers = [...new Set(entries.map((entry) => {
+ let url;
+ try { url = new URL(entry); } catch { throw new Error('invalid'); }
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !/^\/*$/.test(url.pathname)) throw new Error('invalid');
+ return url.origin;
+ }))];
+ if (servers.length > 6) throw new Error('invalid');
+ return servers;
+}
+export function customMediaServers() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(KEY));
+ return Array.isArray(saved) ? parseMediaServers(saved.join('\n')) : null;
+ } catch { return null; }
+}
+export const mediaServers = (defaults) => customMediaServers() || defaults;
+export function saveMediaServers(text) {
+ const servers = parseMediaServers(text);
+ localStorage.setItem(KEY, JSON.stringify(servers));
+ return servers;
+}
+export function resetMediaServers() { localStorage.removeItem(KEY); }
diff --git a/src/media-upload.js b/src/media-upload.js
index e45d2fe..d7a99e1 100644
--- a/src/media-upload.js
+++ b/src/media-upload.js
@@ -1,3 +1,4 @@
+import { mediaServers } from './media-servers.js';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
@@ -65,7 +66,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.
export async function uploadPublicMedia(file, signEvent, {
- servers = PUBLIC_MEDIA_SERVERS,
+ servers = mediaServers(PUBLIC_MEDIA_SERVERS),
fetcher = globalThis.fetch,
timeoutMs = 5 * 60_000,
onProgress = null, // (fraction 0..1) as the bytes go out
@@ -132,7 +133,7 @@ export function rewritePlaylist(text, urls) {
return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`);
}).join('\n');
}
-export async function uploadStream({ files, master }, signEvent, { servers = STREAM_SERVERS, fetcher, onProgress = null } = {}) {
+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;
diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js
index 512b8bd..94b343a 100644
--- a/tools/composer-media-test.js
+++ b/tools/composer-media-test.js
@@ -18,7 +18,7 @@ const page = await browser.newPage();
const uploads = [];
await page.setRequestInterception(true);
// every public media host the upload may try first (nothing leaves the machine)
-const HOSTS = /blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/;
+const HOSTS = /media\.example|blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/;
page.on('request', (req) => {
const u = req.url();
if (HOSTS.test(u) && req.method() === 'PUT') {
@@ -46,6 +46,23 @@ try {
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('button[aria-label="Settings"]').click());
+ await sleep(300);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find(e => e.textContent.includes('Nostr')).click());
+ await page.waitForSelector('[data-key="media-servers"] textarea');
+ const setServers = async (value) => page.evaluate(value => {
+ const card = document.querySelector('[data-key="media-servers"]');
+ const input = card.querySelector('textarea'); input.value = value;
+ input.dispatchEvent(new Event('input', {bubbles:true}));
+ card.querySelector('button').click();
+ }, value);
+ await setServers('https://media.example/');
+ check('settings save a normalized custom server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]'));
+ await setServers('https://media.example/path');
+ check('invalid settings preserve the previous server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]'));
+ await page.evaluate(() => document.querySelectorAll('[data-key="media-servers"] button')[1].click());
+ check('Use default clears the override', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === null));
+ await setServers('https://media.example');
const base = cacheKeyFor(mn + '\n');
const A = 'a'.repeat(63) + '9';
await page.evaluate(([k, a]) => {
@@ -91,7 +108,7 @@ try {
document.querySelector('.thread-reply-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
});
const landed = await waitFor(() => /nostr\.download\/f{64}\.gif/.test(document.querySelector('.thread-reply-input')?.value || ''), 15000);
- check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1,
+ check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1 && uploads[0] === 'https://media.example/upload',
JSON.stringify(await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)) + ' uploads=' + uploads.length);
check('...and the preview shows it', await waitFor(() => !!document.querySelector('.draft-preview img, .draft-preview .note-img, .draft-preview a'), 5000));
// the upload re-rendered the page: the field must still be editable
diff --git a/tools/media-servers-test.js b/tools/media-servers-test.js
new file mode 100644
index 0000000..c5cad2f
--- /dev/null
+++ b/tools/media-servers-test.js
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import { parseMediaServers, customMediaServers, mediaServers, saveMediaServers, resetMediaServers } from '../src/media-servers.js';
+import { uploadPublicMedia, uploadStream, PUBLIC_MEDIA_SERVERS } from '../src/media-upload.js';
+const stored = new Map();
+globalThis.localStorage = { getItem: k => stored.get(k) ?? null, setItem: (k, v) => stored.set(k, v), removeItem: k => stored.delete(k) };
+assert.equal(customMediaServers(), null);
+assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS);
+assert.deepEqual(saveMediaServers('https://FIRST.example/\nhttps://first.example https://second.example/'), ['https://first.example', 'https://second.example']);
+for (const bad of ['', 'http://host.test', 'https://host.test/path', 'https://user:[email protected]', 'https://host.test?x=1', 'https://host.test#x', 'https://good.test nope', Array.from({length:7}, (_,i)=>`https://host${i}.test`).join('\n')]) {
+ assert.throws(() => saveMediaServers(bad));
+ assert.equal(customMediaServers()[0], 'https://first.example', 'invalid input must not overwrite saved servers');
+}
+const calls = [];
+const fetcher = async (url, opts) => {
+ calls.push(url);
+ if (url.startsWith('https://first.')) return new Response('', {status:503});
+ if (opts.method === 'HEAD') return new Response('');
+ return Response.json({url: 'https://second.example/' + 'a'.repeat(64)});
+};
+const sign = async e => e;
+await uploadPublicMedia(new File(['picture'], 'photo.png', {type:'image/png'}), sign, {fetcher});
+assert(calls.some(u => u.startsWith('https://second.')));
+assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u)));
+calls.length = 0;
+await uploadStream({master:'master.m3u8', files:new Map([
+ ['video.mp4', new File(['video'], 'video.mp4', {type:'video/mp4'})],
+ ['master.m3u8', new File(['#EXTM3U\nvideo.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})],
+])}, sign, {fetcher});
+assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u)));
+await assert.rejects(uploadPublicMedia(new File(['x'], 'x'), sign, {fetcher: async url => {
+ assert(calls.includes(url), 'must not fall back to public hosts');
+ return new Response('', {status:503});
+}}));
+// Exercise the attachment uploader used by DMs, communities and White Noise.
+const source = await Bun.file('src/features/messages.js').text();
+const start = source.indexOf(' async function uploadEncrypted(');
+const end = source.indexOf(' const imetaTag', start);
+const encryptedCalls = [];
+const uploadEncrypted = new Function('mediaServers', 'MEDIA_SERVERS', 'fetch', 't',
+ source.slice(start, end) + '\nreturn uploadEncrypted;')(
+ mediaServers, ['https://public.example'], async (url, init) => {
+ encryptedCalls.push({url, init});
+ return Response.json({url: url.replace('/upload', '/cipher')});
+ }, key => key);
+const cipher = new Uint8Array([9, 8, 7]);
+const urls = await uploadEncrypted({pubkey:'a'.repeat(64), signer:{signEvent: sign}}, {cipher, x:'b'.repeat(64)});
+assert.deepEqual(urls, ['https://first.example/cipher', 'https://second.example/cipher']);
+assert(encryptedCalls.every(c => c.init.body === cipher && c.init.headers['content-type'] === 'application/octet-stream'));
+resetMediaServers();
+assert.equal(customMediaServers(), null);
+assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS);
+console.log('✓ Media server validation, persistence, custom-only file/HLS/encrypted attachment uploads, failover, and reset');