openvibers/openvibe.wiki Commits
Serve cache headers through openvibe-shared cache-policy (#2)
Changes
@@@ -12,6 +12,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 { createSessionRoutes } = require('./auth/session');const { createApi } = require('./http/api');@@@ -112,7 +113,7 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valksetHeaders(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) }));},}));// GET /<key>.txt — the IndexNow key file, only when a key is configured (server/index.js).@@@ -131,7 +132,7 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valklog.error('[Wiki]', err && err.stack ? err.stack : err);if (res.headersSent) return;if (req.path.startsWith('/api/')) return require('openvibe-contracts').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.');});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';@@@ -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.@@@ -3,6 +3,7 @@* Shared HTTP helpers: the actor middleware, capability guards, error mapping and citation input.*/const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');const { checkCapability } = require('../auth/capabilities');const { AuthError } = require('../auth/viewer');@@@ -49,7 +50,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);}@@@ -13,16 +13,17 @@ const express = require('express');const seo = require('openvibe-publishing/seo');const ssr = require('openvibe-publishing/ssr');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/', '/new-space', '/search', '/proposals/'],}));@@@ -49,14 +50,14 @@ function createMachine({ svc, config }) {const pages = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP });const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));-- cache(res).type('application/xml').send(seo.sitemapIndex(maps));++ setCache(res).type('application/xml').send(seo.sitemapIndex(maps));}));-- router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => cache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));++ router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => setCache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));router.get('/sitemaps/pages-:n.xml', wrap(async (req, res) => {const files = seo.sitemap(await pageEntries(), { 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() {@@@ -86,15 +87,15 @@ function createMachine({ svc, config }) {router.get('/feed.atom', wrap(async (_req, res) => {const items = await feedItems();const updated = items.some((i) => i.decision.listable) ? null : await emptyFeedUpdated();-- cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));++ setCache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));}));router.get('/feed.json', wrap(async (_req, res) => {-- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));++ setCache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));}));router.get('/llms.txt', wrap(async (_req, res) => {const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');-- cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({++ setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({name: 'OpenVibe.Wiki',summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',details: 'Every public page has a JSON representation at the same address plus ".json" (same content, same visibility rules). Revision history and diffs are public for public pages. AI-generated revisions are labelled and are published only after a person approves them.',@@@ -28,6 +28,7 @@*/const express = require('express');const ovServe = require('openvibe-shared/serve');++const cache = require('openvibe-shared/cache-policy');const seo = require('openvibe-publishing/seo');const ssr = require('openvibe-publishing/ssr');const { renderPage } = require('../render/layout');@@@ -64,13 +65,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })router.use((req, res, next) => {if (req.method !== 'POST') return next();const o = req.get('origin');-- if (o && o !== origin && o !== 'null') return res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');++ if (o && o !== origin && o !== 'null') return res.status(403).type('text/plain').set('Cache-Control', cache.htmlHeaders({ private: true })).send('Cross-site form posts are not accepted.');next();});const 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, ...o, body }));};const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });@@@ -96,7 +97,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })if (page && page.state === 'deleted') { gone(req, res); return null; }if (!page) {const r = await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);-- if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }++ if (r && r.status === 301) { res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }notFound(req, res);return null;@@@ -319,9 +320,9 @@ function createPages({ svc, viewers, platform, config, log = console, limits })let v;try { v = await svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';-- const cache = open && v.isPublishedRevision ? 'public' : null;++ const publicCache = open && v.isPublishedRevision ? 'public' : null;if (json) {-- res.status(200).set('Cache-Control', cache && req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization')++ res.status(200).set('Cache-Control', publicCache && req.actor.kind === 'anonymous' ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization').set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));return;}@@@ -350,7 +351,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })].filter(Boolean) : null;const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {-- head, path: svc.pagePath(space, page), cache,++ head, path: svc.pagePath(space, page), cache: publicCache,});}));@@@ -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/wiki.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/wiki.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/wiki.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); });@@@ -46,7 +46,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');// The published revision by default, readable without scripts.r = await H.req(h, 'GET', '/w/bread/rye/sources');assert.strictEqual(r.status, 200);-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');assert.ok(r.text.includes('<meta name="robots" content="noindex, follow">'));let page2 = stripScripts(r.text);assert.ok(page2.includes('<h1>Sources of Rye, revision 2</h1>'));@@@ -50,7 +50,7 @@ function atom(text) {let r = await H.req(h, 'GET', '/feed.atom');assert.strictEqual(r.status, 200, 'an empty feed is a feed, not a 404');assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);-- 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=3600');let f = atom(r.text);assert.ok(f.root, 'root <feed> in the Atom namespace');assert.strictEqual(f.id, 'http://wiki.test/feed.atom');@@@ -41,7 +41,7 @@ async function lastIndexEvent(h, pageId) {assert.strictEqual(ev.payload.indexability.decision, 'index');r = await H.req(h, 'GET', '/w/notes/open-page');assert.strictEqual(r.status, 200);-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));// A signed-in view is personalised: never publicly cacheable.r = await H.req(h, 'GET', '/w/notes/open-page', { cookie: H.cookieFor(H.userToken({ subject: owner.subject })) });
@@ -12,6 +12,7 @@ const path = require('path');
@@ -12,6 +12,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 { createSessionRoutes } = require('./auth/session');
const { createSessionRoutes } = require('./auth/session');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
@@ -112,7 +113,7 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valk
@@ -112,7 +113,7 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valk
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) }));
},
},
}));
}));
// GET /<key>.txt — the IndexNow key file, only when a key is configured (server/index.js).
// GET /<key>.txt — the IndexNow key file, only when a key is configured (server/index.js).
@@ -131,7 +132,7 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valk
@@ -131,7 +132,7 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valk
log.error('[Wiki]', err && err.stack ? err.stack : err);
log.error('[Wiki]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (res.headersSent) return;
if (req.path.startsWith('/api/')) return require('openvibe-contracts').http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error' });
if (req.path.startsWith('/api/')) return require('openvibe-contracts').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.');
});
});
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';
@@ -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.
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
* Shared HTTP helpers: the actor middleware, capability guards, error mapping and citation input.
* Shared HTTP helpers: the actor middleware, capability guards, error mapping and citation input.
*/
*/
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');
@@ -49,7 +50,7 @@ function run(fn, status = 200, log = console) {
@@ -49,7 +50,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);
}
}
@@ -13,16 +13,17 @@ const express = require('express');
@@ -13,16 +13,17 @@ const express = require('express');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
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/', '/new-space', '/search', '/proposals/'],
disallow: ['/auth/', '/api/', '/new-space', '/search', '/proposals/'],
}));
}));
@@ -49,14 +50,14 @@ function createMachine({ svc, config }) {
@@ -49,14 +50,14 @@ function createMachine({ svc, config }) {
const pages = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP });
const pages = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP });
const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];
const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));
- cache(res).type('application/xml').send(seo.sitemapIndex(maps));
+ setCache(res).type('application/xml').send(seo.sitemapIndex(maps));
}));
}));
- router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => cache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));
+ router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => setCache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));
router.get('/sitemaps/pages-:n.xml', wrap(async (req, res) => {
router.get('/sitemaps/pages-:n.xml', wrap(async (req, res) => {
const files = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP }).files;
const files = seo.sitemap(await pageEntries(), { 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() {
@@ -86,15 +87,15 @@ function createMachine({ svc, config }) {
@@ -86,15 +87,15 @@ function createMachine({ svc, config }) {
router.get('/feed.atom', wrap(async (_req, res) => {
router.get('/feed.atom', wrap(async (_req, res) => {
const items = await feedItems();
const items = await feedItems();
const updated = items.some((i) => i.decision.listable) ? null : await emptyFeedUpdated();
const updated = items.some((i) => i.decision.listable) ? null : await emptyFeedUpdated();
- cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));
+ setCache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));
}));
}));
router.get('/feed.json', wrap(async (_req, res) => {
router.get('/feed.json', wrap(async (_req, res) => {
- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));
+ setCache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));
}));
}));
router.get('/llms.txt', wrap(async (_req, res) => {
router.get('/llms.txt', wrap(async (_req, res) => {
const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');
const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');
- cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
+ setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Wiki',
name: 'OpenVibe.Wiki',
summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',
summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',
details: 'Every public page has a JSON representation at the same address plus ".json" (same content, same visibility rules). Revision history and diffs are public for public pages. AI-generated revisions are labelled and are published only after a person approves them.',
details: 'Every public page has a JSON representation at the same address plus ".json" (same content, same visibility rules). Revision history and diffs are public for public pages. AI-generated revisions are labelled and are published only after a person approves them.',
@@ -28,6 +28,7 @@
@@ -28,6 +28,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 seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
@@ -64,13 +65,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -64,13 +65,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
router.use((req, res, next) => {
router.use((req, res, next) => {
if (req.method !== 'POST') return next();
if (req.method !== 'POST') return next();
const o = req.get('origin');
const o = req.get('origin');
- if (o && o !== origin && o !== 'null') return res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');
+ if (o && o !== origin && o !== 'null') return res.status(403).type('text/plain').set('Cache-Control', cache.htmlHeaders({ private: true })).send('Cross-site form posts are not accepted.');
next();
next();
});
});
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, ...o, body }));
.send(renderPage({ config, actor: req.actor, path: o.path || req.path, ...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' });
@@ -96,7 +97,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -96,7 +97,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
if (page && page.state === 'deleted') { gone(req, res); return null; }
if (page && page.state === 'deleted') { gone(req, res); return null; }
if (!page) {
if (!page) {
const r = await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);
const r = await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);
- if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }
+ if (r && r.status === 301) { res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
notFound(req, res);
notFound(req, res);
return null;
return null;
@@ -319,9 +320,9 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -319,9 +320,9 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
let v;
let v;
try { v = await svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
try { v = await svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';
const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';
- const cache = open && v.isPublishedRevision ? 'public' : null;
+ const publicCache = open && v.isPublishedRevision ? 'public' : null;
if (json) {
if (json) {
- res.status(200).set('Cache-Control', cache && req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization')
+ res.status(200).set('Cache-Control', publicCache && req.actor.kind === 'anonymous' ? cache.htmlHeaders({ maxAge: 60 }) : cache.htmlHeaders({ private: true })).set('Vary', 'Cookie, Authorization')
.set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));
.set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));
return;
return;
}
}
@@ -350,7 +351,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -350,7 +351,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
].filter(Boolean) : null;
].filter(Boolean) : null;
const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });
const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });
send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {
send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {
- head, path: svc.pagePath(space, page), cache,
+ head, path: svc.pagePath(space, page), cache: publicCache,
});
});
}));
}));
@@ -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/wiki.css?v=0123456789`);
r = await fetch(`${h.base}/css/wiki.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/wiki.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/wiki.css`);
r = await fetch(`${h.base}/css/wiki.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); });
@@ -46,7 +46,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
@@ -46,7 +46,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
// The published revision by default, readable without scripts.
// The published revision by default, readable without scripts.
r = await H.req(h, 'GET', '/w/bread/rye/sources');
r = await H.req(h, 'GET', '/w/bread/rye/sources');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
assert.ok(r.text.includes('<meta name="robots" content="noindex, follow">'));
assert.ok(r.text.includes('<meta name="robots" content="noindex, follow">'));
let page2 = stripScripts(r.text);
let page2 = stripScripts(r.text);
assert.ok(page2.includes('<h1>Sources of Rye, revision 2</h1>'));
assert.ok(page2.includes('<h1>Sources of Rye, revision 2</h1>'));
@@ -50,7 +50,7 @@ function atom(text) {
@@ -50,7 +50,7 @@ function atom(text) {
let r = await H.req(h, 'GET', '/feed.atom');
let r = await H.req(h, 'GET', '/feed.atom');
assert.strictEqual(r.status, 200, 'an empty feed is a feed, not a 404');
assert.strictEqual(r.status, 200, 'an empty feed is a feed, not a 404');
assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);
assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);
- 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=3600');
let f = atom(r.text);
let f = atom(r.text);
assert.ok(f.root, 'root <feed> in the Atom namespace');
assert.ok(f.root, 'root <feed> in the Atom namespace');
assert.strictEqual(f.id, 'http://wiki.test/feed.atom');
assert.strictEqual(f.id, 'http://wiki.test/feed.atom');
@@ -41,7 +41,7 @@ async function lastIndexEvent(h, pageId) {
@@ -41,7 +41,7 @@ async function lastIndexEvent(h, pageId) {
assert.strictEqual(ev.payload.indexability.decision, 'index');
assert.strictEqual(ev.payload.indexability.decision, 'index');
r = await H.req(h, 'GET', '/w/notes/open-page');
r = await H.req(h, 'GET', '/w/notes/open-page');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
// A signed-in view is personalised: never publicly cacheable.
// A signed-in view is personalised: never publicly cacheable.
r = await H.req(h, 'GET', '/w/notes/open-page', { cookie: H.cookieFor(H.userToken({ subject: owner.subject })) });
r = await H.req(h, 'GET', '/w/notes/open-page', { cookie: H.cookieFor(H.userToken({ subject: owner.subject })) });