source: Klonkt/src/routes/posts.js@ 156baa3

main
Last change on this file since 156baa3 was 156baa3, checked in by roboburr <roboburr@…>, 5 weeks ago

De chrome en vier schermen met servergegevens (shaer-bqr, stap 3e)

topnav en profile-sheet naar mod/chrome.js; admin-media, admin-videos, paid-gate,
paid-passkey en admin-playlists naar eigen modules.

TWEE MANIEREN om servergegevens bij een module te krijgen, en de keuze is niet
willekeurig:

OP HET ELEMENT waar de tekst bij EEN ding hoort. De zoekteksten staan nu op

#search-overlay, de twee themalabels op het label zelf. Dat is
ook robuuster: bij een htmx-navigatie wordt de chrome
vervangen, en dan komt de tekst gewoon mee.

VIA pageData() waar het om paginabrede gegevens gaat (csrf, slug, een blob,

een setje meldingen).

De chrome kon niet via pageData: die leeft langer dan een pagina.

DRIE FOUTEN ONDERWEG, alle drie gevangen voordat er iets stuk ging:

  1. Ik zocht de eerste </script> in het HELE bestand in plaats van die na het openende tag. paid-gate en paid-passkey hebben een <script src> erboven, dus het "blok" werd leeg en er werd een lege module geschreven. Teruggezet uit git en de zoekfunctie kreeg een offset plus een controle dat het blok niet leeg is.
  2. Een zoekterm sloot te vroeg af: de tekst van delete_failed loopt door in dezelfde JS-string (": " + err.message), dus het patroon met de sluitquote matchte niet.
  3. Een eerdere poging verving nul regels en schreef toch. Nu telt de helper de vervangingen en weigert als het er niet exact zoveel zijn als opgegeven.

Die controle op "geen enkele module bevat nog EJS" draait mee, en er is er nu een
bij die een verdacht lege module afvangt -- precies wat fout 1 opleverde.

Templates compileren, suite 565/565.

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