openvibers/openvibe.live Commits

Kiosk omnibar titles through OpenVibe.Tools (platform S9): /api/kiosk/site asks the opengraph tool's run API with Live's service token (tools.tool.run on openvibe.tools; anonymous tier without one) and decodes entities in the title; Live fetches the page itself only when Tools refuses or is down. The DNS public-address check still runs first. test/kiosk-tools.test.js

OP OpenVibers committed 24 Sept 2026 · cf72bd94d5 · 2 files, +76 −1


Changes

ddiff --git a/server/kiosk/routes.js b/server/kiosk/routes.js
iindex 7a49751b..f9c8b907 100644
---- a/server/kiosk/routes.js
++++ b/server/kiosk/routes.js
@@@ -72,6 +72,28 @@ function normalizeUrl(raw) {
} catch { return null; }
}
++const TOOLS_URL = (process.env.OV_TOOLS_INTERNAL_URL || 'http://127.0.0.1:4001').replace(/\/$/, '');
++const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
++const decodeEntities = (s) => s.replace(/&(#x[0-9a-f]{1,6}|#[0-9]{1,7}|[a-z]{2,6});/gi, (m, e) => {
++ if (e[0] !== '#') return ENTITIES[e.toLowerCase()] ?? m;
++ const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
++ return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m;
++});
++/** { title, url } from Tools' opengraph tool (POST /api/v1/tools/opengraph/run), or null when Tools cannot say. */
++async function titleViaTools(url) {
++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
++ try { Object.assign(headers, await require('../net/network-principal').serviceHeaders('openvibe.tools')); } catch { /* no token: the anonymous tier */ }
++ try {
++ const r = await fetch(`${TOOLS_URL}/api/v1/tools/opengraph/run`, { method: 'POST', headers, body: JSON.stringify({ input: { url } }), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS + 1000) });
++ if (r.status === 401) require('../net/network-principal').invalidate('openvibe.tools');
++ if (!r.ok) return null;
++ const d = ((await r.json()).result || {}).data;
++ if (!d) return null;
++ const t = (d.preview && d.preview.title) || (d.tags && (d.tags['og:title'] || d.tags.title)) || '';
++ return { title: decodeEntities(String(t)).replace(/\s+/g, ' ').trim().slice(0, 140), url: d.url || url };
++ } catch { return null; }
++}
++
// ── GET /api/kiosk/site?url=<input> ──────────────────────────────
// { reachable, url, title, favicon, host } — reachable=false ⇒ the omnibar searches instead.
router.get('/site', async (req, res) => {
@@@ -86,7 +108,13 @@ router.get('/site', async (req, res) => {
if (!(await hostIsPublic(u.hostname))) return res.json({ reachable: false });
let title = '', finalUrl = u.href, finalHost = u.hostname;
-- try {
++ // OpenVibe.Tools' Open Graph tool reads the page (its SSRF guard, cache and per-target throttle);
++ // Live asks it first and only fetches itself when Tools does not answer.
++ const viaTools = await titleViaTools(u.href);
++ if (viaTools) {
++ title = viaTools.title;
++ try { const fu = new URL(viaTools.url || u.href); if (fu.protocol === 'https:' || fu.protocol === 'http:') { finalUrl = fu.href; finalHost = fu.hostname; } } catch { /* keep */ }
++ } else try {
// egress.fetchText re-applies the public-address rule at connect time on every redirect hop.
// The old fetch({ redirect: 'follow' }) only checked the first host, so a public page that
// redirected to an internal service had that service's <title> returned to anyone.
@@@ -140,3 +168,4 @@ router.get('/favicon', async (req, res) => {
});
module.exports = router;
++module.exports._titleViaTools = titleViaTools;
ddiff --git a/test/kiosk-tools.test.js b/test/kiosk-tools.test.js
nnew file mode 100644
iindex 00000000..a955c9d8
---- /dev/null
++++ b/test/kiosk-tools.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++/** The kiosk omnibar asks OpenVibe.Tools' opengraph tool for a page title first and only fetches the page itself when Tools cannot say. */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++
++(async () => {
++ let answer = { status: 200, body: { state: 'succeeded', result: { data: { url: 'https://1.1.1.1/', status: 200, tags: {}, preview: { title: ' One &amp; One\nOne&#39;s &#x4e00; ' } } } } };
++ const seen = [];
++ const tools = await new Promise((r) => { const s = http.createServer((req, res) => {
++ let b = ''; req.on('data', (c) => { b += c; }); req.on('end', () => {
++ seen.push({ path: req.url, host: req.headers.host, auth: req.headers.authorization || null, body: JSON.parse(b || '{}') });
++ res.writeHead(answer.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(answer.body));
++ });
++ }).listen(0, '127.0.0.1', () => r(s)); });
++ process.env.OV_TOOLS_INTERNAL_URL = `http://127.0.0.1:${tools.address().port}`;
++ delete process.env.OV_OAUTH_CLIENT_SECRET;
++ const kiosk = require('../server/kiosk/routes');
++ const app = express(); app.use('/api/kiosk', kiosk);
++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const site = (u) => fetch(`http://127.0.0.1:${srv.address().port}/api/kiosk/site?url=${encodeURIComponent(u)}`).then((r) => r.json());
++
++ let r = await site('1.1.1.1');
++ assert.deepStrictEqual({ reachable: r.reachable, title: r.title, host: r.host }, { reachable: true, title: "One & One One's 一", host: '1.1.1.1' });
++ assert.strictEqual(seen.length, 1);
++ assert.strictEqual(seen[0].path, '/api/v1/tools/opengraph/run');
++ assert.deepStrictEqual(seen[0].body, { input: { url: 'https://1.1.1.1/' } });
++ assert.strictEqual(seen[0].auth, null, 'no service secret: the anonymous tier');
++
++ r = await site('127.0.0.1');
++ assert.deepStrictEqual(r, { reachable: false });
++ assert.strictEqual(seen.length, 1, 'a private target never reaches Tools');
++
++ answer = { status: 200, body: { state: 'succeeded', result: { data: { url: 'javascript:alert(1)', preview: { title: 'x' } } } } };
++ assert.deepStrictEqual(await kiosk._titleViaTools('https://1.1.1.1/'), { title: 'x', url: 'javascript:alert(1)' });
++ r = await site('1.1.1.1/a');
++ assert.strictEqual(r.url, 'https://1.1.1.1/a', 'a non-http final URL from Tools is ignored');
++
++ answer = { status: 503, body: { type: 'about:blank', title: 'busy', status: 503 } };
++ assert.strictEqual(await kiosk._titleViaTools('https://1.1.1.1/'), null, 'Tools refusing = fall back to the local fetch');
++ tools.close();
++ assert.strictEqual(await kiosk._titleViaTools('https://1.1.1.1/'), null, 'Tools down = fall back');
++ srv.close();
++ console.log('kiosk tools: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });