source: Klonkt/src/routes/posts.js@ 928d1c7

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

Feature: paid posts slice 2, post model + teaser gate

A post can be marked paid (klonkt-demo-aki), premium-gated in the
editor with an optional per-post price; additive columns posts.paid +
paid_min_cents. On the web, a paid post shows only a teaser to anyone
who is not the owner/editor (new pages/paid-gate, mirroring the
fan-gate); the owner previews the full post. The passkey unlock arrives
in slices 3-4, so the gate says so for now.

Federation is leak-safe: buildNote federates only a PUBLIC teaser (the
excerpt, else the first paragraph, never later content) plus a
"read the full post (supporters)" link back, and no media attachments.
A short paid post can no longer spill its body: the teaser is the first
paragraph only, pinned by a test.

Changed files:
src/config/database.js

  • additive columns posts.paid, posts.paid_min_cents

src/routes/posts.js

  • create/update read paid + price (premium-gated), store them, pass to deliverCreate/Update; paidTeaser helper; the paid web gate

src/services/ActivityPubService.js

  • buildNote: paid post -> public teaser + link, first paragraph only

src/views/pages/post-edit.ejs

  • paid toggle + price field (in the premium block)

New file:
src/views/pages/paid-gate.ejs

  • teaser + supporters notice

test/paid-federation.test.js

  • teaser + link, no full content, excerpt-as-teaser, non-paid intact

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

  • Property mode set to 100644
