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

OP OpenVibers committed 25 Sept 2026 · 49282fc253 · 8 files, +206 −1


Changes

ddiff --git a/public/css/community.css b/public/css/community.css
iindex 769310f..7383e64 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex 07ed708..322f249 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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));
ddiff --git a/server/config.js b/server/config.js
iindex 5fb4ff7..6393cf2 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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(/\/$/, ''),
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3712b82..cb1dac6 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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' }] },
ddiff --git a/server/render/search.js b/server/render/search.js
nnew file mode 100644
iindex 0000000..6a9b2af
---- /dev/null
++++ b/server/render/search.js
@@@ -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(/&lt;(\/?)mark&gt;/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 };
ddiff --git a/server/search/query.js b/server/search/query.js
nnew file mode 100644
iindex 0000000..28d1d67
---- /dev/null
++++ b/server/search/query.js
@@@ -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 };
ddiff --git a/test/search-page.test.js b/test/search-page.test.js
nnew file mode 100644
iindex 0000000..446b368
---- /dev/null
++++ b/test/search-page.test.js
@@@ -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 &lt;b&gt;boards&lt;/b&gt;'), 'the title is escaped');
++ assert.ok(r.text.includes('classic <mark>forum</mark> boards &lt;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);
++})();
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex 9864bbc..babb509 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -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}');