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

main
Last change on this file since ff3b8ce was e9c9ae1, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: mentions bar with conversation partners (klonkt-demo-u02)

Implicit mentions leave the text and become an editable "To:" bar above the
reply editor: the parent author plus the thread's ancestor authors as chips.
Removing a chip stops addressing that partner (no mention anchor, no Mention
tag, no inbox ping); explicit @mentions typed in the text keep working via the
existing resolveMentionsInText path.

  • getInteractions: each thread node gets "participants" (author + ancestor chain via the byId map, own nodes skipped, deduped, capped at 8) and carries actor_uri now.
  • reply-editor partial: the bar renders server-side with a hidden "mentions" JSON field carrying the full list, so the no-JS path addresses everyone; the JS removes chips and mirrors the remaining list into the field.
  • deliverReply({mentions}): undefined = legacy parent-only behavior; an array (possibly empty) = the kept list drives the mention prefix, the Mention tags (mentionTags reads the content anchors) and the per-actor inbox pings. to_actor/to_handle follow the kept list (parent when kept, else the first chip, else null -> the note addresses Public only).
  • deliverOutboxUpdate: an edit reuses the OLD content's leading mention anchors instead of rebuilding just to_actor, so co-mentions survive edits (legacy rows fall back as before).
  • Interact page passes the target author as the single chip. Full-screen mobile keeps the top bar above the mentions bar (flex order).

4 new tests (participant chains, multi-chip tags + to_actor, empty bar goes
Public-only, co-mentions survive edits); 97 green. Browser-verified: bar shows
@bob + @alice on a nested reply, removing @alice updates the hidden field, the
sent reply mentions and addresses only @bob; no console errors.

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

  • Property mode set to 100644
