source: Klonkt/src/routes/posts.js@ 072a242

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

Fix: unlocked post renders via its own page; no duplicate supporter button

Two things the tester hit once the passkey flow worked:

  1. Duplicate "Word supporter". The gate's no-WebAuthn fallback relabelled the unlock button to "Word supporter", which sat next to the real "Word supporter op Patreon" button. In the two-button layout that fallback now hides the (unusable) unlock button instead; the single-button layout keeps the relabel since it's the only button.
  1. The unlocked content sat in a bare <div>, so it lost the post page's layout and scoped typography ("div niet in een goeie div"). Instead of injecting HTML into the gate, /paid/unlock now returns a short-lived (120s) signed unlock capability and the client reloads the real post URL with it (?u=). GET /:slug renders the FULL post through its normal template when the capability is valid: correct wrapper, scoped styles, and working audio players (which bind on load and couldn't init in injected HTML). Not a cookie and not stored: the token lives only in that one URL and expires.

Changed files:
src/routes/paid.js

  • /unlock returns { ok, redirect } with a 120s unlock blob; drop the renderPostBodyHtml import (no longer injected here)

src/routes/posts.js

  • a valid ?u= capability bypasses the paid gate and renders the post

src/views/pages/paid-gate.ejs

  • no-WebAuthn fallback hides the unlock button when a Patreon page exists; success reloads the real post via the redirect

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

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