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

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

Volglijst: Featured is auto_boost, plus de knoppen om hem mee te nemen

Twee dingen: een correctie op de vorige commit, en de ingang die er nog niet was.

DE CORRECTIE. Ik had auto_boost in de kolom "Show boosts" gezet omdat in beide het
woord boost zit. Dat is fout. Bij Mastodon betekent die kolom "toon de reblogs van
deze persoon in mijn tijdlijn", en dat kent Klonkt niet; onze vlag gaat over hun
EIGEN posts in JOUW Cirkel. Dat is de vlag die in de UI al "Uitgelicht" heet
(tl.autoboost, in het Engels letterlijk "Featured"), dus hij hoort in de kolom
Featured en "Show boosts" blijft leeg.

Daarmee vervalt ook de kolom highlighted die ik in de vorige commit toevoegde:
die was een nieuw begrip naast een bestaand begrip met dezelfde naam. Eén ding, één
naam. Een bestand uit Mastodon levert nu featured: false, en dat is juist: hun
kolom 2 mag hier niet als uitgelicht binnenkomen.

DE INGANG. De vorige commit had de functies maar geen knop, en volgens de eigen
regel is een feature pas af als een eindgebruiker hem kan gebruiken. Op /following
staat nu een uitklapblok met een downloadknop en een plakveld.

De importroute geeft followActor mee als followFn, zodat er één pad naar een
volgrelatie blijft. Daar zat een val in: followActor meldt een fout als VELD en
niet als exception, dus zonder vertaling telde een onvindbaar account als geslaagd
mee. Die vertaling zit er nu in.

Changed files:
src/config/database.js

  • kolom highlighted teruggedraaid; commentaar bij auto_boost zegt nu dat het "Uitgelicht"/"Featured" heet

src/services/ArchiveExportService.js

  • auto_boost naar kolom Featured, Show boosts/Notify/Languages blijven leeg
  • parseFollowingCsv leest alleen kolom 5, en levert featured in plaats van twee velden

src/services/ArchiveImportService.js

  • de uitgelicht-stand gaat mee IN de Follow (derde argument), geen tweede UPDATE erna

src/routes/posts.js

  • GET /news/following.csv, met private/no-store want dit is jouw volglijst
  • POST /news/following/import, urlencoded, roept importFollowing aan met followActor

src/services/i18n.js

  • vijf teksten in nl, en en de

src/views/pages/following.ejs

  • het uitklapblok met de downloadknop en het plakveld

test/following-csv.test.js

  • bijgewerkt naar de juiste kolomindeling; de Mastodon-test controleert nu expliciet dat hun "Show boosts: true" NIET als uitgelicht landt

remarks: 921 groen in UTC en Europe/Amsterdam. Server gestart en de drie routes
nagelopen: alle drie bestaan en sturen naar de login, geen serverfouten. De template
apart gerenderd in nl/en/de, geen onvertaalde sleutels. De pagina zelf achter de
login niet visueel gezien; daar is een account voor nodig. Onderweg per ongeluk
.claude/launch.json overschreven (die zette PUBLIC_BASE_URL) en weer hersteld.

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

  • Property mode set to 100644
