openvibers/openvibe.community Commits
Search page (WS-O task 10: product search boxes use the query API): /search finds public threads and pastes through OpenVibe.Search (anonymous, owner=community, at its internal origin), with type filter and cursor paging; snippets keep only Search's <mark>; Search not answering is a plain 503 page offering the paste filter; noindex; Search in the navbar; a link to search the whole network
Changes
@@@ -318,3 +318,11 @@ button.rx:hover { border-color: var(--accent); }.paste-embed img { display: block; max-width: 100%; max-height: 360px; margin: 0 auto; }.crosspost { margin: 8px 0; }.crosspost-note { margin: 4px 0; }++++/* /search */++.search-results { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }++.search-hit { padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); min-width: 0; }++.search-title { font-weight: 600; font-size: 1.05rem; overflow-wrap: anywhere; }++.search-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 4px 0; font-size: .8rem; color: var(--text-muted); }++.search-snippet { margin: 4px 0 0; color: var(--text-secondary); overflow-wrap: anywhere; }++.search-snippet mark { background: rgba(var(--accent-rgb), .22); color: inherit; border-radius: 3px; padding: 0 2px; }@@@ -231,6 +231,23 @@ function createApp(opts = {}) {app.get('/updates', (req, res) => html(res, pages.updatesPage()));++ // Threads and pastes through OpenVibe.Search (server/search/query.js); the paste filter below works without it.++ const searchQuery = opts.searchQuery || require('./search/query').createSearchQuery({ baseUrl: config.searchInternalUrl });++ app.get('/search', withUser, wrap(async (req, res) => {++ const q = String(req.query.q || '').trim().slice(0, 200);++ const type = ['thread', 'paste'].includes(req.query.type) ? req.query.type : '';++ const { searchPage } = require('./render/search');++ if (!q) return html(res, searchPage({ q, type }));++ try {++ const out = await searchQuery.search({ q, type, cursor: String(req.query.cursor || '') });++ html(res, searchPage({ q, type, results: out.results, nextCursor: out.next_cursor }));++ } catch (err) {++ if (!err || !err.unavailable) throw err;++ console.warn('[Search] /search:', err.message);++ html(res, searchPage({ q, type, unavailable: true }), 503);++ }++ }));++app.get('/pastes', withUser, wrap(async (req, res) => {const [result, languages] = await Promise.all([catalog.browse(req.query), catalog.languages()]);html(res, pages.browsePage(result, languages));@@@ -41,6 +41,8 @@ module.exports = {// accounts these pastes belong to, so it is the only service that can turn a signed-in// visitor (a Network JWT) into the user id Media files the write under.liveInternalUrl: (process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''),++ // OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.++ searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),// OpenVibe.Media — public host that serves raw paste text and screenshots.mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),@@@ -48,6 +48,7 @@ function navbarInit(opts) {{ label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },{ label: 'Spaces', href: '/s', active: opts.active === 'spaces' },{ label: 'Pulse', href: '/pulse', active: opts.active === 'pulse' },++ { label: 'Search', href: '/search', icon: 'fa-magnifying-glass', active: opts.active === 'search' },{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },],menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++/**++ * /search: Community's threads and pastes, found by OpenVibe.Search (server/search/query.js). Useful++ * without JavaScript; never indexed. When Search does not answer, the page says so and offers the++ * paste filter, which works without it.++ */++const { renderPage } = require('./layout');++const { escapeHtml: esc } = require('./highlight');++++// Search escapes the text and adds only <mark>; keep exactly that, whatever arrives.++const snippet = (html) => esc(String(html || '')).replace(/<(\/?)mark>/g, '<$1mark>');++const pathOf = (url) => { try { const u = new URL(url); return u.pathname + u.search; } catch { return '#'; } };++++function resultItem(r) {++ const f = r.facets || {};++ const kind = r.type === 'thread'++ ? `<span class="badge"><i class="fa-solid fa-comments" aria-hidden="true"></i> Thread${f.space ? ` in ${esc(f.space)}` : ''}</span>`++ : `<span class="badge"><i class="fa-solid fa-${f.kind === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${f.kind === 'screenshot' ? 'Screenshot' : 'Paste'}${f.syntax && f.syntax !== 'text' ? ` · ${esc(f.syntax)}` : ''}</span>`;++ const when = r.updated_at || r.published_at;++ return `<li class="search-hit">++ <a class="search-title" href="${esc(pathOf(r.canonical_url))}">${esc(r.title || 'Untitled')}</a>++ <div class="search-meta">${kind}${r.authorship === 'ai_generated' ? ' <span class="badge">AI</span>' : ''}${when ? ` <time datetime="${esc(when)}">${esc(String(when).slice(0, 10))}</time>` : ''}</div>++ <p class="search-snippet">${r.snippet_html ? snippet(r.snippet_html) : esc(r.summary || '')}</p>++</li>`;++}++++function searchPage({ q = '', type = '', results = [], nextCursor = null, unavailable = false }) {++ const opt = (v, label) => `<option value="${v}"${type === v ? ' selected' : ''}>${label}</option>`;++ const more = nextCursor ? `<nav class="pager"><a class="btn" href="/search?${new URLSearchParams({ q, ...(type ? { type } : {}), cursor: nextCursor })}">More results</a></nav>` : '';++ let section;++ if (unavailable) section = `<p class="empty">Search is not answering right now. <a href="/pastes?q=${encodeURIComponent(q)}">Filter pastes for “${esc(q)}”</a> instead, or try again in a minute.</p>`;++ else if (!q) section = '<p class="empty">Search every public thread and paste on OpenVibe.Community.</p>';++ else if (!results.length) section = `<p class="empty">Nothing public matches “${esc(q)}”.</p>`;++ else section = `<ol class="search-results">${results.map(resultItem).join('\n')}</ol>${more}`;++ const body = `++<header class="page-head">++ <h1>Search</h1>++ <p class="muted">Threads and pastes${q ? ` matching “${esc(q)}”` : ''}. <a href="https://search.openvibe.network/?q=${encodeURIComponent(q)}">Search the whole OpenVibe network</a></p>++</header>++<form class="filters" method="get" action="/search" role="search">++ <label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search threads and pastes" aria-label="Search threads and pastes" maxlength="200" autofocus></label>++ <label>Show <select name="type">${opt('', 'Everything')}${opt('thread', 'Threads')}${opt('paste', 'Pastes')}</select></label>++ <button class="btn" type="submit">Search</button>++</form>++<section id="results">${section}</section>`;++ return renderPage({++ title: q ? `Search: ${q}` : 'Search',++ description: 'Search every public thread and paste on OpenVibe.Community.',++ canonicalPath: '/search',++ robots: 'noindex,follow',++ active: 'search',++ body,++ });++}++++module.exports = { searchPage, resultItem };nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++/**++ * OpenVibe.Search's query API for Community's /search page (roadmap WS-O task 10: product search boxes++ * use the query API). Asked anonymously at Search's internal origin, so it answers with public,++ * published, indexable documents only: exactly what Community sent as public (./documents.js).++ *++ * search({ q, type, cursor }) → { results, next_cursor } throws SearchUnavailable when Search does not answer++ */++const TYPES = new Set(['thread', 'paste']);++++class SearchUnavailable extends Error {++ constructor(message) { super(message); this.unavailable = true; }++}++++function createSearchQuery({ baseUrl, fetchImpl = globalThis.fetch, timeoutMs = 4000 }) {++ const base = String(baseUrl).replace(/\/+$/, '');++ async function search({ q, type = '', cursor = '', limit = 20 }) {++ const params = new URLSearchParams({ q: String(q).slice(0, 200), owner: 'community', limit: String(limit) });++ if (TYPES.has(type)) params.set('type', type);++ if (cursor && /^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.set('cursor', cursor);++ let res;++ try {++ res = await fetchImpl(`${base}/api/v1/search?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });++ } catch (err) {++ throw new SearchUnavailable(`Search did not answer: ${err.message}`);++ }++ const body = await res.json().catch(() => null);++ if (res.status === 400 && body) return { results: [], next_cursor: null, bad_query: true };++ if (!res.ok || !body || !Array.isArray(body.results)) throw new SearchUnavailable(`Search answered ${res.status}`);++ return { results: body.results, next_cursor: body.next_cursor || null };++ }++ return { search };++}++++module.exports = { createSearchQuery, SearchUnavailable, TYPES };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * /search (WS-O task 10: product search boxes use the query API): results come from OpenVibe.Search,++ * asked for Community's documents only; a snippet keeps Search's <mark> and nothing else; no query++ * asks nothing; Search not answering is a 503 page that offers the paste filter; the page is never++ * indexed; the navbar links to it. The client asks Search anonymously with owner=community.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/app');++const { createSearchQuery, SearchUnavailable } = require('../server/search/query');++++(async () => {++ const asked = [];++ let down = false;++ const searchQuery = {++ async search(args) {++ asked.push(args);++ if (down) throw new SearchUnavailable('Search did not answer: connect ECONNREFUSED');++ return {++ results: [++ { owner: 'community', type: 'thread', id: '18', title: 'Forum <b>boards</b>', canonical_url: 'https://openvibe.community/s/roadmap/t/forum-boards', facets: { space: 'roadmap' }, authorship: 'human', updated_at: '2026-09-25T18:37:20.000Z', snippet_html: 'classic <mark>forum</mark> boards <img src=x onerror=alert(1)>' },++ { owner: 'community', type: 'paste', id: 'amber-fox-42', title: 'Quick sort', canonical_url: 'https://openvibe.community/p/amber-fox-42', facets: { kind: 'paste', syntax: 'javascript' }, authorship: 'ai_generated', summary: 'a sort' },++ ],++ next_cursor: 'abc_123',++ };++ },++ };++ const t = await boot({ appOpts: { searchQuery } });++++ await check('results: titles link to the page, the snippet keeps only <mark>, More carries the cursor', async () => {++ const r = await t.get('/search?q=forum&type=thread');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(asked.at(-1), { q: 'forum', type: 'thread', cursor: '' });++ assert.ok(r.text.includes('href="/s/roadmap/t/forum-boards"'));++ assert.ok(r.text.includes('Forum <b>boards</b>'), 'the title is escaped');++ assert.ok(r.text.includes('classic <mark>forum</mark> boards <img'), 'only <mark> survives');++ assert.ok(!r.text.includes('<img src=x'));++ assert.ok(r.text.includes('Thread in roadmap') && r.text.includes('Paste · javascript') && r.text.includes('>AI</span>'));++ assert.ok(r.text.includes('cursor=abc_123'));++ assert.ok(r.text.includes('noindex,follow'));++ });++++ await check('no query asks Search nothing; the navbar links to /search', async () => {++ const n = asked.length;++ const r = await t.get('/search');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(asked.length, n);++ assert.ok(r.text.includes('"href":"/search"'));++ });++++ await check('Search not answering: 503, said plainly, with the paste filter', async () => {++ down = true;++ const r = await t.get('/search?q=forum');++ assert.strictEqual(r.status, 503);++ assert.ok(r.text.includes('Search is not answering right now'));++ assert.ok(r.text.includes('href="/pastes?q=forum"'));++ });++++ await check('the client asks anonymously for owner=community, drops a malformed cursor, and reports failures', async () => {++ const seen = [];++ const srv = http.createServer((req, res) => {++ seen.push({ url: req.url, auth: req.headers.authorization });++ res.setHeader('content-type', 'application/json');++ if (req.url.includes('q=bad')) { res.statusCode = 400; return res.end('{"code":"search.bad_query"}'); }++ if (req.url.includes('q=boom')) { res.statusCode = 500; return res.end('{}'); }++ res.end(JSON.stringify({ results: [{ id: '1' }], next_cursor: null }));++ });++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const c = createSearchQuery({ baseUrl: `http://127.0.0.1:${srv.address().port}/` });++ const out = await c.search({ q: 'forum', type: 'paste', cursor: 'x y' });++ assert.deepStrictEqual(out, { results: [{ id: '1' }], next_cursor: null });++ const u = new URL(seen[0].url, 'http://x');++ assert.strictEqual(u.pathname, '/api/v1/search');++ assert.strictEqual(u.searchParams.get('owner'), 'community');++ assert.strictEqual(u.searchParams.get('type'), 'paste');++ assert.strictEqual(u.searchParams.get('cursor'), null);++ assert.strictEqual(seen[0].auth, undefined, 'anonymous: public documents only');++ assert.deepStrictEqual(await c.search({ q: 'bad' }), { results: [], next_cursor: null, bad_query: true });++ await assert.rejects(c.search({ q: 'boom' }), SearchUnavailable);++ srv.close();++ await assert.rejects(createSearchQuery({ baseUrl: 'http://127.0.0.1:9' }).search({ q: 'x' }), SearchUnavailable);++ });++++ await done(t);++})();@@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/app');// Navbar/footer init payload matches the shared-chrome contract.const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);assert.strictEqual(cfg.navbar.service, 'community');-- assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/pastes', '/s', '/pulse', '/new']);++ assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/pastes', '/s', '/pulse', '/search', '/new']);assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');assert.strictEqual(cfg.navbar.history.type, 'page');assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');
@@ -318,3 +318,11 @@ button.rx:hover { border-color: var(--accent); }
@@ -318,3 +318,11 @@ button.rx:hover { border-color: var(--accent); }
.paste-embed img { display: block; max-width: 100%; max-height: 360px; margin: 0 auto; }
.paste-embed img { display: block; max-width: 100%; max-height: 360px; margin: 0 auto; }
.crosspost { margin: 8px 0; }
.crosspost { margin: 8px 0; }
.crosspost-note { margin: 4px 0; }
.crosspost-note { margin: 4px 0; }
+
+/* /search */
+.search-results { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
+.search-hit { padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); min-width: 0; }
+.search-title { font-weight: 600; font-size: 1.05rem; overflow-wrap: anywhere; }
+.search-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 4px 0; font-size: .8rem; color: var(--text-muted); }
+.search-snippet { margin: 4px 0 0; color: var(--text-secondary); overflow-wrap: anywhere; }
+.search-snippet mark { background: rgba(var(--accent-rgb), .22); color: inherit; border-radius: 3px; padding: 0 2px; }
@@ -231,6 +231,23 @@ function createApp(opts = {}) {
@@ -231,6 +231,23 @@ function createApp(opts = {}) {
app.get('/updates', (req, res) => html(res, pages.updatesPage()));
app.get('/updates', (req, res) => html(res, pages.updatesPage()));
+ // Threads and pastes through OpenVibe.Search (server/search/query.js); the paste filter below works without it.
+ const searchQuery = opts.searchQuery || require('./search/query').createSearchQuery({ baseUrl: config.searchInternalUrl });
+ app.get('/search', withUser, wrap(async (req, res) => {
+ const q = String(req.query.q || '').trim().slice(0, 200);
+ const type = ['thread', 'paste'].includes(req.query.type) ? req.query.type : '';
+ const { searchPage } = require('./render/search');
+ if (!q) return html(res, searchPage({ q, type }));
+ try {
+ const out = await searchQuery.search({ q, type, cursor: String(req.query.cursor || '') });
+ html(res, searchPage({ q, type, results: out.results, nextCursor: out.next_cursor }));
+ } catch (err) {
+ if (!err || !err.unavailable) throw err;
+ console.warn('[Search] /search:', err.message);
+ html(res, searchPage({ q, type, unavailable: true }), 503);
+ }
+ }));
+
app.get('/pastes', withUser, wrap(async (req, res) => {
app.get('/pastes', withUser, wrap(async (req, res) => {
const [result, languages] = await Promise.all([catalog.browse(req.query), catalog.languages()]);
const [result, languages] = await Promise.all([catalog.browse(req.query), catalog.languages()]);
html(res, pages.browsePage(result, languages));
html(res, pages.browsePage(result, languages));
@@ -41,6 +41,8 @@ module.exports = {
@@ -41,6 +41,8 @@ module.exports = {
// accounts these pastes belong to, so it is the only service that can turn a signed-in
// accounts these pastes belong to, so it is the only service that can turn a signed-in
// visitor (a Network JWT) into the user id Media files the write under.
// visitor (a Network JWT) into the user id Media files the write under.
liveInternalUrl: (process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''),
liveInternalUrl: (process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''),
+ // OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.
+ searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
// OpenVibe.Media — public host that serves raw paste text and screenshots.
// OpenVibe.Media — public host that serves raw paste text and screenshots.
mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
@@ -48,6 +48,7 @@ function navbarInit(opts) {
@@ -48,6 +48,7 @@ function navbarInit(opts) {
{ label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },
{ label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },
{ label: 'Spaces', href: '/s', active: opts.active === 'spaces' },
{ label: 'Spaces', href: '/s', active: opts.active === 'spaces' },
{ label: 'Pulse', href: '/pulse', active: opts.active === 'pulse' },
{ label: 'Pulse', href: '/pulse', active: opts.active === 'pulse' },
+ { label: 'Search', href: '/search', icon: 'fa-magnifying-glass', active: opts.active === 'search' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
],
],
menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },
menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+/**
+ * /search: Community's threads and pastes, found by OpenVibe.Search (server/search/query.js). Useful
+ * without JavaScript; never indexed. When Search does not answer, the page says so and offers the
+ * paste filter, which works without it.
+ */
+const { renderPage } = require('./layout');
+const { escapeHtml: esc } = require('./highlight');
+
+// Search escapes the text and adds only <mark>; keep exactly that, whatever arrives.
+const snippet = (html) => esc(String(html || '')).replace(/<(\/?)mark>/g, '<$1mark>');
+const pathOf = (url) => { try { const u = new URL(url); return u.pathname + u.search; } catch { return '#'; } };
+
+function resultItem(r) {
+ const f = r.facets || {};
+ const kind = r.type === 'thread'
+ ? `<span class="badge"><i class="fa-solid fa-comments" aria-hidden="true"></i> Thread${f.space ? ` in ${esc(f.space)}` : ''}</span>`
+ : `<span class="badge"><i class="fa-solid fa-${f.kind === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${f.kind === 'screenshot' ? 'Screenshot' : 'Paste'}${f.syntax && f.syntax !== 'text' ? ` · ${esc(f.syntax)}` : ''}</span>`;
+ const when = r.updated_at || r.published_at;
+ return `<li class="search-hit">
+ <a class="search-title" href="${esc(pathOf(r.canonical_url))}">${esc(r.title || 'Untitled')}</a>
+ <div class="search-meta">${kind}${r.authorship === 'ai_generated' ? ' <span class="badge">AI</span>' : ''}${when ? ` <time datetime="${esc(when)}">${esc(String(when).slice(0, 10))}</time>` : ''}</div>
+ <p class="search-snippet">${r.snippet_html ? snippet(r.snippet_html) : esc(r.summary || '')}</p>
+</li>`;
+}
+
+function searchPage({ q = '', type = '', results = [], nextCursor = null, unavailable = false }) {
+ const opt = (v, label) => `<option value="${v}"${type === v ? ' selected' : ''}>${label}</option>`;
+ const more = nextCursor ? `<nav class="pager"><a class="btn" href="/search?${new URLSearchParams({ q, ...(type ? { type } : {}), cursor: nextCursor })}">More results</a></nav>` : '';
+ let section;
+ if (unavailable) section = `<p class="empty">Search is not answering right now. <a href="/pastes?q=${encodeURIComponent(q)}">Filter pastes for “${esc(q)}”</a> instead, or try again in a minute.</p>`;
+ else if (!q) section = '<p class="empty">Search every public thread and paste on OpenVibe.Community.</p>';
+ else if (!results.length) section = `<p class="empty">Nothing public matches “${esc(q)}”.</p>`;
+ else section = `<ol class="search-results">${results.map(resultItem).join('\n')}</ol>${more}`;
+ const body = `
+<header class="page-head">
+ <h1>Search</h1>
+ <p class="muted">Threads and pastes${q ? ` matching “${esc(q)}”` : ''}. <a href="https://search.openvibe.network/?q=${encodeURIComponent(q)}">Search the whole OpenVibe network</a></p>
+</header>
+<form class="filters" method="get" action="/search" role="search">
+ <label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search threads and pastes" aria-label="Search threads and pastes" maxlength="200" autofocus></label>
+ <label>Show <select name="type">${opt('', 'Everything')}${opt('thread', 'Threads')}${opt('paste', 'Pastes')}</select></label>
+ <button class="btn" type="submit">Search</button>
+</form>
+<section id="results">${section}</section>`;
+ return renderPage({
+ title: q ? `Search: ${q}` : 'Search',
+ description: 'Search every public thread and paste on OpenVibe.Community.',
+ canonicalPath: '/search',
+ robots: 'noindex,follow',
+ active: 'search',
+ body,
+ });
+}
+
+module.exports = { searchPage, resultItem };
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+/**
+ * OpenVibe.Search's query API for Community's /search page (roadmap WS-O task 10: product search boxes
+ * use the query API). Asked anonymously at Search's internal origin, so it answers with public,
+ * published, indexable documents only: exactly what Community sent as public (./documents.js).
+ *
+ * search({ q, type, cursor }) → { results, next_cursor } throws SearchUnavailable when Search does not answer
+ */
+const TYPES = new Set(['thread', 'paste']);
+
+class SearchUnavailable extends Error {
+ constructor(message) { super(message); this.unavailable = true; }
+}
+
+function createSearchQuery({ baseUrl, fetchImpl = globalThis.fetch, timeoutMs = 4000 }) {
+ const base = String(baseUrl).replace(/\/+$/, '');
+ async function search({ q, type = '', cursor = '', limit = 20 }) {
+ const params = new URLSearchParams({ q: String(q).slice(0, 200), owner: 'community', limit: String(limit) });
+ if (TYPES.has(type)) params.set('type', type);
+ if (cursor && /^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.set('cursor', cursor);
+ let res;
+ try {
+ res = await fetchImpl(`${base}/api/v1/search?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
+ } catch (err) {
+ throw new SearchUnavailable(`Search did not answer: ${err.message}`);
+ }
+ const body = await res.json().catch(() => null);
+ if (res.status === 400 && body) return { results: [], next_cursor: null, bad_query: true };
+ if (!res.ok || !body || !Array.isArray(body.results)) throw new SearchUnavailable(`Search answered ${res.status}`);
+ return { results: body.results, next_cursor: body.next_cursor || null };
+ }
+ return { search };
+}
+
+module.exports = { createSearchQuery, SearchUnavailable, TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * /search (WS-O task 10: product search boxes use the query API): results come from OpenVibe.Search,
+ * asked for Community's documents only; a snippet keeps Search's <mark> and nothing else; no query
+ * asks nothing; Search not answering is a 503 page that offers the paste filter; the page is never
+ * indexed; the navbar links to it. The client asks Search anonymously with owner=community.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/app');
+const { createSearchQuery, SearchUnavailable } = require('../server/search/query');
+
+(async () => {
+ const asked = [];
+ let down = false;
+ const searchQuery = {
+ async search(args) {
+ asked.push(args);
+ if (down) throw new SearchUnavailable('Search did not answer: connect ECONNREFUSED');
+ return {
+ results: [
+ { owner: 'community', type: 'thread', id: '18', title: 'Forum <b>boards</b>', canonical_url: 'https://openvibe.community/s/roadmap/t/forum-boards', facets: { space: 'roadmap' }, authorship: 'human', updated_at: '2026-09-25T18:37:20.000Z', snippet_html: 'classic <mark>forum</mark> boards <img src=x onerror=alert(1)>' },
+ { owner: 'community', type: 'paste', id: 'amber-fox-42', title: 'Quick sort', canonical_url: 'https://openvibe.community/p/amber-fox-42', facets: { kind: 'paste', syntax: 'javascript' }, authorship: 'ai_generated', summary: 'a sort' },
+ ],
+ next_cursor: 'abc_123',
+ };
+ },
+ };
+ const t = await boot({ appOpts: { searchQuery } });
+
+ await check('results: titles link to the page, the snippet keeps only <mark>, More carries the cursor', async () => {
+ const r = await t.get('/search?q=forum&type=thread');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(asked.at(-1), { q: 'forum', type: 'thread', cursor: '' });
+ assert.ok(r.text.includes('href="/s/roadmap/t/forum-boards"'));
+ assert.ok(r.text.includes('Forum <b>boards</b>'), 'the title is escaped');
+ assert.ok(r.text.includes('classic <mark>forum</mark> boards <img'), 'only <mark> survives');
+ assert.ok(!r.text.includes('<img src=x'));
+ assert.ok(r.text.includes('Thread in roadmap') && r.text.includes('Paste · javascript') && r.text.includes('>AI</span>'));
+ assert.ok(r.text.includes('cursor=abc_123'));
+ assert.ok(r.text.includes('noindex,follow'));
+ });
+
+ await check('no query asks Search nothing; the navbar links to /search', async () => {
+ const n = asked.length;
+ const r = await t.get('/search');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(asked.length, n);
+ assert.ok(r.text.includes('"href":"/search"'));
+ });
+
+ await check('Search not answering: 503, said plainly, with the paste filter', async () => {
+ down = true;
+ const r = await t.get('/search?q=forum');
+ assert.strictEqual(r.status, 503);
+ assert.ok(r.text.includes('Search is not answering right now'));
+ assert.ok(r.text.includes('href="/pastes?q=forum"'));
+ });
+
+ await check('the client asks anonymously for owner=community, drops a malformed cursor, and reports failures', async () => {
+ const seen = [];
+ const srv = http.createServer((req, res) => {
+ seen.push({ url: req.url, auth: req.headers.authorization });
+ res.setHeader('content-type', 'application/json');
+ if (req.url.includes('q=bad')) { res.statusCode = 400; return res.end('{"code":"search.bad_query"}'); }
+ if (req.url.includes('q=boom')) { res.statusCode = 500; return res.end('{}'); }
+ res.end(JSON.stringify({ results: [{ id: '1' }], next_cursor: null }));
+ });
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const c = createSearchQuery({ baseUrl: `http://127.0.0.1:${srv.address().port}/` });
+ const out = await c.search({ q: 'forum', type: 'paste', cursor: 'x y' });
+ assert.deepStrictEqual(out, { results: [{ id: '1' }], next_cursor: null });
+ const u = new URL(seen[0].url, 'http://x');
+ assert.strictEqual(u.pathname, '/api/v1/search');
+ assert.strictEqual(u.searchParams.get('owner'), 'community');
+ assert.strictEqual(u.searchParams.get('type'), 'paste');
+ assert.strictEqual(u.searchParams.get('cursor'), null);
+ assert.strictEqual(seen[0].auth, undefined, 'anonymous: public documents only');
+ assert.deepStrictEqual(await c.search({ q: 'bad' }), { results: [], next_cursor: null, bad_query: true });
+ await assert.rejects(c.search({ q: 'boom' }), SearchUnavailable);
+ srv.close();
+ await assert.rejects(createSearchQuery({ baseUrl: 'http://127.0.0.1:9' }).search({ q: 'x' }), SearchUnavailable);
+ });
+
+ await done(t);
+})();
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/app');
// Navbar/footer init payload matches the shared-chrome contract.
// Navbar/footer init payload matches the shared-chrome contract.
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
assert.strictEqual(cfg.navbar.service, 'community');
assert.strictEqual(cfg.navbar.service, 'community');
- assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/pastes', '/s', '/pulse', '/new']);
+ assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/pastes', '/s', '/pulse', '/search', '/new']);
assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.history.type, 'page');
assert.strictEqual(cfg.navbar.history.type, 'page');
assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');
assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');