source: Klonkt/src/routes/admin-sites.js@ 746d98a

main
Last change on this file since 746d98a was 746d98a, checked in by Robin <roboburr@…>, 3 hours ago

Wardmodus: een besloten Klonkt met alleen het profiel openbaar

Robin, 30-9: "Ward Profile mode gaat actief wanneer ward gekoppeld is, dus
wanneer de persoon guardian(s) heeft. Een besloten Klonkt met enkel het profiel
van de ward publiek. Wij denken dat dit het beste valt bij emancipation om de
functie weer naar normaal Klonkt te brengen."

AFGELEID, NIET OPGESLAGEN. Heeft de site geaccepteerde guardians, dan staat de
modus aan; is de set leeg, dan uit. listGuardians telt alleen geaccepteerde
guardianships, dus een voorstel zet niets dicht, en de set leegmaken is
emancipatie (FEP-633c 3.4) -- precies waar het ontwerp de weg terug legt. Een
schakelaar zou een tweede waarheid naast de guardianship zijn.

STANDAARD DICHT (middleware/ward-profile.js, in server.js direct na de
siteresolver en de schrijfpoort). Een bezoeker ziet op / het profiel en verder
niets: een pagina gaat terug naar het profiel, al het andere is 404, een POST
bereikt zijn handler niet, en een htmx-navigatie krijgt HX-Redirect in plaats
van een brokstuk. Wat niet op de lijst staat komt er niet langs, zodat een
nieuwe route niet vanzelf een kind openzet.

Open blijft: inloggen, account, beheer, het guardianpaneel, /lang, de bestanden
van de pagina zelf, en /audio/stream -- die bewaakt elk bestand zelf, en een
track die de ward op de fediverse opende moet daar speelbaar blijven. De
federatie (ActivityPub, WebFinger, OAuth voor Shaer, /media, /assets, /og) hangt
al VOOR de poort; de paden staan toch op de lijst, voor als de volgorde ooit
omgaat, want dan zou een ward stil van de fediverse verdwijnen.

Wie alles ziet: de ward zelf, en god, kijker en sitebeheer. Een andere
ingelogde gebruiker is gewoon een bezoeker.

DE PROFIELPAGINA (pages/ward-profile.ejs) mag verzorgd, want dit is alles wat
een bezoeker ziet: foto met een ring in de accentkleur, de naam groot, de
handle op een pil, de bio, de links als brede knoppen in hun merkkleur, een
volgknop die de bestaande fediverse-dialoog opent (een volgverzoek aan een ward
gaat eerst langs de guardians), en een neutrale regel dat berichten alleen
voor volgers zijn. Nergens "kind" of "ward": dat de account guardians heeft
staat al openbaar op de fediverse. Dezelfde bronnen als de profielkop, zodat
het profiel op geen twee plekken iets anders kan zeggen. De chrome biedt een
bezoeker niets aan wat dicht is: geen zoekknop, geen archief, geen
Lezen/Grid-schakelaar.

