aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js61
-rw-r--r--tools/pic-proxy-test.js132
-rw-r--r--vmedia/Dockerfile4
-rw-r--r--vmedia/server.js123
4 files changed, 302 insertions, 18 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index bb2d788..ef427eb 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -6152,6 +6152,7 @@ export function messagesFeature(ctx) {
const READY_MS = 8000;
const mediaReady = new Map(); // URL -> decoded dimensions
const mediaFailed = new Set(); // URLs whose picture refused to load (not merely slow)
+ const corsRefused = new Set(); // pictures whose host wouldn't hand over the bytes (this session)
const mediaWarming = new Map();
// A post's photo is often the camera's original — 3024px wide, twelve
// megapixels — painted into a column a sixth of that. Chrome decodes it
@@ -6185,7 +6186,7 @@ export function messagesFeature(ctx) {
if (!res.ok) return null;
blob = await res.blob();
} catch (e) {
- if (e && e.corsRefused) noteNoCors(url); // refused, as corsFetch confirmed
+ if (e && e.corsRefused) { noteNoCors(url); corsRefused.add(url); } // refused, as corsFetch confirmed
return null;
}
const result = await makeImageThumbnails(blob, {
@@ -6216,7 +6217,10 @@ export function messagesFeature(ctx) {
if (mediaWarming.has(url)) return mediaWarming.get(url);
if (thumb && /^https?:/i.test(url)) {
const task = (async () => {
- const got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null));
+ let got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null));
+ // no copy to be made here (the host keeps its bytes, or no worker to
+ // make it): the server makes one
+ if (!got && (noCorsHost(url) || corsRefused.has(url) || typeof OffscreenCanvas === 'undefined')) got = await serverMediaThumb(url).catch(() => null);
if (got) {
// no copy (small already, or a GIF): the original is what paints,
// so it's the one decoded ahead — the row mustn't decode it on sight
@@ -7383,6 +7387,47 @@ export function messagesFeature(ctx) {
const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url);
const noPicture = new Set(), h264Failed = new Set();
const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session)
+ // A picture from a host that won't hand over its bytes (no CORS: primal's
+ // blossom, nostrcheck, most personal sites) can't have its column copy made
+ // here, so the server makes it (pic.webp: 1100px wide, or a 360px square
+ // for the grid; libvips, cached for everyone at the CDN). Without this the
+ // <img> painted the original, and a 9528×6328 camera file froze a wheel
+ // scroll for 400 ms on its texture upload. The copy is kept in the same
+ // cache as the ones made here, so the next visit paints it at once.
+ const serverPic = (url, shape) => (vmediaOk(url) && !/\.gif(\?|$)/i.test(url) ? VMEDIA + 'pic.webp?s=' + shape + '&u=' + encodeURIComponent(url) : null);
+ const serverPicFailed = new Set(); // pictures the server had no copy for (this session)
+ const picDims = (src) => new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => (img.decode ? img.decode() : Promise.resolve()).then(() => resolve({ width: img.naturalWidth, height: img.naturalHeight }), () => resolve(null));
+ img.onerror = () => resolve(null);
+ img.src = src;
+ });
+ async function serverPicBlob(url, shape) {
+ const src = serverPic(url, shape);
+ if (!src || serverPicFailed.has(shape + '|' + url)) return null;
+ let res;
+ try { res = await fetch(src, { signal: AbortSignal.timeout(READY_MS) }); } catch { return null; }
+ if (!res.ok) {
+ if (res.status !== 429 && res.status !== 503) serverPicFailed.add(shape + '|' + url); // busy is tried again
+ return null;
+ }
+ const blob = await res.blob();
+ return /^image\//.test(blob.type) && blob.size ? blob : null;
+ }
+ async function serverMediaThumb(url) {
+ const blob = await serverPicBlob(url, 'col');
+ if (!blob) return null;
+ const thumb = URL.createObjectURL(blob);
+ const dims = await picDims(thumb);
+ if (!dims || !dims.width) { URL.revokeObjectURL(thumb); serverPicFailed.add('col|' + url); return null; }
+ try {
+ const store = await thumbStore();
+ await store.put(url, new Response(blob, { headers: { 'x-dims': JSON.stringify(dims), 'x-original': '0' } }));
+ const keys = await store.keys();
+ for (const k of keys.slice(0, Math.max(0, keys.length - MEDIA_THUMB_MAX))) store.delete(k);
+ } catch {}
+ return { ...dims, thumb };
+ }
let hevcPlays;
const canHevc = () => hevcPlays ??= (() => {
try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; }
@@ -11388,13 +11433,21 @@ export function messagesFeature(ctx) {
if (typeof createImageBitmap === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof caches === 'undefined') return;
// the column copy is already here and smaller than the original
const kept = mediaReady.get(url)?.thumb;
- if (!kept && noCorsHost(url)) return;
+ if (!kept && (noCorsHost(url) || corsRefused.has(url))) {
+ // the host keeps its bytes: the server cuts the tile
+ const tile = await serverPicBlob(url, 'tile');
+ if (tile) await saveTileThumb(url, tile);
+ return;
+ }
let blob;
try {
const res = kept ? await fetch(kept) : await corsFetch(url, AbortSignal.timeout(READY_MS));
if (!res.ok) return;
blob = await res.blob();
- } catch { return; }
+ } catch (e) {
+ if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); }
+ return;
+ }
if (/gif/i.test(blob.type)) return; // animated: the tile shows the original
const result = await makeImageThumbnails(blob, { tileSize: TILE_PX });
if (result?.tile) await saveTileThumb(url, result.tile);
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 <img> 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);
diff --git a/vmedia/Dockerfile b/vmedia/Dockerfile
index 336ddff..3552f5f 100644
--- a/vmedia/Dockerfile
+++ b/vmedia/Dockerfile
@@ -1,8 +1,8 @@
-# vmedia: bun + ffmpeg. Built and run on cs (see vmedia/server.js):
+# vmedia: bun + ffmpeg + libvips. Built and run on cs (see vmedia/server.js):
# docker build -t vmedia vmedia/
# docker run -d --name vmedia --network net --restart unless-stopped --cpus 6 --memory 3g \
# -v ~/vmedia/server.js:/app/server.js:ro -v ~/vmedia/data:/data vmedia
FROM oven/bun:1
-RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libvips-tools ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
CMD ["bun", "server.js"]
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');