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

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

Feature: rich editor on the edit forms too (klonkt-demo-c7f, last slice)

Editing a sent reply (Messages + the interact page's manage list) now opens the
same shared editor as new replies, instead of a bare textarea. Prutter stays
plain on purpose (Robin: not part of this).

  • reply-editor partial: initialHtml/initialText prefill for edit mode, and a noAttach flag that omits the media UI (an edit never touches attachments). JS grew canAttach guards so the component runs without the attach elements; pasted files are still swallowed there rather than becoming base64 blobs.
  • deliverOutboxUpdate(site, id, text, {html, language}): rich path with the same sanitize + enrichment + mention-first-paragraph logic as deliverReply; language updated via COALESCE (bogus codes keep the old one); attachments survive untouched. Plain path unchanged.
  • /fediverse/:id/edit passes content + language; listOutbox and the Messages sent-projection carry language so the select preselects correctly.
  • The interact page's edit-toggle focuses the rich editor when present.

2 new tests (93 green). Browser-verified on /messages: prefilled editor
(mention-stripped HTML + plain fallback + language preselected, no paperclip),
edit saved -> content replaced with markup, mention re-attached inline,
language nl->en, no console errors.

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

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