Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/posts.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,11 +3,9 @@
 import path from 'path';
 import fs from 'fs';
+import { fileURLToPath } from 'url';
 import multer from 'multer';
-import ejs from 'ejs';
 import db from '../config/database.js';
-import { POST_TYPES, KEUZE_TYPES } from '../config/post-types.js';
-import { requireAuth, requireSiteManager, isViewer } from '../middleware/auth.js';
+import { requireAuth } from '../middleware/auth.js';
 import { renderPage } from '../middleware/render.js';
-import { recordPageview, recordPostView } from '../services/StatsService.js';
 import PermissionsService from '../services/PermissionsService.js';
 import MarkdownService from '../services/MarkdownService.js';
@@ -15,46 +13,15 @@
 import AudioEmbedService from '../services/AudioEmbedService.js';
 import PlaylistService from '../services/PlaylistService.js';
-import { audioEnabled } from '../config/features.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import VideoCoverService from '../services/VideoCoverService.js';
-import ActivityPubService from '../services/ActivityPubService.js';
-import * as Guardianship from '../services/guardianship/index.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { defaultMinCents as paidDefaultMinCents, patreonUrl as paidPatronUrl } from '../services/PaidPatreonService.js';
-import { verifyBlob } from '../services/CryptoBox.js';
-import { postEntry } from '../services/PostAccessService.js';
-import * as OWA from '../services/OpenWebAuthService.js';
-import MusicMeta from '../services/MusicMeta.js';
-import { mediaDir } from '../config/paths.js';
-
-const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
+import { signUrl } from '../services/AudioStreamService.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const POST_IMAGES_DIR = path.resolve(
+  process.env.POST_IMAGES_PATH ||
+  path.join(__dirname, '..', '..', 'storage', 'media', 'post-images')
+);
 fs.mkdirSync(POST_IMAGES_DIR, { recursive: true });
 
 const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
 const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
-
-// Rich replies: media dropped/pasted into the reply editor. Images, audio and
-// video, stored as-is (no transcode; a reply attachment is not a track).
-const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
-fs.mkdirSync(REPLY_MEDIA_DIR, { recursive: true });
-const ALLOWED_REPLY_MEDIA_EXT = new Set([
-  '.jpg', '.jpeg', '.png', '.webp', '.gif',
-  '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav',
-  '.mp4', '.webm', '.mov',
-]);
-const MAX_REPLY_MEDIA_BYTES = 32 * 1024 * 1024;
-const replyMediaUpload = multer({
-  storage: multer.diskStorage({
-    destination: (req, file, cb) => cb(null, REPLY_MEDIA_DIR),
-    filename: (req, file, cb) => cb(null, `${uuid()}${path.extname(file.originalname).toLowerCase()}`),
-  }),
-  limits: { fileSize: MAX_REPLY_MEDIA_BYTES },
-  fileFilter: (req, file, cb) => {
-    const ext = path.extname(file.originalname).toLowerCase();
-    if (!ALLOWED_REPLY_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
-    cb(null, true);
-  },
-});
 
 const imageStorage = multer.diskStorage({
@@ -77,25 +44,5 @@
 });
 
-// Generates a unique slug within the site: 'title', 'title-2', 'title-3', …
-// A second post with the same title is NOT rejected ("already exists"),
-// but automatically gets a free suffix. exceptId = the post being updated
-// (allowed to keep its own slug).
-function uniqueSlug(siteId, base, exceptId = null) {
-  let candidate = base;
-  let n = 2;
-  for (;;) {
-    const row = exceptId
-      ? db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ? AND id != ?').get(siteId, candidate, exceptId)
-      : db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?').get(siteId, candidate);
-    if (!row) return candidate;
-    candidate = `${base}-${n++}`;
-  }
-}
-
 const router = express.Router();
-
-// Feed page size for "Load more" (Solo, News, Messages, Cirkel). 72 is divisible
-// by 2/3/4 so every grid column count ends on a full row.
-const FEED_PAGE = 72;
 
 // ==================== UPLOAD IMAGE (cover or content) ====================
@@ -103,40 +50,9 @@
 // insert a markdown ![](url) into content.
 router.post('/posts/upload-image', requireAuth, (req, res) => {
-  imageUpload.single('image')(req, res, async (err) => {
+  imageUpload.single('image')(req, res, (err) => {
     if (err) return res.status(400).json({ error: err.message });
     if (!req.file) return res.status(400).json({ error: 'No file' });
-    const name = toWebp(req.file);
-    const url = '/media/post-images/' + name;
-    // An animated WebP cover → also make a muted loop MP4 (Safari plays it smoothly where the
-    // animated WebP is janky on iOS). Best-effort; on failure we just return the still image.
-    // The editor stores `video` in the hidden cover_video_url field for the cover.
-    let video = null;
-    try {
-      const src = path.join(POST_IMAGES_DIR, name);
-      if (VideoCoverService.isAnimatedWebp(src)) {
-        const r = await VideoCoverService.animatedWebpToVideo(src, POST_IMAGES_DIR, path.basename(name, path.extname(name)) + '-v');
-        if (r) video = '/media/post-images/' + path.basename(r.videoPath);
-      }
-    } catch { /* keep the still image */ }
-    res.json({ url, video, size: req.file.size, mime: req.file.mimetype });
-  });
-});
-
-// Rich replies: media for a reply (image/audio/video). Returns { url, mediaType, name }
-// exactly as the editor's attachments JSON wants it; deliverReply re-validates.
-router.post('/posts/upload-reply-media', requireSiteManager, (req, res) => {
-  replyMediaUpload.single('media')(req, res, (err) => {
-    if (err) return res.status(400).json({ error: err.message });
-    if (!req.file) return res.status(400).json({ error: 'No file' });
-    const mime = String(req.file.mimetype || '');
-    if (!/^(image|audio|video)\//.test(mime)) {
-      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
-      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
-    }
-    res.json({
-      url: '/media/reply-media/' + req.file.filename,
-      mediaType: mime,
-      name: String(req.file.originalname || '').slice(0, 120),
-    });
+    const url = '/media/post-images/' + req.file.filename;
+    res.json({ url, size: req.file.size, mime: req.file.mimetype });
   });
 });
@@ -145,12 +61,7 @@
   'auth', 'admin', 'login', 'register', 'logout',
   'archive', 'search', 'account', 'sites', 'comments',
-  'posts', 'media', 'audio', 'forum',
-  'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml',
+  'posts', 'media', 'audio', 'prutter', 'forum',
+  'tag', 'type', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
-  'authorize_interaction', 'fediverse', 'news', 'following', 'notifications', 'blocking',
-  'paid', 'push', 'guardian',
-  // De meeslepende leesweergave. Gereserveerd
-  // omdat een bericht met deze slug de route anders zou overschaduwen.
-  'read',
 ]);
 
@@ -171,29 +82,4 @@
 }
 
