diff options
| author | Adam Soltys <[email protected]> | 2026-10-11 22:06:12 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-11 22:06:12 -0700 |
| commit | 8ff0bf21e308d4907cc2620d80f238a10341a28d (patch) | |
| tree | 4e6e6a3995fc4d469313100b69ef9768c330aadc /vmedia/server.js | |
| parent | a34769e3f60aa5b6920e4e2c9e0a9ee1013cacd1 (diff) | |
| download | coinosv3-8ff0bf21e308d4907cc2620d80f238a10341a28d.tar.gz coinosv3-8ff0bf21e308d4907cc2620d80f238a10341a28d.zip | |
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 <img> 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=<picture>: 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.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'vmedia/server.js')
| -rw-r--r-- | vmedia/server.js | 123 |
1 files changed, 111 insertions, 12 deletions
diff --git a/vmedia/server.js b/vmedia/server.js index c543d6c..b2f5bae 100644 --- a/vmedia/server.js +++ b/vmedia/server.js @@ -9,6 +9,14 @@ // for Linux plays its sound over black). The first ask streams the // transcode as it's made (fragmented MP4); once done it's a plain // faststart file served in ranges and cached for everyone. +// /vmedia/pic.webp?s=col|tile&u=<picture> the picture at the size the +// feed paints it: 1100px wide for the column, or a 360px square of its +// middle for the grid. The app makes these copies itself from a host that +// allows CORS; one that doesn't (primal's blossom, nostrcheck, most +// personal sites) left the <img> painting the original, and a 9528×6328 +// camera file froze a wheel scroll for 400 ms on its texture upload. +// libvips makes the copy (shrink-on-load: the original is never decoded +// whole), turned the way its EXIF says, stripped, as WebP. // // Only https sources on public addresses; ffmpeg may speak nothing else. A // few jobs at a time, a per-address hourly allowance for new transcodes, @@ -19,11 +27,13 @@ import { isIP } from 'node:net'; import { createHash } from 'node:crypto'; const DATA = process.env.DATA || '/data'; -const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', WORK = DATA + '/work'; -for (const d of [THUMBS, CLIPS, WORK]) mkdirSync(d, { recursive: true }); -const THUMB_CAP = 2e9, CLIP_CAP = 25e9; -const THUMB_JOBS = 8, CLIP_JOBS = 2, CLIP_QUEUE = 6; +const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work'; +for (const d of [THUMBS, CLIPS, PICS, WORK]) mkdirSync(d, { recursive: true }); +const THUMB_CAP = 2e9, CLIP_CAP = 25e9, PIC_CAP = 8e9; +const THUMB_JOBS = 8, CLIP_JOBS = 2, CLIP_QUEUE = 6, PIC_JOBS = 4, PIC_QUEUE = 60; const CLIP_PER_HOUR = 12; // new transcodes per address +const PIC_PER_HOUR = 600; // new picture copies per address (a scroll through a feed asks for dozens) +const PIC_BYTES = 60e6, PIC_FETCH_MS = 20_000, PIC_MAKE_MS = 30_000; const MAX_SECONDS = 900; const FAIL_TTL = 3_600_000; @@ -133,12 +143,12 @@ async function thumb(src) { // ---- transcodes ----------------------------------------------------------- const clipSlots = limiter(CLIP_JOBS); const clipJobs = new Map(); // key -> { work, started, done, ok } -const allowance = new Map(); // ip -> [times] -function allowed(ip) { - const now = Date.now(), list = (allowance.get(ip) || []).filter((t) => now - t < 3_600_000); - if (list.length >= CLIP_PER_HOUR) { allowance.set(ip, list); return false; } - list.push(now); allowance.set(ip, list); - if (allowance.size > 5000) allowance.delete(allowance.keys().next().value); +const allowance = new Map(), picAllowance = new Map(); // ip -> [times] +function allowed(ip, book = allowance, perHour = CLIP_PER_HOUR) { + const now = Date.now(), list = (book.get(ip) || []).filter((t) => now - t < 3_600_000); + if (list.length >= perHour) { book.set(ip, list); return false; } + list.push(now); book.set(ip, list); + if (book.size > 5000) book.delete(book.keys().next().value); return true; } function startClip(src, key) { @@ -231,6 +241,91 @@ async function clipResponse(req, src, ip) { { headers: { 'Content-Type': 'video/mp4', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*' } }); } +// ---- pictures --------------------------------------------------------------- +const SHAPES = { + col: ['--size', '1100x>'], // the 560px column at 2x; never enlarged + tile: ['--size', '360x360>', '--smartcrop', 'centre'], // a 120–180px grid tile at 2–3x +}; +const PIC_V = 1; +// what the bytes say the file is (the header is often octet-stream, sometimes a lie) +export function pictureKind(b) { + const at = (i, str) => b.length >= i + str.length && String.fromCharCode(...b.subarray(i, i + str.length)) === str; + if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg'; + if (at(1, 'PNG') && b[0] === 0x89) return 'png'; + if (at(0, 'GIF8')) return 'gif'; + if (at(0, 'RIFF') && at(8, 'WEBP')) return 'webp'; + if (at(4, 'ftyp')) return 'heif'; // avif, heic + if (at(0, 'BM')) return 'bmp'; + if (at(0, 'II*\0') || at(0, 'MM\0*')) return 'tiff'; + return null; +} +// the picture's bytes, each redirect's host checked like the first, capped +export async function fetchPicture(src, fetchFn = fetch) { + let url = src; + for (let hop = 0; hop < 5; hop++) { + const res = await fetchFn(url, { redirect: 'manual', signal: AbortSignal.timeout(PIC_FETCH_MS), headers: { 'User-Agent': 'coinos-vmedia', Accept: 'image/*,*/*;q=0.5' } }); + if ([301, 302, 303, 307, 308].includes(res.status)) { + try { await res.body?.cancel(); } catch {} + let to = null; + try { to = await sourceOk(new URL(res.headers.get('location') || '', url).href); } catch {} + if (!to) return null; + url = to; + continue; + } + if (!res.ok || !res.body) return null; + if (+(res.headers.get('content-length') || 0) > PIC_BYTES) { try { await res.body.cancel(); } catch {} return null; } + const chunks = []; + let total = 0; + const reader = res.body.getReader(); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > PIC_BYTES) { try { await reader.cancel(); } catch {} return null; } + chunks.push(value); + } + return Buffer.concat(chunks); + } + return null; +} +const picSlots = limiter(PIC_JOBS); +const picJobs = new Map(); +async function makePic(src, shape, key) { + const out = `${PICS}/${key}.webp`, work = `${WORK}/${key}.src`, part = `${WORK}/${key}.webp`; + try { + const bytes = await fetchPicture(src).catch(() => null); + const kind = bytes && pictureKind(bytes); + // a GIF is left alone: the copy would be its first frame, still + if (!kind || kind === 'gif') { failed.set(key, Date.now()); return null; } + await Bun.write(work, bytes); + const p = Bun.spawn(['vipsthumbnail', work, ...SHAPES[shape], '--export-profile', 'srgb', '-o', part + '[Q=82,strip]'], + { stdout: 'ignore', stderr: 'pipe', env: { ...process.env, VIPS_CONCURRENCY: '2', VIPS_WARNING: '0' } }); + const timer = setTimeout(() => p.kill(9), PIC_MAKE_MS); + await p.exited.finally(() => clearTimeout(timer)); + if (p.exitCode !== 0 || sizeOf(part) < 50) { + console.log('pic failed', src, (await new Response(p.stderr).text()).slice(-300)); + failed.set(key, Date.now()); + return null; + } + renameSync(part, out); + return out; + } finally { + for (const f of [work, part]) try { unlinkSync(f); } catch {} + } +} +async function picResponse(req, src, shape, ip) { + const key = keyOf('p' + PIC_V + '|' + shape + '|' + src), out = `${PICS}/${key}.webp`; + if (existsSync(out)) { touch(out); return fileResponse(req, out, 'image/webp'); } + if (failedLately(key)) return nope(404, 'No picture'); + if (!picJobs.has(key)) { + if (picSlots.waiting() >= PIC_QUEUE) return nope(503, 'Busy'); + if (!allowed(ip, picAllowance, PIC_PER_HOUR)) return nope(429, 'Too many'); + picJobs.set(key, picSlots(() => makePic(src, shape, key)).catch(() => null).finally(() => picJobs.delete(key))); + } + const p = await picJobs.get(key); + return p ? fileResponse(req, p, 'image/webp') : nope(404, 'No picture'); +} + // ---- the caches stay a size ------------------------------------------------ function trim(dir, cap) { try { @@ -243,7 +338,7 @@ function trim(dir, cap) { } catch {} } export function startTrim() { - const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); }; + const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); }; go(); setInterval(go, 10 * 60_000); // work left over from a restart is no good to anyone @@ -254,10 +349,14 @@ export async function handle(req) { const u = new URL(req.url); if (!['GET', 'HEAD'].includes(req.method)) return nope(405, 'GET only'); const route = u.pathname.replace(/^\/vmedia/, ''); - if (route !== '/thumb.jpg' && route !== '/h264.mp4') return nope(404, 'Not found'); + if (route !== '/thumb.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found'); const src = await sourceOk(u.searchParams.get('u') || ''); if (!src) return nope(400, 'Not a public https URL'); const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown'; + if (route === '/pic.webp') { + const shape = u.searchParams.get('s') || 'col'; + return SHAPES[shape] ? picResponse(req, src, shape, ip) : nope(400, 'Unknown shape'); + } if (route === '/thumb.jpg') { const p = await thumb(src); return p ? fileResponse(req, p, 'image/jpeg') : nope(404, 'No picture'); |
