source: Klonkt/src/routes/posts.js@ 1d76e0e

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

De reply-editor wordt een module (shaer-nh2)

Stond nog als <script src> onderaan partials/reply-editor.ejs -- het patroon dat
shaer-bqr overal weghaalde, maar dit bestand ging niet mee omdat die migratie
over INLINE scripts ging en dit er een met src is.

Bij een volledige laadbeurt ging het goed: injectCspNonce zet er een nonce op.
Bij een htmx-navigatie draagt de partial de nonce van DAT verzoek, terwijl het
document die van zijn eigen laadbeurt heeft. De CSP is strict-dynamic, dus
alleen die laatste telt en het script draaide niet. Gevolg: geen toolbar, geen
media-drop, geen .re-full -- en op mobiel dus geen fullscreen-compose.

De omzetting was mechanisch, want de opzet was al goed: init(form) had al een
__re-vlag PER FORMULIER. Na een htmx-wissel zijn de formulieren nieuw, dus
geen vlag, dus opnieuw opgewaardeerd. Alleen de IIFE, de window-vlag en de
DOMContentLoaded eromheen konden weg; init(form) heet nu initForm om plaats te
maken voor de geexporteerde init().

pageJs erbij op de drie routes die de editor tonen: Berichten, een post met
reacties, en authorize-interaction.