-// Poll durations offered in the editor (seconds) — the Mastodon set (5m … 7d).
-const POLL_DURATIONS = new Set([300, 1800, 3600, 21600, 43200, 86400, 259200, 604800]);
-// Parse the editor's poll fields into the poll_json we store on the post (which
-// buildNote federates as an AS2 Question). Returns null when no valid poll (< 2
-// options or the poll checkbox is off). endTime is set from the chosen duration
-// (default 1 day) so the Scheduler can close it.
-function parsePollForm(body) {
-  if (!body || !body.poll_enabled) return null;
-  const raw = body.poll_option == null ? [] : (Array.isArray(body.poll_option) ? body.poll_option : [body.poll_option]);
-  const options = [];
-  const seen = new Set();
-  for (const o of raw) {
-    const name = String(o == null ? '' : o).trim().slice(0, 100);
-    if (!name) continue;
-    const key = name.toLowerCase();
-    if (seen.has(key)) continue; seen.add(key);
-    options.push({ name });
-    if (options.length >= 8) break;
-  }
-  if (options.length < 2) return null;
-  const dur = parseInt(body.poll_duration, 10);
-  const secs = POLL_DURATIONS.has(dur) ? dur : 86400;
-  return JSON.stringify({ multiple: !!body.poll_multiple, options, endTime: new Date(Date.now() + secs * 1000).toISOString(), closed: false });
-}
-
 // ==================== HOME (Posts list) ====================
 router.get('/', (req, res) => {
@@ -218,42 +104,19 @@
   `).all(site.id);
 
-  // Regular posts: anything with pinned = 0. Paged in blocks of 72 (Load more).
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const rows = db.prepare(`
+  // Regular posts: anything with pinned = 0
+  const posts = db.prepare(`
     SELECT p.*, u.username as author_username
     FROM posts p JOIN users u ON p.author_id = u.id
     WHERE p.site_id = ? AND p.status = 'published' AND p.pinned = 0
     ORDER BY p.published_at DESC
-    LIMIT ? OFFSET ?
-  `).all(site.id, FEED_PAGE + 1, offset);
-  const hasMore = rows.length > FEED_PAGE;
-  const posts = rows.slice(0, FEED_PAGE);
-  const moreBase = res.locals.siteUrlBase || '';
-
-  if (append) {
-    return renderPage(req, res, 'partials/home-append', {
-      posts, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-      readerItems: readerItems(site, posts, req),
-    });
-  }
-
-  recordPageview(site.id, req);
-
-  // FEP-7628 slice 3: this account moved. A visitor who lands here deserves
-  // the same signpost the fediverse gets — one big link to the new address.
-  const movedTo = site.moved_to && /^https?:\/\//i.test(String(site.moved_to)) ? String(site.moved_to) : null;
+    LIMIT 30
+  `).all(site.id);
+
   renderPage(req, res, 'pages/home', {
     pinnedPosts,
     posts,
-    readerItems: readerItems(site, [...pinnedPosts, ...posts], req),
-    hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-    movedTo,
-    movedToLabel: movedTo ? (ActivityPubService.actorDisplay(site.slug, movedTo).handle || movedTo) : null,
     pageTitle: site.title,
     socialDescr: site.description || site.tagline || '',
     bodyClass: 'on-home',
-    // mod/read.js: alleen nog de tik-op-een-bericht in de leesweergave.
-    pageJs: 'read tape',
   });
 });
@@ -268,6 +131,4 @@
 
   renderPage(req, res, 'pages/post-edit', {
-    // post-edit neemt de playlist-editor op.
-    pageJs: 'post-edit playlist-editor',
     post: {
       id: uuid(),
@@ -277,5 +138,4 @@
     },
     isNew: true,
-    keuzeTypes: KEUZE_TYPES,
     pageTitle: 'New post',
     bodyClass: 'on-special',
@@ -284,59 +144,4 @@
 
 // ==================== CREATE POST ====================
-// ── Per-post audio federation ──────────────────────────────────────────────
-// "Share audio on the fediverse" is a per-post choice in the editor, but the underlying
-// flag is per track (audio_tracks.fedi_open — it gates the file + drives the AS2 Audio
-// attachment). NB: the file gate is per file, so opening a track in one post makes its file
-// fetchable for every post that reuses it.
-// ONE-WAY: opening is permanent. Once the file has federated it's out there — re-gating
-// would be false security (remote copies keep the URL), so we never write fedi_open back to 0.
-function setAudioFediOpen(siteId, content, open) {
-  if (!open) return; // never close — see one-way note above
-  const c = content || '';
-  try {
-    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);
-    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());
-    // playlists.id is a GLOBAL key, so the site filter has to sit on the tracks: without it a
-    // post on site A embedding site B's playlist would open B's files — permanently.
-    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]);
-  } catch { /* non-fatal */ }
-}
-// True when the post references hosted audio AND all of it is currently fedi_open (drives the
-// editor checkbox's initial state).
-function postAudioFediOpen(siteId, content) {
-  const c = content || '';
-  if (!/\[\[(track|album|playlist):/i.test(c)) return false;
-  let total = 0, open = 0;
-  const tally = (r) => { if (r && r.media_id) { total++; if (r.fedi_open) open++; } };
-  try {
-    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));
-    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);
-    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);
-  } catch { /* non-fatal */ }
-  return total > 0 && open === total;
-}
-
-// Bake + cache a post's display HTML (ActivityPub `source` model): `content` stays the raw
-// source (used by the editor + re-rendering), content_rendered holds the linkified render the
-// page serves. Called after every create/edit. Non-fatal: the render route falls back to
-// baking on the fly if this ever fails.
-function cacheRenderedContent(postId, rawContent) {
-  const raw = rawContent || '';
-  // 1. Immediate + synchronous: bake #hashtags + URLs so the post renders enriched at once.
-  try {
-    db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?')
-      .run(ActivityPubService.bakePostContent(raw), postId);
-  } catch (e) { /* fallback bake in the render route keeps display correct */ }
-  // 2. Async: resolve @mentions (webfinger, once) and re-store, WITHOUT blocking the save
-  //    response — a moment later the post's @mentions are clickable too. A slow/dead remote
-  //    server can't stall the save; on failure the sync bake from step 1 stands.
-  ActivityPubService.bakePostContentWithMentions(raw)
-    .then((html) => {
-      try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(html, postId); }
-      catch (e) { /* keep the sync bake */ }
-    })
-    .catch(() => { /* keep the sync bake */ });
-}
-
 router.post('/posts/create', requireAuth, (req, res) => {
   const site = res.locals.site;
@@ -344,23 +149,6 @@
     return res.status(403).send('No permission');
   }
-  // Verhuisd = niet meer schrijven. Dit moet HIER staan en niet pas bij
-  // deliverCreate: die weigert alleen de bezorging, waarna de post gewoon in de
-  // database belandt met een object-URI op een adres dat je hebt opgezegd. Dan
-  // lijkt het gelukt, staat het er, en sterft het met het domein. Precies de
-  // halve toestand die dit slot moet voorkomen.
-  if (ActivityPubService.movedLock(site).locked) {
-    return res.status(409).send('Dit account is verhuisd naar ' + ActivityPubService.movedLock(site).movedTo
-      + '. Nieuwe berichten maak je daar. Wil je terug? Maak het verhuisadres leeg bij Uiterlijk.');
-  }
 
   const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
-  const fanOnly = req.body.fan_only ? 1 : 0;
-  const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0;   // paid posts (klonkt-demo-aki)
-  const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
-  const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
-  const nsfw = req.body.nsfw ? 1 : 0;
-  const cw = (req.body.content_warning || '').trim().slice(0, 200);
-  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
-  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
 
   // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
@@ -370,5 +158,5 @@
 
   // Generate slug from title if empty
-  let finalSlug = (slug || title || '')
+  const finalSlug = (slug || title || '')
     .toLowerCase()
     .replace(/[^a-z0-9]+/g, '-')
@@ -376,45 +164,31 @@
 
   if (!finalSlug) return res.status(400).send('Title or slug required');
-  if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
-
-  // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
-  finalSlug = uniqueSlug(site.id, finalSlug);
-
-  const finalType = POST_TYPES.has(type) ? type : 'post';
-  const pollJson = parsePollForm(req.body);   // AS2 Question definition, or null
+  if (RESERVED_SLUGS.has(finalSlug)) return res.status(400).send('That slug is reserved');
+
+  // Uniqueness check
+  const existing = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?').get(site.id, finalSlug);
+  if (existing) return res.status(400).send('A post with that slug already exists');
+
+  const validTypes = new Set(['post', 'foto', 'video', 'audio']);
+  const finalType = validTypes.has(type) ? type : 'post';
   const postId = uuid();
   const now = new Date().toISOString();
-  let finalStatus = status || 'draft';
-  let publishedAt = finalStatus === 'published' ? now : null;
-  // Release planning: published + a future publish_at -> 'scheduled'
-  // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
-  let publishAt = null;
-  const pa = Date.parse(req.body.publish_at || '');
-  if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
-    finalStatus = 'scheduled';
-    publishAt = new Date(pa).toISOString();
-    publishedAt = null;
-  }
+  const finalStatus = status || 'draft';
+  const publishedAt = finalStatus === 'published' ? now : null;
 
   db.prepare(`
     INSERT INTO posts (
       id, site_id, slug, author_id, title, content, excerpt,
-      status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
+      status, cover_image_url, pinned, tags, type, noindex,
       created_at, updated_at, published_at
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     postId, site.id, finalSlug, req.session.user.id,
     title || finalSlug, cleanContent, excerpt || '',
-    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
+    finalStatus, cover_image_url || null, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
-    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
+    finalType, noindex ? 1 : 0,
     now, now, publishedAt
   );
-  cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
-  db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, postId);
-
-  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
-  // BEFORE federating, so the Create note carries the right Audio attachments.
-  setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
 
   if (finalStatus === 'published') {
@@ -424,14 +198,4 @@
       ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId);
     } catch (e) { /* FTS index issues are non-fatal */ }
