openvibers/openvibe.mediahub Commits

Download: previews — images show a picture in the drive list, on their page and on a share page (Media's signed variants) (#51)

OP OpenVibers committed 29 days ago · fb200b7cf1 · 8 files, +142 −5

Previews in the Download drive (openvibe.download). Images show a picture of themselves; until now every file was a name and a size.

- **Where they appear:**
  - The file list shows a 96 px thumbnail for each image (JPEG, PNG, WebP, GIF, AVIF; never SVG), and the extension for anything else.
  - The file page and a single-file share page show a 640 px preview.
- **Routes:** `/files/:id/preview?w=96|640`, owner only, and `/s/:slug/preview`, for whoever may open the share (the same checks as the share page, and not counted as a download). Other widths fall back to 96.
- **The bytes:** OpenVibe.Media's signed WebP variant of the private object (Media#121; the media client's `bytes()` takes `variant: { w, fmt }`), passed through with `nosniff`, a sandbox CSP, `private, max-age=300` and no attachment. A response that isn't a previewable image type is refused.
- **CSS:** thumbnails and the preview figure; the home's CSS brotli budget goes 4.1 → 4.4 KB (measured 4.2).
- **Test stub:** the Media stub answers `?w=&fmt=` like Media (signature still required for private).

`test/drive-previews.test.js` (4 checks):
- the list and page markup;
- the signed variant request (`exp`/`sig` then `w=96&fmt=webp`), its headers, the two sizes;
- no preview for others' files, non-images or SVG, or when signed out;
- the share preview for the named viewer, 403 for others, and no download counted.

The drive-download, drive-pages, drive-shares, drive-upload, drive-reports and perf-budget tests pass.

Deploy after Media#121.

Changes

