source: Klonkt/src/routes/posts.js@ 746d98a

main
Last change on this file since 746d98a was 31680c3, checked in by Robin <roboburr@…>, 5 hours ago

[Add to HUB] on the Connect page — the click is the owner's yes

Putting a klonkt on the Klonkt Hub means the hub sends a Follow. With the
owner gate on (the default) that Follow then waits for the owner — the same
person who just asked for it. Without this they would sign up on the hub and
then have to approve their own sign-up on /connect.

The button POSTs to /connect/add-to-hub, which records a one-day invitation
and redirects to the hub's sign-up form with the site's handle filled in
(?add=). A Follow from the hub's host, signed by the hub itself, passes the
owner gate while that invitation holds. It is not used up on the first
Follow: a hub that re-sends after a timeout would otherwise land in the queue
for something already decided.

Narrow on purpose: only the hub's host, only signed by it, only for a day.
Anyone else still waits for the owner, and a WARD's guardians still decide —
their gate sits before this one and this path never reaches it.

If a request from the hub is already waiting (someone signed the klonkt up
earlier), the click is exactly the yes it waits for: it is accepted on the
spot and the button goes to the channel on the hub instead of the form. Once
the hub follows, /connect shows a confirmation instead of the button. Hidden
after a move, where the outgoing side is locked.

KLONKT_HUB_URL lets a self-hoster point the button at another hub; default
​https://hub.klonkt.com.

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

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