source: Klonkt/src/routes/posts.js@ 02c6231

main
Last change on this file since 02c6231 was bfe4a55, checked in by Robin <roboburr@…>, 5 weeks ago

De playlist-poort opent alleen bestanden van de eigen site (shaer-ayc)

setAudioFediOpen opende de playlist-tak zonder site-check, terwijl de track-
en album-tak er wel een hadden. playlists.id is een globale sleutel, dus een
post op site A met [[playlist:x]] van site B zette de bestanden van B open --
en fedi_open is eenrichtings, dus dat draai je niet terug.

Het filter zit op de TRACKS, niet op de playlist: ook een vreemde track in je
eigen playlist is niet van jou. De test bewaakt dat verschil, plus de eigen
track als vangrail tegen een "fix" die de tak stilzet.

  • Property mode set to 100644
File size: 79.0 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 // FEP-7628 slice 3: this account moved. A visitor who lands here deserves
235 // the same signpost the fediverse gets — one big link to the new address.
236 const movedTo = site.moved_to && /^https?:\/\//i.test(String(site.moved_to)) ? String(site.moved_to) : null;
237 renderPage(req, res, 'pages/home', {
238 pinnedPosts,
239 posts,
240 hasMore, nextOffset: offset + FEED_PAGE, moreBase,
241 movedTo,
242 movedToLabel: movedTo ? (ActivityPubService.actorDisplay(site.slug, movedTo).handle || movedTo) : null,
243 pageTitle: site.title,
244 socialDescr: site.description || site.tagline || '',
245 bodyClass: 'on-home',
246 });
247});
248
249// ==================== NEW POST FORM ====================
250router.get('/posts/new', requireAuth, (req, res) => {
251 const site = res.locals.site;
252 if (!site) return res.status(404).send('Site required');
253 if (!PermissionsService.canCreatePost(req.session.user, site)) {
254 return res.status(403).send('No permission');
255 }
256
257 renderPage(req, res, 'pages/post-edit', {
258 // post-edit neemt de playlist-editor op.
259 pageJs: 'post-edit playlist-editor',
260 post: {
261 id: uuid(),
262 title: '', slug: '', content: '', excerpt: '',
263 status: 'draft', pinned: 0, tags: [],
264 cover_image_url: '',
265 },
266 isNew: true,
267 pageTitle: 'New post',
268 bodyClass: 'on-special',
269 });
270});
271
272// ==================== CREATE POST ====================
273// ── Per-post audio federation ──────────────────────────────────────────────
274// "Share audio on the fediverse" is a per-post choice in the editor, but the underlying
275// flag is per track (audio_tracks.fedi_open — it gates the file + drives the AS2 Audio
276// attachment). NB: the file gate is per file, so opening a track in one post makes its file
277// fetchable for every post that reuses it.
278// ONE-WAY: opening is permanent. Once the file has federated it's out there — re-gating
279// would be false security (remote copies keep the URL), so we never write fedi_open back to 0.
280function setAudioFediOpen(siteId, content, open) {
281 if (!open) return; // never close — see one-way note above
282 const c = content || '';
283 try {
284 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);
285 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());
286 // playlists.id is a GLOBAL key, so the site filter has to sit on the tracks: without it a
287 // post on site A embedding site B's playlist would open B's files — permanently.
288 for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE site_id = ? AND id IN (SELECT track_id FROM playlist_tracks WHERE playlist_id = ?)').run(siteId, m[1]);
289 } catch { /* non-fatal */ }
290}
291// True when the post references hosted audio AND all of it is currently fedi_open (drives the
292// editor checkbox's initial state).
293function postAudioFediOpen(siteId, content) {
294 const c = content || '';
295 if (!/\[\[(track|album|playlist):/i.test(c)) return false;
296 let total = 0, open = 0;
297 const tally = (r) => { if (r && r.media_id) { total++; if (r.fedi_open) open++; } };
298 try {
299 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));
300 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);
301 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);
302 } catch { /* non-fatal */ }
303 return total > 0 && open === total;
304}
305
306// Bake + cache a post's display HTML (ActivityPub `source` model): `content` stays the raw
307// source (used by the editor + re-rendering), content_rendered holds the linkified render the
308// page serves. Called after every create/edit. Non-fatal: the render route falls back to
309// baking on the fly if this ever fails.
310function cacheRenderedContent(postId, rawContent) {
311 const raw = rawContent || '';
312 // 1. Immediate + synchronous: bake #hashtags + URLs so the post renders enriched at once.
313 try {
314 db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?')
315 .run(ActivityPubService.bakePostContent(raw), postId);
316 } catch (e) { /* fallback bake in the render route keeps display correct */ }
317 // 2. Async: resolve @mentions (webfinger, once) and re-store, WITHOUT blocking the save
318 // response — a moment later the post's @mentions are clickable too. A slow/dead remote
319 // server can't stall the save; on failure the sync bake from step 1 stands.
320 ActivityPubService.bakePostContentWithMentions(raw)
321 .then((html) => {
322 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(html, postId); }
323 catch (e) { /* keep the sync bake */ }
324 })
325 .catch(() => { /* keep the sync bake */ });
326}
327
328router.post('/posts/create', requireAuth, (req, res) => {
329 const site = res.locals.site;
330 if (!site || !PermissionsService.canCreatePost(req.session.user, site)) {
331 return res.status(403).send('No permission');
332 }
333
334 const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
335 const fanOnly = req.body.fan_only ? 1 : 0;
336 const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0; // paid posts (klonkt-demo-aki)
337 const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
338 const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
339 const nsfw = req.body.nsfw ? 1 : 0;
340 const cw = (req.body.content_warning || '').trim().slice(0, 200);
341 const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
342 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
343
344 // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
345 // before storage. Shortcode text tokens like [[track:UUID]] live in text
346 // nodes and pass through untouched.
347 const cleanContent = HtmlSanitizerService.sanitize(content || '');
348
349 // Generate slug from title if empty
350 let finalSlug = (slug || title || '')
351 .toLowerCase()
352 .replace(/[^a-z0-9]+/g, '-')
353 .replace(/^-|-$/g, '');
354
355 if (!finalSlug) return res.status(400).send('Title or slug required');
356 if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
357
358 // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
359 finalSlug = uniqueSlug(site.id, finalSlug);
360
361 const validTypes = new Set(['post', 'foto', 'video', 'audio']);
362 const finalType = validTypes.has(type) ? type : 'post';
363 const pollJson = parsePollForm(req.body); // AS2 Question definition, or null
364 const postId = uuid();
365 const now = new Date().toISOString();
366 let finalStatus = status || 'draft';
367 let publishedAt = finalStatus === 'published' ? now : null;
368 // Release planning: published + a future publish_at -> 'scheduled'
369 // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
370 let publishAt = null;
371 const pa = Date.parse(req.body.publish_at || '');
372 if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
373 finalStatus = 'scheduled';
374 publishAt = new Date(pa).toISOString();
375 publishedAt = null;
376 }
377
378 db.prepare(`
379 INSERT INTO posts (
380 id, site_id, slug, author_id, title, content, excerpt,
381 status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
382 created_at, updated_at, published_at
383 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
384 `).run(
385 postId, site.id, finalSlug, req.session.user.id,
386 title || finalSlug, cleanContent, excerpt || '',
387 finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
388 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
389 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
390 now, now, publishedAt
391 );
392 cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
393 db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, postId);
394
395 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
396 // BEFORE federating, so the Create note carries the right Audio attachments.
397 setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
398
399 if (finalStatus === 'published') {
400 try {
401 db.prepare(
402 'INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'
403 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId);
404 } catch (e) { /* FTS index issues are non-fatal */ }
405
406 // ActivityPub: federate a freshly published post to followers. fan_only → delivered
407 // to followers but addressed followers-only (option A: "fans" = your fedi followers).
408 if (status === 'published') {
409 ActivityPubService.deliverCreate(site, {
410 id: postId, slug: finalSlug, title: title || finalSlug,
411 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
412 published_at: publishedAt, created_at: now, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
413 }).catch(() => { /* best-effort */ });
414 }
415 }
416
417 // HTMX request -> return redirect header
418 if (req.headers['hx-request']) {
419 res.setHeader('HX-Redirect', `${res.locals.siteUrlBase || ''}/${finalSlug}`);
420 return res.send('OK');
421 }
422
423 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
424});
425
426// ==================== EDIT POST FORM ====================
427router.get('/posts/:slug/edit', requireAuth, (req, res) => {
428 const site = res.locals.site;
429 if (!site) return res.status(404).send('Site required');
430
431 const post = db.prepare(
432 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
433 ).get(site.id, req.params.slug);
434
435 if (!post) return res.status(404).send('Post not found');
436 if (!PermissionsService.canEditPost(req.session.user, post, site)) {
437 return res.status(403).send('No permission');
438 }
439
440 if (post.tags) {
441 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
442 } else {
443 post.tags = [];
444 }
445
446 // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
447 let pollLocked = false;
448 try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
449
450 renderPage(req, res, 'pages/post-edit', {
451 // Zelfde modules als de nieuw-route hierboven: zonder deze regel laadt de
452 // editor niet, en dan wist een opslag de post (shaer-5s1, de beet van 7-8).
453 pageJs: 'post-edit playlist-editor',
454 post,
455 isNew: false,
456 pollLocked,
457 fediOpenAudio: postAudioFediOpen(site.id, post.content),
458 pageTitle: 'Edit: ' + (post.title || 'Untitled'),
459 bodyClass: 'on-special',
460 });
461});
462
463// ==================== SAVE POST ====================
464router.post('/posts/:slug/save', requireAuth, (req, res) => {
465 const site = res.locals.site;
466 if (!site) return res.status(404).send('Site required');
467
468 const post = db.prepare(
469 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
470 ).get(site.id, req.params.slug);
471
472 if (!post) return res.status(404).send('Post not found');
473 if (!PermissionsService.canEditPost(req.session.user, post, site)) {
474 return res.status(403).send('No permission');
475 }
476
477 const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
478 const fanOnly = req.body.fan_only ? 1 : 0;
479 const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0; // paid posts (klonkt-demo-aki)
480 const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
481 const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
482 const nsfw = req.body.nsfw ? 1 : 0;
483 const cw = (req.body.content_warning || '').trim().slice(0, 200);
484 const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
485 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
486 const newSlug = req.body.slug;
487 const action = req.body.action || 'save';
488 const validTypes = new Set(['post', 'foto', 'video', 'audio']);
489 const finalType = validTypes.has(type) ? type : (post.type || 'post');
490
491 // A poll that has already received votes is frozen (you can still edit the surrounding
492 // post, but not the options) — changing options after votes would scramble the tally and
493 // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
494 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; } })());
495 const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
496
497 // Sanitize before storage — same pipeline as create.
498 const cleanContent = HtmlSanitizerService.sanitize(content || '');
499
500 let finalSlug = post.slug;
501 if (newSlug && newSlug !== post.slug) {
502 const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
503 const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
504 // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
505 finalSlug = uniqueSlug(site.id, safe, post.id);
506 }
507
508 const now = new Date().toISOString();
509 let finalStatus = status || post.status;
510 let publishedAt = post.published_at;
511
512 if (action === 'publish') {
513 finalStatus = 'published';
514 if (!publishedAt) publishedAt = now;
515 }
516
517 // Release planning: published + future publish_at -> 'scheduled'.
518 let publishAt = null;
519 const pa = Date.parse(req.body.publish_at || '');
520 if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
521 finalStatus = 'scheduled';
522 publishAt = new Date(pa).toISOString();
523 publishedAt = null;
524 }
525
526 db.prepare(`
527 UPDATE posts SET
528 title = ?, content = ?, excerpt = ?, status = ?,
529 cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
530 type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
531 slug = ?, published_at = ?, updated_at = ?
532 WHERE id = ?
533 `).run(
534 title, cleanContent, excerpt, finalStatus,
535 cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
536 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
537 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
538 finalSlug, publishedAt, now, post.id
539 );
540 cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
541 db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, post.id);
542
543 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
544 // BEFORE federating, so the Update/Create note carries the right Audio attachments.
545 setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
546
547 // Update FTS
548 try {
549 db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id);
550 if (finalStatus === 'published') {
551 db.prepare(
552 'INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'
553 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, post.id);
554 }
555 } catch (e) { /* FTS issues non-fatal */ }
556
557 // ActivityPub: federate edits to followers. A post that BECOMES published →
558 // Create (new post); an already-published post that's edited → Update (so
559 // Mastodon refreshes its cached copy). fan_only → followers-only (option A).
560 if (finalStatus === 'published') {
561 const apPost = {
562 id: post.id, slug: finalSlug, title: title || finalSlug,
563 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
564 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,
565 };
566 if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
567 else ActivityPubService.deliverUpdate(site, apPost).catch(() => { /* best-effort */ });
568 }
569
570 // Pin/unpin/reorder → push Add/Remove activities so followers' instances update the
571 // pinned order immediately (reliable, unlike re-fetching the cached featured collection).
572 if ((post.pinned || 0) !== parsePinnedRank(pinned)) {
573 const unpinned = (post.pinned || 0) > 0 && parsePinnedRank(pinned) === 0 ? [post.id] : [];
574 ActivityPubService.resyncFeaturedPins(site, unpinned).catch(() => { /* best-effort */ });
575 }
576
577 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
578});
579
580// ==================== DELETE POST ====================
581router.post('/posts/:slug/delete', requireAuth, (req, res) => {
582 const site = res.locals.site;
583 if (!site) return res.status(404).send('Site required');
584
585 const post = db.prepare(
586 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
587 ).get(site.id, req.params.slug);
588
589 if (!post) return res.status(404).send('Not found');
590 if (!PermissionsService.canDeletePost(req.session.user, post, site)) {
591 return res.status(403).send('No permission');
592 }
593
594 // ActivityPub: tell followers the post is gone (Delete + Tombstone) if it was
595 // federated (any published post now federates — fan_only goes followers-only).
596 // Fire before the row is removed — we still have post.id (= the Note id).
597 if (post.status === 'published') {
598 ActivityPubService.deliverDelete(site, post).catch(() => { /* best-effort */ });
599 }
600
601 // Cascade: comments + FTS row, THEN the post itself.
602 // FK constraints are ON (config/database.js), so a bare DELETE on posts
603 // fails when comments still reference it.
604 const cascade = db.transaction(() => {
605 db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
606 try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch {}
607 db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
608 });
609 cascade();
610
611 if (req.headers['hx-request']) {
612 res.setHeader('HX-Redirect', res.locals.siteUrlBase || '/');
613 return res.send('OK');
614 }
615 res.redirect(res.locals.siteUrlBase || '/');
616});
617
618// ==================== ARCHIVE ====================
619router.get('/archive', (req, res) => {
620 const site = res.locals.site;
621 if (!site) return res.status(404).send('No site');
622
623 const posts = db.prepare(`
624 SELECT p.*, u.username as author_username
625 FROM posts p JOIN users u ON p.author_id = u.id
626 WHERE p.site_id = ? AND p.status = 'published'
627 ORDER BY p.published_at DESC
628 `).all(site.id);
629
630 // Group by year/month
631 const grouped = {};
632 for (const post of posts) {
633 if (!post.published_at) continue;
634 const d = new Date(post.published_at);
635 const year = d.getFullYear();
636 const month = d.getMonth();
637 const monthName = ['januari','februari','maart','april','mei','juni','juli','augustus','september','oktober','november','december'][month];
638
639 if (!grouped[year]) grouped[year] = {};
640 if (!grouped[year][monthName]) grouped[year][monthName] = [];
641 grouped[year][monthName].push(post);
642 }
643
644 renderPage(req, res, 'pages/archive', {
645 grouped,
646 totalPosts: posts.length,
647 pageTitle: 'Archive - ' + site.title,
648 bodyClass: 'on-archive',
649 });
650});
651
652// Local likes/favourites are removed — engagement is fediverse-only now
653// (the ⭐ on a post likes via the fediverse). No post_likes, no /favorieten.
654
655// Newer/Older neighbours across ALL posts in feed order. Shared by the full
656// post render and the fan gate (premium fan_only) so navigation is consistent
657// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
658// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
659// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
660export function renderPostBodyHtml(site, post, req) {
661 let html = (post.content_rendered != null && post.content_rendered !== '')
662 ? post.content_rendered
663 : ActivityPubService.bakePostContent(post.content || '');
664 if (audioEnabled()) {
665 if (site.enable_audio_player !== 0) {
666 html = AudioEmbedService.autoembed(html);
667 html = AudioEmbedService.embedMediaShortcodes(html);
668 html = AudioEmbedService.embedExternalLinkShortcodes(html);
669
670 // Fetch any tracks referenced by [[track:id]] in this post.
671 // Cheap to do unconditionally — only matches if the post actually has shortcodes.
672 const trackIds = [...html.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)].map(m => m[1]);
673 if (trackIds.length) {
674 const placeholders = trackIds.map(() => '?').join(',');
675 const rows = db.prepare(`
676 SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
677 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
678 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
679 WHERE t.site_id = ? AND t.id IN (${placeholders})
680 `).all(site.id, ...trackIds);
681 const byId = new Map(rows.map(r => [r.id, r]));
682 html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
683 const r = byId.get(id);
684 if (!r) return null;
685 return {
686 id: r.id,
687 title: r.title,
688 artist: r.artist,
689 cover: r.cover_url,
690 credit: r.credit || '',
691 license: r.license || '',
692 link_spotify: r.link_spotify || '',
693 link_youtube: r.link_youtube || '',
694 link_soundcloud: r.link_soundcloud || '',
695 url: r.filename ? audioUrl(r.filename) : '', // '' = link-only track
696 };
697 });
698 }
699
700 // Album shortcodes: [[album:Some Album Name]]
701 const albumNames = [...html.matchAll(/\[\[album:([^\]]+)\]\]/g)].map(m => m[1].trim());
702 if (albumNames.length) {
703 const placeholders = albumNames.map(() => '?').join(',');
704 const albumRows = db.prepare(`
705 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
706 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
707 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
708 WHERE t.site_id = ? AND t.album IN (${placeholders})
709 ORDER BY t.position ASC, t.created_at ASC
710 `).all(site.id, ...albumNames);
711 const byAlbum = new Map();
712 for (const r of albumRows) {
713 // Link-only tracks (no file) remain in the album overview (url '').
714 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
715 byAlbum.get(r.album).push({
716 id: r.id,
717 url: r.filename ? audioUrl(r.filename) : '',
718 title: r.title || 'Untitled',
719 artist: r.artist || '',
720 cover: r.cover_url || '',
721 link_spotify: r.link_spotify || '',
722 link_youtube: r.link_youtube || '',
723 link_soundcloud: r.link_soundcloud || '',
724 });
725 }
726 html = AudioEmbedService.embedAlbumShortcodes(html, (name) => {
727 const tracks = byAlbum.get(name);
728 if (!tracks || !tracks.length) return null;
729 return {
730 title: name,
731 artist: tracks[0].artist || '',
732 cover: tracks[0].cover || '',
733 tracks,
734 };
735 });
736 }
737
738 // Playlist shortcodes: [[playlist:some-slug-id]] — first-class entity.
739 // Editing the playlist propagates to every post that embeds it.
740 const playlistIds = [...html.matchAll(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi)]
741 .map(m => m[1].toLowerCase());
742 if (playlistIds.length) {
743 const isAdmin = req.session?.user?.role === 'god';
744 html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
745 return PlaylistService.get(site.id, id, audioUrl);
746 }, { isAdmin });
747 }
748 }
749 } else {
750 // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
751 // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
752 // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
753 html = AudioEmbedService.autoembed(html);
754 html = AudioEmbedService.embedMediaShortcodes(html);
755 html = AudioEmbedService.embedExternalLinkShortcodes(html);
756 html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
757 }
758 return html;
759}
760
761// A short public teaser for a paid post: its excerpt, else the first ~280 chars
762// of the (stripped) content. Shared by the web gate and federation.
763function paidTeaser(post, max = 280) {
764 if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
765 // Only the FIRST paragraph: a paid teaser must never spill later content.
766 const html = String((post && post.content) || '');
767 const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
768 const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
769 return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
770}
771
772function postNeighbors(site, post) {
773 const ordered = db.prepare(`
774 SELECT id, slug, title, pinned FROM posts
775 WHERE site_id = ? AND status = 'published'
776 ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
777 `).all(site.id);
778 const idx = ordered.findIndex((p) => p.id === post.id);
779 const newerPost = idx > 0 ? ordered[idx - 1] : null;
780 const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
781 if (newerPost) newerPost._urlBase = '';
782 if (olderPost) olderPost._urlBase = '';
783 return { newerPost, olderPost };
784}
785
786// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
787// Standard fediverse "reply from your own server" landing endpoint. A post page
788// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
789// composes a reply that federates back to that post.
790router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
791 const site = res.locals.site;
792 const uri = (req.query.uri || '').toString();
793 const sent = !!req.query.sent;
794 const followed = !!req.query.followed;
795 const voted = !!req.query.voted;
796 const reported = !!req.query.reported;
797 let target = null, followTarget = null;
798 if (!sent && !followed && !voted && !reported && uri) {
799 try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
800 // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
801 if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
802 }
803 renderPage(req, res, 'pages/authorize-interaction', {
804 pageJs: 'authorize-interaction',
805 pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
806 bodyClass: 'on-special',
807 uri,
808 target,
809 followTarget,
810 sent,
811 followed,
812 voted: !!req.query.voted,
813 reported: !!req.query.reported,
814 liked: !!req.query.liked,
815 boosted: !!req.query.boosted,
816 reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
817 siteTitle: site ? site.title : '',
818 });
819});
820
821// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
822// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
823router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
824 const site = res.locals.site;
825 const uri = (req.body.uri || '').toString();
826 let choice = req.body.choice;
827 if (choice == null) choice = [];
828 if (!Array.isArray(choice)) choice = [choice];
829 if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
830 res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
831});
832
833// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
834router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
835 const site = res.locals.site;
836 const uri = (req.body.uri || '').toString();
837 const actorUri = (req.body.actor_uri || '').toString();
838 const reason = (req.body.reason || '').toString();
839 if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
840 res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
841});
842
843// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
844router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
845 const site = res.locals.site;
846 const uri = (req.body.uri || '').toString();
847 let on = false;
848 if (site && uri) {
849 on = !ActivityPubService.getReaction(site.slug, uri).liked;
850 ActivityPubService.resolveRemoteNote(uri)
851 .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
852 .catch((e) => console.warn('[AP] remote like failed:', e.message));
853 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
854 ActivityPubService.setReaction(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.getReaction(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 // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
874 // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
875 // de Cirkel verschijnt.
876 .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
877 })
878 .catch((e) => console.warn('[AP] remote boost failed:', e.message));
879 // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
880 // setReaction en niet via setMyReaction: ook dit korte moment mag geen
881 // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
882 // note, zodat de post ook in je tijdlijn belandt.
883 ActivityPubService.setReaction(site.slug, uri, 'boost', on);
884 }
885 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
886 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
887});
888
889// Follow a remote actor from your own site (when the target is a profile, not a post).
890router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
891 const site = res.locals.site;
892 const uri = (req.body.uri || '').toString();
893 if (site && uri) {
894 ActivityPubService.followActor(site, uri)
895 .catch((e) => console.warn('[AP] remote follow failed:', e.message));
896 }
897 res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
898});
899
900router.post('/authorize_interaction', requireSiteManager, (req, res) => {
901 const site = res.locals.site;
902 const uri = (req.body.uri || '').toString();
903 const text = (req.body.text || '').toString();
904 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
905 const language = (req.body.language || '').toString();
906 let attachments = [];
907 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
908 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
909 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
910 if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
911 // Resolve + deliver in the background so Send responds instantly.
912 ActivityPubService.resolveRemoteNote(uri)
913 .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
914 .catch((e) => console.warn('[AP] remote reply failed:', e.message));
915 }
916 res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
917});
918
919// Manage / delete your own outbound fediverse replies (site owner only).
920// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
921// The old /fediverse (manage) and /notifications pages redirect here.
922router.get('/messages', requireSiteManager, (req, res) => {
923 const site = res.locals.site;
924 const append = req.query.append === '1';
925 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
926 const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
927 const hasMore = page.length > FEED_PAGE;
928 const items = page.slice(0, FEED_PAGE);
929 // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
930 const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
931 // Only stamp "seen" on the first page load (not on Load-more appends).
932 if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
933 const moreBase = res.locals.siteUrlBase || '';
934 if (append) {
935 return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
936 }
937 // FEP-633c: pending guardianship offers TO this account (I am the ward)
938 // show as a special message with an accept button (Robins besluit: the kid
939 // answers in its own Klonkt; safety is out-of-band by the guardians).
940 const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
941 const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
942 const guardianOffers = (site
943 ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
944 : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
945 renderPage(req, res, 'pages/messages', {
946 pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages', items, seenAt,
947 hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
948 success: req.query.success || null, error: req.query.error || null,
949 });
950});
951
952// The kid answers a guardianship offer from Berichten: the same C2S
953// Accept/Reject pipeline the Shaer apps use (one path, one behavior).
954router.post('/messages/guardianship', requireSiteManager, async (req, res) => {
955 const site = res.locals.site;
956 const back = `${res.locals.siteUrlBase || ''}/messages`;
957 const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
958 const offer = String(req.body.offer || '').trim();
959 if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
960 try {
961 // Same C2S Accept/Reject the apps use; the handshake module records the
962 // ward's accept and (once the candidate returns the handle) commits.
963 const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
964 if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
965 } catch { /* fall through */ }
966 res.redirect(back + '?error=guardianship');
967});
968// A ward answers a guardian's wave without publishing: a canned private note
969// back to the sender (FEP-633c §5, shaer:wave reply). Same direct-note leg.
970router.post('/messages/quick-reply', requireSiteManager, express.urlencoded({ extended: false }), async (req, res) => {
971 const site = res.locals.site;
972 const back = `${res.locals.siteUrlBase || ''}/messages`;
973 const to = String(req.body.to || '').trim();
974 const text = String(req.body.text || '').trim().slice(0, 200);
975 if (!site || !/^https?:\/\//i.test(to) || !text) return res.redirect(back + '?error=quickreply');
976 try {
977 const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
978 if (r) return res.redirect(back + '?success=wave_sent');
979 } catch { /* fall through */ }
980 res.redirect(back + '?error=quickreply');
981});
982
983// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
984// bepaalt de draad zelf (zie groupConversations → replyTo):
985// - hangt de draad aan een post van jou, dan is dit een gewone reply op het
986// nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
987// - hangt hij aan een persoon, dan is het een direct bericht terug.
988// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
989// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
990// als enige stuurt).
991router.post('/messages/reply', requireSiteManager, async (req, res) => {
992 const site = res.locals.site;
993 const back = `${res.locals.siteUrlBase || ''}/messages`;
994 if (!site) return res.status(404).send('Site required');
995 const text = String(req.body.text || '');
996 const html = String(req.body.content || '');
997 let attachments = [];
998 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
999 let mentions;
1000 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1001 const language = String(req.body.language || '');
1002 // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
1003 if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
1004
1005 const interactionId = parseInt(req.body.interaction_id, 10) || 0;
1006 const postSlug = String(req.body.post_slug || '');
1007 const toActor = String(req.body.to || '');
1008 try {
1009 if (interactionId && postSlug) {
1010 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
1011 const parent = ActivityPubService.getInteractionById(interactionId);
1012 // De parent MOET bij deze post horen: anders zou een gemanipuleerd
1013 // formulier een antwoord onder andermans draad kunnen hangen.
1014 if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
1015 await ActivityPubService.deliverReply(site, {
1016 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
1017 });
1018 } else if (/^https?:\/\//i.test(toActor)) {
1019 const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
1020 if (!r) return res.redirect(back + '?error=reply_failed');
1021 } else {
1022 return res.redirect(back + '?error=reply_target');
1023 }
1024 } catch (e) {
1025 console.warn('[AP] reply from Berichten failed:', e.message);
1026 return res.redirect(back + '?error=reply_failed');
1027 }
1028 res.redirect(back + '?success=reply_sent');
1029});
1030
1031router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1032
1033router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
1034 const site = res.locals.site;
1035 if (site) {
1036 try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
1037 catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
1038 }
1039 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1040});
1041
1042// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
1043// object URI so re-delivery and thread-crawling never bring it back. Works for private
1044// notes too (acts on the local copy; no remote fetch involved).
1045router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
1046 const site = res.locals.site;
1047 if (site) {
1048 const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
1049 if (r.error) console.warn('[AP] interaction remove failed:', r.error);
1050 }
1051 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1052});
1053
1054// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
1055// locally stored object/actor URIs, so it also works for private notes that
1056// authorize_interaction cannot fetch (401/404).
1057router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
1058 const site = res.locals.site;
1059 if (site) {
1060 const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
1061 if (tgt && (tgt.objectUri || tgt.actorUri)) {
1062 try {
1063 const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
1064 if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
1065 } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
1066 }
1067 }
1068 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1069});
1070
1071// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
1072router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
1073 const site = res.locals.site;
1074 const text = String(req.body.text || '');
1075 const html = String(req.body.content || ''); // rich reply editor HTML (sanitized in deliverOutboxUpdate)
1076 if (site && (text.trim() || html.trim())) {
1077 try {
1078 await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
1079 html, language: String(req.body.language || ''),
1080 });
1081 } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
1082 }
1083 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1084});
1085
1086// ==================== FEDIVERSE CLIENT: home timeline + following ====================
1087// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
1088// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
1089function timelineEmbedHtml(html) {
1090 if (!html) return null;
1091 const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
1092 while ((m = re.exec(html))) {
1093 const u = m[1]; if (seen.has(u)) continue; seen.add(u);
1094 let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
1095 if (!p) {
1096 // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
1097 // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
1098 // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
1099 const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
1100 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>`;
1101 continue;
1102 }
1103 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>`;
1104 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>`;
1105 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>`;
1106 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>`;
1107 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>`;
1108 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>`; }
1109 }
1110 return null;
1111}
1112
1113// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
1114// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
1115// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
1116function klonktAudioEmbed(html, url) {
1117 if (!html || !url || html.indexOf('🎵') < 0) return null;
1118 let u; try { u = new URL(url); } catch { return null; }
1119 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
1120 const slug = u.pathname.replace(/^\/+|\/+$/g, '');
1121 if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
1122 const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
1123 // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
1124 const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
1125 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>` };
1126}
1127
1128/**
1129 * FEP-633c §5.3-style gated feature: may this account see previews of links
1130 * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
1131 *
1132 * Applied at SERVE time on every surface, the way the app's inbox read already
1133 * does it (routes/activitypub.js): a card the client merely hides has still
1134 * been delivered.
1135 */
1136function gateEmbeds(site, rows) {
1137 if (!site || !rows.length) return rows;
1138 if (embedsAllowedFor(site)) return rows;
1139 return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
1140}
1141
1142function isWardSite(site) {
1143 try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
1144}
1145function embedsAllowedFor(site) {
1146 return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
1147}
1148/**
1149 * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
1150 * sibling of the preview gate.) This was the hole: the player iframe is built
1151 * from the note's content by timelineEmbedHtml, on a path that never touched
1152 * gateEmbeds. A ward whose guardians had allowed nothing still got the full
1153 * YouTube player on the web, while the app showed nothing at all: the heavy
1154 * thing open, the light thing shut. Playback also requires the preview gate,
1155 * because you cannot play what you may not see.
1156 */
1157function playbackAllowedFor(site) {
1158 if (!site) return true;
1159 if (!embedsAllowedFor(site)) return false;
1160 return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
1161}
1162
1163router.get('/news', requireSiteManager, (req, res) => {
1164 const site = res.locals.site;
1165 const append = req.query.append === '1';
1166 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
1167 const cspOrigins = new Set();
1168 // Fetch one extra to know whether a "Load more" button belongs on this page.
1169 const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
1170 const hasMore = rows.length > FEED_PAGE;
1171 // Players (a third party's engine inside our page) ride the playback gate;
1172 // a Klonkt site's own audio embed is ours and stays.
1173 const mayPlay = playbackAllowedFor(site);
1174 const timeline = rows.slice(0, FEED_PAGE).map((p) => {
1175 let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
1176 let content = p.content;
1177 let embedUrl = null;
1178 if (!embedHtml) {
1179 const k = klonktAudioEmbed(p.content, p.url);
1180 if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
1181 }
1182 // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
1183 // top-level "open the player" link that works even when a browser shield/CSP blocks
1184 // the cross-site iframe (a full-page navigation is not a cross-site frame).
1185 let poll = null;
1186 if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
1187 return { ...p, content, embedHtml, embedUrl, poll };
1188 });
1189 // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
1190 // extending ONLY this response's CSP frame-src. The global policy stays locked down.
1191 if (cspOrigins.size) {
1192 const csp = res.getHeader('Content-Security-Policy');
1193 if (csp) {
1194 const extra = [...cspOrigins].join(' ');
1195 res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
1196 }
1197 }
1198 const moreBase = res.locals.siteUrlBase || '';
1199 if (append) {
1200 return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
1201 }
1202 renderPage(req, res, 'pages/news', {
1203 pageJs: 'news',
1204 pageTitle: 'News', bodyClass: 'on-special',
1205 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
1206 success: req.query.success || null, error: req.query.error || null,
1207 });
1208});
1209
1210// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
1211// Connect = who you follow + who follows you, merged into one page with direction
1212// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
1213// separate Following/Followers pages, which redirect here so old links keep working.
1214router.get('/connect', requireSiteManager, (req, res) => {
1215 const site = res.locals.site;
1216 const connections = site ? ActivityPubService.listConnections(site.slug) : [];
1217 // FEP-633c §2: the ward always sees who guards it, and §3.6 how available
1218 // each of them is. Connect is where "who am I connected to" belongs; a
1219 // guardian is the one connection a ward should never have to hunt for.
1220 // Owner-only by construction: this page is the owner's.
1221 const guardianHandle = (uri, cached) => {
1222 if (cached && cached.charAt(0) === '@') return cached;
1223 try { const u = new URL(uri); return `@${u.pathname.split('/').filter(Boolean).pop()}@${u.host}`; }
1224 catch { return uri; }
1225 };
1226 const gStatus = site ? Object.fromEntries(
1227 Guardianship.availability.statusesFor(site.slug, Guardianship.listGuardians(site.slug).map((g) => g.other_uri), Date.now())
1228 .map((s) => [s.id, s]),
1229 ) : {};
1230 const myGuardians = (site ? Guardianship.listGuardians(site.slug) : [])
1231 .map((g) => ({
1232 uri: g.other_uri,
1233 handle: guardianHandle(g.other_uri, g.other_handle),
1234 availability: (gStatus[g.other_uri] || {})['shaer:availability'] || 'active',
1235 awayUntil: (gStatus[g.other_uri] || {})['shaer:awayUntil'] || null,
1236 }));
1237 renderPage(req, res, 'pages/connect', {
1238 pageTitle: 'Connect', bodyClass: 'on-special',
1239 connections, myGuardians,
1240 success: req.query.success || null, error: req.query.error || null,
1241 });
1242});
1243router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1244router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1245
1246router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
1247 const site = res.locals.site;
1248 const base = res.locals.siteUrlBase || '';
1249 if (!site) return res.redirect(`${base}/connect`);
1250 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
1251 return res.redirect(`${base}/connect?` + (ok
1252 ? 'success=' + encodeURIComponent('Volger verwijderd')
1253 : 'error=' + encodeURIComponent('Volger niet gevonden')));
1254});
1255
1256router.post('/news/follow', requireSiteManager, async (req, res) => {
1257 const site = res.locals.site;
1258 const handle = (req.body.handle || '').toString();
1259 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
1260 if (site && handle.trim()) {
1261 try {
1262 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
1263 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
1264 else {
1265 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
1266 }
1267 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
1268 }
1269 res.redirect('/following?' + q);
1270});
1271
1272router.post('/news/unfollow', requireSiteManager, async (req, res) => {
1273 const site = res.locals.site;
1274 const actorUri = (req.body.actor_uri || '').toString();
1275 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
1276 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
1277});
1278
1279// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
1280router.post('/news/autoboost', requireSiteManager, (req, res) => {
1281 const site = res.locals.site;
1282 const actorUri = (req.body.actor_uri || '').toString();
1283 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
1284 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
1285});
1286
1287// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
1288// no banner); no-JS → redirect back.
1289router.post('/news/like', requireSiteManager, async (req, res) => {
1290 const site = res.locals.site;
1291 const note = (req.body.note || '').toString();
1292 let on = false;
1293 if (site && note) {
1294 on = !ActivityPubService.getReaction(site.slug, note).liked;
1295 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1296 ActivityPubService.setReaction(site.slug, note, 'like', on);
1297 }
1298 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1299 res.redirect('/news');
1300});
1301
1302// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
1303router.post('/news/boost', requireSiteManager, async (req, res) => {
1304 const site = res.locals.site;
1305 const note = (req.body.note || '').toString();
1306 let on = false;
1307 if (site && note) {
1308 on = !ActivityPubService.getReaction(site.slug, note).boosted;
1309 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1310 ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
1311 if (on) {
1312 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1313 // (cover/content) — boosting again heals a stale copy from EVERY boost
1314 // path, not just the interact page.
1315 ActivityPubService.resolveRemoteNote(note)
1316 .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
1317 .catch(() => { /* best-effort */ });
1318 }
1319 }
1320 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1321 res.redirect('/news');
1322});
1323
1324// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1325router.post('/news/vote', requireSiteManager, async (req, res) => {
1326 const site = res.locals.site;
1327 const note = (req.body.note || '').toString();
1328 let choice = req.body.choice;
1329 if (choice == null) choice = [];
1330 if (!Array.isArray(choice)) choice = [choice];
1331 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1332 res.redirect('/news');
1333});
1334
1335// Notifications inbox (new followers + replies/likes/boosts on your posts).
1336router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1337
1338// Blocking / defederation (owner-only).
1339router.get('/blocking', requireSiteManager, (req, res) => {
1340 const site = res.locals.site;
1341 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1342 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1343});
1344
1345router.post('/blocking/add', requireSiteManager, async (req, res) => {
1346 const site = res.locals.site;
1347 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1348 if (site) {
1349 try {
1350 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1351 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1352 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1353 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1354 }
1355 const ref = req.get('Referer') || '';
1356 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1357});
1358
1359router.post('/blocking/remove', requireSiteManager, (req, res) => {
1360 const site = res.locals.site;
1361 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
1362 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1363});
1364
1365// ==================== VIEW POST (last route — catches /:slug) ====================
1366router.get('/:slug', (req, res, next) => {
1367 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1368
1369 const site = res.locals.site;
1370 if (!site) return next(); // -> nette 404 catch-all
1371
1372 const post = db.prepare(`
1373 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1374 FROM posts p JOIN users u ON p.author_id = u.id
1375 WHERE p.site_id = ? AND p.slug = ?
1376 `).get(site.id, req.params.slug);
1377
1378 if (!post) return next(); // unknown slug -> clean 404 catch-all
1379
1380 // Permission to view: published OR (logged in + can edit)
1381 if (post.status !== 'published') {
1382 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1383 if (!canEdit) return res.status(403).send('Not published');
1384 }
1385
1386 // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
1387 // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
1388 // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
1389 // paid gate wins (otherwise anonymous visitors land on the login gate and
1390 // never see the unlock button).
1391 const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1392 // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
1393 // supporter render the FULL post through this normal template (correct layout,
1394 // scoped styles, working audio). Short-lived signed blob, single post, not a
1395 // cookie and not stored.
1396 const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
1397 const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
1398 if (post.paid && !canEditThis && !_unlocked) {
1399 const { newerPost, olderPost } = postNeighbors(site, post);
1400 return renderPage(req, res, 'pages/paid-gate', {
1401 pageJs: 'paid-gate',
1402 pageTitle: post.title || 'Voor supporters',
1403 bodyClass: 'on-special',
1404 pgTitle: post.title || '',
1405 pgTeaser: paidTeaser(post),
1406 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
1407 pgSlug: post.slug,
1408 pgPatronUrl: paidPatronUrl(site.id),
1409 newerPost,
1410 olderPost,
1411 });
1412 }
1413
1414 // Fan-only preview (premium #3): full content only for logged-in fans.
1415 // Anonymous visitors get a clean login gate instead of the content (the title/
1416 // teaser may still appear elsewhere as a teaser).
1417 if (post.fan_only && !(req.session && req.session.user)) {
1418 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1419 // stuck on the fan gate but can keep browsing.
1420 const { newerPost, olderPost } = postNeighbors(site, post);
1421 return renderPage(req, res, 'pages/fan-gate', {
1422 pageTitle: post.title || 'Alleen voor fans',
1423 bodyClass: 'on-special',
1424 fgTitle: post.title || '',
1425 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1426 newerPost,
1427 olderPost,
1428 });
1429 }
1430
1431 // Statistics: count the view (skips admins + unpublished own-preview).
1432 if (post.status === 'published') recordPostView(post, req);
1433
1434 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1435 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1436 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1437 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1438 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1439 post.content_html = renderPostBodyHtml(site, post, req);
1440
1441 if (post.tags) {
1442 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1443 } else {
1444 post.tags = [];
1445 }
1446
1447 // Native comments removed: social interaction is fediverse-only (see the
1448 // "From the fediverse" section below).
1449
1450 // Prev / next chronological (kept for back-compat — "post-nav" feature
1451 // below the article still uses these as a simple linear navigation).
1452 const urlBaseFor = () => '';
1453
1454 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1455 const { newerPost, olderPost } = postNeighbors(site, post);
1456
1457 // ── Related posts: same-tag matching with recency fallback ─────
1458 // Fetch ~50 candidates, score by tag overlap, take top 3.
1459 // Excluding self via `id != ?`.
1460 const candidates = db.prepare(`
1461 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1462 FROM posts
1463 WHERE site_id = ? AND status = 'published' AND id != ?
1464 ORDER BY published_at DESC LIMIT 50
1465 `).all(site.id, post.id);
1466
1467 // Parse tags JSON safely; missing/malformed → empty array.
1468 const parseTags = (raw) => {
1469 if (!raw) return [];
1470 try {
1471 const v = JSON.parse(raw);
1472 return Array.isArray(v) ? v.map(String) : [];
1473 } catch { return []; }
1474 };
1475
1476 const myTags = new Set(parseTags(post.tags));
1477 let relatedPosts;
1478 if (myTags.size > 0) {
1479 // Score = number of overlapping tags. Posts with zero overlap are
1480 // included only if we don't have 3 with-overlap candidates.
1481 const scored = candidates.map(p => {
1482 const theirTags = parseTags(p.tags);
1483 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1484 return { ...p, _overlap: overlap };
1485 });
1486 const withOverlap = scored.filter(p => p._overlap > 0)
1487 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1488 if (withOverlap.length >= 3) {
1489 relatedPosts = withOverlap.slice(0, 3);
1490 } else {
1491 // Pad with most-recent non-overlap posts so the section is never empty
1492 const overlapIds = new Set(withOverlap.map(p => p.id));
1493 const filler = candidates.filter(p => !overlapIds.has(p.id));
1494 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1495 }
1496 } else {
1497 // No tags on current post → just show 3 most-recent
1498 relatedPosts = candidates.slice(0, 3);
1499 }
1500 // Strip the internal _overlap field before sending to view
1501 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1502
1503 // Inbound fediverse activity (threaded) for this post.
1504 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1505 try {
1506 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1507 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1508 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1509 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1510 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1511 } catch { /* non-fatal */ }
1512 // Owner/admin of this site may reply back to a fediverse interaction.
1513 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1514 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1515 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1516
1517 renderPage(req, res, 'pages/post', {
1518 pageJs: 'post',
1519 post,
1520 poll: ActivityPubService.ownPollView(post),
1521 newerPost,
1522 olderPost,
1523 relatedPosts,
1524 fediverse,
1525 canManageSite,
1526 siteAvatar,
1527 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1528 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1529 pageTitle: post.title + ' - ' + site.title,
1530 socialDescr: post.excerpt || '',
1531 socialImage: post.cover_image_url || '',
1532 bodyClass: 'on-post',
1533 });
1534});
1535
1536// ── Reply back to a fediverse interaction (site owner/admin only) ──
1537router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1538 const site = res.locals.site;
1539 if (!site) return res.status(404).send('Site required');
1540 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1541 if (!post) return res.status(404).send('Not found');
1542 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1543 const text = (req.body.text || '').toString();
1544 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1545 let attachments = [];
1546 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1547 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1548 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1549 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1550 try {
1551 await ActivityPubService.deliverReply(site, {
1552 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1553 language: (req.body.language || '').toString(),
1554 });
1555 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1556 }
1557 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1558});
1559
1560// Owner likes/boosts a fediverse comment on their own post — directly as the
1561// site, no "your server" detour (mirrors /fedi-reply).
1562router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1563 const site = res.locals.site;
1564 if (!site) return res.status(404).send('Site required');
1565 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1566 if (!post) return res.status(404).send('Not found');
1567 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1568 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1569 if (parent && parent.post_id === post.id && parent.object_uri) {
1570 // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
1571 // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
1572 // de kolom en de knop uit de tussentabel, dan draait een divergentie de
1573 // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
1574 const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
1575 const on = kind === 'boost' ? !ik.boosted : !ik.liked;
1576 ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
1577 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1578 // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
1579 // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
1580 // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
1581 // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
1582 ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
1583 if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
1584 else ActivityPubService.setInteractionLiked(parent.id, on);
1585 }
1586 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1587});
1588
1589export default router;
1590export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.