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

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

Een verhuisd account gaat op slot aan de uitgaande kant

Robins vraag: "nu kan je er nog alles mee". Klopt. moved_to deed maar twee dingen,
de actor-wegwijzer en een banner op de homepage. Posten, volgen, liken, boosten en
reageren kon gewoon door, en dat federeerde de wereld in.

Drie dingen gingen daar mis. Nieuwe posts kregen een object-URI op een adres dat je
hebt opgezegd, en die URI's overleven het domein niet. Je volgers waren al verhuisd,
dus je postte in het niets terwijl het lijkt of je post. En een server die je
movedTo ziet én tegelijk verse activiteit van dat adres krijgt, krijgt
tegenstrijdige signalen over de verhuizing.

DE POORT STAAT IN DE SERVICE, NIET OP DE KNOPPEN. Een C2S-client (Shaer) praat
rechtstreeks met deze functies en zou langs een verborgen knop lopen. De UI volgt
de poort.

Dicht: posten, reageren, volgen, liken, boosten, stemmen, en een tweede verhuizing.
Open: de actor met movedTo, webfinger, bestaande posts, de outbox, alles inkomend
(reacties op oude posts blijven binnenkomen en leesbaar), je eigen beheer (archief
en volglijst exporteren), ontvolgen, en rapporteren. Dat laatste is een
veiligheidsklep, geen inhoud maken.

Omkeerbaar: moved_to leegmaken heft het slot op. Een verhuizing kan mislukken.

setReaction is APART gepoort, want die schrijft alleen de lokale vlag; het
versturen gebeurt elders. Zonder die tweede poort zag je een like staan die nooit
de deur uit was: dezelfde halve toestand die vandaag al twee keer opdook.

Changed files:
src/services/ActivityPubService.js

  • movedLock() en movedRefusal(), met de scheidslijn in het commentaar
  • poorten in deliverCreate, deliverReply, followActor, sendInteraction, voteOnRemotePoll, setReaction en moveAccount

src/routes/posts.js

  • 'moved' krijgt een eigen melding: het is een weigering met een reden, niet een mislukking, en "Volgen mislukt" laat je zoeken naar een storing die er niet is
  • connect krijgt movedTo mee

src/views/pages/connect.ejs

  • banner in plaats van het volgformulier; het volglijst-blok BLIJFT, want je archief meenemen moet je juist kunnen op een verhuisd account

src/services/i18n.js

  • drie teksten in nl, en en de

New file:
test/moved-lockdown.test.js

  • 11 tests die per soort vastleggen wat dicht gaat en wat open blijft
  • inclusief: een half ingevuld moved_to zet niets op slot, ontvolgen mag nog, en de wegwijzer plus de inbox blijven staan

remarks: tegenproef gedaan. Zonder de poort vallen 8 van de 11 om en blijven de
drie "blijft open"-tests groen. Suite 949 in UTC en Europe/Amsterdam. De pagina in
beide standen gerenderd: formulier weg, banner er, exportblok blijft, geen
onvertaalde sleutels.

Doorsturen van reacties op oude posts is bewust NIET gebouwd. Zo'n reply wijst naar
een object-URI met de oude base, en findThreadTarget matcht alleen op onze eigen
base: hij zou als wees binnenkomen. Om het te laten werken zou de nieuwe instance
objecten moeten claimen op een domein dat hij niet beheert, en dat is precies waar
de sleutelbinding van vanmiddag tegen beschermt. Antwoorden doe je vanaf het nieuwe
account via de bestaande bladwijzerroute; dan blijft elke URI bij wie hem hoort.

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

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