openvibers/openvibe.bot Commits
The panel's own files by their content hash, so a deploy never draws with a cached stylesheet (#394)
Changes
@@@ -65,6 +65,24 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" arconst AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };++/*++ * The panel's own files by their content (2026-11-11: Bot #393 shipped new markup, and the edge kept serving the old++ * panel.css under its plain address for a while, so the new stick drew without its styles). Each address carries the++ * file's hash, so a deploy is a new address and a cached copy can only ever be the right one.++ */++const _assetHash = new Map();++function versioned(src) {++ const m = /^\/panel\/([a-z0-9-]+\.(?:js|css))$/.exec(String(src));++ if (!m) return src;++ if (!_assetHash.has(m[1])) {++ let h = '';++ try { h = require('crypto').createHash('sha1').update(require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'public', m[1]))).digest('hex').slice(0, 10); } catch { h = ''; }++ _assetHash.set(m[1], h);++ }++ const h = _assetHash.get(m[1]);++ return h ? `${src}?v=${h}` : src;++}++function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null, htmlClass = '', htmlAttrs = '', colorScheme = 'dark' } = {}) {return `<!doctype html><html lang="en"${htmlClass ? ` class="${esc(htmlClass)}"` : ''}${htmlAttrs}>@@@ -83,11 +101,11 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c<meta property="og:image" content="https://openvibe.bot${esc(social.url('bot'))}"><meta name="twitter:card" content="summary_large_image">` : ''}<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}">--<link rel="stylesheet" href="/panel/panel.css">++<link rel="stylesheet" href="${versioned('/panel/panel.css')}"></head><body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>${body}--${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}++${scripts.map((s) => `<script src="${esc(versioned(s))}" defer></script>`).join('\n')}</body></html>`;@@@ -23,7 +23,8 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), / disabled/, `${p.id}: ${w.type} is enabled with no allowed kind`);}-- assert.ok(html.includes('<script src="/panel/panel.js" defer></script>'));++ assert.match(html, /<script src="\/panel\/panel\.js\?v=[0-9a-f]{10}" defer><\/script>/, 'the client by its content hash');++ assert.match(html, /<link rel="stylesheet" href="\/panel\/panel\.css\?v=[0-9a-f]{10}">/, 'the stylesheet by its content hash');assert.doesNotMatch(html, /<script>|style="/, 'nothing inline (CSP default-src self)');}});@@@ -27,7 +27,7 @@ const { loadProfiles } = require('../server/profiles');assert.doesNotMatch(csp, /esm\.sh|unsafe-inline/);assert.match(r.headers.get('permissions-policy'), /camera=\(self\)/);const html = await r.text();-- assert.ok(html.includes('<script src="/panel/phone-device.js" defer></script>') && html.includes('data-pair-form') && html.includes('data-local-stop'));++ assert.ok(/<script src="\/panel\/phone-device\.js\?v=[0-9a-f]{10}" defer><\/script>/.test(html) && html.includes('data-pair-form') && html.includes('data-local-stop'));assert.doesNotMatch(html, /<script>|style="/, 'nothing inline');assert.strictEqual((await get('/panel/phone-device.js')).status, 200);});@@@ -98,7 +98,7 @@ const { renderPanel, renderEmbedRefused } = require('../server/web/render');assert.ok(js.includes('const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);'), 'takes it from that page only');assert.ok(js.includes('const fromPopup = e.origin === location.origin && popup && e.source === popup;'), 'or from its own popup');const refused = renderEmbedRefused({ robotId: 'rob_d1', parentOrigins: ['https://openvibe.live'] });-- assert.ok(refused.includes('src="/panel/refused.js"') && refused.includes('data-parent-origins="https://openvibe.live"'));++ assert.ok(refused.includes('src="/panel/refused.js?v=') && refused.includes('data-parent-origins="https://openvibe.live"'));assert.ok(refused.includes('/panel/rob_d1#on-stream') && refused.includes('Put the buttons under my OpenVibe.Live stream'));const rjs = fs.readFileSync(path.join(__dirname, '..', 'public', 'refused.js'), 'utf8');assert.ok(rjs.includes("window.parent.postMessage({ type: 'ov-bot-embed-refused', robot_id: robotId }, origin)"));@@@ -49,7 +49,7 @@ const { loadProfiles } = require('../server/profiles');// The description fits a search result (at most 160 characters).{ const d = (/<meta name="description" content="([^"]*)"/.exec(html) || [])[1] || ''; assert.ok(d.length >= 50 && d.length <= 160, `description ${d.length}: ${d}`); }assert.ok(html.includes('data-transport="local"') && html.includes('data-role="owner"') && html.includes('data-sim-camera'));-- const scripts = [...html.matchAll(/<script src="([^"]+)" defer><\/script>/g)].map((m) => m[1]);++ const scripts = [...html.matchAll(/<script src="([^"?]+)(?:\?v=[0-9a-f]{10})?" defer><\/script>/g)].map((m) => m[1]);assert.deepStrictEqual(scripts, ['/panel/sim-world.js', '/panel/try-device.js', '/panel/panel.js', '/panel/sim-view.js'], 'the rover and its socket before the panel, the camera after');const json = /<script type="application\/json" id="try-profile">([^<]*)<\/script>/.exec(html);assert.ok(json, 'the profile rides along as data');
@@ -65,6 +65,24 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
@@ -65,6 +65,24 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
+/*
+ * The panel's own files by their content (2026-11-11: Bot #393 shipped new markup, and the edge kept serving the old
+ * panel.css under its plain address for a while, so the new stick drew without its styles). Each address carries the
+ * file's hash, so a deploy is a new address and a cached copy can only ever be the right one.
+ */
+const _assetHash = new Map();
+function versioned(src) {
+ const m = /^\/panel\/([a-z0-9-]+\.(?:js|css))$/.exec(String(src));
+ if (!m) return src;
+ if (!_assetHash.has(m[1])) {
+ let h = '';
+ try { h = require('crypto').createHash('sha1').update(require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'public', m[1]))).digest('hex').slice(0, 10); } catch { h = ''; }
+ _assetHash.set(m[1], h);
+ }
+ const h = _assetHash.get(m[1]);
+ return h ? `${src}?v=${h}` : src;
+}
+
function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null, htmlClass = '', htmlAttrs = '', colorScheme = 'dark' } = {}) {
function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null, htmlClass = '', htmlAttrs = '', colorScheme = 'dark' } = {}) {
return `<!doctype html>
return `<!doctype html>
<html lang="en"${htmlClass ? ` class="${esc(htmlClass)}"` : ''}${htmlAttrs}>
<html lang="en"${htmlClass ? ` class="${esc(htmlClass)}"` : ''}${htmlAttrs}>
@@ -83,11 +101,11 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c
@@ -83,11 +101,11 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c
<meta property="og:image" content="https://openvibe.bot${esc(social.url('bot'))}">
<meta property="og:image" content="https://openvibe.bot${esc(social.url('bot'))}">
<meta name="twitter:card" content="summary_large_image">` : ''}
<meta name="twitter:card" content="summary_large_image">` : ''}
<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}">
<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}">
-<link rel="stylesheet" href="/panel/panel.css">
+<link rel="stylesheet" href="${versioned('/panel/panel.css')}">
</head>
</head>
<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
${body}
${body}
-${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
+${scripts.map((s) => `<script src="${esc(versioned(s))}" defer></script>`).join('\n')}
</body>
</body>
</html>
</html>
`;
`;
@@ -23,7 +23,8 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -23,7 +23,8 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);
assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);
assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), / disabled/, `${p.id}: ${w.type} is enabled with no allowed kind`);
assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), / disabled/, `${p.id}: ${w.type} is enabled with no allowed kind`);
}
}
- assert.ok(html.includes('<script src="/panel/panel.js" defer></script>'));
+ assert.match(html, /<script src="\/panel\/panel\.js\?v=[0-9a-f]{10}" defer><\/script>/, 'the client by its content hash');
+ assert.match(html, /<link rel="stylesheet" href="\/panel\/panel\.css\?v=[0-9a-f]{10}">/, 'the stylesheet by its content hash');
assert.doesNotMatch(html, /<script>|style="/, 'nothing inline (CSP default-src self)');
assert.doesNotMatch(html, /<script>|style="/, 'nothing inline (CSP default-src self)');
}
}
});
});
@@ -27,7 +27,7 @@ const { loadProfiles } = require('../server/profiles');
@@ -27,7 +27,7 @@ const { loadProfiles } = require('../server/profiles');
assert.doesNotMatch(csp, /esm\.sh|unsafe-inline/);
assert.doesNotMatch(csp, /esm\.sh|unsafe-inline/);
assert.match(r.headers.get('permissions-policy'), /camera=\(self\)/);
assert.match(r.headers.get('permissions-policy'), /camera=\(self\)/);
const html = await r.text();
const html = await r.text();
- assert.ok(html.includes('<script src="/panel/phone-device.js" defer></script>') && html.includes('data-pair-form') && html.includes('data-local-stop'));
+ assert.ok(/<script src="\/panel\/phone-device\.js\?v=[0-9a-f]{10}" defer><\/script>/.test(html) && html.includes('data-pair-form') && html.includes('data-local-stop'));
assert.doesNotMatch(html, /<script>|style="/, 'nothing inline');
assert.doesNotMatch(html, /<script>|style="/, 'nothing inline');
assert.strictEqual((await get('/panel/phone-device.js')).status, 200);
assert.strictEqual((await get('/panel/phone-device.js')).status, 200);
});
});
@@ -98,7 +98,7 @@ const { renderPanel, renderEmbedRefused } = require('../server/web/render');
@@ -98,7 +98,7 @@ const { renderPanel, renderEmbedRefused } = require('../server/web/render');
assert.ok(js.includes('const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);'), 'takes it from that page only');
assert.ok(js.includes('const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);'), 'takes it from that page only');
assert.ok(js.includes('const fromPopup = e.origin === location.origin && popup && e.source === popup;'), 'or from its own popup');
assert.ok(js.includes('const fromPopup = e.origin === location.origin && popup && e.source === popup;'), 'or from its own popup');
const refused = renderEmbedRefused({ robotId: 'rob_d1', parentOrigins: ['https://openvibe.live'] });
const refused = renderEmbedRefused({ robotId: 'rob_d1', parentOrigins: ['https://openvibe.live'] });
- assert.ok(refused.includes('src="/panel/refused.js"') && refused.includes('data-parent-origins="https://openvibe.live"'));
+ assert.ok(refused.includes('src="/panel/refused.js?v=') && refused.includes('data-parent-origins="https://openvibe.live"'));
assert.ok(refused.includes('/panel/rob_d1#on-stream') && refused.includes('Put the buttons under my OpenVibe.Live stream'));
assert.ok(refused.includes('/panel/rob_d1#on-stream') && refused.includes('Put the buttons under my OpenVibe.Live stream'));
const rjs = fs.readFileSync(path.join(__dirname, '..', 'public', 'refused.js'), 'utf8');
const rjs = fs.readFileSync(path.join(__dirname, '..', 'public', 'refused.js'), 'utf8');
assert.ok(rjs.includes("window.parent.postMessage({ type: 'ov-bot-embed-refused', robot_id: robotId }, origin)"));
assert.ok(rjs.includes("window.parent.postMessage({ type: 'ov-bot-embed-refused', robot_id: robotId }, origin)"));
@@ -49,7 +49,7 @@ const { loadProfiles } = require('../server/profiles');
@@ -49,7 +49,7 @@ const { loadProfiles } = require('../server/profiles');
// The description fits a search result (at most 160 characters).
// The description fits a search result (at most 160 characters).
{ const d = (/<meta name="description" content="([^"]*)"/.exec(html) || [])[1] || ''; assert.ok(d.length >= 50 && d.length <= 160, `description ${d.length}: ${d}`); }
{ const d = (/<meta name="description" content="([^"]*)"/.exec(html) || [])[1] || ''; assert.ok(d.length >= 50 && d.length <= 160, `description ${d.length}: ${d}`); }
assert.ok(html.includes('data-transport="local"') && html.includes('data-role="owner"') && html.includes('data-sim-camera'));
assert.ok(html.includes('data-transport="local"') && html.includes('data-role="owner"') && html.includes('data-sim-camera'));
- const scripts = [...html.matchAll(/<script src="([^"]+)" defer><\/script>/g)].map((m) => m[1]);
+ const scripts = [...html.matchAll(/<script src="([^"?]+)(?:\?v=[0-9a-f]{10})?" defer><\/script>/g)].map((m) => m[1]);
assert.deepStrictEqual(scripts, ['/panel/sim-world.js', '/panel/try-device.js', '/panel/panel.js', '/panel/sim-view.js'], 'the rover and its socket before the panel, the camera after');
assert.deepStrictEqual(scripts, ['/panel/sim-world.js', '/panel/try-device.js', '/panel/panel.js', '/panel/sim-view.js'], 'the rover and its socket before the panel, the camera after');
const json = /<script type="application\/json" id="try-profile">([^<]*)<\/script>/.exec(html);
const json = /<script type="application\/json" id="try-profile">([^<]*)<\/script>/.exec(html);
assert.ok(json, 'the profile rides along as data');
assert.ok(json, 'the profile rides along as data');