source: Klonkt/src/routes/search.js@ ca260d1

main
Last change on this file since ca260d1 was fd8faf5, checked in by Robin <roboburr@…>, 8 hours ago

Zoeken is één vlak: de balk bovenaan, de resultaten eronder, en @naam@server

Robins ontwerp van 30-9: de aparte resultatenpagina is niet meer nodig als het
vak onder de zoekbalk blijft staan. De balk en het vak eronder zijn samen het
hele zoeken, en het vak krijgt alle ruimte die er onder de balk is.

EEN VORM, TWEE INGANGEN. partials/search-surface.ejs is de balk plus het vak;
de overlay op elke pagina gebruikt hem, en /search?q=... ook. De resultaten
zelf staan in partials/search-results.ejs, en het live vak haalt precies die
render op (/search/results). De pagina bevat dus teken voor teken wat het vak
toont; een toets bewaakt dat, want twee renders lopen gegarandeerd uit de pas.
/search blijft bestaan zodat een gedeelde link en Enter zonder JavaScript op
hetzelfde beeld uitkomen.

/search/suggest (JSON, dat de browser tot een eigen lijstje bouwde, anders dan
de pagina) en /search/remote (alleen het adres) zijn weg; niets anders
gebruikte ze. remote-preview.ejs ging mee.

HET VLAK. Vaste positie over het hele scherm, de balk bovenaan, het vak vult
de rest en scrolt zelf. Op z-index 1060: de vorige overlay lag op 200, onder de
tabbalk (1050), zodat op een telefoon het onderste stuk resultaten wegviel. Enter
blijft staan en zoekt meteen, want het vak ís de resultaten. Op de pagina beweegt
het adres mee (/search?q=...), zodat wat je ziet te delen blijft; sluiten is daar
teruggaan. Balk en resultaten staan op één lijn: een scrollbalk alleen rechts
schoof de kolom anders zo'n 9 pixels naar links.

WEBFINGER. @naam@server (met of zonder @ vooraan, zoals Mastodon) haalt het
profiel op, met de volgknop. Zonder eerst een post te proberen: een handle is
nooit een post. De server moet een echte domeinnaam zijn; "@robin" blijft een
naam om naar te zoeken. WebFinger zelf is openbaar en loopt via safeFetch;
het profiel erachter gaat ondertekend, en alleen namens een ingelogde
beheerder, dezelfde grens als een geplakte link.

Onderweg gevonden: search-results.ejs verwees kaal naar queryError, en een
aanroeper die hem vergat liet de hele render omvallen. Alles gaat nu via
locals. In de app gaf gather hem altijd mee, dus live ging het goed; het was
één vergeten veld van een kapotte zoekpagina.

Nagelopen in de browser op 375 en 1280 pixels: de balk bovenaan, het vak tot
de onderrand, tabbalk bedekt, geen horizontale overloop, tikdoelen 44, balk en
resultaten op één lijn. Het gedrag met de echte chrome.js: openen vers en met
focus, typen, Enter zonder weg te gaan, de wachtregel bij een link en een
handle, een traag antwoord dat niets overschrijft, een resultaat aantikken en
Escape sluiten, en het adres dat op de pagina meebeweegt.

Getoetst: pagina en vak teken voor teken gelijk, de handle-herkenning, een
handle ondertekend en zonder post-poging, en niet ingelogd geen enkel verzoek.
Tegenproef: pagina laten afwijken, handle niet herkennen, bij een handle toch
een post proberen en de inlogcontrole eruit laten elk hun toetsen omvallen.
Volledige suite 1292 groen.

shaer-utpi

Co-Authored-By: Claude Opus 5.5 <noreply@…>

  • Property mode set to 100644