-
-    // ActivityPub: federate a freshly published post to followers. fan_only → delivered
-    // to followers but addressed followers-only (option A: "fans" = your fedi followers).
-    if (status === 'published') {
-      ActivityPubService.deliverCreate(site, {
-        id: postId, slug: finalSlug, title: title || finalSlug,
-        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
-        published_at: publishedAt, created_at: now, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
-      }).catch(() => { /* best-effort */ });
-    }
   }
 
@@ -465,17 +229,7 @@
   }
 
-  // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
-  let pollLocked = false;
-  try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
-
   renderPage(req, res, 'pages/post-edit', {
-    // Zelfde modules als de nieuw-route hierboven: zonder deze regel laadt de
-    // editor niet, en dan wist een opslag de post (shaer-5s1, de beet van 7-8).
-    pageJs: 'post-edit playlist-editor',
     post,
     isNew: false,
-    keuzeTypes: KEUZE_TYPES,
-    pollLocked,
-    fediOpenAudio: postAudioFediOpen(site.id, post.content),
     pageTitle: 'Edit: ' + (post.title || 'Untitled'),
     bodyClass: 'on-special',
@@ -497,32 +251,9 @@
   }
 
-  // Verhuisd: een BESTAANDE post bewerken mag nog -- daar wil je juist "ik ben
-  // verhuisd naar ..." in kunnen zetten, en die URI bestaat al. Een concept
-  // alsnog publiceren mag niet: dat is nieuwe inhoud op een adres dat je hebt
-  // opgezegd.
-  if (post.status !== 'published' && String(req.body.status || '') === 'published'
-      && ActivityPubService.movedLock(site).locked) {
-    return res.status(409).send('Dit account is verhuisd. Publiceren doe je op '
-      + ActivityPubService.movedLock(site).movedTo + '. Bestaande berichten bewerken kan hier wel.');
-  }
-
   const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
-  const fanOnly = req.body.fan_only ? 1 : 0;
-  const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0;   // paid posts (klonkt-demo-aki)
-  const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
-  const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
-  const nsfw = req.body.nsfw ? 1 : 0;
-  const cw = (req.body.content_warning || '').trim().slice(0, 200);
-  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
-  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
   const newSlug = req.body.slug;
   const action = req.body.action || 'save';
-  const finalType = POST_TYPES.has(type) ? type : (post.type || 'post');
-
-  // A poll that has already received votes is frozen (you can still edit the surrounding
-  // post, but not the options) — changing options after votes would scramble the tally and
-  // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
-  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; } })());
-  const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
+  const validTypes = new Set(['post', 'foto', 'video', 'audio']);
+  const finalType = validTypes.has(type) ? type : (post.type || 'post');
 
   // Sanitize before storage — same pipeline as create.
@@ -532,7 +263,8 @@
   if (newSlug && newSlug !== post.slug) {
     const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
-    const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
-    // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
-    finalSlug = uniqueSlug(site.id, safe, post.id);
+    if (RESERVED_SLUGS.has(cleaned)) return res.status(400).send('That slug is reserved');
+    const conflict = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ? AND id != ?').get(site.id, cleaned, post.id);
+    if (conflict) return res.status(400).send('Slug already taken');
+    finalSlug = cleaned;
   }
 
@@ -546,33 +278,18 @@
   }
 
-  // Release planning: published + future publish_at -> 'scheduled'.
-  let publishAt = null;
-  const pa = Date.parse(req.body.publish_at || '');
-  if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
-    finalStatus = 'scheduled';
-    publishAt = new Date(pa).toISOString();
-    publishedAt = null;
-  }
-
   db.prepare(`
     UPDATE posts SET
       title = ?, content = ?, excerpt = ?, status = ?,
-      cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
-      type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
+      cover_image_url = ?, pinned = ?, tags = ?,
+      type = ?, noindex = ?,
       slug = ?, published_at = ?, updated_at = ?
     WHERE id = ?
   `).run(
     title, cleanContent, excerpt, finalStatus,
-    cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
+    cover_image_url || null, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
-    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
+    finalType, noindex ? 1 : 0,
     finalSlug, publishedAt, now, post.id
   );
-  cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
-  db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, post.id);
-
-  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
-  // BEFORE federating, so the Update/Create note carries the right Audio attachments.
-  setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
 
   // Update FTS
@@ -586,29 +303,4 @@
   } catch (e) { /* FTS issues non-fatal */ }
 
-  // ActivityPub: federate edits to followers. A post that BECOMES published →
-  // Create (new post); an already-published post that's edited → Update (so
-  // Mastodon refreshes its cached copy). fan_only → followers-only (option A).
-  if (finalStatus === 'published') {
-    const apPost = {
-      id: post.id, slug: finalSlug, title: title || finalSlug,
-      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
-      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,
-    };
-    // Op een verhuisd account mag een BESTAANDE post nog bewerkt worden -- daar
-    // wil je juist "ik ben verhuisd naar ..." in kunnen zetten, en die URI
-    // bestaat al. Wat niet mag is een concept alsnog publiceren: dat is nieuwe
-    // inhoud op een adres dat je hebt opgezegd. deliverCreate/deliverUpdate
-    // weigeren zelf ook, dit voorkomt alleen de lokale halve toestand.
-    if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
-    else ActivityPubService.deliverUpdate(site, apPost).catch(() => { /* best-effort */ });
-  }
-
-  // Pin/unpin/reorder → push Add/Remove activities so followers' instances update the
-  // pinned order immediately (reliable, unlike re-fetching the cached featured collection).
-  if ((post.pinned || 0) !== parsePinnedRank(pinned)) {
-    const unpinned = (post.pinned || 0) > 0 && parsePinnedRank(pinned) === 0 ? [post.id] : [];
-    ActivityPubService.resyncFeaturedPins(site, unpinned).catch(() => { /* best-effort */ });
-  }
-
   res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
 });
@@ -626,11 +318,4 @@
   if (!PermissionsService.canDeletePost(req.session.user, post, site)) {
     return res.status(403).send('No permission');
-  }
-
-  // ActivityPub: tell followers the post is gone (Delete + Tombstone) if it was
-  // federated (any published post now federates — fan_only goes followers-only).
-  // Fire before the row is removed — we still have post.id (= the Note id).
-  if (post.status === 'published') {
-    ActivityPubService.deliverDelete(site, post).catch(() => { /* best-effort */ });
   }
 
@@ -686,36 +371,31 @@
 });
 
