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

Last change on this file since 495b019 was 495b019, checked in by Bart <bart@…>, 2 days ago

Lay-outthema's: een pakket, de beheerder kiest, en de zijbalk als eerste

Een lay-out is nu een pakket: een map in src/themes/ met een theme.json (id,
naam, versie, contract, stylesheet) en de stylesheet zelf. De zijbalk is het
eerste ingebouwde thema; een thema van buiten installeren komt later en gaat
door dezelfde lader, zodat er één vorm is.

Dezelfde markup, een ander stelsel: een thema verandert geen template. Het
contract (1) is de afspraak over de ids en klassen waar het op mag bouwen; een
thema op een ander contract valt terug op de gewone kop in plaats van half te
werken. Elke regel staat onder html[data-layout="<id>"], en een toets bewaakt
dat, zodat een thema niets raakt zolang het niet gekozen is.

WIE KIEST: de beheerder, in het sitebeheer (sites.layout). Een bezoeker niet
-- de server zet data-layout zelf, het bezoekersscript met localStorage is
weg, en ?layout= werkt alleen als voorproefje voor wie de site beheert. Het
beheer houdt altijd de gewone kop, ook bij navigeren via htmx (de lay-out reist
mee in pcmsNav): een kapot thema mag je nooit buitensluiten van de instelling
waarmee je het uitzet.

Geserveerd op /themes/<id>/<versie>/<bestand>, met de versie in het pad als
cache-buster; alleen wat het pakket zelf noemt komt eruit. De actor noemt het
gekozen thema als shaer:theme, zodat Shaer het van zijn eigen site kan halen
in plaats van er zelf een te kiezen.

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

  • Property mode set to 100644
