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)
Changes
@@@ -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 (imagesexcept 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@@@ -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; }@@@ -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,@@@ -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 classtitle: 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)}@@@ -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 choicesignal: AbortSignal.timeout(media.timeoutMs),nnew file mode 100644@@@ -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();++})();@@@ -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])) {@@@ -21,7 +21,7 @@ const BUDGETS = {jsBrotliKB: 62.5, // 56.5cssFiles: 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};
@@ -96,6 +96,12 @@ OpenVibe.Download v1 is a **private drive**, not a public host:
@@ -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
`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
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.
`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.
- 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
## How it works
@@ -52,6 +52,10 @@ button.sc-btn.sc-primary { border: 0; }
@@ -52,6 +52,10 @@ button.sc-btn.sc-primary { border: 0; }
.file-list .file:last-child { border-bottom: 0; }
.file-list .file:last-child { border-bottom: 0; }
.file-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.file-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.file-name { font-weight: 600; overflow-wrap: anywhere; }
.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; }
.file-meta { margin-top: 2px; }
.upload progress { max-width: 420px; }
.upload progress { max-width: 420px; }
.staff-item h2 { margin-top: 0; }
.staff-item h2 { margin-top: 0; }
@@ -59,6 +59,13 @@ function normalizeType(value) {
@@ -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
* 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.
* 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) {
function safeContentType(type) {
const t = normalizeType(type);
const t = normalizeType(type);
if (!t) return null;
if (!t) return null;
@@ -135,6 +142,7 @@ function formatBytes(n) {
@@ -135,6 +142,7 @@ function formatBytes(n) {
}
}
module.exports = {
module.exports = {
+ isPreviewable, PREVIEW_WIDTHS,
ID_RE, SLUG_RE, isFileId, isFolderId, isUploadId, isSlug, isUsername,
ID_RE, SLUG_RE, isFileId, isFolderId, isUploadId, isSlug, isUsername,
NAME_MAX, FOLDER_NAME_MAX, REASONS, REASON_TEXT, SUSPEND_AT_REPORTS, SUSPEND_IMMEDIATELY,
NAME_MAX, FOLDER_NAME_MAX, REASONS, REASON_TEXT, SUSPEND_AT_REPORTS, SUSPEND_IMMEDIATELY,
EXPIRY_MIN_HOURS, EXPIRY_MAX_HOURS, EXPIRY_DEFAULT_HOURS, EXPIRY_CHOICES,
EXPIRY_MIN_HOURS, EXPIRY_MAX_HOURS, EXPIRY_DEFAULT_HOURS, EXPIRY_CHOICES,
@@ -82,7 +82,11 @@ function createPageRoutes(ctx) {
@@ -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 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 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-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>
<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>`;
<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
@@ -247,6 +251,7 @@ ${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class
title: row.name,
title: row.name,
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],
body: html`<h1>${row.name}</h1>
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)]])}
${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>
<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>
<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)
@@ -418,6 +423,35 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
return undefined;
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) => {
r.get('/files/:id/download', mustSignIn('/files'), async (req, res) => {
const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
if (!row) return notFoundFile(req, res);
if (!row) return notFoundFile(req, res);
@@ -462,6 +496,13 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
@@ -462,6 +496,13 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
return share;
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) => {
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);
const share = await openShare(req, res);
if (!share) return undefined;
if (!share) return undefined;
@@ -474,6 +515,7 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
@@ -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' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],
body: html`<h1>${file.name}</h1>
body: html`<h1>${file.name}</h1>
${notice(`Shared with you by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
${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)]])}
${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>
<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>
${mine ? '' : reportForm(share)}
${mine ? '' : reportForm(share)}
@@ -150,13 +150,15 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -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
* 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);
const signed = await this.downloadUrl(objectId);
if (!signed.ok) return signed;
if (!signed.ok) return signed;
+ const url = variant ? `${signed.url}&w=${encodeURIComponent(variant.w)}&fmt=${encodeURIComponent(variant.fmt || 'webp')}` : signed.url;
try {
try {
- const res = await fetchImpl(signed.url, {
+ const res = await fetchImpl(url, {
headers: { ...(range ? { Range: String(range) } : {}) },
headers: { ...(range ? { Range: String(range) } : {}) },
redirect: 'follow', // Media may hand the read to its own storage provider; that is Media's choice
redirect: 'follow', // Media may hand the read to its own storage provider; that is Media's choice
signal: AbortSignal.timeout(media.timeoutMs),
signal: AbortSignal.timeout(media.timeoutMs),
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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();
+})();
@@ -106,6 +106,14 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -106,6 +106,14 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
'X-Content-Type-Options': 'nosniff',
'X-Content-Type-Options': 'nosniff',
'Accept-Ranges': 'bytes',
'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.
// A Range read, as Media serves one for a packed or large object.
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
if (range && (range[1] || range[2])) {
if (range && (range[1] || range[2])) {
@@ -21,7 +21,7 @@ const BUDGETS = {
@@ -21,7 +21,7 @@ const BUDGETS = {
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
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
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
externalFiles: 0, // 0
};
};