source: Klonkt/src/routes/search.js@ 9eb6e1b

main
Last change on this file since 9eb6e1b was 9eb6e1b, checked in by Robin <roboburr@…>, 6 hours ago

Een fediverse-post ziet eruit als een post, ook in de zoekbalk

Plak je een link in de zoekbalk, dan laadt de uitklaplijst nu de post zelf
in plaats van te zoeken naar de tekst van de link. Dezelfde kaart staat op de
zoekpagina en op /authorize_interaction, dus één render op drie plekken.

DE KAART. Wat eromheen stond is teruggebracht tot een kop met gezicht, de
inhoud, en één rij knoppen: Reageer, Like, Boost en het origineel. Weg zijn de
zin "je reageert op", de aparte knop naar het origineel, de twee grote pillen,
"of reageer:" en het antwoordvenster dat altijd openstond. Antwoorden klapt
open als je erom vraagt; melden staat onderaan en niet in de weg.

Foto's staan in een raster met vaste rijhoogtes. Met verhoudingen groeide het
mee met de breedte, en werd op een desktop de eerste van drie foto's zo hoog
dat de knoppen onder de vouw van de uitklaplijst verdwenen.

EEN INHOUDSWAARSCHUWING WORDT GERESPECTEERD. De oude render toonde alles open,
ook wat de schrijver achter een waarschuwing had gezet.

MOBIEL. Elke knop minstens 44 punten. Op een smal scherm vallen de woorden weg
en blijven de tekens staan, gelijk verdeeld; de woorden blijven bestaan voor
wie het scherm voorgelezen krijgt. Nagemeten op 375 pixels: geen horizontale
overloop, kleinste tikdoel 44, de knoppen in beeld zonder te scrollen.

DE PREVIEW IS COMPACT. Waarderen en boosten kan ter plekke; antwoorden en
melden gaan naar de volle pagina, met ?reply=1 of ?report=1 zodat dat deel
daar al openstaat. Een editor in een uitklaplijst is op een telefoon geen plek
om te schrijven. Zonder JS doet dezelfde link hetzelfde.

/search/remote levert het fragment, achter dezelfde grens als de volle
pagina: alleen wie de site beheert laat ophalen. Die grens weegt hier zwaarder,
want deze route wordt bij elke toetsaanslag geraakt; zonder hem is hij een
haalservice die iedereen deze server op adressen naar keuze laat afsturen.
Voor een bezoeker 204, en dan valt de zoekbalk terug op de gewone suggesties.
Elke toetsaanslag krijgt een volgnummer, zodat een traag antwoord op een oude
vraag niet overschrijft wat je inmiddels hebt getypt.

Waarderen en boosten ter plekke verhuisde van authorize-interaction.js naar
chrome.js: de kaart staat nu op elke pagina, via de zoekbalk. De zoekpagina
stuurde je bij een like eerder weg naar /authorize_interaction.

Onderweg: /authorize_interaction haalt nu ook ondertekend op, net als de
zoekbalk. Anders zegt die pagina "niet gevonden" over een post voor volgers die
de preview wel liet zien.

Getoetst: de preview met de post, ondertekend; wat er in de compacte kaart wel
en niet staat; dat een bezoeker via de preview niets laat ophalen; dat een
zoekterm niets ophaalt; de waarschuwing; het venster op verzoek. Tegenproef:
rechtencheck eruit, waarschuwing genegeerd, compact genegeerd en anoniem
ophalen laten elk hun eigen toets omvallen. Volledige suite 1283 groen.

Het gedrag in chrome.js is in de browser nagelopen met een nagebootste
server: herkennen, laden, invoegen, de volgorde bij een traag antwoord, de
terugval bij 204, waarderen en boosten heen en terug, en het antwoordvenster
open en dicht met de focus op de goede plek.

shaer-utpi

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

  • Property mode set to 100644
