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

main
Last change on this file since af2cc73 was af2cc73, checked in by Bart <bart@…>, 3 weeks ago

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

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

  • Property mode set to 100644
File size: 89.3 KB
Line 
1import express from 'express';
2import { v4 as uuid } from 'uuid';
3import path from 'path';
4import fs from 'fs';
5import multer from 'multer';
6import ejs from 'ejs';
7import db from '../config/database.js';
8import { POST_TYPES, KEUZE_TYPES } from '../config/post-types.js';
9import { requireAuth, requireSiteManager, isViewer } from '../middleware/auth.js';
10import { renderPage } from '../middleware/render.js';
11import { recordPageview, recordPostView } from '../services/StatsService.js';
12import PermissionsService from '../services/PermissionsService.js';
13import MarkdownService from '../services/MarkdownService.js';
14import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
15import AudioEmbedService from '../services/AudioEmbedService.js';
16import PlaylistService from '../services/PlaylistService.js';
17import { audioEnabled } from '../config/features.js';
18import { audioUrl } from '../services/AudioStreamService.js';
19import { toWebp } from '../services/ImageWebpService.js';
20import VideoCoverService from '../services/VideoCoverService.js';
21import ActivityPubService from '../services/ActivityPubService.js';
22import * as Guardianship from '../services/guardianship/index.js';
23import { 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',
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
824function postNeighbors(site, post) {
825 const ordered = db.prepare(`
826 SELECT id, slug, title, pinned FROM posts
827 WHERE site_id = ? AND status = 'published'
828 ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
829 `).all(site.id);
830 const idx = ordered.findIndex((p) => p.id === post.id);
831 const newerPost = idx > 0 ? ordered[idx - 1] : null;
832 const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
833 if (newerPost) newerPost._urlBase = '';
834 if (olderPost) olderPost._urlBase = '';
835 return { newerPost, olderPost };
836}
837
838// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
839// Standard fediverse "reply from your own server" landing endpoint. A post page
840// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
841// composes a reply that federates back to that post.
842router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
843 const site = res.locals.site;
844 const uri = (req.query.uri || '').toString();
845 const sent = !!req.query.sent;
846 const followed = !!req.query.followed;
847 const voted = !!req.query.voted;
848 const reported = !!req.query.reported;
849 let target = null, followTarget = null;
850 if (!sent && !followed && !voted && !reported && uri) {
851 try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
852 // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
853 if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
854 }
855 renderPage(req, res, 'pages/authorize-interaction', {
856 pageJs: 'authorize-interaction reply-editor',
857 pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
858 bodyClass: 'on-special',
859 uri,
860 target,
861 followTarget,
862 sent,
863 followed,
864 voted: !!req.query.voted,
865 reported: !!req.query.reported,
866 liked: !!req.query.liked,
867 boosted: !!req.query.boosted,
868 reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
869 siteTitle: site ? site.title : '',
870 });
871});
872
873// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
874// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
875router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
876 const site = res.locals.site;
877 const uri = (req.body.uri || '').toString();
878 let choice = req.body.choice;
879 if (choice == null) choice = [];
880 if (!Array.isArray(choice)) choice = [choice];
881 if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
882 res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
883});
884
885// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
886router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
887 const site = res.locals.site;
888 const uri = (req.body.uri || '').toString();
889 const actorUri = (req.body.actor_uri || '').toString();
890 const reason = (req.body.reason || '').toString();
891 if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
892 res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
893});
894
895// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
896router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
897 const site = res.locals.site;
898 const uri = (req.body.uri || '').toString();
899 let on = false;
900 if (site && uri) {
901 on = !ActivityPubService.getReaction(site.slug, uri).liked;
902 ActivityPubService.resolveRemoteNote(uri)
903 .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
904 .catch((e) => console.warn('[AP] remote like failed:', e.message));
905 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
906 ActivityPubService.setReaction(site.slug, uri, 'like', on);
907 }
908 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
909 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
910});
911
912// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
913// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
914router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
915 const site = res.locals.site;
916 const uri = (req.body.uri || '').toString();
917 let on = false;
918 if (site && uri) {
919 on = !ActivityPubService.getReaction(site.slug, uri).boosted;
920 ActivityPubService.resolveRemoteNote(uri)
921 .then((note) => {
922 if (!note) return;
923 const id = note.object_uri || uri;
924 return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
925 // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
926 // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
927 // de Cirkel verschijnt.
928 .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
929 })
930 .catch((e) => console.warn('[AP] remote boost failed:', e.message));
931 // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
932 // setReaction en niet via setMyReaction: ook dit korte moment mag geen
933 // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
934 // note, zodat de post ook in je tijdlijn belandt.
935 ActivityPubService.setReaction(site.slug, uri, 'boost', on);
936 }
937 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
938 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
939});
940
941// Follow a remote actor from your own site (when the target is a profile, not a post).
942router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
943 const site = res.locals.site;
944 const uri = (req.body.uri || '').toString();
945 if (!site || !uri) return res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
946 // Afwachten in plaats van wegsturen: ligt het verzoek bij de guardians, dan
947 // moet dat op het scherm staan (shaer-p729). "followed=1" terwijl er niets
948 // gebeurd is, is precies de leugen die de poort waardeloos maakt.
949 ActivityPubService.followActor(site, uri)
950 .then((r) => res.redirect('/authorize_interaction?' + (r && r.held ? 'held=1' : 'followed=1') + '&uri=' + encodeURIComponent(uri)))
951 .catch((e) => {
952 console.warn('[AP] remote follow failed:', e.message);
953 res.redirect('/authorize_interaction?error=1&uri=' + encodeURIComponent(uri));
954 });
955});
956
957router.post('/authorize_interaction', requireSiteManager, (req, res) => {
958 const site = res.locals.site;
959 const uri = (req.body.uri || '').toString();
960 const text = (req.body.text || '').toString();
961 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
962 const language = (req.body.language || '').toString();
963 let attachments = [];
964 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
965 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
966 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
967 if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
968 // Resolve + deliver in the background so Send responds instantly.
969 ActivityPubService.resolveRemoteNote(uri)
970 .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
971 .catch((e) => console.warn('[AP] remote reply failed:', e.message));
972 }
973 res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
974});
975
976// Manage / delete your own outbound fediverse replies (site owner only).
977// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
978// The old /fediverse (manage) and /notifications pages redirect here.
979router.get('/messages', requireSiteManager, (req, res) => {
980 const site = res.locals.site;
981 const append = req.query.append === '1';
982 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
983 const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
984 const hasMore = page.length > FEED_PAGE;
985 const items = page.slice(0, FEED_PAGE);
986 // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
987 const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
988 // Only stamp "seen" on the first page load (not on Load-more appends).
989 if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
990 const moreBase = res.locals.siteUrlBase || '';
991 if (append) {
992 return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
993 }
994 // FEP-633c: pending guardianship offers TO this account (I am the ward)
995 // show as a special message with an accept button (Robins besluit: the kid
996 // answers in its own Klonkt; safety is out-of-band by the guardians).
997 const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
998 const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
999 const guardianOffers = (site
1000 ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
1001 : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
1002 renderPage(req, res, 'pages/messages', {
1003 pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages reply-editor', items, seenAt,
1004 hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
1005 success: req.query.success || null, error: req.query.error || null,
1006 });
1007});
1008
1009// The kid answers a guardianship offer from Berichten: the same C2S
1010// Accept/Reject pipeline the Shaer apps use (one path, one behavior).
1011router.post('/messages/guardianship', requireSiteManager, async (req, res) => {
1012 const site = res.locals.site;
1013 const back = `${res.locals.siteUrlBase || ''}/messages`;
1014 const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
1015 const offer = String(req.body.offer || '').trim();
1016 if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
1017 try {
1018 // Same C2S Accept/Reject the apps use; the handshake module records the
1019 // ward's accept and (once the candidate returns the handle) commits.
1020 const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
1021 if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
1022 } catch { /* fall through */ }
1023 res.redirect(back + '?error=guardianship');
1024});
1025// A ward answers a guardian's wave without publishing: a canned private note
1026// back to the sender (FEP-633c §5, shaer:wave reply). Same direct-note leg.
1027router.post('/messages/quick-reply', requireSiteManager, express.urlencoded({ extended: false }), async (req, res) => {
1028 const site = res.locals.site;
1029 const back = `${res.locals.siteUrlBase || ''}/messages`;
1030 const to = String(req.body.to || '').trim();
1031 const text = String(req.body.text || '').trim().slice(0, 200);
1032 // Zwaaien is een seintje, en een seintje hoort de pagina niet te herladen.
1033 // De module stuurt hem met X-Requested-With: fetch en krijgt JSON terug;
1034 // zonder JS blijft het formulier gewoon posten en omleiden.
1035 const viaFetch = req.get('X-Requested-With') === 'fetch';
1036 const mis = (reden) => (viaFetch ? res.status(400).json({ ok: false, error: reden }) : res.redirect(back + '?error=' + reden));
1037 if (!site || !/^https?:\/\//i.test(to) || !text) return mis('quickreply');
1038 try {
1039 const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
1040 if (r) return viaFetch ? res.json({ ok: true }) : res.redirect(back + '?success=wave_sent');
1041 } catch { /* fall through */ }
1042 return mis('quickreply');
1043});
1044
1045// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
1046// bepaalt de draad zelf (zie groupConversations → replyTo):
1047// - hangt de draad aan een post van jou, dan is dit een gewone reply op het
1048// nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
1049// - hangt hij aan een persoon, dan is het een direct bericht terug.
1050// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
1051// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
1052// als enige stuurt).
1053router.post('/messages/reply', requireSiteManager, async (req, res) => {
1054 const site = res.locals.site;
1055 const back = `${res.locals.siteUrlBase || ''}/messages`;
1056 if (!site) return res.status(404).send('Site required');
1057 const text = String(req.body.text || '');
1058 const html = String(req.body.content || '');
1059 let attachments = [];
1060 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1061 let mentions;
1062 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1063 const language = String(req.body.language || '');
1064 // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
1065 if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
1066
1067 const interactionId = parseInt(req.body.interaction_id, 10) || 0;
1068 const postSlug = String(req.body.post_slug || '');
1069 const toActor = String(req.body.to || '');
1070 try {
1071 if (interactionId && postSlug) {
1072 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
1073 const parent = ActivityPubService.getInteractionById(interactionId);
1074 // De parent MOET bij deze post horen: anders zou een gemanipuleerd
1075 // formulier een antwoord onder andermans draad kunnen hangen.
1076 if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
1077 await ActivityPubService.deliverReply(site, {
1078 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
1079 });
1080 } else if (/^https?:\/\//i.test(toActor)) {
1081 const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
1082 if (!r) return res.redirect(back + '?error=reply_failed');
1083 } else {
1084 return res.redirect(back + '?error=reply_target');
1085 }
1086 } catch (e) {
1087 console.warn('[AP] reply from Berichten failed:', e.message);
1088 return res.redirect(back + '?error=reply_failed');
1089 }
1090 res.redirect(back + '?success=reply_sent');
1091});
1092
1093router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1094
1095router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
1096 const site = res.locals.site;
1097 if (site) {
1098 try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
1099 catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
1100 }
1101 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1102});
1103
1104// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
1105// object URI so re-delivery and thread-crawling never bring it back. Works for private
1106// notes too (acts on the local copy; no remote fetch involved).
1107router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
1108 const site = res.locals.site;
1109 if (site) {
1110 const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
1111 if (r.error) console.warn('[AP] interaction remove failed:', r.error);
1112 }
1113 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1114});
1115
1116// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
1117// locally stored object/actor URIs, so it also works for private notes that
1118// authorize_interaction cannot fetch (401/404).
1119router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
1120 const site = res.locals.site;
1121 if (site) {
1122 const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
1123 if (tgt && (tgt.objectUri || tgt.actorUri)) {
1124 try {
1125 const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
1126 if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
1127 } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
1128 }
1129 }
1130 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1131});
1132
1133// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
1134router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
1135 const site = res.locals.site;
1136 const text = String(req.body.text || '');
1137 const html = String(req.body.content || ''); // rich reply editor HTML (sanitized in deliverOutboxUpdate)
1138 if (site && (text.trim() || html.trim())) {
1139 try {
1140 await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
1141 html, language: String(req.body.language || ''),
1142 });
1143 } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
1144 }
1145 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1146});
1147
1148// ==================== FEDIVERSE CLIENT: home timeline + following ====================
1149// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
1150// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
1151function timelineEmbedHtml(html) {
1152 if (!html) return null;
1153 const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
1154 while ((m = re.exec(html))) {
1155 const u = m[1]; if (seen.has(u)) continue; seen.add(u);
1156 let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
1157 if (!p) {
1158 // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
1159 // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
1160 // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
1161 const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
1162 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>`;
1163 continue;
1164 }
1165 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>`;
1166 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>`;
1167 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>`;
1168 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>`;
1169 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>`;
1170 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>`; }
1171 }
1172 return null;
1173}
1174
1175// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
1176// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
1177// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
1178function klonktAudioEmbed(html, url) {
1179 if (!html || !url || html.indexOf('🎵') < 0) return null;
1180 let u; try { u = new URL(url); } catch { return null; }
1181 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
1182 const slug = u.pathname.replace(/^\/+|\/+$/g, '');
1183 if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
1184 const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
1185 // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
1186 const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
1187 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>` };
1188}
1189
1190/**
1191 * FEP-633c §5.3-style gated feature: may this account see previews of links
1192 * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
1193 *
1194 * Applied at SERVE time on every surface, the way the app's inbox read already
1195 * does it (routes/activitypub.js): a card the client merely hides has still
1196 * been delivered.
1197 */
1198function gateEmbeds(site, rows) {
1199 if (!site || !rows.length) return rows;
1200 if (embedsAllowedFor(site)) return rows;
1201 return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
1202}
1203
1204function isWardSite(site) {
1205 try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
1206}
1207function embedsAllowedFor(site) {
1208 return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
1209}
1210/**
1211 * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
1212 * sibling of the preview gate.) This was the hole: the player iframe is built
1213 * from the note's content by timelineEmbedHtml, on a path that never touched
1214 * gateEmbeds. A ward whose guardians had allowed nothing still got the full
1215 * YouTube player on the web, while the app showed nothing at all: the heavy
1216 * thing open, the light thing shut. Playback also requires the preview gate,
1217 * because you cannot play what you may not see.
1218 */
1219function playbackAllowedFor(site) {
1220 if (!site) return true;
1221 if (!embedsAllowedFor(site)) return false;
1222 return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
1223}
1224
1225router.get('/news', requireSiteManager, (req, res) => {
1226 const site = res.locals.site;
1227 const append = req.query.append === '1';
1228 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
1229 const cspOrigins = new Set();
1230 // Fetch one extra to know whether a "Load more" button belongs on this page.
1231 const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
1232 const hasMore = rows.length > FEED_PAGE;
1233 // Players (a third party's engine inside our page) ride the playback gate;
1234 // a Klonkt site's own audio embed is ours and stays.
1235 const mayPlay = playbackAllowedFor(site);
1236 const timeline = rows.slice(0, FEED_PAGE).map((p) => {
1237 let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
1238 let content = p.content;
1239 let embedUrl = null;
1240 if (!embedHtml) {
1241 const k = klonktAudioEmbed(p.content, p.url);
1242 if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
1243 }
1244 // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
1245 // top-level "open the player" link that works even when a browser shield/CSP blocks
1246 // the cross-site iframe (a full-page navigation is not a cross-site frame).
1247 let poll = null;
1248 if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
1249 return { ...p, content, embedHtml, embedUrl, poll };
1250 });
1251 // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
1252 // extending ONLY this response's CSP frame-src. The global policy stays locked down.
1253 if (cspOrigins.size) {
1254 const csp = res.getHeader('Content-Security-Policy');
1255 if (csp) {
1256 const extra = [...cspOrigins].join(' ');
1257 res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
1258 }
1259 }
1260 const moreBase = res.locals.siteUrlBase || '';
1261 if (append) {
1262 return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
1263 }
1264 renderPage(req, res, 'pages/news', {
1265 pageJs: 'news',
1266 pageTitle: 'News', bodyClass: 'on-special',
1267 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
1268 success: req.query.success || null, error: req.query.error || null,
1269 });
1270});
1271
1272// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
1273// Connect = who you follow + who follows you, merged into one page with direction
1274// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
1275// separate Following/Followers pages, which redirect here so old links keep working.
1276router.get('/connect', requireSiteManager, (req, res) => {
1277 const site = res.locals.site;
1278 const connections = site ? ActivityPubService.listConnections(site.slug) : [];
1279 // FEP-633c §2: the ward always sees who guards it, and §3.6 how available
1280 // each of them is. Connect is where "who am I connected to" belongs; a
1281 // guardian is the one connection a ward should never have to hunt for.
1282 // Owner-only by construction: this page is the owner's.
1283 const guardianHandle = (uri, cached) => {
1284 if (cached && cached.charAt(0) === '@') return cached;
1285 try { const u = new URL(uri); return `@${u.pathname.split('/').filter(Boolean).pop()}@${u.host}`; }
1286 catch { return uri; }
1287 };
1288 const gStatus = site ? Object.fromEntries(
1289 Guardianship.availability.statusesFor(site.slug, Guardianship.listGuardians(site.slug).map((g) => g.other_uri), Date.now())
1290 .map((s) => [s.id, s]),
1291 ) : {};
1292 const myGuardians = (site ? Guardianship.listGuardians(site.slug) : [])
1293 .map((g) => ({
1294 uri: g.other_uri,
1295 handle: guardianHandle(g.other_uri, g.other_handle),
1296 availability: (gStatus[g.other_uri] || {})['shaer:availability'] || 'active',
1297 awayUntil: (gStatus[g.other_uri] || {})['shaer:awayUntil'] || null,
1298 }));
1299 // De eigenaarspoort: openstaande volgverzoeken, alleen buiten voogdij.
1300 // Een ward-follow beslissen de guardians — die tonen we hier dus NIET,
1301 // anders is deze pagina een deur naast hun poort.
1302 const followRequests = (site && !myGuardians.length)
1303 ? Guardianship.follows.listForWard(site.slug) : [];
1304 renderPage(req, res, 'pages/connect', {
1305 pageTitle: 'Connect', bodyClass: 'on-special',
1306 connections, myGuardians, followRequests,
1307 approveFollowers: !!(site && site.approve_followers),
1308 // Na een verhuizing staat de uitgaande kant op slot. Dat hoort te blijken
1309 // VOORDAT je op een knop drukt, niet daarna uit een foutmelding.
1310 movedTo: ActivityPubService.movedLock(site).movedTo,
1311 success: req.query.success || null, error: req.query.error || null,
1312 });
1313});
1314router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1315router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1316
1317router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
1318 const site = res.locals.site;
1319 const base = res.locals.siteUrlBase || '';
1320 if (!site) return res.redirect(`${base}/connect`);
1321 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
1322 return res.redirect(`${base}/connect?` + (ok
1323 ? 'success=' + encodeURIComponent('Volger verwijderd')
1324 : 'error=' + encodeURIComponent('Volger niet gevonden')));
1325});
1326
1327// De poort zelf aan- of uitzetten, op de plek waar de verzoeken toch al
1328// staan (Robins wens, 18-8: "op de connect is logischer").
1329router.post('/connect/approve-followers', requireSiteManager, (req, res) => {
1330 const site = res.locals.site;
1331 const base = res.locals.siteUrlBase || '';
1332 if (site) {
1333 db.prepare('UPDATE sites SET approve_followers = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
1334 .run(req.body.on ? 1 : 0, site.id);
1335 }
1336 return res.redirect(`${base}/connect`);
1337});
1338
1339// De eigenaarspoort beslist (Robins wens, 18-8): accepteer of weiger een
1340// volgverzoek dat door approve_followers is vastgehouden. Bewust NIET voor
1341// wards — daar beslissen de guardians, en deze route weigert dan hard, zodat
1342// hij geen sluiproute naast die poort wordt.
1343router.post('/follow-requests/:decision', requireSiteManager, async (req, res) => {
1344 const site = res.locals.site;
1345 const base = res.locals.siteUrlBase || '';
1346 const { decision } = req.params;
1347 if (!site || !['approve', 'deny'].includes(decision)) return res.redirect(`${base}/connect`);
1348 if (Guardianship.listGuardians(site.slug).length) {
1349 return res.redirect(`${base}/connect?error=` + encodeURIComponent('Volgverzoeken lopen via je guardians'));
1350 }
1351 const pending = Guardianship.follows.getPending(String(req.body.id || ''));
1352 if (!pending || pending.ward_slug !== site.slug || pending.status !== 'pending') {
1353 return res.redirect(`${base}/connect?error=` + encodeURIComponent('Verzoek niet gevonden'));
1354 }
1355 if (decision === 'approve') await ActivityPubService.acceptGatedFollow(pending);
1356 else await ActivityPubService.rejectGatedFollow(pending);
1357 Guardianship.follows.remove(pending.id);
1358 return res.redirect(`${base}/connect?success=` + encodeURIComponent(
1359 decision === 'approve' ? 'Volger geaccepteerd' : 'Verzoek geweigerd'));
1360});
1361
1362router.post('/news/follow', requireSiteManager, async (req, res) => {
1363 const site = res.locals.site;
1364 const handle = (req.body.handle || '').toString();
1365 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
1366 if (site && handle.trim()) {
1367 try {
1368 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
1369 // 'moved' is geen mislukking maar een weigering met een reden, en die reden
1370 // hoort de gebruiker te lezen. "Volgen mislukt" laat hem zoeken naar een
1371 // storing die er niet is.
1372 if (r && r.error === 'moved') q = 'error=' + encodeURIComponent(`Dit account is verhuisd naar ${r.movedTo}. Volgen doe je daarvandaan.`);
1373 else if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
1374 // Een DERDE uitkomst, niet gelukt en niet mislukt (shaer-p729). "Je volgt
1375 // nu X" zeggen terwijl het verzoek bij de guardians ligt is de leugen die
1376 // deze poort waardeloos maakt: het kind denkt dat het gebeurd is.
1377 else if (r && r.held) q = 'success=' + encodeURIComponent(r.status === 'denied' ? 'Je guardians hebben dit geweigerd' : 'Je verzoek ligt bij je guardians');
1378 else {
1379 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
1380 }
1381 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
1382 }
1383 res.redirect('/following?' + q);
1384});
1385
1386// ── Je volglijst meenemen ─────────────────────────────────────────
1387//
1388// Zonder dit was verhuizen halfslachtig: de Move vertelt je VOLGERS waar je heen
1389// ging, maar niets vertelde JOU wie jij volgde. Die lijst stond alleen in de
1390// database die je achterlaat.
1391router.get('/news/following.csv', requireSiteManager, async (req, res) => {
1392 const site = res.locals.site;
1393 const { followingCsv } = await import('../services/ArchiveExportService.js');
1394 const csv = site ? followingCsv(site.slug) : null;
1395 if (!csv) return res.redirect('/connect?error=' + encodeURIComponent('Je volgt nog niemand'));
1396 res.set('Content-Type', 'text/csv; charset=utf-8');
1397 res.set('Content-Disposition', `attachment; filename="following-${site.slug}.csv"`);
1398 // Privé: dit is de lijst van wie jij volgt, niets voor een cache onderweg.
1399 res.set('Cache-Control', 'private, no-store');
1400 res.send(csv);
1401});
1402
1403// Een bestand OF geplakte tekst. Multer leest een multipart-formulier, en dat
1404// bevat allebei: het bestandsveld en het tekstveld. In het geheugen, niet op
1405// schijf: dit is een lijstje adressen van een paar kilobyte dat na het lezen
1406// niets meer te zoeken heeft op de server.
1407const followingCsvUpload = multer({
1408 storage: multer.memoryStorage(),
1409 limits: { fileSize: 512 * 1024, files: 1 },
1410}).single('csvfile');
1411
1412router.post('/news/following/import', requireSiteManager, followingCsvUpload, async (req, res) => {
1413 const site = res.locals.site;
1414 // Een geupload bestand wint van het plakveld: wie een bestand kiest bedoelt dat.
1415 const csv = (req.file && req.file.buffer)
1416 ? req.file.buffer.toString('utf8').replace(/^/, '') // BOM eraf; Excel zet die erin
1417 : ((req.body && req.body.csv) || '');
1418 // Terug naar waar je vandaan kwam. Sinds 14-8 staat dit formulier op
1419 // /admin/migrate (Robin: alle migratie-opties bij elkaar); terugspringen naar
1420 // Connect is dan desorienterend. Alleen een eigen pad, geen open redirect.
1421 const terug = /^\/[A-Za-z0-9/_-]*$/.test(String(req.body.next || '')) ? String(req.body.next) : '/connect';
1422 if (!site || !String(csv).trim()) return res.redirect(terug + '?error=' + encodeURIComponent('Geen lijst ontvangen'));
1423
1424 const { importFollowing } = await import('../services/ArchiveImportService.js');
1425 // followActor als followFn: die doet de webfinger, stuurt de Follow en zet
1426 // auto_boost meteen goed. Zo blijft er één pad naar een volgrelatie.
1427 const r = await importFollowing(site, csv, {
1428 followFn: async (s, adres, uitgelicht) => {
1429 const uit = await ActivityPubService.followActor(s, adres, !!uitgelicht);
1430 // followActor meldt een fout als VELD, niet als exception. Zonder deze
1431 // vertaling telde een onvindbaar account gewoon als geslaagd mee.
1432 if (uit && uit.error) throw new Error(uit.error);
1433 return true;
1434 },
1435 });
1436
1437 const delen = [`${r.gevolgd} gevolgd`];
1438 if (r.overgeslagen) delen.push(`${r.overgeslagen} overgeslagen`);
1439 if (r.mislukt.length) {
1440 const namen = r.mislukt.slice(0, 3).map((m) => m.adres).join(', ');
1441 delen.push(`${r.mislukt.length} mislukt (${namen}${r.mislukt.length > 3 ? '…' : ''})`);
1442 }
1443 // Terug naar /connect: daar staat het blok, /following is de oude pagina.
1444 res.redirect(terug + '?' + (r.mislukt.length ? 'error=' : 'success=') + encodeURIComponent(delen.join(', ')));
1445});
1446
1447router.post('/news/unfollow', requireSiteManager, async (req, res) => {
1448 const site = res.locals.site;
1449 const actorUri = (req.body.actor_uri || '').toString();
1450 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
1451 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
1452});
1453
1454// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
1455router.post('/news/autoboost', requireSiteManager, (req, res) => {
1456 const site = res.locals.site;
1457 const actorUri = (req.body.actor_uri || '').toString();
1458 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
1459 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
1460});
1461
1462// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
1463// no banner); no-JS → redirect back.
1464router.post('/news/like', requireSiteManager, async (req, res) => {
1465 const site = res.locals.site;
1466 const note = (req.body.note || '').toString();
1467 let on = false;
1468 if (site && note) {
1469 on = !ActivityPubService.getReaction(site.slug, note).liked;
1470 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1471 ActivityPubService.setReaction(site.slug, note, 'like', on);
1472 }
1473 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1474 res.redirect('/news');
1475});
1476
1477// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
1478router.post('/news/boost', requireSiteManager, async (req, res) => {
1479 const site = res.locals.site;
1480 const note = (req.body.note || '').toString();
1481 let on = false;
1482 if (site && note) {
1483 on = !ActivityPubService.getReaction(site.slug, note).boosted;
1484 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1485 ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
1486 if (on) {
1487 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1488 // (cover/content) — boosting again heals a stale copy from EVERY boost
1489 // path, not just the interact page.
1490 ActivityPubService.resolveRemoteNote(note)
1491 .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
1492 .catch(() => { /* best-effort */ });
1493 }
1494 }
1495 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1496 res.redirect('/news');
1497});
1498
1499// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1500router.post('/news/vote', requireSiteManager, async (req, res) => {
1501 const site = res.locals.site;
1502 const note = (req.body.note || '').toString();
1503 let choice = req.body.choice;
1504 if (choice == null) choice = [];
1505 if (!Array.isArray(choice)) choice = [choice];
1506 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1507 res.redirect('/news');
1508});
1509
1510// Notifications inbox (new followers + replies/likes/boosts on your posts).
1511router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1512
1513// Blocking / defederation (owner-only).
1514router.get('/blocking', requireSiteManager, (req, res) => {
1515 const site = res.locals.site;
1516 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1517 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1518});
1519
1520router.post('/blocking/add', requireSiteManager, async (req, res) => {
1521 const site = res.locals.site;
1522 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1523 if (site) {
1524 try {
1525 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1526 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1527 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1528 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1529 }
1530 const ref = req.get('Referer') || '';
1531 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1532});
1533
1534router.post('/blocking/remove', requireSiteManager, (req, res) => {
1535 const site = res.locals.site;
1536 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
1537 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1538});
1539
1540// ==================== VIEW POST (last route — catches /:slug) ====================
1541router.get('/:slug', (req, res, next) => {
1542 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1543
1544 const site = res.locals.site;
1545 if (!site) return next(); // -> nette 404 catch-all
1546
1547 const post = db.prepare(`
1548 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1549 FROM posts p JOIN users u ON p.author_id = u.id
1550 WHERE p.site_id = ? AND p.slug = ?
1551 `).get(site.id, req.params.slug);
1552
1553 if (!post) return next(); // unknown slug -> clean 404 catch-all
1554
1555 // Permission to view: published OR (logged in + can edit)
1556 if (post.status !== 'published') {
1557 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1558 if (!canEdit) return res.status(403).send('Not published');
1559 }
1560
1561 // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
1562 // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
1563 // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
1564 // paid gate wins (otherwise anonymous visitors land on the login gate and
1565 // never see the unlock button).
1566 const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1567 // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
1568 // supporter render the FULL post through this normal template (correct layout,
1569 // scoped styles, working audio). Short-lived signed blob, single post, not a
1570 // cookie and not stored.
1571 const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
1572 const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
1573 if (post.paid && !canEditThis && !_unlocked) {
1574 const { newerPost, olderPost } = postNeighbors(site, post);
1575 return renderPage(req, res, 'pages/paid-gate', {
1576 pageJs: 'paid-gate',
1577 pageTitle: post.title || 'Voor supporters',
1578 bodyClass: 'on-special',
1579 pgTitle: post.title || '',
1580 pgTeaser: paidTeaser(post),
1581 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
1582 pgSlug: post.slug,
1583 pgPatronUrl: paidPatronUrl(site.id),
1584 newerPost,
1585 olderPost,
1586 });
1587 }
1588
1589 // Fan-only preview (premium #3): full content only for logged-in fans.
1590 // Anonymous visitors get a clean login gate instead of the content (the title/
1591 // teaser may still appear elsewhere as a teaser).
1592 // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site
1593 // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien.
1594 const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req));
1595 if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) {
1596 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1597 // stuck on the fan gate but can keep browsing.
1598 const { newerPost, olderPost } = postNeighbors(site, post);
1599 return renderPage(req, res, 'pages/fan-gate', {
1600 pageTitle: post.title || 'Alleen voor fans',
1601 bodyClass: 'on-special',
1602 fgTitle: post.title || '',
1603 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1604 owaError: !!(req.query && req.query.owa_error),
1605 newerPost,
1606 olderPost,
1607 });
1608 }
1609
1610 // Statistics: count the view (skips admins + unpublished own-preview).
1611 if (post.status === 'published') recordPostView(post, req);
1612
1613 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1614 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1615 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1616 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1617 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1618 post.content_html = renderPostBodyHtml(site, post, req);
1619
1620 if (post.tags) {
1621 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1622 } else {
1623 post.tags = [];
1624 }
1625
1626 // Native comments removed: social interaction is fediverse-only (see the
1627 // "From the fediverse" section below).
1628
1629 // Prev / next chronological (kept for back-compat — "post-nav" feature
1630 // below the article still uses these as a simple linear navigation).
1631 const urlBaseFor = () => '';
1632
1633 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1634 const { newerPost, olderPost } = postNeighbors(site, post);
1635
1636 // ── Related posts: same-tag matching with recency fallback ─────
1637 // Fetch ~50 candidates, score by tag overlap, take top 3.
1638 // Excluding self via `id != ?`.
1639 const candidates = db.prepare(`
1640 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1641 FROM posts
1642 WHERE site_id = ? AND status = 'published' AND id != ?
1643 ORDER BY published_at DESC LIMIT 50
1644 `).all(site.id, post.id);
1645
1646 // Parse tags JSON safely; missing/malformed → empty array.
1647 const parseTags = (raw) => {
1648 if (!raw) return [];
1649 try {
1650 const v = JSON.parse(raw);
1651 return Array.isArray(v) ? v.map(String) : [];
1652 } catch { return []; }
1653 };
1654
1655 const myTags = new Set(parseTags(post.tags));
1656 let relatedPosts;
1657 if (myTags.size > 0) {
1658 // Score = number of overlapping tags. Posts with zero overlap are
1659 // included only if we don't have 3 with-overlap candidates.
1660 const scored = candidates.map(p => {
1661 const theirTags = parseTags(p.tags);
1662 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1663 return { ...p, _overlap: overlap };
1664 });
1665 const withOverlap = scored.filter(p => p._overlap > 0)
1666 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1667 if (withOverlap.length >= 3) {
1668 relatedPosts = withOverlap.slice(0, 3);
1669 } else {
1670 // Pad with most-recent non-overlap posts so the section is never empty
1671 const overlapIds = new Set(withOverlap.map(p => p.id));
1672 const filler = candidates.filter(p => !overlapIds.has(p.id));
1673 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1674 }
1675 } else {
1676 // No tags on current post → just show 3 most-recent
1677 relatedPosts = candidates.slice(0, 3);
1678 }
1679 // Strip the internal _overlap field before sending to view
1680 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1681
1682 // Inbound fediverse activity (threaded) for this post.
1683 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1684 try {
1685 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1686 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1687 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1688 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1689 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1690 } catch { /* non-fatal */ }
1691 // Owner/admin of this site may reply back to a fediverse interaction.
1692 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1693 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1694 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1695
1696 renderPage(req, res, 'pages/post', {
1697 pageJs: 'post reply-editor',
1698 post,
1699 poll: ActivityPubService.ownPollView(post),
1700 newerPost,
1701 olderPost,
1702 relatedPosts,
1703 fediverse,
1704 canManageSite,
1705 siteAvatar,
1706 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1707 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1708 pageTitle: post.title + ' - ' + site.title,
1709 socialDescr: post.excerpt || '',
1710 socialImage: post.cover_image_url || '',
1711 bodyClass: 'on-post',
1712 });
1713});
1714
1715// ── Reply back to a fediverse interaction (site owner/admin only) ──
1716router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1717 const site = res.locals.site;
1718 if (!site) return res.status(404).send('Site required');
1719 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1720 if (!post) return res.status(404).send('Not found');
1721 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1722 const text = (req.body.text || '').toString();
1723 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1724 let attachments = [];
1725 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1726 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1727 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1728 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1729 try {
1730 await ActivityPubService.deliverReply(site, {
1731 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1732 language: (req.body.language || '').toString(),
1733 });
1734 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1735 }
1736 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1737});
1738
1739// Owner likes/boosts a fediverse comment on their own post — directly as the
1740// site, no "your server" detour (mirrors /fedi-reply).
1741router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1742 const site = res.locals.site;
1743 if (!site) return res.status(404).send('Site required');
1744 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1745 if (!post) return res.status(404).send('Not found');
1746 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1747 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1748 if (parent && parent.post_id === post.id && parent.object_uri) {
1749 // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
1750 // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
1751 // de kolom en de knop uit de tussentabel, dan draait een divergentie de
1752 // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
1753 const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
1754 const on = kind === 'boost' ? !ik.boosted : !ik.liked;
1755 ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
1756 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1757 // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
1758 // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
1759 // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
1760 // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
1761 ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
1762 if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
1763 else ActivityPubService.setInteractionLiked(parent.id, on);
1764 }
1765 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1766});
1767
1768export default router;
1769export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.