/** * GET /search?q=... -> full results page * GET /search/suggest?q=... -> compact JSON for live results in the overlay * * Searches the current site across: * 1. Posts via posts_fts (FTS5, prefix-matching) — published only. * 2. Tracks (audio_tracks) on title / artist / album. * 3. Events (shows) on city / venue / country / notes — when the agenda is enabled. * 4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only * the available ones. * 5. Een ADRES van een andere server (shaer-utpi): plak je een link naar een * fediverse-post of -profiel, dan is dat geen zoekterm maar een aanwijzing. * Die halen we op en tonen we, met dezelfde knoppen als * /authorize_interaction: antwoorden, waarderen, boosten, stemmen, * volgen, melden. * * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors. */ import express from 'express'; import db from '../config/database.js'; import { renderPage } from '../middleware/render.js'; import { audioUrl } from '../services/AudioStreamService.js'; import { getSetting } from '../services/SettingsService.js'; import { premiumUnlocked } from '../services/PatreonService.js'; import { t as i18nT, resolveLang } from '../services/i18n.js'; import ActivityPubService from '../services/ActivityPubService.js'; import PermissionsService from '../services/PermissionsService.js'; const router = express.Router(); function buildFtsQuery(q) { const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean); if (!terms.length) return null; return terms.map((t) => '"' + t + '"*').join(' '); } function likeArg(q) { return '%' + q.replace(/[%_\\]/g, '\\$&') + '%'; } function cleanSnippet(html, excerpt) { const esc = (s) => s.replace(/&/g, '&').replace(//g, '>'); const s = (html || '') .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\[\[[^\]]*?\]\]/g, ' ') .replace(/\[\[|\]\]/g, ' ') .replace(/[#>*_`~]+/g, ' ') .replace(/\s+/g, ' ') .trim(); if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160)); return s; } /** * Is dit een ADRES en geen zoekterm? Dan het genormaliseerde adres, anders null. * * HET SCHEMA IS VERPLICHT, en dat is een keuze. "soundfabrics.nl" is een * geldige zoekterm -- iemand zoekt naar de naam -- en een bare domeinnaam als * adres opvatten maakt van elke zoekopdracht met een punt erin een * netwerkverzoek. Met https:// ervoor is er geen twijfel over de bedoeling. * * De fragmentverwijzing gaat eraf: die hoort bij de browser, niet bij het * object, en met #comment eraan is het een andere sleutel voor hetzelfde ding. */ export function lookupUri(q) { const s = String(q || '').trim(); if (!/^https?:\/\//i.test(s) || /\s/.test(s)) return null; try { const u = new URL(s); if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; // Een naam zonder punt is geen adres op het open net (localhost, een // intern hostname). De SSRF-bewaking in de transportlaag houdt de rest // tegen; dit scheelt het verzoek. if (!u.hostname.includes('.')) return null; u.hash = ''; return u.toString(); } catch { return null; } } /** * Mag deze bezoeker een adres laten OPHALEN? * * Dezelfde grens als /authorize_interaction (requireSiteManager), en met opzet: * ophalen is een uitgaand verzoek dat deze server namens iemand anders doet. * Voor een willekeurige bezoeker zou de zoekbalk daarmee een haalservice zijn * waarmee je andermans server kunt laten aankloppen op adressen naar keuze. * * Dit spiegelt requireSiteManager in plaats van hem aan te roepen: die stuurt * je naar het inlogscherm, en een zoekopdracht hoort niemand weg te sturen. */ function mayLookUp(req, res) { const u = req.session && req.session.user; if (!u) return false; if (u.role === 'god' || u.role === 'kijker') return true; const site = res.locals.site; return !!(site && PermissionsService.canAdminSite(u, site)); } // ── Core: search all sources for one site. `lim` caps results per group // (small for live suggestions, large for the full page). ────────────────── function searchSite(req, res, rawQ, lim) { const site = res.locals.site; const base = res.locals.siteUrlBase || ''; const urlFor = (slug) => `/${slug}`; // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat). const lang = resolveLang(req); const t = (k) => i18nT(lang, k); const out = { results: [], tracks: [], events: [], pages: [], queryError: null }; if (!site || !rawQ) return out; const like = likeArg(rawQ); // 1. Posts (FTS5) const ftsQuery = buildFtsQuery(rawQ); if (ftsQuery) { try { out.results = db.prepare(` SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username, snippet(posts_fts, 0, '', '', '…', 18) AS snippet, bm25(posts_fts) AS score FROM posts_fts JOIN posts p ON p.id = posts_fts.post_id JOIN users u ON u.id = p.author_id WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published' ORDER BY score ASC LIMIT ? `).all(ftsQuery, site.id, lim.posts); out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) })); } catch (err) { out.queryError = err.message; } } // 2. Tracks try { const trackRows = db.prepare(` SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.site_id = @site AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' ) ORDER BY t.play_count DESC, t.title ASC LIMIT @lim `).all({ site: site.id, like, lim: lim.tracks }); const playable = trackRows.filter((t) => t.filename); let posts = []; if (playable.length) { posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id); } const postUrlForTrack = (tr) => { let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]')); if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]')); if (!hit) { const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id); if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]'))); } return hit ? urlFor(hit.slug) : null; }; out.tracks = playable.map((tr) => ({ id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '', cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr), })); } catch (err) { if (!out.queryError) out.queryError = err.message; } // 3. Events (agenda) — only when the agenda is publicly enabled. if (premiumUnlocked() && getSetting('agenda_enabled') === '1') { try { out.events = db.prepare(` SELECT date, time, city, venue, country FROM shows WHERE site_id = @site AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\' OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' ) ORDER BY date ASC LIMIT @lim `).all({ site: site.id, like, lim: lim.events }).map((e) => ({ date: e.date, time: e.time || '', where: [e.venue, e.city, e.country].filter(Boolean).join(', '), url: base + '/shows', })); } catch (err) { if (!out.queryError) out.queryError = err.message; } } // 4. Pages — curated, available ones only; matched against the (translated) name. const ql = rawQ.toLowerCase(); const candidates = [ { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' }, { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() }, { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() }, { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() }, { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link }, ]; out.pages = candidates .filter((c) => c.on) .map((c) => ({ label: t(c.key), url: c.url })) .filter((c) => c.label.toLowerCase().includes(ql)) .slice(0, lim.pages); return out; } // ── Full results page ──────────────────────────────────────────────────────── router.get('/', async (req, res) => { const site = res.locals.site; if (!site) return res.status(404).send('No site'); const rawQ = (req.query.q || '').toString().trim(); if (!rawQ) { return renderPage(req, res, 'pages/search', { pageTitle: 'Zoeken', bodyClass: 'on-special', query: '', results: [], tracks: [], events: [], pages: [], total: 0, }); } // Een adres wordt OPGEHAALD, niet doorzocht (shaer-utpi). Eerst als post, // dan als profiel: een URL die geen van beide oplevert is gewoon een // zoekterm die toevallig op een link lijkt, en dan blijft de rest staan. const remoteUri = lookupUri(rawQ); const mayLookup = remoteUri ? mayLookUp(req, res) : false; let remote = null; let remoteKind = null; if (remoteUri && mayLookup) { // Ondertekend als deze site: een post die alleen voor volgers zichtbaar is // weigert een anonieme GET, en dan lijkt een bestaande post te ontbreken. try { remote = await ActivityPubService.resolveRemoteNote(remoteUri, { asSlug: site.slug }); } catch { /* onbereikbaar */ } if (remote) remoteKind = 'note'; if (!remote) { try { remote = await ActivityPubService.resolveRemoteActor(remoteUri); } catch { /* onbereikbaar */ } if (remote) remoteKind = 'actor'; } } const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 }); const total = r.results.length + r.tracks.length + r.events.length + r.pages.length; renderPage(req, res, 'pages/search', { pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ, results: r.results, tracks: r.tracks, events: r.events, pages: r.pages, total, queryError: r.queryError, remoteUri, remote, remoteKind, mayLookup, remoteReacted: (remoteKind === 'note') ? ActivityPubService.getReaction(site.slug, remote.object_uri || remoteUri) : { liked: false, boosted: false }, siteTitle: site.title || '', }); }); // ── Live suggestions (JSON) ────────────────────────────────────────────────── router.get('/suggest', (req, res) => { const site = res.locals.site; if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] }); const rawQ = (req.query.q || '').toString().trim().slice(0, 100); if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] }); const urlFor = (slug) => `/${slug}`; const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 }); res.json({ posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })), tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })), events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })), pages: r.pages, }); }); export default router;