source: Klonkt/src/routes/posts.js@ 9a00f28

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

Feature: web push slice 1, VAPID keys + subscription store

The foundation for background notifications (docs/webpush-design.md).

  • Dependency (approved): web-push for RFC 8292 VAPID JWTs and RFC 8291 payload encryption. Lazy import so a canary that autofollows before npm ci never crashes on boot (same pattern as @simplewebauthn/server).
  • VAPID keys: env (VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT) wins, else auto-generated once into storage/.vapid (0600), never regenerated while the file exists: new keys would invalidate every subscription. Subject: PUBLIC_BASE_URL, else mailto from SMTP_FROM.
  • push_subscriptions table: one row per device, client keys for encrypted payloads, per-type alert preferences (follow/reply on, like/boost off, dm on by default), self-pruning on 404/410 in the send path.
  • notifyUser/notifySite: honour alert prefs, cap title/body length, fire-and-forget at call sites (slice 3 wires the triggers).

Changed files:
package.json, package-lock.json

  • web-push@3.6.7

src/config/database.js

  • push_subscriptions table (additive)

src/routes/posts.js

  • RESERVED_SLUGS: add 'push' (and the missing 'paid') so a post can't shadow the mounted routes

New file:
src/services/PushService.js

  • VAPID key resolve/persist, subscription CRUD, encrypted send with pruning, notifyUser/notifySite

test/push.test.js

  • key autogen (0600, persists, served=stored), subscription CRUD, upsert-not-duplicate, refuse incomplete payloads

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

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