ddiff --git a/README.md b/README.md
iindex b39bd83..0232c1e 100644
---- a/README.md
++++ b/README.md
@@@ -96,6 +96,12 @@ OpenVibe.Download v1 is a **private drive**, not a public host:
`Content-Security-Policy: default-src 'none'; sandbox` and a real `Content-Type` only for a short safe list (images
except SVG, audio, video, PDF, plain text, zip). HTML, SVG and anything executable come back as
`application/octet-stream`. The name survives in any script (`filename*=UTF-8''…`) and can never break the header.
++- **Previews** (`/files/:id/preview?w=96|640`, and `/s/:slug/preview` for a single-file share):
++ - An image (JPEG, PNG, WebP, GIF or AVIF, never SVG) shows a picture of itself in the list (96 px) and on its page
++ and share page (640 px).
++ - OpenVibe.Media makes it as a signed WebP variant of the private object, and MediaHub passes it through: `nosniff`,
++ a sandbox CSP, `private, max-age=300`, never an attachment and never counted as a download.
++ - Only the owner gets one, or whoever may open the share. Other files show their extension.
- Per-person limits, all from the environment: 2 GB stored, 512 MB per file, 50 uploads a day, 20 live share links.
## How it works
ddiff --git a/public/css/app.css b/public/css/app.css
iindex f7ca059..10ba9fc 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -52,6 +52,10 @@ button.sc-btn.sc-primary { border: 0; }
.file-list .file:last-child { border-bottom: 0; }
.file-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.file-name { font-weight: 600; overflow-wrap: anywhere; }
++.file-thumb { width: 48px; height: 48px; flex: none; border-radius: 6px; object-fit: cover; background: var(--surface-2, #111a2e); }
++.file-thumb-type { display: grid; place-items: center; font: 700 10px/1 ui-monospace, monospace; letter-spacing: .04em; color: var(--text-secondary, #96a7c2); border: 1px solid var(--border, #1f2d47); }
++.file-preview { margin: 0 0 16px; }
++.file-preview img { display: block; max-width: 100%; max-height: 480px; height: auto; border-radius: 8px; background: var(--surface-2, #111a2e); }
.file-meta { margin-top: 2px; }
.upload progress { max-width: 420px; }
.staff-item h2 { margin-top: 0; }
ddiff --git a/server/drive/rules.js b/server/drive/rules.js
iindex 8bbaa2c..9cd1d33 100644
---- a/server/drive/rules.js
++++ b/server/drive/rules.js
@@@ -59,6 +59,13 @@ function normalizeType(value) {
* anything executable — is served as application/octet-stream, so a browser never treats an upload as a document
* of its own. SVG is deliberately out: it is a script container wearing an image's type.
*/
++/** Images a preview is made of (Media's variants: JPEG, PNG, WebP, GIF's first frame, AVIF); never SVG. */
++function isPreviewable(type) {
++ return /^image\/(jpeg|png|webp|gif|avif)$/.test(normalizeType(type) || '');
++}
++/** The preview widths the pages ask for: a list thumbnail and the file page's picture. */
++const PREVIEW_WIDTHS = [96, 640];
++
function safeContentType(type) {
const t = normalizeType(type);
if (!t) return null;
@@@ -135,6 +142,7 @@ function formatBytes(n) {
}
module.exports = {
++ isPreviewable, PREVIEW_WIDTHS,
ID_RE, SLUG_RE, isFileId, isFolderId, isUploadId, isSlug, isUsername,
NAME_MAX, FOLDER_NAME_MAX, REASONS, REASON_TEXT, SUSPEND_AT_REPORTS, SUSPEND_IMMEDIATELY,
EXPIRY_MIN_HOURS, EXPIRY_MAX_HOURS, EXPIRY_DEFAULT_HOURS, EXPIRY_CHOICES,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a4630a9..dbb5679 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -82,7 +82,11 @@ function createPageRoutes(ctx) {
const SHARE_STATE = { live: ['live', 'ok'], expired: ['expired', 'warn'], revoked: ['revoked', 'bad'], suspended: ['suspended — with staff', 'bad'] };
const shareBadge = (state) => badge(SHARE_STATE[state] ? SHARE_STATE[state][0] : state, SHARE_STATE[state] ? SHARE_STATE[state][1] : '');
-- const fileRow = (row) => html`<li class="file">
++ // An image shows a small preview of itself (GET /files/:id/preview, Media's signed variant); anything else, its type.
++ const fileThumb = (row) => (rules.isPreviewable(row.content_type)
++ ? html`<img class="file-thumb" src="/files/${row.id}/preview?w=96" alt="" width="48" height="48" loading="lazy" decoding="async">`
++ : html`<span class="file-thumb file-thumb-type" aria-hidden="true">${((/\.([A-Za-z0-9]{1,4})$/.exec(String(row.name || '')) || [])[1] || 'file').toUpperCase()}</span>`);
++ const fileRow = (row) => html`<li class="file">${fileThumb(row)}
<span class="file-main"><a class="file-name" href="/files/${row.id}">${row.name}</a>
<span class="file-meta small muted">${fileSize(row)} · ${row.content_type} · added ${time(row.created_at)}</span></span>
<a class="sc-btn" href="/files/${row.id}/download">Download</a></li>`;
@@@ -247,6 +251,7 @@ ${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class
title: row.name,
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],
body: html`<h1>${row.name}</h1>
++${rules.isPreviewable(row.content_type) ? html`<figure class="file-preview"><img src="/files/${row.id}/preview?w=640" alt="${row.name}" decoding="async"></figure>` : ''}
${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type, row.sha256 || '—', time(row.created_at)]])}
<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a></p>
<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>
@@@ -418,6 +423,35 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
return undefined;
}
++ /**
++ * A preview of an image: Media's resized WebP of the object (a signed variant; the original when Media cannot make
++ * one), for the person allowed to see the file. Private to their browser for five minutes; never an attachment.
++ */
++ async function sendPreview(req, res, row) {
++ if (!row || !rules.isPreviewable(row.content_type)) return res.status(404).type('text/plain').send('No preview.');
++ const w = rules.PREVIEW_WIDTHS.includes(Number(req.query.w)) ? Number(req.query.w) : rules.PREVIEW_WIDTHS[0];
++ const got = await media.bytes(row.object_id, { variant: { w, fmt: 'webp' } });
++ const type = got.ok ? String(got.res.headers.get('content-type') || '') : '';
++ if (!got.ok || !rules.isPreviewable(type)) return res.status(got.ok ? 404 : 502).type('text/plain').send('No preview.');
++ res.status(200).set({
++ 'Content-Type': type,
++ 'X-Content-Type-Options': 'nosniff',
++ 'Content-Security-Policy': "default-src 'none'; sandbox",
++ 'X-Robots-Tag': 'noindex',
++ 'Cache-Control': 'private, max-age=300',
++ });
++ const len = got.res.headers.get('content-length');
++ if (len) res.set('Content-Length', len);
++ if (!got.res.body) return res.end();
++ try { await pipeline(Readable.fromWeb(got.res.body), res); } catch { res.destroy(); }
++ return undefined;
++ }
++
++ r.get('/files/:id/preview', mustSignIn('/files'), async (req, res) => {
++ const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
++ return sendPreview(req, res, row);
++ });
++
r.get('/files/:id/download', mustSignIn('/files'), async (req, res) => {
const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
if (!row) return notFoundFile(req, res);
@@@ -462,6 +496,13 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
return share;
}
++ // A single-file share's picture, for whoever may open the share (the same checks as the page; not a download).
++ r.get('/s/:slug/preview', mustSignIn('here', 'A share link still needs an OpenVibe account.'), async (req, res) => {
++ const share = await openShare(req, res);
++ if (!share) return undefined;
++ return sendPreview(req, res, share.file_id ? await fileById(share) : null);
++ });
++
r.get('/s/:slug', mustSignIn('here', 'A share link still needs an OpenVibe account: uploads, downloads and reports here all belong to a signed-in person.'), async (req, res) => {
const share = await openShare(req, res);
if (!share) return undefined;
@@@ -474,6 +515,7 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],
body: html`<h1>${file.name}</h1>
${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
++${rules.isPreviewable(file.content_type) ? html`<figure class="file-preview"><img src="/s/${share.slug}/preview?w=640" alt="${file.name}" decoding="async"></figure>` : ''}
${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}
<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>
${mine ? '' : reportForm(share)}
ddiff --git a/server/media/client.js b/server/media/client.js
iindex 077bc14..f9482a1 100644
---- a/server/media/client.js
++++ b/server/media/client.js
@@@ -150,13 +150,15 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
/**
* The object's bytes, streamed. `range` (a Range header value) is passed through, so a browser can
-- * resume a download; the caller answers 206 from the upstream status and Content-Range.
++ * resume a download; the caller answers 206 from the upstream status and Content-Range. `variant`
++ * ({ w, fmt }) asks Media for a resized image instead (its signed variants of a private object).
*/
-- async bytes(objectId, { range = null } = {}) {
++ async bytes(objectId, { range = null, variant = null } = {}) {
const signed = await this.downloadUrl(objectId);
if (!signed.ok) return signed;
++ const url = variant ? `${signed.url}&w=${encodeURIComponent(variant.w)}&fmt=${encodeURIComponent(variant.fmt || 'webp')}` : signed.url;
try {
-- const res = await fetchImpl(signed.url, {
++ const res = await fetchImpl(url, {
headers: { ...(range ? { Range: String(range) } : {}) },
redirect: 'follow', // Media may hand the read to its own storage provider; that is Media's choice
signal: AbortSignal.timeout(media.timeoutMs),
ddiff --git a/test/drive-previews.test.js b/test/drive-previews.test.js
nnew file mode 100644
iindex 0000000..7b1e3ed
---- /dev/null
++++ b/test/drive-previews.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * Previews: an image in the drive shows a small picture of itself in the list and a larger one on its page, made by
++ * OpenVibe.Media as a signed variant of the private object (GET /o/:id?…&w=&fmt=webp) and passed through MediaHub.
++ * Only the file's owner (or, for a single-file share, whoever may open the share) gets one; other files get their
++ * type instead; a preview is private to the browser, never an attachment, never a download.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { bytes, upload, share } = require('./helpers/drive');
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const sam = t.network.addUser('sam');
++ const lee = t.network.addUser('lee');
++ const put = async (name, data, contentType) => (await upload(t, kim, data, { name, contentType })).json().file;
++ const photo = await put('holiday.png', bytes(256, 'png'), 'image/png');
++ const notes = await put('notes.txt', bytes(64, 'txt'), 'text/plain');
++ const vector = await put('logo.svg', bytes(64, 'svg'), 'image/svg+xml');
++
++ await check('the list shows a picture of each image and the type of everything else', async () => {
++ const page = await t.get('/files', { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes(`<img class="file-thumb" src="/files/${photo.id}/preview?w=96" alt="" width="48" height="48" loading="lazy" decoding="async">`));
++ assert.match(page.text, /<span class="file-thumb file-thumb-type" aria-hidden="true">TXT<\/span>/);
++ assert.match(page.text, /<span class="file-thumb file-thumb-type" aria-hidden="true">SVG<\/span>/, 'an SVG is never rendered: its type, not a picture');
++ const one = await t.get(`/files/${photo.id}`, { as: kim });
++ assert.ok(one.text.includes(`<figure class="file-preview"><img src="/files/${photo.id}/preview?w=640" alt="holiday.png" decoding="async"></figure>`));
++ assert.ok(!(await t.get(`/files/${notes.id}`, { as: kim })).text.includes('file-preview'));
++ });
++
++ await check('a preview is Media\'s signed WebP variant, private to the browser and never an attachment', async () => {
++ const before = t.media.requests.length;
++ const r = await t.get(`/files/${photo.id}/preview?w=96`, { as: kim });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.headers.get('content-type'), r.headers.get('cache-control'), r.headers.get('x-content-type-options'), r.headers.get('content-disposition')], ['image/webp', 'private, max-age=300', 'nosniff', null]);
++ assert.match(r.buffer.toString(), /^variant:96:webp:/);
++ const asked = t.media.requests.slice(before).find((q) => q.path.startsWith('/o/'));
++ assert.match(asked.query, /[?&]exp=\d+&sig=[^&]+&w=96&fmt=webp$/, 'signed, then the size and the format');
++ const big = await t.get(`/files/${photo.id}/preview?w=640`, { as: kim });
++ assert.match(big.buffer.toString(), /^variant:640:webp:/);
++ const odd = await t.get(`/files/${photo.id}/preview?w=1234`, { as: kim });
++ assert.match(odd.buffer.toString(), /^variant:96:webp:/, 'only the two sizes the pages use');
++ });
++
++ await check('no preview for other people\'s files, for files that are not images, or for SVG', async () => {
++ assert.strictEqual((await t.get(`/files/${photo.id}/preview?w=96`, { as: sam })).status, 404);
++ assert.strictEqual((await t.get(`/files/${notes.id}/preview?w=96`, { as: kim })).status, 404);
++ assert.strictEqual((await t.get(`/files/${vector.id}/preview?w=96`, { as: kim })).status, 404);
++ const anon = await t.get(`/files/${photo.id}/preview?w=96`);
++ assert.ok([302, 303, 401].includes(anon.status), `signed out: ${anon.status}`);
++ });
++
++ await check('a single-file share shows its picture to whoever may open it; it is not a download', async () => {
++ const slug = (await share(t, kim, { file_id: photo.id, hours: 24, usernames: ['sam'] })).json().share.slug;
++ const page = await t.get(`/s/${slug}`, { as: sam });
++ assert.ok(page.text.includes(`<figure class="file-preview"><img src="/s/${slug}/preview?w=640" alt="holiday.png" decoding="async"></figure>`));
++ const pv = await t.get(`/s/${slug}/preview?w=640`, { as: sam });
++ assert.deepStrictEqual([pv.status, pv.headers.get('content-type')], [200, 'image/webp']);
++ assert.strictEqual((await t.get(`/s/${slug}/preview?w=640`, { as: lee })).status, 403, 'not shared with lee');
++ const shares = (await t.get(`/api/v1/files/${photo.id}`, { as: kim })).json().shares;
++ assert.strictEqual(shares.find((x) => x.slug === slug).downloads, 0, 'looking is not downloading');
++ });
++
++ done();
++})();
ddiff --git a/test/helpers/media.js b/test/helpers/media.js
iindex 745bcd6..2000c0b 100644
---- a/test/helpers/media.js
++++ b/test/helpers/media.js
@@@ -106,6 +106,14 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
'X-Content-Type-Options': 'nosniff',
'Accept-Ranges': 'bytes',
};
++ // ?w=&fmt= on an image: Media's resized variant (here a stand-in body naming the size; private needs the signature).
++ const vw = url.searchParams.get('w');
++ if (vw && /^image\/(jpeg|png|webp|gif|avif)$/.test(obj.mime_type || '')) {
++ const fmt = url.searchParams.get('fmt') || 'webp';
++ const body = Buffer.from(`variant:${vw}:${fmt}:${obj.id}`);
++ res.writeHead(200, { ...headers, 'Content-Type': `image/${fmt}`, 'Content-Length': String(body.length) });
++ return res.end(body);
++ }
// A Range read, as Media serves one for a packed or large object.
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
if (range && (range[1] || range[2])) {
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex effbc5b..4e4384c 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -21,7 +21,7 @@ const BUDGETS = {
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
cssRawKB: 18.3, // measured 16.6 (shared 4.1.8: four steps two by two); was 16.5 at 15.0
-- cssBrotliKB: 4.1, // 3.7
++ cssBrotliKB: 4.4, // 3.7; 4.2 with the drive previews (10-15)
externalFiles: 0, // 0
};