openvibers/openvibe.mediahub Commits
Pics: resize and convert (/i/<slug>/w/<width>.<webp|avif|jpg|png>) (#78)
Changes
@@@ -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`, asandboxing CSP, `Access-Control-Allow-Origin: *` and a 10-minute public cache, so the Media id never reaches abrowser 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.txtdisallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCodeand HTML.@@@ -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; }@@@ -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 },@@@ -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}">nnew file mode 100644@@@ -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 };@@@ -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`);@@@ -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 });
@@ -148,6 +148,10 @@ An open, ad-free picture host: drop a picture, get a link to share or embed. Bui
@@ -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
`/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
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.
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
- **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
disallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCode
and HTML.
and HTML.
@@ -136,3 +136,11 @@
@@ -136,3 +136,11 @@
.ovp-drop-inner { padding: 28px 14px 24px; }
.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; } }
@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; }
@@ -56,6 +56,8 @@ const BUDGETS = {
@@ -56,6 +56,8 @@ const BUDGETS = {
'media-hub.playlist.write': { minute: 120, hour: 1_200 },
'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.
// 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 },
'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.image.write': { minute: 60, hour: 600 },
'pics.album.create': { minute: 10, hour: 100 },
'pics.album.create': { minute: 10, hour: 100 },
'pics.report.create': { minute: 5, hour: 30 },
'pics.report.create': { minute: 5, hour: 30 },
@@ -35,6 +35,7 @@ const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
@@ -35,6 +35,7 @@ const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME } = require('./layout');
const { send, assetVersion, SITE_NAME } = require('./layout');
const store = require('./store');
const store = require('./store');
const { createPicsApi } = require('./api');
const { createPicsApi } = require('./api');
+const { createPicVariants } = require('./variants');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const FILE_CACHE = 'public, max-age=600, s-maxage=600';
const FILE_CACHE = 'public, max-age=600, s-maxage=600';
@@ -47,6 +48,7 @@ function createPicsRoutes(ctx) {
@@ -47,6 +48,7 @@ function createPicsRoutes(ctx) {
const base = cfg.url;
const base = cfg.url;
const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
const r = asyncRouter();
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 hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
const page = (req, res, o, status = 200) => send(res, status, {
const page = (req, res, o, status = 200) => send(res, status, {
@@ -146,6 +148,27 @@ function createPicsRoutes(ctx) {
@@ -146,6 +148,27 @@ function createPicsRoutes(ctx) {
return serveObject(req, res, row, row.object_id, row.mime_type);
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) => {
r.get('/i/:slug/thumb.webp', async (req, res) => {
const { row, status } = await picture(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');
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>` : ''}
@@ -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>
<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')}
${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>
</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>
${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">
<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}">
<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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 };
@@ -25,7 +25,7 @@ const READ = 'media-hub.api.read';
@@ -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.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.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',
'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)) {
for (const [name, own] of Object.entries(BUDGETS)) {
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
@@ -129,6 +129,22 @@ function multipart(fields, file) {
@@ -129,6 +129,22 @@ function multipart(fields, file) {
assert.ok((await t.get(`/i/${slug}`, { headers: PICS, as: kim })).text.includes('Delete this picture'));
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 () => {
await check('the plain form uploads without JavaScript; a write from another site is refused', async () => {
const png = await photo('#a3c').png().toBuffer();
const png = await photo('#a3c').png().toBuffer();
const form = multipart({ title: 'From the form' }, { name: 'pink.png', type: 'image/png', buffer: png });
const form = multipart({ title: 'From the form' }, { name: 'pink.png', type: 'image/png', buffer: png });