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
Changes
@@@ -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;nnew file mode 100644@@@ -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 & One\nOne's 一 ' } } } } };++ 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); });
@@ -72,6 +72,28 @@ function normalizeUrl(raw) {
@@ -72,6 +72,28 @@ function normalizeUrl(raw) {
} catch { return null; }
} 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> ──────────────────────────────
// ── GET /api/kiosk/site?url=<input> ──────────────────────────────
// { reachable, url, title, favicon, host } — reachable=false ⇒ the omnibar searches instead.
// { reachable, url, title, favicon, host } — reachable=false ⇒ the omnibar searches instead.
router.get('/site', async (req, res) => {
router.get('/site', async (req, res) => {
@@ -86,7 +108,13 @@ 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 });
if (!(await hostIsPublic(u.hostname))) return res.json({ reachable: false });
let title = '', finalUrl = u.href, finalHost = u.hostname;
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.
// 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
// 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.
// redirected to an internal service had that service's <title> returned to anyone.
@@ -140,3 +168,4 @@ router.get('/favicon', async (req, res) => {
@@ -140,3 +168,4 @@ router.get('/favicon', async (req, res) => {
});
});
module.exports = router;
module.exports = router;
+module.exports._titleViaTools = titleViaTools;
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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 & One\nOne's 一 ' } } } } };
+ 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); });