-// Local likes/favourites are removed — engagement is fediverse-only now
-// (the ⭐ on a post likes via the fediverse). No post_likes, no /favorieten.
-
-// Newer/Older neighbours across ALL posts in feed order. Shared by the full
-// post render and the fan gate (premium fan_only) so navigation is consistent
-// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
-// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
-// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
-// Dezelfde berichten, klaar voor de leesweergave.
-//
-// Tijdlijn en Grid tonen kaartjes; Lezen toont het hele stuk. Het is dus geen
-// andere PAGINA maar een andere vorm van dezelfde rijen -- vandaar dat de feed
-// ze alledrie meestuurt en CSS kiest, precies zoals timeline/grid dat al deden.
-//
-// Het lijf loopt door PostAccessService: een gesloten poort levert hier GEEN
-// tekst op, want wat niet gerenderd wordt kan ook niet lekken.
-function readerItems(site, rows, req) {
-  const viewer = OWA.viewerFor(req, site, { unlockedSlug: null });
-  return rows.map((post) => ({
-    post,
-    entry: postEntry(post, viewer, { renderBody: (p) => renderPostBodyHtml(site, p, req) }),
-  }));
-}
-
-export function renderPostBodyHtml(site, post, req) {
-  let html = (post.content_rendered != null && post.content_rendered !== '')
-    ? post.content_rendered
-    : ActivityPubService.bakePostContent(post.content || '');
-  if (audioEnabled()) {
+// ==================== VIEW POST (last route â€” catches /:slug) ====================
+router.get('/:slug', (req, res, next) => {
+  if (RESERVED_SLUGS.has(req.params.slug)) return next();
+
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site not found');
+
+  const post = db.prepare(`
+    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
+    FROM posts p JOIN users u ON p.author_id = u.id
+    WHERE p.site_id = ? AND p.slug = ?
+  `).get(site.id, req.params.slug);
+
+  if (!post) return res.status(404).send('Post not found');
+
+  // Permission to view: published OR (logged in + can edit)
+  if (post.status !== 'published') {
+    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
+    if (!canEdit) return res.status(403).send('Not published');
+  }
+
+  // Render content. Content is now user-authored HTML (already sanitized on
+  // save). The pipeline still adds autoembed iframes and shortcode embeds:
+  //   stored HTML → autoembed → [[track]]/[[album]]/[[playlist]] → response
+  let html = post.content || '';
   if (site.enable_audio_player !== 0) {
     html = AudioEmbedService.autoembed(html);
-    html = AudioEmbedService.embedMediaShortcodes(html);
     html = AudioEmbedService.embedExternalLinkShortcodes(html);
 
@@ -726,6 +406,5 @@
       const placeholders = trackIds.map(() => '?').join(',');
       const rows = db.prepare(`
-        SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
-               t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+        SELECT t.id, t.title, t.artist, t.cover_url, m.filename
         FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
         WHERE t.site_id = ? AND t.id IN (${placeholders})
@@ -734,5 +413,5 @@
       html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
         const r = byId.get(id);
-        if (!r) return null;
+        if (!r || !r.filename) return null;
         return {
           id: r.id,
@@ -740,10 +419,5 @@
           artist: r.artist,
           cover: r.cover_url,
-          credit: r.credit || '',
-          license: r.license || '',
-          link_spotify: r.link_spotify || '',
-          link_youtube: r.link_youtube || '',
-          link_soundcloud: r.link_soundcloud || '',
-          url: r.filename ? audioUrl(r.filename) : '',  // '' = link-only track
+          url: signUrl(r.filename).url,
         };
       });
@@ -755,6 +429,5 @@
       const placeholders = albumNames.map(() => '?').join(',');
       const albumRows = db.prepare(`
-        SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
-               t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+        SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position, m.filename
         FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
         WHERE t.site_id = ? AND t.album IN (${placeholders})
@@ -763,15 +436,11 @@
       const byAlbum = new Map();
       for (const r of albumRows) {
-        // Link-only tracks (no file) remain in the album overview (url '').
+        if (!r.filename) continue;
         if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
         byAlbum.get(r.album).push({
-          id: r.id,
-          url: r.filename ? audioUrl(r.filename) : '',
+          url: signUrl(r.filename).url,
           title: r.title || 'Untitled',
           artist: r.artist || '',
           cover: r.cover_url || '',
-          link_spotify: r.link_spotify || '',
-          link_youtube: r.link_youtube || '',
-          link_soundcloud: r.link_soundcloud || '',
         });
       }
@@ -795,855 +464,9 @@
       const isAdmin = req.session?.user?.role === 'god';
       html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
-        return PlaylistService.get(site.id, id, audioUrl);
+        return PlaylistService.get(site.id, id, signUrl);
       }, { isAdmin });
     }
   }
-  } else {
-    // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
-    // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
-    // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
-    html = AudioEmbedService.autoembed(html);
-    html = AudioEmbedService.embedMediaShortcodes(html);
-    html = AudioEmbedService.embedExternalLinkShortcodes(html);
-    html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
-  }
-  return html;
-}
-
-// A short public teaser for a paid post: its excerpt, else the first ~280 chars
-// of the (stripped) content. Shared by the web gate and federation.
-function paidTeaser(post, max = 280) {
-  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
-  // Only the FIRST paragraph: a paid teaser must never spill later content.
-  const html = String((post && post.content) || '');
-  const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
-  const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
-  return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
-}
-
-// De muziek van een betaalde post op de poortpagina zelf.
-//
-// WAAROM DIE DAAR HOORT. Zodra een nummer `fedi_open` is, federeert het als
-// eigen Audio-object en speelt het bij iedereen die de post in een hub of in
-// Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal
-// beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde
-// kant op (Robin, 24-8). De muur staat om de tekst.
-//
-// ALLES OF NIETS. Alleen als ELK nummer waar de post naar wijst open staat.
-// Een shortcode rendert zijn hele lijst, dus bij een half-open bandje zou de
-// speler ook de gesloten nummers krijgen -- en /audio/stream laat een
-// gelijke-oorsprong-fetch door, dus dat is geen theoretisch lek maar een echt.
-// Half open is hier dus dicht.
-//
-// De TEKST komt hier niet langs: we geven renderPostBodyHtml een post mee die
-// alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook
-// niet lekken -- dezelfde regel als bij readerItems.
-export function paidOpenAudioHtml(site, post, req) {
-  if (!postAudioFediOpen(site.id, post.content)) return '';
-  const codes = String(post.content || '').match(/\[\[(?:track|album|playlist):[^\]]+\]\]/gi) || [];
-  if (!codes.length) return '';
-  const alleenMuziek = codes.join('\n');
-  try {
-    return renderPostBodyHtml(site, { ...post, content: alleenMuziek, content_rendered: alleenMuziek }, req);
-  } catch { return ''; /* geen speler is geen kapotte poort */ }
-}
-
-function postNeighbors(site, post) {
-  const ordered = db.prepare(`
-    SELECT id, slug, title, pinned FROM posts
-    WHERE site_id = ? AND status = 'published'
-    ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
-  `).all(site.id);
-  const idx = ordered.findIndex((p) => p.id === post.id);
-  const newerPost = idx > 0 ? ordered[idx - 1] : null;
-  const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
-  if (newerPost) newerPost._urlBase = '';
-  if (olderPost) olderPost._urlBase = '';
-  return { newerPost, olderPost };
-}
-
-// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
-// Standard fediverse "reply from your own server" landing endpoint. A post page
-// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
-// composes a reply that federates back to that post.
-router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.query.uri || '').toString();
-  const sent = !!req.query.sent;
-  const followed = !!req.query.followed;
-  const voted = !!req.query.voted;
-  const reported = !!req.query.reported;
-  let target = null, followTarget = null;
-  if (!sent && !followed && !voted && !reported && uri) {
-    try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
-    // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
-    if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
-  }
-  renderPage(req, res, 'pages/authorize-interaction', {
-    pageJs: 'authorize-interaction reply-editor',
-    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
-    bodyClass: 'on-special',
-    uri,
-    target,
-    followTarget,
-    sent,
-    followed,
-    voted: !!req.query.voted,
-    reported: !!req.query.reported,
-    liked: !!req.query.liked,
-    boosted: !!req.query.boosted,
-    reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
-    siteTitle: site ? site.title : '',
-  });
-});
-
-// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
-// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
-router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let choice = req.body.choice;
-  if (choice == null) choice = [];
-  if (!Array.isArray(choice)) choice = [choice];
-  if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
-  res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
-});
-
-// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
-router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  const actorUri = (req.body.actor_uri || '').toString();
-  const reason = (req.body.reason || '').toString();
-  if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
-  res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
-});
-
-// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
-router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let on = false;
-  if (site && uri) {
-    on = !ActivityPubService.getReaction(site.slug, uri).liked;
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
-      .catch((e) => console.warn('[AP] remote like failed:', e.message));
-    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
-    ActivityPubService.setReaction(site.slug, uri, 'like', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
-});
-
-// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
-// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
-router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let on = false;
-  if (site && uri) {
-    on = !ActivityPubService.getReaction(site.slug, uri).boosted;
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((note) => {
-        if (!note) return;
-        const id = note.object_uri || uri;
-        return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
-          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
-          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
-          // de Cirkel verschijnt.
-          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
-      })
-      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
-    // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
-    // setReaction en niet via setMyReaction: ook dit korte moment mag geen
-    // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
-    // note, zodat de post ook in je tijdlijn belandt.
-    ActivityPubService.setReaction(site.slug, uri, 'boost', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
-});
-
-// Follow a remote actor from your own site (when the target is a profile, not a post).
-router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  if (!site || !uri) return res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
-  // Afwachten in plaats van wegsturen: ligt het verzoek bij de guardians, dan
-  // moet dat op het scherm staan (shaer-p729). "followed=1" terwijl er niets
-  // gebeurd is, is precies de leugen die de poort waardeloos maakt.
-  ActivityPubService.followActor(site, uri)
-    .then((r) => res.redirect('/authorize_interaction?' + (r && r.held ? 'held=1' : 'followed=1') + '&uri=' + encodeURIComponent(uri)))
-    .catch((e) => {
-      console.warn('[AP] remote follow failed:', e.message);
-      res.redirect('/authorize_interaction?error=1&uri=' + encodeURIComponent(uri));
-    });
-});
-
-router.post('/authorize_interaction', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  const text = (req.body.text || '').toString();
-  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
-  const language = (req.body.language || '').toString();
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
-    // Resolve + deliver in the background so Send responds instantly.
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
-      .catch((e) => console.warn('[AP] remote reply failed:', e.message));
-  }
-  res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
-});
-
-// Manage / delete your own outbound fediverse replies (site owner only).
-// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
-// The old /fediverse (manage) and /notifications pages redirect here.
-router.get('/messages', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
-  const hasMore = page.length > FEED_PAGE;
-  const items = page.slice(0, FEED_PAGE);
-  // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
-  const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
-  // Only stamp "seen" on the first page load (not on Load-more appends).
-  if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
-  }
-  // FEP-633c: pending guardianship offers TO this account (I am the ward)
-  // show as a special message with an accept button (Robins besluit: the kid
-  // answers in its own Klonkt; safety is out-of-band by the guardians).
-  const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
-  const guardianOffers = (site
-    ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
-    : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
-  renderPage(req, res, 'pages/messages', {
-    pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages reply-editor', items, seenAt,
-    hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-
-// The kid answers a guardianship offer from Berichten: the same C2S
-// Accept/Reject pipeline the Shaer apps use (one path, one behavior).
-router.post('/messages/guardianship', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
-  const offer = String(req.body.offer || '').trim();
-  if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
-  try {
-    // Same C2S Accept/Reject the apps use; the handshake module records the
-    // ward's accept and (once the candidate returns the handle) commits.
-    const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
-    if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
-  } catch { /* fall through */ }
-  res.redirect(back + '?error=guardianship');
-});
-// A ward answers a guardian's wave without publishing: a canned private note
-// back to the sender (FEP-633c §5, shaer:wave reply). Same direct-note leg.
-router.post('/messages/quick-reply', requireSiteManager, express.urlencoded({ extended: false }), async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  const to = String(req.body.to || '').trim();
-  const text = String(req.body.text || '').trim().slice(0, 200);
-  // Zwaaien is een seintje, en een seintje hoort de pagina niet te herladen.
-  // De module stuurt hem met X-Requested-With: fetch en krijgt JSON terug;
-  // zonder JS blijft het formulier gewoon posten en omleiden.
-  const viaFetch = req.get('X-Requested-With') === 'fetch';
-  const mis = (reden) => (viaFetch ? res.status(400).json({ ok: false, error: reden }) : res.redirect(back + '?error=' + reden));
-  if (!site || !/^https?:\/\//i.test(to) || !text) return mis('quickreply');
-  try {
-    const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
-    if (r) return viaFetch ? res.json({ ok: true }) : res.redirect(back + '?success=wave_sent');
-  } catch { /* fall through */ }
-  return mis('quickreply');
-});
-
-// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
-// bepaalt de draad zelf (zie groupConversations → replyTo):
-//   - hangt de draad aan een post van jou, dan is dit een gewone reply op het
-//     nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
-//   - hangt hij aan een persoon, dan is het een direct bericht terug.
-// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
-// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
-// als enige stuurt).
-router.post('/messages/reply', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  if (!site) return res.status(404).send('Site required');
-  const text = String(req.body.text || '');
-  const html = String(req.body.content || '');
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  const language = String(req.body.language || '');
-  // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
-  if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
-
-  const interactionId = parseInt(req.body.interaction_id, 10) || 0;
-  const postSlug = String(req.body.post_slug || '');
-  const toActor = String(req.body.to || '');
-  try {
-    if (interactionId && postSlug) {
-      const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
-      const parent = ActivityPubService.getInteractionById(interactionId);
-      // De parent MOET bij deze post horen: anders zou een gemanipuleerd
-      // formulier een antwoord onder andermans draad kunnen hangen.
-      if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
-      await ActivityPubService.deliverReply(site, {
-        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
-      });
-    } else if (/^https?:\/\//i.test(toActor)) {
-      const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
-      if (!r) return res.redirect(back + '?error=reply_failed');
-    } else {
-      return res.redirect(back + '?error=reply_target');
-    }
-  } catch (e) {
-    console.warn('[AP] reply from Berichten failed:', e.message);
-    return res.redirect(back + '?error=reply_failed');
-  }
-  res.redirect(back + '?success=reply_sent');
-});
-
-router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
-
-router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
-    catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
-});
-
-// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
-// object URI so re-delivery and thread-crawling never bring it back. Works for private
-// notes too (acts on the local copy; no remote fetch involved).
-router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
-    if (r.error) console.warn('[AP] interaction remove failed:', r.error);
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
-});
-
-// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
-// locally stored object/actor URIs, so it also works for private notes that
-// authorize_interaction cannot fetch (401/404).
-router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
-    if (tgt && (tgt.objectUri || tgt.actorUri)) {
-      try {
-        const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
-        if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
-      } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
-    }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
-});
-
-// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
-router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const text = String(req.body.text || '');
-  const html = String(req.body.content || '');   // rich reply editor HTML (sanitized in deliverOutboxUpdate)
-  if (site && (text.trim() || html.trim())) {
-    try {
-      await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
-        html, language: String(req.body.language || ''),
-      });
-    } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
-});
-
-// ==================== FEDIVERSE CLIENT: home timeline + following ====================
-// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
-// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
-function timelineEmbedHtml(html) {
-  if (!html) return null;
-  const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
-  while ((m = re.exec(html))) {
-    const u = m[1]; if (seen.has(u)) continue; seen.add(u);
-    let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
-    if (!p) {
-      // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
-      // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
-      // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
-      const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
-      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>`;
-      continue;
-    }
-    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>`;
-    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>`;
-    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>`;
-    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>`;
-    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>`;
-    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>`; }
-  }
-  return null;
-}
-
-// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
-// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
-// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
-function klonktAudioEmbed(html, url) {
-  if (!html || !url || html.indexOf('🎵') < 0) return null;
-  let u; try { u = new URL(url); } catch { return null; }
-  if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
-  const slug = u.pathname.replace(/^\/+|\/+$/g, '');
-  if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
-  const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
-  // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
-  const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
-  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>` };
-}
-
-/**
- * FEP-633c §5.3-style gated feature: may this account see previews of links
- * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
- *
- * Applied at SERVE time on every surface, the way the app's inbox read already
- * does it (routes/activitypub.js): a card the client merely hides has still
- * been delivered.
- */
-function gateEmbeds(site, rows) {
-  if (!site || !rows.length) return rows;
-  if (embedsAllowedFor(site)) return rows;
-  return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
-}
-
-function isWardSite(site) {
-  try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
-}
-function embedsAllowedFor(site) {
-  return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
-}
-/**
- * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
- * sibling of the preview gate.) This was the hole: the player iframe is built
- * from the note's content by timelineEmbedHtml, on a path that never touched
- * gateEmbeds. A ward whose guardians had allowed nothing still got the full
- * YouTube player on the web, while the app showed nothing at all: the heavy
- * thing open, the light thing shut. Playback also requires the preview gate,
- * because you cannot play what you may not see.
- */
-function playbackAllowedFor(site) {
-  if (!site) return true;
-  if (!embedsAllowedFor(site)) return false;
-  return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
-}
-
-router.get('/news', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const cspOrigins = new Set();
-  // Fetch one extra to know whether a "Load more" button belongs on this page.
-  const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
-  const hasMore = rows.length > FEED_PAGE;
-  // Players (a third party's engine inside our page) ride the playback gate;
-  // a Klonkt site's own audio embed is ours and stays.
-  const mayPlay = playbackAllowedFor(site);
-  const timeline = rows.slice(0, FEED_PAGE).map((p) => {
-    let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
-    let content = p.content;
-    let embedUrl = null;
-    if (!embedHtml) {
-      const k = klonktAudioEmbed(p.content, p.url);
-      if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
-    }
-    // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
-    // top-level "open the player" link that works even when a browser shield/CSP blocks
-    // the cross-site iframe (a full-page navigation is not a cross-site frame).
-    let poll = null;
-    if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
-    return { ...p, content, embedHtml, embedUrl, poll };
-  });
-  // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
-  // extending ONLY this response's CSP frame-src. The global policy stays locked down.
-  if (cspOrigins.size) {
-    const csp = res.getHeader('Content-Security-Policy');
-    if (csp) {
-      const extra = [...cspOrigins].join(' ');
-      res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
-    }
-  }
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
-  }
-  renderPage(req, res, 'pages/news', {
-    pageJs: 'news',
-    pageTitle: 'News', bodyClass: 'on-special',
-    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-
-// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
-// Connect = who you follow + who follows you, merged into one page with direction
-// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
-// separate Following/Followers pages, which redirect here so old links keep working.
-router.get('/connect', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const connections = site ? ActivityPubService.listConnections(site.slug) : [];
-  // FEP-633c §2: the ward always sees who guards it, and §3.6 how available
-  // each of them is. Connect is where "who am I connected to" belongs; a
-  // guardian is the one connection a ward should never have to hunt for.
-  // Owner-only by construction: this page is the owner's.
-  const guardianHandle = (uri, cached) => {
-    if (cached && cached.charAt(0) === '@') return cached;
-    try { const u = new URL(uri); return `@${u.pathname.split('/').filter(Boolean).pop()}@${u.host}`; }
-    catch { return uri; }
-  };
-  const gStatus = site ? Object.fromEntries(
-    Guardianship.availability.statusesFor(site.slug, Guardianship.listGuardians(site.slug).map((g) => g.other_uri), Date.now())
-      .map((s) => [s.id, s]),
-  ) : {};
-  const myGuardians = (site ? Guardianship.listGuardians(site.slug) : [])
-    .map((g) => ({
-      uri: g.other_uri,
-      handle: guardianHandle(g.other_uri, g.other_handle),
-      availability: (gStatus[g.other_uri] || {})['shaer:availability'] || 'active',
-      awayUntil: (gStatus[g.other_uri] || {})['shaer:awayUntil'] || null,
-    }));
-  // De eigenaarspoort: openstaande volgverzoeken, alleen buiten voogdij.
-  // Een ward-follow beslissen de guardians — die tonen we hier dus NIET,
-  // anders is deze pagina een deur naast hun poort.
-  const followRequests = (site && !myGuardians.length)
-    ? Guardianship.follows.listForWard(site.slug) : [];
-  renderPage(req, res, 'pages/connect', {
-    pageTitle: 'Connect', bodyClass: 'on-special',
-    connections, myGuardians, followRequests,
-    approveFollowers: !!(site && site.approve_followers),
-    // Na een verhuizing staat de uitgaande kant op slot. Dat hoort te blijken
-    // VOORDAT je op een knop drukt, niet daarna uit een foutmelding.
-    movedTo: ActivityPubService.movedLock(site).movedTo,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
-router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
-
-router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  if (!site) return res.redirect(`${base}/connect`);
-  const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
-  return res.redirect(`${base}/connect?` + (ok
-    ? 'success=' + encodeURIComponent('Volger verwijderd')
-    : 'error=' + encodeURIComponent('Volger niet gevonden')));
-});
-
-// De poort zelf aan- of uitzetten, op de plek waar de verzoeken toch al
-// staan (Robins wens, 18-8: "op de connect is logischer").
-router.post('/connect/approve-followers', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  if (site) {
-    db.prepare('UPDATE sites SET approve_followers = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
-      .run(req.body.on ? 1 : 0, site.id);
-  }
-  return res.redirect(`${base}/connect`);
-});
-
-// De eigenaarspoort beslist (Robins wens, 18-8): accepteer of weiger een
-// volgverzoek dat door approve_followers is vastgehouden. Bewust NIET voor
-// wards — daar beslissen de guardians, en deze route weigert dan hard, zodat
-// hij geen sluiproute naast die poort wordt.
-router.post('/follow-requests/:decision', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  const { decision } = req.params;
-  if (!site || !['approve', 'deny'].includes(decision)) return res.redirect(`${base}/connect`);
-  if (Guardianship.listGuardians(site.slug).length) {
-    return res.redirect(`${base}/connect?error=` + encodeURIComponent('Volgverzoeken lopen via je guardians'));
-  }
-  const pending = Guardianship.follows.getPending(String(req.body.id || ''));
-  if (!pending || pending.ward_slug !== site.slug || pending.status !== 'pending') {
-    return res.redirect(`${base}/connect?error=` + encodeURIComponent('Verzoek niet gevonden'));
-  }
-  if (decision === 'approve') await ActivityPubService.acceptGatedFollow(pending);
-  else await ActivityPubService.rejectGatedFollow(pending);
-  Guardianship.follows.remove(pending.id);
-  return res.redirect(`${base}/connect?success=` + encodeURIComponent(
-    decision === 'approve' ? 'Volger geaccepteerd' : 'Verzoek geweigerd'));
-});
-
-router.post('/news/follow', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const handle = (req.body.handle || '').toString();
-  let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
-  if (site && handle.trim()) {
-    try {
-      const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
-      // 'moved' is geen mislukking maar een weigering met een reden, en die reden
-      // hoort de gebruiker te lezen. "Volgen mislukt" laat hem zoeken naar een
-      // storing die er niet is.
-      if (r && r.error === 'moved') q = 'error=' + encodeURIComponent(`Dit account is verhuisd naar ${r.movedTo}. Volgen doe je daarvandaan.`);
-      else if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
-      // Een DERDE uitkomst, niet gelukt en niet mislukt (shaer-p729). "Je volgt
-      // nu X" zeggen terwijl het verzoek bij de guardians ligt is de leugen die
-      // deze poort waardeloos maakt: het kind denkt dat het gebeurd is.
-      else if (r && r.held) q = 'success=' + encodeURIComponent(r.status === 'denied' ? 'Je guardians hebben dit geweigerd' : 'Je verzoek ligt bij je guardians');
-      else {
-        q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
-      }
-    } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
-  }
-  res.redirect('/following?' + q);
-});
-
-// ── Je volglijst meenemen ─────────────────────────────────────────
-//
-// Zonder dit was verhuizen halfslachtig: de Move vertelt je VOLGERS waar je heen
-// ging, maar niets vertelde JOU wie jij volgde. Die lijst stond alleen in de
-// database die je achterlaat.
-router.get('/news/following.csv', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const { followingCsv } = await import('../services/ArchiveExportService.js');
-  const csv = site ? followingCsv(site.slug) : null;
-  if (!csv) return res.redirect('/connect?error=' + encodeURIComponent('Je volgt nog niemand'));
-  res.set('Content-Type', 'text/csv; charset=utf-8');
-  res.set('Content-Disposition', `attachment; filename="following-${site.slug}.csv"`);
-  // Privé: dit is de lijst van wie jij volgt, niets voor een cache onderweg.
-  res.set('Cache-Control', 'private, no-store');
-  res.send(csv);
-});
-
-// Een bestand OF geplakte tekst. Multer leest een multipart-formulier, en dat
-// bevat allebei: het bestandsveld en het tekstveld. In het geheugen, niet op
-// schijf: dit is een lijstje adressen van een paar kilobyte dat na het lezen
-// niets meer te zoeken heeft op de server.
-const followingCsvUpload = multer({
-  storage: multer.memoryStorage(),
-  limits: { fileSize: 512 * 1024, files: 1 },
-}).single('csvfile');
-
-router.post('/news/following/import', requireSiteManager, followingCsvUpload, async (req, res) => {
-  const site = res.locals.site;
-  // Een geupload bestand wint van het plakveld: wie een bestand kiest bedoelt dat.
-  const csv = (req.file && req.file.buffer)
-    ? req.file.buffer.toString('utf8').replace(/^﻿/, '')   // BOM eraf; Excel zet die erin
-    : ((req.body && req.body.csv) || '');
-  // Terug naar waar je vandaan kwam. Sinds 14-8 staat dit formulier op
-  // /admin/migrate (Robin: alle migratie-opties bij elkaar); terugspringen naar
-  // Connect is dan desorienterend. Alleen een eigen pad, geen open redirect.
-  const terug = /^\/[A-Za-z0-9/_-]*$/.test(String(req.body.next || '')) ? String(req.body.next) : '/connect';
-  if (!site || !String(csv).trim()) return res.redirect(terug + '?error=' + encodeURIComponent('Geen lijst ontvangen'));
-
-  const { importFollowing } = await import('../services/ArchiveImportService.js');
-  // followActor als followFn: die doet de webfinger, stuurt de Follow en zet
-  // auto_boost meteen goed. Zo blijft er één pad naar een volgrelatie.
-  const r = await importFollowing(site, csv, {
-    followFn: async (s, adres, uitgelicht) => {
-      const uit = await ActivityPubService.followActor(s, adres, !!uitgelicht);
-      // followActor meldt een fout als VELD, niet als exception. Zonder deze
-      // vertaling telde een onvindbaar account gewoon als geslaagd mee.
-      if (uit && uit.error) throw new Error(uit.error);
-      return true;
-    },
-  });
-
-  const delen = [`${r.gevolgd} gevolgd`];
-  if (r.overgeslagen) delen.push(`${r.overgeslagen} overgeslagen`);
-  if (r.mislukt.length) {
-    const namen = r.mislukt.slice(0, 3).map((m) => m.adres).join(', ');
-    delen.push(`${r.mislukt.length} mislukt (${namen}${r.mislukt.length > 3 ? '…' : ''})`);
-  }
-  // Terug naar /connect: daar staat het blok, /following is de oude pagina.
-  res.redirect(terug + '?' + (r.mislukt.length ? 'error=' : 'success=') + encodeURIComponent(delen.join(', ')));
-});
-
-router.post('/news/unfollow', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const actorUri = (req.body.actor_uri || '').toString();
-  if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
-  res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
-});
-
-// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
-router.post('/news/autoboost', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const actorUri = (req.body.actor_uri || '').toString();
-  if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
-  res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
-});
-
-// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
-// no banner); no-JS → redirect back.
-router.post('/news/like', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let on = false;
-  if (site && note) {
-    on = !ActivityPubService.getReaction(site.slug, note).liked;
-    try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    ActivityPubService.setReaction(site.slug, note, 'like', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/news');
-});
-
-// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
-router.post('/news/boost', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let on = false;
-  if (site && note) {
-    on = !ActivityPubService.getReaction(site.slug, note).boosted;
-    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
-    if (on) {
-      // Fire-and-forget: re-resolve the note so the cached row is refreshed
-      // (cover/content) — boosting again heals a stale copy from EVERY boost
-      // path, not just the interact page.
-      ActivityPubService.resolveRemoteNote(note)
-        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
-        .catch(() => { /* best-effort */ });
-    }
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/news');
-});
-
-// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
-router.post('/news/vote', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let choice = req.body.choice;
-  if (choice == null) choice = [];
-  if (!Array.isArray(choice)) choice = [choice];
-  if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
-  res.redirect('/news');
-});
-
-// Notifications inbox (new followers + replies/likes/boosts on your posts).
-router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
-
-// Blocking / defederation (owner-only).
-router.get('/blocking', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
-  renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
-});
-
-router.post('/blocking/add', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  let q = 'success=' + encodeURIComponent('Geblokkeerd');
-  if (site) {
-    try {
-      const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
-      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
-      else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
-    } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
-  }
-  const ref = req.get('Referer') || '';
-  res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
-});
-
-router.post('/blocking/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()).catch(() => {}); } catch (e) { /* ignore */ } }
-  res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
-});
-
-// ==================== VIEW POST (last route â€” catches /:slug) ====================
-router.get('/:slug', (req, res, next) => {
-  if (RESERVED_SLUGS.has(req.params.slug)) return next();
-
-  const site = res.locals.site;
-  if (!site) return next(); // -> nette 404 catch-all
-
-  const post = db.prepare(`
-    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
-    FROM posts p JOIN users u ON p.author_id = u.id
-    WHERE p.site_id = ? AND p.slug = ?
-  `).get(site.id, req.params.slug);
-
-  if (!post) return next(); // unknown slug -> clean 404 catch-all
-
-  // Permission to view: published OR (logged in + can edit)
-  if (post.status !== 'published') {
-    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
-    if (!canEdit) return res.status(403).send('Not published');
-  }
-
-  // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
-  // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
-  // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
-  // paid gate wins (otherwise anonymous visitors land on the login gate and
-  // never see the unlock button).
-  const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
-  // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
-  // supporter render the FULL post through this normal template (correct layout,
-  // scoped styles, working audio). Short-lived signed blob, single post, not a
-  // cookie and not stored.
-  const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
-  const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
-  if (post.paid && !canEditThis && !_unlocked) {
-    const { newerPost, olderPost } = postNeighbors(site, post);
-    const pgAudio = paidOpenAudioHtml(site, post, req);
-    return renderPage(req, res, 'pages/paid-gate', {
-    pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
-      pageTitle: post.title || 'Voor supporters',
-      bodyClass: 'on-special',
-      pgTitle: post.title || '',
-      pgTeaser: paidTeaser(post),
-      pgAudio,
-      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
-      pgSlug: post.slug,
-      pgPatronUrl: paidPatronUrl(site.id),
-      newerPost,
-      olderPost,
-    });
-  }
-
-  // Fan-only preview (premium #3): full content only for logged-in fans.
-  // Anonymous visitors get a clean login gate instead of the content (the title/
-  // teaser may still appear elsewhere as a teaser).
-  // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site
-  // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien.
-  const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req));
-  if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) {
-    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
-    // stuck on the fan gate but can keep browsing.
-    const { newerPost, olderPost } = postNeighbors(site, post);
-    return renderPage(req, res, 'pages/fan-gate', {
-      pageTitle: post.title || 'Alleen voor fans',
-      bodyClass: 'on-special',
-      fgTitle: post.title || '',
-      fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
-      owaError: !!(req.query && req.query.owa_error),
-      newerPost,
-      olderPost,
-    });
-  }
-
-  // Statistics: count the view (skips admins + unpublished own-preview).
-  if (post.status === 'published') recordPostView(post, req);
-
-  // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
-  // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
-  // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
-  // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
-  // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
-  post.content_html = renderPostBodyHtml(site, post, req);
+  post.content_html = html;
 
   if (post.tags) {
@@ -1653,13 +476,39 @@
   }
 
