source: Klonkt/src/routes/posts.js@ 7cfe35a

main
Last change on this file since 7cfe35a was fb9a8ad, checked in by roboburr <roboburr@…>, 5 weeks ago

De posteditor, de laatste (shaer-bqr, stap 3g)

Zeven blokken, ruim 1000 regels, 34 vertaalsleutels en een tijdzone. Daarmee is
er in pages en partials geen inline script meer over, op embed-player na -- die
staat los (res.render, eigen CSP, altijd een volledige laadbeurt).

ESCAPEN. Deze pagina bouwt op plekken HTML met stringplakwerk, en daar gingen de
vertalingen doorheen als escapende EJS-tag. Een module heeft die niet, dus er is
een esc() bij gekomen in lib.js en alle 34 gaan daardoorheen. Dat is
gedragsgelijk aan wat er stond, en het houdt een apostrof in een vertaling uit
het attribuut waar hij in staat -- het soort fout dat pas in een andere taal
opvalt.

EEN FOUT DIE DE STEEKPROEF VING: ik liet de vervanging kiezen tussen een enkele
en een dubbele aanhalingsvorm op grond van het teken ernaast. Maar bij
aria-label="..." is dat aanhalingsteken HTML en niet de grens van de JS-string;
die is overal enkel. Resultaat was aria-label="" + esc(T.title) + "". Teruggezet
en uniform de enkele vorm gebruikt.

post-edit neemt de playlist-editor op, dus de route vraagt om beide modules.

Templates compileren, 23 modules schoon en syntactisch geldig, suite 565/565.

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