openvibers/openvibe.media Commits

Media: a private image takes a variant with its signature (the drive's previews), private and no-store (#121)

OP OpenVibers committed 29 days ago · 52784cb241 · 4 files, +27 −7

Image variants for private images with a signature (Media Fabric F4; for openvibe.download's previews).

`GET /o/:id?w=&fmt=` made variants only for public and unlisted images. A private or sandbox image now gets one too when the request carries a valid download signature (`exp`/`sig`).
- The variant is served with the same `private, no-store` headers as the original.
- Without a valid signature it answers 404, exactly as the original does, so nothing about its existence leaks.
- The signature is the same short-lived one MediaHub gets from `/download`. The size and format params don't need signing: they only choose among sizes of the same object.

`test/objects-api.test.js`:
- A private 300×200 PNG unsigned → 404.
- Signed `&w=96&fmt=webp` → 200 `image/webp`, `private, no-store`, 96 px wide and smaller than the original.
- A tampered signature → 404.

The README and media-fabric.md say so.

Changes

ddiff --git a/README.md b/README.md
iindex e49453f..5d37f8e 100644
---- a/README.md
++++ b/README.md
@@@ -585,7 +585,8 @@ thumbnail only while its VOD or clip is public. Tests: `test/security-*.test.js`
`GET /o/:id` serves object bytes by canonical id (`med_…`): public/unlisted
objects openly (and, for a JPEG, PNG, WebP, AVIF or GIF with a local copy, `?w=&fmt=` answers a resized variant exactly
as `/t/:id` does, from its own cache `OBJECT_VARIANTS_PATH`, default `object-variants`; Network's `/avatar/:username?s=`
--and the shared navbar ask for avatars this way; without a local copy, or when the cache is busy, the original), private ones only with a valid signature from
++and the shared navbar ask for avatars this way; a private image takes a variant only with its signature, served
++private and no-store like the original, which is how openvibe.download shows its previews; without a local copy, or when the cache is busy, the original), private ones only with a valid signature from
`/api/v2/:app/objects/:id/download`, deleted ones 410 (a deleted private object
without a signature is a 404 like any other private one). A native object in the
R2 popularity cache redirects to its verified R2 copy; a GET of a native object
ddiff --git a/docs/media-fabric.md b/docs/media-fabric.md
iindex f99506c..4bcb39a 100644
---- a/docs/media-fabric.md
++++ b/docs/media-fabric.md
@@@ -455,7 +455,8 @@ reads are metered by CDN logs. Nothing here bills delivery.
VODs, compute placement. (Slice 1 shipped: one on-demand 720p rung, queued by the master playlist / JSON download
and cut into the timeline. Slice 2 shipped 2026-10-15: image variants. `/t/:id?w=&fmt=` and `/o/:id?w=&fmt=` (public
images with a local copy) answer WebP, AVIF or JPEG at 64–640 px, kept in bounded LRU caches; openvibe.video's and
-- Live's cards and the shared navbar's avatars ask for them. The economics beyond an LRU budget, AV1 and compute
++ Live's cards and the shared navbar's avatars ask for them. A private image takes one with a valid signature (private,
++ no-store): the Download drive's previews. The economics beyond an LRU budget, AV1 and compute
placement are still open.)
- **F5 multi-CDN delivery:** Bunny path (B2 origin), R2 custom-domain path, OpenVibe edge first up to capacity, route
epochs, canary shifts with auto-revert, player QoE beacons, seek SLOs.
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 4bff3f8..1e86ac2 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -998,11 +998,12 @@ publicRouter.get('/:id', async (req, res) => {
'Cache-Control': obj.visibility === 'private' || sandbox ? 'private, no-store' : 'public, max-age=3600',
'Content-Disposition': contentDisposition(md.filename || obj.id, { inline: INLINE.test(mime) }),
};
-- // ?w=&fmt= on a public or unlisted image asks for a variant (Media Fabric F4, thumbnails/variants.js): resized
-- // from its local copy and cached (an avatar of 18 KB is under 1 KB at 96 px). Without a local copy, or when the
-- // cache is busy or the image cannot be decoded, the original answers.
++ // ?w=&fmt= on an image asks for a variant (Media Fabric F4, thumbnails/variants.js): resized from its local copy
++ // and cached (an avatar of 18 KB is under 1 KB at 96 px). Without a local copy, or when the cache is busy or the
++ // image cannot be decoded, the original answers. A private or sandbox image takes one only with a valid
++ // signature (the drive's previews, MediaHub), served with the same private, no-store headers as the original.
const variants = require('../thumbnails/variants');
-- const spec = VARIANT_MIME.test(mime) && obj.visibility !== 'private' && !sandbox ? variants.parse(req.query) : null;
++ const spec = VARIANT_MIME.test(mime) && (signed || (obj.visibility !== 'private' && !sandbox)) ? variants.parse(req.query) : null;
if (spec && spec.error) return res.status(400).json({ error: spec.error });
// A viewer of a native object, counted once a day without being identified (objects/popularity.js); the
// object tiering reads these counts. Sandbox objects are never tiered, so they are not counted.
ddiff --git a/test/objects-api.test.js b/test/objects-api.test.js
iindex 8b98f28..32d412a 100644
---- a/test/objects-api.test.js
++++ b/test/objects-api.test.js
@@@ -156,7 +156,24 @@ const crypto = require('crypto');
assert.deepStrictEqual([r.status, r.headers.get('content-type')], [200, 'image/png'], 'bytes that do not decode: the original');
r = await call('GET', `/o/${imgId}`, { bearer: null });
assert.deepStrictEqual([r.status, r.headers.get('content-type')], [200, 'image/png'], 'no query: the original');
-- console.log('✅ image variants: a public image in its asked size, the original otherwise');
++ // A private image takes a variant only with its signature (the drive's previews), private and no-store as the original.
++ r = await call('POST', O, { body: { kind: 'file', visibility: 'private' } });
++ const privId = r.body.id;
++ await call('PUT', `${O}/${privId}/content`, { raw: realPng, headers: { 'content-type': 'image/png' } });
++ r = await call('POST', `${O}/${privId}/complete`);
++ assert.strictEqual(r.body.lifecycle_status, 'ready');
++ r = await call('GET', `/o/${privId}?w=96&fmt=webp`, { bearer: null });
++ assert.strictEqual(r.status, 404, 'unsigned: indistinguishable from missing');
++ r = await call('GET', `${O}/${privId}/download`);
++ const privSigned = local(r.body.url);
++ const pv = await fetch(`${privSigned}&w=96&fmt=webp`);
++ const pbuf = Buffer.from(await pv.arrayBuffer());
++ assert.deepStrictEqual([pv.status, pv.headers.get('content-type'), pv.headers.get('cache-control')], [200, 'image/webp', 'private, no-store']);
++ assert.deepStrictEqual([(await require('sharp')(pbuf).metadata()).width, pbuf.length < realPng.length], [96, true]);
++ r = await call('GET', `${privSigned.replace(/sig=[^&]+/, 'sig=AAAA')}&w=96&fmt=webp`, { bearer: null });
++ assert.strictEqual(r.status, 404, 'a bad signature gets no variant either');
++ await db.run('DELETE FROM media_objects WHERE id = ?', [privId]); // the listing checks below count the objects
++ console.log('✅ image variants: a public image in its asked size, the original otherwise; a private one only signed');
// ── Service tokens ──
r = await call('POST', O, { bearer: tok(), headers: { 'x-ov-subject': `user:${SUBJECT}` }, body: { kind: 'asset', size_bytes: 3 } });