-  // Native comments removed: social interaction is fediverse-only (see the
-  // "From the fediverse" section below).
+  // Comments: top-level + replies. Two-pass build: fetch all approved
+  // comments for the post, then group replies under their parent.
+  const commentRows = db.prepare(`
+    SELECT c.id, c.parent_comment_id, c.content, c.status, c.created_at,
+           c.author_id, u.username AS author_username, u.avatar_url AS author_avatar
+    FROM comments c JOIN users u ON u.id = c.author_id
+    WHERE c.post_id = ? AND c.status = 'approved'
+    ORDER BY c.created_at ASC
+  `).all(post.id);
+  const topLevel = [];
+  const repliesById = new Map();
+  for (const c of commentRows) {
+    if (c.parent_comment_id) {
+      if (!repliesById.has(c.parent_comment_id)) repliesById.set(c.parent_comment_id, []);
+      repliesById.get(c.parent_comment_id).push(c);
+    } else {
+      topLevel.push(c);
+    }
+  }
+  for (const c of topLevel) c.replies = repliesById.get(c.id) || [];
+  const totalComments = commentRows.length;
 
   // Prev / next chronological (kept for back-compat — "post-nav" feature
   // below the article still uses these as a simple linear navigation).
-  const urlBaseFor = () => '';
-
-  // Newer/Older across ALL posts (shared helper — also used by the fan gate).
-  const { newerPost, olderPost } = postNeighbors(site, post);
+  const prevPost = db.prepare(`
+    SELECT slug, title FROM posts
+    WHERE site_id = ? AND status = 'published' AND published_at < ? AND id != ?
+    ORDER BY published_at DESC LIMIT 1
+  `).get(site.id, post.published_at, post.id);
+
+  const nextPost = db.prepare(`
+    SELECT slug, title FROM posts
+    WHERE site_id = ? AND status = 'published' AND published_at > ? AND id != ?
+    ORDER BY published_at ASC LIMIT 1
+  `).get(site.id, post.published_at, post.id);
 
   // ── Related posts: same-tag matching with recency fallback ─────