File size: 82.6 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
336 const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
337 const fanOnly = req.body.fan_only ? 1 : 0;
338 const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0; // paid posts (klonkt-demo-aki)
339 const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
340 const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
341 const nsfw = req.body.nsfw ? 1 : 0;
342 const cw = (req.body.content_warning || '').trim().slice(0, 200);
343 const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
344 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
345
346 // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
347 // before storage. Shortcode text tokens like [[track:UUID]] live in text
348 // nodes and pass through untouched.
349 const cleanContent = HtmlSanitizerService.sanitize(content || '');
350
351 // Generate slug from title if empty
352 let finalSlug = (slug || title || '')
353 .toLowerCase()
354 .replace(/[^a-z0-9]+/g, '-')
355 .replace(/^-|-$/g, '');
356
357 if (!finalSlug) return res.status(400).send('Title or slug required');
358 if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
359
360 // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
361 finalSlug = uniqueSlug(site.id, finalSlug);
362
363 const finalType = POST_TYPES.has(type) ? type : 'post';
364 const pollJson = parsePollForm(req.body); // AS2 Question definition, or null
365 const postId = uuid();
366 const now = new Date().toISOString();
367 let finalStatus = status || 'draft';
368 let publishedAt = finalStatus === 'published' ? now : null;
369 // Release planning: published + a future publish_at -> 'scheduled'
370 // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
371 let publishAt = null;
372 const pa = Date.parse(req.body.publish_at || '');
373 if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
374 finalStatus = 'scheduled';
375 publishAt = new Date(pa).toISOString();
376 publishedAt = null;
377 }
378
379 db.prepare(`
380 INSERT INTO posts (
381 id, site_id, slug, author_id, title, content, excerpt,
382 status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
383 created_at, updated_at, published_at
384 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
385 `).run(
386 postId, site.id, finalSlug, req.session.user.id,
387 title || finalSlug, cleanContent, excerpt || '',
388 finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
389 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
390 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
391 now, now, publishedAt
392 );
393 cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
394 db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, postId);
395
396 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
397 // BEFORE federating, so the Create note carries the right Audio attachments.
398 setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
399
400 if (finalStatus === 'published') {
401 try {
402 db.prepare(
403 'INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'
404 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId);
405 } catch (e) { /* FTS index issues are non-fatal */ }
406
407 // ActivityPub: federate a freshly published post to followers. fan_only → delivered
408 // to followers but addressed followers-only (option A: "fans" = your fedi followers).
409 if (status === 'published') {
410 ActivityPubService.deliverCreate(site, {
411 id: postId, slug: finalSlug, title: title || finalSlug,
412 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
413 published_at: publishedAt, created_at: now, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
414 }).catch(() => { /* best-effort */ });
415 }
416 }
417
418 // HTMX request -> return redirect header
419 if (req.headers['hx-request']) {
420 res.setHeader('HX-Redirect', `${res.locals.siteUrlBase || ''}/${finalSlug}`);
421 return res.send('OK');
422 }
423
424 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
425});
426
427// ==================== EDIT POST FORM ====================
428router.get('/posts/:slug/edit', requireAuth, (req, res) => {
429 const site = res.locals.site;
430 if (!site) return res.status(404).send('Site required');
431
432 const post = db.prepare(
433 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
434 ).get(site.id, req.params.slug);
435
436 if (!post) return res.status(404).send('Post not found');
437 if (!PermissionsService.canEditPost(req.session.user, post, site)) {
438 return res.status(403).send('No permission');
439 }
440
441 if (post.tags) {
442 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
443 } else {
444 post.tags = [];
445 }
446
447 // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
448 let pollLocked = false;
449 try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
450
451 renderPage(req, res, 'pages/post-edit', {
452 // Zelfde modules als de nieuw-route hierboven: zonder deze regel laadt de
453 // editor niet, en dan wist een opslag de post (shaer-5s1, de beet van 7-8).
454 pageJs: 'post-edit playlist-editor',
455 post,
456 isNew: false,
457 keuzeTypes: KEUZE_TYPES,
458 pollLocked,
459 fediOpenAudio: postAudioFediOpen(site.id, post.content),
460 pageTitle: 'Edit: ' + (post.title || 'Untitled'),
461 bodyClass: 'on-special',
462 });
463});
464
465// ==================== SAVE POST ====================
466router.post('/posts/:slug/save', requireAuth, (req, res) => {
467 const site = res.locals.site;
468 if (!site) return res.status(404).send('Site required');
469
470 const post = db.prepare(
471 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
472 ).get(site.id, req.params.slug);
473
474 if (!post) return res.status(404).send('Post not found');
475 if (!PermissionsService.canEditPost(req.session.user, post, site)) {
476 return res.status(403).send('No permission');
477 }
478
479 const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
480 const fanOnly = req.body.fan_only ? 1 : 0;
481 const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0; // paid posts (klonkt-demo-aki)
482 const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
483 const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
484 const nsfw = req.body.nsfw ? 1 : 0;
485 const cw = (req.body.content_warning || '').trim().slice(0, 200);
486 const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
487 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
488 const newSlug = req.body.slug;
489 const action = req.body.action || 'save';
490 const finalType = POST_TYPES.has(type) ? type : (post.type || 'post');
491
492 // A poll that has already received votes is frozen (you can still edit the surrounding
493 // post, but not the options) — changing options after votes would scramble the tally and
494 // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
495 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; } })());
496 const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
497
498 // Sanitize before storage — same pipeline as create.
499 const cleanContent = HtmlSanitizerService.sanitize(content || '');
500
501 let finalSlug = post.slug;
502 if (newSlug && newSlug !== post.slug) {
503 const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
504 const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
505 // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
506 finalSlug = uniqueSlug(site.id, safe, post.id);
507 }
508
509 const now = new Date().toISOString();
510 let finalStatus = status || post.status;
511 let publishedAt = post.published_at;
512
513 if (action === 'publish') {
514 finalStatus = 'published';
515 if (!publishedAt) publishedAt = now;
516 }
517
518 // Release planning: published + future publish_at -> 'scheduled'.
519 let publishAt = null;
520 const pa = Date.parse(req.body.publish_at || '');
521 if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
522 finalStatus = 'scheduled';
523 publishAt = new Date(pa).toISOString();
524 publishedAt = null;
525 }
526
527 db.prepare(`
528 UPDATE posts SET
529 title = ?, content = ?, excerpt = ?, status = ?,
530 cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
531 type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
532 slug = ?, published_at = ?, updated_at = ?
533 WHERE id = ?
534 `).run(
535 title, cleanContent, excerpt, finalStatus,
536 cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
537 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
538 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
539 finalSlug, publishedAt, now, post.id
540 );
541 cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
542 db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, post.id);
543
544 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
545 // BEFORE federating, so the Update/Create note carries the right Audio attachments.
546 setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
547
548 // Update FTS
549 try {
550 db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id);
551 if (finalStatus === 'published') {
552 db.prepare(
553 'INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'
554 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, post.id);
555 }
556 } catch (e) { /* FTS issues non-fatal */ }
557
558 // ActivityPub: federate edits to followers. A post that BECOMES published →
559 // Create (new post); an already-published post that's edited → Update (so
560 // Mastodon refreshes its cached copy). fan_only → followers-only (option A).
561 if (finalStatus === 'published') {
562 const apPost = {
563 id: post.id, slug: finalSlug, title: title || finalSlug,
564 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
565 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,
566 };
567 if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
568 else ActivityPubService.deliverUpdate(site, apPost).catch(() => { /* best-effort */ });
569 }
570
571 // Pin/unpin/reorder → push Add/Remove activities so followers' instances update the
572 // pinned order immediately (reliable, unlike re-fetching the cached featured collection).
573 if ((post.pinned || 0) !== parsePinnedRank(pinned)) {
574 const unpinned = (post.pinned || 0) > 0 && parsePinnedRank(pinned) === 0 ? [post.id] : [];
575 ActivityPubService.resyncFeaturedPins(site, unpinned).catch(() => { /* best-effort */ });
576 }
577
578 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
579});
580
581// ==================== DELETE POST ====================
582router.post('/posts/:slug/delete', requireAuth, (req, res) => {
583 const site = res.locals.site;
584 if (!site) return res.status(404).send('Site required');
585
586 const post = db.prepare(
587 'SELECT * FROM posts WHERE site_id = ? AND slug = ?'
588 ).get(site.id, req.params.slug);
589
590 if (!post) return res.status(404).send('Not found');
591 if (!PermissionsService.canDeletePost(req.session.user, post, site)) {
592 return res.status(403).send('No permission');
593 }
594
595 // ActivityPub: tell followers the post is gone (Delete + Tombstone) if it was
596 // federated (any published post now federates — fan_only goes followers-only).
597 // Fire before the row is removed — we still have post.id (= the Note id).
598 if (post.status === 'published') {
599 ActivityPubService.deliverDelete(site, post).catch(() => { /* best-effort */ });
600 }
601
602 // Cascade: comments + FTS row, THEN the post itself.
603 // FK constraints are ON (config/database.js), so a bare DELETE on posts
604 // fails when comments still reference it.
605 const cascade = db.transaction(() => {
606 db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
607 try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch {}
608 db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
609 });
610 cascade();
611
612 if (req.headers['hx-request']) {
613 res.setHeader('HX-Redirect', res.locals.siteUrlBase || '/');
614 return res.send('OK');
615 }
616 res.redirect(res.locals.siteUrlBase || '/');
617});
618
619// ==================== ARCHIVE ====================
620router.get('/archive', (req, res) => {
621 const site = res.locals.site;
622 if (!site) return res.status(404).send('No site');
623
624 const posts = db.prepare(`
625 SELECT p.*, u.username as author_username
626 FROM posts p JOIN users u ON p.author_id = u.id
627 WHERE p.site_id = ? AND p.status = 'published'
628 ORDER BY p.published_at DESC
629 `).all(site.id);
630
631 // Group by year/month
632 const grouped = {};
633 for (const post of posts) {
634 if (!post.published_at) continue;
635 const d = new Date(post.published_at);
636 const year = d.getFullYear();
637 const month = d.getMonth();
638 const monthName = ['januari','februari','maart','april','mei','juni','juli','augustus','september','oktober','november','december'][month];
639
640 if (!grouped[year]) grouped[year] = {};
641 if (!grouped[year][monthName]) grouped[year][monthName] = [];
642 grouped[year][monthName].push(post);
643 }
644
645 renderPage(req, res, 'pages/archive', {
646 grouped,
647 totalPosts: posts.length,
648 pageTitle: 'Archive - ' + site.title,
649 bodyClass: 'on-archive',
650 });
651});
652
653// Local likes/favourites are removed — engagement is fediverse-only now
654// (the ⭐ on a post likes via the fediverse). No post_likes, no /favorieten.
655
656// Newer/Older neighbours across ALL posts in feed order. Shared by the full
657// post render and the fan gate (premium fan_only) so navigation is consistent
658// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
659// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
660// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
661export function renderPostBodyHtml(site, post, req) {
662 let html = (post.content_rendered != null && post.content_rendered !== '')
663 ? post.content_rendered
664 : ActivityPubService.bakePostContent(post.content || '');
665 if (audioEnabled()) {
666 if (site.enable_audio_player !== 0) {
667 html = AudioEmbedService.autoembed(html);
668 html = AudioEmbedService.embedMediaShortcodes(html);
669 html = AudioEmbedService.embedExternalLinkShortcodes(html);
670
671 // Fetch any tracks referenced by [[track:id]] in this post.
672 // Cheap to do unconditionally — only matches if the post actually has shortcodes.
673 const trackIds = [...html.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)].map(m => m[1]);
674 if (trackIds.length) {
675 const placeholders = trackIds.map(() => '?').join(',');
676 const rows = db.prepare(`
677 SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
678 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
679 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
680 WHERE t.site_id = ? AND t.id IN (${placeholders})
681 `).all(site.id, ...trackIds);
682 const byId = new Map(rows.map(r => [r.id, r]));
683 html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
684 const r = byId.get(id);
685 if (!r) return null;
686 return {
687 id: r.id,
688 title: r.title,
689 artist: r.artist,
690 cover: r.cover_url,
691 credit: r.credit || '',
692 license: r.license || '',
693 link_spotify: r.link_spotify || '',
694 link_youtube: r.link_youtube || '',
695 link_soundcloud: r.link_soundcloud || '',
696 url: r.filename ? audioUrl(r.filename) : '', // '' = link-only track
697 };
698 });
699 }
700
701 // Album shortcodes: [[album:Some Album Name]]
702 const albumNames = [...html.matchAll(/\[\[album:([^\]]+)\]\]/g)].map(m => m[1].trim());
703 if (albumNames.length) {
704 const placeholders = albumNames.map(() => '?').join(',');
705 const albumRows = db.prepare(`
706 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
707 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
708 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
709 WHERE t.site_id = ? AND t.album IN (${placeholders})
710 ORDER BY t.position ASC, t.created_at ASC
711 `).all(site.id, ...albumNames);
712 const byAlbum = new Map();
713 for (const r of albumRows) {
714 // Link-only tracks (no file) remain in the album overview (url '').
715 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
716 byAlbum.get(r.album).push({
717 id: r.id,
718 url: r.filename ? audioUrl(r.filename) : '',
719 title: r.title || 'Untitled',
720 artist: r.artist || '',
721 cover: r.cover_url || '',
722 link_spotify: r.link_spotify || '',
723 link_youtube: r.link_youtube || '',
724 link_soundcloud: r.link_soundcloud || '',
725 });
726 }
727 html = AudioEmbedService.embedAlbumShortcodes(html, (name) => {
728 const tracks = byAlbum.get(name);
729 if (!tracks || !tracks.length) return null;
730 return {
731 title: name,
732 artist: tracks[0].artist || '',
733 cover: tracks[0].cover || '',
734 tracks,
735 };
736 });
737 }
738
739 // Playlist shortcodes: [[playlist:some-slug-id]] — first-class entity.
740 // Editing the playlist propagates to every post that embeds it.
741 const playlistIds = [...html.matchAll(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi)]
742 .map(m => m[1].toLowerCase());
743 if (playlistIds.length) {
744 const isAdmin = req.session?.user?.role === 'god';
745 html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
746 return PlaylistService.get(site.id, id, audioUrl);
747 }, { isAdmin });
748 }
749 }
750 } else {
751 // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
752 // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
753 // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
754 html = AudioEmbedService.autoembed(html);
755 html = AudioEmbedService.embedMediaShortcodes(html);
756 html = AudioEmbedService.embedExternalLinkShortcodes(html);
757 html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
758 }
759 return html;
760}
761
762// A short public teaser for a paid post: its excerpt, else the first ~280 chars
763// of the (stripped) content. Shared by the web gate and federation.
764function paidTeaser(post, max = 280) {
765 if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
766 // Only the FIRST paragraph: a paid teaser must never spill later content.
767 const html = String((post && post.content) || '');
768 const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
769 const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
770 return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
771}
772
773function postNeighbors(site, post) {
774 const ordered = db.prepare(`
775 SELECT id, slug, title, pinned FROM posts
776 WHERE site_id = ? AND status = 'published'
777 ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
778 `).all(site.id);
779 const idx = ordered.findIndex((p) => p.id === post.id);
780 const newerPost = idx > 0 ? ordered[idx - 1] : null;
781 const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
782 if (newerPost) newerPost._urlBase = '';
783 if (olderPost) olderPost._urlBase = '';
784 return { newerPost, olderPost };
785}
786
787// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
788// Standard fediverse "reply from your own server" landing endpoint. A post page
789// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
790// composes a reply that federates back to that post.
791router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
792 const site = res.locals.site;
793 const uri = (req.query.uri || '').toString();
794 const sent = !!req.query.sent;
795 const followed = !!req.query.followed;
796 const voted = !!req.query.voted;
797 const reported = !!req.query.reported;
798 let target = null, followTarget = null;
799 if (!sent && !followed && !voted && !reported && uri) {
800 try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
801 // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
802 if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
803 }
804 renderPage(req, res, 'pages/authorize-interaction', {
805 pageJs: 'authorize-interaction reply-editor',
806 pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
807 bodyClass: 'on-special',
808 uri,
809 target,
810 followTarget,
811 sent,
812 followed,
813 voted: !!req.query.voted,
814 reported: !!req.query.reported,
815 liked: !!req.query.liked,
816 boosted: !!req.query.boosted,
817 reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
818 siteTitle: site ? site.title : '',
819 });
820});
821
822// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
823// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
824router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
825 const site = res.locals.site;
826 const uri = (req.body.uri || '').toString();
827 let choice = req.body.choice;
828 if (choice == null) choice = [];
829 if (!Array.isArray(choice)) choice = [choice];
830 if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
831 res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
832});
833
834// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
835router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
836 const site = res.locals.site;
837 const uri = (req.body.uri || '').toString();
838 const actorUri = (req.body.actor_uri || '').toString();
839 const reason = (req.body.reason || '').toString();
840 if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
841 res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
842});
843
844// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
845router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
846 const site = res.locals.site;
847 const uri = (req.body.uri || '').toString();
848 let on = false;
849 if (site && uri) {
850 on = !ActivityPubService.getReaction(site.slug, uri).liked;
851 ActivityPubService.resolveRemoteNote(uri)
852 .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
853 .catch((e) => console.warn('[AP] remote like failed:', e.message));
854 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
855 ActivityPubService.setReaction(site.slug, uri, 'like', on);
856 }
857 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
858 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
859});
860
861// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
862// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
863router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
864 const site = res.locals.site;
865 const uri = (req.body.uri || '').toString();
866 let on = false;
867 if (site && uri) {
868 on = !ActivityPubService.getReaction(site.slug, uri).boosted;
869 ActivityPubService.resolveRemoteNote(uri)
870 .then((note) => {
871 if (!note) return;
872 const id = note.object_uri || uri;
873 return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
874 // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
875 // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
876 // de Cirkel verschijnt.
877 .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
878 })
879 .catch((e) => console.warn('[AP] remote boost failed:', e.message));
880 // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
881 // setReaction en niet via setMyReaction: ook dit korte moment mag geen
882 // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
883 // note, zodat de post ook in je tijdlijn belandt.
884 ActivityPubService.setReaction(site.slug, uri, 'boost', on);
885 }
886 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
887 res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
888});
889
890// Follow a remote actor from your own site (when the target is a profile, not a post).
891router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
892 const site = res.locals.site;
893 const uri = (req.body.uri || '').toString();
894 if (!site || !uri) return res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
895 // Afwachten in plaats van wegsturen: ligt het verzoek bij de guardians, dan
896 // moet dat op het scherm staan (shaer-p729). "followed=1" terwijl er niets
897 // gebeurd is, is precies de leugen die de poort waardeloos maakt.
898 ActivityPubService.followActor(site, uri)
899 .then((r) => res.redirect('/authorize_interaction?' + (r && r.held ? 'held=1' : 'followed=1') + '&uri=' + encodeURIComponent(uri)))
900 .catch((e) => {
901 console.warn('[AP] remote follow failed:', e.message);
902 res.redirect('/authorize_interaction?error=1&uri=' + encodeURIComponent(uri));
903 });
904});
905
906router.post('/authorize_interaction', requireSiteManager, (req, res) => {
907 const site = res.locals.site;
908 const uri = (req.body.uri || '').toString();
909 const text = (req.body.text || '').toString();
910 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
911 const language = (req.body.language || '').toString();
912 let attachments = [];
913 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
914 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
915 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
916 if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
917 // Resolve + deliver in the background so Send responds instantly.
918 ActivityPubService.resolveRemoteNote(uri)
919 .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
920 .catch((e) => console.warn('[AP] remote reply failed:', e.message));
921 }
922 res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
923});
924
925// Manage / delete your own outbound fediverse replies (site owner only).
926// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
927// The old /fediverse (manage) and /notifications pages redirect here.
928router.get('/messages', requireSiteManager, (req, res) => {
929 const site = res.locals.site;
930 const append = req.query.append === '1';
931 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
932 const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
933 const hasMore = page.length > FEED_PAGE;
934 const items = page.slice(0, FEED_PAGE);
935 // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
936 const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
937 // Only stamp "seen" on the first page load (not on Load-more appends).
938 if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
939 const moreBase = res.locals.siteUrlBase || '';
940 if (append) {
941 return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
942 }
943 // FEP-633c: pending guardianship offers TO this account (I am the ward)
944 // show as a special message with an accept button (Robins besluit: the kid
945 // answers in its own Klonkt; safety is out-of-band by the guardians).
946 const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
947 const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
948 const guardianOffers = (site
949 ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
950 : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
951 renderPage(req, res, 'pages/messages', {
952 pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages reply-editor', items, seenAt,
953 hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
954 success: req.query.success || null, error: req.query.error || null,
955 });
956});
957
958// The kid answers a guardianship offer from Berichten: the same C2S
959// Accept/Reject pipeline the Shaer apps use (one path, one behavior).
960router.post('/messages/guardianship', requireSiteManager, async (req, res) => {
961 const site = res.locals.site;
962 const back = `${res.locals.siteUrlBase || ''}/messages`;
963 const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
964 const offer = String(req.body.offer || '').trim();
965 if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
966 try {
967 // Same C2S Accept/Reject the apps use; the handshake module records the
968 // ward's accept and (once the candidate returns the handle) commits.
969 const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
970 if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
971 } catch { /* fall through */ }
972 res.redirect(back + '?error=guardianship');
973});
974// A ward answers a guardian's wave without publishing: a canned private note
975// back to the sender (FEP-633c §5, shaer:wave reply). Same direct-note leg.
976router.post('/messages/quick-reply', requireSiteManager, express.urlencoded({ extended: false }), async (req, res) => {
977 const site = res.locals.site;
978 const back = `${res.locals.siteUrlBase || ''}/messages`;
979 const to = String(req.body.to || '').trim();
980 const text = String(req.body.text || '').trim().slice(0, 200);
981 // Zwaaien is een seintje, en een seintje hoort de pagina niet te herladen.
982 // De module stuurt hem met X-Requested-With: fetch en krijgt JSON terug;
983 // zonder JS blijft het formulier gewoon posten en omleiden.
984 const viaFetch = req.get('X-Requested-With') === 'fetch';
985 const mis = (reden) => (viaFetch ? res.status(400).json({ ok: false, error: reden }) : res.redirect(back + '?error=' + reden));
986 if (!site || !/^https?:\/\//i.test(to) || !text) return mis('quickreply');
987 try {
988 const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
989 if (r) return viaFetch ? res.json({ ok: true }) : res.redirect(back + '?success=wave_sent');
990 } catch { /* fall through */ }
991 return mis('quickreply');
992});
993
994// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
995// bepaalt de draad zelf (zie groupConversations → replyTo):
996// - hangt de draad aan een post van jou, dan is dit een gewone reply op het
997// nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
998// - hangt hij aan een persoon, dan is het een direct bericht terug.
999// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
1000// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
1001// als enige stuurt).
1002router.post('/messages/reply', requireSiteManager, async (req, res) => {
1003 const site = res.locals.site;
1004 const back = `${res.locals.siteUrlBase || ''}/messages`;
1005 if (!site) return res.status(404).send('Site required');
1006 const text = String(req.body.text || '');
1007 const html = String(req.body.content || '');
1008 let attachments = [];
1009 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1010 let mentions;
1011 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1012 const language = String(req.body.language || '');
1013 // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
1014 if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
1015
1016 const interactionId = parseInt(req.body.interaction_id, 10) || 0;
1017 const postSlug = String(req.body.post_slug || '');
1018 const toActor = String(req.body.to || '');
1019 try {
1020 if (interactionId && postSlug) {
1021 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
1022 const parent = ActivityPubService.getInteractionById(interactionId);
1023 // De parent MOET bij deze post horen: anders zou een gemanipuleerd
1024 // formulier een antwoord onder andermans draad kunnen hangen.
1025 if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
1026 await ActivityPubService.deliverReply(site, {
1027 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
1028 });
1029 } else if (/^https?:\/\//i.test(toActor)) {
1030 const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
1031 if (!r) return res.redirect(back + '?error=reply_failed');
1032 } else {
1033 return res.redirect(back + '?error=reply_target');
1034 }
1035 } catch (e) {
1036 console.warn('[AP] reply from Berichten failed:', e.message);
1037 return res.redirect(back + '?error=reply_failed');
1038 }
1039 res.redirect(back + '?success=reply_sent');
1040});
1041
1042router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1043
1044router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
1045 const site = res.locals.site;
1046 if (site) {
1047 try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
1048 catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
1049 }
1050 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1051});
1052
1053// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
1054// object URI so re-delivery and thread-crawling never bring it back. Works for private
1055// notes too (acts on the local copy; no remote fetch involved).
1056router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
1057 const site = res.locals.site;
1058 if (site) {
1059 const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
1060 if (r.error) console.warn('[AP] interaction remove failed:', r.error);
1061 }
1062 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1063});
1064
1065// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
1066// locally stored object/actor URIs, so it also works for private notes that
1067// authorize_interaction cannot fetch (401/404).
1068router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
1069 const site = res.locals.site;
1070 if (site) {
1071 const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
1072 if (tgt && (tgt.objectUri || tgt.actorUri)) {
1073 try {
1074 const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
1075 if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
1076 } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
1077 }
1078 }
1079 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
1080});
1081
1082// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
1083router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
1084 const site = res.locals.site;
1085 const text = String(req.body.text || '');
1086 const html = String(req.body.content || ''); // rich reply editor HTML (sanitized in deliverOutboxUpdate)
1087 if (site && (text.trim() || html.trim())) {
1088 try {
1089 await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
1090 html, language: String(req.body.language || ''),
1091 });
1092 } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
1093 }
1094 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
1095});
1096
1097// ==================== FEDIVERSE CLIENT: home timeline + following ====================
1098// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
1099// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
1100function timelineEmbedHtml(html) {
1101 if (!html) return null;
1102 const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
1103 while ((m = re.exec(html))) {
1104 const u = m[1]; if (seen.has(u)) continue; seen.add(u);
1105 let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
1106 if (!p) {
1107 // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
1108 // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
1109 // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
1110 const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
1111 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>`;
1112 continue;
1113 }
1114 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>`;
1115 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>`;
1116 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>`;
1117 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>`;
1118 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>`;
1119 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>`; }
1120 }
1121 return null;
1122}
1123
1124// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
1125// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
1126// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
1127function klonktAudioEmbed(html, url) {
1128 if (!html || !url || html.indexOf('🎵') < 0) return null;
1129 let u; try { u = new URL(url); } catch { return null; }
1130 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
1131 const slug = u.pathname.replace(/^\/+|\/+$/g, '');
1132 if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
1133 const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
1134 // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
1135 const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
1136 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>` };
1137}
1138
1139/**
1140 * FEP-633c §5.3-style gated feature: may this account see previews of links
1141 * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
1142 *
1143 * Applied at SERVE time on every surface, the way the app's inbox read already
1144 * does it (routes/activitypub.js): a card the client merely hides has still
1145 * been delivered.
1146 */
1147function gateEmbeds(site, rows) {
1148 if (!site || !rows.length) return rows;
1149 if (embedsAllowedFor(site)) return rows;
1150 return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
1151}
1152
1153function isWardSite(site) {
1154 try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
1155}
1156function embedsAllowedFor(site) {
1157 return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
1158}
1159/**
1160 * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
1161 * sibling of the preview gate.) This was the hole: the player iframe is built
1162 * from the note's content by timelineEmbedHtml, on a path that never touched
1163 * gateEmbeds. A ward whose guardians had allowed nothing still got the full
1164 * YouTube player on the web, while the app showed nothing at all: the heavy
1165 * thing open, the light thing shut. Playback also requires the preview gate,
1166 * because you cannot play what you may not see.
1167 */
1168function playbackAllowedFor(site) {
1169 if (!site) return true;
1170 if (!embedsAllowedFor(site)) return false;
1171 return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
1172}
1173
1174router.get('/news', requireSiteManager, (req, res) => {
1175 const site = res.locals.site;
1176 const append = req.query.append === '1';
1177 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
1178 const cspOrigins = new Set();
1179 // Fetch one extra to know whether a "Load more" button belongs on this page.
1180 const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
1181 const hasMore = rows.length > FEED_PAGE;
1182 // Players (a third party's engine inside our page) ride the playback gate;
1183 // a Klonkt site's own audio embed is ours and stays.
1184 const mayPlay = playbackAllowedFor(site);
1185 const timeline = rows.slice(0, FEED_PAGE).map((p) => {
1186 let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
1187 let content = p.content;
1188 let embedUrl = null;
1189 if (!embedHtml) {
1190 const k = klonktAudioEmbed(p.content, p.url);
1191 if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
1192 }
1193 // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
1194 // top-level "open the player" link that works even when a browser shield/CSP blocks
1195 // the cross-site iframe (a full-page navigation is not a cross-site frame).
1196 let poll = null;
1197 if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
1198 return { ...p, content, embedHtml, embedUrl, poll };
1199 });
1200 // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
1201 // extending ONLY this response's CSP frame-src. The global policy stays locked down.
1202 if (cspOrigins.size) {
1203 const csp = res.getHeader('Content-Security-Policy');
1204 if (csp) {
1205 const extra = [...cspOrigins].join(' ');
1206 res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
1207 }
1208 }
1209 const moreBase = res.locals.siteUrlBase || '';
1210 if (append) {
1211 return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
1212 }
1213 renderPage(req, res, 'pages/news', {
1214 pageJs: 'news',
1215 pageTitle: 'News', bodyClass: 'on-special',
1216 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
1217 success: req.query.success || null, error: req.query.error || null,
1218 });
1219});
1220
1221// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
1222// Connect = who you follow + who follows you, merged into one page with direction
1223// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
1224// separate Following/Followers pages, which redirect here so old links keep working.
1225router.get('/connect', requireSiteManager, (req, res) => {
1226 const site = res.locals.site;
1227 const connections = site ? ActivityPubService.listConnections(site.slug) : [];
1228 // FEP-633c §2: the ward always sees who guards it, and §3.6 how available
1229 // each of them is. Connect is where "who am I connected to" belongs; a
1230 // guardian is the one connection a ward should never have to hunt for.
1231 // Owner-only by construction: this page is the owner's.
1232 const guardianHandle = (uri, cached) => {
1233 if (cached && cached.charAt(0) === '@') return cached;
1234 try { const u = new URL(uri); return `@${u.pathname.split('/').filter(Boolean).pop()}@${u.host}`; }
1235 catch { return uri; }
1236 };
1237 const gStatus = site ? Object.fromEntries(
1238 Guardianship.availability.statusesFor(site.slug, Guardianship.listGuardians(site.slug).map((g) => g.other_uri), Date.now())
1239 .map((s) => [s.id, s]),
1240 ) : {};
1241 const myGuardians = (site ? Guardianship.listGuardians(site.slug) : [])
1242 .map((g) => ({
1243 uri: g.other_uri,
1244 handle: guardianHandle(g.other_uri, g.other_handle),
1245 availability: (gStatus[g.other_uri] || {})['shaer:availability'] || 'active',
1246 awayUntil: (gStatus[g.other_uri] || {})['shaer:awayUntil'] || null,
1247 }));
1248 renderPage(req, res, 'pages/connect', {
1249 pageTitle: 'Connect', bodyClass: 'on-special',
1250 connections, myGuardians,
1251 success: req.query.success || null, error: req.query.error || null,
1252 });
1253});
1254router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1255router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
1256
1257router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
1258 const site = res.locals.site;
1259 const base = res.locals.siteUrlBase || '';
1260 if (!site) return res.redirect(`${base}/connect`);
1261 const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
1262 return res.redirect(`${base}/connect?` + (ok
1263 ? 'success=' + encodeURIComponent('Volger verwijderd')
1264 : 'error=' + encodeURIComponent('Volger niet gevonden')));
1265});
1266
1267router.post('/news/follow', requireSiteManager, async (req, res) => {
1268 const site = res.locals.site;
1269 const handle = (req.body.handle || '').toString();
1270 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
1271 if (site && handle.trim()) {
1272 try {
1273 const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
1274 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
1275 // Een DERDE uitkomst, niet gelukt en niet mislukt (shaer-p729). "Je volgt
1276 // nu X" zeggen terwijl het verzoek bij de guardians ligt is de leugen die
1277 // deze poort waardeloos maakt: het kind denkt dat het gebeurd is.
1278 else if (r && r.held) q = 'success=' + encodeURIComponent(r.status === 'denied' ? 'Je guardians hebben dit geweigerd' : 'Je verzoek ligt bij je guardians');
1279 else {
1280 q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
1281 }
1282 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
1283 }
1284 res.redirect('/following?' + q);
1285});
1286
1287// ── Je volglijst meenemen ─────────────────────────────────────────
1288//
1289// Zonder dit was verhuizen halfslachtig: de Move vertelt je VOLGERS waar je heen
1290// ging, maar niets vertelde JOU wie jij volgde. Die lijst stond alleen in de
1291// database die je achterlaat.
1292router.get('/news/following.csv', requireSiteManager, async (req, res) => {
1293 const site = res.locals.site;
1294 const { followingCsv } = await import('../services/ArchiveExportService.js');
1295 const csv = site ? followingCsv(site.slug) : null;
1296 if (!csv) return res.redirect('/following?error=' + encodeURIComponent('Je volgt nog niemand'));
1297 res.set('Content-Type', 'text/csv; charset=utf-8');
1298 res.set('Content-Disposition', `attachment; filename="following-${site.slug}.csv"`);
1299 // Privé: dit is de lijst van wie jij volgt, niets voor een cache onderweg.
1300 res.set('Cache-Control', 'private, no-store');
1301 res.send(csv);
1302});
1303
1304router.post('/news/following/import', requireSiteManager, express.urlencoded({ extended: false, limit: '256kb' }), async (req, res) => {
1305 const site = res.locals.site;
1306 const csv = (req.body && req.body.csv) || '';
1307 if (!site || !String(csv).trim()) return res.redirect('/following?error=' + encodeURIComponent('Geen lijst ontvangen'));
1308
1309 const { importFollowing } = await import('../services/ArchiveImportService.js');
1310 // followActor als followFn: die doet de webfinger, stuurt de Follow en zet
1311 // auto_boost meteen goed. Zo blijft er één pad naar een volgrelatie.
1312 const r = await importFollowing(site, csv, {
1313 followFn: async (s, adres, uitgelicht) => {
1314 const uit = await ActivityPubService.followActor(s, adres, !!uitgelicht);
1315 // followActor meldt een fout als VELD, niet als exception. Zonder deze
1316 // vertaling telde een onvindbaar account gewoon als geslaagd mee.
1317 if (uit && uit.error) throw new Error(uit.error);
1318 return true;
1319 },
1320 });
1321
1322 const delen = [`${r.gevolgd} gevolgd`];
1323 if (r.overgeslagen) delen.push(`${r.overgeslagen} overgeslagen`);
1324 if (r.mislukt.length) {
1325 const namen = r.mislukt.slice(0, 3).map((m) => m.adres).join(', ');
1326 delen.push(`${r.mislukt.length} mislukt (${namen}${r.mislukt.length > 3 ? '…' : ''})`);
1327 }
1328 res.redirect('/following?' + (r.mislukt.length ? 'error=' : 'success=') + encodeURIComponent(delen.join(', ')));
1329});
1330
1331router.post('/news/unfollow', requireSiteManager, async (req, res) => {
1332 const site = res.locals.site;
1333 const actorUri = (req.body.actor_uri || '').toString();
1334 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
1335 res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
1336});
1337
1338// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
1339router.post('/news/autoboost', requireSiteManager, (req, res) => {
1340 const site = res.locals.site;
1341 const actorUri = (req.body.actor_uri || '').toString();
1342 if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
1343 res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
1344});
1345
1346// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
1347// no banner); no-JS → redirect back.
1348router.post('/news/like', requireSiteManager, async (req, res) => {
1349 const site = res.locals.site;
1350 const note = (req.body.note || '').toString();
1351 let on = false;
1352 if (site && note) {
1353 on = !ActivityPubService.getReaction(site.slug, note).liked;
1354 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1355 ActivityPubService.setReaction(site.slug, note, 'like', on);
1356 }
1357 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1358 res.redirect('/news');
1359});
1360
1361// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
1362router.post('/news/boost', requireSiteManager, async (req, res) => {
1363 const site = res.locals.site;
1364 const note = (req.body.note || '').toString();
1365 let on = false;
1366 if (site && note) {
1367 on = !ActivityPubService.getReaction(site.slug, note).boosted;
1368 try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
1369 ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
1370 if (on) {
1371 // Fire-and-forget: re-resolve the note so the cached row is refreshed
1372 // (cover/content) — boosting again heals a stale copy from EVERY boost
1373 // path, not just the interact page.
1374 ActivityPubService.resolveRemoteNote(note)
1375 .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
1376 .catch(() => { /* best-effort */ });
1377 }
1378 }
1379 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
1380 res.redirect('/news');
1381});
1382
1383// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
1384router.post('/news/vote', requireSiteManager, async (req, res) => {
1385 const site = res.locals.site;
1386 const note = (req.body.note || '').toString();
1387 let choice = req.body.choice;
1388 if (choice == null) choice = [];
1389 if (!Array.isArray(choice)) choice = [choice];
1390 if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
1391 res.redirect('/news');
1392});
1393
1394// Notifications inbox (new followers + replies/likes/boosts on your posts).
1395router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
1396
1397// Blocking / defederation (owner-only).
1398router.get('/blocking', requireSiteManager, (req, res) => {
1399 const site = res.locals.site;
1400 const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
1401 renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
1402});
1403
1404router.post('/blocking/add', requireSiteManager, async (req, res) => {
1405 const site = res.locals.site;
1406 let q = 'success=' + encodeURIComponent('Geblokkeerd');
1407 if (site) {
1408 try {
1409 const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
1410 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
1411 else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
1412 } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
1413 }
1414 const ref = req.get('Referer') || '';
1415 res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
1416});
1417
1418router.post('/blocking/remove', requireSiteManager, (req, res) => {
1419 const site = res.locals.site;
1420 if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
1421 res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
1422});
1423
1424// ==================== VIEW POST (last route — catches /:slug) ====================
1425router.get('/:slug', (req, res, next) => {
1426 if (RESERVED_SLUGS.has(req.params.slug)) return next();
1427
1428 const site = res.locals.site;
1429 if (!site) return next(); // -> nette 404 catch-all
1430
1431 const post = db.prepare(`
1432 SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
1433 FROM posts p JOIN users u ON p.author_id = u.id
1434 WHERE p.site_id = ? AND p.slug = ?
1435 `).get(site.id, req.params.slug);
1436
1437 if (!post) return next(); // unknown slug -> clean 404 catch-all
1438
1439 // Permission to view: published OR (logged in + can edit)
1440 if (post.status !== 'published') {
1441 const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1442 if (!canEdit) return res.status(403).send('Not published');
1443 }
1444
1445 // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
1446 // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
1447 // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
1448 // paid gate wins (otherwise anonymous visitors land on the login gate and
1449 // never see the unlock button).
1450 const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
1451 // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
1452 // supporter render the FULL post through this normal template (correct layout,
1453 // scoped styles, working audio). Short-lived signed blob, single post, not a
1454 // cookie and not stored.
1455 const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
1456 const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
1457 if (post.paid && !canEditThis && !_unlocked) {
1458 const { newerPost, olderPost } = postNeighbors(site, post);
1459 return renderPage(req, res, 'pages/paid-gate', {
1460 pageJs: 'paid-gate',
1461 pageTitle: post.title || 'Voor supporters',
1462 bodyClass: 'on-special',
1463 pgTitle: post.title || '',
1464 pgTeaser: paidTeaser(post),
1465 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
1466 pgSlug: post.slug,
1467 pgPatronUrl: paidPatronUrl(site.id),
1468 newerPost,
1469 olderPost,
1470 });
1471 }
1472
1473 // Fan-only preview (premium #3): full content only for logged-in fans.
1474 // Anonymous visitors get a clean login gate instead of the content (the title/
1475 // teaser may still appear elsewhere as a teaser).
1476 if (post.fan_only && !(req.session && req.session.user)) {
1477 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
1478 // stuck on the fan gate but can keep browsing.
1479 const { newerPost, olderPost } = postNeighbors(site, post);
1480 return renderPage(req, res, 'pages/fan-gate', {
1481 pageTitle: post.title || 'Alleen voor fans',
1482 bodyClass: 'on-special',
1483 fgTitle: post.title || '',
1484 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
1485 newerPost,
1486 olderPost,
1487 });
1488 }
1489
1490 // Statistics: count the view (skips admins + unpublished own-preview).
1491 if (post.status === 'published') recordPostView(post, req);
1492
1493 // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
1494 // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
1495 // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
1496 // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
1497 // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
1498 post.content_html = renderPostBodyHtml(site, post, req);
1499
1500 if (post.tags) {
1501 try { post.tags = JSON.parse(post.tags); } catch { post.tags = []; }
1502 } else {
1503 post.tags = [];
1504 }
1505
1506 // Native comments removed: social interaction is fediverse-only (see the
1507 // "From the fediverse" section below).
1508
1509 // Prev / next chronological (kept for back-compat — "post-nav" feature
1510 // below the article still uses these as a simple linear navigation).
1511 const urlBaseFor = () => '';
1512
1513 // Newer/Older across ALL posts (shared helper — also used by the fan gate).
1514 const { newerPost, olderPost } = postNeighbors(site, post);
1515
1516 // ── Related posts: same-tag matching with recency fallback ─────
1517 // Fetch ~50 candidates, score by tag overlap, take top 3.
1518 // Excluding self via `id != ?`.
1519 const candidates = db.prepare(`
1520 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
1521 FROM posts
1522 WHERE site_id = ? AND status = 'published' AND id != ?
1523 ORDER BY published_at DESC LIMIT 50
1524 `).all(site.id, post.id);
1525
1526 // Parse tags JSON safely; missing/malformed → empty array.
1527 const parseTags = (raw) => {
1528 if (!raw) return [];
1529 try {
1530 const v = JSON.parse(raw);
1531 return Array.isArray(v) ? v.map(String) : [];
1532 } catch { return []; }
1533 };
1534
1535 const myTags = new Set(parseTags(post.tags));
1536 let relatedPosts;
1537 if (myTags.size > 0) {
1538 // Score = number of overlapping tags. Posts with zero overlap are
1539 // included only if we don't have 3 with-overlap candidates.
1540 const scored = candidates.map(p => {
1541 const theirTags = parseTags(p.tags);
1542 const overlap = theirTags.reduce((n, t) => n + (myTags.has(t) ? 1 : 0), 0);
1543 return { ...p, _overlap: overlap };
1544 });
1545 const withOverlap = scored.filter(p => p._overlap > 0)
1546 .sort((a, b) => b._overlap - a._overlap || new Date(b.published_at) - new Date(a.published_at));
1547 if (withOverlap.length >= 3) {
1548 relatedPosts = withOverlap.slice(0, 3);
1549 } else {
1550 // Pad with most-recent non-overlap posts so the section is never empty
1551 const overlapIds = new Set(withOverlap.map(p => p.id));
1552 const filler = candidates.filter(p => !overlapIds.has(p.id));
1553 relatedPosts = [...withOverlap, ...filler].slice(0, 3);
1554 }
1555 } else {
1556 // No tags on current post → just show 3 most-recent
1557 relatedPosts = candidates.slice(0, 3);
1558 }
1559 // Strip the internal _overlap field before sending to view
1560 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
1561
1562 // Inbound fediverse activity (threaded) for this post.
1563 let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
1564 try {
1565 const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1566 fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
1567 // Stale-while-revalidate: render from cache now; refresh the remote thread in the
1568 // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
1569 if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
1570 } catch { /* non-fatal */ }
1571 // Owner/admin of this site may reply back to a fediverse interaction.
1572 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
1573 // Avatar for our own (outbound) fediverse replies = the site's profile photo.
1574 const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
1575
1576 renderPage(req, res, 'pages/post', {
1577 pageJs: 'post reply-editor',
1578 post,
1579 poll: ActivityPubService.ownPollView(post),
1580 newerPost,
1581 olderPost,
1582 relatedPosts,
1583 fediverse,
1584 canManageSite,
1585 siteAvatar,
1586 postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
1587 musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
1588 pageTitle: post.title + ' - ' + site.title,
1589 socialDescr: post.excerpt || '',
1590 socialImage: post.cover_image_url || '',
1591 bodyClass: 'on-post',
1592 });
1593});
1594
1595// ── Reply back to a fediverse interaction (site owner/admin only) ──
1596router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
1597 const site = res.locals.site;
1598 if (!site) return res.status(404).send('Site required');
1599 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1600 if (!post) return res.status(404).send('Not found');
1601 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1602 const text = (req.body.text || '').toString();
1603 const html = (req.body.content || '').toString(); // rich reply editor HTML (sanitized in deliverReply)
1604 let attachments = [];
1605 try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
1606 let mentions; // undefined = geen balk meegestuurd (legacy addressing)
1607 try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
1608 if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
1609 try {
1610 await ActivityPubService.deliverReply(site, {
1611 postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
1612 language: (req.body.language || '').toString(),
1613 });
1614 } catch (e) { console.warn('[AP] reply send failed:', e.message); }
1615 }
1616 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1617});
1618
1619// Owner likes/boosts a fediverse comment on their own post — directly as the
1620// site, no "your server" detour (mirrors /fedi-reply).
1621router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
1622 const site = res.locals.site;
1623 if (!site) return res.status(404).send('Site required');
1624 const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
1625 if (!post) return res.status(404).send('Not found');
1626 const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
1627 const kind = req.body.kind === 'boost' ? 'boost' : 'like';
1628 if (parent && parent.post_id === post.id && parent.object_uri) {
1629 // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
1630 // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
1631 // de kolom en de knop uit de tussentabel, dan draait een divergentie de
1632 // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
1633 const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
1634 const on = kind === 'boost' ? !ik.boosted : !ik.liked;
1635 ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
1636 .catch((e) => console.warn('[AP] reaction failed:', e.message));
1637 // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
1638 // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
1639 // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
1640 // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
1641 ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
1642 if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
1643 else ActivityPubService.setInteractionLiked(parent.id, on);
1644 }
1645 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
1646});
1647
1648export default router;
1649export { postNeighbors };
Note: See TracBrowser for help on using the repository browser.