Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-sites.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -17,20 +17,21 @@
 import path from 'path';
 import fs from 'fs';
+import { fileURLToPath } from 'url';
 import multer from 'multer';
 import { v4 as uuid } from 'uuid';
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
+import { requireGod } from '../middleware/auth.js';
 import ThemeService from '../services/ThemeService.js';
 import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import { mediaDir } from '../config/paths.js';
-import AP from '../services/ActivityPubService.js';
-
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 // Profile photos share the avatar directory with user avatars — same physical
 // folder, same URL prefix. Filenames are uuid-prefixed so site photos and
 // user avatars never collide.
-const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
+const PHOTO_DIR = path.resolve(
+  process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
+);
 fs.mkdirSync(PHOTO_DIR, { recursive: true });
 
@@ -74,25 +75,4 @@
 }
 
-/**
- * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
- * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
- * purpose — a typo'd alias that silently lands on the actor would make a later
- * Move fail at the old server with no hint why. Throws the offending line.
- */
-export async function parseApAliases(raw, ownActorUri) {
-  const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
-  if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
-  const out = [];
-  for (const line of lines) {
-    let uri = null;
-    if (/^https?:\/\//i.test(line)) uri = line;
-    else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
-    if (!uri) throw new Error(line);
-    if (uri === ownActorUri) continue; // claiming yourself adds nothing
-    if (!out.includes(uri)) out.push(uri);
-  }
-  return out;
-}
-
 const router = express.Router();
 
@@ -102,5 +82,5 @@
 // holds the URL string in `profile_photo` — this endpoint just stores the
 // file and hands back a URL that the form can paste into the input field.
-router.post('/upload-photo', requireAuth, (req, res) => {
+router.post('/upload-photo', requireGod, (req, res) => {
   photoUpload.single('photo')(req, res, (err) => {
     if (err) return res.status(400).json({ ok: false, error: err.message });
@@ -108,5 +88,5 @@
     res.json({
       ok: true,
-      url: `/media/avatars/${toWebp(req.file)}`,
+      url: `/media/avatars/${req.file.filename}`,
       size: req.file.size,
       mime: req.file.mimetype,
@@ -117,8 +97,7 @@
 const RESERVED_SITE_SLUGS = new Set([
   'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
-  'account', 'sites', 'comments', 'posts', 'media', 'audio',
-  'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
+  'account', 'sites', 'comments', 'posts', 'media', 'audio', 'prutter',
+  'forum', 'tag', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
-  'paid', 'push', 'guardian',
 ]);
 
@@ -129,6 +108,6 @@
     tagline: '',
     language: 'nl',
-    palette: 'klonkt',
-    accent: '#e8b04b',
+    palette: 'sage',
+    accent: '#c2410c',
     profile_photo: '',
     profile_enabled: 1,
@@ -137,8 +116,8 @@
     is_public: 1,
     robots_index: 1,
-    require_login_to_comment: 1,
+    require_login_to_comment: 0,
     enable_audio_player: 1,
-    comments_moderation_mode: 'moderate',
-    feed_view_default: 'grid',
+    enable_prutter: 1,
+    feed_view_default: 'timeline',
     feed_view_switch: 1,
     show_search: 1,
@@ -157,37 +136,17 @@
 }
 
-/** Valid user-id for owner assignment, or null if empty/unknown. */
-function validOwnerId(raw) {
-  const id = (raw || '').toString().trim();
-  if (!id) return null;
-  return db.prepare('SELECT 1 FROM users WHERE id = ?').get(id) ? id : null;
-}
-
-/** Grant a user admin rights on a site (idempotent upsert). */
-function grantSiteAdmin(siteId, userId) {
-  db.prepare(`
-    INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
-    ON CONFLICT(site_id, user_id) DO UPDATE SET role = 'admin'
-  `).run(siteId, userId);
-}
-
-/** Candidate owners for the owner selector field (god-only). */
-function listOwnerCandidates() {
-  return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
-}
-
 // ==================== LIST ====================
 router.get('/', requireGod, (req, res) => {
   const sites = db.prepare(`
     SELECT s.id, s.slug, s.title, s.description, s.created_at,
-           s.is_public, s.robots_index, s.is_primary,
+           s.is_public, s.robots_index,
            u.username AS owner_username,
            (SELECT COUNT(*) FROM posts WHERE site_id = s.id) AS post_count
     FROM sites s LEFT JOIN users u ON u.id = s.owner_id
-    ORDER BY s.is_primary DESC, s.created_at DESC
+    ORDER BY s.created_at DESC
   `).all();
 
   renderPage(req, res, 'pages/admin-sites', {
-    pageTitleKey: 'admin.t_sites',
+    pageTitle: 'Sites',
     bodyClass: 'on-admin',
     sites,
@@ -200,17 +159,12 @@
 router.get('/new', requireGod, (req, res) => {
   renderPage(req, res, 'pages/admin-site-edit', {
-    pageJs: 'admin-site-edit',
-    pageTitleKey: 'admin.t_newsite',
+    pageTitle: 'New site',
     bodyClass: 'on-admin',
     isNew: true,
-    // ?owner=<id> (from the users page: "give this user a Klonkt") is
-    // pre-selected; otherwise defaults to the creating god.
-    site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
-    users: listOwnerCandidates(),
+    site: { slug: '', ...siteEditableFields() },
     palettes: ThemeService.listPalettes(),
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
     parsedLinks: [],
-    apAliases: '',
     error: null,
   });
@@ -232,10 +186,4 @@
 
   const f = { ...siteEditableFields(), ...req.body };
-
-  // Owner: god may assign the site to a DIFFERENT user — this is the core of
-  // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
-  // creating god themselves.
-  const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
-
   const siteId = uuid();
   db.prepare(`
@@ -243,7 +191,6 @@
       id, slug, title, description, tagline, owner_id,
       language, palette, accent, profile_photo,
-      is_public, robots_index, require_login_to_comment, enable_audio_player,
-      feed_view_default
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      is_public, robots_index, require_login_to_comment, enable_audio_player
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     siteId, slug,
@@ -251,8 +198,8 @@
     (f.description || '').slice(0, 500),
     (f.tagline || '').slice(0, 200),
-    ownerId,
+    req.session.user.id,
     f.language || 'nl',
-    f.palette || 'klonkt',
-    ThemeService.validateAccent(f.accent) || '#e8b04b',
+    f.palette || 'sage',
+    ThemeService.validateAccent(f.accent) || '#c2410c',
     f.profile_photo || null,
     f.is_public ? 1 : 0,
@@ -260,16 +207,16 @@
     f.require_login_to_comment ? 1 : 0,
     (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
-    f.feed_view_default === 'grid' ? 'grid' : 'reader',
   );
 
-  // The OWNER (not necessarily the creator) gets a site_members admin row → this
-  // lets them pass canAdminSite + requireSiteManager gates to manage their site.
-  grantSiteAdmin(siteId, ownerId);
-
-  res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
+  // The site_members entry lets the god/owner show up in canAdminSite checks.
+  db.prepare(`
+    INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
+  `).run(siteId, req.session.user.id);
+
+  res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site created'));
 });
 
 // ==================== EDIT (form) ====================
-router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
+router.get('/:slug/edit', requireGod, (req, res) => {
   const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
   if (!site) return res.redirect('/admin/sites?error=Not+found');
@@ -280,19 +227,13 @@
   }
 
-  let apAliases = '';
-  try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
-
   renderPage(req, res, 'pages/admin-site-edit', {
-    pageJs: 'admin-site-edit',
-    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
+    pageTitle: `Edit: ${site.title}`,
     bodyClass: 'on-admin',
     isNew: false,
     site,
-    users: listOwnerCandidates(),
     palettes: ThemeService.listPalettes(),
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
     parsedLinks,
-    apAliases,
     success: req.query.success || null,
     error: req.query.error || null,
@@ -300,56 +241,13 @@
 });
 
-// ==================== MOVE (FEP-7628, slice 2) ====================
-// The explicit departure: announce to every follower that this account now
-// lives elsewhere. Deliberately its own POST with its own button, never a
-// side effect of Save: a Move is a door you close behind you.
-router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
+// ==================== SAVE ====================
+router.post('/:slug/save', requireGod, (req, res) => {
+  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
   if (!site) return res.redirect('/admin/sites?error=Not+found');
-  const r = await AP.moveAccount(site, req.body.move_target || '');
-  if (r && r.ok) {
-    return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
-  }
-  const msg = {
-    guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
-    no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
-    not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
-    unreachable: 'Het nieuwe profiel is niet bereikbaar.',
-    self: 'Dat is dit account zelf.',
-  }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
-  res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
-});
-
-// ==================== SAVE ====================
-router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
-  const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.redirect('/admin/sites?error=Not+found');
 
   const f = req.body;
-  // Twee vragen, en ze hingen scheef: dit pad schreef 'timeline' terwijl het
-  // AANMAAKpad 'reader' schreef, voor precies dezelfde keuze. Elke site die ooit
-  // is opgeslagen droeg dus 'timeline', en de client vertaalde dat stil terug.
-  // Nu betekent de waarde weer wat er staat.
-  const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader';
-  const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt;
+  const moderationMode = f.comments_moderation_mode === 'moderate' ? 'moderate' : 'trust';
+  const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
   const profileLinksJson = buildProfileLinks(f);
-
-  // FEP-7628 aliases — validated/resolved before anything is written.
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  // Het INVOERVELD staat hier sinds 14-8 niet meer: aliassen horen bij
-  // Migreren. Dit formulier mag ze dus niet aanraken, en al helemaal niet
-  // leegmaken omdat het veld ontbreekt. Anders verlies je je claim op je oude
-  // account door je kleuren aan te passen, en weigert de Move daarna met
-  // no_backreference. Alleen verwerken als het veld ECHT is meegestuurd, zodat
-  // een oude gecachte pagina die hem nog wel heeft blijft werken.
-  let apAliasesJson = site.ap_aliases || null;
-  if (Object.prototype.hasOwnProperty.call(f, 'ap_aliases')) {
-    try {
-      const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
-      apAliasesJson = arr.length ? JSON.stringify(arr) : null;
-    } catch (e) {
-      return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
-    }
-  }
 
   // theme_override: only accept the three legal values. Empty string means
@@ -359,5 +257,5 @@
   // accent: only accept colors from the curated ACCENTS list. Falls back to
   // the orange default if the submitted value isn't recognised.
-  const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
+  const accent = ThemeService.validateAccent(f.accent) || '#c2410c';
 
   db.prepare(`
@@ -365,12 +263,14 @@
       title = ?, description = ?, tagline = ?, language = ?,
       palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
-      profile_enabled = ?,
+      profile_enabled = ?, profile_name = ?, profile_bio = ?,
       profile_links = ?,
-      ap_aliases = ?,
       is_public = ?, robots_index = ?, require_login_to_comment = ?,
-      enable_audio_player = ?,
-      approve_followers = ?,
-      feed_view_default = ?, feed_view_switch = ?, feed_alt_view = ?, reader_full_page = ?,
+      enable_audio_player = ?, enable_prutter = ?,
+      comments_moderation_mode = ?,
+      feed_view_default = ?, feed_view_switch = ?,
       show_search = ?, show_archive_link = ?,
+      title_template = ?, twitter = ?, canonical = ?,
+      google_verification = ?, bing_verification = ?,
+      pinterest_verification = ?, yandex_verification = ?,
       custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
       updated_at = CURRENT_TIMESTAMP
@@ -381,22 +281,29 @@
     (f.tagline || '').slice(0, 200),
     f.language || 'nl',
-    f.palette || 'klonkt',
+    f.palette || 'sage',
     accent,
     themeOverride,
     f.profile_photo || null,
     f.profile_enabled ? 1 : 0,
+    (f.profile_name || '').slice(0, 100) || null,
+    (f.profile_bio  || '').slice(0, 500) || null,
     profileLinksJson,
-    apAliasesJson,
     f.is_public ? 1 : 0,
     f.robots_index ? 1 : 0,
     f.require_login_to_comment ? 1 : 0,
     f.enable_audio_player ? 1 : 0,
-    f.approve_followers ? 1 : 0,
+    f.enable_prutter ? 1 : 0,
+    moderationMode,
     feedViewDef,
     f.feed_view_switch ? 1 : 0,
-    feedAlt,
-    f.reader_full_page ? 1 : 0,
     f.show_search ? 1 : 0,
     f.show_archive_link ? 1 : 0,
+    (f.title_template || '{title} — {site}').slice(0, 200),
+    (f.twitter || '').slice(0, 64) || null,
+    (f.canonical || '').slice(0, 200) || null,
+    (f.google_verification    || '').slice(0, 200) || null,
+    (f.bing_verification      || '').slice(0, 200) || null,
+    (f.pinterest_verification || '').slice(0, 200) || null,
+    (f.yandex_verification    || '').slice(0, 200) || null,
     f.custom_css      || null,
     f.custom_head_html || null,
@@ -405,38 +312,5 @@
   );
 
-  // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
-  // change the owner (the field is not shown to non-god users either).
-  if (req.session.user.role === 'god') {
-    const newOwner = validOwnerId(req.body.owner_id);
-    if (newOwner) {
-      db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
-      grantSiteAdmin(site.id, newOwner);
-    }
-  }
-
-  // Alias change → broadcast an actor Update so remote caches refresh. The old
-  // server re-fetches the actor live during a Move anyway; this is freshness,
-  // not correctness, hence best-effort.
-  if ((site.ap_aliases || null) !== apAliasesJson) {
-    try {
-      const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
-      AP.deliverActorUpdate(fresh).catch(() => {});
-    } catch { /* never blocks the save */ }
-  }
-
-  res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
-});
-
-// ==================== MAKE PRIMARY ====================
-// God chooses which site is the primary/main site (the label/company site in hub;
-// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
-router.post('/:slug/make-primary', requireGod, (req, res) => {
-  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
-  db.transaction(() => {
-    db.prepare('UPDATE sites SET is_primary = 0').run();
-    db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
-  })();
-  res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
+  res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Saved'));
 });
 