File size: 17.9 KB
Line 
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';
23import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
24import ThemeService from '../services/ThemeService.js';
25import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
26import { toWebp } from '../services/ImageWebpService.js';
27import { mediaDir } from '../config/paths.js';
28import AP from '../services/ActivityPubService.js';
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.
34const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
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
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 */
64export async function parseApAliases(raw, ownActorUri) {
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
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.
86router.post('/upload-photo', requireAuth, (req, res) => {
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,
92 url: `/media/avatars/${toWebp(req.file)}`,
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',
101 'account', 'sites', 'comments', 'posts', 'media', 'audio',
102 'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
103 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
104 'paid', 'push', 'guardian',
105]);
106
107function siteEditableFields() {
108 return {
109 title: '',
110 description: '',
111 tagline: '',
112 language: 'nl',
113 palette: 'klonkt',
114 accent: '#e8b04b',
115 profile_photo: '',
116 profile_enabled: 1,
117 profile_name: '',
118 profile_bio: '',
119 is_public: 1,
120 robots_index: 1,
121 require_login_to_comment: 1,
122 enable_audio_player: 1,
123 comments_moderation_mode: 'moderate',
124 feed_view_default: 'grid',
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
141/** Valid user-id for owner assignment, or null if empty/unknown. */
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
148/** Grant a user admin rights on a site (idempotent upsert). */
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
156/** Candidate owners for the owner selector field (god-only). */
157function listOwnerCandidates() {
158 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
159}
160
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,
165 s.is_public, s.robots_index, s.is_primary,
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
169 ORDER BY s.is_primary DESC, s.created_at DESC
170 `).all();
171
172 renderPage(req, res, 'pages/admin-sites', {
173 pageTitleKey: 'admin.t_sites',
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', {
184 pageJs: 'admin-site-edit',
185 pageTitleKey: 'admin.t_newsite',
186 bodyClass: 'on-admin',
187 isNew: true,
188 // ?owner=<id> (from the users page: "give this user a Klonkt") is
189 // pre-selected; otherwise defaults to the creating god.
190 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
191 users: listOwnerCandidates(),
192 palettes: ThemeService.listPalettes(),
193 layouts: ThemeService.listLayouts(),
194 accents: ThemeService.listAccents(),
195 platforms: listPlatforms(),
196 apAliases: '',
197 error: null,
198 });
199});
200
201// ==================== CREATE ====================
202router.post('/create', requireGod, (req, res) => {
203 const slug = (req.body.slug || '').toString().toLowerCase().trim();
204 if (!/^[a-z0-9_-]{2,40}$/.test(slug)) {
205 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug: 2-40 chars, letters/numbers/underscore/dash'));
206 }
207 if (RESERVED_SITE_SLUGS.has(slug)) {
208 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('That slug is reserved'));
209 }
210 const existing = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
211 if (existing) {
212 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug already taken'));
213 }
214
215 const f = { ...siteEditableFields(), ...req.body };
216
217 // Owner: god may assign the site to a DIFFERENT user — this is the core of
218 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
219 // creating god themselves.
220 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
221
222 const siteId = uuid();
223 db.prepare(`
224 INSERT INTO sites (
225 id, slug, title, description, tagline, owner_id,
226 language, palette, accent, profile_photo,
227 is_public, robots_index, require_login_to_comment, enable_audio_player,
228 feed_view_default
229 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
230 `).run(
231 siteId, slug,
232 (f.title || slug).slice(0, 200),
233 (f.description || '').slice(0, 500),
234 (f.tagline || '').slice(0, 200),
235 ownerId,
236 f.language || 'nl',
237 f.palette || 'klonkt',
238 ThemeService.validateAccent(f.accent) || '#e8b04b',
239 f.profile_photo || null,
240 f.is_public ? 1 : 0,
241 f.robots_index ? 1 : 0,
242 f.require_login_to_comment ? 1 : 0,
243 (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
244 f.feed_view_default === 'grid' ? 'grid' : 'reader',
245 );
246
247 // The OWNER (not necessarily the creator) gets a site_members admin row → this
248 // lets them pass canAdminSite + requireSiteManager gates to manage their site.
249 grantSiteAdmin(siteId, ownerId);
250
251 res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
252});
253
254// ==================== EDIT (form) ====================
255router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
256 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
257 if (!site) return res.redirect('/admin/sites?error=Not+found');
258
259
260 let apAliases = '';
261 try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
262
263 renderPage(req, res, 'pages/admin-site-edit', {
264 pageJs: 'admin-site-edit',
265 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
266 bodyClass: 'on-admin',
267 isNew: false,
268 site,
269 users: listOwnerCandidates(),
270 palettes: ThemeService.listPalettes(),
271 layouts: ThemeService.listLayouts(),
272 accents: ThemeService.listAccents(),
273 platforms: listPlatforms(),
274 apAliases,
275 success: req.query.success || null,
276 error: req.query.error || null,
277 });
278});
279
280// ==================== MOVE (FEP-7628, slice 2) ====================
281// The explicit departure: announce to every follower that this account now
282// lives elsewhere. Deliberately its own POST with its own button, never a
283// side effect of Save: a Move is a door you close behind you.
284router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
285 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
286 if (!site) return res.redirect('/admin/sites?error=Not+found');
287 const r = await AP.moveAccount(site, req.body.move_target || '');
288 if (r && r.ok) {
289 return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
290 }
291 const msg = {
292 guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
293 no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
294 not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
295 unreachable: 'Het nieuwe profiel is niet bereikbaar.',
296 self: 'Dat is dit account zelf.',
297 }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
298 res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
299});
300
301// ==================== SAVE ====================
302router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
303 const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
304 if (!site) return res.redirect('/admin/sites?error=Not+found');
305
306 const f = req.body;
307 // Twee vragen, en ze hingen scheef: dit pad schreef 'timeline' terwijl het
308 // AANMAAKpad 'reader' schreef, voor precies dezelfde keuze. Elke site die ooit
309 // is opgeslagen droeg dus 'timeline', en de client vertaalde dat stil terug.
310 // Nu betekent de waarde weer wat er staat.
311 const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader';
312 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt;
313 // De profiellinks staan sinds 30-9 bij je profiel (/account). Deze pagina
314 // schrijft ze dus NIET meer: met de velden weg zou elke keer opslaan hier ze
315 // wissen. De kolom blijft van /account/links.
316
317 // FEP-7628 aliases — validated/resolved before anything is written.
318 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
319 // Het INVOERVELD staat hier sinds 14-8 niet meer: aliassen horen bij
320 // Migreren. Dit formulier mag ze dus niet aanraken, en al helemaal niet
321 // leegmaken omdat het veld ontbreekt. Anders verlies je je claim op je oude
322 // account door je kleuren aan te passen, en weigert de Move daarna met
323 // no_backreference. Alleen verwerken als het veld ECHT is meegestuurd, zodat
324 // een oude gecachte pagina die hem nog wel heeft blijft werken.
325 let apAliasesJson = site.ap_aliases || null;
326 if (Object.prototype.hasOwnProperty.call(f, 'ap_aliases')) {
327 try {
328 const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
329 apAliasesJson = arr.length ? JSON.stringify(arr) : null;
330 } catch (e) {
331 return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
332 }
333 }
334
335 // theme_override: only accept the three legal values. Empty string means
336 // "Auto" — defer to user's prefers-color-scheme on first paint.
337 const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
338 // layout: alleen een thema dat deze Klonkt kent (experimental/theme). Leeg
339 // is de gewone kop. Een onbekend id opslaan zou een site maken die naar
340 // niets verwijst, en die valt dan stil terug -- liever meteen leeg.
341 const layout = ThemeService.getLayout(f.layout) ? f.layout : null;
342
343 // accent: only accept colors from the curated ACCENTS list. Falls back to
344 // the orange default if the submitted value isn't recognised.
345 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
346
347 db.prepare(`
348 UPDATE sites SET
349 title = ?, description = ?, tagline = ?, language = ?,
350 palette = ?, accent = ?, theme_override = ?, layout = ?, profile_photo = ?,
351 profile_enabled = ?,
352 ap_aliases = ?,
353 is_public = ?, robots_index = ?, require_login_to_comment = ?,
354 enable_audio_player = ?,
355 approve_followers = ?,
356 feed_view_default = ?, feed_view_switch = ?, feed_alt_view = ?, reader_full_page = ?,
357 show_search = ?, show_archive_link = ?,
358 custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
359 updated_at = CURRENT_TIMESTAMP
360 WHERE id = ?
361 `).run(
362 (f.title || '').slice(0, 200),
363 (f.description || '').slice(0, 500),
364 (f.tagline || '').slice(0, 200),
365 f.language || 'nl',
366 f.palette || 'klonkt',
367 accent,
368 themeOverride,
369 layout,
370 f.profile_photo || null,
371 f.profile_enabled ? 1 : 0,
372 apAliasesJson,
373 f.is_public ? 1 : 0,
374 f.robots_index ? 1 : 0,
375 f.require_login_to_comment ? 1 : 0,
376 f.enable_audio_player ? 1 : 0,
377 f.approve_followers ? 1 : 0,
378 feedViewDef,
379 f.feed_view_switch ? 1 : 0,
380 feedAlt,
381 f.reader_full_page ? 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
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).
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
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
410 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
411});
412
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.
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
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.