source: Klonkt/src/routes/posts.js@ 2dd1dc4

main
Last change on this file since 2dd1dc4 was e2c3d09, checked in by Robin <roboburr@…>, 6 weeks ago

Media-submappen volgen nu MEDIA_PATH

De submappen voor avatars, post-images, reply-media, hero en audio-covers hadden
elk hun eigen env-variabele met een fallback naar <app>/storage/media/<sub>.
Daardoor negeerden ze MEDIA_PATH: wie zijn data buiten de checkout zette kreeg
alsnog een storage/-map in de work-tree, en uploads landden naast de code. Een
opruimstap bij een volgende deploy kan die vervolgens weggooien.

Nu leiden ze allemaal af van MEDIA_PATH via een gedeelde helper. Een eigen
override per submap wint nog steeds, dus bestaande installaties merken niets.
Dit maakt de scheiding van gebruikersdata en programmadata mogelijk met drie
regels in .env in plaats van acht.

Changed files:
src/routes/account.js

  • AVATAR_DIR via mediaDir(); dode dirname en fileURLToPath-import weg

src/routes/posts.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-media.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-settings.js

  • HERO_DIR via mediaDir(); dode declaraties weg

src/routes/admin-playlists.js

  • COVER_DIR via mediaDir(); dode dirname weg

src/routes/admin-audio.js

  • COVER_DIR via mediaDir(); AUDIO_DIR ongewijzigd (eigen wortel)

src/routes/admin-sites.js

  • PHOTO_DIR via mediaDir(), deelt bewust de avatars-map

src/routes/activitypub.js

  • AP_MEDIA_DIR via mediaDir(); ongebruikte fileURLToPath-import weg

New file:
src/config/paths.js

  • MEDIA_ROOT afgeleid van MEDIA_PATH
  • mediaDir(envVar, sub) voor submappen, met behoud van per-map overrides

DATABASE_PATH en AUDIO_PATH zijn eigen wortels en bewust ongemoeid gelaten.
Geverifieerd: 356 tests groen, en een server met externe MEDIA_PATH maakt al
zijn mappen buiten de checkout aan zonder de work-tree te raken.

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

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