source: Klonkt/src/routes/posts.js@ 2d2fd8c

main
Last change on this file since 2d2fd8c was 2d2fd8c, checked in by Bart <bart@…>, 3 weeks ago

Tijdlijn | Grid | Lezen: de leesweergave krijgt een ingang

De leesweergave bestond wel maar was alleen te bereiken door /read in te tikken.
Nu staat ze als derde optie in dezelfde pil.

Een <a>, geen <button>, en met opzet NIET de klasse view-switch-btn: de
gedelegeerde klik in shell.ejs pakt alles met die klasse op, zet
body[data-feed-view] erop en springt op niet-feedpagina's terug naar home. Die
had deze link opgegeten.

Door er een link naartoe te leggen kwamen twee fouten boven die er al zaten:

  1. De route gaf ELKE htmx-aanvraag één kaal artikel terug, want de leesmodule had ?partial=1 geleend -- en dat betekent op deze site al iets anders: "de pagina zonder de schil". Een gebooste link naar /read schoof dus één artikel in #pcms-main: geen stroom, geen script. De module vraagt nu ?fragment=1, en al het andere gaat naar pages/read waar renderPage zelf over de schil beslist.
  1. PAGE_CLASSES in shell.ejs is een witte lijst, en 'on-read' stond er niet in. Bij een htmx-navigatie werd de class dus weggefilterd en sloeg ELKE regel van de leesweergave (.on-read ...) niet aan: geen schermhoge berichten, geen snappen, geen wegschuivende balken. Bij intikken ging het goed, want dan zet de shell de class zelf -- vandaar dat het onzichtbaar bleef.

Beide nagelopen in de browser: de pil navigeert, on-read staat er, min-height is
de schermhoogte, snappen staat aan, en de opgeslagen feed-view blijft ongemoeid.

MOD_V naar 3 (read.js) en style.css naar v68 (nieuwe selector).

Co-Authored-By: Claude Opus 5 <claude@…>

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