openvibers/openvibe.mediahub Commits

Pics: resize and convert (/i/<slug>/w/<width>.<webp|avif|jpg|png>) (#78)

OP OpenVibers committed 28 days ago · cd8dc36d8b · 7 files, +133 −1

The audit's Pics "image processing": an active picture at 320, 640, 1280, 1920 or 2560 px (never wider than
itself) as WebP, AVIF, JPEG or PNG, made here with sharp from the stored copy (Media's variants stop at 640
px), kept in a 64 MB LRU, served with the direct link's headers and 10-minute cache; an animated picture
keeps its frames as WebP. The picture page has a Resize and convert table of links; pics.image.variant
budgets the misses (60 a minute, 600 an hour).

Changes

ddiff --git a/README.md b/README.md
iindex b60fa14..3b77e4c 100644
---- a/README.md
++++ b/README.md
@@@ -148,6 +148,10 @@ An open, ad-free picture host: drop a picture, get a link to share or embed. Bui
`/i/<slug>.<ext>` (the direct link) and `/i/<slug>/thumb.webp` read them from Media and serve them with `nosniff`, a
sandboxing CSP, `Access-Control-Allow-Origin: *` and a 10-minute public cache, so the Media id never reaches a
browser and a hidden picture leaves shared caches within minutes.
++- **Resize and convert** ([server/pics/variants.js](server/pics/variants.js)): `/i/<slug>/w/<width>.<webp|avif|jpg|png>`
++ at 320, 640, 1280, 1920 or 2560 px (never wider than the picture), made here with sharp from the stored copy
++ (Media's own variants stop at 640 px), kept in a 64 MB LRU and served with the direct link's 10-minute cache; an
++ animated picture keeps its frames as WebP. The picture page lists them; `pics.image.variant` budgets the misses.
- **Unlisted:** `/i/<slug>` (8 random characters) is reachable by its link only, noindex, never listed; robots.txt
disallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCode
and HTML.
ddiff --git a/public/css/pics.css b/public/css/pics.css
iindex 4871d99..483ea6e 100644
---- a/public/css/pics.css
++++ b/public/css/pics.css
@@@ -136,3 +136,11 @@
.ovp-drop-inner { padding: 28px 14px 24px; }
}
@media (prefers-reduced-motion: reduce) { .ovp-tile img, .ovp-drop { transition: none; } .ovp-tile:hover img { transform: none; } }
++
++/* Resize and convert: formats by width, each cell a link. Scrolls on its own on a narrow screen. */
++.ovp-sizes { overflow-x: auto; }
++.ovp-size-table { border-collapse: collapse; min-width: 320px; font-variant-numeric: tabular-nums; }
++.ovp-size-table th, .ovp-size-table td { padding: 8px 12px; border-bottom: 1px solid var(--border, #1f2d47); text-align: left; white-space: nowrap; }
++.ovp-size-table thead th { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-faint, #7386a3); font-weight: 700; }
++.ovp-size-table tbody th { font-weight: 700; }
++.ovp-size-table a { display: inline-block; min-width: 44px; padding: 4px 0; }
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex a169688..f1f20a1 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -56,6 +56,8 @@ const BUDGETS = {
'media-hub.playlist.write': { minute: 120, hour: 1_200 },
// OpenVibe.Pics (server/pics): a burst of pictures dropped at once, but not a script emptying a disk into it.
'pics.image.upload': { minute: 30, hour: 300 },
++ // Resize and convert (server/pics/variants.js): each new variant is decoded and encoded here.
++ 'pics.image.variant': { minute: 60, hour: 600 },
'pics.image.write': { minute: 60, hour: 600 },
'pics.album.create': { minute: 10, hour: 100 },
'pics.report.create': { minute: 5, hour: 30 },
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 98c4ea2..c6c404d 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -35,6 +35,7 @@ const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME } = require('./layout');
const store = require('./store');
const { createPicsApi } = require('./api');
++const { createPicVariants } = require('./variants');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const FILE_CACHE = 'public, max-age=600, s-maxage=600';
@@@ -47,6 +48,7 @@ function createPicsRoutes(ctx) {
const base = cfg.url;
const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
const r = asyncRouter();
++ const variants = createPicVariants({ media, maxBytes: Number(process.env.MEDIAHUB_PICS_VARIANT_CACHE_BYTES) || 64 * 1024 * 1024 });
const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
const page = (req, res, o, status = 200) => send(res, status, {
@@@ -146,6 +148,27 @@ function createPicsRoutes(ctx) {
return serveObject(req, res, row, row.object_id, row.mime_type);
});
++ // Resize and convert (server/pics/variants.js): an active picture at a ladder width, as WebP, AVIF, JPEG or PNG.
++ r.get(/^\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\/w\/(\d{3,4})\.(webp|avif|jpg|png)$/, budgeted('pics.image.variant'), async (req, res) => {
++ req.params.slug = req.params[0];
++ const { row, status } = await picture(req, res);
++ if (!row || status !== 200 || row.state !== 'active') return res.status(status === 410 ? 410 : 404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('Not found');
++ const v = await variants.get(row, Number(req.params[1]), req.params[2]);
++ if (v.status !== 200) return res.status(v.status).set('Cache-Control', 'public, max-age=60').type('text/plain').send(v.status === 400 ? 'That size or format is not offered for this picture.' : 'Not available.');
++ res.status(200).set({
++ 'Content-Type': v.type,
++ 'X-Content-Type-Options': 'nosniff',
++ 'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
++ 'Cross-Origin-Resource-Policy': 'cross-origin',
++ 'Access-Control-Allow-Origin': '*',
++ 'X-Robots-Tag': 'noindex',
++ 'Cache-Control': FILE_CACHE,
++ 'Content-Disposition': `inline; filename="${row.slug}-${req.params[1]}.${req.params[2]}"`,
++ 'Content-Length': String(v.buffer.length),
++ });
++ return res.end(req.method === 'HEAD' ? undefined : v.buffer);
++ });
++
r.get('/i/:slug/thumb.webp', async (req, res) => {
const { row, status } = await picture(req, res);
if (!row || status !== 200) return res.status(status === 410 ? 410 : 404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('Not found');
@@@ -255,6 +278,11 @@ ${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
${copyRow('Link to this page', L.page, 'c-page')}${copyRow('Direct link (for <img> and forums)', L.direct, 'c-direct')}${copyRow('Markdown', L.markdown, 'c-md')}${copyRow('BBCode (forums)', L.bbcode, 'c-bb')}${copyRow('HTML', L.html, 'c-html')}
</section>
++${!hidden && variants.widthsFor(row).length ? html`<section class="ovp-section ovp-sizes" aria-labelledby="h-sizes"><h2 id="h-sizes">Resize and convert</h2>
++<p class="muted">The same picture at another width or in another format, each a link of its own${row.animated ? ' (WebP keeps the animation; the others take its first frame)' : ''}.</p>
++<table class="ovp-size-table"><thead><tr><th scope="col">Format</th>${variants.widthsFor(row).map((w) => html`<th scope="col">${w} px</th>`)}</tr></thead><tbody>
++${[['webp', 'WebP'], ['avif', 'AVIF'], ['jpg', 'JPEG'], ['png', 'PNG']].map(([fmt, label]) => html`<tr><th scope="row">${label}</th>${variants.widthsFor(row).map((w) => html`<td><a href="/i/${row.slug}/w/${w}.${fmt}" rel="nofollow">${w}</a></td>`)}</tr>`)}
++</tbody></table></section>` : ''}
${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><h2 id="h-yours">Yours</h2>
<form class="ovp-form" method="post" action="/i/${row.slug}/edit">
<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">
ddiff --git a/server/pics/variants.js b/server/pics/variants.js
nnew file mode 100644
iindex 0000000..1b79ff3
---- /dev/null
++++ b/server/pics/variants.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * Resize and convert (the owner's "image processing" for OpenVibe.Pics): a picture at a width on a fixed ladder and in
++ * another format, made here with sharp from the stored (already re-encoded) picture. OpenVibe.Media's own variants stop
++ * at 640 px, so the larger sizes are this service's.
++ *
++ * GET /i/<slug>/w/<width>.<webp|avif|jpg|png> width: 320, 640, 1280, 1920 or 2560 (never wider than the picture)
++ *
++ * A made variant is kept in a small in-process LRU (MEDIAHUB_PICS_VARIANT_CACHE_BYTES, 64 MB) and served with the direct
++ * link's 10-minute public cache, so a burst of the same variant is made once. An animated picture keeps its frames as
++ * WebP; the other formats get its first frame.
++ */
++const LADDER = [320, 640, 1280, 1920, 2560];
++const FORMATS = Object.freeze({
++ webp: { type: 'image/webp', apply: (img) => img.webp({ quality: 80 }) },
++ avif: { type: 'image/avif', apply: (img) => img.avif({ quality: 55, effort: 3 }) },
++ jpg: { type: 'image/jpeg', apply: (img) => img.jpeg({ quality: 85, mozjpeg: true }) },
++ png: { type: 'image/png', apply: (img) => img.png({ compressionLevel: 8 }) },
++});
++
++function createPicVariants({ media, sharp = require('sharp'), maxBytes = 64 * 1024 * 1024, maxSourceBytes = 40 * 1024 * 1024 }) {
++ const cache = new Map(); // key → { type, buffer } in least-recently-used order
++ let held = 0;
++ const inflight = new Map();
++
++ const remember = (key, v) => {
++ if (v.buffer.length > maxBytes / 4) return;
++ cache.set(key, v);
++ held += v.buffer.length;
++ for (const [k, old] of cache) {
++ if (held <= maxBytes) break;
++ cache.delete(k);
++ held -= old.buffer.length;
++ }
++ };
++
++ /** The widths this picture can be had at (the ladder up to its own width). */
++ const widthsFor = (row) => LADDER.filter((w) => w <= Number(row.width));
++
++ async function make(row, w, fmt) {
++ const got = await media.bytes(row.object_id);
++ if (!got.ok) return { status: got.status === 404 || got.status === 410 ? 404 : 502 };
++ const source = Buffer.from(await got.res.arrayBuffer());
++ if (source.length > maxSourceBytes) return { status: 413 };
++ const animated = Boolean(row.animated) && fmt === 'webp';
++ const img = sharp(source, { animated, failOn: 'error', limitInputPixels: 100_000_000 })
++ .resize({ width: w, withoutEnlargement: true });
++ const buffer = await FORMATS[fmt].apply(img).toBuffer();
++ return { status: 200, type: FORMATS[fmt].type, buffer };
++ }
++
++ /** → { status, type, buffer } for an active picture; 400 for a width or format off the ladder. */
++ async function get(row, w, fmt) {
++ if (!FORMATS[fmt] || !LADDER.includes(w) || w > Number(row.width)) return { status: 400 };
++ const key = `${row.id}:${w}:${fmt}`;
++ const hit = cache.get(key);
++ if (hit) {
++ cache.delete(key);
++ cache.set(key, hit);
++ return { status: 200, ...hit };
++ }
++ if (inflight.has(key)) return inflight.get(key);
++ const p = make(row, w, fmt).then((v) => {
++ if (v.status === 200) remember(key, { type: v.type, buffer: v.buffer });
++ return v;
++ }).finally(() => inflight.delete(key));
++ inflight.set(key, p);
++ return p;
++ }
++
++ return { get, widthsFor, LADDER, FORMATS: Object.keys(FORMATS), stats: () => ({ entries: cache.size, bytes: held }) };
++}
++
++module.exports = { createPicVariants, LADDER };
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 02296dc..7c88754 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -25,7 +25,7 @@ const READ = 'media-hub.api.read';
'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'media-hub.share.create', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
-- 'pics.album.create', 'pics.image.upload', 'pics.image.write', 'pics.report.create',
++ 'pics.album.create', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',
]);
for (const [name, own] of Object.entries(BUDGETS)) {
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
ddiff --git a/test/pics.test.js b/test/pics.test.js
iindex 5e052c2..b430c7a 100644
---- a/test/pics.test.js
++++ b/test/pics.test.js
@@@ -129,6 +129,22 @@ function multipart(fields, file) {
assert.ok((await t.get(`/i/${slug}`, { headers: PICS, as: kim })).text.includes('Delete this picture'));
});
++ await check('resize and convert: ladder widths up to the picture\'s own, in WebP, AVIF, JPEG or PNG', async () => {
++ const page = await t.get(`/i/${slug}`, { headers: PICS });
++ assert.ok(page.text.includes('Resize and convert') && page.text.includes(`href="/i/${slug}/w/640.webp"`));
++ assert.ok(!page.text.includes(`/i/${slug}/w/1280.`), 'never wider than the picture (800 px)');
++ const webp = await t.get(`/i/${slug}/w/320.webp`, { headers: PICS });
++ assert.strictEqual(webp.status, 200);
++ assert.strictEqual(webp.headers.get('content-type'), 'image/webp');
++ assert.strictEqual(webp.headers.get('cache-control'), 'public, max-age=600, s-maxage=600');
++ const meta = await sharp(webp.buffer).metadata();
++ assert.deepStrictEqual([meta.format, meta.width], ['webp', 320]);
++ const png = await t.get(`/i/${slug}/w/640.png`, { headers: PICS });
++ assert.deepStrictEqual([png.status, (await sharp(png.buffer).metadata()).format], [200, 'png']);
++ assert.strictEqual((await t.get(`/i/${slug}/w/1280.webp`, { headers: PICS })).status, 400, 'wider than the picture');
++ assert.strictEqual((await t.get(`/i/${slug}/w/500.webp`, { headers: PICS })).status, 400, 'off the ladder');
++ });
++
await check('the plain form uploads without JavaScript; a write from another site is refused', async () => {
const png = await photo('#a3c').png().toBuffer();
const form = multipart({ title: 'From the form' }, { name: 'pink.png', type: 'image/png', buffer: png });