File size: 14.5 KB
Line 
1/**
2 * GET /search?q=... -> full results page
3 * GET /search/suggest?q=... -> compact JSON for live results in the overlay
4 *
5 * Searches the current site across:
6 * 1. Posts via posts_fts (FTS5, prefix-matching) — published only.
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.
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.
16 *
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.
19 */
20
21import express from 'express';
22import db from '../config/database.js';
23import { renderPage, formatDate } from '../middleware/render.js';
24import { audioUrl } from '../services/AudioStreamService.js';
25import { getSetting } from '../services/SettingsService.js';
26import { premiumUnlocked } from '../services/PatreonService.js';
27import { t as i18nT, resolveLang } from '../services/i18n.js';
28import ActivityPubService from '../services/ActivityPubService.js';
29import ejs from 'ejs';
30import path from 'path';
31import { fileURLToPath } from 'url';
32import PermissionsService from '../services/PermissionsService.js';
33
34const router = express.Router();
35
36function 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
42function likeArg(q) {
43 return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
44}
45
46function cleanSnippet(html, excerpt) {
47 const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
48 const s = (html || '')
49 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
50 .replace(/\[\[[^\]]*?\]\]/g, ' ')
51 .replace(/\[\[|\]\]/g, ' ')
52 .replace(/[#>*_`~]+/g, ' ')
53 .replace(/\s+/g, ' ')
54 .trim();
55 if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
56 return s;
57}
58
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 */
70export 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
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 */
98export 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
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 */
114function 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
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 */
132async function lookUpRemote(remoteUri, site, { handle = false } = {}) {
133 let remote = null;
134 let remoteKind = null;
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 }
140 if (remote) remoteKind = 'note';
141 if (!remote) {
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 */ }
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
153// ── Core: search all sources for one site. `lim` caps results per group
154// (small for live suggestions, large for the full page). ──────────────────
155function searchSite(req, res, rawQ, lim) {
156 const site = res.locals.site;
157 const base = res.locals.siteUrlBase || '';
158 const urlFor = (slug) => `/${slug}`;
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);
162
163 const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
164 if (!site || !rawQ) return out;
165 const like = likeArg(rawQ);
166
167 // 1. Posts (FTS5)
168 const ftsQuery = buildFtsQuery(rawQ);
169 if (ftsQuery) {
170 try {
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
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
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; }
182 }
183
184 // 2. Tracks
185 try {
186 const trackRows = db.prepare(`
187 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
188 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
189 WHERE t.site_id = @site
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 });
193 const playable = trackRows.filter((t) => t.filename);
194 let posts = [];
195 if (playable.length) {
196 posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
197 }
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 + ']]'));
201 if (!hit) {
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 + ']]')));
204 }
205 return hit ? urlFor(hit.slug) : null;
206 };
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),
210 }));
211 } catch (err) { if (!out.queryError) out.queryError = err.message; }
212
213 // 3. Events (agenda) — only when the agenda is publicly enabled.
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; }
228 }
229
230 // 4. Pages — curated, available ones only; matched against the (translated) name.
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}
247
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.
256async function gather(req, res, rawQ) {
257 const site = res.locals.site;
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;
263 const mayLookup = remoteUri ? mayLookUp(req, res) : false;
264 const { remote, remoteKind, reacted: remoteReacted } = (remoteUri && mayLookup)
265 ? await lookUpRemote(remoteUri, site, { handle: !!handle })
266 : { remote: null, remoteKind: null, reacted: { liked: false, boosted: false } };
267 const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
268 return {
269 query: rawQ,
270 results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
271 total: r.results.length + r.tracks.length + r.events.length + r.pages.length,
272 queryError: r.queryError,
273 remoteUri, remote, remoteKind, mayLookup, remoteReacted,
274 siteTitle: site.title || '',
275 };
276}
277
278// ── De pagina: het zoekvlak, open, met de resultaten erin ────────────────────
279router.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,
287 });
288});
289
290// ── Het live vak onder de zoekbalk (HTML-fragment) ───────────────────────────
291//
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).
296//
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.
301const VIEWS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'views');
302
303router.get('/results', async (req, res) => {
304 const site = res.locals.site;
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
309 // in een Nederlandse zoekbalk.
310 const lang = resolveLang(req, { userLang: req.session?.user?.lang, defaultLang: getSetting('default_lang') });
311 const html = await ejs.renderFile(path.join(VIEWS_DIR, 'partials', 'search-results.ejs'), {
312 ...data,
313 t: (k, vars) => i18nT(lang, k, vars),
314 formatDate,
315 siteUrlBase: res.locals.siteUrlBase || '',
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
323export default router;
Note: See TracBrowser for help on using the repository browser.