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

main
Last change on this file since d9ad6c5 was d9ad6c5, checked in by Robin Genis <roboburr@â€Ļ>, 6 weeks ago

Een hulpvraag hoort in Berichten, niet in de Krant

Een 🛟 van een ward kwam bij de guardian op twee plekken binnen: als mention in
Berichten en de Guardian PWA, maar ook als gewone post in de Krant. Op
sound-fabrics.com stonden vijf van de zes hulpvragen in allebei.

De oorzaak zat in de inbox: de tijdlijn-insert vroeg alleen "is dit een
top-level post van iemand die ik volg" en keek niet naar wie de post geadresseerd
was. Dat is nu belongsInTimeline: een directe note is aan iemand persoonlijk
gericht, dus een bericht en geen post. Dat dekt meteen de wave en de gewone DM,
die om dezelfde reden in de Krant terechtkwamen. De self-heal ruimt de al
opgeslagen exemplaren op, beperkt tot de twee soorten die achteraf nog te
herkennen zijn; een publieke mention van iemand die je volgt is wel een post en
blijft staan.

Tweede helft: de weergave gelijkgetrokken. De Krant rendert een post met
emoji's, een quote- of linkkaart en de media; Berichten liet daar niks van zien
(zelfs de shortcodes bleven staan, want ap_mentions had geen emoji_json) en de
Guardian PWA plakte de kale content in een div. Die opmaak zat bovendien in de
<style> van de Krant zelf, dus een post buiten de Krant kwam sowieso ongestyled
binnen.

Nu is er ÊÊn partial, note-body, met de opmaak in shared-styles ernaast. Alle
drie de oppervlakken gebruiken hem: de PWA bouwt zijn kaarten in de browser en
krijgt de body server-side gerenderd mee. ap_mentions en ap_interactions kregen
de kolommen die daarvoor nodig zijn, gevuld bij binnenkomst, met de quote- of
linkkaart out of band zoals de tijdlijn dat al deed.

En passant: de embed-gate stond alleen op de C2S-read, dus een ward zag in de
web-Krant nog steeds linkvoorbeelden die de guardians hadden uitgezet. Die gate
zit nu ook op /news en /messages.

Changed files:
src/services/ActivityPubService.js

  • belongsInTimeline: een directe note is geen tijdlijn-post
  • self-heal v21 verwijdert al opgeslagen hulpvragen en waves uit ap_timeline
  • resolveCard: quote of linkvoorbeeld, ÊÊn kaart, out of band opgelost
  • mentions en replies slaan emoji's, media en die kaart op
  • getNotifications geeft die kolommen door aan Berichten

src/config/database.js

  • kolommen op ap_mentions en ap_interactions voor emoji's, media, quote, embed

src/middleware/render.js

  • renderNoteBody: dezelfde partial als string, voor niet-EJS oppervlakken

src/routes/posts.js

  • gateEmbeds op /news en /messages (FEP-633c gated feature)

src/routes/guardian.js

  • hulpvragen krijgen body_html en name_html mee

src/assets/js/guardian.js

  • kaart rendert die body in plaats van de kale content

src/assets/css/guardian.css

  • opmaak voor de gedeelde post-body in de kleuren van de PWA

src/views/partials/tl-item.ejs

  • body vervangen door de gedeelde partial

src/views/partials/msg-item.ejs

  • idem, plus een 🛟-markering bij een hulpvraag

src/views/partials/shared-styles.ejs

  • .tl-content, .tl-quote* en .tl-media* hierheen verhuisd

src/views/pages/news.ejs

  • die regels weggehaald, alleen Krant-eigen opmaak blijft

src/services/i18n.js

  • msg.help_request in nl, en, de

New file:
src/views/partials/note-body.ejs

  • de body van een post: content, quote/linkkaart, media

test/help-request-timeline.test.js

  • een 🛟 blijft uit de Krant en in Berichten

test/note-body-shared.test.js

  • ÊÊn renderer, en beide views gaan er doorheen

remarks: geverifieerd op een wegwerp-database in de browser: de hulpvraag
verdwijnt bij het opstarten uit de Krant en staat mÊt quote en capture in
Berichten en de PWA. Nog niet uitgerold.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@â€Ļ>

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