source: Klonkt/src/routes/search.js@ 86cfa1a

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

Een profiel op mastodon.social laadt: ook het profiel wordt ondertekend opgehaald

Plak je ​https://mastodon.social/@hotdogsladies in de zoekbalk, dan kwam er
"niet gevonden"; ​https://arvr.social/@bedrijfzondernaam liet gewoon een profiel
zien. Allebei Mastodon. Het verschil is authorized fetch: mastodon.social geeft
een onbetekende GET van een profiel een 401 {"error":"Request not signed"},
arvr.social serveert hem aan iedereen.

fetchActor kan dat al aan -- eerst onbetekend, en tekenen als dat niet lukt --
maar alleen als de aanroeper zegt als welke site. resolveRemoteActor gaf dat
nooit door, dus tekende nooit. De ironie: de zoekbalk probeert eerst "is het een
post?", en dat verzoek gaat WEL ondertekend en krijgt het profiel ook terug, maar
gooit het weg omdat het geen post is. Daarna haalde de profielstap hetzelfde
adres opnieuw op, onbetekend.

Gemeten op dev voor de wijziging: onbetekend niets, ondertekend als dev het
profiel met inbox. arvr.social in beide gevallen gevonden, en daar gaat ook
geen ondertekend verzoek uit: dat is alleen de terugval.

ALLEEN ALS JE INGELOGD BENT (Robin, 30-9). Tekenen is deze site die met haar
eigen sleutel voor het verzoek instaat, en dat mag alleen namens een ingelogde
beheerder van die site. resolveRemoteActor blijft zonder opts onbetekend; de
enige aanroepers die de site meegeven zijn de zoekbalk (achter mayLookUp) en
/authorize_interaction (requireSiteManager).

De volgknop had dit gat niet: followActor haalt al sinds 31-7 ondertekend op.

Getoetst: het profiel ondertekend op de zoekpagina, in de preview en op de
interactiepagina, en dat niet ingelogd geen enkel verzoek uitgaat, dus ook geen
ondertekend. Tegenproef: onbetekend in de zoekbalk, onbetekend op de
interactiepagina en de inlogcontrole eruit laten elk hun eigen toets omvallen.
Volledige suite 1286 groen.

shaer-utpi

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

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