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

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

Feature: media in replies — rich replies phase 2 (klonkt-demo-c7f)

Drop, paste or pick images/audio/video in the reply editor; they upload, show
as removable chips, travel as AS2 attachments on the federated Note, and render
in the thread.

  • POST /posts/upload-reply-media (requireSiteManager): image/audio/video by extension AND mimetype, stored as-is under /media/reply-media/ (no transcode; a reply attachment is not a track), 32MB cap, returns {url, mediaType, name}.
  • Editor: paperclip button + hidden file input (the mobile path), paste-files and drag/drop handlers, busy/error chips, image thumbnails, max 4, hidden attachments JSON field. Media-only submit allowed (text no longer required when something is attached).
  • deliverReply({attachments}): re-validates server-side — own /media/ paths only (the upload route is the sole producer, remote URLs rejected), image|audio|video mimetypes, capped at 4; stored as JSON on ap_outbox (additive column). Dedup guard now includes attachments so two media-only replies to the same parent are distinct from each other but double-submits still dedup.
  • buildNote reply branch: attachment array with Image/Audio/Video types and absolute URLs. getInteractions passes media through; fedi-node renders it (img/audio/video) for visitors too, loading the stylesheet when the owner-only editor is not on the page.

3 new tests (foreign-URL and type rejection, typed absolute Note attachments,
media-only allowed, only-invalid rejected); 91 green. Browser-verified end to
end: real upload via the endpoint, paste-event -> chip with thumbnail ->
submit -> ap_outbox row with content+language+attachments -> media rendered in
the thread -> /ap/notes/<id> serves the typed absolute attachment.

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

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