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

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

Poort op Connect én standaard aan

De aan/uit-toggle staat nu op de Connect-pagina, waar de volgverzoeken
toch al wonen — de checkbox in het sitebeheer blijft ook werken. En de
poort staat voortaan STANDAARD AAN: een nieuwe of bijgewerkte klonkt
beschermt zijn eigenaar meteen; uitzetten is de bewuste keuze. De
kolom-default regelt bestaande sites bij de upgrade vanzelf mee.

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

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