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

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

Feature: Load more on Messages, page 72 (klonkt-demo-r9u, slice 2/4)

Messages now pages in 72s with the shared Load-more button instead of a
hard 80-item cap. getMessages gains an offset arg and pages the merged,
grouped stream by recomputing top-down and slicing [offset, offset+72]
(stable across pages); getNotifications' per-source caps scale with the
requested limit so deep paging can reach older items. The item markup
moved to partials/msg-item.ejs so the page and the append fragment
render identically. The client-side filter/search re-indexes appended
rows on htmx:afterSettle and re-applies the active chip, so search keeps
working across pages. Seen-watermark is only stamped on the first page,
not on appends.

Tests: getMessages offset paging (no overlap, PAGE+1 probe). Browser:
72 -> 144 -> 150 then the button drops; searching a term only present on
page 2 matches after append.

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

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