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

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

De volglijst als bestand uploaden, niet alleen plakken

Robins vraag bij de echte verhuizing: hij downloadt een CSV en moet hem dan met de
hand openen en de inhoud overplakken. Dat is een omweg die niemand hoort te lopen.

Nu een bestandskiezer naast het plakveld. Multer met memoryStorage, want dit is een
lijstje adressen van een paar kilobyte dat na het lezen niets te zoeken heeft op de
server; op schijf schrijven zou alleen rommel achterlaten. Een gekozen bestand wint
van het plakveld: wie een bestand aanwijst bedoelt dat.

De BOM gaat eraf. Excel zet die voor een CSV, en zonder dat eraf te halen plakt hij
aan het eerste adres vast en is de eerste regel stil onbruikbaar.

En twee redirects in deze route wezen nog naar /following. Dat is de oude pagina
waar niets meer naartoe linkt (zie 822d16d), dus je kwam na een import op een
scherm waar je zelf niet meer weg kon. Beide naar /connect.

Changed files:
src/routes/posts.js

  • multer memoryStorage, single('csvfile'), 512kb, 1 bestand
  • bestand wint van plakveld, BOM eraf
  • drie redirects van /following naar /connect

src/views/pages/connect.ejs

  • bestandskiezer met accept=".csv,text/csv,text/plain", formulier op multipart

src/services/i18n.js

  • tl.move_import_file in nl, en en de; het plakveld-label ingekort tot "Of plak de lijst hier"

test/following-csv.test.js

  • test 14: een echt multipart-verzoek met een BOM ervoor, plus de controle dat het plakveld ernaast blijft werken

remarks: 927 groen in UTC en Europe/Amsterdam. De uploadweg is met een echte
multipart-POST tegen een draaiende express+multer getest, niet alleen beredeneerd.
De pagina apart gerenderd: bestandskiezer en multipart-enctype aanwezig, geen
onvertaalde sleutels.

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

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