source: Klonkt/src/routes/posts.js@ 09fc5fb

main
Last change on this file since 09fc5fb was d7e72b8, checked in by Robin <roboburr@…>, 4 weeks ago

Audio is geen keuze meer: Album en Playlist staan in de balk (shaer-cyg)

De posttype-balk had post/foto/video/audio. Audio verdwijnt als keuze -- alles
wat muziek is landt voortaan op album of playlist, en dat maakt de keuze
betekenisvol in plaats van cosmetisch. Album en Playlist delen het muziekpaneel,
want het verschil zit in de playlist en niet in wat je uploadt.

De knop Audio blijft alleen staan zolang een post nog type=audio IS. Anders zou
het openen van een oude post hem stilzwijgend van type veranderen, en dat is
precies het soort verlies dat niemand ziet gebeuren.

De lijst van geldige types stond op DRIE plaatsen los van elkaar: twee keer in
routes/posts.js en een keer in routes/types.js. Dat viel niet op zolang ze
gelijk waren, maar het faalt stil: kent het opslaan 'playlist' niet, dan wordt
de post zonder melding een gewone post en is de keuze verdwenen in plaats van
geweigerd. Nu een lijst, in config/post-types.js, die ook de editor voedt.

En de migratie: scripts/backfill-post-types.mjs zet bestaande audio-posts om via
postMusicType. Proefdraai is de standaard, --doen schrijft. Wat de afleiding
niet tot album of playlist maakt blijft met rust en wordt apart gemeld -- die
verdienen een blik en geen gok.

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

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