source: Klonkt/src/routes/posts.js@ 0cea12b

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

Feature: Load more on Solo + Cirkel, page 72 (klonkt-demo-r9u, slice 3/4)

The home feed (Solo) and the Cirkel feed now page in 72s instead of a
hard 30/80 cap. Both render two containers (list + grid, CSS-toggled),
so the append fragment (partials/home-append) sends the post-cards as
the primary beforeend swap into #post-list and OOB-appends the same
posts as tiles into #grid-tiles. htmx 1.9.12 unwraps the OOB wrapper's
children on a positional swap, so the tiles land as direct grid items
(the display:contents wrapper is a belt-and-braces fallback). The
button lives once below both views and OOB-replaces itself with the
next offset, dropping on the last page. Pinned posts stay on page 1
only. getCirkelPosts gains an offset arg; FEED_PAGE hoisted to the top
of posts.js.

Browser-verified both feeds: 72 -> 144 -> 150 in list AND grid, then
the button disappears.

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

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