openvibers/openvibe.media Commits

Move Media Cache-Control onto openvibe-shared/cache-policy (#7)

OP OpenVibers committed 2 Oct 2026 · a84a6692c2 · 9 files, +67 −14

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high) · 1 fix round

https://github.com/OpenVibers/OpenVibe.Media/pull/7

Changes

ddiff --git a/server/me/routes.js b/server/me/routes.js
iindex b307f8c..5e9477f 100644
---- a/server/me/routes.js
++++ b/server/me/routes.js
@@@ -28,6 +28,7 @@
const express = require('express');
const { http, ids, staff } = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
const config = require('../config');
const drill = require('../drill');
const { extractToken } = require('../user-auth');
@@@ -43,7 +44,7 @@ function problem(res, status, code, detail) {
}
function privateHeaders(_req, res, next) {
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('X-Robots-Tag', 'noindex, nofollow');
res.set('X-Content-Type-Options', 'nosniff');
next();
@@@ -187,7 +188,7 @@ function createMeRoutes({ auth }) {
// The enhancement script (client.js). Versioned by content: a matching ?v= is cached for a year.
site.get('/app.js', (req, res) => {
-- res.set('Cache-Control', req.query.v === pages.CLIENT_VERSION ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
++ res.set('Cache-Control', cache.assetHeaders('app.js', { hashed: req.query.v === pages.CLIENT_VERSION }));
res.set('X-Content-Type-Options', 'nosniff');
res.type('application/javascript').send(pages.CLIENT_SOURCE);
});
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 3d5954e..6ddeb27 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -44,6 +44,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { ids, http } = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
const db = require('../db/database');
const config = require('../config');
const model = require('./model');
@@@ -664,7 +665,7 @@ router.get('/:id/download', read, limits('media.object.download'), async (req, r
if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Object was deleted');
if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);
const json = req.query.format === 'json';
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
// Developer-project sandbox objects are never public, whatever their visibility: always signed.
if (obj.visibility !== 'private' && !await db.isSandboxTenant(obj.app_id)) {
const url = model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`;
ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex 441163c..4aa9efe 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -19,6 +19,7 @@ const THUMB_DIR = path.resolve(config.thumbnails.path);
// Public base URL per app for "source" links — shared with the watch/paste pages.
const { appUrl, DEFAULT_THEME_CSS } = require('./page-frame');
++const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@@ -304,7 +305,9 @@ async function handle(req, res) {
const tab = TABS.some(([k]) => k === req.query.tab) ? req.query.tab : 'all';
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const data = await fetchTab(tab, page);
-- res.set({ 'Cache-Control': 'public, max-age=30', 'Content-Type': 'text/html; charset=utf-8' });
++ // The listing names media that can become private at any moment: no stale serving, or a cache
++ // could still hand out the old listing afterwards.
++ res.set({ 'Cache-Control': cache.htmlHeaders({ maxAge: 30, swr: 0 }), 'Content-Type': 'text/html; charset=utf-8' });
res.send(renderPage(tab, page, data, await tabCounts()));
} catch (err) {
console.error('[Browse] render error:', err.message);
ddiff --git a/server/public/crawl.js b/server/public/crawl.js
iindex 9e62da6..de08e2d 100644
---- a/server/public/crawl.js
++++ b/server/public/crawl.js
@@@ -14,6 +14,7 @@ const express = require('express');
const config = require('../config');
const db = require('../db/database');
const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
const pages = require('./pages');
const readiness = require('../objects/readiness');
@@@ -104,7 +105,7 @@ function llmsTxt() {
}
function send(res, type, body) {
-- res.set('Cache-Control', 'public, max-age=3600');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));
res.type(type).send(body);
}
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 75e7ab6..2d17783 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -24,6 +24,7 @@
const express = require('express');
const ovServe = require('openvibe-shared/serve');
++const cache = require('openvibe-shared/cache-policy');
const path = require('path');
const fs = require('fs');
const db = require('../db/database');
@@@ -142,7 +143,9 @@ async function serveMediaRecord(kind, record, req, res) {
// verified); otherwise it says why. The bytes route below is unchanged: Live's DVR player reads a
// recording's sidecar through it while the stream is still being recorded.
if (pages.wantsHtmlPage(req)) {
-- res.set('Cache-Control', visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
++ // A watch page names media that can become private at any moment: no stale serving, or a cache
++ // could still hand out its title and description afterwards.
++ res.set('Cache-Control', cache.htmlHeaders(visibility === 'public' ? { maxAge: 60, swr: 0 } : { private: true }));
return res.type('html').send(pages.renderWatchPage(kind, record, await require('../objects/readiness').forRow(record)));
}
if (drill.refuseBytes(res)) return;
@@@ -359,7 +362,7 @@ router.get('/', async (req, res) => await require('./browse').handle(req, res));
router.get('/updates', (req, res) => {
const pf = require('./page-frame');
const frame = require('openvibe-shared/frame');
-- res.set('Cache-Control', 'public, max-age=60').type('html').send(pf.page({
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 })).type('html').send(pf.page({
seo: { title: 'What shipped on OpenVibe.Media', description: 'Every change deployed to OpenVibe.Media, newest first, with the Patch notes that gather them.', canonical: pf.abs('/updates') },
body: frame.updatesBody({ service: 'media', siteName: 'OpenVibe.Media' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
footer: { variant: 'full' },
@@@ -414,7 +417,9 @@ router.get('/api/thumbnails/:name', async (req, res) => {
// Never for a private row: the redirect would hand out the current thumbnail URL of any
// private recording to whoever guesses vod-<id>-0.jpg. The exact file name still serves.
if (row && row.thumbnail_url && recordVisibility(row) !== 'private' && !row.thumbnail_url.endsWith(`/${name}`)) {
-- res.set('Cache-Control', 'public, max-age=3600');
++ // A regenerated thumbnail keeps its old URL until the row is updated: no stale serving, or the
++ // redirect could point at the stale thumbnail for another hour.
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600, swr: 0 }));
return res.redirect(302, row.thumbnail_url);
}
}
@@@ -534,7 +539,7 @@ router.get('/p/:slug', optionalIdentity, async (req, res) => {
return res.status(410).send('This paste has been burned after reading.');
}
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.type('html').send(pages.renderPastePage(paste));
} catch (err) {
console.error('[Public] /p error:', err.message);
@@@ -550,7 +555,7 @@ router.get('/p/:slug/raw', async (req, res) => {
// Image pastes have no raw text — bounce to the screenshot (stale
// consumers stored /raw URLs for hero-moment images).
if (paste && paste.type === 'screenshot') {
-- res.set('Cache-Control', 'public, max-age=3600');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));
return res.redirect(302, `/p/${encodeURIComponent(paste.slug)}/screenshot`);
}
// Moved: every slug goes to Community, found or not, like /p/:slug.
@@@ -579,7 +584,7 @@ router.get('/p/:slug/screenshot', async (req, res) => {
// /p/:slug and /p/:slug/raw (Community answers with the image, or its own 404). Stored
// hero-moment thumbnails pointed here and showed broken images.
if ((!paste || paste.visibility === 'private') && movedTo()) { // private answers like missing
-- res.set('Cache-Control', 'public, max-age=3600');
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));
return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(String(req.params.slug))}/screenshot`);
}
if (!paste || !paste.screenshot_path) return res.status(404).send('Not found');
ddiff --git a/server/user-auth.js b/server/user-auth.js
iindex 1f82ea0..996b996 100644
---- a/server/user-auth.js
++++ b/server/user-auth.js
@@@ -35,6 +35,7 @@
const express = require('express');
const crypto = require('crypto');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
++const cache = require('openvibe-shared/cache-policy');
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
@@@ -388,7 +389,7 @@ function createAuthRoutes(config, auth) {
// No credential at all (a guest) is signed out, not an error: the shared navbar asks this on every
// page view, and a 401 logged a console error on each (browser check, OpenVibe.Host). A credential
// that is present but invalid or expired still answers 401.
-- if (!token) return res.set('Cache-Control', 'private, no-store').json({ user: null });
++ if (!token) return res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ user: null });
const claims = await auth.verify(token);
if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 7481c59..a6593e1 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -21,6 +21,7 @@ const config = require('../config');
const db = require('../db/database');
const tools = require('./media-tools');
const { tenantAuth, tenantCors } = require('../auth');
++const cache = require('openvibe-shared/cache-policy');
const objects = require('../objects/model');
const router = express.Router({ mergeParams: true });
@@@ -333,7 +334,7 @@ router.get('/:id/signed-url', tenantAuth({ verb: 'read' }), async (req, res) =>
if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app signs a playback URL' });
const clip = await _getClipScoped(req, res);
if (!clip) return;
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.json(require('../objects/signing').signedMediaUrl('clip', clip.id, req.query.ttl));
});
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex 7a10f77..baa48f9 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -25,6 +25,7 @@ const recorder = require('./recorder');
const tools = require('./media-tools');
const { finalizeVod, vodPublic } = require('./finalize');
const { tenantAuth, tenantCors } = require('../auth');
++const cache = require('openvibe-shared/cache-policy');
const objects = require('../objects/model');
const router = express.Router({ mergeParams: true });
@@@ -318,7 +319,7 @@ router.get('/:id/signed-url', tenantAuth({ verb: 'read' }), async (req, res) =>
if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app signs a playback URL' });
const vod = await _getVodScoped(req, res);
if (!vod) return;
-- res.set('Cache-Control', 'private, no-store');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.json(require('../objects/signing').signedMediaUrl('vod', vod.id, req.query.ttl));
});
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..1ce3525
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++// The cache policy comes from openvibe-shared/cache-policy (plan T11, lane D): a content-addressed
++// asset is immutable for a year, everything else is a short public window with a long
++// stale-while-revalidate. /me/app.js is the one setHeaders site that decides by the ?v= it was handed
++// (server/me/routes.js): the layout's asset version gets the year, anything else the five minutes.
++const assert = require('assert');
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++
++(async () => {
++ const pages = require('../server/me/pages');
++ const { createMeRoutes } = require('../server/me/routes');
++
++ const app = express();
++ app.use('/me', createMeRoutes({ auth: { verify: async () => null } }).pages);
++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++ const fetchApp = async (v) => {
++ const qs = v === undefined ? '' : `?v=${v}`;
++ const r = await fetch(`http://127.0.0.1:${server.address().port}/me/app.js${qs}`);
++ return { status: r.status, cache: r.headers.get('cache-control') };
++ };
++
++ try {
++ // The layout asks for the exact asset version it rendered: content-addressed, immutable.
++ const pinned = await fetchApp(pages.CLIENT_VERSION);
++ assert.strictEqual(pinned.status, 200);
++ assert.strictEqual(pinned.cache, 'public, max-age=31536000, immutable');
++ assert.strictEqual(pinned.cache, cache.IMMUTABLE);
++
++ // A wrong-but-hex ?v= and no ?v= at all are not content-addressed: five minutes plus a day of swr.
++ for (const v of ['deadbeefdeadbeef', undefined]) {
++ const r = await fetchApp(v);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.cache, 'public, max-age=300, stale-while-revalidate=86400');
++ assert.strictEqual(r.cache, cache.assetHeaders('app.js', { hashed: false }));
++ }
++ console.log('asset-cache: ?v=<version> is immutable for a year; a wrong or missing ?v= is 5 min + a day of swr');
++ } finally { server.close(); }
++})().catch((err) => { console.error(err); process.exit(1); });