source: Klonkt/src/routes/posts.js@ 34a0053

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

Het slot afmaken: posten tegenhouden en grijzen wat dicht is

Robin keek naar zijn verhuisde instance en zei: alles is nog functioneel. Klopt, en
er zaten drie fouten in wat ik gisteren bouwde.

EEN: movedLock stond niet in de default-export, terwijl de routes hem via die weg
aanroepen. ActivityPubService.movedLock was dus geen functie, en elke route die hem
raakte gooide een 500. Daarom zag Robin ook geen banner op Connect: die pagina
strandde voordat hij hem kon tonen. Twee bestaande tests vielen hierdoor om zodra
ik de poort in de aanmaakroute zette, en dat was maar goed ook.

TWEE: de poort zat op de BEZORGING en niet op het MAKEN. deliverCreate weigerde,
maar de post werd daarvoor al opgeslagen. Je kon dus gewoon schrijven en publiceren;
het federeerde alleen niet. Dan lijkt het gelukt, staat het er, en sterft het met
het domein: precies de halve toestand die dit slot moest voorkomen, en die ik
vandaag drie keer bij anderen heb aangewezen.

DRIE: de UI wist van niets. Alleen connect.ejs en de homepage kenden movedTo; de
posteditor, de like- en boostknoppen en het reactieveld stonden er gewoon. Voor een
gebruiker ziet dat eruit als een werkende site.

