source: Klonkt/src/routes/posts.js@ f5cf299

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

/lees: één bericht vult het scherm, met zijn buren als schildwachten

Stap 2 van de meeslepende tijdlijn. De route levert één bericht plus de twee
buren uit postNeighbors() -- dezelfde die de gewone postpagina gebruikt, zodat
"vorige" daar en hier hetzelfde betekent, gepinde berichten inbegrepen.

De schildwachten staan BINNEN het artikelblok en niet eromheen. Dat is het punt
waar dit later op zou stukgaan: staan ze erbuiten, dan wijzen ze na een
verwisseling nog naar het vorige bericht. Nu komt de hele staat uit het nieuwe
stuk en hoeft de client niets te onthouden.

?partial=1 geeft alleen het artikel, want dat is precies wat er ingeruild wordt.

Het lijf komt via postEntry(), dus een dichte poort levert hier geen tekst op --
gecontroleerd met een rooktest door de echte server: /lees, /lees/:slug, een
betaald bericht en de partial, en in alle vier staat de tweede alinea nergens in
het antwoord.

'lees' staat in de gereserveerde namen, anders overschaduwt een bericht met die
slug de route. En een noscript-terugval met twee gewone links: een tijdlijn die
alleen met script bestaat, bestaat niet voor wie het uitzet.

Nog niet gebouwd: het overscrollen zelf (stap 3).

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

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