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

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

Leesweergave: /read, en fragmenten in de richting van de scroll

De vorige opzet sprong naar het andere uiteinde van de scroll en schoof het
bericht daarna "vloeiend" in beeld -- de truc van PrutCMS, en een die je voelt.
Nu groeit de stroom gewoon: een schildwacht boven en onder het laatste bericht
haalt het volgende of vorige fragment op en zet het erbij. Bij invoegen BOVEN
wordt de ingevoegde hoogte gemeten en met scrollBy gecompenseerd, anders
verspringt de tekst onder je duim.

De grens tussen berichten is CSS, geen JavaScript: scroll-snap-type: y proximity
op de stroom, scroll-snap-align: start per bericht. Proximity en niet mandatory,
want binnen een lang bericht moet je vrij kunnen scrollen.

En het heet read, niet lees: een pad is geen plek voor een Nederlandse
werkwoordsvorm.

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