EN DIT IS DE VOORWAARDE VOOR HET SAMENVOEGEN. Robin wil een herbruikbaar
editor-component; een los script kan niet importeren uit mod/lib.js, een module
wel. Dit is dus stap nul van die samenvoeging en niet alleen een bugfix.

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 { 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 reply-editor',
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) return res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
894 // Afwachten in plaats van wegsturen: ligt het verzoek bij de guardians, dan
895 // moet dat op het scherm staan (shaer-p729). "followed=1" terwijl er niets
896 // gebeurd is, is precies de leugen die de poort waardeloos maakt.
897 ActivityPubService.followActor(site, uri)
898 .then((r) => res.redirect('/authorize_interaction?' + (r && r.held ? 'held=1' : 'followed=1') + '&uri=' + encodeURIComponent(uri)))
899 .catch((e) => {
900 console.warn('[AP] remote follow failed:', e.message);
901 res.redirect('/authorize_interaction?error=1&uri=' + encodeURIComponent(uri));
902 });
903});
904
905router.post('/authorize_interaction', requireSiteManager, (req, res) => {
906 const site = res.locals.site;
907 const uri = (req.body.uri || '').toString();
908 const text = (req.body.text || '').toString();
909 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
910 const language = (req.body.language || '').toString();
911 let attachments = [];
912 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
913 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
914 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
915 if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
916 // Resolve + deliver in the background so Send responds instantly.
917 ActivityPubService.resolveRemoteNote(uri)
918 .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
919 .catch((e) => console.warn('[AP] remote reply failed:', e.message));
920 }
921 res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
922});
923
924// Manage / delete your own outbound fediverse replies (site owner only).
925// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
926// The old /fediverse (manage) and /notifications pages redirect here.
927router.get('/messages', requireSiteManager, (req, res) => {
928 const site = res.locals.site;
929 const append = req.query.append === '1';
930 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
931 const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
932 const hasMore = page.length > FEED_PAGE;
933 const items = page.slice(0, FEED_PAGE);
934 // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
935 const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
936 // Only stamp "seen" on the first page load (not on Load-more appends).
937 if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
938 const moreBase = res.locals.siteUrlBase || '';
939 if (append) {
940 return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
941 }
942 // FEP-633c: pending guardianship offers TO this account (I am the ward)
943 // show as a special message with an accept button (Robins besluit: the kid
944 // answers in its own Klonkt; safety is out-of-band by the guardians).
945 const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
946 const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
947 const guardianOffers = (site
948 ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
949 : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
950 renderPage(req, res, 'pages/messages', {
951 pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages reply-editor', items, seenAt,
952 hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
953 success: req.query.success || null, error: req.query.error || null,
954 });
955});
956
957// The kid answers a guardianship offer from Berichten: the same C2S
958// Accept/Reject pipeline the Shaer apps use (one path, one behavior).
959router.post('/messages/guardianship', requireSiteManager, async (req, res) => {
960 const site = res.locals.site;
961 const back = `${res.locals.siteUrlBase || ''}/messages`;
962 const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
963 const offer = String(req.body.offer || '').trim();
964 if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
965 try {
966 // Same C2S Accept/Reject the apps use; the handshake module records the
967 // ward's accept and (once the candidate returns the handle) commits.
968 const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
969 if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
970 } catch { /* fall through */ }
971 res.redirect(back + '?error=guardianship');
972});
973// A ward answers a guardian's wave without publishing: a canned private note
974// back to the sender (FEP-633c §5, shaer:wave reply). Same direct-note leg.
975router.post('/messages/quick-reply', requireSiteManager, express.urlencoded({ extended: false }), async (req, res) => {
976 const site = res.locals.site;
977 const back = `${res.locals.siteUrlBase || ''}/messages`;
978 const to = String(req.body.to || '').trim();
979 const text = String(req.body.text || '').trim().slice(0, 200);
980 // Zwaaien is een seintje, en een seintje hoort de pagina niet te herladen.
981 // De module stuurt hem met X-Requested-With: fetch en krijgt JSON terug;
982 // zonder JS blijft het formulier gewoon posten en omleiden.
983 const viaFetch = req.get('X-Requested-With') === 'fetch';
984 const mis = (reden) => (viaFetch ? res.status(400).json({ ok: false, error: reden }) : res.redirect(back + '?error=' + reden));
985 if (!site || !/^https?:\/\//i.test(to) || !text) return mis('quickreply');
986 try {
987 const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
988 if (r) return viaFetch ? res.json({ ok: true }) : res.redirect(back + '?success=wave_sent');
989 } catch { /* fall through */ }
990 return mis('quickreply');
991});
992
993// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
994// bepaalt de draad zelf (zie groupConversations → replyTo):
995// - hangt de draad aan een post van jou, dan is dit een gewone reply op het
996// nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
997// - hangt hij aan een persoon, dan is het een direct bericht terug.
998// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
999// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
1000// als enige stuurt).
1001router.post('/messages/reply', requireSiteManager, async (req, res) => {
1002 const site = res.locals.site;
1003 const back = `${res.locals.siteUrlBase || ''}/messages`;
1004 if (!site) return res.status(404).send('Site required');
1005 const text = String(req.body.text || '');
1006 const html = String(req.body.content || '');
1007 let attachments = [];
1008 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1009 let mentions;
1010 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1011 const language = String(req.body.language || '');
1012 // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
1013 if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
1014
1015 const interactionId = parseInt(req.body.interaction_id, 10) || 0;
1016 const postSlug = String(req.body.post_slug || '');
1017 const toActor = String(req.body.to || '');
1018 try {
1019 if (interactionId && postSlug) {
1020 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
1021 const parent = ActivityPubService.getInteractionById(interactionId);
1022 // De parent MOET bij deze post horen: anders zou een gemanipuleerd
1023 // formulier een antwoord onder andermans draad kunnen hangen.
1024 if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
1025 await ActivityPubService.deliverReply(site, {
1026 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
1027 });
1028 } else if (/^https?:\/\//i.test(toActor)) {
1029 const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
1030 if (!r) return res.redirect(back + '?error=reply_failed');
1031 } else {
1032 return res.redirect(back + '?error=reply_target');
1033 }
1034 } catch (e) {
1035 console.warn('[AP] reply from Berichten failed:', e.message);
1036 return res.redirect(back + '?error=reply_failed');
1037 }
1038 res.redirect(back + '?success=reply_sent');
1039});
1040
1041router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1042
1043router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
1044 const site = res.locals.site;
1045 if (site) {
1046 try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
1047 catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
1048 }
1049 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1050});
1051
1052// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
1053// object URI so re-delivery and thread-crawling never bring it back. Works for private
1054// notes too (acts on the local copy; no remote fetch involved).
1055router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
1056 const site = res.locals.site;
1057 if (site) {
1058 const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
1059 if (r.error) console.warn('[AP] interaction remove failed:', r.error);
1060 }
1061 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1062});
1063
1064// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
1065// locally stored object/actor URIs, so it also works for private notes that
1066// authorize_interaction cannot fetch (401/404).
1067router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
1068 const site = res.locals.site;
1069 if (site) {
1070 const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
1071 if (tgt && (tgt.objectUri || tgt.actorUri)) {
1072 try {
1073 const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
1074 if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
1075 } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
1076 }
1077 }
1078 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1079});
1080
1081// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
1082router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
1083 const site = res.locals.site;
1084 const text = String(req.body.text || '');
1085 const html = String(req.body.content || ''); // rich reply editor HTML (sanitized in deliverOutboxUpdate)
1086 if (site && (text.trim() || html.trim())) {
1087 try {
1088 await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
1089 html, language: String(req.body.language || ''),
1090 });
1091 } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
1092 }
1093 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1094});
1095
1096// ==================== FEDIVERSE CLIENT: home timeline + following ====================
1097// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
1098// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
1099function timelineEmbedHtml(html) {
1100 if (!html) return null;
1101 const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
1102 while ((m = re.exec(html))) {
1103 const u = m[1]; if (seen.has(u)) continue; seen.add(u);
1104 let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
1105 if (!p) {
1106 // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
1107 // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
1108 // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
1109 const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
1110 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>`;
1111 continue;
1112 }
1113 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>`;
1114 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>`;
1115 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>`;
1116 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>`;
1117 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>`;
1118 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>`; }
1119 }
1120 return null;
1121}
1122
1123// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
1124// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
1125// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
1126function klonktAudioEmbed(html, url) {
1127 if (!html || !url || html.indexOf('🎵') < 0) return null;
1128 let u; try { u = new URL(url); } catch { return null; }
1129 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
1130 const slug = u.pathname.replace(/^\/+|\/+$/g, '');
1131 if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
1132 const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
1133 // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
1134 const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
1135 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>` };
1136}
1137
1138/**
1139 * FEP-633c §5.3-style gated feature: may this account see previews of links
1140 * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
1141 *
1142 * Applied at SERVE time on every surface, the way the app's inbox read already
1143 * does it (routes/activitypub.js): a card the client merely hides has still
1144 * been delivered.
1145 */
1146function gateEmbeds(site, rows) {
1147 if (!site || !rows.length) return rows;
1148 if (embedsAllowedFor(site)) return rows;
1149 return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
1150}
1151
1152function isWardSite(site) {
1153 try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
1154}
1155function embedsAllowedFor(site) {
1156 return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
1157}
1158/**
1159 * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
1160 * sibling of the preview gate.) This was the hole: the player iframe is built
1161 * from the note's content by timelineEmbedHtml, on a path that never touched
1162 * gateEmbeds. A ward whose guardians had allowed nothing still got the full
1163 * YouTube player on the web, while the app showed nothing at all: the heavy
1164 * thing open, the light thing shut. Playback also requires the preview gate,
1165 * because you cannot play what you may not see.
1166 */
1167function playbackAllowedFor(site) {
1168 if (!site) return true;
1169 if (!embedsAllowedFor(site)) return false;
1170 return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
1171}
1172
1173router.get('/news', requireSiteManager, (req, res) => {
1174 const site = res.locals.site;
1175 const append = req.query.append === '1';
1176 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
1177 const cspOrigins = new Set();
1178 // Fetch one extra to know whether a "Load more" button belongs on this page.
1179 const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
1180 const hasMore = rows.length > FEED_PAGE;
1181 // Players (a third party's engine inside our page) ride the playback gate;
1182 // a Klonkt site's own audio embed is ours and stays.
1183 const mayPlay = playbackAllowedFor(site);
1184 const timeline = rows.slice(0, FEED_PAGE).map((p) => {
1185 let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
1186 let content = p.content;
1187 let embedUrl = null;
1188 if (!embedHtml) {
1189 const k = klonktAudioEmbed(p.content, p.url);
1190 if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
1191 }
1192 // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
1193 // top-level "open the player" link that works even when a browser shield/CSP blocks
1194 // the cross-site iframe (a full-page navigation is not a cross-site frame).
1195 let poll = null;
1196 if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
1197 return { ...p, content, embedHtml, embedUrl, poll };
1198 });
1199 // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
1200 // extending ONLY this response's CSP frame-src. The global policy stays locked down.
1201 if (cspOrigins.size) {
1202 const csp = res.getHeader('Content-Security-Policy');
1203 if (csp) {
1204 const extra = [...cspOrigins].join(' ');
1205 res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
1206 }
1207 }
1208 const moreBase = res.locals.siteUrlBase || '';
1209 if (append) {
1210 return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
1211 }
1212 renderPage(req, res, 'pages/news', {
1213 pageJs: 'news',
1214 pageTitle: 'News', bodyClass: 'on-special',
1215 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
1216 success: req.query.success || null, error: req.query.error || null,
1217 });
1218});
1219
1220// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
1221// Connect = who you follow + who follows you, merged into one page with direction
1222// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
1223// separate Following/Followers pages, which redirect here so old links keep working.
1224router.get('/connect', requireSiteManager, (req, res) => {
1225 const site = res.locals.site;
1226 const connections = site ? ActivityPubService.listConnections(site.slug) : [];
1227 // FEP-633c §2: the ward always sees who guards it, and §3.6 how available
1228 // each of them is. Connect is where "who am I connected to" belongs; a
1229 // guardian is the one connection a ward should never have to hunt for.
1230 // Owner-only by construction: this page is the owner's.
1231 const guardianHandle = (uri, cached) => {
1232 if (cached && cached.charAt(0) === '@') return cached;
1233 try { const u = new URL(uri); return `@${u.pathname.split('/').filter(Boolean).pop()}@${u.host}`; }
1234 catch { return uri; }
1235 };
1236 const gStatus = site ? Object.fromEntries(
1237 Guardianship.availability.statusesFor(site.slug, Guardianship.listGuardians(site.slug).map((g) => g.other_uri), Date.now())
1238 .map((s) => [s.id, s]),
1239 ) : {};
1240 const myGuardians = (site ? Guardianship.listGuardians(site.slug) : [])
1241 .map((g) => ({
1242 uri: g.other_uri,
1243 handle: guardianHandle(g.other_uri, g.other_handle),
1244 availability: (gStatus[g.other_uri] || {})['shaer:availability'] || 'active',
1245 awayUntil: (gStatus[g.other_uri] || {})['shaer:awayUntil'] || null,
1246 }));
1247 renderPage(req, res, 'pages/connect', {
1248 pageTitle: 'Connect', bodyClass: 'on-special',
1249 connections, myGuardians,
1250 success: req.query.success || null, error: req.query.error || null,
1251 });
1252});
1253router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1254router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1255
1256router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
1257 const site = res.locals.site;
1258 const base = res.locals.siteUrlBase || '';
1259 if (!site) return res.redirect(`${base}/connect`);
1260 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
1261 return res.redirect(`${base}/connect?` + (ok
1262 ? 'success=' + encodeURIComponent('Volger verwijderd')
1263 : 'error=' + encodeURIComponent('Volger niet gevonden')));
1264});
1265
1266router.post('/news/follow', requireSiteManager, async (req, res) => {
1267 const site = res.locals.site;
1268 const handle = (req.body.handle || '').toString();
1269 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
1270 if (site && handle.trim()) {
1271 try {
1272 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
1273 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
1274 // Een DERDE uitkomst, niet gelukt en niet mislukt (shaer-p729). "Je volgt
1275 // nu X" zeggen terwijl het verzoek bij de guardians ligt is de leugen die
1276 // deze poort waardeloos maakt: het kind denkt dat het gebeurd is.
1277 else if (r && r.held) q = 'success=' + encodeURIComponent(r.status === 'denied' ? 'Je guardians hebben dit geweigerd' : 'Je verzoek ligt bij je guardians');
1278 else {
1279 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
1280 }
1281 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
1282 }
1283 res.redirect('/following?' + q);
1284});
1285
1286router.post('/news/unfollow', requireSiteManager, async (req, res) => {
1287 const site = res.locals.site;
1288 const actorUri = (req.body.actor_uri || '').toString();
1289 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
1290 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
1291});
1292
1293// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
1294router.post('/news/autoboost', requireSiteManager, (req, res) => {
1295 const site = res.locals.site;
1296 const actorUri = (req.body.actor_uri || '').toString();
1297 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
1298 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
1299});
1300
1301// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
1302// no banner); no-JS → redirect back.
1303router.post('/news/like', 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).liked;
1309 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1310 ActivityPubService.setReaction(site.slug, note, 'like', on);
1311 }
1312 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1313 res.redirect('/news');
1314});
1315
1316// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
1317router.post('/news/boost', requireSiteManager, async (req, res) => {
1318 const site = res.locals.site;
1319 const note = (req.body.note || '').toString();
1320 let on = false;
1321 if (site && note) {
1322 on = !ActivityPubService.getReaction(site.slug, note).boosted;
1323 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1324 ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
1325 if (on) {
1326 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1327 // (cover/content) — boosting again heals a stale copy from EVERY boost
1328 // path, not just the interact page.
1329 ActivityPubService.resolveRemoteNote(note)
1330 .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
1331 .catch(() => { /* best-effort */ });
1332 }
1333 }
1334 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1335 res.redirect('/news');
1336});
1337
1338// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1339router.post('/news/vote', requireSiteManager, async (req, res) => {
1340 const site = res.locals.site;
1341 const note = (req.body.note || '').toString();
1342 let choice = req.body.choice;
1343 if (choice == null) choice = [];
1344 if (!Array.isArray(choice)) choice = [choice];
1345 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1346 res.redirect('/news');
1347});
1348
1349// Notifications inbox (new followers + replies/likes/boosts on your posts).
1350router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1351
1352// Blocking / defederation (owner-only).
1353router.get('/blocking', requireSiteManager, (req, res) => {
1354 const site = res.locals.site;
1355 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1356 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1357});
1358
1359router.post('/blocking/add', requireSiteManager, async (req, res) => {
1360 const site = res.locals.site;
1361 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1362 if (site) {
1363 try {
1364 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1365 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1366 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1367 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1368 }
1369 const ref = req.get('Referer') || '';
1370 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1371});
1372
1373router.post('/blocking/remove', requireSiteManager, (req, res) => {
1374 const site = res.locals.site;
1375 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
1376 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1377});
1378
1379// ==================== VIEW POST (last route — catches /:slug) ====================
1380router.get('/:slug', (req, res, next) => {
1381 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1382
1383 const site = res.locals.site;
1384 if (!site) return next(); // -> nette 404 catch-all
1385
1386 const post = db.prepare(`
1387 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1388 FROM posts p JOIN users u ON p.author_id = u.id
1389 WHERE p.site_id = ? AND p.slug = ?
1390 `).get(site.id, req.params.slug);
1391
1392 if (!post) return next(); // unknown slug -> clean 404 catch-all
1393
1394 // Permission to view: published OR (logged in + can edit)
1395 if (post.status !== 'published') {
1396 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1397 if (!canEdit) return res.status(403).send('Not published');
1398 }
1399
1400 // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
1401 // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
1402 // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
1403 // paid gate wins (otherwise anonymous visitors land on the login gate and
1404 // never see the unlock button).
1405 const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1406 // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
1407 // supporter render the FULL post through this normal template (correct layout,
1408 // scoped styles, working audio). Short-lived signed blob, single post, not a
1409 // cookie and not stored.
1410 const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
1411 const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
1412 if (post.paid && !canEditThis && !_unlocked) {
1413 const { newerPost, olderPost } = postNeighbors(site, post);
1414 return renderPage(req, res, 'pages/paid-gate', {
1415 pageJs: 'paid-gate',
1416 pageTitle: post.title || 'Voor supporters',
1417 bodyClass: 'on-special',
1418 pgTitle: post.title || '',
1419 pgTeaser: paidTeaser(post),
1420 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
1421 pgSlug: post.slug,
1422 pgPatronUrl: paidPatronUrl(site.id),
1423 newerPost,
1424 olderPost,
1425 });
1426 }
1427
1428 // Fan-only preview (premium #3): full content only for logged-in fans.
1429 // Anonymous visitors get a clean login gate instead of the content (the title/
1430 // teaser may still appear elsewhere as a teaser).
1431 if (post.fan_only && !(req.session && req.session.user)) {
1432 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1433 // stuck on the fan gate but can keep browsing.
1434 const { newerPost, olderPost } = postNeighbors(site, post);
1435 return renderPage(req, res, 'pages/fan-gate', {
1436 pageTitle: post.title || 'Alleen voor fans',
1437 bodyClass: 'on-special',
1438 fgTitle: post.title || '',
1439 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1440 newerPost,
1441 olderPost,
1442 });
1443 }
1444
1445 // Statistics: count the view (skips admins + unpublished own-preview).
1446 if (post.status === 'published') recordPostView(post, req);
1447
1448 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1449 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1450 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1451 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1452 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1453 post.content_html = renderPostBodyHtml(site, post, req);
1454
1455 if (post.tags) {
1456 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1457 } else {
1458 post.tags = [];
1459 }
1460
1461 // Native comments removed: social interaction is fediverse-only (see the
1462 // "From the fediverse" section below).
1463
1464 // Prev / next chronological (kept for back-compat — "post-nav" feature
1465 // below the article still uses these as a simple linear navigation).
1466 const urlBaseFor = () => '';
1467
1468 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1469 const { newerPost, olderPost } = postNeighbors(site, post);
1470
1471 // ── Related posts: same-tag matching with recency fallback ─────
1472 // Fetch ~50 candidates, score by tag overlap, take top 3.
1473 // Excluding self via `id != ?`.
1474 const candidates = db.prepare(`
1475 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1476 FROM posts
1477 WHERE site_id = ? AND status = 'published' AND id != ?
1478 ORDER BY published_at DESC LIMIT 50
1479 `).all(site.id, post.id);
1480
1481 // Parse tags JSON safely; missing/malformed → empty array.
1482 const parseTags = (raw) => {
1483 if (!raw) return [];
1484 try {
1485 const v = JSON.parse(raw);
1486 return Array.isArray(v) ? v.map(String) : [];
1487 } catch { return []; }
1488 };
1489
1490 const myTags = new Set(parseTags(post.tags));
1491 let relatedPosts;
1492 if (myTags.size > 0) {
1493 // Score = number of overlapping tags. Posts with zero overlap are
1494 // included only if we don't have 3 with-overlap candidates.
1495 const scored = candidates.map(p => {
1496 const theirTags = parseTags(p.tags);
1497 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1498 return { ...p, _overlap: overlap };
1499 });
1500 const withOverlap = scored.filter(p => p._overlap > 0)
1501 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1502 if (withOverlap.length >= 3) {
1503 relatedPosts = withOverlap.slice(0, 3);
1504 } else {
1505 // Pad with most-recent non-overlap posts so the section is never empty
1506 const overlapIds = new Set(withOverlap.map(p => p.id));
1507 const filler = candidates.filter(p => !overlapIds.has(p.id));
1508 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1509 }
1510 } else {
1511 // No tags on current post → just show 3 most-recent
1512 relatedPosts = candidates.slice(0, 3);
1513 }
1514 // Strip the internal _overlap field before sending to view
1515 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1516
1517 // Inbound fediverse activity (threaded) for this post.
1518 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1519 try {
1520 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1521 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1522 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1523 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1524 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1525 } catch { /* non-fatal */ }
1526 // Owner/admin of this site may reply back to a fediverse interaction.
1527 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1528 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1529 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1530
1531 renderPage(req, res, 'pages/post', {
1532 pageJs: 'post reply-editor',
1533 post,
1534 poll: ActivityPubService.ownPollView(post),
1535 newerPost,
1536 olderPost,
1537 relatedPosts,
1538 fediverse,
1539 canManageSite,
1540 siteAvatar,
1541 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1542 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1543 pageTitle: post.title + ' - ' + site.title,
1544 socialDescr: post.excerpt || '',
1545 socialImage: post.cover_image_url || '',
1546 bodyClass: 'on-post',
1547 });
1548});
1549
1550// ── Reply back to a fediverse interaction (site owner/admin only) ──
1551router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1552 const site = res.locals.site;
1553 if (!site) return res.status(404).send('Site required');
1554 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1555 if (!post) return res.status(404).send('Not found');
1556 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1557 const text = (req.body.text || '').toString();
1558 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1559 let attachments = [];
1560 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1561 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1562 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1563 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1564 try {
1565 await ActivityPubService.deliverReply(site, {
1566 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1567 language: (req.body.language || '').toString(),
1568 });
1569 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1570 }
1571 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1572});
1573
1574// Owner likes/boosts a fediverse comment on their own post — directly as the
1575// site, no "your server" detour (mirrors /fedi-reply).
1576router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1577 const site = res.locals.site;
1578 if (!site) return res.status(404).send('Site required');
1579 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1580 if (!post) return res.status(404).send('Not found');
1581 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1582 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1583 if (parent && parent.post_id === post.id && parent.object_uri) {
1584 // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
1585 // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
1586 // de kolom en de knop uit de tussentabel, dan draait een divergentie de
1587 // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
1588 const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
1589 const on = kind === 'boost' ? !ik.boosted : !ik.liked;
1590 ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
1591 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1592 // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
1593 // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
1594 // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
1595 // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
1596 ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
1597 if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
1598 else ActivityPubService.setInteractionLiked(parent.id, on);
1599 }
1600 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1601});
1602
1603export default router;
1604export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.