@@ -1667,8 +516,9 @@
   // Excluding self via `id != ?`.
   const candidates = db.prepare(`
-    SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
+    SELECT id, slug, title, cover_image_url, published_at, tags
     FROM posts
     WHERE site_id = ? AND status = 'published' AND id != ?
-    ORDER BY published_at DESC LIMIT 50
+    ORDER BY published_at DESC
+    LIMIT 50
   `).all(site.id, post.id);
 
@@ -1707,32 +557,50 @@
   }
   // Strip the internal _overlap field before sending to view
-  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
-
-  // Inbound fediverse activity (threaded) for this post.
-  let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
-  try {
-    const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-    fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
-    // Stale-while-revalidate: render from cache now; refresh the remote thread in the
-    // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
-    if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
-  } catch { /* non-fatal */ }
-  // Owner/admin of this site may reply back to a fediverse interaction.
-  const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
-  // Avatar for our own (outbound) fediverse replies = the site's profile photo.
-  const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
+  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => rest);
+
+  // ── Pinned navigation: prev/next pinned post ───────────────────
+  // Only meaningful if the current post is pinned. We order by
+  // published_at DESC (newest pinned first) — same as the homepage feed.
+  // Pinned navigation: prev/next pinned post by RANK (not by date).
+  // - prev (← back to) = post with smaller rank, i.e. higher in stack
+  // - next (→ forward) = post with larger rank, i.e. lower in stack
+  // BOVENAAN appears when current is rank 1 (no rank 0 above);
+  // ONDERAAN appears when current is the highest rank (no further down).
+  let prevPinnedPost = null;
+  let nextPinnedPost = null;
+  let pinnedTopOfStack = false;
+  let pinnedBottomOfStack = false;
+  if (post.pinned > 0) {
+    // The rank one step UP the stack (towards #1)
+    prevPinnedPost = db.prepare(`
+      SELECT slug, title FROM posts
+      WHERE site_id = ? AND status = 'published' AND pinned > 0
+        AND pinned < ? AND id != ?
+      ORDER BY pinned DESC LIMIT 1
+    `).get(site.id, post.pinned, post.id) || null;
+
+    // The rank one step DOWN the stack (away from #1)
+    nextPinnedPost = db.prepare(`
+      SELECT slug, title FROM posts
+      WHERE site_id = ? AND status = 'published' AND pinned > 0
+        AND pinned > ? AND id != ?
+      ORDER BY pinned ASC LIMIT 1
+    `).get(site.id, post.pinned, post.id) || null;
+
+    pinnedTopOfStack    = !prevPinnedPost;  // already rank #1 (or nothing higher)
+    pinnedBottomOfStack = !nextPinnedPost;  // nothing further down the stack
+  }
 
   renderPage(req, res, 'pages/post', {
-    pageJs: 'post reply-editor tape',
     post,
-    poll: ActivityPubService.ownPollView(post),
-    newerPost,
-    olderPost,
+    prevPost,
+    nextPost,
     relatedPosts,
-    fediverse,
-    canManageSite,
-    siteAvatar,
-    postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
-    musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
+    prevPinnedPost,
+    nextPinnedPost,
+    pinnedTopOfStack,
+    pinnedBottomOfStack,
+    comments: topLevel,
+    totalComments,
     pageTitle: post.title + ' - ' + site.title,
     socialDescr: post.excerpt || '',
@@ -1742,57 +610,3 @@
 });
 
