| [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';
|
|---|
| [fd8faf5] | 23 | import { renderPage, formatDate } 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';
|
|---|
| [9eb6e1b] | 29 | import ejs from 'ejs';
|
|---|
| 30 | import path from 'path';
|
|---|
| 31 | import { fileURLToPath } from 'url';
|
|---|
| [3ceaef6] | 32 | import PermissionsService from '../services/PermissionsService.js';
|
|---|
| [7bc636b] | 33 |
|
|---|
| 34 | const router = express.Router();
|
|---|
| 35 |
|
|---|
| [1720482] | 36 | function buildFtsQuery(q) {
|
|---|
| 37 | const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|---|
| 38 | if (!terms.length) return null;
|
|---|
| 39 | return terms.map((t) => '"' + t + '"*').join(' ');
|
|---|
| 40 | }
|
|---|
| 41 |
|
|---|
| 42 | function likeArg(q) {
|
|---|
| 43 | return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
|
|---|
| [7bc636b] | 44 | }
|
|---|
| 45 |
|
|---|
| [5f47671] | 46 | function cleanSnippet(html, excerpt) {
|
|---|
| 47 | const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|---|
| 48 | const s = (html || '')
|
|---|
| [097da22] | 49 | .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|---|
| 50 | .replace(/\[\[[^\]]*?\]\]/g, ' ')
|
|---|
| 51 | .replace(/\[\[|\]\]/g, ' ')
|
|---|
| 52 | .replace(/[#>*_`~]+/g, ' ')
|
|---|
| [5f47671] | 53 | .replace(/\s+/g, ' ')
|
|---|
| 54 | .trim();
|
|---|
| 55 | if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
|
|---|
| 56 | return s;
|
|---|
| 57 | }
|
|---|
| 58 |
|
|---|
| [3ceaef6] | 59 | /**
|
|---|
| 60 | * Is dit een ADRES en geen zoekterm? Dan het genormaliseerde adres, anders null.
|
|---|
| 61 | *
|
|---|
| 62 | * HET SCHEMA IS VERPLICHT, en dat is een keuze. "soundfabrics.nl" is een
|
|---|
| 63 | * geldige zoekterm -- iemand zoekt naar de naam -- en een bare domeinnaam als
|
|---|
| 64 | * adres opvatten maakt van elke zoekopdracht met een punt erin een
|
|---|
| 65 | * netwerkverzoek. Met https:// ervoor is er geen twijfel over de bedoeling.
|
|---|
| 66 | *
|
|---|
| 67 | * De fragmentverwijzing gaat eraf: die hoort bij de browser, niet bij het
|
|---|
| 68 | * object, en met #comment eraan is het een andere sleutel voor hetzelfde ding.
|
|---|
| 69 | */
|
|---|
| 70 | export function lookupUri(q) {
|
|---|
| 71 | const s = String(q || '').trim();
|
|---|
| 72 | if (!/^https?:\/\//i.test(s) || /\s/.test(s)) return null;
|
|---|
| 73 | try {
|
|---|
| 74 | const u = new URL(s);
|
|---|
| 75 | if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|---|
| 76 | // Een naam zonder punt is geen adres op het open net (localhost, een
|
|---|
| 77 | // intern hostname). De SSRF-bewaking in de transportlaag houdt de rest
|
|---|
| 78 | // tegen; dit scheelt het verzoek.
|
|---|
| 79 | if (!u.hostname.includes('.')) return null;
|
|---|
| 80 | u.hash = '';
|
|---|
| 81 | return u.toString();
|
|---|
| 82 | } catch {
|
|---|
| 83 | return null;
|
|---|
| 84 | }
|
|---|
| 85 | }
|
|---|
| 86 |
|
|---|
| [fd8faf5] | 87 | /**
|
|---|
| 88 | * Is dit een HANDLE (@naam@server) en geen zoekterm? Dan de genormaliseerde
|
|---|
| 89 | * handle, anders null.
|
|---|
| 90 | *
|
|---|
| 91 | * Met of zonder @ vooraan, zoals Mastodon het ook aanneemt. De server moet een
|
|---|
| 92 | * echte domeinnaam zijn (met een punt en een extensie): "@robin" is een naam om
|
|---|
| 93 | * naar te zoeken, geen adres. Een spatie maakt er een zoekopdracht van.
|
|---|
| 94 | *
|
|---|
| 95 | * WebFinger zelf is openbaar en gaat niet ondertekend; het profiel erachter
|
|---|
| 96 | * wel, via resolveRemoteActor, en alleen namens een ingelogde beheerder.
|
|---|
| 97 | */
|
|---|
| 98 | export function lookupHandle(q) {
|
|---|
| 99 | const m = /^@?([a-z0-9_.-]+)@([a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})$/i.exec(String(q || '').trim());
|
|---|
| 100 | return m ? `@${m[1]}@${m[2].toLowerCase()}` : null;
|
|---|
| 101 | }
|
|---|
| 102 |
|
|---|
| [3ceaef6] | 103 | /**
|
|---|
| 104 | * Mag deze bezoeker een adres laten OPHALEN?
|
|---|
| 105 | *
|
|---|
| 106 | * Dezelfde grens als /authorize_interaction (requireSiteManager), en met opzet:
|
|---|
| 107 | * ophalen is een uitgaand verzoek dat deze server namens iemand anders doet.
|
|---|
| 108 | * Voor een willekeurige bezoeker zou de zoekbalk daarmee een haalservice zijn
|
|---|
| 109 | * waarmee je andermans server kunt laten aankloppen op adressen naar keuze.
|
|---|
| 110 | *
|
|---|
| 111 | * Dit spiegelt requireSiteManager in plaats van hem aan te roepen: die stuurt
|
|---|
| 112 | * je naar het inlogscherm, en een zoekopdracht hoort niemand weg te sturen.
|
|---|
| 113 | */
|
|---|
| 114 | function mayLookUp(req, res) {
|
|---|
| 115 | const u = req.session && req.session.user;
|
|---|
| 116 | if (!u) return false;
|
|---|
| 117 | if (u.role === 'god' || u.role === 'kijker') return true;
|
|---|
| 118 | const site = res.locals.site;
|
|---|
| 119 | return !!(site && PermissionsService.canAdminSite(u, site));
|
|---|
| 120 | }
|
|---|
| 121 |
|
|---|
| [9eb6e1b] | 122 | /**
|
|---|
| 123 | * Haal het object achter een adres op: eerst als post, dan als profiel.
|
|---|
| 124 | *
|
|---|
| 125 | * Eén weg voor de zoekpagina en de live-preview. Staan ze los, dan verschilt er
|
|---|
| 126 | * vroeg of laat iets -- de ondertekening bijvoorbeeld, en dan toont de preview
|
|---|
| 127 | * een post als ontbrekend die de pagina wel vindt.
|
|---|
| 128 | *
|
|---|
| 129 | * ONDERTEKEND als de site: een post die alleen voor volgers zichtbaar is weigert
|
|---|
| 130 | * een anonieme GET, en dan lijkt een bestaande post te ontbreken.
|
|---|
| 131 | */
|
|---|
| [fd8faf5] | 132 | async function lookUpRemote(remoteUri, site, { handle = false } = {}) {
|
|---|
| [9eb6e1b] | 133 | let remote = null;
|
|---|
| 134 | let remoteKind = null;
|
|---|
| [fd8faf5] | 135 | // Een handle wijst altijd naar een persoon: geen post-poging, die zou alleen
|
|---|
| 136 | // een verzoek kosten dat niets kan opleveren.
|
|---|
| 137 | if (!handle) {
|
|---|
| 138 | try { remote = await ActivityPubService.resolveRemoteNote(remoteUri, { asSlug: site.slug }); } catch { /* onbereikbaar */ }
|
|---|
| 139 | }
|
|---|
| [9eb6e1b] | 140 | if (remote) remoteKind = 'note';
|
|---|
| 141 | if (!remote) {
|
|---|
| [86cfa1a] | 142 | // Ook ondertekend: een profiel op een instance met authorized fetch
|
|---|
| 143 | // (mastodon.social) geeft anders een 401 en leek dan niet te bestaan.
|
|---|
| 144 | try { remote = await ActivityPubService.resolveRemoteActor(remoteUri, { asSlug: site.slug }); } catch { /* onbereikbaar */ }
|
|---|
| [9eb6e1b] | 145 | if (remote) remoteKind = 'actor';
|
|---|
| 146 | }
|
|---|
| 147 | const reacted = remoteKind === 'note'
|
|---|
| 148 | ? ActivityPubService.getReaction(site.slug, remote.object_uri || remoteUri)
|
|---|
| 149 | : { liked: false, boosted: false };
|
|---|
| 150 | return { remote, remoteKind, reacted };
|
|---|
| 151 | }
|
|---|
| 152 |
|
|---|
| [834bcc3] | 153 | // ── Core: search all sources for one site. `lim` caps results per group
|
|---|
| 154 | // (small for live suggestions, large for the full page). ──────────────────
|
|---|
| [097da22] | 155 | function searchSite(req, res, rawQ, lim) {
|
|---|
| [7bc636b] | 156 | const site = res.locals.site;
|
|---|
| [097da22] | 157 | const base = res.locals.siteUrlBase || '';
|
|---|
| [72ec6a4] | 158 | const urlFor = (slug) => `/${slug}`;
|
|---|
| [fefb80c] | 159 | // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat).
|
|---|
| 160 | const lang = resolveLang(req);
|
|---|
| 161 | const t = (k) => i18nT(lang, k);
|
|---|
| [7bc636b] | 162 |
|
|---|
| [097da22] | 163 | const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
|
|---|
| 164 | if (!site || !rawQ) return out;
|
|---|
| 165 | const like = likeArg(rawQ);
|
|---|
| [7bc636b] | 166 |
|
|---|
| [097da22] | 167 | // 1. Posts (FTS5)
|
|---|
| [1720482] | 168 | const ftsQuery = buildFtsQuery(rawQ);
|
|---|
| 169 | if (ftsQuery) {
|
|---|
| 170 | try {
|
|---|
| [097da22] | 171 | out.results = db.prepare(`
|
|---|
| 172 | SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
|
|---|
| 173 | snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
|
|---|
| [1720482] | 174 | FROM posts_fts
|
|---|
| 175 | JOIN posts p ON p.id = posts_fts.post_id
|
|---|
| 176 | JOIN users u ON u.id = p.author_id
|
|---|
| [097da22] | 177 | WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
|
|---|
| 178 | ORDER BY score ASC LIMIT ?
|
|---|
| 179 | `).all(ftsQuery, site.id, lim.posts);
|
|---|
| 180 | out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
|
|---|
| 181 | } catch (err) { out.queryError = err.message; }
|
|---|
| [1720482] | 182 | }
|
|---|
| 183 |
|
|---|
| [834bcc3] | 184 | // 2. Tracks
|
|---|
| [7bc636b] | 185 | try {
|
|---|
| [1720482] | 186 | const trackRows = db.prepare(`
|
|---|
| 187 | SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
|
|---|
| [097da22] | 188 | FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
|
|---|
| [1720482] | 189 | WHERE t.site_id = @site
|
|---|
| [097da22] | 190 | AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
|
|---|
| 191 | ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
|
|---|
| 192 | `).all({ site: site.id, like, lim: lim.tracks });
|
|---|
| [1720482] | 193 | const playable = trackRows.filter((t) => t.filename);
|
|---|
| 194 | let posts = [];
|
|---|
| 195 | if (playable.length) {
|
|---|
| [097da22] | 196 | posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
|
|---|
| [1720482] | 197 | }
|
|---|
| [097da22] | 198 | const postUrlForTrack = (tr) => {
|
|---|
| 199 | let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
|
|---|
| 200 | if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
|
|---|
| [1720482] | 201 | if (!hit) {
|
|---|
| [097da22] | 202 | const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
|
|---|
| 203 | if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
|
|---|
| [1720482] | 204 | }
|
|---|
| 205 | return hit ? urlFor(hit.slug) : null;
|
|---|
| 206 | };
|
|---|
| [097da22] | 207 | out.tracks = playable.map((tr) => ({
|
|---|
| 208 | id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
|
|---|
| 209 | cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
|
|---|
| [1720482] | 210 | }));
|
|---|
| [097da22] | 211 | } catch (err) { if (!out.queryError) out.queryError = err.message; }
|
|---|
| 212 |
|
|---|
| [834bcc3] | 213 | // 3. Events (agenda) — only when the agenda is publicly enabled.
|
|---|
| [097da22] | 214 | if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
|
|---|
| 215 | try {
|
|---|
| 216 | out.events = db.prepare(`
|
|---|
| 217 | SELECT date, time, city, venue, country FROM shows
|
|---|
| 218 | WHERE site_id = @site
|
|---|
| 219 | AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
|
|---|
| 220 | OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
|
|---|
| 221 | ORDER BY date ASC LIMIT @lim
|
|---|
| 222 | `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
|
|---|
| 223 | date: e.date, time: e.time || '',
|
|---|
| 224 | where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
|
|---|
| 225 | url: base + '/shows',
|
|---|
| 226 | }));
|
|---|
| 227 | } catch (err) { if (!out.queryError) out.queryError = err.message; }
|
|---|
| [7bc636b] | 228 | }
|
|---|
| 229 |
|
|---|
| [834bcc3] | 230 | // 4. Pages — curated, available ones only; matched against the (translated) name.
|
|---|
| [097da22] | 231 | const ql = rawQ.toLowerCase();
|
|---|
| 232 | const candidates = [
|
|---|
| 233 | { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
|
|---|
| 234 | { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
|
|---|
| 235 | { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
|
|---|
| 236 | { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
|
|---|
| 237 | { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
|
|---|
| 238 | ];
|
|---|
| 239 | out.pages = candidates
|
|---|
| 240 | .filter((c) => c.on)
|
|---|
| 241 | .map((c) => ({ label: t(c.key), url: c.url }))
|
|---|
| 242 | .filter((c) => c.label.toLowerCase().includes(ql))
|
|---|
| 243 | .slice(0, lim.pages);
|
|---|
| 244 |
|
|---|
| 245 | return out;
|
|---|
| 246 | }
|
|---|
| [1720482] | 247 |
|
|---|
| [fd8faf5] | 248 | // ── Wat er bij een zoekopdracht hoort ────────────────────────────────────────
|
|---|
| 249 | //
|
|---|
| 250 | // EEN verzameling voor beide ingangen: de pagina /search?q=... en het live vak
|
|---|
| 251 | // onder de zoekbalk (/search/results). Ze tonen sinds Robins ontwerp van 30-9
|
|---|
| 252 | // hetzelfde, dus ze horen ook hetzelfde op te halen.
|
|---|
| 253 | //
|
|---|
| 254 | // Een adres wordt OPGEHAALD, niet doorzocht (shaer-utpi). Eerst als post, dan
|
|---|
| 255 | // als profiel; ondertekend, en alleen namens een ingelogde beheerder.
|
|---|
| 256 | async function gather(req, res, rawQ) {
|
|---|
| [097da22] | 257 | const site = res.locals.site;
|
|---|
| [fd8faf5] | 258 | // Een adres (https://...) of een handle (@naam@server): allebei iets dat van
|
|---|
| 259 | // een andere server moet komen, dus allebei achter dezelfde rechtengrens.
|
|---|
| 260 | const uri = lookupUri(rawQ);
|
|---|
| 261 | const handle = uri ? null : lookupHandle(rawQ);
|
|---|
| 262 | const remoteUri = uri || handle;
|
|---|
| [3ceaef6] | 263 | const mayLookup = remoteUri ? mayLookUp(req, res) : false;
|
|---|
| [9eb6e1b] | 264 | const { remote, remoteKind, reacted: remoteReacted } = (remoteUri && mayLookup)
|
|---|
| [fd8faf5] | 265 | ? await lookUpRemote(remoteUri, site, { handle: !!handle })
|
|---|
| [9eb6e1b] | 266 | : { remote: null, remoteKind: null, reacted: { liked: false, boosted: false } };
|
|---|
| [097da22] | 267 | const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
|
|---|
| [fd8faf5] | 268 | return {
|
|---|
| 269 | query: rawQ,
|
|---|
| [097da22] | 270 | results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
|
|---|
| [fd8faf5] | 271 | total: r.results.length + r.tracks.length + r.events.length + r.pages.length,
|
|---|
| 272 | queryError: r.queryError,
|
|---|
| 273 | remoteUri, remote, remoteKind, mayLookup, remoteReacted,
|
|---|
| [3ceaef6] | 274 | siteTitle: site.title || '',
|
|---|
| [fd8faf5] | 275 | };
|
|---|
| 276 | }
|
|---|
| 277 |
|
|---|
| 278 | // ── De pagina: het zoekvlak, open, met de resultaten erin ────────────────────
|
|---|
| 279 | router.get('/', async (req, res) => {
|
|---|
| 280 | const site = res.locals.site;
|
|---|
| 281 | if (!site) return res.status(404).send('No site');
|
|---|
| 282 | const rawQ = (req.query.q || '').toString().trim().slice(0, 2048);
|
|---|
| 283 | const data = rawQ ? await gather(req, res, rawQ) : { query: '' };
|
|---|
| 284 | renderPage(req, res, 'pages/search', {
|
|---|
| 285 | pageTitle: rawQ ? `Zoeken: ${rawQ}` : 'Zoeken', bodyClass: 'on-special',
|
|---|
| 286 | ...data,
|
|---|
| [097da22] | 287 | });
|
|---|
| 288 | });
|
|---|
| 289 |
|
|---|
| [fd8faf5] | 290 | // ── Het live vak onder de zoekbalk (HTML-fragment) ───────────────────────────
|
|---|
| [9eb6e1b] | 291 | //
|
|---|
| [fd8faf5] | 292 | // Dezelfde render als de pagina (partials/search-results.ejs), zodat het vak
|
|---|
| 293 | // en de pagina er per constructie hetzelfde uitzien. Dit verving twee routes:
|
|---|
| 294 | // /suggest (JSON, dat de browser zelf tot een lijstje bouwde, anders dan de
|
|---|
| 295 | // pagina) en /remote (alleen het adres).
|
|---|
| [9eb6e1b] | 296 | //
|
|---|
| [fd8faf5] | 297 | // De rechtengrens voor het ophalen zit in gather en is dezelfde als voor de
|
|---|
| 298 | // pagina; hier weegt hij zwaarder, want dit wordt bij elke toetsaanslag
|
|---|
| 299 | // geraakt. Een bezoeker die een link plakt krijgt de gewone resultaten en de
|
|---|
| 300 | // zin waarom er verder niets staat, en er gaat geen verzoek uit.
|
|---|
| [9eb6e1b] | 301 | const VIEWS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'views');
|
|---|
| 302 |
|
|---|
| [fd8faf5] | 303 | router.get('/results', async (req, res) => {
|
|---|
| [9eb6e1b] | 304 | const site = res.locals.site;
|
|---|
| [fd8faf5] | 305 | const rawQ = String(req.query.q || '').trim().slice(0, 2048);
|
|---|
| 306 | if (!site || rawQ.length < 2) return res.status(204).end();
|
|---|
| 307 | const data = await gather(req, res, rawQ);
|
|---|
| 308 | // Dezelfde taal als de pagina eromheen, anders staat er een Engelse lijst
|
|---|
| [9eb6e1b] | 309 | // in een Nederlandse zoekbalk.
|
|---|
| 310 | const lang = resolveLang(req, { userLang: req.session?.user?.lang, defaultLang: getSetting('default_lang') });
|
|---|
| [fd8faf5] | 311 | const html = await ejs.renderFile(path.join(VIEWS_DIR, 'partials', 'search-results.ejs'), {
|
|---|
| 312 | ...data,
|
|---|
| [9eb6e1b] | 313 | t: (k, vars) => i18nT(lang, k, vars),
|
|---|
| [fd8faf5] | 314 | formatDate,
|
|---|
| 315 | siteUrlBase: res.locals.siteUrlBase || '',
|
|---|
| [9eb6e1b] | 316 | }, { async: false });
|
|---|
| 317 | // Wat deze bezoeker ziet hangt van zijn rechten en zijn reacties af: niet
|
|---|
| 318 | // bewaren, niet delen.
|
|---|
| 319 | res.set('Cache-Control', 'private, no-store');
|
|---|
| 320 | res.type('html').send(html);
|
|---|
| 321 | });
|
|---|
| 322 |
|
|---|
| [7bc636b] | 323 | export default router;
|
|---|