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

main
Last change on this file since b4af6f3 was 520c993, checked in by Robin <roboburr@…>, 3 weeks ago

Lezen: elk bericht een eigen scherm wordt een keuze

Robins vraag (20-8): de lege ruimtes tussen berichten optioneel maken, en
specifiek voor Lezen.

Nieuwe kolom reader_full_page op sites, standaard UIT zodat bestaande
sites niet ineens anders lezen:

uit bericht na bericht, geen lege ruimte eronder
aan elk bericht vult minstens een scherm (min-height:100svh)

Dit stond eerst vast op AAN, is op 20-8 vast op UIT gezet omdat een kort
bericht een halve pagina leegte gaf onder "Replies and reactions", en hoort
dus eigenlijk bij de schrijver: lange stukken lezen prettiger in de stroom,
korte losse berichten juist als eigen scherm.

De keuze staat in Beheer -> Instellingen bij de andere feed-keuzes, en gaat
via een attribuut op body zodat CSS het werk doet.

OVER DE SQL, want daar ging het deze week twee keer mis: deze wijziging
voegt een kolom toe aan dezelfde UPDATE waar ik eerder een kolom zonder
waarde in zette, waarna show_search de waarde van zijn buurman kreeg en
alles erna een plek opschoof. De tests gaan daarom door de ECHTE route en
toetsen expliciet de buurkolommen. Controleproef gedaan: laat je de waarde
weg, dan vallen 6 van de 8 tests om.

style.css naar v83, MOD_V naar 21.

Suite 1172/1172.

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

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