DE PROFIELLINKS verhuizen van Appearance naar het account (/account#links),
bij naam, bio en foto. Twee vallen onderweg, allebei getoetst:

  • Appearance schreef profile_links altijd, uit de formuliervelden. Met de velden weg zou elke keer opslaan daar de links wissen. Die pagina schrijft ze niet meer.
  • ownedSite haalde profile_links niet op, dus de accountpagina toonde nul links en opslaan wiste ze. Gevonden bij het bekijken, niet door een toets; nu wel.

buildProfileLinks staat in PlatformIcons.js, en de rijen-knoppen in een eigen
module (mod/profile-links.js).

Getoetst: gewone Klonkt zonder guardians, een voorstel zet niets dicht,
gekoppeld alleen het profiel, pagina's terug, 404 voor wat geen pagina is, geen
handler voor een POST, HX-Redirect, wat open moet blijft open, ward en beheer
zien alles, een andere ingelogde is een bezoeker, emancipatie maakt het weer
gewoon, de links bij het account en Appearance wist ze niet. Tegenproef per
zaak. Volledige suite 1325 groen.

Nagekeken in de browser op 375 en 1280 pixels: het profiel past op een
telefoonscherm, knoppen van 55 pixels, geen overloop, de volgknop opent de
dialoog; de accountpagina toont de bestaande links en voegt er een toe.

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

  • Property mode set to 100644
File size: 17.5 KB
RevLine 
[7bc636b]1/**
2 * Admin: Site management — Phase E.
3 *
4 * GET /admin/sites -> list all sites
5 * GET /admin/sites/new -> create form
6 * POST /admin/sites/create -> insert + redirect to edit
7 * GET /admin/sites/:slug/edit -> edit form
8 * POST /admin/sites/:slug/save -> update
9 * POST /admin/sites/:slug/delete-> delete (refuses if site has posts)
10 *
11 * God-only (requireGod middleware). Slug is immutable after create — too
12 * many things hang off it (URLs, manifest scope, federation). If you really
13 * need to rename: delete + re-create.
14 */
15
16import express from 'express';
17import path from 'path';
18import fs from 'fs';
19import multer from 'multer';
20import { v4 as uuid } from 'uuid';
21import db from '../config/database.js';
22import { renderPage } from '../middleware/render.js';
[8cb1dc7]23import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
[7bc636b]24import ThemeService from '../services/ThemeService.js';
25import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
[8f6225c]26import { toWebp } from '../services/ImageWebpService.js';
[e2c3d09]27import { mediaDir } from '../config/paths.js';
[ccaa530]28import AP from '../services/ActivityPubService.js';
[7bc636b]29
30
31// Profile photos share the avatar directory with user avatars — same physical
32// folder, same URL prefix. Filenames are uuid-prefixed so site photos and
33// user avatars never collide.
[e2c3d09]34const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
[7bc636b]35fs.mkdirSync(PHOTO_DIR, { recursive: true });
36
37const ALLOWED_PHOTO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
38const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
39const photoUpload = multer({
40 storage: multer.diskStorage({
41 destination: (req, file, cb) => cb(null, PHOTO_DIR),
42 filename: (req, file, cb) => {
43 const ext = path.extname(file.originalname || '').toLowerCase() || '.jpg';
44 cb(null, `site-${uuid()}${ext}`);
45 },
46 }),
47 limits: { fileSize: MAX_PHOTO_BYTES },
48 fileFilter: (req, file, cb) => {
49 const ext = path.extname(file.originalname || '').toLowerCase();
50 if (!ALLOWED_PHOTO_EXT.has(ext)) {
51 return cb(new Error('Alleen JPG/PNG/WebP/GIF toegestaan'));
52 }
53 cb(null, true);
54 },
55});
56
57
[ccaa530]58/**
59 * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
60 * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
61 * purpose — a typo'd alias that silently lands on the actor would make a later
62 * Move fail at the old server with no hint why. Throws the offending line.
63 */
[e9128bc]64export async function parseApAliases(raw, ownActorUri) {
[ccaa530]65 const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
66 if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
67 const out = [];
68 for (const line of lines) {
69 let uri = null;
70 if (/^https?:\/\//i.test(line)) uri = line;
71 else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
72 if (!uri) throw new Error(line);
73 if (uri === ownActorUri) continue; // claiming yourself adds nothing
74 if (!out.includes(uri)) out.push(uri);
75 }
76 return out;
77}
78
[7bc636b]79const router = express.Router();
80
81// ==================== UPLOAD PROFILE PHOTO (JSON) ====================
82// POST /admin/sites/upload-photo → { ok: true, url: '/media/avatars/<filename>' }
83// Used by the admin-site-edit form's photo picker. The form itself still
84// holds the URL string in `profile_photo` — this endpoint just stores the
85// file and hands back a URL that the form can paste into the input field.
[8cb1dc7]86router.post('/upload-photo', requireAuth, (req, res) => {
[7bc636b]87 photoUpload.single('photo')(req, res, (err) => {
88 if (err) return res.status(400).json({ ok: false, error: err.message });
89 if (!req.file) return res.status(400).json({ ok: false, error: 'Geen bestand ontvangen' });
90 res.json({
91 ok: true,
[8f6225c]92 url: `/media/avatars/${toWebp(req.file)}`,
[7bc636b]93 size: req.file.size,
94 mime: req.file.mimetype,
95 });
96 });
97});
98
99const RESERVED_SITE_SLUGS = new Set([
100 'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
[8f2f97c]101 'account', 'sites', 'comments', 'posts', 'media', 'audio',
[a1c8cb8]102 'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
[7bc636b]103 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
[318d0c2]104 'paid', 'push', 'guardian',
[7bc636b]105]);
106
107function siteEditableFields() {
108 return {
109 title: '',
110 description: '',
111 tagline: '',
112 language: 'nl',
[dd7e2a2]113 palette: 'klonkt',
114 accent: '#e8b04b',
[7bc636b]115 profile_photo: '',
116 profile_enabled: 1,
117 profile_name: '',
118 profile_bio: '',
119 is_public: 1,
120 robots_index: 1,
[74544fb]121 require_login_to_comment: 1,
[7bc636b]122 enable_audio_player: 1,
[8ea3d0d]123 comments_moderation_mode: 'moderate',
124 feed_view_default: 'grid',
[7bc636b]125 feed_view_switch: 1,
126 show_search: 1,
127 show_archive_link: 1,
128 title_template: '{title} — {site}',
129 twitter: '',
130 canonical: '',
131 google_verification: '',
132 bing_verification: '',
133 pinterest_verification: '',
134 yandex_verification: '',
135 custom_css: '',
136 custom_head_html: '',
137 custom_foot_html: '',
138 };
139}
140
[834bcc3]141/** Valid user-id for owner assignment, or null if empty/unknown. */
[98ecf51]142function validOwnerId(raw) {
143 const id = (raw || '').toString().trim();
144 if (!id) return null;
145 return db.prepare('SELECT 1 FROM users WHERE id = ?').get(id) ? id : null;
146}
147
[834bcc3]148/** Grant a user admin rights on a site (idempotent upsert). */
[98ecf51]149function grantSiteAdmin(siteId, userId) {
150 db.prepare(`
151 INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
152 ON CONFLICT(site_id, user_id) DO UPDATE SET role = 'admin'
153 `).run(siteId, userId);
154}
155
[834bcc3]156/** Candidate owners for the owner selector field (god-only). */
[98ecf51]157function listOwnerCandidates() {
158 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
159}
160
[7bc636b]161// ==================== LIST ====================
162router.get('/', requireGod, (req, res) => {
163 const sites = db.prepare(`
164 SELECT s.id, s.slug, s.title, s.description, s.created_at,
[7881080]165 s.is_public, s.robots_index, s.is_primary,
[7bc636b]166 u.username AS owner_username,
167 (SELECT COUNT(*) FROM posts WHERE site_id = s.id) AS post_count
168 FROM sites s LEFT JOIN users u ON u.id = s.owner_id
[7881080]169 ORDER BY s.is_primary DESC, s.created_at DESC
[7bc636b]170 `).all();
171
172 renderPage(req, res, 'pages/admin-sites', {
[3487567]173 pageTitleKey: 'admin.t_sites',
[7bc636b]174 bodyClass: 'on-admin',
175 sites,
176 success: req.query.success || null,
177 error: req.query.error || null,
178 });
179});
180
181// ==================== NEW (form) ====================
182router.get('/new', requireGod, (req, res) => {
183 renderPage(req, res, 'pages/admin-site-edit', {
[0475b13]184 pageJs: 'admin-site-edit',
[3487567]185 pageTitleKey: 'admin.t_newsite',
[7bc636b]186 bodyClass: 'on-admin',
187 isNew: true,
[834bcc3]188 // ?owner=<id> (from the users page: "give this user a Klonkt") is
189 // pre-selected; otherwise defaults to the creating god.
[86793f9]190 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
[98ecf51]191 users: listOwnerCandidates(),
[7bc636b]192 palettes: ThemeService.listPalettes(),
193 accents: ThemeService.listAccents(),
194 platforms: listPlatforms(),
[5462bab]195 apAliases: '',
[7bc636b]196 error: null,
197 });
198});
199
200// ==================== CREATE ====================
201router.post('/create', requireGod, (req, res) => {
202 const slug = (req.body.slug || '').toString().toLowerCase().trim();
203 if (!/^[a-z0-9_-]{2,40}$/.test(slug)) {
204 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug: 2-40 chars, letters/numbers/underscore/dash'));
205 }
206 if (RESERVED_SITE_SLUGS.has(slug)) {
207 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('That slug is reserved'));
208 }
209 const existing = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
210 if (existing) {
211 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug already taken'));
212 }
213
214 const f = { ...siteEditableFields(), ...req.body };
[98ecf51]215
[834bcc3]216 // Owner: god may assign the site to a DIFFERENT user — this is the core of
217 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
218 // creating god themselves.
[98ecf51]219 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
220
[7bc636b]221 const siteId = uuid();
222 db.prepare(`
223 INSERT INTO sites (
224 id, slug, title, description, tagline, owner_id,
225 language, palette, accent, profile_photo,
[8ea3d0d]226 is_public, robots_index, require_login_to_comment, enable_audio_player,
[abdcf33]227 feed_view_default
228 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
[7bc636b]229 `).run(
230 siteId, slug,
231 (f.title || slug).slice(0, 200),
232 (f.description || '').slice(0, 500),
233 (f.tagline || '').slice(0, 200),
[98ecf51]234 ownerId,
[7bc636b]235 f.language || 'nl',
[dd7e2a2]236 f.palette || 'klonkt',
237 ThemeService.validateAccent(f.accent) || '#e8b04b',
[7bc636b]238 f.profile_photo || null,
239 f.is_public ? 1 : 0,
240 f.robots_index ? 1 : 0,
241 f.require_login_to_comment ? 1 : 0,
242 (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
[70e9147]243 f.feed_view_default === 'grid' ? 'grid' : 'reader',
[7bc636b]244 );
245
[834bcc3]246 // The OWNER (not necessarily the creator) gets a site_members admin row → this
247 // lets them pass canAdminSite + requireSiteManager gates to manage their site.
[98ecf51]248 grantSiteAdmin(siteId, ownerId);
[7bc636b]249
[98ecf51]250 res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
[7bc636b]251});
252
253// ==================== EDIT (form) ====================
[8cb1dc7]254router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
[7bc636b]255 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
256 if (!site) return res.redirect('/admin/sites?error=Not+found');
257
258
[ccaa530]259 let apAliases = '';
260 try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
261
[7bc636b]262 renderPage(req, res, 'pages/admin-site-edit', {
[0475b13]263 pageJs: 'admin-site-edit',
[3487567]264 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
[7bc636b]265 bodyClass: 'on-admin',
266 isNew: false,
267 site,
[98ecf51]268 users: listOwnerCandidates(),
[7bc636b]269 palettes: ThemeService.listPalettes(),
270 accents: ThemeService.listAccents(),
271 platforms: listPlatforms(),
[ccaa530]272 apAliases,
[7bc636b]273 success: req.query.success || null,
274 error: req.query.error || null,
275 });
276});
277
[0ca7e9a4]278// ==================== MOVE (FEP-7628, slice 2) ====================
279// The explicit departure: announce to every follower that this account now
280// lives elsewhere. Deliberately its own POST with its own button, never a
281// side effect of Save: a Move is a door you close behind you.
282router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
283 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
284 if (!site) return res.redirect('/admin/sites?error=Not+found');
285 const r = await AP.moveAccount(site, req.body.move_target || '');
286 if (r && r.ok) {
287 return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
288 }
289 const msg = {
290 guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
291 no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
292 not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
293 unreachable: 'Het nieuwe profiel is niet bereikbaar.',
294 self: 'Dat is dit account zelf.',
295 }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
296 res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
297});
298
[7bc636b]299// ==================== SAVE ====================
[ccaa530]300router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
301 const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
[7bc636b]302 if (!site) return res.redirect('/admin/sites?error=Not+found');
303
304 const f = req.body;
[e46ebaa]305 // Twee vragen, en ze hingen scheef: dit pad schreef 'timeline' terwijl het
306 // AANMAAKpad 'reader' schreef, voor precies dezelfde keuze. Elke site die ooit
307 // is opgeslagen droeg dus 'timeline', en de client vertaalde dat stil terug.
308 // Nu betekent de waarde weer wat er staat.
309 const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader';
310 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt;
[746d98a]311 // De profiellinks staan sinds 30-9 bij je profiel (/account). Deze pagina
312 // schrijft ze dus NIET meer: met de velden weg zou elke keer opslaan hier ze
313 // wissen. De kolom blijft van /account/links.
[7bc636b]314
[ccaa530]315 // FEP-7628 aliases — validated/resolved before anything is written.
316 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
[e9128bc]317 // Het INVOERVELD staat hier sinds 14-8 niet meer: aliassen horen bij
318 // Migreren. Dit formulier mag ze dus niet aanraken, en al helemaal niet
319 // leegmaken omdat het veld ontbreekt. Anders verlies je je claim op je oude
320 // account door je kleuren aan te passen, en weigert de Move daarna met
321 // no_backreference. Alleen verwerken als het veld ECHT is meegestuurd, zodat
322 // een oude gecachte pagina die hem nog wel heeft blijft werken.
323 let apAliasesJson = site.ap_aliases || null;
324 if (Object.prototype.hasOwnProperty.call(f, 'ap_aliases')) {
325 try {
326 const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
327 apAliasesJson = arr.length ? JSON.stringify(arr) : null;
328 } catch (e) {
329 return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
330 }
[ccaa530]331 }
332
[7bc636b]333 // theme_override: only accept the three legal values. Empty string means
334 // "Auto" — defer to user's prefers-color-scheme on first paint.
335 const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
336
337 // accent: only accept colors from the curated ACCENTS list. Falls back to
338 // the orange default if the submitted value isn't recognised.
[dd7e2a2]339 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
[7bc636b]340
341 db.prepare(`
342 UPDATE sites SET
343 title = ?, description = ?, tagline = ?, language = ?,
344 palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
[2c246d4]345 profile_enabled = ?,
[ccaa530]346 ap_aliases = ?,
[7bc636b]347 is_public = ?, robots_index = ?, require_login_to_comment = ?,
[8f2f97c]348 enable_audio_player = ?,
[acbc9fc]349 approve_followers = ?,
[520c993]350 feed_view_default = ?, feed_view_switch = ?, feed_alt_view = ?, reader_full_page = ?,
[7bc636b]351 show_search = ?, show_archive_link = ?,
352 custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
353 updated_at = CURRENT_TIMESTAMP
354 WHERE id = ?
355 `).run(
356 (f.title || '').slice(0, 200),
357 (f.description || '').slice(0, 500),
358 (f.tagline || '').slice(0, 200),
359 f.language || 'nl',
[dd7e2a2]360 f.palette || 'klonkt',
[7bc636b]361 accent,
362 themeOverride,
363 f.profile_photo || null,
364 f.profile_enabled ? 1 : 0,
[ccaa530]365 apAliasesJson,
[7bc636b]366 f.is_public ? 1 : 0,
367 f.robots_index ? 1 : 0,
368 f.require_login_to_comment ? 1 : 0,
369 f.enable_audio_player ? 1 : 0,
[acbc9fc]370 f.approve_followers ? 1 : 0,
[7bc636b]371 feedViewDef,
372 f.feed_view_switch ? 1 : 0,
[e46ebaa]373 feedAlt,
[520c993]374 f.reader_full_page ? 1 : 0,
[7bc636b]375 f.show_search ? 1 : 0,
376 f.show_archive_link ? 1 : 0,
377 f.custom_css || null,
378 f.custom_head_html || null,
379 f.custom_foot_html || null,
380 site.id,
381 );
382
[834bcc3]383 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
384 // change the owner (the field is not shown to non-god users either).
[98ecf51]385 if (req.session.user.role === 'god') {
386 const newOwner = validOwnerId(req.body.owner_id);
387 if (newOwner) {
388 db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
389 grantSiteAdmin(site.id, newOwner);
390 }
391 }
392
[ccaa530]393 // Alias change → broadcast an actor Update so remote caches refresh. The old
394 // server re-fetches the actor live during a Move anyway; this is freshness,
395 // not correctness, hence best-effort.
396 if ((site.ap_aliases || null) !== apAliasesJson) {
397 try {
398 const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
399 AP.deliverActorUpdate(fresh).catch(() => {});
400 } catch { /* never blocks the save */ }
401 }
402
[98ecf51]403 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
[7bc636b]404});
405
[834bcc3]406// ==================== MAKE PRIMARY ====================
407// God chooses which site is the primary/main site (the label/company site in hub;
408// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
[7881080]409router.post('/:slug/make-primary', requireGod, (req, res) => {
410 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
411 if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
412 db.transaction(() => {
413 db.prepare('UPDATE sites SET is_primary = 0').run();
414 db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
415 })();
416 res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
417});
418
[7bc636b]419// ==================== DELETE ====================
420router.post('/:slug/delete', requireGod, (req, res) => {
421 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
422 if (!site) return res.redirect('/admin/sites?error=Not+found');
423
424 const postCount = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE site_id = ?').get(site.id).c;
425 if (postCount > 0) {
426 return res.redirect('/admin/sites?error=' + encodeURIComponent(`Cannot delete: site has ${postCount} post(s). Delete posts first.`));
427 }
428
429 // Clean up site_members and audio_tracks (no posts to worry about).
430 db.prepare('DELETE FROM site_members WHERE site_id = ?').run(site.id);
431 db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(site.id);
432 db.prepare('DELETE FROM sites WHERE id = ?').run(site.id);
433
434 res.redirect('/admin/sites?success=' + encodeURIComponent('Site deleted'));
435});
436
437export default router;
Note: See TracBrowser for help on using the repository browser.