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

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

Feature: a way to actually become a supporter, and a styled expired page

Bart's test surfaced two gaps in the visitor flow:

  • After "Allow" on Patreon, a non-supporter landed on "Nog geen supporter" with no way to actually pledge. The page now shows a primary "Word supporter op Patreon" button. The site owner sets their public Patreon page in Beheer -> Betaalde posts (new field); it's also linked from the gate itself ("Nog geen supporter? Word het op Patreon").
  • The invalid/expired callback replied with a raw res.send() plain-text line. It now renders the normal paid-result page (reason 'expired').

Changed files:
src/config/database.js

  • paid_patreon.patreon_url column (additive)

src/services/PaidPatreonService.js

  • patreonUrl in config/status/save (undefined keeps, empty clears) + patreonUrl(siteId) helper

src/routes/admin-paid.js

  • save patreon_url from the form

src/views/pages/admin-paid.ejs

  • "Openbare Patreon-pagina" field

src/routes/paid.js

  • expired callback renders paid-result; pass patronUrl to the result pages

src/routes/posts.js

  • pass pgPatronUrl to the gate

src/views/pages/paid-gate.ejs

  • "Word supporter" join line under the unlock button

src/views/pages/paid-result.ejs

  • "Word supporter op Patreon" primary button, 'expired' reason, ghost back button always has a label

test/paid-patron.test.js

  • patreonUrl set/keep/clear semantics

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

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