From 8ff0bf21e308d4907cc2620d80f238a10341a28d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 11 Oct 2026 22:06:12 -0700 Subject: Feed: a picture from a host that won't hand over its bytes paints from a server-made copy. The app shrinks a post's photo to the column's size itself (an 1100px copy made once, kept on disk) — but only when the host allows CORS; one that doesn't (primal's blossom, nostrcheck, most personal sites) left the painting the original, and a 9528×6328 camera file froze a wheel scroll for 400 ms on its texture upload. vmedia now has /vmedia/pic.webp?s=col|tile&u=: the picture at 1100px wide (or a 360px square of its middle for the grid), made with libvips (shrink-on-load, so the original is never decoded whole; turned the way its EXIF says; alpha kept; stripped) as WebP, cached on disk and at the CDN for everyone. The usual guards: https on public addresses only, each redirect's host checked, 60 MB cap, the bytes sniffed (a GIF is refused — its copy would be a still), a few jobs at a time, 600 new copies per address per hour, an 8 GB cache. The client asks the server for the column copy when the host is known to keep its bytes (or has just refused, or there's no worker to make one here), keeps it in the same cache as the copies it makes, and takes the server's tile for the grid the same way; a picture the server has no copy for paints as before. The container gets libvips-tools (Dockerfile). tools/pic-proxy-test.js (canned server answers). Server measured on cs: a 3072×4608 photo in 0.5 s the first time, a millisecond after; feed-stability's two pre-existing asserts still fail on 491904e. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- tools/pic-proxy-test.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tools/pic-proxy-test.js (limited to 'tools') diff --git a/tools/pic-proxy-test.js b/tools/pic-proxy-test.js new file mode 100644 index 0000000..881867c --- /dev/null +++ b/tools/pic-proxy-test.js @@ -0,0 +1,132 @@ +// A picture from a host that won't hand over its bytes (no CORS) paints from +// the server's column copy (vmedia pic.webp) instead of the original, and the +// copy is kept in the media-thumb cache for the next visit. A GIF is never +// asked for (the copy would be a still); a picture the server has no copy for +// paints the original as before. The server is stood in for: its answers are +// canned. +// Run: bun tools/pic-proxy-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const still = (args) => { + const p = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + args.size, '-frames:v', '1', '-f', 'image2', '-c:v', args.codec, 'pipe:1']); + if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); } + return p.stdout; +}; +const ORIGINAL = still({ size: '640x480', codec: 'mjpeg' }); // stands in for the camera original +const COPY = still({ size: '320x240', codec: 'libwebp' }); // the server's column copy +const GIF = still({ size: '64x64', codec: 'gif' }); + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const PIC = { copied: 'https://pics.test/camera.jpg', gif: 'https://pics.test/loop.gif', lost: 'https://pics.test/odd.png' }; +const seeded = [post('garden ' + PIC.copied, 100), post('loop ' + PIC.gif, 200), post('broken ' + PIC.lost, 300)]; +const PIC_URL = (u, s = 'col') => 'https://v3.coinos.io/vmedia/pic.webp?s=' + s + '&u=' + encodeURIComponent(u); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setRequestInterception(true); +const asked = []; // [kind, url] +page.on('request', (r) => { + const u = new URL(r.url()); + if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) { + const src = u.searchParams.get('u'); + asked.push([u.pathname + '?s=' + u.searchParams.get('s'), src]); + if (src === PIC.lost) return r.respond({ status: 404, body: 'No picture' }); + return r.respond({ status: 200, contentType: 'image/webp', body: COPY, headers: { 'cache-control': 'public, max-age=604800', 'access-control-allow-origin': '*' } }); + } + if (u.host === 'pics.test') { + asked.push([r.resourceType(), r.url()]); + // no CORS header: a fetch from the page is refused, an paints it + if (r.url() === PIC.gif) return r.respond({ status: 200, contentType: 'image/gif', body: GIF }); + return r.respond({ status: 200, contentType: r.url().endsWith('.png') ? 'image/png' : 'image/jpeg', body: ORIGINAL }); + } + r.continue(); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +const pic = (word) => page.evaluate((w) => { + const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((r) => r.textContent.includes(w)); + const img = row && row.querySelector('img.note-img'); + return img && { src: img.getAttribute('src'), w: img.naturalWidth, h: img.naturalHeight, late: img.classList.contains('note-img-late'), ar: img.style.aspectRatio }; +}, word); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + localStorage.setItem('btc-wallet-network', 'regtest'); + // the host is already known to keep its bytes (two refusals, a week ago) + localStorage.setItem('coinos-thumb-nocors-v5', JSON.stringify({ 'pics.test': { n: 2, until: Date.now() + 86400000 } })); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the three pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000)); + + console.log('\n[a host that keeps its bytes]'); + // the cached rows paint at once (the original, lazily); the prepared paint + // that follows the warm swaps the copy in + await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].some((i) => i.getAttribute('src').startsWith('blob:')), null, 15000); + const p0 = await pic('garden'); + check('the row paints the server\'s copy, not the original', p0 && p0.src.startsWith('blob:') && p0.w === 320, JSON.stringify(p0)); + check('…with the box shaped like the picture', p0 && /^(320 \/ 240|4 \/ 3)$/.test(p0.ar), p0 && p0.ar); + check('the server was asked for the column copy', asked.some(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.copied)); + check('the original was never fetched by script', !asked.some(([k, u]) => k === 'fetch' && u === PIC.copied), JSON.stringify(asked.filter(([, u]) => u === PIC.copied))); + const kept = await page.evaluate(async (u) => { + const r = await (await caches.open('coinos-media-thumbs-v1')).match(u); + return r && { dims: r.headers.get('x-dims'), original: r.headers.get('x-original'), type: (await r.blob()).type }; + }, PIC.copied); + check('the copy is kept for the next visit', kept && kept.original === '0' && kept.type === 'image/webp' && /320/.test(kept.dims), JSON.stringify(kept)); + + console.log('\n[a GIF]'); + const p1 = await pic('loop'); + check('a GIF paints the original (a copy would be a still)', p1 && p1.src === PIC.gif, JSON.stringify(p1)); + check('…and the server was not asked', !asked.some(([k, u]) => k.startsWith('/vmedia/pic.webp') && u === PIC.gif)); + + console.log('\n[the server has no copy]'); + const p2 = await pic('broken'); + check('the original paints as before', p2 && p2.src === PIC.lost && p2.w === 640, JSON.stringify(p2)); + check('the server was asked once', asked.filter(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.lost).length === 1); + + console.log('\n[the next visit]'); + const before = asked.length; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000); + const p3 = await pic('garden'); + check('the kept copy paints without asking the server again', p3 && p3.src.startsWith('blob:') && p3.w === 320 + && !asked.slice(before).some(([k, u]) => k === '/vmedia/pic.webp?s=col' && u === PIC.copied), JSON.stringify(p3)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ pictures from hosts without CORS paint from the server\'s copy' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3