| [7bc636b] | 1 | /**
|
|---|
| [834bcc3] | 2 | * GET /search?q=... -> full results page
|
|---|
| 3 | * GET /search/suggest?q=... -> compact JSON for live results in the overlay
|
|---|
| [7bc636b] | 4 | *
|
|---|
| [834bcc3] | 5 | * Searches the current site across:
|
|---|
| [097da22] | 6 | * 1. Posts via posts_fts (FTS5, prefix-matching) — published only.
|
|---|
| [834bcc3] | 7 | * 2. Tracks (audio_tracks) on title / artist / album.
|
|---|
| 8 | * 3. Events (shows) on city / venue / country / notes — when the agenda is enabled.
|
|---|
| 9 | * 4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only
|
|---|
| 10 | * the available ones.
|
|---|
| [3ceaef6] | 11 | * 5. Een ADRES van een andere server (shaer-utpi): plak je een link naar een
|
|---|
| 12 | * fediverse-post of -profiel, dan is dat geen zoekterm maar een aanwijzing.
|
|---|
| 13 | * Die halen we op en tonen we, met dezelfde knoppen als
|
|---|
| 14 | * /authorize_interaction: antwoorden, waarderen, boosten, stemmen,
|
|---|
| 15 | * volgen, melden.
|
|---|
| [7bc636b] | 16 | *
|
|---|
| [834bcc3] | 17 | * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped
|
|---|
| 18 | * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors.
|
|---|
| [7bc636b] | 19 | */
|
|---|
| 20 |
|
|---|
| 21 | import express from 'express';
|
|---|
| 22 | import db from '../config/database.js';
|
|---|
| 23 | import { renderPage } from '../middleware/render.js';
|
|---|
| [1720482] | 24 | import { audioUrl } from '../services/AudioStreamService.js';
|
|---|
| [097da22] | 25 | import { getSetting } from '../services/SettingsService.js';
|
|---|
| 26 | import { premiumUnlocked } from '../services/PatreonService.js';
|
|---|
| [fefb80c] | 27 | import { t as i18nT, resolveLang } from '../services/i18n.js';
|
|---|
| [3ceaef6] | 28 | import ActivityPubService from '../services/ActivityPubService.js';
|
|---|
| 29 | import PermissionsService from '../services/PermissionsService.js';
|
|---|
| [7bc636b] | 30 |
|
|---|
| 31 | const router = express.Router();
|
|---|
| 32 |
|
|---|
| [1720482] | 33 | function buildFtsQuery(q) {
|
|---|
| 34 | const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|---|
| 35 | if (!terms.length) return null;
|
|---|
| 36 | return terms.map((t) => '"' + t + '"*').join(' ');
|
|---|
| 37 | }
|
|---|
| 38 |
|
|---|
| 39 | function likeArg(q) {
|
|---|
| 40 | return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
|
|---|
| [7bc636b] | 41 | }
|
|---|
| 42 |
|
|---|
| [5f47671] | 43 | function cleanSnippet(html, excerpt) {
|
|---|
| 44 | const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|---|
| 45 | const s = (html || '')
|
|---|
| [097da22] | 46 | .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|---|
| 47 | .replace(/\[\[[^\]]*?\]\]/g, ' ')
|
|---|
| 48 | .replace(/\[\[|\]\]/g, ' ')
|
|---|
| 49 | .replace(/[#>*_`~]+/g, ' ')
|
|---|
| [5f47671] | 50 | .replace(/\s+/g, ' ')
|
|---|
| 51 | .trim();
|
|---|
| 52 | if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
|
|---|
| 53 | return s;
|
|---|
| 54 | }
|
|---|
| 55 |
|
|---|
| [3ceaef6] | 56 | /**
|
|---|
| 57 | * Is dit een ADRES en geen zoekterm? Dan het genormaliseerde adres, anders null.
|
|---|
| 58 | *
|
|---|
| 59 | * HET SCHEMA IS VERPLICHT, en dat is een keuze. "soundfabrics.nl" is een
|
|---|
| 60 | * geldige zoekterm -- iemand zoekt naar de naam -- en een bare domeinnaam als
|
|---|
| 61 | * adres opvatten maakt van elke zoekopdracht met een punt erin een
|
|---|
| 62 | * netwerkverzoek. Met https:// ervoor is er geen twijfel over de bedoeling.
|
|---|
| 63 | *
|
|---|
| 64 | * De fragmentverwijzing gaat eraf: die hoort bij de browser, niet bij het
|
|---|
| 65 | * object, en met #comment eraan is het een andere sleutel voor hetzelfde ding.
|
|---|
| 66 | */
|
|---|
| 67 | export function lookupUri(q) {
|
|---|
| 68 | const s = String(q || '').trim();
|
|---|
| 69 | if (!/^https?:\/\//i.test(s) || /\s/.test(s)) return null;
|
|---|
| 70 | try {
|
|---|
| 71 | const u = new URL(s);
|
|---|
| 72 | if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|---|
| 73 | // Een naam zonder punt is geen adres op het open net (localhost, een
|
|---|
| 74 | // intern hostname). De SSRF-bewaking in de transportlaag houdt de rest
|
|---|
| 75 | // tegen; dit scheelt het verzoek.
|
|---|
| 76 | if (!u.hostname.includes('.')) return null;
|
|---|
| 77 | u.hash = '';
|
|---|
| 78 | return u.toString();
|
|---|
| 79 | } catch {
|
|---|
| 80 | return null;
|
|---|
| 81 | }
|
|---|
| 82 | }
|
|---|
| 83 |
|
|---|
| 84 | /**
|
|---|
| 85 | * Mag deze bezoeker een adres laten OPHALEN?
|
|---|
| 86 | *
|
|---|
| 87 | * Dezelfde grens als /authorize_interaction (requireSiteManager), en met opzet:
|
|---|
| 88 | * ophalen is een uitgaand verzoek dat deze server namens iemand anders doet.
|
|---|
| 89 | * Voor een willekeurige bezoeker zou de zoekbalk daarmee een haalservice zijn
|
|---|
| 90 | * waarmee je andermans server kunt laten aankloppen op adressen naar keuze.
|
|---|
| 91 | *
|
|---|
| 92 | * Dit spiegelt requireSiteManager in plaats van hem aan te roepen: die stuurt
|
|---|
| 93 | * je naar het inlogscherm, en een zoekopdracht hoort niemand weg te sturen.
|
|---|
| 94 | */
|
|---|
| 95 | function mayLookUp(req, res) {
|
|---|
| 96 | const u = req.session && req.session.user;
|
|---|
| 97 | if (!u) return false;
|
|---|
| 98 | if (u.role === 'god' || u.role === 'kijker') return true;
|
|---|
| 99 | const site = res.locals.site;
|
|---|
| 100 | return !!(site && PermissionsService.canAdminSite(u, site));
|
|---|
| 101 | }
|
|---|
| 102 |
|
|---|
| [834bcc3] | 103 | // ── Core: search all sources for one site. `lim` caps results per group
|
|---|
| 104 | // (small for live suggestions, large for the full page). ──────────────────
|
|---|
| [097da22] | 105 | function searchSite(req, res, rawQ, lim) {
|
|---|
| [7bc636b] | 106 | const site = res.locals.site;
|
|---|
| [097da22] | 107 | const base = res.locals.siteUrlBase || '';
|
|---|
| [72ec6a4] | 108 | const urlFor = (slug) => `/${slug}`;
|
|---|
| [fefb80c] | 109 | // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat).
|
|---|
| 110 | const lang = resolveLang(req);
|
|---|
| 111 | const t = (k) => i18nT(lang, k);
|
|---|
| [7bc636b] | 112 |
|
|---|
| [097da22] | 113 | const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
|
|---|
| 114 | if (!site || !rawQ) return out;
|
|---|
| 115 | const like = likeArg(rawQ);
|
|---|
| [7bc636b] | 116 |
|
|---|
| [097da22] | 117 | // 1. Posts (FTS5)
|
|---|
| [1720482] | 118 | const ftsQuery = buildFtsQuery(rawQ);
|
|---|
| 119 | if (ftsQuery) {
|
|---|
| 120 | try {
|
|---|
| [097da22] | 121 | out.results = db.prepare(`
|
|---|
| 122 | SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
|
|---|
| 123 | snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
|
|---|
| [1720482] | 124 | FROM posts_fts
|
|---|
| 125 | JOIN posts p ON p.id = posts_fts.post_id
|
|---|
| 126 | JOIN users u ON u.id = p.author_id
|
|---|
| [097da22] | 127 | WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
|
|---|
| 128 | ORDER BY score ASC LIMIT ?
|
|---|
| 129 | `).all(ftsQuery, site.id, lim.posts);
|
|---|
| 130 | out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
|
|---|
| 131 | } catch (err) { out.queryError = err.message; }
|
|---|
| [1720482] | 132 | }
|
|---|
| 133 |
|
|---|
| [834bcc3] | 134 | // 2. Tracks
|
|---|
| [7bc636b] | 135 | try {
|
|---|
| [1720482] | 136 | const trackRows = db.prepare(`
|
|---|
| 137 | SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
|
|---|
| [097da22] | 138 | FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
|
|---|
| [1720482] | 139 | WHERE t.site_id = @site
|
|---|
| [097da22] | 140 | AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
|
|---|
| 141 | ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
|
|---|
| 142 | `).all({ site: site.id, like, lim: lim.tracks });
|
|---|
| [1720482] | 143 | const playable = trackRows.filter((t) => t.filename);
|
|---|
| 144 | let posts = [];
|
|---|
| 145 | if (playable.length) {
|
|---|
| [097da22] | 146 | posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
|
|---|
| [1720482] | 147 | }
|
|---|
| [097da22] | 148 | const postUrlForTrack = (tr) => {
|
|---|
| 149 | let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
|
|---|
| 150 | if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
|
|---|
| [1720482] | 151 | if (!hit) {
|
|---|
| [097da22] | 152 | const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
|
|---|
| 153 | if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
|
|---|
| [1720482] | 154 | }
|
|---|
| 155 | return hit ? urlFor(hit.slug) : null;
|
|---|
| 156 | };
|
|---|
| [097da22] | 157 | out.tracks = playable.map((tr) => ({
|
|---|
| 158 | id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
|
|---|
| 159 | cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
|
|---|
| [1720482] | 160 | }));
|
|---|
| [097da22] | 161 | } catch (err) { if (!out.queryError) out.queryError = err.message; }
|
|---|
| 162 |
|
|---|
| [834bcc3] | 163 | // 3. Events (agenda) — only when the agenda is publicly enabled.
|
|---|
| [097da22] | 164 | if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
|
|---|
| 165 | try {
|
|---|
| 166 | out.events = db.prepare(`
|
|---|
| 167 | SELECT date, time, city, venue, country FROM shows
|
|---|
| 168 | WHERE site_id = @site
|
|---|
| 169 | AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
|
|---|
| 170 | OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
|
|---|
| 171 | ORDER BY date ASC LIMIT @lim
|
|---|
| 172 | `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
|
|---|
| 173 | date: e.date, time: e.time || '',
|
|---|
| 174 | where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
|
|---|
| 175 | url: base + '/shows',
|
|---|
| 176 | }));
|
|---|
| 177 | } catch (err) { if (!out.queryError) out.queryError = err.message; }
|
|---|
| [7bc636b] | 178 | }
|
|---|
| 179 |
|
|---|
| [834bcc3] | 180 | // 4. Pages — curated, available ones only; matched against the (translated) name.
|
|---|
| [097da22] | 181 | const ql = rawQ.toLowerCase();
|
|---|
| 182 | const candidates = [
|
|---|
| 183 | { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
|
|---|
| 184 | { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
|
|---|
| 185 | { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
|
|---|
| 186 | { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
|
|---|
| 187 | { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
|
|---|
| 188 | ];
|
|---|
| 189 | out.pages = candidates
|
|---|
| 190 | .filter((c) => c.on)
|
|---|
| 191 | .map((c) => ({ label: t(c.key), url: c.url }))
|
|---|
| 192 | .filter((c) => c.label.toLowerCase().includes(ql))
|
|---|
| 193 | .slice(0, lim.pages);
|
|---|
| 194 |
|
|---|
| 195 | return out;
|
|---|
| 196 | }
|
|---|
| [1720482] | 197 |
|
|---|
| [834bcc3] | 198 | // ── Full results page ────────────────────────────────────────────────────────
|
|---|
| [3ceaef6] | 199 | router.get('/', async (req, res) => {
|
|---|
| [097da22] | 200 | const site = res.locals.site;
|
|---|
| 201 | if (!site) return res.status(404).send('No site');
|
|---|
| 202 | const rawQ = (req.query.q || '').toString().trim();
|
|---|
| 203 |
|
|---|
| 204 | if (!rawQ) {
|
|---|
| 205 | return renderPage(req, res, 'pages/search', {
|
|---|
| 206 | pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
|
|---|
| 207 | results: [], tracks: [], events: [], pages: [], total: 0,
|
|---|
| 208 | });
|
|---|
| 209 | }
|
|---|
| 210 |
|
|---|
| [3ceaef6] | 211 | // Een adres wordt OPGEHAALD, niet doorzocht (shaer-utpi). Eerst als post,
|
|---|
| 212 | // dan als profiel: een URL die geen van beide oplevert is gewoon een
|
|---|
| 213 | // zoekterm die toevallig op een link lijkt, en dan blijft de rest staan.
|
|---|
| 214 | const remoteUri = lookupUri(rawQ);
|
|---|
| 215 | const mayLookup = remoteUri ? mayLookUp(req, res) : false;
|
|---|
| 216 | let remote = null;
|
|---|
| 217 | let remoteKind = null;
|
|---|
| 218 | if (remoteUri && mayLookup) {
|
|---|
| 219 | // Ondertekend als deze site: een post die alleen voor volgers zichtbaar is
|
|---|
| 220 | // weigert een anonieme GET, en dan lijkt een bestaande post te ontbreken.
|
|---|
| 221 | try { remote = await ActivityPubService.resolveRemoteNote(remoteUri, { asSlug: site.slug }); } catch { /* onbereikbaar */ }
|
|---|
| 222 | if (remote) remoteKind = 'note';
|
|---|
| 223 | if (!remote) {
|
|---|
| 224 | try { remote = await ActivityPubService.resolveRemoteActor(remoteUri); } catch { /* onbereikbaar */ }
|
|---|
| 225 | if (remote) remoteKind = 'actor';
|
|---|
| 226 | }
|
|---|
| 227 | }
|
|---|
| 228 |
|
|---|
| [097da22] | 229 | const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
|
|---|
| 230 | const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
|
|---|
| [7bc636b] | 231 | renderPage(req, res, 'pages/search', {
|
|---|
| [097da22] | 232 | pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
|
|---|
| 233 | results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
|
|---|
| 234 | total, queryError: r.queryError,
|
|---|
| [3ceaef6] | 235 | remoteUri, remote, remoteKind, mayLookup,
|
|---|
| 236 | remoteReacted: (remoteKind === 'note') ? ActivityPubService.getReaction(site.slug, remote.object_uri || remoteUri) : { liked: false, boosted: false },
|
|---|
| 237 | siteTitle: site.title || '',
|
|---|
| [097da22] | 238 | });
|
|---|
| 239 | });
|
|---|
| 240 |
|
|---|
| [834bcc3] | 241 | // ── Live suggestions (JSON) ──────────────────────────────────────────────────
|
|---|
| [097da22] | 242 | router.get('/suggest', (req, res) => {
|
|---|
| 243 | const site = res.locals.site;
|
|---|
| 244 | if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
|
|---|
| 245 | const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
|
|---|
| 246 | if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
|
|---|
| 247 |
|
|---|
| [72ec6a4] | 248 | const urlFor = (slug) => `/${slug}`;
|
|---|
| [097da22] | 249 | const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
|
|---|
| 250 | res.json({
|
|---|
| 251 | posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
|
|---|
| 252 | tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
|
|---|
| 253 | events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
|
|---|
| 254 | pages: r.pages,
|
|---|
| [7bc636b] | 255 | });
|
|---|
| 256 | });
|
|---|
| 257 |
|
|---|
| 258 | export default router;
|
|---|