File size: 65.8 KB
Line 
1import express from 'express';
2import { v4 as uuid } from 'uuid';
3import path from 'path';
4import fs from 'fs';
5import { fileURLToPath } from 'url';
6import multer from 'multer';
7import ejs from 'ejs';
8import db from '../config/database.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 MusicMeta from '../services/MusicMeta.js';
23
24const __dirname = path.dirname(fileURLToPath(import.meta.url));
25const POST_IMAGES_DIR = path.resolve(
26 process.env.POST_IMAGES_PATH ||
27 path.join(__dirname, '..', '..', 'storage', 'media', 'post-images')
28);
29fs.mkdirSync(POST_IMAGES_DIR, { recursive: true });
30
31const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
32const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
33
34// Rich replies: media dropped/pasted into the reply editor. Images, audio and
35// video, stored as-is (no transcode; a reply attachment is not a track).
36const REPLY_MEDIA_DIR = path.resolve(
37 process.env.REPLY_MEDIA_PATH ||
38 path.join(__dirname, '..', '..', 'storage', 'media', 'reply-media')
39);
40fs.mkdirSync(REPLY_MEDIA_DIR, { recursive: true });
41const ALLOWED_REPLY_MEDIA_EXT = new Set([
42 '.jpg', '.jpeg', '.png', '.webp', '.gif',
43 '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav',
44 '.mp4', '.webm', '.mov',
45]);
46const MAX_REPLY_MEDIA_BYTES = 32 * 1024 * 1024;
47const replyMediaUpload = multer({
48 storage: multer.diskStorage({
49 destination: (req, file, cb) => cb(null, REPLY_MEDIA_DIR),
50 filename: (req, file, cb) => cb(null, `${uuid()}${path.extname(file.originalname).toLowerCase()}`),
51 }),
52 limits: { fileSize: MAX_REPLY_MEDIA_BYTES },
53 fileFilter: (req, file, cb) => {
54 const ext = path.extname(file.originalname).toLowerCase();
55 if (!ALLOWED_REPLY_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
56 cb(null, true);
57 },
58});
59
60const imageStorage = multer.diskStorage({
61 destination: (req, file, cb) => cb(null, POST_IMAGES_DIR),
62 filename: (req, file, cb) => {
63 const ext = path.extname(file.originalname).toLowerCase();
64 cb(null, `${uuid()}${ext}`);
65 },
66});
67const imageUpload = multer({
68 storage: imageStorage,
69 limits: { fileSize: MAX_IMAGE_BYTES },
70 fileFilter: (req, file, cb) => {
71 const ext = path.extname(file.originalname).toLowerCase();
72 if (!ALLOWED_IMAGE_EXT.has(ext)) {
73 return cb(new Error('Image must be jpg/png/webp/gif'));
74 }
75 cb(null, true);
76 },
77});
78
79// Generates a unique slug within the site: 'title', 'title-2', 'title-3', …
80// A second post with the same title is NOT rejected ("already exists"),
81// but automatically gets a free suffix. exceptId = the post being updated
82// (allowed to keep its own slug).
83function uniqueSlug(siteId, base, exceptId = null) {
84 let candidate = base;
85 let n = 2;
86 for (;;) {
87 const row = exceptId
88 ? db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ? AND id != ?').get(siteId, candidate, exceptId)
89 : db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?').get(siteId, candidate);
90 if (!row) return candidate;
91 candidate = `${base}-${n++}`;
92 }
93}
94
95const router = express.Router();
96
97// ==================== UPLOAD IMAGE (cover or content) ====================
98// Returns JSON {url} so the editor can stick it into the cover field or
99// insert a markdown ![](url) into content.
100router.post('/posts/upload-image', requireAuth, (req, res) => {
101 imageUpload.single('image')(req, res, async (err) => {
102 if (err) return res.status(400).json({ error: err.message });
103 if (!req.file) return res.status(400).json({ error: 'No file' });
104 const name = toWebp(req.file);
105 const url = '/media/post-images/' + name;
106 // An animated WebP cover → also make a muted loop MP4 (Safari plays it smoothly where the
107 // animated WebP is janky on iOS). Best-effort; on failure we just return the still image.
108 // The editor stores `video` in the hidden cover_video_url field for the cover.
109 let video = null;
110 try {
111 const src = path.join(POST_IMAGES_DIR, name);
112 if (VideoCoverService.isAnimatedWebp(src)) {
113 const r = await VideoCoverService.animatedWebpToVideo(src, POST_IMAGES_DIR, path.basename(name, path.extname(name)) + '-v');
114 if (r) video = '/media/post-images/' + path.basename(r.videoPath);
115 }
116 } catch { /* keep the still image */ }
117 res.json({ url, video, size: req.file.size, mime: req.file.mimetype });
118 });
119});
120
121// Rich replies: media for a reply (image/audio/video). Returns { url, mediaType, name }
122// exactly as the editor's attachments JSON wants it; deliverReply re-validates.
123router.post('/posts/upload-reply-media', requireSiteManager, (req, res) => {
124 replyMediaUpload.single('media')(req, res, (err) => {
125 if (err) return res.status(400).json({ error: err.message });
126 if (!req.file) return res.status(400).json({ error: 'No file' });
127 const mime = String(req.file.mimetype || '');
128 if (!/^(image|audio|video)\//.test(mime)) {
129 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
130 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
131 }
132 res.json({
133 url: '/media/reply-media/' + req.file.filename,
134 mediaType: mime,
135 name: String(req.file.originalname || '').slice(0, 120),
136 });
137 });
138});
139
140const RESERVED_SLUGS = new Set([
141 'auth', 'admin', 'login', 'register', 'logout',
142 'archive', 'search', 'account', 'sites', 'comments',
143 'posts', 'media', 'audio', 'forum',
144 'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml',
145 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
146 'authorize_interaction', 'fediverse', 'news', 'following', 'notifications', 'blocking',
147]);
148
149/**
150 * Parse the form's `pinned` field into a non-negative integer rank.
151 * Empty / undefined / NaN / negative → 0 (= not pinned).
152 * Otherwise: integer rank (1 = top of pinned stack, 2 = below, ...).
153 *
154 * Multiple posts CAN share the same rank — UI shows them tiebroken by
155 * published_at DESC. Saying #2 twice doesn't error, it just duplicates.
156 * (We don't enforce uniqueness at this layer because race conditions and
157 * "swap two ranks" workflows are easier without a UNIQUE constraint.)
158 */
159function parsePinnedRank(raw) {
160 const n = parseInt(raw, 10);
161 if (!Number.isFinite(n) || n < 0) return 0;
162 return n;
163}
164
165// Poll durations offered in the editor (seconds) — the Mastodon set (5m … 7d).
166const POLL_DURATIONS = new Set([300, 1800, 3600, 21600, 43200, 86400, 259200, 604800]);
167// Parse the editor's poll fields into the poll_json we store on the post (which
168// buildNote federates as an AS2 Question). Returns null when no valid poll (< 2
169// options or the poll checkbox is off). endTime is set from the chosen duration
170// (default 1 day) so the Scheduler can close it.
171function parsePollForm(body) {
172 if (!body || !body.poll_enabled) return null;
173 const raw = body.poll_option == null ? [] : (Array.isArray(body.poll_option) ? body.poll_option : [body.poll_option]);
174 const options = [];
175 const seen = new Set();
176 for (const o of raw) {
177 const name = String(o == null ? '' : o).trim().slice(0, 100);
178 if (!name) continue;
179 const key = name.toLowerCase();
180 if (seen.has(key)) continue; seen.add(key);
181 options.push({ name });
182 if (options.length >= 8) break;
183 }
184 if (options.length < 2) return null;
185 const dur = parseInt(body.poll_duration, 10);
186 const secs = POLL_DURATIONS.has(dur) ? dur : 86400;
187 return JSON.stringify({ multiple: !!body.poll_multiple, options, endTime: new Date(Date.now() + secs * 1000).toISOString(), closed: false });
188}
189
190// ==================== HOME (Posts list) ====================
191router.get('/', (req, res) => {
192 const site = res.locals.site;
193
194 if (!site) {
195 return renderPage(req, res, 'pages/welcome', {
196 pageTitle: 'Welcome',
197 bodyClass: 'on-special',
198 });
199 }
200
201 // Pinned first — ordered by their rank (1 = top, 2 = below, etc).
202 // pinned column is now an integer rank: 0 = not pinned, 1+ = pinned at
203 // that position. Older boolean usage where pinned was always 1 still
204 // works because integer ranks 1, 2, 3 sort the same as a flat 1.
205 const pinnedPosts = db.prepare(`
206 SELECT p.*, u.username as author_username
207 FROM posts p JOIN users u ON p.author_id = u.id
208 WHERE p.site_id = ? AND p.status = 'published' AND p.pinned > 0
209 ORDER BY p.pinned ASC, p.published_at DESC
210 `).all(site.id);
211
212 // Regular posts: anything with pinned = 0
213 const posts = db.prepare(`
214 SELECT p.*, u.username as author_username
215 FROM posts p JOIN users u ON p.author_id = u.id
216 WHERE p.site_id = ? AND p.status = 'published' AND p.pinned = 0
217 ORDER BY p.published_at DESC
218 LIMIT 30
219 `).all(site.id);
220
221 recordPageview(site.id, req);
222
223 renderPage(req, res, 'pages/home', {
224 pinnedPosts,
225 posts,
226 pageTitle: site.title,
227 socialDescr: site.description || site.tagline || '',
228 bodyClass: 'on-home',
229 });
230});
231
232// ==================== NEW POST FORM ====================
233router.get('/posts/new', requireAuth, (req, res) => {
234 const site = res.locals.site;
235 if (!site) return res.status(404).send('Site required');
236 if (!PermissionsService.canCreatePost(req.session.user, site)) {
237 return res.status(403).send('No permission');
238 }
239
240 renderPage(req, res, 'pages/post-edit', {
241 post: {
242 id: uuid(),
243 title: '', slug: '', content: '', excerpt: '',
244 status: 'draft', pinned: 0, tags: [],
245 cover_image_url: '',
246 },
247 isNew: true,
248 pageTitle: 'New post',
249 bodyClass: 'on-special',
250 });
251});
252
253// ==================== CREATE POST ====================
254// ── Per-post audio federation ──────────────────────────────────────────────
255// "Share audio on the fediverse" is a per-post choice in the editor, but the underlying
256// flag is per track (audio_tracks.fedi_open — it gates the file + drives the AS2 Audio
257// attachment). NB: the file gate is per file, so opening a track in one post makes its file
258// fetchable for every post that reuses it.
259// ONE-WAY: opening is permanent. Once the file has federated it's out there — re-gating
260// would be false security (remote copies keep the URL), so we never write fedi_open back to 0.
261function setAudioFediOpen(siteId, content, open) {
262 if (!open) return; // never close — see one-way note above
263 const c = content || '';
264 try {
265 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);
266 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());
267 for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE id IN (SELECT track_id FROM playlist_tracks WHERE playlist_id = ?)').run(m[1]);
268 } catch { /* non-fatal */ }
269}
270// True when the post references hosted audio AND all of it is currently fedi_open (drives the
271// editor checkbox's initial state).
272function postAudioFediOpen(siteId, content) {
273 const c = content || '';
274 if (!/\[\[(track|album|playlist):/i.test(c)) return false;
275 let total = 0, open = 0;
276 const tally = (r) => { if (r && r.media_id) { total++; if (r.fedi_open) open++; } };
277 try {
278 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));
279 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);
280 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);
281 } catch { /* non-fatal */ }
282 return total > 0 && open === total;
283}
284
285// Bake + cache a post's display HTML (ActivityPub `source` model): `content` stays the raw
286// source (used by the editor + re-rendering), content_rendered holds the linkified render the
287// page serves. Called after every create/edit. Non-fatal: the render route falls back to
288// baking on the fly if this ever fails.
289function cacheRenderedContent(postId, rawContent) {
290 const raw = rawContent || '';
291 // 1. Immediate + synchronous: bake #hashtags + URLs so the post renders enriched at once.
292 try {
293 db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?')
294 .run(ActivityPubService.bakePostContent(raw), postId);
295 } catch (e) { /* fallback bake in the render route keeps display correct */ }
296 // 2. Async: resolve @mentions (webfinger, once) and re-store, WITHOUT blocking the save
297 // response — a moment later the post's @mentions are clickable too. A slow/dead remote
298 // server can't stall the save; on failure the sync bake from step 1 stands.
299 ActivityPubService.bakePostContentWithMentions(raw)
300 .then((html) => {
301 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(html, postId); }
302 catch (e) { /* keep the sync bake */ }
303 })
304 .catch(() => { /* keep the sync bake */ });
305}
306
307router.post('/posts/create', requireAuth, (req, res) => {
308 const site = res.locals.site;
309 if (!site || !PermissionsService.canCreatePost(req.session.user, site)) {
310 return res.status(403).send('No permission');
311 }
312
313 const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
314 const fanOnly = req.body.fan_only ? 1 : 0;
315 const nsfw = req.body.nsfw ? 1 : 0;
316 const cw = (req.body.content_warning || '').trim().slice(0, 200);
317 const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
318 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
319
320 // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
321 // before storage. Shortcode text tokens like [[track:UUID]] live in text
322 // nodes and pass through untouched.
323 const cleanContent = HtmlSanitizerService.sanitize(content || '');
324
325 // Generate slug from title if empty
326 let finalSlug = (slug || title || '')
327 .toLowerCase()
328 .replace(/[^a-z0-9]+/g, '-')
329 .replace(/^-|-$/g, '');
330
331 if (!finalSlug) return res.status(400).send('Title or slug required');
332 if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
333
334 // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
335 finalSlug = uniqueSlug(site.id, finalSlug);
336
337 const validTypes = new Set(['post', 'foto', 'video', 'audio']);
338 const finalType = validTypes.has(type) ? type : 'post';
339 const pollJson = parsePollForm(req.body); // AS2 Question definition, or null
340 const postId = uuid();
341 const now = new Date().toISOString();
342 let finalStatus = status || 'draft';
343 let publishedAt = finalStatus === 'published' ? now : null;
344 // Release planning: published + a future publish_at -> 'scheduled'
345 // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
346 let publishAt = null;
347 const pa = Date.parse(req.body.publish_at || '');
348 if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
349 finalStatus = 'scheduled';
350 publishAt = new Date(pa).toISOString();
351 publishedAt = null;
352 }
353
354 db.prepare(`
355 INSERT INTO posts (
356 id, site_id, slug, author_id, title, content, excerpt,
357 status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
358 created_at, updated_at, published_at
359 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
360 `).run(
361 postId, site.id, finalSlug, req.session.user.id,
362 title || finalSlug, cleanContent, excerpt || '',
363 finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
364 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
365 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
366 now, now, publishedAt
367 );
368 cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
369
370 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
371 // BEFORE federating, so the Create note carries the right Audio attachments.
372 setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
373
374 if (finalStatus === 'published') {
375 try {
376 db.prepare(
377 'INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'
378 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId);
379 } catch (e) { /* FTS index issues are non-fatal */ }
380
381 // ActivityPub: federate a freshly published post to followers. fan_only → delivered
382 // to followers but addressed followers-only (option A: "fans" = your fedi followers).
383 if (status === 'published') {
384 ActivityPubService.deliverCreate(site, {
385 id: postId, slug: finalSlug, title: title || finalSlug,
386 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
387 published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
388 }).catch(() => { /* best-effort */ });
389 }
390 }
391
392 // HTMX request -> return redirect header
393 if (req.headers['hx-request']) {
394 res.setHeader('HX-Redirect', `${res.locals.siteUrlBase || ''}/${finalSlug}`);
395 return res.send('OK');
396 }
397
398 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
399});
400
401// ==================== EDIT POST FORM ====================
402router.get('/posts/:slug/edit', requireAuth, (req, res) => {
403 const site = res.locals.site;
404 if (!site) return res.status(404).send('Site required');
405
406 const post = db.prepare(
407 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
408 ).get(site.id, req.params.slug);
409
410 if (!post) return res.status(404).send('Post not found');
411 if (!PermissionsService.canEditPost(req.session.user, post, site)) {
412 return res.status(403).send('No permission');
413 }
414
415 if (post.tags) {
416 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
417 } else {
418 post.tags = [];
419 }
420
421 // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
422 let pollLocked = false;
423 try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
424
425 renderPage(req, res, 'pages/post-edit', {
426 post,
427 isNew: false,
428 pollLocked,
429 fediOpenAudio: postAudioFediOpen(site.id, post.content),
430 pageTitle: 'Edit: ' + (post.title || 'Untitled'),
431 bodyClass: 'on-special',
432 });
433});
434
435// ==================== SAVE POST ====================
436router.post('/posts/:slug/save', requireAuth, (req, res) => {
437 const site = res.locals.site;
438 if (!site) return res.status(404).send('Site required');
439
440 const post = db.prepare(
441 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
442 ).get(site.id, req.params.slug);
443
444 if (!post) return res.status(404).send('Post not found');
445 if (!PermissionsService.canEditPost(req.session.user, post, site)) {
446 return res.status(403).send('No permission');
447 }
448
449 const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
450 const fanOnly = req.body.fan_only ? 1 : 0;
451 const nsfw = req.body.nsfw ? 1 : 0;
452 const cw = (req.body.content_warning || '').trim().slice(0, 200);
453 const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
454 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
455 const newSlug = req.body.slug;
456 const action = req.body.action || 'save';
457 const validTypes = new Set(['post', 'foto', 'video', 'audio']);
458 const finalType = validTypes.has(type) ? type : (post.type || 'post');
459
460 // A poll that has already received votes is frozen (you can still edit the surrounding
461 // post, but not the options) — changing options after votes would scramble the tally and
462 // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
463 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; } })());
464 const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
465
466 // Sanitize before storage — same pipeline as create.
467 const cleanContent = HtmlSanitizerService.sanitize(content || '');
468
469 let finalSlug = post.slug;
470 if (newSlug && newSlug !== post.slug) {
471 const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
472 const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
473 // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
474 finalSlug = uniqueSlug(site.id, safe, post.id);
475 }
476
477 const now = new Date().toISOString();
478 let finalStatus = status || post.status;
479 let publishedAt = post.published_at;
480
481 if (action === 'publish') {
482 finalStatus = 'published';
483 if (!publishedAt) publishedAt = now;
484 }
485
486 // Release planning: published + future publish_at -> 'scheduled'.
487 let publishAt = null;
488 const pa = Date.parse(req.body.publish_at || '');
489 if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
490 finalStatus = 'scheduled';
491 publishAt = new Date(pa).toISOString();
492 publishedAt = null;
493 }
494
495 db.prepare(`
496 UPDATE posts SET
497 title = ?, content = ?, excerpt = ?, status = ?,
498 cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
499 type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
500 slug = ?, published_at = ?, updated_at = ?
501 WHERE id = ?
502 `).run(
503 title, cleanContent, excerpt, finalStatus,
504 cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
505 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
506 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
507 finalSlug, publishedAt, now, post.id
508 );
509 cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
510
511 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
512 // BEFORE federating, so the Update/Create note carries the right Audio attachments.
513 setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
514
515 // Update FTS
516 try {
517 db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id);
518 if (finalStatus === 'published') {
519 db.prepare(
520 'INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'
521 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, post.id);
522 }
523 } catch (e) { /* FTS issues non-fatal */ }
524
525 // ActivityPub: federate edits to followers. A post that BECOMES published →
526 // Create (new post); an already-published post that's edited → Update (so
527 // Mastodon refreshes its cached copy). fan_only → followers-only (option A).
528 if (finalStatus === 'published') {
529 const apPost = {
530 id: post.id, slug: finalSlug, title: title || finalSlug,
531 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
532 published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
533 };
534 if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
535 else ActivityPubService.deliverUpdate(site, apPost).catch(() => { /* best-effort */ });
536 }
537
538 // Pin/unpin/reorder → push Add/Remove activities so followers' instances update the
539 // pinned order immediately (reliable, unlike re-fetching the cached featured collection).
540 if ((post.pinned || 0) !== parsePinnedRank(pinned)) {
541 const unpinned = (post.pinned || 0) > 0 && parsePinnedRank(pinned) === 0 ? [post.id] : [];
542 ActivityPubService.resyncFeaturedPins(site, unpinned).catch(() => { /* best-effort */ });
543 }
544
545 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
546});
547
548// ==================== DELETE POST ====================
549router.post('/posts/:slug/delete', requireAuth, (req, res) => {
550 const site = res.locals.site;
551 if (!site) return res.status(404).send('Site required');
552
553 const post = db.prepare(
554 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
555 ).get(site.id, req.params.slug);
556
557 if (!post) return res.status(404).send('Not found');
558 if (!PermissionsService.canDeletePost(req.session.user, post, site)) {
559 return res.status(403).send('No permission');
560 }
561
562 // ActivityPub: tell followers the post is gone (Delete + Tombstone) if it was
563 // federated (any published post now federates — fan_only goes followers-only).
564 // Fire before the row is removed — we still have post.id (= the Note id).
565 if (post.status === 'published') {
566 ActivityPubService.deliverDelete(site, post).catch(() => { /* best-effort */ });
567 }
568
569 // Cascade: comments + FTS row, THEN the post itself.
570 // FK constraints are ON (config/database.js), so a bare DELETE on posts
571 // fails when comments still reference it.
572 const cascade = db.transaction(() => {
573 db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
574 try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch {}
575 db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
576 });
577 cascade();
578
579 if (req.headers['hx-request']) {
580 res.setHeader('HX-Redirect', res.locals.siteUrlBase || '/');
581 return res.send('OK');
582 }
583 res.redirect(res.locals.siteUrlBase || '/');
584});
585
586// ==================== ARCHIVE ====================
587router.get('/archive', (req, res) => {
588 const site = res.locals.site;
589 if (!site) return res.status(404).send('No site');
590
591 const posts = db.prepare(`
592 SELECT p.*, u.username as author_username
593 FROM posts p JOIN users u ON p.author_id = u.id
594 WHERE p.site_id = ? AND p.status = 'published'
595 ORDER BY p.published_at DESC
596 `).all(site.id);
597
598 // Group by year/month
599 const grouped = {};
600 for (const post of posts) {
601 if (!post.published_at) continue;
602 const d = new Date(post.published_at);
603 const year = d.getFullYear();
604 const month = d.getMonth();
605 const monthName = ['januari','februari','maart','april','mei','juni','juli','augustus','september','oktober','november','december'][month];
606
607 if (!grouped[year]) grouped[year] = {};
608 if (!grouped[year][monthName]) grouped[year][monthName] = [];
609 grouped[year][monthName].push(post);
610 }
611
612 renderPage(req, res, 'pages/archive', {
613 grouped,
614 totalPosts: posts.length,
615 pageTitle: 'Archive - ' + site.title,
616 bodyClass: 'on-archive',
617 });
618});
619
620// Local likes/favourites are removed — engagement is fediverse-only now
621// (the ⭐ on a post likes via the fediverse). No post_likes, no /favorieten.
622
623// Newer/Older neighbours across ALL posts in feed order. Shared by the full
624// post render and the fan gate (premium fan_only) so navigation is consistent
625// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
626function postNeighbors(site, post, isHub) {
627 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
628 const ordered = isHub
629 ? db.prepare(`
630 SELECT p.id, p.slug, p.title, p.pinned, s.slug AS site_slug
631 FROM posts p JOIN sites s ON s.id = p.site_id
632 WHERE p.status = 'published'
633 ORDER BY p.published_at DESC
634 `).all()
635 : db.prepare(`
636 SELECT id, slug, title, pinned FROM posts
637 WHERE site_id = ? AND status = 'published'
638 ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
639 `).all(site.id);
640 const idx = ordered.findIndex((p) => p.id === post.id);
641 const newerPost = idx > 0 ? ordered[idx - 1] : null;
642 const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
643 if (newerPost) newerPost._urlBase = urlBaseFor(newerPost);
644 if (olderPost) olderPost._urlBase = urlBaseFor(olderPost);
645 return { newerPost, olderPost };
646}
647
648// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
649// Standard fediverse "reply from your own server" landing endpoint. A post page
650// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
651// composes a reply that federates back to that post.
652router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
653 const site = res.locals.site;
654 const uri = (req.query.uri || '').toString();
655 const sent = !!req.query.sent;
656 const followed = !!req.query.followed;
657 const voted = !!req.query.voted;
658 const reported = !!req.query.reported;
659 let target = null, followTarget = null;
660 if (!sent && !followed && !voted && !reported && uri) {
661 try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
662 // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
663 if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
664 }
665 renderPage(req, res, 'pages/authorize-interaction', {
666 pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
667 bodyClass: 'on-special',
668 uri,
669 target,
670 followTarget,
671 sent,
672 followed,
673 voted: !!req.query.voted,
674 reported: !!req.query.reported,
675 liked: !!req.query.liked,
676 boosted: !!req.query.boosted,
677 reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
678 siteTitle: site ? site.title : '',
679 });
680});
681
682// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
683// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
684router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
685 const site = res.locals.site;
686 const uri = (req.body.uri || '').toString();
687 let choice = req.body.choice;
688 if (choice == null) choice = [];
689 if (!Array.isArray(choice)) choice = [choice];
690 if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
691 res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
692});
693
694// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
695router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
696 const site = res.locals.site;
697 const uri = (req.body.uri || '').toString();
698 const actorUri = (req.body.actor_uri || '').toString();
699 const reason = (req.body.reason || '').toString();
700 if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
701 res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
702});
703
704// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
705router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
706 const site = res.locals.site;
707 const uri = (req.body.uri || '').toString();
708 let on = false;
709 if (site && uri) {
710 on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
711 ActivityPubService.resolveRemoteNote(uri)
712 .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
713 .catch((e) => console.warn('[AP] remote like failed:', e.message));
714 ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
715 }
716 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
717 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
718});
719
720// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
721// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
722router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
723 const site = res.locals.site;
724 const uri = (req.body.uri || '').toString();
725 let on = false;
726 if (site && uri) {
727 on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
728 ActivityPubService.resolveRemoteNote(uri)
729 .then((note) => {
730 if (!note) return;
731 const id = note.object_uri || uri;
732 return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
733 // Boost → store the post in the timeline (even if you don't follow the author) so it
734 // surfaces in the Cirkel; unboost → just clear the flag.
735 .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
736 })
737 .catch((e) => console.warn('[AP] remote boost failed:', e.message));
738 ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
739 }
740 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
741 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
742});
743
744// Follow a remote actor from your own site (when the target is a profile, not a post).
745router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
746 const site = res.locals.site;
747 const uri = (req.body.uri || '').toString();
748 if (site && uri) {
749 ActivityPubService.followActor(site, uri)
750 .catch((e) => console.warn('[AP] remote follow failed:', e.message));
751 }
752 res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
753});
754
755router.post('/authorize_interaction', requireSiteManager, (req, res) => {
756 const site = res.locals.site;
757 const uri = (req.body.uri || '').toString();
758 const text = (req.body.text || '').toString();
759 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
760 const language = (req.body.language || '').toString();
761 let attachments = [];
762 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
763 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
764 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
765 if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
766 // Resolve + deliver in the background so Send responds instantly.
767 ActivityPubService.resolveRemoteNote(uri)
768 .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
769 .catch((e) => console.warn('[AP] remote reply failed:', e.message));
770 }
771 res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
772});
773
774// Manage / delete your own outbound fediverse replies (site owner only).
775// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
776// The old /fediverse (manage) and /notifications pages redirect here.
777router.get('/messages', requireSiteManager, (req, res) => {
778 const site = res.locals.site;
779 const items = site ? ActivityPubService.getMessages(site.slug, 80) : [];
780 // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
781 const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
782 if (site && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
783 renderPage(req, res, 'pages/messages', {
784 pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
785 success: req.query.success || null, error: req.query.error || null,
786 });
787});
788router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
789
790router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
791 const site = res.locals.site;
792 if (site) {
793 try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
794 catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
795 }
796 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
797});
798
799// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
800// object URI so re-delivery and thread-crawling never bring it back. Works for private
801// notes too (acts on the local copy; no remote fetch involved).
802router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
803 const site = res.locals.site;
804 if (site) {
805 const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
806 if (r.error) console.warn('[AP] interaction remove failed:', r.error);
807 }
808 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
809});
810
811// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
812// locally stored object/actor URIs, so it also works for private notes that
813// authorize_interaction cannot fetch (401/404).
814router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
815 const site = res.locals.site;
816 if (site) {
817 const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
818 if (tgt && (tgt.objectUri || tgt.actorUri)) {
819 try {
820 const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
821 if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
822 } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
823 }
824 }
825 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
826});
827
828// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
829router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
830 const site = res.locals.site;
831 const text = String(req.body.text || '');
832 const html = String(req.body.content || ''); // rich reply editor HTML (sanitized in deliverOutboxUpdate)
833 if (site && (text.trim() || html.trim())) {
834 try {
835 await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
836 html, language: String(req.body.language || ''),
837 });
838 } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
839 }
840 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
841});
842
843// ==================== FEDIVERSE CLIENT: home timeline + following ====================
844// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
845// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
846function timelineEmbedHtml(html) {
847 if (!html) return null;
848 const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
849 while ((m = re.exec(html))) {
850 const u = m[1]; if (seen.has(u)) continue; seen.add(u);
851 let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
852 if (!p) {
853 // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
854 // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
855 // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
856 const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
857 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>`;
858 continue;
859 }
860 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>`;
861 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>`;
862 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>`;
863 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>`;
864 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>`;
865 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>`; }
866 }
867 return null;
868}
869
870// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
871// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
872// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
873function klonktAudioEmbed(html, url) {
874 if (!html || !url || html.indexOf('🎵') < 0) return null;
875 let u; try { u = new URL(url); } catch { return null; }
876 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
877 const slug = u.pathname.replace(/^\/+|\/+$/g, '');
878 if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
879 const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
880 // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
881 const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
882 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>` };
883}
884
885router.get('/news', requireSiteManager, (req, res) => {
886 const site = res.locals.site;
887 const cspOrigins = new Set();
888 const timeline = (site ? ActivityPubService.getTimeline(site.slug, 60) : []).map((p) => {
889 let embedHtml = timelineEmbedHtml(p.content);
890 let content = p.content;
891 let embedUrl = null;
892 if (!embedHtml) {
893 const k = klonktAudioEmbed(p.content, p.url);
894 if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
895 }
896 // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
897 // top-level "open the player" link that works even when a browser shield/CSP blocks
898 // the cross-site iframe (a full-page navigation is not a cross-site frame).
899 let poll = null;
900 if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
901 return { ...p, content, embedHtml, embedUrl, poll };
902 });
903 // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
904 // extending ONLY this response's CSP frame-src. The global policy stays locked down.
905 if (cspOrigins.size) {
906 const csp = res.getHeader('Content-Security-Policy');
907 if (csp) {
908 const extra = [...cspOrigins].join(' ');
909 res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
910 }
911 }
912 renderPage(req, res, 'pages/news', {
913 pageTitle: 'News', bodyClass: 'on-special',
914 timeline,
915 success: req.query.success || null, error: req.query.error || null,
916 });
917});
918
919// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
920// Connect = who you follow + who follows you, merged into one page with direction
921// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
922// separate Following/Followers pages, which redirect here so old links keep working.
923router.get('/connect', requireSiteManager, (req, res) => {
924 const site = res.locals.site;
925 const connections = site ? ActivityPubService.listConnections(site.slug) : [];
926 renderPage(req, res, 'pages/connect', {
927 pageTitle: 'Connect', bodyClass: 'on-special',
928 connections,
929 success: req.query.success || null, error: req.query.error || null,
930 });
931});
932router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
933router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
934
935router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
936 const site = res.locals.site;
937 const base = res.locals.siteUrlBase || '';
938 if (!site) return res.redirect(`${base}/connect`);
939 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
940 return res.redirect(`${base}/connect?` + (ok
941 ? 'success=' + encodeURIComponent('Volger verwijderd')
942 : 'error=' + encodeURIComponent('Volger niet gevonden')));
943});
944
945router.post('/news/follow', requireSiteManager, async (req, res) => {
946 const site = res.locals.site;
947 const handle = (req.body.handle || '').toString();
948 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
949 if (site && handle.trim()) {
950 try {
951 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
952 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
953 else {
954 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
955 }
956 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
957 }
958 res.redirect('/following?' + q);
959});
960
961router.post('/news/unfollow', requireSiteManager, async (req, res) => {
962 const site = res.locals.site;
963 const actorUri = (req.body.actor_uri || '').toString();
964 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
965 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
966});
967
968// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
969router.post('/news/autoboost', requireSiteManager, (req, res) => {
970 const site = res.locals.site;
971 const actorUri = (req.body.actor_uri || '').toString();
972 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
973 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
974});
975
976// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
977// no banner); no-JS → redirect back.
978router.post('/news/like', requireSiteManager, async (req, res) => {
979 const site = res.locals.site;
980 const note = (req.body.note || '').toString();
981 let on = false;
982 if (site && note) {
983 on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
984 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
985 if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
986 }
987 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
988 res.redirect('/news');
989});
990
991// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
992router.post('/news/boost', requireSiteManager, async (req, res) => {
993 const site = res.locals.site;
994 const note = (req.body.note || '').toString();
995 let on = false;
996 if (site && note) {
997 on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
998 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
999 if (on) {
1000 ActivityPubService.markBoosted(site.slug, note); // instant UI state
1001 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1002 // (cover/content) — boosting again heals a stale copy from EVERY boost
1003 // path, not just the interact page.
1004 ActivityPubService.resolveRemoteNote(note)
1005 .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
1006 .catch(() => { /* best-effort */ });
1007 } else {
1008 ActivityPubService.unmarkBoosted(site.slug, note);
1009 }
1010 }
1011 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1012 res.redirect('/news');
1013});
1014
1015// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1016router.post('/news/vote', requireSiteManager, async (req, res) => {
1017 const site = res.locals.site;
1018 const note = (req.body.note || '').toString();
1019 let choice = req.body.choice;
1020 if (choice == null) choice = [];
1021 if (!Array.isArray(choice)) choice = [choice];
1022 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1023 res.redirect('/news');
1024});
1025
1026// Notifications inbox (new followers + replies/likes/boosts on your posts).
1027router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1028
1029// Blocking / defederation (owner-only).
1030router.get('/blocking', requireSiteManager, (req, res) => {
1031 const site = res.locals.site;
1032 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1033 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1034});
1035
1036router.post('/blocking/add', requireSiteManager, async (req, res) => {
1037 const site = res.locals.site;
1038 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1039 if (site) {
1040 try {
1041 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1042 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1043 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1044 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1045 }
1046 const ref = req.get('Referer') || '';
1047 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1048});
1049
1050router.post('/blocking/remove', requireSiteManager, (req, res) => {
1051 const site = res.locals.site;
1052 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
1053 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1054});
1055
1056// ==================== VIEW POST (last route — catches /:slug) ====================
1057router.get('/:slug', (req, res, next) => {
1058 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1059
1060 const site = res.locals.site;
1061 if (!site) return next(); // -> nette 404 catch-all
1062
1063 const post = db.prepare(`
1064 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1065 FROM posts p JOIN users u ON p.author_id = u.id
1066 WHERE p.site_id = ? AND p.slug = ?
1067 `).get(site.id, req.params.slug);
1068
1069 if (!post) return next(); // unknown slug -> clean 404 catch-all
1070
1071 // Permission to view: published OR (logged in + can edit)
1072 if (post.status !== 'published') {
1073 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1074 if (!canEdit) return res.status(403).send('Not published');
1075 }
1076
1077 // Fan-only preview (premium #3): full content only for logged-in fans.
1078 // Anonymous visitors get a clean login gate instead of the content (the title/
1079 // teaser may still appear elsewhere as a teaser).
1080 if (post.fan_only && !(req.session && req.session.user)) {
1081 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1082 // stuck on the fan gate but can keep browsing.
1083 const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
1084 return renderPage(req, res, 'pages/fan-gate', {
1085 pageTitle: post.title || 'Alleen voor fans',
1086 bodyClass: 'on-special',
1087 fgTitle: post.title || '',
1088 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1089 newerPost,
1090 olderPost,
1091 });
1092 }
1093
1094 // Statistics: count the view (skips admins + unpublished own-preview).
1095 if (post.status === 'published') recordPostView(post, req);
1096
1097 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1098 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1099 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1100 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1101 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1102 let html = (post.content_rendered != null && post.content_rendered !== '')
1103 ? post.content_rendered
1104 : ActivityPubService.bakePostContent(post.content || '');
1105 if (audioEnabled()) {
1106 if (site.enable_audio_player !== 0) {
1107 html = AudioEmbedService.autoembed(html);
1108 html = AudioEmbedService.embedMediaShortcodes(html);
1109 html = AudioEmbedService.embedExternalLinkShortcodes(html);
1110
1111 // Fetch any tracks referenced by [[track:id]] in this post.
1112 // Cheap to do unconditionally — only matches if the post actually has shortcodes.
1113 const trackIds = [...html.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)].map(m => m[1]);
1114 if (trackIds.length) {
1115 const placeholders = trackIds.map(() => '?').join(',');
1116 const rows = db.prepare(`
1117 SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
1118 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
1119 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
1120 WHERE t.site_id = ? AND t.id IN (${placeholders})
1121 `).all(site.id, ...trackIds);
1122 const byId = new Map(rows.map(r => [r.id, r]));
1123 html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
1124 const r = byId.get(id);
1125 if (!r) return null;
1126 return {
1127 id: r.id,
1128 title: r.title,
1129 artist: r.artist,
1130 cover: r.cover_url,
1131 credit: r.credit || '',
1132 license: r.license || '',
1133 link_spotify: r.link_spotify || '',
1134 link_youtube: r.link_youtube || '',
1135 link_soundcloud: r.link_soundcloud || '',
1136 url: r.filename ? audioUrl(r.filename) : '', // '' = link-only track
1137 };
1138 });
1139 }
1140
1141 // Album shortcodes: [[album:Some Album Name]]
1142 const albumNames = [...html.matchAll(/\[\[album:([^\]]+)\]\]/g)].map(m => m[1].trim());
1143 if (albumNames.length) {
1144 const placeholders = albumNames.map(() => '?').join(',');
1145 const albumRows = db.prepare(`
1146 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
1147 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
1148 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
1149 WHERE t.site_id = ? AND t.album IN (${placeholders})
1150 ORDER BY t.position ASC, t.created_at ASC
1151 `).all(site.id, ...albumNames);
1152 const byAlbum = new Map();
1153 for (const r of albumRows) {
1154 // Link-only tracks (no file) remain in the album overview (url '').
1155 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
1156 byAlbum.get(r.album).push({
1157 id: r.id,
1158 url: r.filename ? audioUrl(r.filename) : '',
1159 title: r.title || 'Untitled',
1160 artist: r.artist || '',
1161 cover: r.cover_url || '',
1162 link_spotify: r.link_spotify || '',
1163 link_youtube: r.link_youtube || '',
1164 link_soundcloud: r.link_soundcloud || '',
1165 });
1166 }
1167 html = AudioEmbedService.embedAlbumShortcodes(html, (name) => {
1168 const tracks = byAlbum.get(name);
1169 if (!tracks || !tracks.length) return null;
1170 return {
1171 title: name,
1172 artist: tracks[0].artist || '',
1173 cover: tracks[0].cover || '',
1174 tracks,
1175 };
1176 });
1177 }
1178
1179 // Playlist shortcodes: [[playlist:some-slug-id]] — first-class entity.
1180 // Editing the playlist propagates to every post that embeds it.
1181 const playlistIds = [...html.matchAll(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi)]
1182 .map(m => m[1].toLowerCase());
1183 if (playlistIds.length) {
1184 const isAdmin = req.session?.user?.role === 'god';
1185 html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
1186 return PlaylistService.get(site.id, id, audioUrl);
1187 }, { isAdmin });
1188 }
1189 }
1190 } else {
1191 // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
1192 // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
1193 // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
1194 html = AudioEmbedService.autoembed(html);
1195 html = AudioEmbedService.embedMediaShortcodes(html);
1196 html = AudioEmbedService.embedExternalLinkShortcodes(html);
1197 html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1198 }
1199 // (linkify is baked into content_rendered at save now, not re-run here.)
1200 post.content_html = html;
1201
1202 if (post.tags) {
1203 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1204 } else {
1205 post.tags = [];
1206 }
1207
1208 // Native comments removed: social interaction is fediverse-only (see the
1209 // "From the fediverse" section below).
1210
1211 // Prev / next chronological (kept for back-compat — "post-nav" feature
1212 // below the article still uses these as a simple linear navigation).
1213 // Hub mode: Related posts + Newer/Older pull from ALL users (all sites),
1214 // newest first. Solo mode: within the current site (old behaviour).
1215 const isHub = res.locals.tenancy === 'hub';
1216 // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>.
1217 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
1218
1219 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1220 const { newerPost, olderPost } = postNeighbors(site, post, isHub);
1221
1222 // ── Related posts: same-tag matching with recency fallback ─────
1223 // Fetch ~50 candidates, score by tag overlap, take top 3.
1224 // Excluding self via `id != ?`.
1225 const candidates = isHub
1226 ? db.prepare(`
1227 SELECT p.id, p.slug, p.title, p.cover_image_url, p.cover_video_url, p.published_at, p.tags, p.nsfw, p.content_warning, s.slug AS site_slug
1228 FROM posts p JOIN sites s ON s.id = p.site_id
1229 WHERE p.status = 'published' AND p.id != ?
1230 ORDER BY p.published_at DESC LIMIT 50
1231 `).all(post.id)
1232 : db.prepare(`
1233 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1234 FROM posts
1235 WHERE site_id = ? AND status = 'published' AND id != ?
1236 ORDER BY published_at DESC LIMIT 50
1237 `).all(site.id, post.id);
1238
1239 // Parse tags JSON safely; missing/malformed → empty array.
1240 const parseTags = (raw) => {
1241 if (!raw) return [];
1242 try {
1243 const v = JSON.parse(raw);
1244 return Array.isArray(v) ? v.map(String) : [];
1245 } catch { return []; }
1246 };
1247
1248 const myTags = new Set(parseTags(post.tags));
1249 let relatedPosts;
1250 if (myTags.size > 0) {
1251 // Score = number of overlapping tags. Posts with zero overlap are
1252 // included only if we don't have 3 with-overlap candidates.
1253 const scored = candidates.map(p => {
1254 const theirTags = parseTags(p.tags);
1255 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1256 return { ...p, _overlap: overlap };
1257 });
1258 const withOverlap = scored.filter(p => p._overlap > 0)
1259 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1260 if (withOverlap.length >= 3) {
1261 relatedPosts = withOverlap.slice(0, 3);
1262 } else {
1263 // Pad with most-recent non-overlap posts so the section is never empty
1264 const overlapIds = new Set(withOverlap.map(p => p.id));
1265 const filler = candidates.filter(p => !overlapIds.has(p.id));
1266 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1267 }
1268 } else {
1269 // No tags on current post → just show 3 most-recent
1270 relatedPosts = candidates.slice(0, 3);
1271 }
1272 // Strip the internal _overlap field before sending to view
1273 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1274
1275 // Inbound fediverse activity (threaded) for this post.
1276 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1277 try {
1278 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1279 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1280 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1281 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1282 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1283 } catch { /* non-fatal */ }
1284 // Owner/admin of this site may reply back to a fediverse interaction.
1285 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1286 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1287 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1288
1289 renderPage(req, res, 'pages/post', {
1290 post,
1291 poll: ActivityPubService.ownPollView(post),
1292 newerPost,
1293 olderPost,
1294 relatedPosts,
1295 fediverse,
1296 canManageSite,
1297 siteAvatar,
1298 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1299 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1300 pageTitle: post.title + ' - ' + site.title,
1301 socialDescr: post.excerpt || '',
1302 socialImage: post.cover_image_url || '',
1303 bodyClass: 'on-post',
1304 });
1305});
1306
1307// ── Reply back to a fediverse interaction (site owner/admin only) ──
1308router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1309 const site = res.locals.site;
1310 if (!site) return res.status(404).send('Site required');
1311 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1312 if (!post) return res.status(404).send('Not found');
1313 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1314 const text = (req.body.text || '').toString();
1315 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1316 let attachments = [];
1317 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1318 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1319 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1320 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1321 try {
1322 await ActivityPubService.deliverReply(site, {
1323 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1324 language: (req.body.language || '').toString(),
1325 });
1326 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1327 }
1328 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1329});
1330
1331// Owner likes/boosts a fediverse comment on their own post — directly as the
1332// site, no "your server" detour (mirrors /fedi-reply).
1333router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1334 const site = res.locals.site;
1335 if (!site) return res.status(404).send('Site required');
1336 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1337 if (!post) return res.status(404).send('Not found');
1338 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1339 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1340 if (parent && parent.post_id === post.id && parent.object_uri) {
1341 if (kind === 'boost') {
1342 // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted.
1343 const on = !parent.acted_boost;
1344 ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri)
1345 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1346 ActivityPubService.setInteractionBoosted(parent.id, on);
1347 } else {
1348 // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked.
1349 const on = !parent.acted_like;
1350 ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri)
1351 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1352 ActivityPubService.setInteractionLiked(parent.id, on);
1353 }
1354 }
1355 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1356});
1357
1358export default router;
1359export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.