source: Klonkt/src/routes/search.js@ 3ceaef6

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

Zoeken op een adres: het object erachter, met de knoppen erbij

Plak je een link naar een fediverse-post of -profiel in de zoekbalk, dan is dat
geen zoekterm maar een aanwijzing. Klonkt haalt hem op en zet hem bovenaan de
resultaten, met dezelfde knoppen als /authorize_interaction: antwoorden,
waarderen, boosten, stemmen, volgen en melden.

De weg erheen was vooral opruimen. De render en de formulieren stonden als 200
regels in authorize-interaction.ejs, en er een tweede kopie van maken voor de
zoekpagina zou een tweede plek zijn waar een poort, een sanitizer of een knop
vergeten kan worden. Ze staan nu in partials/remote-object.ejs en
partials/remote-actor.ejs, met de opmaak in een derde. De pagina die ze had
gebruikt ze nu ook, dus er is één render en de formulieren posten in beide
gevallen naar /authorize_interaction/..., waar het werk en de rechtencontrole
al zaten.

DRIE DINGEN DIE VASTLIGGEN, en elk om een eigen reden:

Het schema is verplicht. "soundfabrics.nl" is een geldige zoekterm -- iemand
zoekt naar de naam -- en een kale domeinnaam als adres opvatten maakt van elke
zoekopdracht met een punt erin een netwerkverzoek. Met ​https:// ervoor is er
geen twijfel over de bedoeling.

Ophalen mag alleen een beheerder van de site laten doen. Dat is dezelfde grens
als /authorize_interaction, en met opzet: ophalen is een uitgaand verzoek dat
deze server namens de bezoeker doet. Zonder die grens is de zoekbalk een
haalservice waarmee een vreemde deze server laat aankloppen op adressen naar
keuze. Een bezoeker die toch een link plakt krijgt de gewone zoekresultaten en
een zin die zegt waarom er verder niets staat.

De ophaling gaat ONDERTEKEND als de site. Een post die alleen voor volgers
zichtbaar is weigert een anonieme GET, en dan lijkt een bestaande post te
ontbreken -- dezelfde val als bij het antwoorden op een post van een vriend.

Bijgekomen zonder dat erom gevraagd is, en het is het vermelden waard: het
profiel toont nu zijn avatar. Die hadden we al in handen (actor_icon uit
resolveRemoteActor) en hij stond op geen van beide schermen.

Getoetst: de herkenning zelf, dat een zoekterm GEEN verzoek wordt, dat de post
met zijn vier formulieren binnenkomt, dat een profiel de volgknop geeft, dat
een bezoeker niets laat ophalen, en dat /authorize_interaction na de verhuizing
nog steeds hetzelfde toont -- die pagina had zelf geen enkele toets, dus dat
was de bodem die er onder moest. Tegenproef per zaak.

Nog open: een @naam@server in de zoekbalk (webfinger kan het al, dus klein
werk) en een hint in de live-suggesties terwijl je plakt.

shaer-utpi

  • Property mode set to 100644
File size: 11.6 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 PermissionsService from '../services/PermissionsService.js';
30
31const router = express.Router();
32
33function 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
39function likeArg(q) {
40 return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
41}
42
43function cleanSnippet(html, excerpt) {
44 const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
45 const s = (html || '')
46 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
47 .replace(/\[\[[^\]]*?\]\]/g, ' ')
48 .replace(/\[\[|\]\]/g, ' ')
49 .replace(/[#>*_`~]+/g, ' ')
50 .replace(/\s+/g, ' ')
51 .trim();
52 if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
53 return s;
54}
55
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 */
67export 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 */
95function 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
103// ── Core: search all sources for one site. `lim` caps results per group
104// (small for live suggestions, large for the full page). ──────────────────
105function searchSite(req, res, rawQ, lim) {
106 const site = res.locals.site;
107 const base = res.locals.siteUrlBase || '';
108 const urlFor = (slug) => `/${slug}`;
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);
112
113 const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
114 if (!site || !rawQ) return out;
115 const like = likeArg(rawQ);
116
117 // 1. Posts (FTS5)
118 const ftsQuery = buildFtsQuery(rawQ);
119 if (ftsQuery) {
120 try {
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
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
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; }
132 }
133
134 // 2. Tracks
135 try {
136 const trackRows = db.prepare(`
137 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
138 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
139 WHERE t.site_id = @site
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 });
143 const playable = trackRows.filter((t) => t.filename);
144 let posts = [];
145 if (playable.length) {
146 posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
147 }
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 + ']]'));
151 if (!hit) {
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 + ']]')));
154 }
155 return hit ? urlFor(hit.slug) : null;
156 };
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),
160 }));
161 } catch (err) { if (!out.queryError) out.queryError = err.message; }
162
163 // 3. Events (agenda) — only when the agenda is publicly enabled.
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; }
178 }
179
180 // 4. Pages — curated, available ones only; matched against the (translated) name.
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}
197
198// ── Full results page ────────────────────────────────────────────────────────
199router.get('/', async (req, res) => {
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
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
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;
231 renderPage(req, res, 'pages/search', {
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,
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 || '',
238 });
239});
240
241// ── Live suggestions (JSON) ──────────────────────────────────────────────────
242router.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
248 const urlFor = (slug) => `/${slug}`;
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,
255 });
256});
257
258export default router;
Note: See TracBrowser for help on using the repository browser.