source: Klonkt/src/routes/posts.js@ 7cf8144

main
Last change on this file since 7cf8144 was 7cf8144, checked in by Bart <bart@…>, 3 weeks ago

Lezen is een weergave van de feed, geen eigen plek

/read was een route met een eigen shell: eigen pagina, eigen chrome-gedrag, en
op een site zonder berichten een 404 -- een dode knop in de switcher. Nu is het
wat het altijd had moeten zijn: een derde waarde van body[data-feed-view], naast
timeline en grid. Dezelfde berichten, andere vorm.

De feed stuurt ze alledrie mee en CSS kiest, precies zoals timeline/grid dat al
deden. Het lijf loopt door PostAccessService, dus een gesloten poort levert ook
hier geen tekst op. "Meer laden" vult de leesstroom OOB mee aan, net als de
tegels.

DE LEGE STAAT zit in de :has(.feed-reader) op de verbergregel. Zonder berichten
rendert de leessectie niet, en dan blijft de tijdlijn staan -- want daar staat
"nog niets geschreven" met de knop om te beginnen. Zonder die voorwaarde was
Lezen op een verse site een wit scherm. Feeds zonder leessectie (cirkel,
archief) houden om dezelfde reden gewoon hun tijdlijn.

Wat weg kon, en dat is de winst:

  • de route, pages/read.ejs, en 'on-read' als page-class;
  • mod/read.js van 222 naar 51 regels. Het ophalen van buren, het corrigeren van de scrollpositie bij invoegen en het onderbreken van het snappen zijn niet opgelost maar OVERBODIG: de feed levert de berichten al. Wat blijft is de tik.
  • read.end en read.load_error: het script plaatst geen zinnen meer.

De balken schuiven niet meer weg. Dat was mooi op een eigen scherm, maar hier
zou het de switcher verbergen waarmee je net koos -- en dan kun je niet terug.

MOD_V naar 4, style.css naar v70.

Co-Authored-By: Claude Opus 5 <claude@…>

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