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

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

Zwaaien is een klik, geen paginawissel

Een zwaai is een seintje. Dat de hele pagina ervoor herlaadde -- met een
groene balk bovenaan en je scrollpositie kwijt -- gaf het meer gewicht dan
het heeft.

Nu onderschept mod/messages.js de verzending en stuurt hem met fetch, zoals
like/boost in de Krant al deden. De knoppen maken plaats voor een korte
bevestiging; mislukt het, dan komen ze terug met een schud.

DE VAL HIER, en hij verschilt van like/boost: deze formulieren hebben MEERDERE
verzendknoppen ("Wat leuk!", "Hoe gaat het?", "Terug"), elk met een eigen
name="text". FormData(f) neemt de aangeklikte knop NIET mee -- dat doet de
browser alleen bij een echte verzending. Zonder e.submitter vertrekt er dus
een zwaai zonder tekst. Er is een test die precies dat vastlegt: een verzoek
zonder text moet 400 geven en NIETS versturen.

Het formulier blijft een formulier: zonder JS post het en leidt de route om,
ongewijzigd. De route kiest op X-Requested-With welke van de twee.

Geen aan/uit-knop zoals bij een like: zwaaien valt niet terug te draaien, dus
een toggle zou liegen over wat er kan.

Eigen CSS voor de twee toestanden, want de bestaande klasse nudge bleek
alleen op authorize-interaction opmaak te hebben -- op Berichten deed hij niets
(en doet dat nog steeds niet voor de bookmarklet-knop daar).

5 tests erbij.

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

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