openvibers/openvibe.chat Commits
Serve Chat's cache headers through openvibe-shared/cache-policy (#3)
Changes
@@@ -18,7 +18,7 @@"multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0","pg": "^8.23.0","ws": "^8.18.0"},@@@ -1338,9 +1338,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.0.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",-- "integrity": "sha512-n5CHC0hfxOo0jo/3kS4O3KiwhirhZDlx5zZTIuPyIf3Dq0yI/R6R64btx7NYwZe5dI0NjvqzEtnv19m8unwLMg==",++ "version": "2.4.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",++ "integrity": "sha512-DmFlDcVlT/WRza0M4h0xf+WiwM4FsiJ0WarIesOYByE0v/JYZcoIdjYUvVB5GzshJ7QmFHJLnJsNmdJqj7L1kg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -31,7 +31,7 @@"multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0","pg": "^8.23.0","ws": "^8.18.0"},@@@ -12,6 +12,7 @@* GET /api/chat/user/:username/profile - Get user profile card data*/const express = require('express');++const cache = require('openvibe-shared/cache-policy');const db = require('../db/database');const ctx = require('../live-context');const { optionalAuth, requireAuth } = require('../auth/auth');@@@ -27,7 +28,7 @@ const { limits } = require('../net/actor-limits');*/async function withoutBlocked(req, res, rows) {if (!req.user) return rows;-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));const hidden = await networkBlocks.blockedUserIds(req.user);if (!hidden.size) return rows;return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));@@@ -91,15 +92,15 @@ function enrichMessagesWithCosmetics(messages) {const cosmetics = { getCosmeticProfile: (id) => ctx.getCosmeticProfile(id) };const tags = { getTagProfile: (id) => ctx.getTagProfile(id) };if (!Array.isArray(messages)) return messages;-- const cache = new Map();++ const profileCache = new Map();for (const m of messages) {if (!m || !m.user_id) continue;-- let prof = cache.get(m.user_id);++ let prof = profileCache.get(m.user_id);if (!prof) {prof = {};try { if (cosmetics) Object.assign(prof, cosmetics.getCosmeticProfile(m.user_id) || {}); } catch { /* */ }try { if (tags) prof.tag = tags.getTagProfile(m.user_id) || null; } catch { /* */ }-- cache.set(m.user_id, prof);++ profileCache.set(m.user_id, prof);}if (prof.nameFX) m.nameFX = prof.nameFX;if (prof.particleFX) m.particleFX = prof.particleFX;@@@ -895,7 +896,7 @@ router.get('/me/export', requireAuth, exportLimit, async (req, res) => {}const lines = rows.slice(0, MAX).map((m) => ({ id: m.id, timestamp: m.timestamp, message: m.message, message_type: m.message_type || 'chat', stream_id: m.stream_id || null, stream_title: m.stream_title || null, is_global: !!m.is_global }));const stamp = new Date().toISOString().slice(0, 10);-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (req.query.format === 'csv') {const header = 'id,timestamp,message,message_type,stream_id,stream_title,is_global\n';res.set('Content-Disposition', `attachment; filename="my-chat-${stamp}.csv"`);@@@ -13,6 +13,7 @@*/const crypto = require('crypto');const express = require('express');++const cache = require('openvibe-shared/cache-policy');const TTL_SECONDS = 3600;const STUN = { urls: 'stun:stun.l.google.com:19302' };@@@ -64,7 +65,7 @@ function createIceRoutes() {const router = express.Router();router.get('/', optionalAuth, (req, res) => {const { iceServers: list, ephemeral } = iceServers(req.user ? `u${req.user.id}` : 'anon');-- res.set('Cache-Control', ephemeral ? 'private, max-age=300' : 'private, no-store').json({ iceServers: list });++ res.set('Cache-Control', ephemeral ? 'private, max-age=300' : cache.htmlHeaders({ private: true })).json({ iceServers: list });});return router;}@@@ -17,6 +17,7 @@'use strict';const express = require('express');++const cache = require('openvibe-shared/cache-policy');const { requireAuth } = require('../auth/auth');const { limits } = require('../net/actor-limits');const { PrefsError } = require('./module-store');@@@ -33,7 +34,7 @@ function fail(res, err) {return res.status(500).json({ error: 'Internal server error' });}--const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);++const reply = (res, out) => res.set('Cache-Control', cache.htmlHeaders({ private: true })).set('ETag', `"${out.revision}"`).json(out);function routesFor(store, key) {const router = express.Router();@@@ -23,6 +23,7 @@* may talk, a demoted one is muted, a blocked person is dropped.*/const express = require('express');++const cache = require('openvibe-shared/cache-policy');const { requireAuth, optionalAuth } = require('../auth/auth');const { limits } = require('../net/actor-limits');const rooms = require('./rooms');@@@ -78,7 +79,7 @@ async function readable(req, res) {}router.get('/', optionalAuth, async (req, res) => {-- try { res.set('Cache-Control', 'private, no-store').json(await rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }});router.post('/', requireAuth, createLimit, async (req, res) => {@@@ -88,7 +89,7 @@ router.post('/', requireAuth, createLimit, async (req, res) => {router.get('/:slug', optionalAuth, async (req, res) => {const room = await readable(req, res); if (!room) return;const a = await rooms.access(room, req.user || null);-- res.set('Cache-Control', 'private, no-store').json({++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({room: await rooms.publicRoom(room, { role: a.role }),can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },...(room.kind === 'call' ? { call: callOf(room) } : {}),@@@ -104,7 +105,7 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {const room = await readable(req, res); if (!room) return;try {const messages = await rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });-- res.set('Cache-Control', 'private, no-store').json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });} catch (err) { send(res, err); }});@@@ -150,7 +151,7 @@ router.post('/:slug/read', requireAuth, readLimit, async (req, res) => {router.get('/:slug/members', optionalAuth, async (req, res) => {const room = await readable(req, res); if (!room) return;const moderate = (await rooms.access(room, req.user || null)).moderate;-- res.set('Cache-Control', 'private, no-store').json({ members: (await rooms.members(room, { seen: moderate })).filter((m) => m.role !== 'blocked' || moderate) });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ members: (await rooms.members(room, { seen: moderate })).filter((m) => m.role !== 'blocked' || moderate) });});router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {@@@ -170,7 +171,7 @@ router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {router.get('/:slug/attachments', requireAuth, async (req, res) => {const room = await readable(req, res); if (!room) return;if (!(await rooms.access(room, req.user)).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });-- res.set('Cache-Control', 'private, no-store').json({ attachments: await rooms.attachments(room) });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ attachments: await rooms.attachments(room) });});router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, async (req, res) => {@@@ -12,6 +12,7 @@*/const express = require('express');const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');const historyStore = require('../chat/history-store');const rooms = require('../rooms/rooms');@@@ -75,7 +76,7 @@ function createDiscoveryRoutes({ config }) {router.get('/robots.txt', (req, res) => {// The same rules as before, plus the search and AI crawlers the shared kit names by name and// the sitemap. Every previous Disallow is kept.-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt({++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({sitemaps: [abs('/sitemap.xml')],allow: ['/', '/updates', '/rooms', '/r/'],disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],@@@ -83,7 +84,7 @@ function createDiscoveryRoutes({ config }) {});router.get('/llms.txt', (req, res) => {-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({name: 'OpenVibe.Chat',summary: 'OpenVibe.Chat: the network-wide chat room of OpenVibe, plus its rooms and the direct messages of its members.',details: 'One OpenVibe account works here and on every other OpenVibe site. Anyone can read the global chat and the public rooms without an account; posting needs a signed-in person. The global chat and the public room pages are readable as plain HTML without JavaScript and update live over the site\'s own WebSocket. Direct messages, room settings and the API are per-person and are never listed here.',@@@ -107,7 +108,7 @@ function createDiscoveryRoutes({ config }) {const lastmod = p.lastmod ? await p.lastmod() : null;urls.push({ loc: abs(p.path), ...(lastmod ? { lastmod } : {}), changefreq: p.changefreq, priority: p.priority });}-- res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));++ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));});return router;@@@ -26,13 +26,17 @@ const LINKS = [const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));const hashes = new Map();--function asset(rel) {++/** The content hash in a /web asset's URL: what `?v=` must equal for the bytes to be immutable. */++function assetVersion(rel) {if (!hashes.has(rel)) {let v = 'dev';try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }hashes.set(rel, v);}-- return `/web/${rel}?v=${hashes.get(rel)}`;++ return hashes.get(rel);++}++function asset(rel) {++ return `/web/${rel}?v=${assetVersion(rel)}`;}/** The site's links, with the signed-in person's unread counts (o.counts: { rooms, messages }). */@@@ -109,4 +113,4 @@ document.addEventListener('DOMContentLoaded', function () {</html>`;}--module.exports = { renderPage, asset, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };++module.exports = { renderPage, asset, assetVersion, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };@@@ -31,7 +31,8 @@ const express = require('express');const rateLimit = require('express-rate-limit');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');--const { renderPage, esc, SITE_NAME } = require('./layout');++const cache = require('openvibe-shared/cache-policy');++const { renderPage, assetVersion, esc, SITE_NAME } = require('./layout');const { createSessionRoutes } = require('./session');const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const session = require('../auth/network-session');@@@ -127,7 +128,7 @@ function createWebRoutes({ config }) {};const page = async (req, res, status, o) => {const actor = o.actor || await viewerOf(req);-- res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')++ res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).type('html').send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), ...o }));};const needUser = async (req, res) => {@@@ -139,7 +140,16 @@ function createWebRoutes({ config }) {router.use('/auth', createSessionRoutes({ config: webConfig, viewers }));router.use('/shared', ovServe.handler());-- router.use('/web', express.static(PUBLIC_DIR, { maxAge: '365d', immutable: true, index: false }));++ // A /web asset is content-addressed by layout.asset()'s ?v=: only that exact version is immutable,++ // anything else (a stale link, a hand-typed URL) gets the short public window.++ router.use('/web', express.static(PUBLIC_DIR, {++ index: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));++ },++ }));// ── Global chat ──router.get('/', async (req, res) => {@@@ -594,7 +604,7 @@ ${attachList}`,// ── What shipped, robots, sitemap ──router.get('/updates', async (req, res) => await page(req, res, 200, {-- title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',++ title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}));// ── robots.txt, llms.txt, sitemap.xml: server/web/discovery.js, the shared SEO kit ──@@@ -15,6 +15,7 @@*/const crypto = require('crypto');const express = require('express');++const cache = require('openvibe-shared/cache-policy');const ACCESS_COOKIE = 'ov_token';const REFRESH_COOKIE = 'ov_refresh';@@@ -178,7 +179,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo});router.get('/me', async (req, res) => {-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));// 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.nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++/**++ * openvibe.chat's Cache-Control values (openvibe-shared/cache-policy): a /web asset is immutable++ * only at the content hash its own URL carries (layout.asset()'s ?v=) and gets the module's short++ * public window otherwise; robots.txt and the public pages carry the module's HTML policy, and a++ * personalised answer is never cached by anybody.++ */++const assert = require('assert');++const { boot, suite } = require('./helpers');++const { assetVersion } = require('../server/web/layout');++++const t = suite('asset cache');++let h;++const SITE = 'https://openvibe.chat';++const cacheOf = async (path) => (await fetch(`${h.base}${path}`)).headers.get('cache-control');++++t('boot', async () => { h = await boot({ env: { CHAT_WEB_URL: SITE } }); });++++t('/web asset: immutable only at its own ?v=, the module window otherwise', async () => {++ const v = assetVersion('chat.css');++ assert.strictEqual(v, assetVersion('chat.css'), 'the version is the same every time');++ assert.strictEqual(await cacheOf(`/web/chat.css?v=${v}`), 'public, max-age=31536000, immutable', 'the current hash is immutable');++ assert.strictEqual(await cacheOf('/web/chat.css?v=deadbeefdeadbeef'), 'public, max-age=300, stale-while-revalidate=86400', 'a hex but wrong ?v= is not immutable');++ assert.strictEqual(await cacheOf('/web/chat.css'), 'public, max-age=300, stale-while-revalidate=86400', 'no ?v= is not immutable');++});++++t('robots.txt and a public page carry the module\'s HTML policy', async () => {++ assert.strictEqual(await cacheOf('/robots.txt'), 'public, max-age=3600, stale-while-revalidate=3600');++ assert.strictEqual(await cacheOf('/updates'), 'public, max-age=300, stale-while-revalidate=3600');++});++++t('a personalised answer is private, no-store', async () => {++ assert.strictEqual(await cacheOf('/auth/me'), 'private, no-store');++});++++t.run(async () => { if (h && h.close) await h.close(); });
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1338,9 +1338,9 @@
@@ -1338,9 +1338,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.0.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
- "integrity": "sha512-n5CHC0hfxOo0jo/3kS4O3KiwhirhZDlx5zZTIuPyIf3Dq0yI/R6R64btx7NYwZe5dI0NjvqzEtnv19m8unwLMg==",
+ "version": "2.4.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
+ "integrity": "sha512-DmFlDcVlT/WRza0M4h0xf+WiwM4FsiJ0WarIesOYByE0v/JYZcoIdjYUvVB5GzshJ7QmFHJLnJsNmdJqj7L1kg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
* GET /api/chat/user/:username/profile - Get user profile card data
* GET /api/chat/user/:username/profile - Get user profile card data
*/
*/
const express = require('express');
const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
const db = require('../db/database');
const db = require('../db/database');
const ctx = require('../live-context');
const ctx = require('../live-context');
const { optionalAuth, requireAuth } = require('../auth/auth');
const { optionalAuth, requireAuth } = require('../auth/auth');
@@ -27,7 +28,7 @@ const { limits } = require('../net/actor-limits');
@@ -27,7 +28,7 @@ const { limits } = require('../net/actor-limits');
*/
*/
async function withoutBlocked(req, res, rows) {
async function withoutBlocked(req, res, rows) {
if (!req.user) return rows;
if (!req.user) return rows;
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
const hidden = await networkBlocks.blockedUserIds(req.user);
const hidden = await networkBlocks.blockedUserIds(req.user);
if (!hidden.size) return rows;
if (!hidden.size) return rows;
return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));
return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));
@@ -91,15 +92,15 @@ function enrichMessagesWithCosmetics(messages) {
@@ -91,15 +92,15 @@ function enrichMessagesWithCosmetics(messages) {
const cosmetics = { getCosmeticProfile: (id) => ctx.getCosmeticProfile(id) };
const cosmetics = { getCosmeticProfile: (id) => ctx.getCosmeticProfile(id) };
const tags = { getTagProfile: (id) => ctx.getTagProfile(id) };
const tags = { getTagProfile: (id) => ctx.getTagProfile(id) };
if (!Array.isArray(messages)) return messages;
if (!Array.isArray(messages)) return messages;
- const cache = new Map();
+ const profileCache = new Map();
for (const m of messages) {
for (const m of messages) {
if (!m || !m.user_id) continue;
if (!m || !m.user_id) continue;
- let prof = cache.get(m.user_id);
+ let prof = profileCache.get(m.user_id);
if (!prof) {
if (!prof) {
prof = {};
prof = {};
try { if (cosmetics) Object.assign(prof, cosmetics.getCosmeticProfile(m.user_id) || {}); } catch { /* */ }
try { if (cosmetics) Object.assign(prof, cosmetics.getCosmeticProfile(m.user_id) || {}); } catch { /* */ }
try { if (tags) prof.tag = tags.getTagProfile(m.user_id) || null; } catch { /* */ }
try { if (tags) prof.tag = tags.getTagProfile(m.user_id) || null; } catch { /* */ }
- cache.set(m.user_id, prof);
+ profileCache.set(m.user_id, prof);
}
}
if (prof.nameFX) m.nameFX = prof.nameFX;
if (prof.nameFX) m.nameFX = prof.nameFX;
if (prof.particleFX) m.particleFX = prof.particleFX;
if (prof.particleFX) m.particleFX = prof.particleFX;
@@ -895,7 +896,7 @@ router.get('/me/export', requireAuth, exportLimit, async (req, res) => {
@@ -895,7 +896,7 @@ router.get('/me/export', requireAuth, exportLimit, async (req, res) => {
}
}
const lines = rows.slice(0, MAX).map((m) => ({ id: m.id, timestamp: m.timestamp, message: m.message, message_type: m.message_type || 'chat', stream_id: m.stream_id || null, stream_title: m.stream_title || null, is_global: !!m.is_global }));
const lines = rows.slice(0, MAX).map((m) => ({ id: m.id, timestamp: m.timestamp, message: m.message, message_type: m.message_type || 'chat', stream_id: m.stream_id || null, stream_title: m.stream_title || null, is_global: !!m.is_global }));
const stamp = new Date().toISOString().slice(0, 10);
const stamp = new Date().toISOString().slice(0, 10);
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (req.query.format === 'csv') {
if (req.query.format === 'csv') {
const header = 'id,timestamp,message,message_type,stream_id,stream_title,is_global\n';
const header = 'id,timestamp,message,message_type,stream_id,stream_title,is_global\n';
res.set('Content-Disposition', `attachment; filename="my-chat-${stamp}.csv"`);
res.set('Content-Disposition', `attachment; filename="my-chat-${stamp}.csv"`);
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
const TTL_SECONDS = 3600;
const TTL_SECONDS = 3600;
const STUN = { urls: 'stun:stun.l.google.com:19302' };
const STUN = { urls: 'stun:stun.l.google.com:19302' };
@@ -64,7 +65,7 @@ function createIceRoutes() {
@@ -64,7 +65,7 @@ function createIceRoutes() {
const router = express.Router();
const router = express.Router();
router.get('/', optionalAuth, (req, res) => {
router.get('/', optionalAuth, (req, res) => {
const { iceServers: list, ephemeral } = iceServers(req.user ? `u${req.user.id}` : 'anon');
const { iceServers: list, ephemeral } = iceServers(req.user ? `u${req.user.id}` : 'anon');
- res.set('Cache-Control', ephemeral ? 'private, max-age=300' : 'private, no-store').json({ iceServers: list });
+ res.set('Cache-Control', ephemeral ? 'private, max-age=300' : cache.htmlHeaders({ private: true })).json({ iceServers: list });
});
});
return router;
return router;
}
}
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
'use strict';
'use strict';
const express = require('express');
const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const { PrefsError } = require('./module-store');
const { PrefsError } = require('./module-store');
@@ -33,7 +34,7 @@ function fail(res, err) {
@@ -33,7 +34,7 @@ function fail(res, err) {
return res.status(500).json({ error: 'Internal server error' });
return res.status(500).json({ error: 'Internal server error' });
}
}
-const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);
+const reply = (res, out) => res.set('Cache-Control', cache.htmlHeaders({ private: true })).set('ETag', `"${out.revision}"`).json(out);
function routesFor(store, key) {
function routesFor(store, key) {
const router = express.Router();
const router = express.Router();
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
* may talk, a demoted one is muted, a blocked person is dropped.
* may talk, a demoted one is muted, a blocked person is dropped.
*/
*/
const express = require('express');
const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const rooms = require('./rooms');
@@ -78,7 +79,7 @@ async function readable(req, res) {
@@ -78,7 +79,7 @@ async function readable(req, res) {
}
}
router.get('/', optionalAuth, async (req, res) => {
router.get('/', optionalAuth, async (req, res) => {
- try { res.set('Cache-Control', 'private, no-store').json(await rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
+ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
});
});
router.post('/', requireAuth, createLimit, async (req, res) => {
router.post('/', requireAuth, createLimit, async (req, res) => {
@@ -88,7 +89,7 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
@@ -88,7 +89,7 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
router.get('/:slug', optionalAuth, async (req, res) => {
router.get('/:slug', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
const a = await rooms.access(room, req.user || null);
const a = await rooms.access(room, req.user || null);
- res.set('Cache-Control', 'private, no-store').json({
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({
room: await rooms.publicRoom(room, { role: a.role }),
room: await rooms.publicRoom(room, { role: a.role }),
can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },
can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },
...(room.kind === 'call' ? { call: callOf(room) } : {}),
...(room.kind === 'call' ? { call: callOf(room) } : {}),
@@ -104,7 +105,7 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
@@ -104,7 +105,7 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
try {
try {
const messages = await rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });
const messages = await rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });
- res.set('Cache-Control', 'private, no-store').json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
@@ -150,7 +151,7 @@ router.post('/:slug/read', requireAuth, readLimit, async (req, res) => {
@@ -150,7 +151,7 @@ router.post('/:slug/read', requireAuth, readLimit, async (req, res) => {
router.get('/:slug/members', optionalAuth, async (req, res) => {
router.get('/:slug/members', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
const moderate = (await rooms.access(room, req.user || null)).moderate;
const moderate = (await rooms.access(room, req.user || null)).moderate;
- res.set('Cache-Control', 'private, no-store').json({ members: (await rooms.members(room, { seen: moderate })).filter((m) => m.role !== 'blocked' || moderate) });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ members: (await rooms.members(room, { seen: moderate })).filter((m) => m.role !== 'blocked' || moderate) });
});
});
router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
@@ -170,7 +171,7 @@ router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
@@ -170,7 +171,7 @@ router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
router.get('/:slug/attachments', requireAuth, async (req, res) => {
router.get('/:slug/attachments', requireAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
if (!(await rooms.access(room, req.user)).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });
if (!(await rooms.access(room, req.user)).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });
- res.set('Cache-Control', 'private, no-store').json({ attachments: await rooms.attachments(room) });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ attachments: await rooms.attachments(room) });
});
});
router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, async (req, res) => {
router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, async (req, res) => {
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
*/
*/
const express = require('express');
const express = require('express');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
const historyStore = require('../chat/history-store');
const historyStore = require('../chat/history-store');
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
@@ -75,7 +76,7 @@ function createDiscoveryRoutes({ config }) {
@@ -75,7 +76,7 @@ function createDiscoveryRoutes({ config }) {
router.get('/robots.txt', (req, res) => {
router.get('/robots.txt', (req, res) => {
// The same rules as before, plus the search and AI crawlers the shared kit names by name and
// The same rules as before, plus the search and AI crawlers the shared kit names by name and
// the sitemap. Every previous Disallow is kept.
// the sitemap. Every previous Disallow is kept.
- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt({
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({
sitemaps: [abs('/sitemap.xml')],
sitemaps: [abs('/sitemap.xml')],
allow: ['/', '/updates', '/rooms', '/r/'],
allow: ['/', '/updates', '/rooms', '/r/'],
disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],
disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],
@@ -83,7 +84,7 @@ function createDiscoveryRoutes({ config }) {
@@ -83,7 +84,7 @@ function createDiscoveryRoutes({ config }) {
});
});
router.get('/llms.txt', (req, res) => {
router.get('/llms.txt', (req, res) => {
- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
name: 'OpenVibe.Chat',
name: 'OpenVibe.Chat',
summary: 'OpenVibe.Chat: the network-wide chat room of OpenVibe, plus its rooms and the direct messages of its members.',
summary: 'OpenVibe.Chat: the network-wide chat room of OpenVibe, plus its rooms and the direct messages of its members.',
details: 'One OpenVibe account works here and on every other OpenVibe site. Anyone can read the global chat and the public rooms without an account; posting needs a signed-in person. The global chat and the public room pages are readable as plain HTML without JavaScript and update live over the site\'s own WebSocket. Direct messages, room settings and the API are per-person and are never listed here.',
details: 'One OpenVibe account works here and on every other OpenVibe site. Anyone can read the global chat and the public rooms without an account; posting needs a signed-in person. The global chat and the public room pages are readable as plain HTML without JavaScript and update live over the site\'s own WebSocket. Direct messages, room settings and the API are per-person and are never listed here.',
@@ -107,7 +108,7 @@ function createDiscoveryRoutes({ config }) {
@@ -107,7 +108,7 @@ function createDiscoveryRoutes({ config }) {
const lastmod = p.lastmod ? await p.lastmod() : null;
const lastmod = p.lastmod ? await p.lastmod() : null;
urls.push({ loc: abs(p.path), ...(lastmod ? { lastmod } : {}), changefreq: p.changefreq, priority: p.priority });
urls.push({ loc: abs(p.path), ...(lastmod ? { lastmod } : {}), changefreq: p.changefreq, priority: p.priority });
}
}
- res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));
+ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
});
});
return router;
return router;
@@ -26,13 +26,17 @@ const LINKS = [
@@ -26,13 +26,17 @@ const LINKS = [
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const hashes = new Map();
const hashes = new Map();
-function asset(rel) {
+/** The content hash in a /web asset's URL: what `?v=` must equal for the bytes to be immutable. */
+function assetVersion(rel) {
if (!hashes.has(rel)) {
if (!hashes.has(rel)) {
let v = 'dev';
let v = 'dev';
try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
hashes.set(rel, v);
hashes.set(rel, v);
}
}
- return `/web/${rel}?v=${hashes.get(rel)}`;
+ return hashes.get(rel);
+}
+function asset(rel) {
+ return `/web/${rel}?v=${assetVersion(rel)}`;
}
}
/** The site's links, with the signed-in person's unread counts (o.counts: { rooms, messages }). */
/** The site's links, with the signed-in person's unread counts (o.counts: { rooms, messages }). */
@@ -109,4 +113,4 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -109,4 +113,4 @@ document.addEventListener('DOMContentLoaded', function () {
</html>`;
</html>`;
}
}
-module.exports = { renderPage, asset, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
+module.exports = { renderPage, asset, assetVersion, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
@@ -31,7 +31,8 @@ const express = require('express');
@@ -31,7 +31,8 @@ const express = require('express');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
-const { renderPage, esc, SITE_NAME } = require('./layout');
+const cache = require('openvibe-shared/cache-policy');
+const { renderPage, assetVersion, esc, SITE_NAME } = require('./layout');
const { createSessionRoutes } = require('./session');
const { createSessionRoutes } = require('./session');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const session = require('../auth/network-session');
const session = require('../auth/network-session');
@@ -127,7 +128,7 @@ function createWebRoutes({ config }) {
@@ -127,7 +128,7 @@ function createWebRoutes({ config }) {
};
};
const page = async (req, res, status, o) => {
const page = async (req, res, status, o) => {
const actor = o.actor || await viewerOf(req);
const actor = o.actor || await viewerOf(req);
- res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
+ res.status(status).set('Cache-Control', o.cache || cache.htmlHeaders({ private: true })).type('html')
.send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), ...o }));
.send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), ...o }));
};
};
const needUser = async (req, res) => {
const needUser = async (req, res) => {
@@ -139,7 +140,16 @@ function createWebRoutes({ config }) {
@@ -139,7 +140,16 @@ function createWebRoutes({ config }) {
router.use('/auth', createSessionRoutes({ config: webConfig, viewers }));
router.use('/auth', createSessionRoutes({ config: webConfig, viewers }));
router.use('/shared', ovServe.handler());
router.use('/shared', ovServe.handler());
- router.use('/web', express.static(PUBLIC_DIR, { maxAge: '365d', immutable: true, index: false }));
+ // A /web asset is content-addressed by layout.asset()'s ?v=: only that exact version is immutable,
+ // anything else (a stale link, a hand-typed URL) gets the short public window.
+ router.use('/web', express.static(PUBLIC_DIR, {
+ index: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
+ },
+ }));
// ── Global chat ──
// ── Global chat ──
router.get('/', async (req, res) => {
router.get('/', async (req, res) => {
@@ -594,7 +604,7 @@ ${attachList}`,
@@ -594,7 +604,7 @@ ${attachList}`,
// ── What shipped, robots, sitemap ──
// ── What shipped, robots, sitemap ──
router.get('/updates', async (req, res) => await page(req, res, 200, {
router.get('/updates', async (req, res) => await page(req, res, 200, {
- title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
+ title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}));
}));
// ── robots.txt, llms.txt, sitemap.xml: server/web/discovery.js, the shared SEO kit ──
// ── robots.txt, llms.txt, sitemap.xml: server/web/discovery.js, the shared SEO kit ──
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
const REFRESH_COOKIE = 'ov_refresh';
@@ -178,7 +179,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
@@ -178,7 +179,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
});
});
router.get('/me', async (req, res) => {
router.get('/me', async (req, res) => {
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
// No credential at all (a guest) is signed out, not an error: the shared navbar asks this on every
// 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
// 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.
// that is present but invalid or expired still answers 401.
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+/**
+ * openvibe.chat's Cache-Control values (openvibe-shared/cache-policy): a /web asset is immutable
+ * only at the content hash its own URL carries (layout.asset()'s ?v=) and gets the module's short
+ * public window otherwise; robots.txt and the public pages carry the module's HTML policy, and a
+ * personalised answer is never cached by anybody.
+ */
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+const { assetVersion } = require('../server/web/layout');
+
+const t = suite('asset cache');
+let h;
+const SITE = 'https://openvibe.chat';
+const cacheOf = async (path) => (await fetch(`${h.base}${path}`)).headers.get('cache-control');
+
+t('boot', async () => { h = await boot({ env: { CHAT_WEB_URL: SITE } }); });
+
+t('/web asset: immutable only at its own ?v=, the module window otherwise', async () => {
+ const v = assetVersion('chat.css');
+ assert.strictEqual(v, assetVersion('chat.css'), 'the version is the same every time');
+ assert.strictEqual(await cacheOf(`/web/chat.css?v=${v}`), 'public, max-age=31536000, immutable', 'the current hash is immutable');
+ assert.strictEqual(await cacheOf('/web/chat.css?v=deadbeefdeadbeef'), 'public, max-age=300, stale-while-revalidate=86400', 'a hex but wrong ?v= is not immutable');
+ assert.strictEqual(await cacheOf('/web/chat.css'), 'public, max-age=300, stale-while-revalidate=86400', 'no ?v= is not immutable');
+});
+
+t('robots.txt and a public page carry the module\'s HTML policy', async () => {
+ assert.strictEqual(await cacheOf('/robots.txt'), 'public, max-age=3600, stale-while-revalidate=3600');
+ assert.strictEqual(await cacheOf('/updates'), 'public, max-age=300, stale-while-revalidate=3600');
+});
+
+t('a personalised answer is private, no-store', async () => {
+ assert.strictEqual(await cacheOf('/auth/me'), 'private, no-store');
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });