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

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

Het paneel hoort bij wie je bent, niet bij je bestanden (Robins correctie)

Verhuisd van Beheer -> Audio naar het profielscherm van de site-eigenaar, en
daar staat het NAAST de aliassen -- want dat is dezelfde vraag. Een alias zegt
wie je elders in de fediverse bent, een MBID zegt wie je in het muziekregister
bent. Bij de audiobestanden ging het over beheer van spullen; hier gaat het over
identiteit, en daar hoort het thuis.

DE VERHUIZING VERANDERDE DE VORM, en dat is de moeite waard om te weten. Het
profielscherm IS een formulier, en een formulier in een formulier bestaat niet
in HTML. Dus geen eigen opslaan-knop meer: de keuze zet een verborgen veld en
gaat mee met de Opslaan van de pagina, precies zoals de profielfoto. Dat leest
ook beter -- een halve wijziging die je nog kunt herzien voor je opslaat.

Ontkoppelen is nu het veld leegmaken, geen aparte route. Minder oppervlak.

Het zoek-endpoint ging mee naar /admin/sites/:slug/api/musicbrainz. Een endpoint
onder /admin/audio laten staan voor een scherm in /admin/sites is precies zo'n
naad die later niemand meer kan plaatsen. De teksten heten nu asite.mb_* in
plaats van aaud.mb_*, in alle drie de talen, met een placeholder erbij.

Wat NIET veranderde: de zoekopdracht draait server-side (hun regel geldt per
applicatie), wij kiezen niet voor de artiest, en alleen een echte MBID komt de
kolom in.

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

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