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)
Changes
@@@ -76,8 +76,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, islet 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, isres.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 stuffingnnew file mode 100644@@@ -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); });
@@ -76,8 +76,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -76,8 +76,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
let version = Date.now();
let version = Date.now();
const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
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() {
async function refreshRank() {
@@ -241,6 +241,46 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -241,6 +241,46 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.send(body);
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
// 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,66 @@
@@ -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); });