source: Klonkt/src/routes/posts.js@ b4af6f3

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

Open audio travels with a paid post, and plays on the site itself

A paid post federated a bare teaser and dropped every attachment. But its
fedi_open tracks federate on their own anyway, as public Audio objects with
their context pointing back at that post. So the early return kept no secret:
it withheld only the ORDER and the fact that this is one tape. In the hub a
mixtape fell apart into loose tracks under an empty teaser card
(boiert.eu/introducing-this-machine).

The wall belongs around the TEXT. fedi_open is a separate, one-way, per-track
choice by the owner, and buildMixtapeObject already builds from
playlistOpenTracks — the tape object can only ever carry open tracks. So the
paid branch now carries them, and the redaction of the body is untouched.

The same rule on the site itself: the paid gate now shows the player when the
post's audio is open. Without it the music was available everywhere except on
the site releasing it. Only when ALL of it is open — a shortcode renders its
whole list, and /audio/stream lets a same-origin fetch through, so half open
has to count as closed. The gate gets a post that is nothing but the audio
shortcodes, so the body cannot leak through it.

Extracted openAudioAttachments() and mixtapeAttachment() out of buildNote so
both branches use one implementation.

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

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