aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js102
-rw-r--r--tools/blob-alt-test.js92
2 files changed, 186 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index e3cdc8d..fb9a9d6 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1019,6 +1019,68 @@ export function messagesFeature(ctx) {
}
}
}
+ // ---- a Blossom blob gone from its host, found on another ---------------
+ // A picture named by its sha256 (…/<64 hex>.jpg) is a Blossom blob: any
+ // server holding the same bytes serves them under the same name. When the
+ // host in the URL no longer has it — Karnage's face and banner on
+ // media.21media.to answered "blob not found" (2026-11-02) while
+ // blossom.primal.net, the server he publishes in his kind 10063, still had
+ // both — the picture is looked for on the servers the person published
+ // and then on the public ones we know, and the first that answers stands
+ // in for it: the thumbnail is made from it, the big face and the banner
+ // paint from it. Remembered on the device, so the search runs once.
+ const BLOB_RE = /^(https?:\/\/[^/?#]+)(?:\/[^?#]*)?\/([0-9a-f]{64})(\.[a-z0-9]{2,5})?(?:[?#].*)?$/i;
+ const BLOB_ALT_HOSTS = ['https://blossom.primal.net', 'https://blossom.ditto.pub', 'https://nostr.download',
+ 'https://blossom.nostr.build', 'https://blossom.band', 'https://cdn.satellite.earth'];
+ const BLOB_ALT_KEY = 'btc-wallet-blob-alts';
+ const blobAlts = new Map(); // url -> the copy that answered, or null when none did
+ (() => { try { for (const [k, v] of Object.entries(JSON.parse(localStorage.getItem(BLOB_ALT_KEY) || '{}'))) if (typeof v === 'string') blobAlts.set(k, v); } catch {} })();
+ const saveBlobAlts = () => {
+ try {
+ const found = [...blobAlts].filter(([, v]) => v).slice(-200);
+ localStorage.setItem(BLOB_ALT_KEY, JSON.stringify(Object.fromEntries(found)));
+ } catch {}
+ };
+ const blobProbing = new Map();
+ const blobAltOf = (url) => (url && blobAlts.get(url)) || null;
+ // the servers a person publishes for their files (BUD-03), asked only when
+ // one of their pictures has gone missing
+ const blobServersOf = async (pk) => {
+ try {
+ const evs = await queryOn(PROFILE_RELAYS, { kinds: [10063], authors: [pk], limit: 1 }, 4000);
+ const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ return ev ? ev.tags.filter((x) => x[0] === 'server' && /^(https:\/\/|http:\/\/localhost(:\d+)?\/)/i.test(x[1] || ''))
+ .map((x) => x[1].replace(/\/+$/, '')) : [];
+ } catch { return []; }
+ };
+ // does an <img> load from here? (no CORS needed to find out)
+ const imageAnswers = (url, ms = 8000) => new Promise((resolve) => {
+ const im = new Image();
+ const timer = setTimeout(() => { im.onload = im.onerror = null; im.src = ''; resolve(false); }, ms);
+ im.onload = () => { clearTimeout(timer); resolve(true); };
+ im.onerror = () => { clearTimeout(timer); resolve(false); };
+ im.src = url;
+ });
+ function findBlobAlt(url, pk) {
+ if (blobAlts.has(url)) return Promise.resolve(blobAlts.get(url));
+ if (blobProbing.has(url)) return blobProbing.get(url);
+ const m = BLOB_RE.exec(url || '');
+ if (!m || typeof Image === 'undefined') return Promise.resolve(null);
+ const task = (async () => {
+ const origin = m[1].toLowerCase(), name = m[2] + (m[3] || '');
+ const hosts = [...new Set([...(pk ? await blobServersOf(pk) : []), ...BLOB_ALT_HOSTS])]
+ .filter((h) => h.toLowerCase() !== origin);
+ // asked together (a host that hangs mustn't hold the rest), the
+ // person's own servers preferred among those that answer
+ const answers = await Promise.all(hosts.map((h) => imageAnswers(h + '/' + name).then((ok) => (ok ? h + '/' + name : null))));
+ const alt = answers.find(Boolean) || null;
+ blobAlts.set(url, alt);
+ if (alt) saveBlobAlts();
+ return alt;
+ })().finally(() => blobProbing.delete(url));
+ blobProbing.set(url, task);
+ return task;
+ }
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway
@@ -1075,8 +1137,20 @@ export function messagesFeature(ctx) {
// the browser's cache is welcome: the face was just painted from this
// very URL, and 'reload' re-downloaded every original (38 MB in one
// scroll) — a stale copy still makes a fine thumbnail
- const res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW));
- if (!res.ok) throw new Error('http ' + res.status);
+ let res = null;
+ try { res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW)); } catch (e) {
+ if (e && e.corsRefused) noteNoCors(url);
+ if (!BLOB_RE.test(url)) throw e;
+ }
+ // the host no longer has it (or won't answer): the same blob on
+ // another server is as good — and the face it paints is this one
+ if (!res || !res.ok) {
+ const alt = BLOB_RE.test(url) ? await findBlobAlt(url, pk) : null;
+ if (!alt) throw new Error('http ' + (res ? res.status : 'none'));
+ res = await corsFetch(alt, AbortSignal.timeout(THUMB_SLOW));
+ if (!res.ok) throw new Error('http ' + res.status);
+ scheduleRepaint(); // the big face, where one is up, can paint from the copy now
+ }
bmp = await createImageBitmap(await res.blob());
// centre-crop to a square, the way background-size:cover paints it
const side = Math.min(bmp.width, bmp.height);
@@ -3857,8 +3931,9 @@ export function messagesFeature(ctx) {
const punk = localPunk(p.picture, big);
if (punk) return `url(${JSON.stringify(punk)})`;
const local = p.thumb && p.thumbFor === p.picture ? p.thumb : null;
- if (!local) return `url(${JSON.stringify(p.picture)})`;
- return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`;
+ const pic = blobAltOf(p.picture) || p.picture; // the same blob elsewhere, when the host lost it
+ if (!local) return `url(${JSON.stringify(pic)})`;
+ return (big ? `url(${JSON.stringify(pic)}),` : '') + `url(${JSON.stringify(local)})`;
}
const FACE_WAIT_MS = 1000;
@@ -4064,7 +4139,17 @@ export function messagesFeature(ctx) {
};
function warmBanner(pk) {
const b = bannerOf(pk);
- if (b && !mediaReady.has(b)) return warmMedia(b, { thumb: true }).then(() => scheduleRepaint());
+ if (!b) return;
+ const shown = blobAltOf(b) || b;
+ if (mediaReady.has(shown)) return;
+ return warmMedia(shown, { thumb: true }).then(async () => {
+ // the host lost it: the same blob on another server paints instead
+ if (!mediaReady.has(shown) && shown === b && BLOB_RE.test(b)) {
+ const alt = await findBlobAlt(b, pk);
+ if (alt) await warmMedia(alt, { thumb: true });
+ }
+ scheduleRepaint();
+ });
}
function prefetchProfilePage(pk) {
if (!pk || prefetched.has(pk)) return;
@@ -7100,7 +7185,7 @@ export function messagesFeature(ctx) {
return urls;
}
const avatarUrl = (p, pk) => p?.picture
- ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture
+ ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || blobAltOf(p.picture) || p.picture
: punkSmallUrl(pk);
async function warmAvatar(pk, deadline) {
let p = liveProfileOf(pk);
@@ -13329,7 +13414,8 @@ export function messagesFeature(ctx) {
// doesn't drop by the height of a cover photo when it arrives
: urlish(lp.banner);
const draftPic = draft && urlish(draft.picture);
- if (bannerUrl && !draft && !mediaReady.has(bannerUrl)) warmBanner(pk);
+ const bannerShown = bannerUrl && !draft ? blobAltOf(bannerUrl) || bannerUrl : bannerUrl;
+ if (bannerUrl && !draft && !mediaReady.has(bannerShown)) warmBanner(pk);
return h('div', { class: 'col', style: 'gap:16px' },
// full header on profiles too — losing the search button here made
// finding the NEXT person a trek back home
@@ -13338,7 +13424,7 @@ export function messagesFeature(ctx) {
t(full || lp.eventAt || lp.name || lp.picture ? 'profFetchFailed' : 'profNotFound'),
h('button', { class: 'btn-sm', onClick: () => { fetchFullProfile(pk); render(); } }, t('retry'))) : null,
h('div', { class: 'card col', style: 'gap:12px' },
- bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerUrl, mediaReady.get(bannerUrl)))})` }) : null,
+ bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerShown, mediaReady.get(bannerShown)))})` }) : null,
h('div', { class: 'row gap6', style: 'align-items:center' },
draftPic
? h('div', { class: 'chat-avatar profile-avatar ava-img', style: `background-image:url(${JSON.stringify(draftPic)})` })
diff --git a/tools/blob-alt-test.js b/tools/blob-alt-test.js
new file mode 100644
index 0000000..4d958f2
--- /dev/null
+++ b/tools/blob-alt-test.js
@@ -0,0 +1,92 @@
+// A face or banner whose host has lost the file is found on another server
+// that holds the same Blossom blob (same sha256 name): the one the person
+// publishes in their kind 10063 first. The small avatar's thumbnail is made
+// from the copy, the profile page's big face and banner paint from it, and
+// the find is remembered on the device.
+// Run: bun tools/blob-alt-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { nip19 } from 'nostr-tools';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sk = generateSecretKey(), PK = getPublicKey(sk), now = Math.floor(Date.now() / 1000);
+const H1 = 'c11a29785c1af623a45ebfdb23860170a9d0c0ead31c1bcb209085cd662f7a17';
+const H2 = '479711427c577f040a5bf8cb05c47f75abfef37580893dd63b66f777c34dc327';
+const png = (color) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', `color=c=${color}:size=320x320`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const FACE = png('orange'), COVER = png('teal');
+const html = await buildHtml({ minify: true, pwa: false });
+const hits = { dead: 0, alt: 0 };
+let origin = '';
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const u = new URL(req.url);
+ if (u.pathname.startsWith('/dead/')) { hits.dead++; return new Response('{"error":"The requested blob was not found."}', { status: 404, headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' } }); }
+ if (u.pathname === `/alt/${H1}.png`) { hits.alt++; return new Response(FACE, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); }
+ if (u.pathname === `/alt/${H2}.png`) { hits.alt++; return new Response(COVER, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); }
+ if (u.pathname === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } });
+ return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
+} });
+origin = server.url.origin;
+const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [], content: `Post ${i}: the harbour at dusk, boats coming in.` }, sk));
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Karnage', picture: `${origin}/dead/${H1}.png`, banner: `${origin}/dead/${H2}.png`, about: 'Saying the quiet part out loud.' }) }, sk);
+const servers = finalizeEvent({ kind: 10063, created_at: now, tags: [['server', `${origin}/alt/`]], content: '' }, sk);
+const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(origin) ? r.continue() : r.abort()); // the public hosts: unreachable here
+ await page.evaluateOnNewDocument(([notes, profile, servers]) => {
+ class FakeSocket extends EventTarget {
+ constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set();
+ setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); }
+ emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
+ message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); }
+ send(raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
+ if (m[0] !== 'REQ') return;
+ const [, id, f] = m; this.subs.add(id);
+ let evs = [];
+ if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until)
+ && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100);
+ if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile];
+ if (f.kinds?.includes(10063) && f.authors?.includes(servers.pubkey)) evs = [servers];
+ if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id));
+ setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30);
+ }
+ close() { this.readyState = 3; this.emit('close', new Event('close')); }
+ }
+ Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ window.WebSocket = FakeSocket;
+ }, [notes, profile, servers]);
+ const bg = (sel) => page.evaluate((s) => document.querySelector(s)?.style.backgroundImage || '', sel);
+ // the feed: the small face's thumbnail is made from the copy
+ await page.goto(origin + '/feed', { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 });
+ // the copy is found and the thumbnail made from it (the probe and the
+ // thumbnail's own fetch: two hits); the row keeps the look it was admitted
+ // with, the next paint wears the face
+ for (let i = 0; i < 80 && hits.alt < 2; i++) await sleep(250);
+ assert.ok(hits.dead >= 1 && hits.alt >= 2, `the dead host was asked (${hits.dead}) and the copy fetched for the thumbnail (${hits.alt})`);
+ console.log('✓ the thumbnail is made from the copy on the published server');
+ const remembered = await page.evaluate(() => localStorage.getItem('btc-wallet-blob-alts') || '');
+ assert.ok(remembered.includes(`/alt/${H1}.png`), 'the find is remembered on the device');
+ // the profile page (the screen that showed an empty circle and no cover):
+ // big face and banner from the copies, the post rows' small faces from
+ // the thumbnail
+ await page.goto(origin + '/' + nip19.npubEncode(PK), { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction((h) => (document.querySelector('.profile-banner')?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, H2)
+ .catch(async () => { throw new Error('banner not from the copy: ' + await bg('.profile-banner')); });
+ const face = await bg('.profile-avatar');
+ assert.ok(face.includes(`/alt/${H1}.png`), 'the big face paints from the copy: ' + face);
+ console.log('✓ the profile page paints the big face and the banner from the copies');
+ const rowSel = `.note-post[data-zap-post="${notes[0].id}"] .ava-img`;
+ await page.waitForFunction((s, h) => /^url\("data:/.test(document.querySelector(s)?.style.backgroundImage || '') || (document.querySelector(s)?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, rowSel, H1)
+ .catch(async () => { throw new Error('post row face: ' + await page.evaluate((id) => [...document.querySelector('.note-post[data-zap-post="' + id + '"]')?.querySelectorAll('[class*=avatar], .ava-img') || []].map((e) => e.outerHTML.slice(0, 220)).join(' || '), notes[0].id)); });
+ assert.deepEqual(errors, []);
+ console.log('✓ the post rows wear the face, and no page errors');
+} finally { await browser.close(); server.stop(true); }