Changed files:
src/services/ActivityPubService.js

  • movedLock in de default-export (de oorzaak van de 500's)

src/routes/posts.js

  • /posts/create weigert met 409 op een verhuisd account, VOOR de post bestaat
  • /posts/:slug/save weigert alleen het alsnog PUBLICEREN van een concept; een bestaande post bewerken mag, daar wil je "ik ben verhuisd naar" in kunnen zetten en die URI bestaat al

src/middleware/site.js

  • res.locals.movedTo, zodat elke view kan grijzen wat toch geweigerd wordt. Zelfde plek en zelfde reden als mayReply erboven

src/views/partials/tl-item.ejs

  • like, boost en reageren krijgen disabled; blokkeren blijft klikbaar, dat is een veiligheidsklep net als rapporteren

src/views/pages/news.ejs

  • banner bovenaan plus de grijze staat. Een uitleg boven de lijst en niet een tooltip per knop, want een tooltip op een disabled knop is onzichtbaar

test/moved-lockdown.test.js

  • 12 en 13: de aanmaakroute strandt op 409, en een gewoon account niet

remarks: 951 groen in UTC en Europe/Amsterdam. De Krant in beide standen gerenderd:
banner alleen als er verhuisd is, geen onvertaalde sleutels. Onderweg viel mijn
eigen testopzet twee keer om op ontbrekende locals (timeline, thumb); dat was de
test.

Les voor mezelf: ik had dit gisteren "af" genoemd terwijl de enige controle een
grep op de uitgerolde bestanden was. Dat de code ergens staat is niet hetzelfde als
dat hij draait.

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

  • Property mode set to 100644
File size: 85.4 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 renderPage(req, res, 'pages/connect', {
1273 pageTitle: 'Connect', bodyClass: 'on-special',
1274 connections, myGuardians,
1275 // Na een verhuizing staat de uitgaande kant op slot. Dat hoort te blijken
1276 // VOORDAT je op een knop drukt, niet daarna uit een foutmelding.
1277 movedTo: ActivityPubService.movedLock(site).movedTo,
1278 success: req.query.success || null, error: req.query.error || null,
1279 });
1280});
1281router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1282router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1283
1284router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
1285 const site = res.locals.site;
1286 const base = res.locals.siteUrlBase || '';
1287 if (!site) return res.redirect(`${base}/connect`);
1288 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
1289 return res.redirect(`${base}/connect?` + (ok
1290 ? 'success=' + encodeURIComponent('Volger verwijderd')
1291 : 'error=' + encodeURIComponent('Volger niet gevonden')));
1292});
1293
1294router.post('/news/follow', requireSiteManager, async (req, res) => {
1295 const site = res.locals.site;
1296 const handle = (req.body.handle || '').toString();
1297 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
1298 if (site && handle.trim()) {
1299 try {
1300 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
1301 // 'moved' is geen mislukking maar een weigering met een reden, en die reden
1302 // hoort de gebruiker te lezen. "Volgen mislukt" laat hem zoeken naar een
1303 // storing die er niet is.
1304 if (r && r.error === 'moved') q = 'error=' + encodeURIComponent(`Dit account is verhuisd naar ${r.movedTo}. Volgen doe je daarvandaan.`);
1305 else if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
1306 // Een DERDE uitkomst, niet gelukt en niet mislukt (shaer-p729). "Je volgt
1307 // nu X" zeggen terwijl het verzoek bij de guardians ligt is de leugen die
1308 // deze poort waardeloos maakt: het kind denkt dat het gebeurd is.
1309 else if (r && r.held) q = 'success=' + encodeURIComponent(r.status === 'denied' ? 'Je guardians hebben dit geweigerd' : 'Je verzoek ligt bij je guardians');
1310 else {
1311 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
1312 }
1313 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
1314 }
1315 res.redirect('/following?' + q);
1316});
1317
1318// ── Je volglijst meenemen ─────────────────────────────────────────
1319//
1320// Zonder dit was verhuizen halfslachtig: de Move vertelt je VOLGERS waar je heen
1321// ging, maar niets vertelde JOU wie jij volgde. Die lijst stond alleen in de
1322// database die je achterlaat.
1323router.get('/news/following.csv', requireSiteManager, async (req, res) => {
1324 const site = res.locals.site;
1325 const { followingCsv } = await import('../services/ArchiveExportService.js');
1326 const csv = site ? followingCsv(site.slug) : null;
1327 if (!csv) return res.redirect('/connect?error=' + encodeURIComponent('Je volgt nog niemand'));
1328 res.set('Content-Type', 'text/csv; charset=utf-8');
1329 res.set('Content-Disposition', `attachment; filename="following-${site.slug}.csv"`);
1330 // Privé: dit is de lijst van wie jij volgt, niets voor een cache onderweg.
1331 res.set('Cache-Control', 'private, no-store');
1332 res.send(csv);
1333});
1334
1335// Een bestand OF geplakte tekst. Multer leest een multipart-formulier, en dat
1336// bevat allebei: het bestandsveld en het tekstveld. In het geheugen, niet op
1337// schijf: dit is een lijstje adressen van een paar kilobyte dat na het lezen
1338// niets meer te zoeken heeft op de server.
1339const followingCsvUpload = multer({
1340 storage: multer.memoryStorage(),
1341 limits: { fileSize: 512 * 1024, files: 1 },
1342}).single('csvfile');
1343
1344router.post('/news/following/import', requireSiteManager, followingCsvUpload, async (req, res) => {
1345 const site = res.locals.site;
1346 // Een geupload bestand wint van het plakveld: wie een bestand kiest bedoelt dat.
1347 const csv = (req.file && req.file.buffer)
1348 ? req.file.buffer.toString('utf8').replace(/^/, '') // BOM eraf; Excel zet die erin
1349 : ((req.body && req.body.csv) || '');
1350 if (!site || !String(csv).trim()) return res.redirect('/connect?error=' + encodeURIComponent('Geen lijst ontvangen'));
1351
1352 const { importFollowing } = await import('../services/ArchiveImportService.js');
1353 // followActor als followFn: die doet de webfinger, stuurt de Follow en zet
1354 // auto_boost meteen goed. Zo blijft er één pad naar een volgrelatie.
1355 const r = await importFollowing(site, csv, {
1356 followFn: async (s, adres, uitgelicht) => {
1357 const uit = await ActivityPubService.followActor(s, adres, !!uitgelicht);
1358 // followActor meldt een fout als VELD, niet als exception. Zonder deze
1359 // vertaling telde een onvindbaar account gewoon als geslaagd mee.
1360 if (uit && uit.error) throw new Error(uit.error);
1361 return true;
1362 },
1363 });
1364
1365 const delen = [`${r.gevolgd} gevolgd`];
1366 if (r.overgeslagen) delen.push(`${r.overgeslagen} overgeslagen`);
1367 if (r.mislukt.length) {
1368 const namen = r.mislukt.slice(0, 3).map((m) => m.adres).join(', ');
1369 delen.push(`${r.mislukt.length} mislukt (${namen}${r.mislukt.length > 3 ? '…' : ''})`);
1370 }
1371 // Terug naar /connect: daar staat het blok, /following is de oude pagina.
1372 res.redirect('/connect?' + (r.mislukt.length ? 'error=' : 'success=') + encodeURIComponent(delen.join(', ')));
1373});
1374
1375router.post('/news/unfollow', requireSiteManager, async (req, res) => {
1376 const site = res.locals.site;
1377 const actorUri = (req.body.actor_uri || '').toString();
1378 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
1379 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
1380});
1381
1382// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
1383router.post('/news/autoboost', requireSiteManager, (req, res) => {
1384 const site = res.locals.site;
1385 const actorUri = (req.body.actor_uri || '').toString();
1386 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
1387 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
1388});
1389
1390// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
1391// no banner); no-JS → redirect back.
1392router.post('/news/like', requireSiteManager, async (req, res) => {
1393 const site = res.locals.site;
1394 const note = (req.body.note || '').toString();
1395 let on = false;
1396 if (site && note) {
1397 on = !ActivityPubService.getReaction(site.slug, note).liked;
1398 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1399 ActivityPubService.setReaction(site.slug, note, 'like', on);
1400 }
1401 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1402 res.redirect('/news');
1403});
1404
1405// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
1406router.post('/news/boost', requireSiteManager, async (req, res) => {
1407 const site = res.locals.site;
1408 const note = (req.body.note || '').toString();
1409 let on = false;
1410 if (site && note) {
1411 on = !ActivityPubService.getReaction(site.slug, note).boosted;
1412 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1413 ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
1414 if (on) {
1415 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1416 // (cover/content) — boosting again heals a stale copy from EVERY boost
1417 // path, not just the interact page.
1418 ActivityPubService.resolveRemoteNote(note)
1419 .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
1420 .catch(() => { /* best-effort */ });
1421 }
1422 }
1423 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1424 res.redirect('/news');
1425});
1426
1427// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1428router.post('/news/vote', requireSiteManager, async (req, res) => {
1429 const site = res.locals.site;
1430 const note = (req.body.note || '').toString();
1431 let choice = req.body.choice;
1432 if (choice == null) choice = [];
1433 if (!Array.isArray(choice)) choice = [choice];
1434 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1435 res.redirect('/news');
1436});
1437
1438// Notifications inbox (new followers + replies/likes/boosts on your posts).
1439router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1440
1441// Blocking / defederation (owner-only).
1442router.get('/blocking', requireSiteManager, (req, res) => {
1443 const site = res.locals.site;
1444 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1445 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1446});
1447
1448router.post('/blocking/add', requireSiteManager, async (req, res) => {
1449 const site = res.locals.site;
1450 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1451 if (site) {
1452 try {
1453 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1454 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1455 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1456 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1457 }
1458 const ref = req.get('Referer') || '';
1459 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1460});
1461
1462router.post('/blocking/remove', requireSiteManager, (req, res) => {
1463 const site = res.locals.site;
1464 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
1465 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1466});
1467
1468// ==================== VIEW POST (last route — catches /:slug) ====================
1469router.get('/:slug', (req, res, next) => {
1470 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1471
1472 const site = res.locals.site;
1473 if (!site) return next(); // -> nette 404 catch-all
1474
1475 const post = db.prepare(`
1476 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1477 FROM posts p JOIN users u ON p.author_id = u.id
1478 WHERE p.site_id = ? AND p.slug = ?
1479 `).get(site.id, req.params.slug);
1480
1481 if (!post) return next(); // unknown slug -> clean 404 catch-all
1482
1483 // Permission to view: published OR (logged in + can edit)
1484 if (post.status !== 'published') {
1485 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1486 if (!canEdit) return res.status(403).send('Not published');
1487 }
1488
1489 // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
1490 // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
1491 // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
1492 // paid gate wins (otherwise anonymous visitors land on the login gate and
1493 // never see the unlock button).
1494 const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1495 // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
1496 // supporter render the FULL post through this normal template (correct layout,
1497 // scoped styles, working audio). Short-lived signed blob, single post, not a
1498 // cookie and not stored.
1499 const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
1500 const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
1501 if (post.paid && !canEditThis && !_unlocked) {
1502 const { newerPost, olderPost } = postNeighbors(site, post);
1503 return renderPage(req, res, 'pages/paid-gate', {
1504 pageJs: 'paid-gate',
1505 pageTitle: post.title || 'Voor supporters',
1506 bodyClass: 'on-special',
1507 pgTitle: post.title || '',
1508 pgTeaser: paidTeaser(post),
1509 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
1510 pgSlug: post.slug,
1511 pgPatronUrl: paidPatronUrl(site.id),
1512 newerPost,
1513 olderPost,
1514 });
1515 }
1516
1517 // Fan-only preview (premium #3): full content only for logged-in fans.
1518 // Anonymous visitors get a clean login gate instead of the content (the title/
1519 // teaser may still appear elsewhere as a teaser).
1520 if (post.fan_only && !(req.session && req.session.user)) {
1521 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1522 // stuck on the fan gate but can keep browsing.
1523 const { newerPost, olderPost } = postNeighbors(site, post);
1524 return renderPage(req, res, 'pages/fan-gate', {
1525 pageTitle: post.title || 'Alleen voor fans',
1526 bodyClass: 'on-special',
1527 fgTitle: post.title || '',
1528 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1529 newerPost,
1530 olderPost,
1531 });
1532 }
1533
1534 // Statistics: count the view (skips admins + unpublished own-preview).
1535 if (post.status === 'published') recordPostView(post, req);
1536
1537 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1538 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1539 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1540 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1541 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1542 post.content_html = renderPostBodyHtml(site, post, req);
1543
1544 if (post.tags) {
1545 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1546 } else {
1547 post.tags = [];
1548 }
1549
1550 // Native comments removed: social interaction is fediverse-only (see the
1551 // "From the fediverse" section below).
1552
1553 // Prev / next chronological (kept for back-compat — "post-nav" feature
1554 // below the article still uses these as a simple linear navigation).
1555 const urlBaseFor = () => '';
1556
1557 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1558 const { newerPost, olderPost } = postNeighbors(site, post);
1559
1560 // ── Related posts: same-tag matching with recency fallback ─────
1561 // Fetch ~50 candidates, score by tag overlap, take top 3.
1562 // Excluding self via `id != ?`.
1563 const candidates = db.prepare(`
1564 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1565 FROM posts
1566 WHERE site_id = ? AND status = 'published' AND id != ?
1567 ORDER BY published_at DESC LIMIT 50
1568 `).all(site.id, post.id);
1569
1570 // Parse tags JSON safely; missing/malformed → empty array.
1571 const parseTags = (raw) => {
1572 if (!raw) return [];
1573 try {
1574 const v = JSON.parse(raw);
1575 return Array.isArray(v) ? v.map(String) : [];
1576 } catch { return []; }
1577 };
1578
1579 const myTags = new Set(parseTags(post.tags));
1580 let relatedPosts;
1581 if (myTags.size > 0) {
1582 // Score = number of overlapping tags. Posts with zero overlap are
1583 // included only if we don't have 3 with-overlap candidates.
1584 const scored = candidates.map(p => {
1585 const theirTags = parseTags(p.tags);
1586 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1587 return { ...p, _overlap: overlap };
1588 });
1589 const withOverlap = scored.filter(p => p._overlap > 0)
1590 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1591 if (withOverlap.length >= 3) {
1592 relatedPosts = withOverlap.slice(0, 3);
1593 } else {
1594 // Pad with most-recent non-overlap posts so the section is never empty
1595 const overlapIds = new Set(withOverlap.map(p => p.id));
1596 const filler = candidates.filter(p => !overlapIds.has(p.id));
1597 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1598 }
1599 } else {
1600 // No tags on current post → just show 3 most-recent
1601 relatedPosts = candidates.slice(0, 3);
1602 }
1603 // Strip the internal _overlap field before sending to view
1604 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1605
1606 // Inbound fediverse activity (threaded) for this post.
1607 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1608 try {
1609 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1610 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1611 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1612 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1613 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1614 } catch { /* non-fatal */ }
1615 // Owner/admin of this site may reply back to a fediverse interaction.
1616 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1617 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1618 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1619
1620 renderPage(req, res, 'pages/post', {
1621 pageJs: 'post reply-editor',
1622 post,
1623 poll: ActivityPubService.ownPollView(post),
1624 newerPost,
1625 olderPost,
1626 relatedPosts,
1627 fediverse,
1628 canManageSite,
1629 siteAvatar,
1630 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1631 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1632 pageTitle: post.title + ' - ' + site.title,
1633 socialDescr: post.excerpt || '',
1634 socialImage: post.cover_image_url || '',
1635 bodyClass: 'on-post',
1636 });
1637});
1638
1639// ── Reply back to a fediverse interaction (site owner/admin only) ──
1640router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1641 const site = res.locals.site;
1642 if (!site) return res.status(404).send('Site required');
1643 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1644 if (!post) return res.status(404).send('Not found');
1645 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1646 const text = (req.body.text || '').toString();
1647 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1648 let attachments = [];
1649 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1650 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1651 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1652 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1653 try {
1654 await ActivityPubService.deliverReply(site, {
1655 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1656 language: (req.body.language || '').toString(),
1657 });
1658 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1659 }
1660 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1661});
1662
1663// Owner likes/boosts a fediverse comment on their own post — directly as the
1664// site, no "your server" detour (mirrors /fedi-reply).
1665router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1666 const site = res.locals.site;
1667 if (!site) return res.status(404).send('Site required');
1668 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1669 if (!post) return res.status(404).send('Not found');
1670 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1671 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1672 if (parent && parent.post_id === post.id && parent.object_uri) {
1673 // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
1674 // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
1675 // de kolom en de knop uit de tussentabel, dan draait een divergentie de
1676 // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
1677 const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
1678 const on = kind === 'boost' ? !ik.boosted : !ik.liked;
1679 ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
1680 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1681 // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
1682 // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
1683 // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
1684 // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
1685 ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
1686 if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
1687 else ActivityPubService.setInteractionLiked(parent.id, on);
1688 }
1689 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1690});
1691
1692export default router;
1693export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.