From 06dd5cf1da7293fa397d7b3fc523b1d0aa802800 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 2 Nov 2026 16:07:59 -0700 Subject: A face or cover photo whose host has lost the file is found on another server that holds the same blob. "No avatar for karnage?" (Adam, 2026-11-02): his kind 0 names a picture and a banner on media.21media.to, and that host answers "The requested blob was not found" for both — while blossom.primal.net, the server he publishes in his kind 10063, still has them, which is why other clients show his face. A picture named by its sha256 is a Blossom blob, served under the same name by any server holding the bytes: when the thumbnail maker finds the host without it (a 404, or a host that won't answer), the blob is looked for on the person's published servers and then the public ones we know, asked together, and the first that answers stands in — the thumbnail is made from it, the profile page's big face and banner paint from it, the feed's warm-up loads it — and the find is remembered on the device so the search runs once. tools/blob-alt-test.js serves a dead host and a published server holding the copies and checks the thumbnail, the profile page and the post rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs --- src/features/messages.js | 102 +++++++++++++++++++++++++++++++++++++++++++---- tools/blob-alt-test.js | 92 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 tools/blob-alt-test.js 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 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 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); } -- cgit v1.2.3