source: Klonkt/src/routes/posts.js@ 19e28ce

main
Last change on this file since 19e28ce was 86cfa1a, checked in by Robin <roboburr@…>, 5 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: 91.3 KB
Line 
1import express from 'express';
2import { v4 as uuid } from 'uuid';
3import path from 'path';
4import fs from 'fs';
5import multer from 'multer';
6import ejs from 'ejs';
7import db from '../config/database.js';
8import { POST_TYPES, KEUZE_TYPES } from '../config/post-types.js';
9import { requireAuth, requireSiteManager, isViewer } from '../middleware/auth.js';
10import { renderPage } from '../middleware/render.js';
11import { recordPageview, recordPostView } from '../services/StatsService.js';
12import PermissionsService from '../services/PermissionsService.js';
13import MarkdownService from '../services/MarkdownService.js';
14import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
15import AudioEmbedService from '../services/AudioEmbedService.js';
16import PlaylistService from '../services/PlaylistService.js';
17import { audioEnabled } from '../config/features.js';
18import { audioUrl } from '../services/AudioStreamService.js';
19import { toWebp } from '../services/ImageWebpService.js';
20import VideoCoverService from '../services/VideoCoverService.js';
21import ActivityPubService from '../services/ActivityPubService.js';
22import * as Guardianship from '../services/guardianship/index.js';
23import { premiumUnlocked } from '../services/PatreonService.js';
24import { defaultMinCents as paidDefaultMinCents, patreonUrl as paidPatronUrl } from '../services/PaidPatreonService.js';
25import { verifyBlob } from '../services/CryptoBox.js';
26import { postEntry } from '../services/PostAccessService.js';
27import * as OWA from '../services/OpenWebAuthService.js';
28import MusicMeta from '../services/MusicMeta.js';
29import { mediaDir } from '../config/paths.js';
30
31const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
32fs.mkdirSync(POST_IMAGES_DIR, { recursive: true });
33
34const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
35const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
36
37// Rich replies: media dropped/pasted into the reply editor. Images, audio and
38// video, stored as-is (no transcode; a reply attachment is not a track).
39const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
40fs.mkdirSync(REPLY_MEDIA_DIR, { recursive: true });
41const ALLOWED_REPLY_MEDIA_EXT = new Set([
42 '.jpg', '.jpeg', '.png', '.webp', '.gif',
43 '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav',
44 '.mp4', '.webm', '.mov',
45]);
46const MAX_REPLY_MEDIA_BYTES = 32 * 1024 * 1024;
47const replyMediaUpload = multer({
48 storage: multer.diskStorage({
49 destination: (req, file, cb) => cb(null, REPLY_MEDIA_DIR),
50 filename: (req, file, cb) => cb(null, `${uuid()}${path.extname(file.originalname).toLowerCase()}`),
51 }),
52 limits: { fileSize: MAX_REPLY_MEDIA_BYTES },
53 fileFilter: (req, file, cb) => {
54 const ext = path.extname(file.originalname).toLowerCase();
55 if (!ALLOWED_REPLY_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
56 cb(null, true);
57 },
58});
59
60const imageStorage = multer.diskStorage({
61 destination: (req, file, cb) => cb(null, POST_IMAGES_DIR),
62 filename: (req, file, cb) => {
63 const ext = path.extname(file.originalname).toLowerCase();
64 cb(null, `${uuid()}${ext}`);
65 },
66});
67const imageUpload = multer({
68 storage: imageStorage,
69 limits: { fileSize: MAX_IMAGE_BYTES },
70 fileFilter: (req, file, cb) => {
71 const ext = path.extname(file.originalname).toLowerCase();
72 if (!ALLOWED_IMAGE_EXT.has(ext)) {
73 return cb(new Error('Image must be jpg/png/webp/gif'));
74 }
75 cb(null, true);
76 },
77});
78
79// Generates a unique slug within the site: 'title', 'title-2', 'title-3', …
80// A second post with the same title is NOT rejected ("already exists"),
81// but automatically gets a free suffix. exceptId = the post being updated
82// (allowed to keep its own slug).
83function uniqueSlug(siteId, base, exceptId = null) {
84 let candidate = base;
85 let n = 2;
86 for (;;) {
87 const row = exceptId
88 ? db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ? AND id != ?').get(siteId, candidate, exceptId)
89 : db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?').get(siteId, candidate);
90 if (!row) return candidate;
91 candidate = `${base}-${n++}`;
92 }
93}
94
95const router = express.Router();
96
97// Feed page size for "Load more" (Solo, News, Messages, Cirkel). 72 is divisible
98// by 2/3/4 so every grid column count ends on a full row.
99const FEED_PAGE = 72;
100
101// ==================== UPLOAD IMAGE (cover or content) ====================
102// Returns JSON {url} so the editor can stick it into the cover field or
103// insert a markdown ![](url) into content.
104router.post('/posts/upload-image', requireAuth, (req, res) => {
105 imageUpload.single('image')(req, res, async (err) => {
106 if (err) return res.status(400).json({ error: err.message });
107 if (!req.file) return res.status(400).json({ error: 'No file' });
108 const name = toWebp(req.file);
109 const url = '/media/post-images/' + name;
110 // An animated WebP cover → also make a muted loop MP4 (Safari plays it smoothly where the
111 // animated WebP is janky on iOS). Best-effort; on failure we just return the still image.
112 // The editor stores `video` in the hidden cover_video_url field for the cover.
113 let video = null;
114 try {
115 const src = path.join(POST_IMAGES_DIR, name);
116 if (VideoCoverService.isAnimatedWebp(src)) {
117 const r = await VideoCoverService.animatedWebpToVideo(src, POST_IMAGES_DIR, path.basename(name, path.extname(name)) + '-v');
118 if (r) video = '/media/post-images/' + path.basename(r.videoPath);
119 }
120 } catch { /* keep the still image */ }
121 res.json({ url, video, size: req.file.size, mime: req.file.mimetype });
122 });
123});
124
125// Rich replies: media for a reply (image/audio/video). Returns { url, mediaType, name }
126// exactly as the editor's attachments JSON wants it; deliverReply re-validates.
127router.post('/posts/upload-reply-media', requireSiteManager, (req, res) => {
128 replyMediaUpload.single('media')(req, res, (err) => {
129 if (err) return res.status(400).json({ error: err.message });
130 if (!req.file) return res.status(400).json({ error: 'No file' });
131 const mime = String(req.file.mimetype || '');
132 if (!/^(image|audio|video)\//.test(mime)) {
133 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
134 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
135 }
136 res.json({
137 url: '/media/reply-media/' + req.file.filename,
138 mediaType: mime,
139 name: String(req.file.originalname || '').slice(0, 120),
140 });
141 });
142});
143
144const RESERVED_SLUGS = new Set([
145 'auth', 'admin', 'login', 'register', 'logout',
146 'archive', 'search', 'account', 'sites', 'comments',
147 'posts', 'media', 'audio', 'forum',
148 'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml',
149 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
150 'authorize_interaction', 'fediverse', 'news', 'following', 'notifications', 'blocking',
151 'paid', 'push', 'guardian',
152 // De meeslepende leesweergave. Gereserveerd
153 // omdat een bericht met deze slug de route anders zou overschaduwen.
154 'read',
155]);
156
157/**
158 * Parse the form's `pinned` field into a non-negative integer rank.
159 * Empty / undefined / NaN / negative → 0 (= not pinned).
160 * Otherwise: integer rank (1 = top of pinned stack, 2 = below, ...).
161 *
162 * Multiple posts CAN share the same rank — UI shows them tiebroken by
163 * published_at DESC. Saying #2 twice doesn't error, it just duplicates.
164 * (We don't enforce uniqueness at this layer because race conditions and
165 * "swap two ranks" workflows are easier without a UNIQUE constraint.)
166 */
167function parsePinnedRank(raw) {
168 const n = parseInt(raw, 10);
169 if (!Number.isFinite(n) || n < 0) return 0;
170 return n;
171}
172
173// Poll durations offered in the editor (seconds) — the Mastodon set (5m … 7d).
174const POLL_DURATIONS = new Set([300, 1800, 3600, 21600, 43200, 86400, 259200, 604800]);
175// Parse the editor's poll fields into the poll_json we store on the post (which
176// buildNote federates as an AS2 Question). Returns null when no valid poll (< 2
177// options or the poll checkbox is off). endTime is set from the chosen duration
178// (default 1 day) so the Scheduler can close it.
179function parsePollForm(body) {
180 if (!body || !body.poll_enabled) return null;
181 const raw = body.poll_option == null ? [] : (Array.isArray(body.poll_option) ? body.poll_option : [body.poll_option]);
182 const options = [];
183 const seen = new Set();
184 for (const o of raw) {
185 const name = String(o == null ? '' : o).trim().slice(0, 100);
186 if (!name) continue;
187 const key = name.toLowerCase();
188 if (seen.has(key)) continue; seen.add(key);
189 options.push({ name });
190 if (options.length >= 8) break;
191 }
192 if (options.length < 2) return null;
193 const dur = parseInt(body.poll_duration, 10);
194 const secs = POLL_DURATIONS.has(dur) ? dur : 86400;
195 return JSON.stringify({ multiple: !!body.poll_multiple, options, endTime: new Date(Date.now() + secs * 1000).toISOString(), closed: false });
196}
197
198// ==================== HOME (Posts list) ====================
199router.get('/', (req, res) => {
200 const site = res.locals.site;
201
202 if (!site) {
203 return renderPage(req, res, 'pages/welcome', {
204 pageTitle: 'Welcome',
205 bodyClass: 'on-special',
206 });
207 }
208
209 // Pinned first — ordered by their rank (1 = top, 2 = below, etc).
210 // pinned column is now an integer rank: 0 = not pinned, 1+ = pinned at
211 // that position. Older boolean usage where pinned was always 1 still
212 // works because integer ranks 1, 2, 3 sort the same as a flat 1.
213 const pinnedPosts = db.prepare(`
214 SELECT p.*, u.username as author_username
215 FROM posts p JOIN users u ON p.author_id = u.id
216 WHERE p.site_id = ? AND p.status = 'published' AND p.pinned > 0
217 ORDER BY p.pinned ASC, p.published_at DESC
218 `).all(site.id);
219
220 // Regular posts: anything with pinned = 0. Paged in blocks of 72 (Load more).
221 const append = req.query.append === '1';
222 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
223 const rows = db.prepare(`
224 SELECT p.*, u.username as author_username
225 FROM posts p JOIN users u ON p.author_id = u.id
226 WHERE p.site_id = ? AND p.status = 'published' AND p.pinned = 0
227 ORDER BY p.published_at DESC
228 LIMIT ? OFFSET ?
229 `).all(site.id, FEED_PAGE + 1, offset);
230 const hasMore = rows.length > FEED_PAGE;
231 const posts = rows.slice(0, FEED_PAGE);
232 const moreBase = res.locals.siteUrlBase || '';
233
234 if (append) {
235 return renderPage(req, res, 'partials/home-append', {
236 posts, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
237 readerItems: readerItems(site, posts, req),
238 });
239 }
240
241 recordPageview(site.id, req);
242
243 // FEP-7628 slice 3: this account moved. A visitor who lands here deserves
244 // the same signpost the fediverse gets — one big link to the new address.
245 const movedTo = site.moved_to && /^https?:\/\//i.test(String(site.moved_to)) ? String(site.moved_to) : null;
246 renderPage(req, res, 'pages/home', {
247 pinnedPosts,
248 posts,
249 readerItems: readerItems(site, [...pinnedPosts, ...posts], req),
250 hasMore, nextOffset: offset + FEED_PAGE, moreBase,
251 movedTo,
252 movedToLabel: movedTo ? (ActivityPubService.actorDisplay(site.slug, movedTo).handle || movedTo) : null,
253 pageTitle: site.title,
254 socialDescr: site.description || site.tagline || '',
255 bodyClass: 'on-home',
256 // mod/read.js: alleen nog de tik-op-een-bericht in de leesweergave.
257 pageJs: 'read tape',
258 });
259});
260
261// ==================== NEW POST FORM ====================
262router.get('/posts/new', requireAuth, (req, res) => {
263 const site = res.locals.site;
264 if (!site) return res.status(404).send('Site required');
265 if (!PermissionsService.canCreatePost(req.session.user, site)) {
266 return res.status(403).send('No permission');
267 }
268
269 renderPage(req, res, 'pages/post-edit', {
270 // post-edit neemt de playlist-editor op.
271 pageJs: 'post-edit playlist-editor',
272 post: {
273 id: uuid(),
274 title: '', slug: '', content: '', excerpt: '',
275 status: 'draft', pinned: 0, tags: [],
276 cover_image_url: '',
277 },
278 isNew: true,
279 keuzeTypes: KEUZE_TYPES,
280 pageTitle: 'New post',
281 bodyClass: 'on-special',
282 });
283});
284
285// ==================== CREATE POST ====================
286// ── Per-post audio federation ──────────────────────────────────────────────
287// "Share audio on the fediverse" is a per-post choice in the editor, but the underlying
288// flag is per track (audio_tracks.fedi_open — it gates the file + drives the AS2 Audio
289// attachment). NB: the file gate is per file, so opening a track in one post makes its file
290// fetchable for every post that reuses it.
291// ONE-WAY: opening is permanent. Once the file has federated it's out there — re-gating
292// would be false security (remote copies keep the URL), so we never write fedi_open back to 0.
293function setAudioFediOpen(siteId, content, open) {
294 if (!open) return; // never close — see one-way note above
295 const c = content || '';
296 try {
297 for (const m of c.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE id = ? AND site_id = ?').run(m[1], siteId);
298 for (const m of c.matchAll(/\[\[album:([^\]]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE site_id = ? AND album = ?').run(siteId, m[1].trim());
299 // playlists.id is a GLOBAL key, so the site filter has to sit on the tracks: without it a
300 // post on site A embedding site B's playlist would open B's files — permanently.
301 for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE site_id = ? AND id IN (SELECT track_id FROM playlist_tracks WHERE playlist_id = ?)').run(siteId, m[1]);
302 } catch { /* non-fatal */ }
303}
304// True when the post references hosted audio AND all of it is currently fedi_open (drives the
305// editor checkbox's initial state).
306function postAudioFediOpen(siteId, content) {
307 const c = content || '';
308 if (!/\[\[(track|album|playlist):/i.test(c)) return false;
309 let total = 0, open = 0;
310 const tally = (r) => { if (r && r.media_id) { total++; if (r.fedi_open) open++; } };
311 try {
312 for (const m of c.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) tally(db.prepare('SELECT fedi_open, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], siteId));
313 for (const m of c.matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare('SELECT fedi_open, media_id FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL').all(siteId, m[1].trim())) tally(r);
314 for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.fedi_open, t.media_id FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? AND t.media_id IS NOT NULL').all(m[1])) tally(r);
315 } catch { /* non-fatal */ }
316 return total > 0 && open === total;
317}
318
319// Bake + cache a post's display HTML (ActivityPub `source` model): `content` stays the raw
320// source (used by the editor + re-rendering), content_rendered holds the linkified render the
321// page serves. Called after every create/edit. Non-fatal: the render route falls back to
322// baking on the fly if this ever fails.
323function cacheRenderedContent(postId, rawContent) {
324 const raw = rawContent || '';
325 // 1. Immediate + synchronous: bake #hashtags + URLs so the post renders enriched at once.
326 try {
327 db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?')
328 .run(ActivityPubService.bakePostContent(raw), postId);
329 } catch (e) { /* fallback bake in the render route keeps display correct */ }
330 // 2. Async: resolve @mentions (webfinger, once) and re-store, WITHOUT blocking the save
331 // response — a moment later the post's @mentions are clickable too. A slow/dead remote
332 // server can't stall the save; on failure the sync bake from step 1 stands.
333 ActivityPubService.bakePostContentWithMentions(raw)
334 .then((html) => {
335 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(html, postId); }
336 catch (e) { /* keep the sync bake */ }
337 })
338 .catch(() => { /* keep the sync bake */ });
339}
340
341router.post('/posts/create', requireAuth, (req, res) => {
342 const site = res.locals.site;
343 if (!site || !PermissionsService.canCreatePost(req.session.user, site)) {
344 return res.status(403).send('No permission');
345 }
346 // Verhuisd = niet meer schrijven. Dit moet HIER staan en niet pas bij
347 // deliverCreate: die weigert alleen de bezorging, waarna de post gewoon in de
348 // database belandt met een object-URI op een adres dat je hebt opgezegd. Dan
349 // lijkt het gelukt, staat het er, en sterft het met het domein. Precies de
350 // halve toestand die dit slot moet voorkomen.
351 if (ActivityPubService.movedLock(site).locked) {
352 return res.status(409).send('Dit account is verhuisd naar ' + ActivityPubService.movedLock(site).movedTo
353 + '. Nieuwe berichten maak je daar. Wil je terug? Maak het verhuisadres leeg bij Uiterlijk.');
354 }
355
356 const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
357 const fanOnly = req.body.fan_only ? 1 : 0;
358 const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0; // paid posts (klonkt-demo-aki)
359 const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
360 const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
361 const nsfw = req.body.nsfw ? 1 : 0;
362 const cw = (req.body.content_warning || '').trim().slice(0, 200);
363 const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
364 const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
365
366 // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
367 // before storage. Shortcode text tokens like [[track:UUID]] live in text
368 // nodes and pass through untouched.
369 const cleanContent = HtmlSanitizerService.sanitize(content || '');
370
371 // Generate slug from title if empty
372 let finalSlug = (slug || title || '')
373 .toLowerCase()
374 .replace(/[^a-z0-9]+/g, '-')
375 .replace(/^-|-$/g, '');
376
377 if (!finalSlug) return res.status(400).send('Title or slug required');
378 if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
379
380 // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
381 finalSlug = uniqueSlug(site.id, finalSlug);
382
383 const finalType = POST_TYPES.has(type) ? type : 'post';
384 const pollJson = parsePollForm(req.body); // AS2 Question definition, or null
385 const postId = uuid();
386 const now = new Date().toISOString();
387 let finalStatus = status || 'draft';
388 let publishedAt = finalStatus === 'published' ? now : null;
389 // Release planning: published + a future publish_at -> 'scheduled'
390 // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
391 let publishAt = null;
392 const pa = Date.parse(req.body.publish_at || '');
393 if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
394 finalStatus = 'scheduled';
395 publishAt = new Date(pa).toISOString();
396 publishedAt = null;
397 }
398
399 db.prepare(`
400 INSERT INTO posts (
401 id, site_id, slug, author_id, title, content, excerpt,
402 status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
403 created_at, updated_at, published_at
404 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
405 `).run(
406 postId, site.id, finalSlug, req.session.user.id,
407 title || finalSlug, cleanContent, excerpt || '',
408 finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
409 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
410 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
411 now, now, publishedAt
412 );
413 cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
414 db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, postId);
415
416 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
417 // BEFORE federating, so the Create note carries the right Audio attachments.
418 setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
419
420 if (finalStatus === 'published') {
421 try {
422 db.prepare(
423 'INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'
424 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId);
425 } catch (e) { /* FTS index issues are non-fatal */ }
426
427 // ActivityPub: federate a freshly published post to followers. fan_only → delivered
428 // to followers but addressed followers-only (option A: "fans" = your fedi followers).
429 if (status === 'published') {
430 ActivityPubService.deliverCreate(site, {
431 id: postId, slug: finalSlug, title: title || finalSlug,
432 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
433 published_at: publishedAt, created_at: now, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
434 }).catch(() => { /* best-effort */ });
435 }
436 }
437
438 // HTMX request -> return redirect header
439 if (req.headers['hx-request']) {
440 res.setHeader('HX-Redirect', `${res.locals.siteUrlBase || ''}/${finalSlug}`);
441 return res.send('OK');
442 }
443
444 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
445});
446
447// ==================== EDIT POST FORM ====================
448router.get('/posts/:slug/edit', requireAuth, (req, res) => {
449 const site = res.locals.site;
450 if (!site) return res.status(404).send('Site required');
451
452 const post = db.prepare(
453 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
454 ).get(site.id, req.params.slug);
455
456 if (!post) return res.status(404).send('Post not found');
457 if (!PermissionsService.canEditPost(req.session.user, post, site)) {
458 return res.status(403).send('No permission');
459 }
460
461 if (post.tags) {
462 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
463 } else {
464 post.tags = [];
465 }
466
467 // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
468 let pollLocked = false;
469 try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
470
471 renderPage(req, res, 'pages/post-edit', {
472 // Zelfde modules als de nieuw-route hierboven: zonder deze regel laadt de
473 // editor niet, en dan wist een opslag de post (shaer-5s1, de beet van 7-8).
474 pageJs: 'post-edit playlist-editor',
475 post,
476 isNew: false,
477 keuzeTypes: KEUZE_TYPES,
478 pollLocked,
479 fediOpenAudio: postAudioFediOpen(site.id, post.content),
480 pageTitle: 'Edit: ' + (post.title || 'Untitled'),
481 bodyClass: 'on-special',
482 });
483});
484
485// ==================== SAVE POST ====================
486router.post('/posts/:slug/save', requireAuth, (req, res) => {
487 const site = res.locals.site;
488 if (!site) return res.status(404).send('Site required');
489
490 const post = db.prepare(
491 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
492 ).get(site.id, req.params.slug);
493
494 if (!post) return res.status(404).send('Post not found');
495 if (!PermissionsService.canEditPost(req.session.user, post, site)) {
496 return res.status(403).send('No permission');
497 }
498
499 // Verhuisd: een BESTAANDE post bewerken mag nog -- daar wil je juist "ik ben
500 // verhuisd naar ..." in kunnen zetten, en die URI bestaat al. Een concept
501 // alsnog publiceren mag niet: dat is nieuwe inhoud op een adres dat je hebt
502 // opgezegd.
503 if (post.status !== 'published' && String(req.body.status || '') === 'published'
504 && ActivityPubService.movedLock(site).locked) {
505 return res.status(409).send('Dit account is verhuisd. Publiceren doe je op '
506 + ActivityPubService.movedLock(site).movedTo + '. Bestaande berichten bewerken kan hier wel.');
507 }
508
509 const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
510 const fanOnly = req.body.fan_only ? 1 : 0;
511 const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0; // paid posts (klonkt-demo-aki)
512 const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
513 const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
514 const nsfw = req.body.nsfw ? 1 : 0;
515 const cw = (req.body.content_warning || '').trim().slice(0, 200);
516 const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
517 const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
518 const newSlug = req.body.slug;
519 const action = req.body.action || 'save';
520 const finalType = POST_TYPES.has(type) ? type : (post.type || 'post');
521
522 // A poll that has already received votes is frozen (you can still edit the surrounding
523 // post, but not the options) — changing options after votes would scramble the tally and
524 // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
525 const hasVotes = !!(post.poll_json && (() => { try { return db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id); } catch { return false; } })());
526 const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
527
528 // Sanitize before storage — same pipeline as create.
529 const cleanContent = HtmlSanitizerService.sanitize(content || '');
530
531 let finalSlug = post.slug;
532 if (newSlug && newSlug !== post.slug) {
533 const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
534 const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
535 // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
536 finalSlug = uniqueSlug(site.id, safe, post.id);
537 }
538
539 const now = new Date().toISOString();
540 let finalStatus = status || post.status;
541 let publishedAt = post.published_at;
542
543 if (action === 'publish') {
544 finalStatus = 'published';
545 if (!publishedAt) publishedAt = now;
546 }
547
548 // Release planning: published + future publish_at -> 'scheduled'.
549 let publishAt = null;
550 const pa = Date.parse(req.body.publish_at || '');
551 if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
552 finalStatus = 'scheduled';
553 publishAt = new Date(pa).toISOString();
554 publishedAt = null;
555 }
556
557 db.prepare(`
558 UPDATE posts SET
559 title = ?, content = ?, excerpt = ?, status = ?,
560 cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
561 type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
562 slug = ?, published_at = ?, updated_at = ?
563 WHERE id = ?
564 `).run(
565 title, cleanContent, excerpt, finalStatus,
566 cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
567 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
568 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
569 finalSlug, publishedAt, now, post.id
570 );
571 cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
572 db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, post.id);
573
574 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
575 // BEFORE federating, so the Update/Create note carries the right Audio attachments.
576 setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
577
578 // Update FTS
579 try {
580 db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id);
581 if (finalStatus === 'published') {
582 db.prepare(
583 'INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'
584 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, post.id);
585 }
586 } catch (e) { /* FTS issues non-fatal */ }
587
588 // ActivityPub: federate edits to followers. A post that BECOMES published →
589 // Create (new post); an already-published post that's edited → Update (so
590 // Mastodon refreshes its cached copy). fan_only → followers-only (option A).
591 if (finalStatus === 'published') {
592 const apPost = {
593 id: post.id, slug: finalSlug, title: title || finalSlug,
594 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
595 published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
596 };
597 // Op een verhuisd account mag een BESTAANDE post nog bewerkt worden -- daar
598 // wil je juist "ik ben verhuisd naar ..." in kunnen zetten, en die URI
599 // bestaat al. Wat niet mag is een concept alsnog publiceren: dat is nieuwe
600 // inhoud op een adres dat je hebt opgezegd. deliverCreate/deliverUpdate
601 // weigeren zelf ook, dit voorkomt alleen de lokale halve toestand.
602 if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
603 else ActivityPubService.deliverUpdate(site, apPost).catch(() => { /* best-effort */ });
604 }
605
606 // Pin/unpin/reorder → push Add/Remove activities so followers' instances update the
607 // pinned order immediately (reliable, unlike re-fetching the cached featured collection).
608 if ((post.pinned || 0) !== parsePinnedRank(pinned)) {
609 const unpinned = (post.pinned || 0) > 0 && parsePinnedRank(pinned) === 0 ? [post.id] : [];
610 ActivityPubService.resyncFeaturedPins(site, unpinned).catch(() => { /* best-effort */ });
611 }
612
613 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
614});
615
616// ==================== DELETE POST ====================
617router.post('/posts/:slug/delete', requireAuth, (req, res) => {
618 const site = res.locals.site;
619 if (!site) return res.status(404).send('Site required');
620
621 const post = db.prepare(
622 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
623 ).get(site.id, req.params.slug);
624
625 if (!post) return res.status(404).send('Not found');
626 if (!PermissionsService.canDeletePost(req.session.user, post, site)) {
627 return res.status(403).send('No permission');
628 }
629
630 // ActivityPub: tell followers the post is gone (Delete + Tombstone) if it was
631 // federated (any published post now federates — fan_only goes followers-only).
632 // Fire before the row is removed — we still have post.id (= the Note id).
633 if (post.status === 'published') {
634 ActivityPubService.deliverDelete(site, post).catch(() => { /* best-effort */ });
635 }
636
637 // Cascade: comments + FTS row, THEN the post itself.
638 // FK constraints are ON (config/database.js), so a bare DELETE on posts
639 // fails when comments still reference it.
640 const cascade = db.transaction(() => {
641 db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
642 try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch {}
643 db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
644 });
645 cascade();
646
647 if (req.headers['hx-request']) {
648 res.setHeader('HX-Redirect', res.locals.siteUrlBase || '/');
649 return res.send('OK');
650 }
651 res.redirect(res.locals.siteUrlBase || '/');
652});
653
654// ==================== ARCHIVE ====================
655router.get('/archive', (req, res) => {
656 const site = res.locals.site;
657 if (!site) return res.status(404).send('No site');
658
659 const posts = db.prepare(`
660 SELECT p.*, u.username as author_username
661 FROM posts p JOIN users u ON p.author_id = u.id
662 WHERE p.site_id = ? AND p.status = 'published'
663 ORDER BY p.published_at DESC
664 `).all(site.id);
665
666 // Group by year/month
667 const grouped = {};
668 for (const post of posts) {
669 if (!post.published_at) continue;
670 const d = new Date(post.published_at);
671 const year = d.getFullYear();
672 const month = d.getMonth();
673 const monthName = ['januari','februari','maart','april','mei','juni','juli','augustus','september','oktober','november','december'][month];
674
675 if (!grouped[year]) grouped[year] = {};
676 if (!grouped[year][monthName]) grouped[year][monthName] = [];
677 grouped[year][monthName].push(post);
678 }
679
680 renderPage(req, res, 'pages/archive', {
681 grouped,
682 totalPosts: posts.length,
683 pageTitle: 'Archive - ' + site.title,
684 bodyClass: 'on-archive',
685 });
686});
687
688// Local likes/favourites are removed — engagement is fediverse-only now
689// (the ⭐ on a post likes via the fediverse). No post_likes, no /favorieten.
690
691// Newer/Older neighbours across ALL posts in feed order. Shared by the full
692// post render and the fan gate (premium fan_only) so navigation is consistent
693// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
694// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
695// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
696// Dezelfde berichten, klaar voor de leesweergave.
697//
698// Tijdlijn en Grid tonen kaartjes; Lezen toont het hele stuk. Het is dus geen
699// andere PAGINA maar een andere vorm van dezelfde rijen -- vandaar dat de feed
700// ze alledrie meestuurt en CSS kiest, precies zoals timeline/grid dat al deden.
701//
702// Het lijf loopt door PostAccessService: een gesloten poort levert hier GEEN
703// tekst op, want wat niet gerenderd wordt kan ook niet lekken.
704function readerItems(site, rows, req) {
705 const viewer = OWA.viewerFor(req, site, { unlockedSlug: null });
706 return rows.map((post) => ({
707 post,
708 entry: postEntry(post, viewer, { renderBody: (p) => renderPostBodyHtml(site, p, req) }),
709 }));
710}
711
712export function renderPostBodyHtml(site, post, req) {
713 let html = (post.content_rendered != null && post.content_rendered !== '')
714 ? post.content_rendered
715 : ActivityPubService.bakePostContent(post.content || '');
716 if (audioEnabled()) {
717 if (site.enable_audio_player !== 0) {
718 html = AudioEmbedService.autoembed(html);
719 html = AudioEmbedService.embedMediaShortcodes(html);
720 html = AudioEmbedService.embedExternalLinkShortcodes(html);
721
722 // Fetch any tracks referenced by [[track:id]] in this post.
723 // Cheap to do unconditionally — only matches if the post actually has shortcodes.
724 const trackIds = [...html.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)].map(m => m[1]);
725 if (trackIds.length) {
726 const placeholders = trackIds.map(() => '?').join(',');
727 const rows = db.prepare(`
728 SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
729 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
730 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
731 WHERE t.site_id = ? AND t.id IN (${placeholders})
732 `).all(site.id, ...trackIds);
733 const byId = new Map(rows.map(r => [r.id, r]));
734 html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
735 const r = byId.get(id);
736 if (!r) return null;
737 return {
738 id: r.id,
739 title: r.title,
740 artist: r.artist,
741 cover: r.cover_url,
742 credit: r.credit || '',
743 license: r.license || '',
744 link_spotify: r.link_spotify || '',
745 link_youtube: r.link_youtube || '',
746 link_soundcloud: r.link_soundcloud || '',
747 url: r.filename ? audioUrl(r.filename) : '', // '' = link-only track
748 };
749 });
750 }
751
752 // Album shortcodes: [[album:Some Album Name]]
753 const albumNames = [...html.matchAll(/\[\[album:([^\]]+)\]\]/g)].map(m => m[1].trim());
754 if (albumNames.length) {
755 const placeholders = albumNames.map(() => '?').join(',');
756 const albumRows = db.prepare(`
757 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
758 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
759 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
760 WHERE t.site_id = ? AND t.album IN (${placeholders})
761 ORDER BY t.position ASC, t.created_at ASC
762 `).all(site.id, ...albumNames);
763 const byAlbum = new Map();
764 for (const r of albumRows) {
765 // Link-only tracks (no file) remain in the album overview (url '').
766 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
767 byAlbum.get(r.album).push({
768 id: r.id,
769 url: r.filename ? audioUrl(r.filename) : '',
770 title: r.title || 'Untitled',
771 artist: r.artist || '',
772 cover: r.cover_url || '',
773 link_spotify: r.link_spotify || '',
774 link_youtube: r.link_youtube || '',
775 link_soundcloud: r.link_soundcloud || '',
776 });
777 }
778 html = AudioEmbedService.embedAlbumShortcodes(html, (name) => {
779 const tracks = byAlbum.get(name);
780 if (!tracks || !tracks.length) return null;
781 return {
782 title: name,
783 artist: tracks[0].artist || '',
784 cover: tracks[0].cover || '',
785 tracks,
786 };
787 });
788 }
789
790 // Playlist shortcodes: [[playlist:some-slug-id]] — first-class entity.
791 // Editing the playlist propagates to every post that embeds it.
792 const playlistIds = [...html.matchAll(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi)]
793 .map(m => m[1].toLowerCase());
794 if (playlistIds.length) {
795 const isAdmin = req.session?.user?.role === 'god';
796 html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
797 return PlaylistService.get(site.id, id, audioUrl);
798 }, { isAdmin });
799 }
800 }
801 } else {
802 // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
803 // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
804 // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
805 html = AudioEmbedService.autoembed(html);
806 html = AudioEmbedService.embedMediaShortcodes(html);
807 html = AudioEmbedService.embedExternalLinkShortcodes(html);
808 html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
809 }
810 return html;
811}
812
813// A short public teaser for a paid post: its excerpt, else the first ~280 chars
814// of the (stripped) content. Shared by the web gate and federation.
815function paidTeaser(post, max = 280) {
816 if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
817 // Only the FIRST paragraph: a paid teaser must never spill later content.
818 const html = String((post && post.content) || '');
819 const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
820 const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
821 return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
822}
823
824// De muziek van een betaalde post op de poortpagina zelf.
825//
826// WAAROM DIE DAAR HOORT. Zodra een nummer `fedi_open` is, federeert het als
827// eigen Audio-object en speelt het bij iedereen die de post in een hub of in
828// Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal
829// beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde
830// kant op (Robin, 24-8). De muur staat om de tekst.
831//
832// ALLES OF NIETS. Alleen als ELK nummer waar de post naar wijst open staat.
833// Een shortcode rendert zijn hele lijst, dus bij een half-open bandje zou de
834// speler ook de gesloten nummers krijgen -- en /audio/stream laat een
835// gelijke-oorsprong-fetch door, dus dat is geen theoretisch lek maar een echt.
836// Half open is hier dus dicht.
837//
838// De TEKST komt hier niet langs: we geven renderPostBodyHtml een post mee die
839// alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook
840// niet lekken -- dezelfde regel als bij readerItems.
841export function paidOpenAudioHtml(site, post, req) {
842 if (!postAudioFediOpen(site.id, post.content)) return '';
843 const codes = String(post.content || '').match(/\[\[(?:track|album|playlist):[^\]]+\]\]/gi) || [];
844 if (!codes.length) return '';
845 const alleenMuziek = codes.join('\n');
846 try {
847 return renderPostBodyHtml(site, { ...post, content: alleenMuziek, content_rendered: alleenMuziek }, req);
848 } catch { return ''; /* geen speler is geen kapotte poort */ }
849}
850
851function postNeighbors(site, post) {
852 const ordered = db.prepare(`
853 SELECT id, slug, title, pinned FROM posts
854 WHERE site_id = ? AND status = 'published'
855 ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
856 `).all(site.id);
857 const idx = ordered.findIndex((p) => p.id === post.id);
858 const newerPost = idx > 0 ? ordered[idx - 1] : null;
859 const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
860 if (newerPost) newerPost._urlBase = '';
861 if (olderPost) olderPost._urlBase = '';
862 return { newerPost, olderPost };
863}
864
865// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
866// Standard fediverse "reply from your own server" landing endpoint. A post page
867// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
868// composes a reply that federates back to that post.
869router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
870 const site = res.locals.site;
871 const uri = (req.query.uri || '').toString();
872 const sent = !!req.query.sent;
873 const followed = !!req.query.followed;
874 const voted = !!req.query.voted;
875 const reported = !!req.query.reported;
876 let target = null, followTarget = null;
877 if (!sent && !followed && !voted && !reported && uri) {
878 // Ondertekend als de site, net als de zoekbalk: een post die alleen voor
879 // volgers zichtbaar is weigert een anonieme GET, en dan zegt deze pagina
880 // "niet gevonden" over een post die de preview wel liet zien.
881 try { target = await ActivityPubService.resolveRemoteNote(uri, site ? { asSlug: site.slug } : {}); } catch { /* ignore */ }
882 // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
883 if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri, site ? { asSlug: site.slug } : {}); } catch { /* ignore */ } }
884 }
885 renderPage(req, res, 'pages/authorize-interaction', {
886 pageJs: 'authorize-interaction reply-editor',
887 pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
888 bodyClass: 'on-special',
889 uri,
890 target,
891 followTarget,
892 sent,
893 followed,
894 voted: !!req.query.voted,
895 reported: !!req.query.reported,
896 liked: !!req.query.liked,
897 boosted: !!req.query.boosted,
898 reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
899 siteTitle: site ? site.title : '',
900 // De knoppen in de preview wijzen hierheen met ?reply=1 of ?report=1: dan
901 // staat dat deel al open en hoef je niet twee keer te tikken.
902 replyOpen: !!req.query.reply,
903 reportOpen: !!req.query.report,
904 });
905});
906
907// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
908// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
909router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
910 const site = res.locals.site;
911 const uri = (req.body.uri || '').toString();
912 let choice = req.body.choice;
913 if (choice == null) choice = [];
914 if (!Array.isArray(choice)) choice = [choice];
915 if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
916 res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
917});
918
919// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
920router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
921 const site = res.locals.site;
922 const uri = (req.body.uri || '').toString();
923 const actorUri = (req.body.actor_uri || '').toString();
924 const reason = (req.body.reason || '').toString();
925 if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
926 res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
927});
928
929// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
930router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
931 const site = res.locals.site;
932 const uri = (req.body.uri || '').toString();
933 let on = false;
934 if (site && uri) {
935 on = !ActivityPubService.getReaction(site.slug, uri).liked;
936 ActivityPubService.resolveRemoteNote(uri)
937 .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
938 .catch((e) => console.warn('[AP] remote like failed:', e.message));
939 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
940 ActivityPubService.setReaction(site.slug, uri, 'like', on);
941 }
942 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
943 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
944});
945
946// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
947// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
948router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
949 const site = res.locals.site;
950 const uri = (req.body.uri || '').toString();
951 let on = false;
952 if (site && uri) {
953 on = !ActivityPubService.getReaction(site.slug, uri).boosted;
954 ActivityPubService.resolveRemoteNote(uri)
955 .then((note) => {
956 if (!note) return;
957 const id = note.object_uri || uri;
958 return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
959 // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
960 // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
961 // de Cirkel verschijnt.
962 .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
963 })
964 .catch((e) => console.warn('[AP] remote boost failed:', e.message));
965 // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
966 // setReaction en niet via setMyReaction: ook dit korte moment mag geen
967 // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
968 // note, zodat de post ook in je tijdlijn belandt.
969 ActivityPubService.setReaction(site.slug, uri, 'boost', on);
970 }
971 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
972 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
973});
974
975// Follow a remote actor from your own site (when the target is a profile, not a post).
976router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
977 const site = res.locals.site;
978 const uri = (req.body.uri || '').toString();
979 if (!site || !uri) return res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
980 // Afwachten in plaats van wegsturen: ligt het verzoek bij de guardians, dan
981 // moet dat op het scherm staan (shaer-p729). "followed=1" terwijl er niets
982 // gebeurd is, is precies de leugen die de poort waardeloos maakt.
983 ActivityPubService.followActor(site, uri)
984 .then((r) => res.redirect('/authorize_interaction?' + (r && r.held ? 'held=1' : 'followed=1') + '&uri=' + encodeURIComponent(uri)))
985 .catch((e) => {
986 console.warn('[AP] remote follow failed:', e.message);
987 res.redirect('/authorize_interaction?error=1&uri=' + encodeURIComponent(uri));
988 });
989});
990
991router.post('/authorize_interaction', requireSiteManager, (req, res) => {
992 const site = res.locals.site;
993 const uri = (req.body.uri || '').toString();
994 const text = (req.body.text || '').toString();
995 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
996 const language = (req.body.language || '').toString();
997 let attachments = [];
998 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
999 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1000 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1001 if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1002 // Resolve + deliver in the background so Send responds instantly.
1003 ActivityPubService.resolveRemoteNote(uri)
1004 .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
1005 .catch((e) => console.warn('[AP] remote reply failed:', e.message));
1006 }
1007 res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
1008});
1009
1010// Manage / delete your own outbound fediverse replies (site owner only).
1011// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
1012// The old /fediverse (manage) and /notifications pages redirect here.
1013router.get('/messages', requireSiteManager, (req, res) => {
1014 const site = res.locals.site;
1015 const append = req.query.append === '1';
1016 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
1017 const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
1018 const hasMore = page.length > FEED_PAGE;
1019 const items = page.slice(0, FEED_PAGE);
1020 // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
1021 const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
1022 // Only stamp "seen" on the first page load (not on Load-more appends).
1023 if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
1024 const moreBase = res.locals.siteUrlBase || '';
1025 if (append) {
1026 return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
1027 }
1028 // FEP-633c: pending guardianship offers TO this account (I am the ward)
1029 // show as a special message with an accept button (Robins besluit: the kid
1030 // answers in its own Klonkt; safety is out-of-band by the guardians).
1031 const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1032 const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
1033 const guardianOffers = (site
1034 ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
1035 : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
1036 renderPage(req, res, 'pages/messages', {
1037 pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages reply-editor', items, seenAt,
1038 hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
1039 success: req.query.success || null, error: req.query.error || null,
1040 });
1041});
1042
1043// The kid answers a guardianship offer from Berichten: the same C2S
1044// Accept/Reject pipeline the Shaer apps use (one path, one behavior).
1045router.post('/messages/guardianship', requireSiteManager, async (req, res) => {
1046 const site = res.locals.site;
1047 const back = `${res.locals.siteUrlBase || ''}/messages`;
1048 const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
1049 const offer = String(req.body.offer || '').trim();
1050 if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
1051 try {
1052 // Same C2S Accept/Reject the apps use; the handshake module records the
1053 // ward's accept and (once the candidate returns the handle) commits.
1054 const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
1055 if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
1056 } catch { /* fall through */ }
1057 res.redirect(back + '?error=guardianship');
1058});
1059// A ward answers a guardian's wave without publishing: a canned private note
1060// back to the sender (FEP-633c §5, shaer:wave reply). Same direct-note leg.
1061router.post('/messages/quick-reply', requireSiteManager, express.urlencoded({ extended: false }), async (req, res) => {
1062 const site = res.locals.site;
1063 const back = `${res.locals.siteUrlBase || ''}/messages`;
1064 const to = String(req.body.to || '').trim();
1065 const text = String(req.body.text || '').trim().slice(0, 200);
1066 // Zwaaien is een seintje, en een seintje hoort de pagina niet te herladen.
1067 // De module stuurt hem met X-Requested-With: fetch en krijgt JSON terug;
1068 // zonder JS blijft het formulier gewoon posten en omleiden.
1069 const viaFetch = req.get('X-Requested-With') === 'fetch';
1070 const mis = (reden) => (viaFetch ? res.status(400).json({ ok: false, error: reden }) : res.redirect(back + '?error=' + reden));
1071 if (!site || !/^https?:\/\//i.test(to) || !text) return mis('quickreply');
1072 try {
1073 const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
1074 if (r) return viaFetch ? res.json({ ok: true }) : res.redirect(back + '?success=wave_sent');
1075 } catch { /* fall through */ }
1076 return mis('quickreply');
1077});
1078
1079// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
1080// bepaalt de draad zelf (zie groupConversations → replyTo):
1081// - hangt de draad aan een post van jou, dan is dit een gewone reply op het
1082// nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
1083// - hangt hij aan een persoon, dan is het een direct bericht terug.
1084// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
1085// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
1086// als enige stuurt).
1087router.post('/messages/reply', requireSiteManager, async (req, res) => {
1088 const site = res.locals.site;
1089 const back = `${res.locals.siteUrlBase || ''}/messages`;
1090 if (!site) return res.status(404).send('Site required');
1091 const text = String(req.body.text || '');
1092 const html = String(req.body.content || '');
1093 let attachments = [];
1094 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1095 let mentions;
1096 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1097 const language = String(req.body.language || '');
1098 // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
1099 if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
1100
1101 const interactionId = parseInt(req.body.interaction_id, 10) || 0;
1102 const postSlug = String(req.body.post_slug || '');
1103 const toActor = String(req.body.to || '');
1104 try {
1105 if (interactionId && postSlug) {
1106 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
1107 const parent = ActivityPubService.getInteractionById(interactionId);
1108 // De parent MOET bij deze post horen: anders zou een gemanipuleerd
1109 // formulier een antwoord onder andermans draad kunnen hangen.
1110 if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
1111 await ActivityPubService.deliverReply(site, {
1112 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
1113 });
1114 } else if (/^https?:\/\//i.test(toActor)) {
1115 const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
1116 if (!r) return res.redirect(back + '?error=reply_failed');
1117 } else {
1118 return res.redirect(back + '?error=reply_target');
1119 }
1120 } catch (e) {
1121 console.warn('[AP] reply from Berichten failed:', e.message);
1122 return res.redirect(back + '?error=reply_failed');
1123 }
1124 res.redirect(back + '?success=reply_sent');
1125});
1126
1127router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1128
1129router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
1130 const site = res.locals.site;
1131 if (site) {
1132 try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
1133 catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
1134 }
1135 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1136});
1137
1138// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
1139// object URI so re-delivery and thread-crawling never bring it back. Works for private
1140// notes too (acts on the local copy; no remote fetch involved).
1141router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
1142 const site = res.locals.site;
1143 if (site) {
1144 const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
1145 if (r.error) console.warn('[AP] interaction remove failed:', r.error);
1146 }
1147 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1148});
1149
1150// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
1151// locally stored object/actor URIs, so it also works for private notes that
1152// authorize_interaction cannot fetch (401/404).
1153router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
1154 const site = res.locals.site;
1155 if (site) {
1156 const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
1157 if (tgt && (tgt.objectUri || tgt.actorUri)) {
1158 try {
1159 const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
1160 if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
1161 } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
1162 }
1163 }
1164 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1165});
1166
1167// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
1168router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
1169 const site = res.locals.site;
1170 const text = String(req.body.text || '');
1171 const html = String(req.body.content || ''); // rich reply editor HTML (sanitized in deliverOutboxUpdate)
1172 if (site && (text.trim() || html.trim())) {
1173 try {
1174 await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
1175 html, language: String(req.body.language || ''),
1176 });
1177 } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
1178 }
1179 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1180});
1181
1182// ==================== FEDIVERSE CLIENT: home timeline + following ====================
1183// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
1184// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
1185function timelineEmbedHtml(html) {
1186 if (!html) return null;
1187 const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
1188 while ((m = re.exec(html))) {
1189 const u = m[1]; if (seen.has(u)) continue; seen.add(u);
1190 let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
1191 if (!p) {
1192 // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
1193 // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
1194 // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
1195 const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
1196 if (pt) return `<iframe class="tl-embed-frame" src="https://${pt[1]}/videos/embed/${pt[2]}" title="PeerTube" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
1197 continue;
1198 }
1199 if (p.provider === 'youtube') return `<iframe class="tl-embed-frame" src="https://www.youtube-nocookie.com/embed/${p.id}" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
1200 if (p.provider === 'spotify') return `<iframe class="tl-embed-frame tl-embed-spotify" src="https://open.spotify.com/embed/${p.type}/${p.id}" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
1201 if (p.provider === 'soundcloud') return `<iframe class="tl-embed-frame tl-embed-sc" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(p.url)}&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>`;
1202 if (p.provider === 'vimeo') return `<iframe class="tl-embed-frame" src="https://player.vimeo.com/video/${p.id}" title="Vimeo" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
1203 if (p.provider === 'bandcamp') return `<iframe class="tl-embed-frame tl-embed-bandcamp" src="https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(u)}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/" title="Bandcamp" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
1204 if (p.provider === 'applemusic') { const am = u.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i); if (am) return `<iframe class="tl-embed-frame tl-embed-apple" src="https://embed.music.apple.com/${am[1]}" title="Apple Music" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>`; }
1205 }
1206 return null;
1207}
1208
1209// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
1210// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
1211// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
1212function klonktAudioEmbed(html, url) {
1213 if (!html || !url || html.indexOf('🎵') < 0) return null;
1214 let u; try { u = new URL(url); } catch { return null; }
1215 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
1216 const slug = u.pathname.replace(/^\/+|\/+$/g, '');
1217 if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
1218 const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
1219 // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
1220 const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
1221 return { origin: u.origin, embedUrl: src, content, html: `<iframe class="tl-embed-frame tl-embed-klonkt" src="${src}" title="Audio" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>` };
1222}
1223
1224/**
1225 * FEP-633c §5.3-style gated feature: may this account see previews of links
1226 * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
1227 *
1228 * Applied at SERVE time on every surface, the way the app's inbox read already
1229 * does it (routes/activitypub.js): a card the client merely hides has still
1230 * been delivered.
1231 */
1232function gateEmbeds(site, rows) {
1233 if (!site || !rows.length) return rows;
1234 if (embedsAllowedFor(site)) return rows;
1235 return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
1236}
1237
1238function isWardSite(site) {
1239 try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
1240}
1241function embedsAllowedFor(site) {
1242 return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
1243}
1244/**
1245 * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
1246 * sibling of the preview gate.) This was the hole: the player iframe is built
1247 * from the note's content by timelineEmbedHtml, on a path that never touched
1248 * gateEmbeds. A ward whose guardians had allowed nothing still got the full
1249 * YouTube player on the web, while the app showed nothing at all: the heavy
1250 * thing open, the light thing shut. Playback also requires the preview gate,
1251 * because you cannot play what you may not see.
1252 */
1253function playbackAllowedFor(site) {
1254 if (!site) return true;
1255 if (!embedsAllowedFor(site)) return false;
1256 return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
1257}
1258
1259router.get('/news', requireSiteManager, (req, res) => {
1260 const site = res.locals.site;
1261 const append = req.query.append === '1';
1262 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
1263 const cspOrigins = new Set();
1264 // Fetch one extra to know whether a "Load more" button belongs on this page.
1265 const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
1266 const hasMore = rows.length > FEED_PAGE;
1267 // Players (a third party's engine inside our page) ride the playback gate;
1268 // a Klonkt site's own audio embed is ours and stays.
1269 const mayPlay = playbackAllowedFor(site);
1270 const timeline = rows.slice(0, FEED_PAGE).map((p) => {
1271 let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
1272 let content = p.content;
1273 let embedUrl = null;
1274 if (!embedHtml) {
1275 const k = klonktAudioEmbed(p.content, p.url);
1276 if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
1277 }
1278 // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
1279 // top-level "open the player" link that works even when a browser shield/CSP blocks
1280 // the cross-site iframe (a full-page navigation is not a cross-site frame).
1281 let poll = null;
1282 if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
1283 return { ...p, content, embedHtml, embedUrl, poll };
1284 });
1285 // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
1286 // extending ONLY this response's CSP frame-src. The global policy stays locked down.
1287 if (cspOrigins.size) {
1288 const csp = res.getHeader('Content-Security-Policy');
1289 if (csp) {
1290 const extra = [...cspOrigins].join(' ');
1291 res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
1292 }
1293 }
1294 const moreBase = res.locals.siteUrlBase || '';
1295 if (append) {
1296 return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
1297 }
1298 renderPage(req, res, 'pages/news', {
1299 pageJs: 'news',
1300 pageTitle: 'News', bodyClass: 'on-special',
1301 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
1302 success: req.query.success || null, error: req.query.error || null,
1303 });
1304});
1305
1306// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
1307// Connect = who you follow + who follows you, merged into one page with direction
1308// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
1309// separate Following/Followers pages, which redirect here so old links keep working.
1310router.get('/connect', requireSiteManager, (req, res) => {
1311 const site = res.locals.site;
1312 const connections = site ? ActivityPubService.listConnections(site.slug) : [];
1313 // FEP-633c §2: the ward always sees who guards it, and §3.6 how available
1314 // each of them is. Connect is where "who am I connected to" belongs; a
1315 // guardian is the one connection a ward should never have to hunt for.
1316 // Owner-only by construction: this page is the owner's.
1317 const guardianHandle = (uri, cached) => {
1318 if (cached && cached.charAt(0) === '@') return cached;
1319 try { const u = new URL(uri); return `@${u.pathname.split('/').filter(Boolean).pop()}@${u.host}`; }
1320 catch { return uri; }
1321 };
1322 const gStatus = site ? Object.fromEntries(
1323 Guardianship.availability.statusesFor(site.slug, Guardianship.listGuardians(site.slug).map((g) => g.other_uri), Date.now())
1324 .map((s) => [s.id, s]),
1325 ) : {};
1326 const myGuardians = (site ? Guardianship.listGuardians(site.slug) : [])
1327 .map((g) => ({
1328 uri: g.other_uri,
1329 handle: guardianHandle(g.other_uri, g.other_handle),
1330 availability: (gStatus[g.other_uri] || {})['shaer:availability'] || 'active',
1331 awayUntil: (gStatus[g.other_uri] || {})['shaer:awayUntil'] || null,
1332 }));
1333 // De eigenaarspoort: openstaande volgverzoeken, alleen buiten voogdij.
1334 // Een ward-follow beslissen de guardians — die tonen we hier dus NIET,
1335 // anders is deze pagina een deur naast hun poort.
1336 const followRequests = (site && !myGuardians.length)
1337 ? Guardianship.follows.listForWard(site.slug) : [];
1338 renderPage(req, res, 'pages/connect', {
1339 pageTitle: 'Connect', bodyClass: 'on-special',
1340 connections, myGuardians, followRequests,
1341 approveFollowers: !!(site && site.approve_followers),
1342 // Na een verhuizing staat de uitgaande kant op slot. Dat hoort te blijken
1343 // VOORDAT je op een knop drukt, niet daarna uit een foutmelding.
1344 movedTo: ActivityPubService.movedLock(site).movedTo,
1345 success: req.query.success || null, error: req.query.error || null,
1346 });
1347});
1348router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1349router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1350
1351router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
1352 const site = res.locals.site;
1353 const base = res.locals.siteUrlBase || '';
1354 if (!site) return res.redirect(`${base}/connect`);
1355 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
1356 return res.redirect(`${base}/connect?` + (ok
1357 ? 'success=' + encodeURIComponent('Volger verwijderd')
1358 : 'error=' + encodeURIComponent('Volger niet gevonden')));
1359});
1360
1361// De poort zelf aan- of uitzetten, op de plek waar de verzoeken toch al
1362// staan (Robins wens, 18-8: "op de connect is logischer").
1363router.post('/connect/approve-followers', requireSiteManager, (req, res) => {
1364 const site = res.locals.site;
1365 const base = res.locals.siteUrlBase || '';
1366 if (site) {
1367 db.prepare('UPDATE sites SET approve_followers = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
1368 .run(req.body.on ? 1 : 0, site.id);
1369 }
1370 return res.redirect(`${base}/connect`);
1371});
1372
1373// De eigenaarspoort beslist (Robins wens, 18-8): accepteer of weiger een
1374// volgverzoek dat door approve_followers is vastgehouden. Bewust NIET voor
1375// wards — daar beslissen de guardians, en deze route weigert dan hard, zodat
1376// hij geen sluiproute naast die poort wordt.
1377router.post('/follow-requests/:decision', requireSiteManager, async (req, res) => {
1378 const site = res.locals.site;
1379 const base = res.locals.siteUrlBase || '';
1380 const { decision } = req.params;
1381 if (!site || !['approve', 'deny'].includes(decision)) return res.redirect(`${base}/connect`);
1382 if (Guardianship.listGuardians(site.slug).length) {
1383 return res.redirect(`${base}/connect?error=` + encodeURIComponent('Volgverzoeken lopen via je guardians'));
1384 }
1385 const pending = Guardianship.follows.getPending(String(req.body.id || ''));
1386 if (!pending || pending.ward_slug !== site.slug || pending.status !== 'pending') {
1387 return res.redirect(`${base}/connect?error=` + encodeURIComponent('Verzoek niet gevonden'));
1388 }
1389 if (decision === 'approve') await ActivityPubService.acceptGatedFollow(pending);
1390 else await ActivityPubService.rejectGatedFollow(pending);
1391 Guardianship.follows.remove(pending.id);
1392 return res.redirect(`${base}/connect?success=` + encodeURIComponent(
1393 decision === 'approve' ? 'Volger geaccepteerd' : 'Verzoek geweigerd'));
1394});
1395
1396router.post('/news/follow', requireSiteManager, async (req, res) => {
1397 const site = res.locals.site;
1398 const handle = (req.body.handle || '').toString();
1399 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
1400 if (site && handle.trim()) {
1401 try {
1402 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
1403 // 'moved' is geen mislukking maar een weigering met een reden, en die reden
1404 // hoort de gebruiker te lezen. "Volgen mislukt" laat hem zoeken naar een
1405 // storing die er niet is.
1406 if (r && r.error === 'moved') q = 'error=' + encodeURIComponent(`Dit account is verhuisd naar ${r.movedTo}. Volgen doe je daarvandaan.`);
1407 else if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
1408 // Een DERDE uitkomst, niet gelukt en niet mislukt (shaer-p729). "Je volgt
1409 // nu X" zeggen terwijl het verzoek bij de guardians ligt is de leugen die
1410 // deze poort waardeloos maakt: het kind denkt dat het gebeurd is.
1411 else if (r && r.held) q = 'success=' + encodeURIComponent(r.status === 'denied' ? 'Je guardians hebben dit geweigerd' : 'Je verzoek ligt bij je guardians');
1412 else {
1413 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
1414 }
1415 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
1416 }
1417 res.redirect('/following?' + q);
1418});
1419
1420// ── Je volglijst meenemen ─────────────────────────────────────────
1421//
1422// Zonder dit was verhuizen halfslachtig: de Move vertelt je VOLGERS waar je heen
1423// ging, maar niets vertelde JOU wie jij volgde. Die lijst stond alleen in de
1424// database die je achterlaat.
1425router.get('/news/following.csv', requireSiteManager, async (req, res) => {
1426 const site = res.locals.site;
1427 const { followingCsv } = await import('../services/ArchiveExportService.js');
1428 const csv = site ? followingCsv(site.slug) : null;
1429 if (!csv) return res.redirect('/connect?error=' + encodeURIComponent('Je volgt nog niemand'));
1430 res.set('Content-Type', 'text/csv; charset=utf-8');
1431 res.set('Content-Disposition', `attachment; filename="following-${site.slug}.csv"`);
1432 // Privé: dit is de lijst van wie jij volgt, niets voor een cache onderweg.
1433 res.set('Cache-Control', 'private, no-store');
1434 res.send(csv);
1435});
1436
1437// Een bestand OF geplakte tekst. Multer leest een multipart-formulier, en dat
1438// bevat allebei: het bestandsveld en het tekstveld. In het geheugen, niet op
1439// schijf: dit is een lijstje adressen van een paar kilobyte dat na het lezen
1440// niets meer te zoeken heeft op de server.
1441const followingCsvUpload = multer({
1442 storage: multer.memoryStorage(),
1443 limits: { fileSize: 512 * 1024, files: 1 },
1444}).single('csvfile');
1445
1446router.post('/news/following/import', requireSiteManager, followingCsvUpload, async (req, res) => {
1447 const site = res.locals.site;
1448 // Een geupload bestand wint van het plakveld: wie een bestand kiest bedoelt dat.
1449 const csv = (req.file && req.file.buffer)
1450 ? req.file.buffer.toString('utf8').replace(/^/, '') // BOM eraf; Excel zet die erin
1451 : ((req.body && req.body.csv) || '');
1452 // Terug naar waar je vandaan kwam. Sinds 14-8 staat dit formulier op
1453 // /admin/migrate (Robin: alle migratie-opties bij elkaar); terugspringen naar
1454 // Connect is dan desorienterend. Alleen een eigen pad, geen open redirect.
1455 const terug = /^\/[A-Za-z0-9/_-]*$/.test(String(req.body.next || '')) ? String(req.body.next) : '/connect';
1456 if (!site || !String(csv).trim()) return res.redirect(terug + '?error=' + encodeURIComponent('Geen lijst ontvangen'));
1457
1458 const { importFollowing } = await import('../services/ArchiveImportService.js');
1459 // followActor als followFn: die doet de webfinger, stuurt de Follow en zet
1460 // auto_boost meteen goed. Zo blijft er één pad naar een volgrelatie.
1461 const r = await importFollowing(site, csv, {
1462 followFn: async (s, adres, uitgelicht) => {
1463 const uit = await ActivityPubService.followActor(s, adres, !!uitgelicht);
1464 // followActor meldt een fout als VELD, niet als exception. Zonder deze
1465 // vertaling telde een onvindbaar account gewoon als geslaagd mee.
1466 if (uit && uit.error) throw new Error(uit.error);
1467 return true;
1468 },
1469 });
1470
1471 const delen = [`${r.gevolgd} gevolgd`];
1472 if (r.overgeslagen) delen.push(`${r.overgeslagen} overgeslagen`);
1473 if (r.mislukt.length) {
1474 const namen = r.mislukt.slice(0, 3).map((m) => m.adres).join(', ');
1475 delen.push(`${r.mislukt.length} mislukt (${namen}${r.mislukt.length > 3 ? '…' : ''})`);
1476 }
1477 // Terug naar /connect: daar staat het blok, /following is de oude pagina.
1478 res.redirect(terug + '?' + (r.mislukt.length ? 'error=' : 'success=') + encodeURIComponent(delen.join(', ')));
1479});
1480
1481router.post('/news/unfollow', requireSiteManager, async (req, res) => {
1482 const site = res.locals.site;
1483 const actorUri = (req.body.actor_uri || '').toString();
1484 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
1485 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
1486});
1487
1488// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
1489router.post('/news/autoboost', requireSiteManager, (req, res) => {
1490 const site = res.locals.site;
1491 const actorUri = (req.body.actor_uri || '').toString();
1492 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
1493 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
1494});
1495
1496// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
1497// no banner); no-JS → redirect back.
1498router.post('/news/like', requireSiteManager, async (req, res) => {
1499 const site = res.locals.site;
1500 const note = (req.body.note || '').toString();
1501 let on = false;
1502 if (site && note) {
1503 on = !ActivityPubService.getReaction(site.slug, note).liked;
1504 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1505 ActivityPubService.setReaction(site.slug, note, 'like', on);
1506 }
1507 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1508 res.redirect('/news');
1509});
1510
1511// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
1512router.post('/news/boost', requireSiteManager, async (req, res) => {
1513 const site = res.locals.site;
1514 const note = (req.body.note || '').toString();
1515 let on = false;
1516 if (site && note) {
1517 on = !ActivityPubService.getReaction(site.slug, note).boosted;
1518 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1519 ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
1520 if (on) {
1521 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1522 // (cover/content) — boosting again heals a stale copy from EVERY boost
1523 // path, not just the interact page.
1524 ActivityPubService.resolveRemoteNote(note)
1525 .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
1526 .catch(() => { /* best-effort */ });
1527 }
1528 }
1529 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1530 res.redirect('/news');
1531});
1532
1533// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1534router.post('/news/vote', requireSiteManager, async (req, res) => {
1535 const site = res.locals.site;
1536 const note = (req.body.note || '').toString();
1537 let choice = req.body.choice;
1538 if (choice == null) choice = [];
1539 if (!Array.isArray(choice)) choice = [choice];
1540 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1541 res.redirect('/news');
1542});
1543
1544// Notifications inbox (new followers + replies/likes/boosts on your posts).
1545router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1546
1547// Blocking / defederation (owner-only).
1548router.get('/blocking', requireSiteManager, (req, res) => {
1549 const site = res.locals.site;
1550 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1551 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1552});
1553
1554router.post('/blocking/add', requireSiteManager, async (req, res) => {
1555 const site = res.locals.site;
1556 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1557 if (site) {
1558 try {
1559 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1560 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1561 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1562 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1563 }
1564 const ref = req.get('Referer') || '';
1565 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1566});
1567
1568router.post('/blocking/remove', requireSiteManager, (req, res) => {
1569 const site = res.locals.site;
1570 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()).catch(() => {}); } catch (e) { /* ignore */ } }
1571 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1572});
1573
1574// ==================== VIEW POST (last route — catches /:slug) ====================
1575router.get('/:slug', (req, res, next) => {
1576 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1577
1578 const site = res.locals.site;
1579 if (!site) return next(); // -> nette 404 catch-all
1580
1581 const post = db.prepare(`
1582 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1583 FROM posts p JOIN users u ON p.author_id = u.id
1584 WHERE p.site_id = ? AND p.slug = ?
1585 `).get(site.id, req.params.slug);
1586
1587 if (!post) return next(); // unknown slug -> clean 404 catch-all
1588
1589 // Permission to view: published OR (logged in + can edit)
1590 if (post.status !== 'published') {
1591 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1592 if (!canEdit) return res.status(403).send('Not published');
1593 }
1594
1595 // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
1596 // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
1597 // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
1598 // paid gate wins (otherwise anonymous visitors land on the login gate and
1599 // never see the unlock button).
1600 const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1601 // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
1602 // supporter render the FULL post through this normal template (correct layout,
1603 // scoped styles, working audio). Short-lived signed blob, single post, not a
1604 // cookie and not stored.
1605 const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
1606 const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
1607 if (post.paid && !canEditThis && !_unlocked) {
1608 const { newerPost, olderPost } = postNeighbors(site, post);
1609 const pgAudio = paidOpenAudioHtml(site, post, req);
1610 return renderPage(req, res, 'pages/paid-gate', {
1611 pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
1612 pageTitle: post.title || 'Voor supporters',
1613 bodyClass: 'on-special',
1614 pgTitle: post.title || '',
1615 pgTeaser: paidTeaser(post),
1616 pgAudio,
1617 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
1618 pgSlug: post.slug,
1619 pgPatronUrl: paidPatronUrl(site.id),
1620 newerPost,
1621 olderPost,
1622 });
1623 }
1624
1625 // Fan-only preview (premium #3): full content only for logged-in fans.
1626 // Anonymous visitors get a clean login gate instead of the content (the title/
1627 // teaser may still appear elsewhere as a teaser).
1628 // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site
1629 // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien.
1630 const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req));
1631 if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) {
1632 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1633 // stuck on the fan gate but can keep browsing.
1634 const { newerPost, olderPost } = postNeighbors(site, post);
1635 return renderPage(req, res, 'pages/fan-gate', {
1636 pageTitle: post.title || 'Alleen voor fans',
1637 bodyClass: 'on-special',
1638 fgTitle: post.title || '',
1639 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1640 owaError: !!(req.query && req.query.owa_error),
1641 newerPost,
1642 olderPost,
1643 });
1644 }
1645
1646 // Statistics: count the view (skips admins + unpublished own-preview).
1647 if (post.status === 'published') recordPostView(post, req);
1648
1649 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1650 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1651 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1652 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1653 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1654 post.content_html = renderPostBodyHtml(site, post, req);
1655
1656 if (post.tags) {
1657 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1658 } else {
1659 post.tags = [];
1660 }
1661
1662 // Native comments removed: social interaction is fediverse-only (see the
1663 // "From the fediverse" section below).
1664
1665 // Prev / next chronological (kept for back-compat — "post-nav" feature
1666 // below the article still uses these as a simple linear navigation).
1667 const urlBaseFor = () => '';
1668
1669 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1670 const { newerPost, olderPost } = postNeighbors(site, post);
1671
1672 // ── Related posts: same-tag matching with recency fallback ─────
1673 // Fetch ~50 candidates, score by tag overlap, take top 3.
1674 // Excluding self via `id != ?`.
1675 const candidates = db.prepare(`
1676 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1677 FROM posts
1678 WHERE site_id = ? AND status = 'published' AND id != ?
1679 ORDER BY published_at DESC LIMIT 50
1680 `).all(site.id, post.id);
1681
1682 // Parse tags JSON safely; missing/malformed → empty array.
1683 const parseTags = (raw) => {
1684 if (!raw) return [];
1685 try {
1686 const v = JSON.parse(raw);
1687 return Array.isArray(v) ? v.map(String) : [];
1688 } catch { return []; }
1689 };
1690
1691 const myTags = new Set(parseTags(post.tags));
1692 let relatedPosts;
1693 if (myTags.size > 0) {
1694 // Score = number of overlapping tags. Posts with zero overlap are
1695 // included only if we don't have 3 with-overlap candidates.
1696 const scored = candidates.map(p => {
1697 const theirTags = parseTags(p.tags);
1698 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1699 return { ...p, _overlap: overlap };
1700 });
1701 const withOverlap = scored.filter(p => p._overlap > 0)
1702 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1703 if (withOverlap.length >= 3) {
1704 relatedPosts = withOverlap.slice(0, 3);
1705 } else {
1706 // Pad with most-recent non-overlap posts so the section is never empty
1707 const overlapIds = new Set(withOverlap.map(p => p.id));
1708 const filler = candidates.filter(p => !overlapIds.has(p.id));
1709 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1710 }
1711 } else {
1712 // No tags on current post → just show 3 most-recent
1713 relatedPosts = candidates.slice(0, 3);
1714 }
1715 // Strip the internal _overlap field before sending to view
1716 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1717
1718 // Inbound fediverse activity (threaded) for this post.
1719 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1720 try {
1721 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1722 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1723 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1724 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1725 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1726 } catch { /* non-fatal */ }
1727 // Owner/admin of this site may reply back to a fediverse interaction.
1728 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1729 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1730 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1731
1732 renderPage(req, res, 'pages/post', {
1733 pageJs: 'post reply-editor tape',
1734 post,
1735 poll: ActivityPubService.ownPollView(post),
1736 newerPost,
1737 olderPost,
1738 relatedPosts,
1739 fediverse,
1740 canManageSite,
1741 siteAvatar,
1742 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1743 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1744 pageTitle: post.title + ' - ' + site.title,
1745 socialDescr: post.excerpt || '',
1746 socialImage: post.cover_image_url || '',
1747 bodyClass: 'on-post',
1748 });
1749});
1750
1751// ── Reply back to a fediverse interaction (site owner/admin only) ──
1752router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1753 const site = res.locals.site;
1754 if (!site) return res.status(404).send('Site required');
1755 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1756 if (!post) return res.status(404).send('Not found');
1757 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1758 const text = (req.body.text || '').toString();
1759 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1760 let attachments = [];
1761 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1762 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1763 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1764 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1765 try {
1766 await ActivityPubService.deliverReply(site, {
1767 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1768 language: (req.body.language || '').toString(),
1769 });
1770 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1771 }
1772 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1773});
1774
1775// Owner likes/boosts a fediverse comment on their own post — directly as the
1776// site, no "your server" detour (mirrors /fedi-reply).
1777router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1778 const site = res.locals.site;
1779 if (!site) return res.status(404).send('Site required');
1780 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1781 if (!post) return res.status(404).send('Not found');
1782 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1783 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1784 if (parent && parent.post_id === post.id && parent.object_uri) {
1785 // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
1786 // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
1787 // de kolom en de knop uit de tussentabel, dan draait een divergentie de
1788 // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
1789 const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
1790 const on = kind === 'boost' ? !ik.boosted : !ik.liked;
1791 ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
1792 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1793 // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
1794 // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
1795 // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
1796 // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
1797 ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
1798 if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
1799 else ActivityPubService.setInteractionLiked(parent.id, on);
1800 }
1801 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1802});
1803
1804export default router;
1805export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.