-// ── Reply back to a fediverse interaction (site owner/admin only) ──
-router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
-  if (!post) return res.status(404).send('Not found');
-  const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
-  const text = (req.body.text || '').toString();
-  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
-    try {
-      await ActivityPubService.deliverReply(site, {
-        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
-        language: (req.body.language || '').toString(),
-      });
-    } catch (e) { console.warn('[AP] reply send failed:', e.message); }
-  }
-  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
-});
-
-// Owner likes/boosts a fediverse comment on their own post — directly as the
-// site, no "your server" detour (mirrors /fedi-reply).
-router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
-  if (!post) return res.status(404).send('Not found');
-  const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
-  const kind = req.body.kind === 'boost' ? 'boost' : 'like';
-  if (parent && parent.post_id === post.id && parent.object_uri) {
-    // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
-    // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
-    // de kolom en de knop uit de tussentabel, dan draait een divergentie de
-    // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
-    const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
-    const on = kind === 'boost' ? !ik.boosted : !ik.liked;
-    ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
-      .catch((e) => console.warn('[AP] reaction failed:', e.message));
-    // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
-    // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
-    // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
-    // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
-    ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
-    if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
-    else ActivityPubService.setInteractionLiked(parent.id, on);
-  }
-  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
-});
-
 export default router;
-export { postNeighbors };