File size: 14.0 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 } 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 * Mag deze bezoeker een adres laten OPHALEN?
89 *
90 * Dezelfde grens als /authorize_interaction (requireSiteManager), en met opzet:
91 * ophalen is een uitgaand verzoek dat deze server namens iemand anders doet.
92 * Voor een willekeurige bezoeker zou de zoekbalk daarmee een haalservice zijn
93 * waarmee je andermans server kunt laten aankloppen op adressen naar keuze.
94 *
95 * Dit spiegelt requireSiteManager in plaats van hem aan te roepen: die stuurt
96 * je naar het inlogscherm, en een zoekopdracht hoort niemand weg te sturen.
97 */
98function mayLookUp(req, res) {
99 const u = req.session && req.session.user;
100 if (!u) return false;
101 if (u.role === 'god' || u.role === 'kijker') return true;
102 const site = res.locals.site;
103 return !!(site && PermissionsService.canAdminSite(u, site));
104}
105
106/**
107 * Haal het object achter een adres op: eerst als post, dan als profiel.
108 *
109 * Eén weg voor de zoekpagina en de live-preview. Staan ze los, dan verschilt er
110 * vroeg of laat iets -- de ondertekening bijvoorbeeld, en dan toont de preview
111 * een post als ontbrekend die de pagina wel vindt.
112 *
113 * ONDERTEKEND als de site: een post die alleen voor volgers zichtbaar is weigert
114 * een anonieme GET, en dan lijkt een bestaande post te ontbreken.
115 */
116async function lookUpRemote(remoteUri, site) {
117 let remote = null;
118 let remoteKind = null;
119 try { remote = await ActivityPubService.resolveRemoteNote(remoteUri, { asSlug: site.slug }); } catch { /* onbereikbaar */ }
120 if (remote) remoteKind = 'note';
121 if (!remote) {
122 try { remote = await ActivityPubService.resolveRemoteActor(remoteUri); } catch { /* onbereikbaar */ }
123 if (remote) remoteKind = 'actor';
124 }
125 const reacted = remoteKind === 'note'
126 ? ActivityPubService.getReaction(site.slug, remote.object_uri || remoteUri)
127 : { liked: false, boosted: false };
128 return { remote, remoteKind, reacted };
129}
130
131// ── Core: search all sources for one site. `lim` caps results per group
132// (small for live suggestions, large for the full page). ──────────────────
133function searchSite(req, res, rawQ, lim) {
134 const site = res.locals.site;
135 const base = res.locals.siteUrlBase || '';
136 const urlFor = (slug) => `/${slug}`;
137 // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat).
138 const lang = resolveLang(req);
139 const t = (k) => i18nT(lang, k);
140
141 const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
142 if (!site || !rawQ) return out;
143 const like = likeArg(rawQ);
144
145 // 1. Posts (FTS5)
146 const ftsQuery = buildFtsQuery(rawQ);
147 if (ftsQuery) {
148 try {
149 out.results = db.prepare(`
150 SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
151 snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
152 FROM posts_fts
153 JOIN posts p ON p.id = posts_fts.post_id
154 JOIN users u ON u.id = p.author_id
155 WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
156 ORDER BY score ASC LIMIT ?
157 `).all(ftsQuery, site.id, lim.posts);
158 out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
159 } catch (err) { out.queryError = err.message; }
160 }
161
162 // 2. Tracks
163 try {
164 const trackRows = db.prepare(`
165 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
166 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
167 WHERE t.site_id = @site
168 AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
169 ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
170 `).all({ site: site.id, like, lim: lim.tracks });
171 const playable = trackRows.filter((t) => t.filename);
172 let posts = [];
173 if (playable.length) {
174 posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
175 }
176 const postUrlForTrack = (tr) => {
177 let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
178 if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
179 if (!hit) {
180 const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
181 if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
182 }
183 return hit ? urlFor(hit.slug) : null;
184 };
185 out.tracks = playable.map((tr) => ({
186 id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
187 cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
188 }));
189 } catch (err) { if (!out.queryError) out.queryError = err.message; }
190
191 // 3. Events (agenda) — only when the agenda is publicly enabled.
192 if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
193 try {
194 out.events = db.prepare(`
195 SELECT date, time, city, venue, country FROM shows
196 WHERE site_id = @site
197 AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
198 OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
199 ORDER BY date ASC LIMIT @lim
200 `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
201 date: e.date, time: e.time || '',
202 where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
203 url: base + '/shows',
204 }));
205 } catch (err) { if (!out.queryError) out.queryError = err.message; }
206 }
207
208 // 4. Pages — curated, available ones only; matched against the (translated) name.
209 const ql = rawQ.toLowerCase();
210 const candidates = [
211 { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
212 { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
213 { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
214 { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
215 { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
216 ];
217 out.pages = candidates
218 .filter((c) => c.on)
219 .map((c) => ({ label: t(c.key), url: c.url }))
220 .filter((c) => c.label.toLowerCase().includes(ql))
221 .slice(0, lim.pages);
222
223 return out;
224}
225
226// ── Full results page ────────────────────────────────────────────────────────
227router.get('/', async (req, res) => {
228 const site = res.locals.site;
229 if (!site) return res.status(404).send('No site');
230 const rawQ = (req.query.q || '').toString().trim();
231
232 if (!rawQ) {
233 return renderPage(req, res, 'pages/search', {
234 pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
235 results: [], tracks: [], events: [], pages: [], total: 0,
236 });
237 }
238
239 // Een adres wordt OPGEHAALD, niet doorzocht (shaer-utpi). Eerst als post,
240 // dan als profiel: een URL die geen van beide oplevert is gewoon een
241 // zoekterm die toevallig op een link lijkt, en dan blijft de rest staan.
242 const remoteUri = lookupUri(rawQ);
243 const mayLookup = remoteUri ? mayLookUp(req, res) : false;
244 const { remote, remoteKind, reacted: remoteReacted } = (remoteUri && mayLookup)
245 ? await lookUpRemote(remoteUri, site)
246 : { remote: null, remoteKind: null, reacted: { liked: false, boosted: false } };
247
248 const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
249 const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
250 renderPage(req, res, 'pages/search', {
251 pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
252 results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
253 total, queryError: r.queryError,
254 remoteUri, remote, remoteKind, mayLookup,
255 remoteReacted,
256 siteTitle: site.title || '',
257 });
258});
259
260// ── De live-preview van een adres (HTML-fragment) ─────────────────────────────
261//
262// Plak je een link in de zoekbalk, dan haalt de uitklaplijst de post zelf op
263// in plaats van te zoeken naar de tekst van de link (shaer-utpi).
264//
265// Dezelfde grens als de volle pagina, en hier weegt hij zwaarder: deze route
266// wordt bij elke toetsaanslag geraakt, dus zonder die grens is hij een
267// haalservice die iedereen deze server op adressen naar keuze laat afsturen.
268// Mag je niet ophalen, of is het geen adres, dan 204: de zoekbalk valt dan
269// terug op de gewone suggesties. Geen 403, want voor een bezoeker is een
270// geplakte link gewoon een zoekterm en geen geweigerde handeling.
271const VIEWS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'views');
272
273router.get('/remote', async (req, res) => {
274 const site = res.locals.site;
275 const remoteUri = lookupUri(String(req.query.q || '').slice(0, 2048));
276 if (!site || !remoteUri || !mayLookUp(req, res)) return res.status(204).end();
277 const { remote, remoteKind, reacted } = await lookUpRemote(remoteUri, site);
278 // Dezelfde taal als de pagina eromheen, anders staat er een Engelse kaart
279 // in een Nederlandse zoekbalk.
280 const lang = resolveLang(req, { userLang: req.session?.user?.lang, defaultLang: getSetting('default_lang') });
281 const html = await ejs.renderFile(path.join(VIEWS_DIR, 'partials', 'remote-preview.ejs'), {
282 t: (k, vars) => i18nT(lang, k, vars),
283 remote, remoteKind, remoteUri, reacted,
284 siteTitle: site.title || '',
285 }, { async: false });
286 // Wat deze bezoeker ziet hangt van zijn rechten en zijn reacties af: niet
287 // bewaren, niet delen.
288 res.set('Cache-Control', 'private, no-store');
289 res.type('html').send(html);
290});
291
292// ── Live suggestions (JSON) ──────────────────────────────────────────────────
293router.get('/suggest', (req, res) => {
294 const site = res.locals.site;
295 if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
296 const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
297 if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
298
299 const urlFor = (slug) => `/${slug}`;
300 const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
301 res.json({
302 posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
303 tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
304 events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
305 pages: r.pages,
306 });
307});
308
309export default router;
Note: See TracBrowser for help on using the repository browser.