File size: 70.0 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 } 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// A short public teaser for a paid post: its excerpt, else the first ~280 chars
651// of the (stripped) content. Shared by the web gate and federation.
652function paidTeaser(post, max = 280) {
653 if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
654 // Only the FIRST paragraph: a paid teaser must never spill later content.
655 const html = String((post && post.content) || '');
656 const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
657 const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
658 return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
659}
660
661function postNeighbors(site, post, isHub) {
662 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
663 const ordered = isHub
664 ? db.prepare(`
665 SELECT p.id, p.slug, p.title, p.pinned, s.slug AS site_slug
666 FROM posts p JOIN sites s ON s.id = p.site_id
667 WHERE p.status = 'published'
668 ORDER BY p.published_at DESC
669 `).all()
670 : db.prepare(`
671 SELECT id, slug, title, pinned FROM posts
672 WHERE site_id = ? AND status = 'published'
673 ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
674 `).all(site.id);
675 const idx = ordered.findIndex((p) => p.id === post.id);
676 const newerPost = idx > 0 ? ordered[idx - 1] : null;
677 const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
678 if (newerPost) newerPost._urlBase = urlBaseFor(newerPost);
679 if (olderPost) olderPost._urlBase = urlBaseFor(olderPost);
680 return { newerPost, olderPost };
681}
682
683// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
684// Standard fediverse "reply from your own server" landing endpoint. A post page
685// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
686// composes a reply that federates back to that post.
687router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
688 const site = res.locals.site;
689 const uri = (req.query.uri || '').toString();
690 const sent = !!req.query.sent;
691 const followed = !!req.query.followed;
692 const voted = !!req.query.voted;
693 const reported = !!req.query.reported;
694 let target = null, followTarget = null;
695 if (!sent && !followed && !voted && !reported && uri) {
696 try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
697 // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
698 if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
699 }
700 renderPage(req, res, 'pages/authorize-interaction', {
701 pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
702 bodyClass: 'on-special',
703 uri,
704 target,
705 followTarget,
706 sent,
707 followed,
708 voted: !!req.query.voted,
709 reported: !!req.query.reported,
710 liked: !!req.query.liked,
711 boosted: !!req.query.boosted,
712 reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
713 siteTitle: site ? site.title : '',
714 });
715});
716
717// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
718// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
719router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
720 const site = res.locals.site;
721 const uri = (req.body.uri || '').toString();
722 let choice = req.body.choice;
723 if (choice == null) choice = [];
724 if (!Array.isArray(choice)) choice = [choice];
725 if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
726 res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
727});
728
729// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
730router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
731 const site = res.locals.site;
732 const uri = (req.body.uri || '').toString();
733 const actorUri = (req.body.actor_uri || '').toString();
734 const reason = (req.body.reason || '').toString();
735 if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
736 res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
737});
738
739// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
740router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
741 const site = res.locals.site;
742 const uri = (req.body.uri || '').toString();
743 let on = false;
744 if (site && uri) {
745 on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
746 ActivityPubService.resolveRemoteNote(uri)
747 .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
748 .catch((e) => console.warn('[AP] remote like failed:', e.message));
749 ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
750 }
751 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
752 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
753});
754
755// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
756// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
757router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
758 const site = res.locals.site;
759 const uri = (req.body.uri || '').toString();
760 let on = false;
761 if (site && uri) {
762 on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
763 ActivityPubService.resolveRemoteNote(uri)
764 .then((note) => {
765 if (!note) return;
766 const id = note.object_uri || uri;
767 return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
768 // Boost → store the post in the timeline (even if you don't follow the author) so it
769 // surfaces in the Cirkel; unboost → just clear the flag.
770 .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
771 })
772 .catch((e) => console.warn('[AP] remote boost failed:', e.message));
773 ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
774 }
775 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
776 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
777});
778
779// Follow a remote actor from your own site (when the target is a profile, not a post).
780router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
781 const site = res.locals.site;
782 const uri = (req.body.uri || '').toString();
783 if (site && uri) {
784 ActivityPubService.followActor(site, uri)
785 .catch((e) => console.warn('[AP] remote follow failed:', e.message));
786 }
787 res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
788});
789
790router.post('/authorize_interaction', requireSiteManager, (req, res) => {
791 const site = res.locals.site;
792 const uri = (req.body.uri || '').toString();
793 const text = (req.body.text || '').toString();
794 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
795 const language = (req.body.language || '').toString();
796 let attachments = [];
797 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
798 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
799 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
800 if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
801 // Resolve + deliver in the background so Send responds instantly.
802 ActivityPubService.resolveRemoteNote(uri)
803 .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
804 .catch((e) => console.warn('[AP] remote reply failed:', e.message));
805 }
806 res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
807});
808
809// Manage / delete your own outbound fediverse replies (site owner only).
810// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
811// The old /fediverse (manage) and /notifications pages redirect here.
812router.get('/messages', requireSiteManager, (req, res) => {
813 const site = res.locals.site;
814 const append = req.query.append === '1';
815 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
816 const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : [];
817 const hasMore = page.length > FEED_PAGE;
818 const items = page.slice(0, FEED_PAGE);
819 // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
820 const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
821 // Only stamp "seen" on the first page load (not on Load-more appends).
822 if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
823 const moreBase = res.locals.siteUrlBase || '';
824 if (append) {
825 return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
826 }
827 renderPage(req, res, 'pages/messages', {
828 pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
829 hasMore, nextOffset: offset + FEED_PAGE, moreBase,
830 success: req.query.success || null, error: req.query.error || null,
831 });
832});
833router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
834
835router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
836 const site = res.locals.site;
837 if (site) {
838 try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
839 catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
840 }
841 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
842});
843
844// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
845// object URI so re-delivery and thread-crawling never bring it back. Works for private
846// notes too (acts on the local copy; no remote fetch involved).
847router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
848 const site = res.locals.site;
849 if (site) {
850 const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
851 if (r.error) console.warn('[AP] interaction remove failed:', r.error);
852 }
853 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
854});
855
856// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
857// locally stored object/actor URIs, so it also works for private notes that
858// authorize_interaction cannot fetch (401/404).
859router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
860 const site = res.locals.site;
861 if (site) {
862 const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
863 if (tgt && (tgt.objectUri || tgt.actorUri)) {
864 try {
865 const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
866 if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
867 } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
868 }
869 }
870 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
871});
872
873// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
874router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
875 const site = res.locals.site;
876 const text = String(req.body.text || '');
877 const html = String(req.body.content || ''); // rich reply editor HTML (sanitized in deliverOutboxUpdate)
878 if (site && (text.trim() || html.trim())) {
879 try {
880 await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
881 html, language: String(req.body.language || ''),
882 });
883 } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
884 }
885 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
886});
887
888// ==================== FEDIVERSE CLIENT: home timeline + following ====================
889// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
890// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
891function timelineEmbedHtml(html) {
892 if (!html) return null;
893 const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
894 while ((m = re.exec(html))) {
895 const u = m[1]; if (seen.has(u)) continue; seen.add(u);
896 let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
897 if (!p) {
898 // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
899 // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
900 // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
901 const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
902 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>`;
903 continue;
904 }
905 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>`;
906 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>`;
907 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>`;
908 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>`;
909 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>`;
910 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>`; }
911 }
912 return null;
913}
914
915// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
916// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
917// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
918function klonktAudioEmbed(html, url) {
919 if (!html || !url || html.indexOf('🎵') < 0) return null;
920 let u; try { u = new URL(url); } catch { return null; }
921 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
922 const slug = u.pathname.replace(/^\/+|\/+$/g, '');
923 if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
924 const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
925 // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
926 const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
927 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>` };
928}
929
930router.get('/news', requireSiteManager, (req, res) => {
931 const site = res.locals.site;
932 const append = req.query.append === '1';
933 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
934 const cspOrigins = new Set();
935 // Fetch one extra to know whether a "Load more" button belongs on this page.
936 const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];
937 const hasMore = rows.length > FEED_PAGE;
938 const timeline = rows.slice(0, FEED_PAGE).map((p) => {
939 let embedHtml = timelineEmbedHtml(p.content);
940 let content = p.content;
941 let embedUrl = null;
942 if (!embedHtml) {
943 const k = klonktAudioEmbed(p.content, p.url);
944 if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
945 }
946 // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
947 // top-level "open the player" link that works even when a browser shield/CSP blocks
948 // the cross-site iframe (a full-page navigation is not a cross-site frame).
949 let poll = null;
950 if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
951 return { ...p, content, embedHtml, embedUrl, poll };
952 });
953 // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
954 // extending ONLY this response's CSP frame-src. The global policy stays locked down.
955 if (cspOrigins.size) {
956 const csp = res.getHeader('Content-Security-Policy');
957 if (csp) {
958 const extra = [...cspOrigins].join(' ');
959 res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
960 }
961 }
962 const moreBase = res.locals.siteUrlBase || '';
963 if (append) {
964 return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
965 }
966 renderPage(req, res, 'pages/news', {
967 pageTitle: 'News', bodyClass: 'on-special',
968 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
969 success: req.query.success || null, error: req.query.error || null,
970 });
971});
972
973// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
974// Connect = who you follow + who follows you, merged into one page with direction
975// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
976// separate Following/Followers pages, which redirect here so old links keep working.
977router.get('/connect', requireSiteManager, (req, res) => {
978 const site = res.locals.site;
979 const connections = site ? ActivityPubService.listConnections(site.slug) : [];
980 renderPage(req, res, 'pages/connect', {
981 pageTitle: 'Connect', bodyClass: 'on-special',
982 connections,
983 success: req.query.success || null, error: req.query.error || null,
984 });
985});
986router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
987router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
988
989router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
990 const site = res.locals.site;
991 const base = res.locals.siteUrlBase || '';
992 if (!site) return res.redirect(`${base}/connect`);
993 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
994 return res.redirect(`${base}/connect?` + (ok
995 ? 'success=' + encodeURIComponent('Volger verwijderd')
996 : 'error=' + encodeURIComponent('Volger niet gevonden')));
997});
998
999router.post('/news/follow', requireSiteManager, async (req, res) => {
1000 const site = res.locals.site;
1001 const handle = (req.body.handle || '').toString();
1002 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
1003 if (site && handle.trim()) {
1004 try {
1005 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
1006 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
1007 else {
1008 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
1009 }
1010 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
1011 }
1012 res.redirect('/following?' + q);
1013});
1014
1015router.post('/news/unfollow', requireSiteManager, async (req, res) => {
1016 const site = res.locals.site;
1017 const actorUri = (req.body.actor_uri || '').toString();
1018 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
1019 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
1020});
1021
1022// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
1023router.post('/news/autoboost', requireSiteManager, (req, res) => {
1024 const site = res.locals.site;
1025 const actorUri = (req.body.actor_uri || '').toString();
1026 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
1027 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
1028});
1029
1030// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
1031// no banner); no-JS → redirect back.
1032router.post('/news/like', requireSiteManager, async (req, res) => {
1033 const site = res.locals.site;
1034 const note = (req.body.note || '').toString();
1035 let on = false;
1036 if (site && note) {
1037 on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
1038 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1039 if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
1040 }
1041 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1042 res.redirect('/news');
1043});
1044
1045// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
1046router.post('/news/boost', requireSiteManager, async (req, res) => {
1047 const site = res.locals.site;
1048 const note = (req.body.note || '').toString();
1049 let on = false;
1050 if (site && note) {
1051 on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
1052 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1053 if (on) {
1054 ActivityPubService.markBoosted(site.slug, note); // instant UI state
1055 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1056 // (cover/content) — boosting again heals a stale copy from EVERY boost
1057 // path, not just the interact page.
1058 ActivityPubService.resolveRemoteNote(note)
1059 .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
1060 .catch(() => { /* best-effort */ });
1061 } else {
1062 ActivityPubService.unmarkBoosted(site.slug, note);
1063 }
1064 }
1065 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1066 res.redirect('/news');
1067});
1068
1069// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1070router.post('/news/vote', requireSiteManager, async (req, res) => {
1071 const site = res.locals.site;
1072 const note = (req.body.note || '').toString();
1073 let choice = req.body.choice;
1074 if (choice == null) choice = [];
1075 if (!Array.isArray(choice)) choice = [choice];
1076 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1077 res.redirect('/news');
1078});
1079
1080// Notifications inbox (new followers + replies/likes/boosts on your posts).
1081router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1082
1083// Blocking / defederation (owner-only).
1084router.get('/blocking', requireSiteManager, (req, res) => {
1085 const site = res.locals.site;
1086 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1087 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1088});
1089
1090router.post('/blocking/add', requireSiteManager, async (req, res) => {
1091 const site = res.locals.site;
1092 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1093 if (site) {
1094 try {
1095 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1096 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1097 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1098 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1099 }
1100 const ref = req.get('Referer') || '';
1101 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1102});
1103
1104router.post('/blocking/remove', requireSiteManager, (req, res) => {
1105 const site = res.locals.site;
1106 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
1107 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1108});
1109
1110// ==================== VIEW POST (last route — catches /:slug) ====================
1111router.get('/:slug', (req, res, next) => {
1112 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1113
1114 const site = res.locals.site;
1115 if (!site) return next(); // -> nette 404 catch-all
1116
1117 const post = db.prepare(`
1118 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1119 FROM posts p JOIN users u ON p.author_id = u.id
1120 WHERE p.site_id = ? AND p.slug = ?
1121 `).get(site.id, req.params.slug);
1122
1123 if (!post) return next(); // unknown slug -> clean 404 catch-all
1124
1125 // Permission to view: published OR (logged in + can edit)
1126 if (post.status !== 'published') {
1127 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1128 if (!canEdit) return res.status(403).send('Not published');
1129 }
1130
1131 // Fan-only preview (premium #3): full content only for logged-in fans.
1132 // Anonymous visitors get a clean login gate instead of the content (the title/
1133 // teaser may still appear elsewhere as a teaser).
1134 if (post.fan_only && !(req.session && req.session.user)) {
1135 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1136 // stuck on the fan gate but can keep browsing.
1137 const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
1138 return renderPage(req, res, 'pages/fan-gate', {
1139 pageTitle: post.title || 'Alleen voor fans',
1140 bodyClass: 'on-special',
1141 fgTitle: post.title || '',
1142 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1143 newerPost,
1144 olderPost,
1145 });
1146 }
1147
1148 // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
1149 // is not the owner/editor. The passkey unlock arrives in slices 3-4; for now
1150 // the owner previews the full post, everyone else sees the teaser + notice.
1151 const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1152 if (post.paid && !canEditThis) {
1153 const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
1154 return renderPage(req, res, 'pages/paid-gate', {
1155 pageTitle: post.title || 'Voor supporters',
1156 bodyClass: 'on-special',
1157 pgTitle: post.title || '',
1158 pgTeaser: paidTeaser(post),
1159 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
1160 pgSlug: post.slug,
1161 newerPost,
1162 olderPost,
1163 });
1164 }
1165
1166 // Statistics: count the view (skips admins + unpublished own-preview).
1167 if (post.status === 'published') recordPostView(post, req);
1168
1169 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1170 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1171 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1172 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1173 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1174 let html = (post.content_rendered != null && post.content_rendered !== '')
1175 ? post.content_rendered
1176 : ActivityPubService.bakePostContent(post.content || '');
1177 if (audioEnabled()) {
1178 if (site.enable_audio_player !== 0) {
1179 html = AudioEmbedService.autoembed(html);
1180 html = AudioEmbedService.embedMediaShortcodes(html);
1181 html = AudioEmbedService.embedExternalLinkShortcodes(html);
1182
1183 // Fetch any tracks referenced by [[track:id]] in this post.
1184 // Cheap to do unconditionally — only matches if the post actually has shortcodes.
1185 const trackIds = [...html.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)].map(m => m[1]);
1186 if (trackIds.length) {
1187 const placeholders = trackIds.map(() => '?').join(',');
1188 const rows = db.prepare(`
1189 SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
1190 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
1191 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
1192 WHERE t.site_id = ? AND t.id IN (${placeholders})
1193 `).all(site.id, ...trackIds);
1194 const byId = new Map(rows.map(r => [r.id, r]));
1195 html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
1196 const r = byId.get(id);
1197 if (!r) return null;
1198 return {
1199 id: r.id,
1200 title: r.title,
1201 artist: r.artist,
1202 cover: r.cover_url,
1203 credit: r.credit || '',
1204 license: r.license || '',
1205 link_spotify: r.link_spotify || '',
1206 link_youtube: r.link_youtube || '',
1207 link_soundcloud: r.link_soundcloud || '',
1208 url: r.filename ? audioUrl(r.filename) : '', // '' = link-only track
1209 };
1210 });
1211 }
1212
1213 // Album shortcodes: [[album:Some Album Name]]
1214 const albumNames = [...html.matchAll(/\[\[album:([^\]]+)\]\]/g)].map(m => m[1].trim());
1215 if (albumNames.length) {
1216 const placeholders = albumNames.map(() => '?').join(',');
1217 const albumRows = db.prepare(`
1218 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
1219 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
1220 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
1221 WHERE t.site_id = ? AND t.album IN (${placeholders})
1222 ORDER BY t.position ASC, t.created_at ASC
1223 `).all(site.id, ...albumNames);
1224 const byAlbum = new Map();
1225 for (const r of albumRows) {
1226 // Link-only tracks (no file) remain in the album overview (url '').
1227 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
1228 byAlbum.get(r.album).push({
1229 id: r.id,
1230 url: r.filename ? audioUrl(r.filename) : '',
1231 title: r.title || 'Untitled',
1232 artist: r.artist || '',
1233 cover: r.cover_url || '',
1234 link_spotify: r.link_spotify || '',
1235 link_youtube: r.link_youtube || '',
1236 link_soundcloud: r.link_soundcloud || '',
1237 });
1238 }
1239 html = AudioEmbedService.embedAlbumShortcodes(html, (name) => {
1240 const tracks = byAlbum.get(name);
1241 if (!tracks || !tracks.length) return null;
1242 return {
1243 title: name,
1244 artist: tracks[0].artist || '',
1245 cover: tracks[0].cover || '',
1246 tracks,
1247 };
1248 });
1249 }
1250
1251 // Playlist shortcodes: [[playlist:some-slug-id]] — first-class entity.
1252 // Editing the playlist propagates to every post that embeds it.
1253 const playlistIds = [...html.matchAll(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi)]
1254 .map(m => m[1].toLowerCase());
1255 if (playlistIds.length) {
1256 const isAdmin = req.session?.user?.role === 'god';
1257 html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
1258 return PlaylistService.get(site.id, id, audioUrl);
1259 }, { isAdmin });
1260 }
1261 }
1262 } else {
1263 // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
1264 // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
1265 // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
1266 html = AudioEmbedService.autoembed(html);
1267 html = AudioEmbedService.embedMediaShortcodes(html);
1268 html = AudioEmbedService.embedExternalLinkShortcodes(html);
1269 html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1270 }
1271 // (linkify is baked into content_rendered at save now, not re-run here.)
1272 post.content_html = html;
1273
1274 if (post.tags) {
1275 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1276 } else {
1277 post.tags = [];
1278 }
1279
1280 // Native comments removed: social interaction is fediverse-only (see the
1281 // "From the fediverse" section below).
1282
1283 // Prev / next chronological (kept for back-compat — "post-nav" feature
1284 // below the article still uses these as a simple linear navigation).
1285 // Hub mode: Related posts + Newer/Older pull from ALL users (all sites),
1286 // newest first. Solo mode: within the current site (old behaviour).
1287 const isHub = res.locals.tenancy === 'hub';
1288 // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>.
1289 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
1290
1291 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1292 const { newerPost, olderPost } = postNeighbors(site, post, isHub);
1293
1294 // ── Related posts: same-tag matching with recency fallback ─────
1295 // Fetch ~50 candidates, score by tag overlap, take top 3.
1296 // Excluding self via `id != ?`.
1297 const candidates = isHub
1298 ? db.prepare(`
1299 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
1300 FROM posts p JOIN sites s ON s.id = p.site_id
1301 WHERE p.status = 'published' AND p.id != ?
1302 ORDER BY p.published_at DESC LIMIT 50
1303 `).all(post.id)
1304 : db.prepare(`
1305 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1306 FROM posts
1307 WHERE site_id = ? AND status = 'published' AND id != ?
1308 ORDER BY published_at DESC LIMIT 50
1309 `).all(site.id, post.id);
1310
1311 // Parse tags JSON safely; missing/malformed → empty array.
1312 const parseTags = (raw) => {
1313 if (!raw) return [];
1314 try {
1315 const v = JSON.parse(raw);
1316 return Array.isArray(v) ? v.map(String) : [];
1317 } catch { return []; }
1318 };
1319
1320 const myTags = new Set(parseTags(post.tags));
1321 let relatedPosts;
1322 if (myTags.size > 0) {
1323 // Score = number of overlapping tags. Posts with zero overlap are
1324 // included only if we don't have 3 with-overlap candidates.
1325 const scored = candidates.map(p => {
1326 const theirTags = parseTags(p.tags);
1327 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1328 return { ...p, _overlap: overlap };
1329 });
1330 const withOverlap = scored.filter(p => p._overlap > 0)
1331 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1332 if (withOverlap.length >= 3) {
1333 relatedPosts = withOverlap.slice(0, 3);
1334 } else {
1335 // Pad with most-recent non-overlap posts so the section is never empty
1336 const overlapIds = new Set(withOverlap.map(p => p.id));
1337 const filler = candidates.filter(p => !overlapIds.has(p.id));
1338 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1339 }
1340 } else {
1341 // No tags on current post → just show 3 most-recent
1342 relatedPosts = candidates.slice(0, 3);
1343 }
1344 // Strip the internal _overlap field before sending to view
1345 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1346
1347 // Inbound fediverse activity (threaded) for this post.
1348 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1349 try {
1350 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1351 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1352 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1353 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1354 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1355 } catch { /* non-fatal */ }
1356 // Owner/admin of this site may reply back to a fediverse interaction.
1357 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1358 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1359 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1360
1361 renderPage(req, res, 'pages/post', {
1362 post,
1363 poll: ActivityPubService.ownPollView(post),
1364 newerPost,
1365 olderPost,
1366 relatedPosts,
1367 fediverse,
1368 canManageSite,
1369 siteAvatar,
1370 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1371 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1372 pageTitle: post.title + ' - ' + site.title,
1373 socialDescr: post.excerpt || '',
1374 socialImage: post.cover_image_url || '',
1375 bodyClass: 'on-post',
1376 });
1377});
1378
1379// ── Reply back to a fediverse interaction (site owner/admin only) ──
1380router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1381 const site = res.locals.site;
1382 if (!site) return res.status(404).send('Site required');
1383 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1384 if (!post) return res.status(404).send('Not found');
1385 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1386 const text = (req.body.text || '').toString();
1387 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1388 let attachments = [];
1389 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1390 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1391 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1392 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1393 try {
1394 await ActivityPubService.deliverReply(site, {
1395 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1396 language: (req.body.language || '').toString(),
1397 });
1398 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1399 }
1400 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1401});
1402
1403// Owner likes/boosts a fediverse comment on their own post — directly as the
1404// site, no "your server" detour (mirrors /fedi-reply).
1405router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1406 const site = res.locals.site;
1407 if (!site) return res.status(404).send('Site required');
1408 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1409 if (!post) return res.status(404).send('Not found');
1410 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1411 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1412 if (parent && parent.post_id === post.id && parent.object_uri) {
1413 if (kind === 'boost') {
1414 // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted.
1415 const on = !parent.acted_boost;
1416 ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri)
1417 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1418 ActivityPubService.setInteractionBoosted(parent.id, on);
1419 } else {
1420 // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked.
1421 const on = !parent.acted_like;
1422 ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri)
1423 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1424 ActivityPubService.setInteractionLiked(parent.id, on);
1425 }
1426 }
1427 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1428});
1429
1430export default router;
1431export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.