diff options
| -rw-r--r-- | src/features/messages.js | 61 | ||||
| -rw-r--r-- | tools/pic-proxy-test.js | 132 | ||||
| -rw-r--r-- | vmedia/Dockerfile | 4 | ||||
| -rw-r--r-- | vmedia/server.js | 123 |
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'); |
