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

main
Last change on this file since 1ff801d was 0475b13, checked in by roboburr <roboburr@…>, 5 weeks ago

Acht schone pagina's uit inline script naar modules (shaer-bqr, stap 3b)

Alle acht zonder EJS-interpolatie, dus ongewijzigd te verplaatsen: admin-help,
admin-epk, admin-paid, admin-settings, auth-register, admin-push, admin-site-edit
(2 blokken) en authorize-interaction (3 blokken).

Elke route zegt nu welke module hij wil via pageJs; de bootstrap in de shell
haalt hem op. Meerdere blokken uit een pagina belanden in EEN module, gescheiden
door een streep -- ze deelden toch al een pagina en een levensduur.

Templates compileren, modules syntactisch ok, suite 565/565. Het echte bewijs
per pagina is er via een LINK naartoe gaan; na een herlading werkt alles toch al.

Commit met expliciete paden, niet met -A: eerder vandaag veegde ik daarmee
andermans werk uit dezelfde werkmap mee.

  • Property mode set to 100644
File size: 17.3 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 */
82async function parseApAliases(raw, ownActorUri) {
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),
[8ea3d0d]262 f.feed_view_default === 'timeline' ? 'timeline' : 'grid',
[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;
329 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
330 const profileLinksJson = buildProfileLinks(f);
331
[ccaa530]332 // FEP-7628 aliases — validated/resolved before anything is written.
333 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
334 let apAliasesJson = null;
335 try {
336 const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
337 apAliasesJson = arr.length ? JSON.stringify(arr) : null;
338 } catch (e) {
339 return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
340 }
341
[7bc636b]342 // theme_override: only accept the three legal values. Empty string means
343 // "Auto" — defer to user's prefers-color-scheme on first paint.
344 const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
345
346 // accent: only accept colors from the curated ACCENTS list. Falls back to
347 // the orange default if the submitted value isn't recognised.
[dd7e2a2]348 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
[7bc636b]349
350 db.prepare(`
351 UPDATE sites SET
352 title = ?, description = ?, tagline = ?, language = ?,
353 palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
[2c246d4]354 profile_enabled = ?,
[7bc636b]355 profile_links = ?,
[ccaa530]356 ap_aliases = ?,
[7bc636b]357 is_public = ?, robots_index = ?, require_login_to_comment = ?,
[8f2f97c]358 enable_audio_player = ?,
[7bc636b]359 feed_view_default = ?, feed_view_switch = ?,
360 show_search = ?, show_archive_link = ?,
361 custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
362 updated_at = CURRENT_TIMESTAMP
363 WHERE id = ?
364 `).run(
365 (f.title || '').slice(0, 200),
366 (f.description || '').slice(0, 500),
367 (f.tagline || '').slice(0, 200),
368 f.language || 'nl',
[dd7e2a2]369 f.palette || 'klonkt',
[7bc636b]370 accent,
371 themeOverride,
372 f.profile_photo || null,
373 f.profile_enabled ? 1 : 0,
374 profileLinksJson,
[ccaa530]375 apAliasesJson,
[7bc636b]376 f.is_public ? 1 : 0,
377 f.robots_index ? 1 : 0,
378 f.require_login_to_comment ? 1 : 0,
379 f.enable_audio_player ? 1 : 0,
380 feedViewDef,
381 f.feed_view_switch ? 1 : 0,
382 f.show_search ? 1 : 0,
383 f.show_archive_link ? 1 : 0,
384 f.custom_css || null,
385 f.custom_head_html || null,
386 f.custom_foot_html || null,
387 site.id,
388 );
389
[834bcc3]390 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
391 // change the owner (the field is not shown to non-god users either).
[98ecf51]392 if (req.session.user.role === 'god') {
393 const newOwner = validOwnerId(req.body.owner_id);
394 if (newOwner) {
395 db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
396 grantSiteAdmin(site.id, newOwner);
397 }
398 }
399
[ccaa530]400 // Alias change → broadcast an actor Update so remote caches refresh. The old
401 // server re-fetches the actor live during a Move anyway; this is freshness,
402 // not correctness, hence best-effort.
403 if ((site.ap_aliases || null) !== apAliasesJson) {
404 try {
405 const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
406 AP.deliverActorUpdate(fresh).catch(() => {});
407 } catch { /* never blocks the save */ }
408 }
409
[98ecf51]410 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
[7bc636b]411});
412
[834bcc3]413// ==================== MAKE PRIMARY ====================
414// God chooses which site is the primary/main site (the label/company site in hub;
415// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
[7881080]416router.post('/:slug/make-primary', requireGod, (req, res) => {
417 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
418 if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
419 db.transaction(() => {
420 db.prepare('UPDATE sites SET is_primary = 0').run();
421 db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
422 })();
423 res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
424});
425
[7bc636b]426// ==================== DELETE ====================
427router.post('/:slug/delete', requireGod, (req, res) => {
428 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
429 if (!site) return res.redirect('/admin/sites?error=Not+found');
430
431 const postCount = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE site_id = ?').get(site.id).c;
432 if (postCount > 0) {
433 return res.redirect('/admin/sites?error=' + encodeURIComponent(`Cannot delete: site has ${postCount} post(s). Delete posts first.`));
434 }
435
436 // Clean up site_members and audio_tracks (no posts to worry about).
437 db.prepare('DELETE FROM site_members WHERE site_id = ?').run(site.id);
438 db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(site.id);
439 db.prepare('DELETE FROM sites WHERE id = ?').run(site.id);
440
441 res.redirect('/admin/sites?success=' + encodeURIComponent('Site deleted'));
442});
443
444export default router;
Note: See TracBrowser for help on using the repository browser.