source: Klonkt/src/routes/posts.js@ 7b04d3b

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

Feature: Load more on News feed, page 72 (klonkt-demo-r9u, slice 1/4)

News (Krant) now pages in blocks of 72 (divisible by 2/3/4 so grid
columns stay full) with an htmx "Load more" button instead of a hard
60-item cap. getTimeline gains an offset arg; the route fetches 72+1 to
know whether the button belongs, and serves an append fragment
(partials/news-append) that swaps the next items beforeend into #tl-feed
and OOB-replaces the button with the next offset (or drops it on the
last page).

Reusable pieces for the other three views (Solo, Cirkel, Messages):

  • partials/load-more.ejs (the button + OOB wrapper)
  • partials/tl-item.ejs (extracted the timeline item so full page and append render identically)
  • .load-more-* CSS in shared-styles, i18n feed.load_more (NL/EN/DE)

Tests: feed-pagination.test.js (offset paging, no overlap, probe of
PAGE+1, past-end empty). Browser-verified: 72 -> 144 -> 150 then the
button disappears.

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

  • Property mode set to 100644
File size: 66.4 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
885const FEED_PAGE = 72; // divisible by 2/3/4 → every grid column count keeps full rows
886router.get('/news', requireSiteManager, (req, res) => {
887 const site = res.locals.site;
888 const append = req.query.append === '1';
889 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
890 const cspOrigins = new Set();
891 // Fetch one extra to know whether a "Load more" button belongs on this page.
892 const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];
893 const hasMore = rows.length > FEED_PAGE;
894 const timeline = rows.slice(0, FEED_PAGE).map((p) => {
895 let embedHtml = timelineEmbedHtml(p.content);
896 let content = p.content;
897 let embedUrl = null;
898 if (!embedHtml) {
899 const k = klonktAudioEmbed(p.content, p.url);
900 if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
901 }
902 // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
903 // top-level "open the player" link that works even when a browser shield/CSP blocks
904 // the cross-site iframe (a full-page navigation is not a cross-site frame).
905 let poll = null;
906 if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
907 return { ...p, content, embedHtml, embedUrl, poll };
908 });
909 // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
910 // extending ONLY this response's CSP frame-src. The global policy stays locked down.
911 if (cspOrigins.size) {
912 const csp = res.getHeader('Content-Security-Policy');
913 if (csp) {
914 const extra = [...cspOrigins].join(' ');
915 res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
916 }
917 }
918 const moreBase = res.locals.siteUrlBase || '';
919 if (append) {
920 return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
921 }
922 renderPage(req, res, 'pages/news', {
923 pageTitle: 'News', bodyClass: 'on-special',
924 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
925 success: req.query.success || null, error: req.query.error || null,
926 });
927});
928
929// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
930// Connect = who you follow + who follows you, merged into one page with direction
931// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
932// separate Following/Followers pages, which redirect here so old links keep working.
933router.get('/connect', requireSiteManager, (req, res) => {
934 const site = res.locals.site;
935 const connections = site ? ActivityPubService.listConnections(site.slug) : [];
936 renderPage(req, res, 'pages/connect', {
937 pageTitle: 'Connect', bodyClass: 'on-special',
938 connections,
939 success: req.query.success || null, error: req.query.error || null,
940 });
941});
942router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
943router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
944
945router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
946 const site = res.locals.site;
947 const base = res.locals.siteUrlBase || '';
948 if (!site) return res.redirect(`${base}/connect`);
949 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
950 return res.redirect(`${base}/connect?` + (ok
951 ? 'success=' + encodeURIComponent('Volger verwijderd')
952 : 'error=' + encodeURIComponent('Volger niet gevonden')));
953});
954
955router.post('/news/follow', requireSiteManager, async (req, res) => {
956 const site = res.locals.site;
957 const handle = (req.body.handle || '').toString();
958 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
959 if (site && handle.trim()) {
960 try {
961 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
962 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
963 else {
964 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
965 }
966 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
967 }
968 res.redirect('/following?' + q);
969});
970
971router.post('/news/unfollow', requireSiteManager, async (req, res) => {
972 const site = res.locals.site;
973 const actorUri = (req.body.actor_uri || '').toString();
974 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
975 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
976});
977
978// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
979router.post('/news/autoboost', requireSiteManager, (req, res) => {
980 const site = res.locals.site;
981 const actorUri = (req.body.actor_uri || '').toString();
982 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
983 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
984});
985
986// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
987// no banner); no-JS → redirect back.
988router.post('/news/like', requireSiteManager, async (req, res) => {
989 const site = res.locals.site;
990 const note = (req.body.note || '').toString();
991 let on = false;
992 if (site && note) {
993 on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
994 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
995 if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
996 }
997 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
998 res.redirect('/news');
999});
1000
1001// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
1002router.post('/news/boost', requireSiteManager, async (req, res) => {
1003 const site = res.locals.site;
1004 const note = (req.body.note || '').toString();
1005 let on = false;
1006 if (site && note) {
1007 on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
1008 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1009 if (on) {
1010 ActivityPubService.markBoosted(site.slug, note); // instant UI state
1011 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1012 // (cover/content) — boosting again heals a stale copy from EVERY boost
1013 // path, not just the interact page.
1014 ActivityPubService.resolveRemoteNote(note)
1015 .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
1016 .catch(() => { /* best-effort */ });
1017 } else {
1018 ActivityPubService.unmarkBoosted(site.slug, note);
1019 }
1020 }
1021 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1022 res.redirect('/news');
1023});
1024
1025// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1026router.post('/news/vote', requireSiteManager, async (req, res) => {
1027 const site = res.locals.site;
1028 const note = (req.body.note || '').toString();
1029 let choice = req.body.choice;
1030 if (choice == null) choice = [];
1031 if (!Array.isArray(choice)) choice = [choice];
1032 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1033 res.redirect('/news');
1034});
1035
1036// Notifications inbox (new followers + replies/likes/boosts on your posts).
1037router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1038
1039// Blocking / defederation (owner-only).
1040router.get('/blocking', requireSiteManager, (req, res) => {
1041 const site = res.locals.site;
1042 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1043 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1044});
1045
1046router.post('/blocking/add', requireSiteManager, async (req, res) => {
1047 const site = res.locals.site;
1048 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1049 if (site) {
1050 try {
1051 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1052 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1053 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1054 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1055 }
1056 const ref = req.get('Referer') || '';
1057 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1058});
1059
1060router.post('/blocking/remove', requireSiteManager, (req, res) => {
1061 const site = res.locals.site;
1062 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
1063 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1064});
1065
1066// ==================== VIEW POST (last route — catches /:slug) ====================
1067router.get('/:slug', (req, res, next) => {
1068 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1069
1070 const site = res.locals.site;
1071 if (!site) return next(); // -> nette 404 catch-all
1072
1073 const post = db.prepare(`
1074 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1075 FROM posts p JOIN users u ON p.author_id = u.id
1076 WHERE p.site_id = ? AND p.slug = ?
1077 `).get(site.id, req.params.slug);
1078
1079 if (!post) return next(); // unknown slug -> clean 404 catch-all
1080
1081 // Permission to view: published OR (logged in + can edit)
1082 if (post.status !== 'published') {
1083 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1084 if (!canEdit) return res.status(403).send('Not published');
1085 }
1086
1087 // Fan-only preview (premium #3): full content only for logged-in fans.
1088 // Anonymous visitors get a clean login gate instead of the content (the title/
1089 // teaser may still appear elsewhere as a teaser).
1090 if (post.fan_only && !(req.session && req.session.user)) {
1091 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1092 // stuck on the fan gate but can keep browsing.
1093 const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
1094 return renderPage(req, res, 'pages/fan-gate', {
1095 pageTitle: post.title || 'Alleen voor fans',
1096 bodyClass: 'on-special',
1097 fgTitle: post.title || '',
1098 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1099 newerPost,
1100 olderPost,
1101 });
1102 }
1103
1104 // Statistics: count the view (skips admins + unpublished own-preview).
1105 if (post.status === 'published') recordPostView(post, req);
1106
1107 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1108 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1109 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1110 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1111 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1112 let html = (post.content_rendered != null && post.content_rendered !== '')
1113 ? post.content_rendered
1114 : ActivityPubService.bakePostContent(post.content || '');
1115 if (audioEnabled()) {
1116 if (site.enable_audio_player !== 0) {
1117 html = AudioEmbedService.autoembed(html);
1118 html = AudioEmbedService.embedMediaShortcodes(html);
1119 html = AudioEmbedService.embedExternalLinkShortcodes(html);
1120
1121 // Fetch any tracks referenced by [[track:id]] in this post.
1122 // Cheap to do unconditionally — only matches if the post actually has shortcodes.
1123 const trackIds = [...html.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)].map(m => m[1]);
1124 if (trackIds.length) {
1125 const placeholders = trackIds.map(() => '?').join(',');
1126 const rows = db.prepare(`
1127 SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
1128 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
1129 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
1130 WHERE t.site_id = ? AND t.id IN (${placeholders})
1131 `).all(site.id, ...trackIds);
1132 const byId = new Map(rows.map(r => [r.id, r]));
1133 html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
1134 const r = byId.get(id);
1135 if (!r) return null;
1136 return {
1137 id: r.id,
1138 title: r.title,
1139 artist: r.artist,
1140 cover: r.cover_url,
1141 credit: r.credit || '',
1142 license: r.license || '',
1143 link_spotify: r.link_spotify || '',
1144 link_youtube: r.link_youtube || '',
1145 link_soundcloud: r.link_soundcloud || '',
1146 url: r.filename ? audioUrl(r.filename) : '', // '' = link-only track
1147 };
1148 });
1149 }
1150
1151 // Album shortcodes: [[album:Some Album Name]]
1152 const albumNames = [...html.matchAll(/\[\[album:([^\]]+)\]\]/g)].map(m => m[1].trim());
1153 if (albumNames.length) {
1154 const placeholders = albumNames.map(() => '?').join(',');
1155 const albumRows = db.prepare(`
1156 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
1157 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
1158 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
1159 WHERE t.site_id = ? AND t.album IN (${placeholders})
1160 ORDER BY t.position ASC, t.created_at ASC
1161 `).all(site.id, ...albumNames);
1162 const byAlbum = new Map();
1163 for (const r of albumRows) {
1164 // Link-only tracks (no file) remain in the album overview (url '').
1165 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
1166 byAlbum.get(r.album).push({
1167 id: r.id,
1168 url: r.filename ? audioUrl(r.filename) : '',
1169 title: r.title || 'Untitled',
1170 artist: r.artist || '',
1171 cover: r.cover_url || '',
1172 link_spotify: r.link_spotify || '',
1173 link_youtube: r.link_youtube || '',
1174 link_soundcloud: r.link_soundcloud || '',
1175 });
1176 }
1177 html = AudioEmbedService.embedAlbumShortcodes(html, (name) => {
1178 const tracks = byAlbum.get(name);
1179 if (!tracks || !tracks.length) return null;
1180 return {
1181 title: name,
1182 artist: tracks[0].artist || '',
1183 cover: tracks[0].cover || '',
1184 tracks,
1185 };
1186 });
1187 }
1188
1189 // Playlist shortcodes: [[playlist:some-slug-id]] — first-class entity.
1190 // Editing the playlist propagates to every post that embeds it.
1191 const playlistIds = [...html.matchAll(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi)]
1192 .map(m => m[1].toLowerCase());
1193 if (playlistIds.length) {
1194 const isAdmin = req.session?.user?.role === 'god';
1195 html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
1196 return PlaylistService.get(site.id, id, audioUrl);
1197 }, { isAdmin });
1198 }
1199 }
1200 } else {
1201 // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
1202 // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
1203 // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
1204 html = AudioEmbedService.autoembed(html);
1205 html = AudioEmbedService.embedMediaShortcodes(html);
1206 html = AudioEmbedService.embedExternalLinkShortcodes(html);
1207 html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1208 }
1209 // (linkify is baked into content_rendered at save now, not re-run here.)
1210 post.content_html = html;
1211
1212 if (post.tags) {
1213 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1214 } else {
1215 post.tags = [];
1216 }
1217
1218 // Native comments removed: social interaction is fediverse-only (see the
1219 // "From the fediverse" section below).
1220
1221 // Prev / next chronological (kept for back-compat — "post-nav" feature
1222 // below the article still uses these as a simple linear navigation).
1223 // Hub mode: Related posts + Newer/Older pull from ALL users (all sites),
1224 // newest first. Solo mode: within the current site (old behaviour).
1225 const isHub = res.locals.tenancy === 'hub';
1226 // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>.
1227 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
1228
1229 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1230 const { newerPost, olderPost } = postNeighbors(site, post, isHub);
1231
1232 // ── Related posts: same-tag matching with recency fallback ─────
1233 // Fetch ~50 candidates, score by tag overlap, take top 3.
1234 // Excluding self via `id != ?`.
1235 const candidates = isHub
1236 ? db.prepare(`
1237 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
1238 FROM posts p JOIN sites s ON s.id = p.site_id
1239 WHERE p.status = 'published' AND p.id != ?
1240 ORDER BY p.published_at DESC LIMIT 50
1241 `).all(post.id)
1242 : db.prepare(`
1243 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1244 FROM posts
1245 WHERE site_id = ? AND status = 'published' AND id != ?
1246 ORDER BY published_at DESC LIMIT 50
1247 `).all(site.id, post.id);
1248
1249 // Parse tags JSON safely; missing/malformed → empty array.
1250 const parseTags = (raw) => {
1251 if (!raw) return [];
1252 try {
1253 const v = JSON.parse(raw);
1254 return Array.isArray(v) ? v.map(String) : [];
1255 } catch { return []; }
1256 };
1257
1258 const myTags = new Set(parseTags(post.tags));
1259 let relatedPosts;
1260 if (myTags.size > 0) {
1261 // Score = number of overlapping tags. Posts with zero overlap are
1262 // included only if we don't have 3 with-overlap candidates.
1263 const scored = candidates.map(p => {
1264 const theirTags = parseTags(p.tags);
1265 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1266 return { ...p, _overlap: overlap };
1267 });
1268 const withOverlap = scored.filter(p => p._overlap > 0)
1269 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1270 if (withOverlap.length >= 3) {
1271 relatedPosts = withOverlap.slice(0, 3);
1272 } else {
1273 // Pad with most-recent non-overlap posts so the section is never empty
1274 const overlapIds = new Set(withOverlap.map(p => p.id));
1275 const filler = candidates.filter(p => !overlapIds.has(p.id));
1276 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1277 }
1278 } else {
1279 // No tags on current post → just show 3 most-recent
1280 relatedPosts = candidates.slice(0, 3);
1281 }
1282 // Strip the internal _overlap field before sending to view
1283 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1284
1285 // Inbound fediverse activity (threaded) for this post.
1286 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1287 try {
1288 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1289 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1290 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1291 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1292 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1293 } catch { /* non-fatal */ }
1294 // Owner/admin of this site may reply back to a fediverse interaction.
1295 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1296 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1297 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1298
1299 renderPage(req, res, 'pages/post', {
1300 post,
1301 poll: ActivityPubService.ownPollView(post),
1302 newerPost,
1303 olderPost,
1304 relatedPosts,
1305 fediverse,
1306 canManageSite,
1307 siteAvatar,
1308 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1309 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1310 pageTitle: post.title + ' - ' + site.title,
1311 socialDescr: post.excerpt || '',
1312 socialImage: post.cover_image_url || '',
1313 bodyClass: 'on-post',
1314 });
1315});
1316
1317// ── Reply back to a fediverse interaction (site owner/admin only) ──
1318router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1319 const site = res.locals.site;
1320 if (!site) return res.status(404).send('Site required');
1321 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1322 if (!post) return res.status(404).send('Not found');
1323 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1324 const text = (req.body.text || '').toString();
1325 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1326 let attachments = [];
1327 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1328 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1329 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1330 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1331 try {
1332 await ActivityPubService.deliverReply(site, {
1333 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1334 language: (req.body.language || '').toString(),
1335 });
1336 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1337 }
1338 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1339});
1340
1341// Owner likes/boosts a fediverse comment on their own post — directly as the
1342// site, no "your server" detour (mirrors /fedi-reply).
1343router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1344 const site = res.locals.site;
1345 if (!site) return res.status(404).send('Site required');
1346 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1347 if (!post) return res.status(404).send('Not found');
1348 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1349 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1350 if (parent && parent.post_id === post.id && parent.object_uri) {
1351 if (kind === 'boost') {
1352 // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted.
1353 const on = !parent.acted_boost;
1354 ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri)
1355 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1356 ActivityPubService.setInteractionBoosted(parent.id, on);
1357 } else {
1358 // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked.
1359 const on = !parent.acted_like;
1360 ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri)
1361 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1362 ActivityPubService.setInteractionLiked(parent.id, on);
1363 }
1364 }
1365 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1366});
1367
1368export default router;
1369export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.