openvibers/openvibe.network Commits

GET /api/frame/query: the navbar's full results through Network (Search's fabric): as the person with their token, else anyone's answer kept 60 s (#384)

OP OpenVibers committed 16 days ago · 8b73f5bf58 · 2 files, +108 −2

Changes

ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex addfbafa..4444c263 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -76,8 +76,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
let version = Date.now();
const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
-- async function getJson(url, headers) {
-- try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(6000) }); return r.ok ? await r.json() : null; } catch { return null; }
++ async function getJson(url, headers, timeoutMs = 6000) {
++ try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }); return r.ok ? await r.json() : null; } catch { return null; }
}
async function refreshRank() {
@@@ -241,6 +241,46 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.send(body);
});
++ // GET /api/frame/query?q=&scopes=&freshness=&depth=&limit=&owner=&type= — the navbar's full results (openvibe-shared
++ // search.js, Enter): OpenVibe.Search's fabric (Contracts 0.225.0 search.fabric.query), asked through Network for the same
++ // connect-src reason as /suggest. The restricted CORS above lets only OpenVibe's own sites send a person's token: with
++ // one, Search answers as that person (their own scope, paid web providers within the daily caps) and nothing is kept;
++ // without, it answers as anyone and the answer is kept 60 s. Search not answering is an empty answer, never an error.
++ const SCOPES = new Set(['network', 'mine', 'web', 'news', 'images', 'video', 'products', 'people', 'places', 'code']);
++ const queried = new Map();
++ router.get('/query', async (req, res) => {
++ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });
++ const q = String(req.query.q || '').trim().slice(0, 200);
++ const empty = '{"results":[],"groups":{},"answer":null,"citations":[],"route":{"explain":[],"cost_usd":0},"next_cursor":null}';
++ if (!q) { res.set('Cache-Control', 'no-store'); return res.send(empty); }
++ const scopes = String(req.query.scopes || 'network').split(',').map((x) => x.trim()).filter((x) => SCOPES.has(x)).slice(0, 10);
++ const u = new URL('/api/v1/query', svcUrl('search', 'http://127.0.0.1:4710'));
++ u.searchParams.set('q', q);
++ u.searchParams.set('scopes', (scopes.length ? scopes : ['network']).join(','));
++ for (const k of ['freshness', 'depth', 'limit', 'owner', 'type', 'cursor']) {
++ const v = String(req.query[k] || '');
++ if (v && /^[A-Za-z0-9_=-]{1,512}$/.test(v)) u.searchParams.set(k, v);
++ }
++ const auth = /^Bearer [A-Za-z0-9._~+/=-]{20,4096}$/.test(String(req.headers.authorization || '')) ? String(req.headers.authorization) : '';
++ const headers = { ...(req.ip ? { 'X-Forwarded-For': req.ip } : {}), ...(auth ? { Authorization: auth } : {}) };
++ if (auth) {
++ res.set('Cache-Control', 'private, no-store');
++ const d = await getJson(u.href, headers, 25000);
++ return res.send(d ? JSON.stringify(d) : empty);
++ }
++ res.set('Cache-Control', 'public, max-age=60');
++ const key = u.search.toLowerCase();
++ const kept = queried.get(key);
++ if (kept && Date.now() - kept.at < 60_000) return res.send(kept.body);
++ const d = await getJson(u.href, headers, 25000);
++ const body = d ? JSON.stringify(d) : empty;
++ if (d) {
++ if (queried.size >= 1000) queried.clear();
++ queried.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-query.test.js b/test/frame-query.test.js
nnew file mode 100644
iindex 00000000..3c65a2ef
---- /dev/null
++++ b/test/frame-query.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++// GET /api/frame/query: the navbar's full results (Enter) ask OpenVibe.Search's fabric through Network, like /suggest.
++// With a person's token Search answers as them and nothing is kept; without, the answer is kept 60 s.
++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, auth: req.headers.authorization || '', xff: req.headers['x-forwarded-for'] });
++ if (down) { res.statusCode = 503; return res.end('{}'); }
++ res.setHeader('content-type', 'application/json');
++ res.end(JSON.stringify({ results: [{ scope: 'network', kind: 'document', title: 'surf_utopia', url: 'https://openvibe.live/clips/1' }], groups: { network: 1 }, answer: null, citations: [], route: { explain: [], cost_usd: 0 }, next_cursor: null }));
++ });
++ 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/query`;
++ const get = async (qs, headers) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };
++ const token = `Bearer ${'a'.repeat(40)}.${'b'.repeat(40)}.${'c'.repeat(20)}`;
++
++ let { r, d } = await get('?q=surf&scopes=network,web,bogus&depth=quick&limit=10', { 'X-Forwarded-For': '203.0.113.9' });
++ assert.deepStrictEqual(d.results.map((x) => x.title), ['surf_utopia']);
++ const first = new URL(asked[0].url, 'http://x');
++ assert.deepStrictEqual([first.pathname, first.searchParams.get('q'), first.searchParams.get('scopes'), first.searchParams.get('depth'), first.searchParams.get('limit')],
++ ['/api/v1/query', 'surf', 'network,web', 'quick', '10'], 'unknown scopes are dropped; the rest go along');
++ assert.strictEqual(asked[0].auth, '', 'anonymous asks as anyone');
++ assert.strictEqual(asked[0].xff, '203.0.113.9');
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, 'not open to every origin: only our sites (restricted CORS) may send a token');
++
++ await get('?q=surf&scopes=network,web,bogus&depth=quick&limit=10');
++ assert.strictEqual(asked.length, 1, 'the same anonymous question within 60 s is answered from memory');
++
++ ({ r } = await get('?q=surf&scopes=mine,web', { Authorization: token }));
++ assert.strictEqual(asked.length, 2, 'a person is always asked fresh');
++ assert.strictEqual(asked[1].auth, token, "the person's token goes along");
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ await get('?q=surf&scopes=mine,web', { Authorization: token });
++ assert.strictEqual(asked.length, 3, 'nothing personal is kept');
++ await get('?q=surf&scopes=mine', { Authorization: 'Bearer short' });
++ assert.strictEqual(asked[3].auth, '', 'a malformed token is not passed on');
++
++ ({ d } = await get('?q=x&owner=../admin&cursor=a%20b'));
++ const odd = new URL(asked[asked.length - 1].url, 'http://x');
++ assert.ok(!odd.searchParams.has('owner') && !odd.searchParams.has('cursor'), 'odd values are dropped');
++
++ ({ d } = await get('?q='));
++ assert.deepStrictEqual(d.results, [], 'nothing typed: nothing asked');
++ down = true;
++ ({ r, d } = await get('?q=down'));
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual([d.results, d.next_cursor], [[], null], 'Search not answering is an empty answer');
++
++ server.close(); search.close();
++ console.log('frame query: the fabric through Network, as the person or kept 60 s ok');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });