source: Klonkt/src/routes/posts.js@ 3ca7bdf

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

De editpagina laadt zijn editor weer, en opslaan zonder module wist niets meer

Robins test at een post op: /posts/:slug/edit rendert dezelfde template als
/posts/new, maar alleen de nieuw-route kreeg bij shaer-bqr zijn pageJs. De
editpagina had dus data-js="chrome" -- geen editor, geen mediaprompts, en
een verborgen contentveld dat leeg rendert. Opslaan schreef dat lege veld
naar de database: strike-homerun verloor zo om 18:54 zijn inhoud (testpost
van 18:12, niets ouds verloren).

Drie lagen, van symptoom naar vangrail:

  1. de edit-route declareert pageJs 'post-edit playlist-editor'
  2. het verborgen veld rendert de BESTAANDE inhoud: laadt de module niet (oude cache, js uit), dan is opslaan een no-op in plaats van een wisser
  3. test/page-modules.test.js scheurt luid als een render van de editor zijn modules vergeet -- een brontekst-test, bewust, want het gedrag leeft in de browser en dit is de fout die twee keer beet

En dezelfde audit ving download: drie renders, en pageJs stond op het
FORMULIER in plaats van op ready -- de auto-start hoorde v66r shaer-bqr bij
de ready-tak en omzeilde nu de e-mailvraag. Verplaatst, en de module weigert
voortaan een lege fileUrl.

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

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