openvibers/openvibe.reviews Commits
Serve Reviews cache headers through openvibe-shared cache-policy (#2)
Changes
@@@ -13,6 +13,7 @@ const path = require('path');const express = require('express');const cookieParser = require('cookie-parser');const rateLimit = require('express-rate-limit');++const cache = require('openvibe-shared/cache-policy');const { createReadiness } = require('openvibe-shared/ready');const { http } = require('openvibe-contracts');const { createSessionRoutes } = require('./auth/session');@@@ -152,7 +153,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, losetHeaders(res, filePath) {const v = res.req && res.req.query && res.req.query.v;const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');-- res.setHeader('Cache-Control', v && v === require('./render/layout').assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === require('./render/layout').assetVersion(rel) }));},}));app.use(createMachine({ svc, config }));@@@ -168,7 +169,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lolog.error('[Reviews]', err && err.stack ? err.stack : err);if (res.headersSent) return;if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error' });-- res.status(500).set('Cache-Control', 'private, no-store').type('text/plain').send('Something went wrong on our side. Please try again.');++ res.status(500).set('Cache-Control', cache.htmlHeaders({ private: true })).type('text/plain').send('Something went wrong on our side. Please try again.');});app.locals.consumer = consumer;return app;@@@ -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';@@@ -180,7 +181,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lorouter.get('/me', async (req, res) => {const actor = await viewers.resolve(req, { services: false }).catch(() => null);if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });-- res.set('Cache-Control', 'private, no-store').json({ user: actor.user });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ user: actor.user });});router.post('/refresh', async (req, res) => {@@@ -3,6 +3,7 @@* Shared HTTP helpers: the actor middleware, capability guards and error mapping.*/const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');const { checkCapability } = require('../auth/capabilities');const { AuthError } = require('../auth/viewer');@@@ -62,7 +63,7 @@ function run(fn, status = 200, log = console) {try {const out = await fn(req, res);if (res.headersSent) return;-- res.status(typeof status === 'function' ? status(out) : status).set('Cache-Control', 'private, no-store').json(out);++ res.status(typeof status === 'function' ? status(out) : status).set('Cache-Control', cache.htmlHeaders({ private: true })).json(out);} catch (err) {sendError(res, req, err, log);}@@@ -12,16 +12,17 @@const express = require('express');const seo = require('openvibe-publishing/seo');const sharedSeo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');const PER_SITEMAP = 45000;function createMachine({ svc, config }) {const router = express.Router();const origin = config.baseUrl;-- const cache = (res, s = 300) => res.set('Cache-Control', `public, max-age=${s}`);++ const setCache = (res, s = 300) => res.set('Cache-Control', cache.htmlHeaders({ maxAge: s }));router.get('/robots.txt', (_req, res) => {-- cache(res, 3600).type('text/plain').send(seo.robotsTxt({++ setCache(res, 3600).type('text/plain').send(seo.robotsTxt({sitemaps: [`${origin}/sitemap.xml`],disallow: ['/auth/', '/api/', '/internal/', '/editor', '/search', '/e/*/edit', '/e/*/correct'],}));@@@ -35,18 +36,18 @@ function createMachine({ svc, config }) {const pages = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP });const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));-- cache(res).type('application/xml').send(seo.sitemapIndex(maps));++ setCache(res).type('application/xml').send(seo.sitemapIndex(maps));});router.get('/sitemaps/pages.xml', (_req, res) => {const policy = { minWords: 0, requireSources: false };const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));-- cache(res).type('application/xml').send(seo.sitemap(entries).files[0]);++ setCache(res).type('application/xml').send(seo.sitemap(entries).files[0]);});router.get('/sitemaps/entities-:n.xml', async (req, res) => {const files = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP }).files;const n = Number(req.params.n);if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');-- cache(res).type('application/xml').send(files[n - 1]);++ setCache(res).type('application/xml').send(files[n - 1]);});async function feedItems() {@@@ -65,15 +66,15 @@ function createMachine({ svc, config }) {router.get('/feed.atom', async (_req, res) => {const items = await feedItems();-- if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('No summary has been published yet.');-- cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));++ if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', cache.htmlHeaders({ maxAge: 60 })).type('text/plain').send('No summary has been published yet.');++ setCache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));});router.get('/feed.json', async (_req, res) => {-- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, await feedItems())));++ setCache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, await feedItems())));});router.get('/llms.txt', (_req, res) => {-- cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({++ setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({name: 'OpenVibe.Reviews',summary: 'Review signals from named sources (via OpenVibe.Sources) resolved to entities, with provenance for every number, aggregates that exist only when signals do, and editor-reviewed summaries.',details: 'Every entity page has a JSON representation at the same address plus ".json" (same content). Each signal names its source item, retrieval time and licence note; the aggregate lists its inputs, exclusions and computation (method reviews-aggregate@1). Summaries never carry a rating; AI-drafted summaries are labelled and published only after a person approves them. Review text is not republished.',@@@ -22,6 +22,7 @@*/const express = require('express');const ovServe = require('openvibe-shared/serve');++const cache = require('openvibe-shared/cache-policy');const frame = require('openvibe-shared/frame');const seo = require('openvibe-publishing/seo');const { renderPage } = require('../render/layout');@@@ -93,7 +94,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi// Cross-site form posts are refused (the session cookie is SameSite=Lax as well).router.use((req, res, next) => {if (req.method !== 'POST' || !crossSite(req, origin)) return next();-- res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');++ res.status(403).type('text/plain').set('Cache-Control', cache.htmlHeaders({ private: true })).send('Cross-site form posts are not accepted.');});// Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route@@@ -102,7 +103,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiconst send = (req, res, status, body, o = {}) => {const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;-- res.status(status).set('Cache-Control', cacheable ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').type('html')++ res.status(status).set('Cache-Control', cacheable ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization').type('html').send(renderPage({ config, actor: req.actor, path: o.path || req.path, editor: svc.access.isEditor(req.actor), ...o, body }));};const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });@@@ -127,7 +128,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiconst query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';if (!e) {const r = await svc.resolveRedirect(`/e/${slug}`);-- if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + query); return null; }++ if (r && r.status === 301) { res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).redirect(301, r.location + suffix + query); return null; }if (r && r.status === 410) { gone(req, res); return null; }notFound(req, res);return null;@@@ -135,7 +136,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiif (e.state === 'deleted') { gone(req, res); return null; }if (e.state === 'merged') {const c = await svc.entityById(await svc.canonicalOf(e.id));-- res.set('Cache-Control', 'public, max-age=60').redirect(301, `${svc.entityPath(c)}${suffix}${query}`);++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 })).redirect(301, `${svc.entityPath(c)}${suffix}${query}`);return null;}return e;@@@ -161,7 +162,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiconst e = await locate(req, res, '.json');if (!e) return;const p = await svc.page(e, req.actor);-- res.status(200).set('Cache-Control', req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').set('X-Robots-Tag', p.decision.robots)++ res.status(200).set('Cache-Control', req.actor.kind === 'anonymous' ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization').set('X-Robots-Tag', p.decision.robots).json({ ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } });});@@@ -16,9 +16,12 @@ const { assetVersion } = require('../server/render/layout');assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable', 'the current hash is immutable');r = await fetch(`${h.base}/css/reviews.css?v=0123456789`);assert.strictEqual(r.status, 200);-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300', 'another ?v= is never pinned');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400', 'another ?v= is never pinned');++ r = await fetch(`${h.base}/css/reviews.css?v=deadbeefdeadbeef`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400', 'a hex but wrong ?v= is not immutable');r = await fetch(`${h.base}/css/reviews.css`);-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');console.log('asset cache: all checks passed');} finally { await h.stop(); }})().catch((e) => { console.error(e); process.exit(1); });@@@ -81,7 +81,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow} });const page = await req(h, 'GET', `/e/${e.slug}`);assert.strictEqual(robots(page.text), 'index, follow');-- assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60');++ assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');const text = noScripts(page.text);for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Retrieved', 'steam-reviews-portal-2', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));
@@ -13,6 +13,7 @@ const path = require('path');
@@ -13,6 +13,7 @@ const path = require('path');
const express = require('express');
const express = require('express');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
+const cache = require('openvibe-shared/cache-policy');
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { createSessionRoutes } = require('./auth/session');
const { createSessionRoutes } = require('./auth/session');
@@ -152,7 +153,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lo
@@ -152,7 +153,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lo
setHeaders(res, filePath) {
setHeaders(res, filePath) {
const v = res.req && res.req.query && res.req.query.v;
const v = res.req && res.req.query && res.req.query.v;
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
- res.setHeader('Cache-Control', v && v === require('./render/layout').assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === require('./render/layout').assetVersion(rel) }));
},
},
}));
}));
app.use(createMachine({ svc, config }));
app.use(createMachine({ svc, config }));
@@ -168,7 +169,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lo
@@ -168,7 +169,7 @@ function createApp({ config, svc, viewers, platform, sources, sync, keys, db, lo
log.error('[Reviews]', err && err.stack ? err.stack : err);
log.error('[Reviews]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (res.headersSent) return;
if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error' });
if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error' });
- res.status(500).set('Cache-Control', 'private, no-store').type('text/plain').send('Something went wrong on our side. Please try again.');
+ res.status(500).set('Cache-Control', cache.htmlHeaders({ private: true })).type('text/plain').send('Something went wrong on our side. Please try again.');
});
});
app.locals.consumer = consumer;
app.locals.consumer = consumer;
return app;
return app;
@@ -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';
@@ -180,7 +181,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
@@ -180,7 +181,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
router.get('/me', async (req, res) => {
router.get('/me', async (req, res) => {
const actor = await viewers.resolve(req, { services: false }).catch(() => null);
const actor = await viewers.resolve(req, { services: false }).catch(() => null);
if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });
if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });
- res.set('Cache-Control', 'private, no-store').json({ user: actor.user });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ user: actor.user });
});
});
router.post('/refresh', async (req, res) => {
router.post('/refresh', async (req, res) => {
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
* Shared HTTP helpers: the actor middleware, capability guards and error mapping.
* Shared HTTP helpers: the actor middleware, capability guards and error mapping.
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
const { checkCapability } = require('../auth/capabilities');
const { checkCapability } = require('../auth/capabilities');
const { AuthError } = require('../auth/viewer');
const { AuthError } = require('../auth/viewer');
@@ -62,7 +63,7 @@ function run(fn, status = 200, log = console) {
@@ -62,7 +63,7 @@ function run(fn, status = 200, log = console) {
try {
try {
const out = await fn(req, res);
const out = await fn(req, res);
if (res.headersSent) return;
if (res.headersSent) return;
- res.status(typeof status === 'function' ? status(out) : status).set('Cache-Control', 'private, no-store').json(out);
+ res.status(typeof status === 'function' ? status(out) : status).set('Cache-Control', cache.htmlHeaders({ private: true })).json(out);
} catch (err) {
} catch (err) {
sendError(res, req, err, log);
sendError(res, req, err, log);
}
}
@@ -12,16 +12,17 @@
@@ -12,16 +12,17 @@
const express = require('express');
const express = require('express');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const sharedSeo = require('openvibe-shared/seo');
const sharedSeo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
const PER_SITEMAP = 45000;
const PER_SITEMAP = 45000;
function createMachine({ svc, config }) {
function createMachine({ svc, config }) {
const router = express.Router();
const router = express.Router();
const origin = config.baseUrl;
const origin = config.baseUrl;
- const cache = (res, s = 300) => res.set('Cache-Control', `public, max-age=${s}`);
+ const setCache = (res, s = 300) => res.set('Cache-Control', cache.htmlHeaders({ maxAge: s }));
router.get('/robots.txt', (_req, res) => {
router.get('/robots.txt', (_req, res) => {
- cache(res, 3600).type('text/plain').send(seo.robotsTxt({
+ setCache(res, 3600).type('text/plain').send(seo.robotsTxt({
sitemaps: [`${origin}/sitemap.xml`],
sitemaps: [`${origin}/sitemap.xml`],
disallow: ['/auth/', '/api/', '/internal/', '/editor', '/search', '/e/*/edit', '/e/*/correct'],
disallow: ['/auth/', '/api/', '/internal/', '/editor', '/search', '/e/*/edit', '/e/*/correct'],
}));
}));
@@ -35,18 +36,18 @@ function createMachine({ svc, config }) {
@@ -35,18 +36,18 @@ function createMachine({ svc, config }) {
const pages = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP });
const pages = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP });
const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];
const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));
- cache(res).type('application/xml').send(seo.sitemapIndex(maps));
+ setCache(res).type('application/xml').send(seo.sitemapIndex(maps));
});
});
router.get('/sitemaps/pages.xml', (_req, res) => {
router.get('/sitemaps/pages.xml', (_req, res) => {
const policy = { minWords: 0, requireSources: false };
const policy = { minWords: 0, requireSources: false };
const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));
const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));
- cache(res).type('application/xml').send(seo.sitemap(entries).files[0]);
+ setCache(res).type('application/xml').send(seo.sitemap(entries).files[0]);
});
});
router.get('/sitemaps/entities-:n.xml', async (req, res) => {
router.get('/sitemaps/entities-:n.xml', async (req, res) => {
const files = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP }).files;
const files = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP }).files;
const n = Number(req.params.n);
const n = Number(req.params.n);
if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');
if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');
- cache(res).type('application/xml').send(files[n - 1]);
+ setCache(res).type('application/xml').send(files[n - 1]);
});
});
async function feedItems() {
async function feedItems() {
@@ -65,15 +66,15 @@ function createMachine({ svc, config }) {
@@ -65,15 +66,15 @@ function createMachine({ svc, config }) {
router.get('/feed.atom', async (_req, res) => {
router.get('/feed.atom', async (_req, res) => {
const items = await feedItems();
const items = await feedItems();
- if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('No summary has been published yet.');
- cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));
+ if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', cache.htmlHeaders({ maxAge: 60 })).type('text/plain').send('No summary has been published yet.');
+ setCache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));
});
});
router.get('/feed.json', async (_req, res) => {
router.get('/feed.json', async (_req, res) => {
- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, await feedItems())));
+ setCache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, await feedItems())));
});
});
router.get('/llms.txt', (_req, res) => {
router.get('/llms.txt', (_req, res) => {
- cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
+ setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Reviews',
name: 'OpenVibe.Reviews',
summary: 'Review signals from named sources (via OpenVibe.Sources) resolved to entities, with provenance for every number, aggregates that exist only when signals do, and editor-reviewed summaries.',
summary: 'Review signals from named sources (via OpenVibe.Sources) resolved to entities, with provenance for every number, aggregates that exist only when signals do, and editor-reviewed summaries.',
details: 'Every entity page has a JSON representation at the same address plus ".json" (same content). Each signal names its source item, retrieval time and licence note; the aggregate lists its inputs, exclusions and computation (method reviews-aggregate@1). Summaries never carry a rating; AI-drafted summaries are labelled and published only after a person approves them. Review text is not republished.',
details: 'Every entity page has a JSON representation at the same address plus ".json" (same content). Each signal names its source item, retrieval time and licence note; the aggregate lists its inputs, exclusions and computation (method reviews-aggregate@1). Summaries never carry a rating; AI-drafted summaries are labelled and published only after a person approves them. Review text is not republished.',
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
*/
*/
const express = require('express');
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
+const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
@@ -93,7 +94,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -93,7 +94,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
// Cross-site form posts are refused (the session cookie is SameSite=Lax as well).
// Cross-site form posts are refused (the session cookie is SameSite=Lax as well).
router.use((req, res, next) => {
router.use((req, res, next) => {
if (req.method !== 'POST' || !crossSite(req, origin)) return next();
if (req.method !== 'POST' || !crossSite(req, origin)) return next();
- res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');
+ res.status(403).type('text/plain').set('Cache-Control', cache.htmlHeaders({ private: true })).send('Cross-site form posts are not accepted.');
});
});
// Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route
// Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route
@@ -102,7 +103,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -102,7 +103,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const send = (req, res, status, body, o = {}) => {
const send = (req, res, status, body, o = {}) => {
const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;
const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;
- res.status(status).set('Cache-Control', cacheable ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').type('html')
+ res.status(status).set('Cache-Control', cacheable ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization').type('html')
.send(renderPage({ config, actor: req.actor, path: o.path || req.path, editor: svc.access.isEditor(req.actor), ...o, body }));
.send(renderPage({ config, actor: req.actor, path: o.path || req.path, editor: svc.access.isEditor(req.actor), ...o, body }));
};
};
const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });
const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });
@@ -127,7 +128,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -127,7 +128,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
if (!e) {
if (!e) {
const r = await svc.resolveRedirect(`/e/${slug}`);
const r = await svc.resolveRedirect(`/e/${slug}`);
- if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + query); return null; }
+ if (r && r.status === 301) { res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).redirect(301, r.location + suffix + query); return null; }
if (r && r.status === 410) { gone(req, res); return null; }
if (r && r.status === 410) { gone(req, res); return null; }
notFound(req, res);
notFound(req, res);
return null;
return null;
@@ -135,7 +136,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -135,7 +136,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
if (e.state === 'deleted') { gone(req, res); return null; }
if (e.state === 'deleted') { gone(req, res); return null; }
if (e.state === 'merged') {
if (e.state === 'merged') {
const c = await svc.entityById(await svc.canonicalOf(e.id));
const c = await svc.entityById(await svc.canonicalOf(e.id));
- res.set('Cache-Control', 'public, max-age=60').redirect(301, `${svc.entityPath(c)}${suffix}${query}`);
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 })).redirect(301, `${svc.entityPath(c)}${suffix}${query}`);
return null;
return null;
}
}
return e;
return e;
@@ -161,7 +162,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -161,7 +162,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const e = await locate(req, res, '.json');
const e = await locate(req, res, '.json');
if (!e) return;
if (!e) return;
const p = await svc.page(e, req.actor);
const p = await svc.page(e, req.actor);
- res.status(200).set('Cache-Control', req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').set('X-Robots-Tag', p.decision.robots)
+ res.status(200).set('Cache-Control', req.actor.kind === 'anonymous' ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization').set('X-Robots-Tag', p.decision.robots)
.json({ ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } });
.json({ ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } });
});
});
@@ -16,9 +16,12 @@ const { assetVersion } = require('../server/render/layout');
@@ -16,9 +16,12 @@ const { assetVersion } = require('../server/render/layout');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable', 'the current hash is immutable');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable', 'the current hash is immutable');
r = await fetch(`${h.base}/css/reviews.css?v=0123456789`);
r = await fetch(`${h.base}/css/reviews.css?v=0123456789`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300', 'another ?v= is never pinned');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400', 'another ?v= is never pinned');
+ r = await fetch(`${h.base}/css/reviews.css?v=deadbeefdeadbeef`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400', 'a hex but wrong ?v= is not immutable');
r = await fetch(`${h.base}/css/reviews.css`);
r = await fetch(`${h.base}/css/reviews.css`);
- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
console.log('asset cache: all checks passed');
console.log('asset cache: all checks passed');
} finally { await h.stop(); }
} finally { await h.stop(); }
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -81,7 +81,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
@@ -81,7 +81,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
} });
} });
const page = await req(h, 'GET', `/e/${e.slug}`);
const page = await req(h, 'GET', `/e/${e.slug}`);
assert.strictEqual(robots(page.text), 'index, follow');
assert.strictEqual(robots(page.text), 'index, follow');
- assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60');
+ assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
const text = noScripts(page.text);
const text = noScripts(page.text);
for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Retrieved', 'steam-reviews-portal-2', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);
for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Retrieved', 'steam-reviews-portal-2', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);
assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));
assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));