openvibers/openvibe.network Commits
The navbar's search suggestions through Network: GET /api/frame/suggest asks OpenVibe.Search for the person (their address goes along, answers kept 30 s), because every site's connect-src already allows openvibe.network and 33 refused search.openvibe.network (#370)
Changes
@@@ -121,5 +121,9 @@ module.exports = {chat: {internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',},++ // OpenVibe.Search: the navbar's suggestions (GET /api/frame/suggest, server/frame/service.js).++ search: {++ internalUrl: process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710',++ },},};@@@ -213,6 +213,34 @@ async function createFrameService(db, config, analytics, { privateKey = null, isres.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, updated: null }));});++ // GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's++ // suggestions, asked through Network because every site's connect-src already lets the navbar reach++ // openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own call++ // would be (public, published documents only); the person's address goes along so Search's per-person limit stays++ // per person. Answers are kept 30 s; Search not answering is an empty list, never an error.++ const suggested = new Map();++ router.get('/suggest', async (req, res) => {++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=30', 'Content-Type': 'application/json; charset=utf-8' });++ const q = String(req.query.q || '').trim().slice(0, 100);++ const owner = /^[a-z0-9-]{1,40}$/.test(String(req.query.owner || '')) ? String(req.query.owner) : '';++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 8, 1), 20);++ if (!q) return res.send('{"suggestions":[]}');++ const key = `${owner}|${limit}|${q.toLowerCase()}`;++ const kept = suggested.get(key);++ if (kept && Date.now() - kept.at < 30_000) return res.send(kept.body);++ const u = new URL('/api/v1/suggest', svcUrl('search', 'http://127.0.0.1:4710'));++ u.searchParams.set('q', q);++ u.searchParams.set('limit', String(limit));++ if (owner) u.searchParams.set('owner', owner);++ const d = await getJson(u.href, req.ip ? { 'X-Forwarded-For': req.ip } : undefined);++ const body = JSON.stringify({ suggestions: d && Array.isArray(d.suggestions) ? d.suggestions : [] });++ if (d) {++ if (suggested.size >= 2000) suggested.clear();++ suggested.set(key, { at: Date.now(), body });++ }++ res.send(body);++ });++// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffingnnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++// GET /api/frame/suggest: the navbar's magnifier asks OpenVibe.Search through Network, because every site's++// connect-src lets the navbar reach openvibe.network and most refuse search.openvibe.network (2026-10-28).++const assert = require('assert');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const { createFrameService } = require('../server/frame/service');++++(async () => {++ const asked = [];++ let down = false;++ const search = http.createServer((req, res) => {++ asked.push({ url: req.url, xff: req.headers['x-forwarded-for'] });++ if (down) { res.statusCode = 503; return res.end('{}'); }++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ suggestions: [{ owner: 'live', type: 'channel', id: '1', title: 'JapaneseOldGuy', canonical_url: 'https://openvibe.live/@JapaneseOldGuy' }] }));++ });++ await new Promise((r) => search.listen(0, '127.0.0.1', r));++ const svc = await createFrameService(getDb(), { services: { search: { internalUrl: `http://127.0.0.1:${search.address().port}` } } }, null);++ const app = express();++ app.set('trust proxy', 'loopback');++ app.use('/api/frame', svc.router);++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/frame/suggest`;++ const get = async (qs, headers) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };++++ let { r, d } = await get('?q=japan&owner=live&limit=5', { 'X-Forwarded-For': '203.0.113.9' });++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any site may ask');++ assert.deepStrictEqual(d.suggestions.map((s) => s.title), ['JapaneseOldGuy']);++ const first = new URL(asked[0].url, 'http://x');++ assert.deepStrictEqual([first.pathname, first.searchParams.get('q'), first.searchParams.get('owner'), first.searchParams.get('limit')], ['/api/v1/suggest', 'japan', 'live', '5']);++ assert.strictEqual(asked[0].xff, '203.0.113.9', "the person's address goes along (Search's limit is per person)");++++ ({ d } = await get('?q=Japan&owner=live&limit=5'));++ assert.strictEqual(asked.length, 1, 'the same question within 30 s is answered from memory');++ assert.strictEqual(d.suggestions.length, 1);++++ ({ d } = await get('?q=&owner=live'));++ assert.deepStrictEqual(d, { suggestions: [] }, 'nothing typed: nothing asked');++ ({ d } = await get('?q=x&owner=../admin&limit=999'));++ const odd = new URL(asked[asked.length - 1].url, 'http://x');++ assert.ok(!odd.searchParams.has('owner') && odd.searchParams.get('limit') === '20', 'an odd owner is dropped and the limit capped');++++ down = true;++ ({ r, d } = await get('?q=down'));++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(d, { suggestions: [] }, 'Search not answering is an empty list');++++ server.close(); search.close();++ console.log('frame suggest: Search through Network for the navbar, per person, kept 30 s ok');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -121,5 +121,9 @@ module.exports = {
@@ -121,5 +121,9 @@ module.exports = {
chat: {
chat: {
internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
},
},
+ // OpenVibe.Search: the navbar's suggestions (GET /api/frame/suggest, server/frame/service.js).
+ search: {
+ internalUrl: process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710',
+ },
},
},
};
};
@@ -213,6 +213,34 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -213,6 +213,34 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, updated: null }));
res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, updated: null }));
});
});
+ // GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's
+ // suggestions, asked through Network because every site's connect-src already lets the navbar reach
+ // openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own call
+ // would be (public, published documents only); the person's address goes along so Search's per-person limit stays
+ // per person. Answers are kept 30 s; Search not answering is an empty list, never an error.
+ const suggested = new Map();
+ router.get('/suggest', async (req, res) => {
+ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=30', 'Content-Type': 'application/json; charset=utf-8' });
+ const q = String(req.query.q || '').trim().slice(0, 100);
+ const owner = /^[a-z0-9-]{1,40}$/.test(String(req.query.owner || '')) ? String(req.query.owner) : '';
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 8, 1), 20);
+ if (!q) return res.send('{"suggestions":[]}');
+ const key = `${owner}|${limit}|${q.toLowerCase()}`;
+ const kept = suggested.get(key);
+ if (kept && Date.now() - kept.at < 30_000) return res.send(kept.body);
+ const u = new URL('/api/v1/suggest', svcUrl('search', 'http://127.0.0.1:4710'));
+ u.searchParams.set('q', q);
+ u.searchParams.set('limit', String(limit));
+ if (owner) u.searchParams.set('owner', owner);
+ const d = await getJson(u.href, req.ip ? { 'X-Forwarded-For': req.ip } : undefined);
+ const body = JSON.stringify({ suggestions: d && Array.isArray(d.suggestions) ? d.suggestions : [] });
+ if (d) {
+ if (suggested.size >= 2000) suggested.clear();
+ suggested.set(key, { at: Date.now(), body });
+ }
+ res.send(body);
+ });
+
// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the
// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the
// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.
// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.
const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing
const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+// GET /api/frame/suggest: the navbar's magnifier asks OpenVibe.Search through Network, because every site's
+// connect-src lets the navbar reach openvibe.network and most refuse search.openvibe.network (2026-10-28).
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { createFrameService } = require('../server/frame/service');
+
+(async () => {
+ const asked = [];
+ let down = false;
+ const search = http.createServer((req, res) => {
+ asked.push({ url: req.url, xff: req.headers['x-forwarded-for'] });
+ if (down) { res.statusCode = 503; return res.end('{}'); }
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ suggestions: [{ owner: 'live', type: 'channel', id: '1', title: 'JapaneseOldGuy', canonical_url: 'https://openvibe.live/@JapaneseOldGuy' }] }));
+ });
+ await new Promise((r) => search.listen(0, '127.0.0.1', r));
+ const svc = await createFrameService(getDb(), { services: { search: { internalUrl: `http://127.0.0.1:${search.address().port}` } } }, null);
+ const app = express();
+ app.set('trust proxy', 'loopback');
+ app.use('/api/frame', svc.router);
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/frame/suggest`;
+ const get = async (qs, headers) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };
+
+ let { r, d } = await get('?q=japan&owner=live&limit=5', { 'X-Forwarded-For': '203.0.113.9' });
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any site may ask');
+ assert.deepStrictEqual(d.suggestions.map((s) => s.title), ['JapaneseOldGuy']);
+ const first = new URL(asked[0].url, 'http://x');
+ assert.deepStrictEqual([first.pathname, first.searchParams.get('q'), first.searchParams.get('owner'), first.searchParams.get('limit')], ['/api/v1/suggest', 'japan', 'live', '5']);
+ assert.strictEqual(asked[0].xff, '203.0.113.9', "the person's address goes along (Search's limit is per person)");
+
+ ({ d } = await get('?q=Japan&owner=live&limit=5'));
+ assert.strictEqual(asked.length, 1, 'the same question within 30 s is answered from memory');
+ assert.strictEqual(d.suggestions.length, 1);
+
+ ({ d } = await get('?q=&owner=live'));
+ assert.deepStrictEqual(d, { suggestions: [] }, 'nothing typed: nothing asked');
+ ({ d } = await get('?q=x&owner=../admin&limit=999'));
+ const odd = new URL(asked[asked.length - 1].url, 'http://x');
+ assert.ok(!odd.searchParams.has('owner') && odd.searchParams.get('limit') === '20', 'an odd owner is dropped and the limit capped');
+
+ down = true;
+ ({ r, d } = await get('?q=down'));
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(d, { suggestions: [] }, 'Search not answering is an empty list');
+
+ server.close(); search.close();
+ console.log('frame suggest: Search through Network for the navbar, per person, kept 30 s ok');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });