openvibers/openvibe.search Commits
The OpenVibe Frame on search.openvibe.network: the shared navbar, footer and themes (scripts only from this site or openvibe.network; the init is the same-origin /frame-init.js reading a JSON config, so the CSP still allows no inline script), what shipped on the front page, and /updates as the shared log; nginx serves both new paths; openvibe-shared 1.11.1
Changes
@@@ -79,6 +79,13 @@ server {proxy_pass http://127.0.0.1:4710;}location = /robots.txt { proxy_pass http://127.0.0.1:4710; }++ # The OpenVibe Frame's same-origin init script, and what shipped on this site (the shared log).++ location = /frame-init.js { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }++ location = /updates {++ limit_except GET { deny all; }++ limit_req zone=ovsearch_query burst=40 nodelay;++ proxy_pass http://127.0.0.1:4710;++ }# ── Public query API (GET only) ─────────────────────────location ~ ^/api/v1/(search|suggest)$ {@@@ -15,7 +15,7 @@"dotenv": "^16.4.7","express": "^4.21.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1"},"engines": {"node": ">=22.0.0"@@@ -985,9 +985,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.7.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",-- "integrity": "sha512-gF1PABMEGHhtu0WMkJQHwJOvbYzWcdfOi3feGYrCUkLXIZhpeSvrSOD7SF0+nveWhfCQMFjqNU8Q0d90vv4s/A==",++ "version": "1.11.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1",++ "integrity": "sha512-hBaZlZRVZjkGfsPQE16rQIou2lUuusADea3Evx1tVjB84FTHGHS7ZALfcXaNOk7dR53HOVTJrQ0njmScdreUiw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -21,7 +21,7 @@"dotenv": "^16.4.7","express": "^4.21.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1"},"engines": {"node": ">=22.0.0"@@@ -14,7 +14,27 @@ const express = require('express');const { AuthError, ANONYMOUS } = require('../auth');const { QueryError, one } = require('../api/query');--const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";++// The OpenVibe Frame (navbar, footer, "shipped" views, themes) comes from openvibe.network; its init is++// /frame-init.js (same origin, no inline script), reading the JSON config in #ov-frame-config.++const NETWORK = 'https://openvibe.network';++const CSP = `default-src 'none'; script-src 'self' ${NETWORK}; connect-src 'self' ${NETWORK}; style-src 'unsafe-inline' ${NETWORK}; img-src 'self' data: https:; frame-src ${NETWORK}; form-action 'self' ${NETWORK}; base-uri 'none'; frame-ancestors 'none'`;++const frame = require('openvibe-shared/frame');++const FRAME_INIT = `(function () {++ var tries = 0;++ function boot() {++ if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }++ var el = document.getElementById('ov-frame-config'); var cfg = {};++ try { cfg = JSON.parse(el ? el.textContent : '{}'); } catch (e) { /* */ }++ try { if (cfg.navbar) OpenVibeNavbar.init(cfg.navbar); } catch (e) { /* the Frame is optional */ }++ try { if (cfg.footer) OpenVibeFooter.init(cfg.footer); } catch (e) { /* */ }++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();++})();++`;++const FRAME_CONFIG = {++ navbar: { service: 'search', apiBase: NETWORK, links: [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }] },++ footer: { service: 'search', variant: 'compact', mount: '#ov-footer', brandName: 'OpenVibe.Search', updates: '/updates' },++};const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));@@@ -59,6 +79,10 @@ function layout({ title, q, owner, type, body, noindex }) {<title>${esc(title)}</title>${noindex ? '<meta name="robots" content="noindex, nofollow">\n' : ''}<meta name="description" content="Search what the OpenVibe network's services have published."><meta name="color-scheme" content="light dark">++<script src="${NETWORK}/shared/theme-loader.js" defer></script>++<script src="${NETWORK}/shared/navbar.js" defer></script>++<script src="${NETWORK}/shared/footer.js" defer></script>++<script src="/frame-init.js" defer></script><style>:root { --bg: #fff; --fg: #1a1a1a; --muted: #5c5c66; --line: #dcdce3; --accent: #2456d6; --mark: #fff2a8; }@media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8e8ec; --muted: #a0a0ab; --line: #2c2f36; --accent: #7aa2ff; --mark: #5a4b00; } }@@@ -79,11 +103,13 @@ li a.t:hover { text-decoration: underline; }mark { background: var(--mark); color: inherit; }.empty, .note { color: var(--muted); }.more { display: inline-block; margin-top: 16px; color: var(--accent); }--footer { margin-top: 40px; color: var(--muted); font-size: .85rem; }--footer a { color: inherit; }++.page-note { margin-top: 40px; color: var(--muted); font-size: .85rem; }++.page-note a { color: inherit; }</style></head><body>++<div id="navbar-mount"></div>++${frame.noscriptNav({ name: 'OpenVibe.Search', links: [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }] })}<main><h1><a href="/" style="color:inherit;text-decoration:none">OpenVibe.Search</a></h1><p class="lede">Search what the OpenVibe network's services have published. Alpha: the index holds only what they have sent so far.</p>@@@ -92,12 +118,14 @@ footer a { color: inherit; }${owner ? `<input type="hidden" name="owner" value="${esc(owner)}">` : ''}${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}<button type="submit">Search</button></form>${body}--<footer>++<section class="page-note"><p>Private, draft and deleted documents are never shown. A document that is removed or made private leaves these results at once.JSON API: <a href="/api/v1/search?q=${encodeURIComponent(q || '')}">/api/v1/search</a>.Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenVibe.Search</a>.</p>--</footer>++</section></main>++${frame.footer({ service: 'search', variant: 'compact', updates: '/updates' })}++<script type="application/json" id="ov-frame-config">${JSON.stringify(FRAME_CONFIG).replace(/</g, '\\u003c')}</script></body></html>`;@@@ -117,9 +145,21 @@ ${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p clafunction pageRouter({ searcher, auth }) {const router = express.Router();++ router.get('/frame-init.js', (_req, res) => {++ res.type('application/javascript').set('Cache-Control', 'public, max-age=3600').send(FRAME_INIT);++ });++++ // What shipped on OpenVibe.Search: the shared update log every OpenVibe site has.++ router.get('/updates', (_req, res) => {++ res.setHeader('Content-Security-Policy', CSP);++ res.setHeader('X-Frame-Options', 'DENY');++ res.setHeader('Cache-Control', 'public, max-age=60');++ res.type('html').send(layout({ title: 'What shipped on OpenVibe.Search', q: '', owner: '', type: '', body: frame.updatesBody({ service: 'search', siteName: 'OpenVibe.Search' }) + frame.shippedScript() }));++ });++router.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', 'public, max-age=3600')-- .send('User-agent: *\nAllow: /$\nDisallow: /?\nDisallow: /api/\nDisallow: /internal/\n');++ .send('User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /?\nDisallow: /api/\nDisallow: /internal/\n');});router.get('/', (req, res, next) => {@@@ -145,7 +185,7 @@ function pageRouter({ searcher, auth }) {if (!(err instanceof AuthError)) return next(err);viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous}-- let body = '<p class="note">Type some words to search. Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>';++ let body = '<p class="note">Type some words to search. Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>' + frame.shipped({ service: 'search', title: 'Recently shipped on OpenVibe.Search' });let status = 200;if (searching) {try {@@@ -30,7 +30,31 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asyassert.strictEqual(r.headers.get('x-robots-tag'), null);assert.strictEqual(r.headers.get('cache-control'), 'no-store');assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);-- assert.ok(!/<script/i.test(r.text), 'no JavaScript on the page');++ // The OpenVibe Frame (navbar, footer, shipped views) is the only script: from this site or++ // openvibe.network, never inline JavaScript (the Frame's config is a JSON data block).++ const csp = r.headers.get('content-security-policy');++ assert.match(csp, /script-src 'self' https:\/\/openvibe\.network;/);++ assert.ok(!/script-src[^;]*unsafe-inline/.test(csp), 'no inline script allowed');++ const scripts = [...r.text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)];++ for (const [, attrs, body] of scripts) {++ if (/type="application\/json"/.test(attrs)) continue;++ assert.strictEqual(body.trim(), '', 'no inline JavaScript');++ const src = (/src="([^"]+)"/.exec(attrs) || [])[1];++ assert.ok(src && (src.startsWith('/') || src.startsWith('https://openvibe.network/')), `script from this site or openvibe.network: ${src}`);++ }++ assert.ok(r.text.includes('<div id="navbar-mount"></div>') && r.text.includes('id="ov-footer"'), 'the OpenVibe Frame');++ assert.ok(r.text.includes('data-ov-shipped="latest" data-service="search" href="/updates"'), 'what shipped');++});++++t('the Frame init is a same-origin script and /updates is the shared log', async () => {++ const init = await fetch(`${svc.base}/frame-init.js`);++ assert.strictEqual(init.status, 200);++ assert.match(init.headers.get('content-type'), /javascript/);++ assert.match(await init.text(), /OpenVibeNavbar\.init/);++ const r = await html('/updates');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('What shipped on OpenVibe.Search') && r.text.includes('data-ov-shipped="log" data-service="search"'));++ assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);});t('results show public documents only, escaped, linked to their canonical URL, noindex', async () => {@@@ -27,6 +27,8 @@ t('wildcard certificate and client address from $remote_addr only', () => {t('/metrics is 404; owner API, webhook and document writes are loopback-only', () => {assert.match(conf, /location = \/metrics \{ return 404; \}/);++ assert.match(conf, /location = \/frame-init\.js \{ limit_except GET \{ deny all; \} proxy_pass http:\/\/127\.0\.0\.1:4710; \}/, 'the Frame init script');++ assert.match(block('location = /updates'), /limit_except GET \{ deny all; \}/, 'the update log, GET only');assert.match(block('location ^~ /internal/'), loopbackOnly);assert.match(block('location ^~ /api/v1/owners/'), loopbackOnly);assert.match(block('location ^~ /api/v1/documents/'), /limit_except GET \{\s*allow 127\.0\.0\.1;\s*allow ::1;\s*deny all;/);
@@ -79,6 +79,13 @@ server {
@@ -79,6 +79,13 @@ server {
proxy_pass http://127.0.0.1:4710;
proxy_pass http://127.0.0.1:4710;
}
}
location = /robots.txt { proxy_pass http://127.0.0.1:4710; }
location = /robots.txt { proxy_pass http://127.0.0.1:4710; }
+ # The OpenVibe Frame's same-origin init script, and what shipped on this site (the shared log).
+ location = /frame-init.js { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
+ location = /updates {
+ limit_except GET { deny all; }
+ limit_req zone=ovsearch_query burst=40 nodelay;
+ proxy_pass http://127.0.0.1:4710;
+ }
# ── Public query API (GET only) ─────────────────────────
# ── Public query API (GET only) ─────────────────────────
location ~ ^/api/v1/(search|suggest)$ {
location ~ ^/api/v1/(search|suggest)$ {
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -985,9 +985,9 @@
@@ -985,9 +985,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.7.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",
- "integrity": "sha512-gF1PABMEGHhtu0WMkJQHwJOvbYzWcdfOi3feGYrCUkLXIZhpeSvrSOD7SF0+nveWhfCQMFjqNU8Q0d90vv4s/A==",
+ "version": "1.11.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1",
+ "integrity": "sha512-hBaZlZRVZjkGfsPQE16rQIou2lUuusADea3Evx1tVjB84FTHGHS7ZALfcXaNOk7dR53HOVTJrQ0njmScdreUiw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -14,7 +14,27 @@ const express = require('express');
@@ -14,7 +14,27 @@ const express = require('express');
const { AuthError, ANONYMOUS } = require('../auth');
const { AuthError, ANONYMOUS } = require('../auth');
const { QueryError, one } = require('../api/query');
const { QueryError, one } = require('../api/query');
-const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
+// The OpenVibe Frame (navbar, footer, "shipped" views, themes) comes from openvibe.network; its init is
+// /frame-init.js (same origin, no inline script), reading the JSON config in #ov-frame-config.
+const NETWORK = 'https://openvibe.network';
+const CSP = `default-src 'none'; script-src 'self' ${NETWORK}; connect-src 'self' ${NETWORK}; style-src 'unsafe-inline' ${NETWORK}; img-src 'self' data: https:; frame-src ${NETWORK}; form-action 'self' ${NETWORK}; base-uri 'none'; frame-ancestors 'none'`;
+const frame = require('openvibe-shared/frame');
+const FRAME_INIT = `(function () {
+ var tries = 0;
+ function boot() {
+ if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }
+ var el = document.getElementById('ov-frame-config'); var cfg = {};
+ try { cfg = JSON.parse(el ? el.textContent : '{}'); } catch (e) { /* */ }
+ try { if (cfg.navbar) OpenVibeNavbar.init(cfg.navbar); } catch (e) { /* the Frame is optional */ }
+ try { if (cfg.footer) OpenVibeFooter.init(cfg.footer); } catch (e) { /* */ }
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
+})();
+`;
+const FRAME_CONFIG = {
+ navbar: { service: 'search', apiBase: NETWORK, links: [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }] },
+ footer: { service: 'search', variant: 'compact', mount: '#ov-footer', brandName: 'OpenVibe.Search', updates: '/updates' },
+};
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -59,6 +79,10 @@ function layout({ title, q, owner, type, body, noindex }) {
@@ -59,6 +79,10 @@ function layout({ title, q, owner, type, body, noindex }) {
<title>${esc(title)}</title>
<title>${esc(title)}</title>
${noindex ? '<meta name="robots" content="noindex, nofollow">\n' : ''}<meta name="description" content="Search what the OpenVibe network's services have published.">
${noindex ? '<meta name="robots" content="noindex, nofollow">\n' : ''}<meta name="description" content="Search what the OpenVibe network's services have published.">
<meta name="color-scheme" content="light dark">
<meta name="color-scheme" content="light dark">
+<script src="${NETWORK}/shared/theme-loader.js" defer></script>
+<script src="${NETWORK}/shared/navbar.js" defer></script>
+<script src="${NETWORK}/shared/footer.js" defer></script>
+<script src="/frame-init.js" defer></script>
<style>
<style>
:root { --bg: #fff; --fg: #1a1a1a; --muted: #5c5c66; --line: #dcdce3; --accent: #2456d6; --mark: #fff2a8; }
:root { --bg: #fff; --fg: #1a1a1a; --muted: #5c5c66; --line: #dcdce3; --accent: #2456d6; --mark: #fff2a8; }
@media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8e8ec; --muted: #a0a0ab; --line: #2c2f36; --accent: #7aa2ff; --mark: #5a4b00; } }
@media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8e8ec; --muted: #a0a0ab; --line: #2c2f36; --accent: #7aa2ff; --mark: #5a4b00; } }
@@ -79,11 +103,13 @@ li a.t:hover { text-decoration: underline; }
@@ -79,11 +103,13 @@ li a.t:hover { text-decoration: underline; }
mark { background: var(--mark); color: inherit; }
mark { background: var(--mark); color: inherit; }
.empty, .note { color: var(--muted); }
.empty, .note { color: var(--muted); }
.more { display: inline-block; margin-top: 16px; color: var(--accent); }
.more { display: inline-block; margin-top: 16px; color: var(--accent); }
-footer { margin-top: 40px; color: var(--muted); font-size: .85rem; }
-footer a { color: inherit; }
+.page-note { margin-top: 40px; color: var(--muted); font-size: .85rem; }
+.page-note a { color: inherit; }
</style>
</style>
</head>
</head>
<body>
<body>
+<div id="navbar-mount"></div>
+${frame.noscriptNav({ name: 'OpenVibe.Search', links: [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }] })}
<main>
<main>
<h1><a href="/" style="color:inherit;text-decoration:none">OpenVibe.Search</a></h1>
<h1><a href="/" style="color:inherit;text-decoration:none">OpenVibe.Search</a></h1>
<p class="lede">Search what the OpenVibe network's services have published. Alpha: the index holds only what they have sent so far.</p>
<p class="lede">Search what the OpenVibe network's services have published. Alpha: the index holds only what they have sent so far.</p>
@@ -92,12 +118,14 @@ footer a { color: inherit; }
@@ -92,12 +118,14 @@ footer a { color: inherit; }
${owner ? `<input type="hidden" name="owner" value="${esc(owner)}">` : ''}${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}<button type="submit">Search</button>
${owner ? `<input type="hidden" name="owner" value="${esc(owner)}">` : ''}${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}<button type="submit">Search</button>
</form>
</form>
${body}
${body}
-<footer>
+<section class="page-note">
<p>Private, draft and deleted documents are never shown. A document that is removed or made private leaves these results at once.
<p>Private, draft and deleted documents are never shown. A document that is removed or made private leaves these results at once.
JSON API: <a href="/api/v1/search?q=${encodeURIComponent(q || '')}">/api/v1/search</a>.
JSON API: <a href="/api/v1/search?q=${encodeURIComponent(q || '')}">/api/v1/search</a>.
Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenVibe.Search</a>.</p>
Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenVibe.Search</a>.</p>
-</footer>
+</section>
</main>
</main>
+${frame.footer({ service: 'search', variant: 'compact', updates: '/updates' })}
+<script type="application/json" id="ov-frame-config">${JSON.stringify(FRAME_CONFIG).replace(/</g, '\\u003c')}</script>
</body>
</body>
</html>
</html>
`;
`;
@@ -117,9 +145,21 @@ ${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p cla
@@ -117,9 +145,21 @@ ${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p cla
function pageRouter({ searcher, auth }) {
function pageRouter({ searcher, auth }) {
const router = express.Router();
const router = express.Router();
+ router.get('/frame-init.js', (_req, res) => {
+ res.type('application/javascript').set('Cache-Control', 'public, max-age=3600').send(FRAME_INIT);
+ });
+
+ // What shipped on OpenVibe.Search: the shared update log every OpenVibe site has.
+ router.get('/updates', (_req, res) => {
+ res.setHeader('Content-Security-Policy', CSP);
+ res.setHeader('X-Frame-Options', 'DENY');
+ res.setHeader('Cache-Control', 'public, max-age=60');
+ res.type('html').send(layout({ title: 'What shipped on OpenVibe.Search', q: '', owner: '', type: '', body: frame.updatesBody({ service: 'search', siteName: 'OpenVibe.Search' }) + frame.shippedScript() }));
+ });
+
router.get('/robots.txt', (_req, res) => {
router.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
- .send('User-agent: *\nAllow: /$\nDisallow: /?\nDisallow: /api/\nDisallow: /internal/\n');
+ .send('User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /?\nDisallow: /api/\nDisallow: /internal/\n');
});
});
router.get('/', (req, res, next) => {
router.get('/', (req, res, next) => {
@@ -145,7 +185,7 @@ function pageRouter({ searcher, auth }) {
@@ -145,7 +185,7 @@ function pageRouter({ searcher, auth }) {
if (!(err instanceof AuthError)) return next(err);
if (!(err instanceof AuthError)) return next(err);
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
}
}
- let body = '<p class="note">Type some words to search. Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>';
+ let body = '<p class="note">Type some words to search. Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>' + frame.shipped({ service: 'search', title: 'Recently shipped on OpenVibe.Search' });
let status = 200;
let status = 200;
if (searching) {
if (searching) {
try {
try {
@@ -30,7 +30,31 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
@@ -30,7 +30,31 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
assert.strictEqual(r.headers.get('x-robots-tag'), null);
assert.strictEqual(r.headers.get('x-robots-tag'), null);
assert.strictEqual(r.headers.get('cache-control'), 'no-store');
assert.strictEqual(r.headers.get('cache-control'), 'no-store');
assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
- assert.ok(!/<script/i.test(r.text), 'no JavaScript on the page');
+ // The OpenVibe Frame (navbar, footer, shipped views) is the only script: from this site or
+ // openvibe.network, never inline JavaScript (the Frame's config is a JSON data block).
+ const csp = r.headers.get('content-security-policy');
+ assert.match(csp, /script-src 'self' https:\/\/openvibe\.network;/);
+ assert.ok(!/script-src[^;]*unsafe-inline/.test(csp), 'no inline script allowed');
+ const scripts = [...r.text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)];
+ for (const [, attrs, body] of scripts) {
+ if (/type="application\/json"/.test(attrs)) continue;
+ assert.strictEqual(body.trim(), '', 'no inline JavaScript');
+ const src = (/src="([^"]+)"/.exec(attrs) || [])[1];
+ assert.ok(src && (src.startsWith('/') || src.startsWith('https://openvibe.network/')), `script from this site or openvibe.network: ${src}`);
+ }
+ assert.ok(r.text.includes('<div id="navbar-mount"></div>') && r.text.includes('id="ov-footer"'), 'the OpenVibe Frame');
+ assert.ok(r.text.includes('data-ov-shipped="latest" data-service="search" href="/updates"'), 'what shipped');
+});
+
+t('the Frame init is a same-origin script and /updates is the shared log', async () => {
+ const init = await fetch(`${svc.base}/frame-init.js`);
+ assert.strictEqual(init.status, 200);
+ assert.match(init.headers.get('content-type'), /javascript/);
+ assert.match(await init.text(), /OpenVibeNavbar\.init/);
+ const r = await html('/updates');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('What shipped on OpenVibe.Search') && r.text.includes('data-ov-shipped="log" data-service="search"'));
+ assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
});
});
t('results show public documents only, escaped, linked to their canonical URL, noindex', async () => {
t('results show public documents only, escaped, linked to their canonical URL, noindex', async () => {
@@ -27,6 +27,8 @@ t('wildcard certificate and client address from $remote_addr only', () => {
@@ -27,6 +27,8 @@ t('wildcard certificate and client address from $remote_addr only', () => {
t('/metrics is 404; owner API, webhook and document writes are loopback-only', () => {
t('/metrics is 404; owner API, webhook and document writes are loopback-only', () => {
assert.match(conf, /location = \/metrics \{ return 404; \}/);
assert.match(conf, /location = \/metrics \{ return 404; \}/);
+ assert.match(conf, /location = \/frame-init\.js \{ limit_except GET \{ deny all; \} proxy_pass http:\/\/127\.0\.0\.1:4710; \}/, 'the Frame init script');
+ assert.match(block('location = /updates'), /limit_except GET \{ deny all; \}/, 'the update log, GET only');
assert.match(block('location ^~ /internal/'), loopbackOnly);
assert.match(block('location ^~ /internal/'), loopbackOnly);
assert.match(block('location ^~ /api/v1/owners/'), loopbackOnly);
assert.match(block('location ^~ /api/v1/owners/'), loopbackOnly);
assert.match(block('location ^~ /api/v1/documents/'), /limit_except GET \{\s*allow 127\.0\.0\.1;\s*allow ::1;\s*deny all;/);
assert.match(block('location ^~ /api/v1/documents/'), /limit_except GET \{\s*allow 127\.0\.0\.1;\s*allow ::1;\s*deny all;/);