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)

OP OpenVibers committed 16 days ago · ee50fac413 · 3 files, +86 −0

Changes

ddiff --git a/server/config.js b/server/config.js
iindex f45036f1..edcb978e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
++ },
},
};
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 38ec4e86..5975464d 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -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 }));
});
++ // 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 stuffing
ddiff --git a/test/frame-suggest.test.js b/test/frame-suggest.test.js
nnew file mode 100644
iindex 00000000..aafe2782
---- /dev/null
++++ b/test/frame-suggest.test.js
@@@ -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); });