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

main
Last change on this file since 2dd1dc4 was e2c3d09, checked in by Robin <roboburr@…>, 6 weeks ago

Media-submappen volgen nu MEDIA_PATH

De submappen voor avatars, post-images, reply-media, hero en audio-covers hadden
elk hun eigen env-variabele met een fallback naar <app>/storage/media/<sub>.
Daardoor negeerden ze MEDIA_PATH: wie zijn data buiten de checkout zette kreeg
alsnog een storage/-map in de work-tree, en uploads landden naast de code. Een
opruimstap bij een volgende deploy kan die vervolgens weggooien.

Nu leiden ze allemaal af van MEDIA_PATH via een gedeelde helper. Een eigen
override per submap wint nog steeds, dus bestaande installaties merken niets.
Dit maakt de scheiding van gebruikersdata en programmadata mogelijk met drie
regels in .env in plaats van acht.

Changed files:
src/routes/account.js

  • AVATAR_DIR via mediaDir(); dode dirname en fileURLToPath-import weg

src/routes/posts.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-media.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-settings.js

  • HERO_DIR via mediaDir(); dode declaraties weg

src/routes/admin-playlists.js

  • COVER_DIR via mediaDir(); dode dirname weg

src/routes/admin-audio.js

  • COVER_DIR via mediaDir(); AUDIO_DIR ongewijzigd (eigen wortel)

src/routes/admin-sites.js

  • PHOTO_DIR via mediaDir(), deelt bewust de avatars-map

src/routes/activitypub.js

  • AP_MEDIA_DIR via mediaDir(); ongebruikte fileURLToPath-import weg

New file:
src/config/paths.js

  • MEDIA_ROOT afgeleid van MEDIA_PATH
  • mediaDir(envVar, sub) voor submappen, met behoud van per-map overrides

DATABASE_PATH en AUDIO_PATH zijn eigen wortels en bewust ongemoeid gelaten.
Geverifieerd: 356 tests groen, en een server met externe MEDIA_PATH maakt al
zijn mappen buiten de checkout aan zonder de work-tree te raken.

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

  • Property mode set to 100644
File size: 13.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';
28
29
30// Profile photos share the avatar directory with user avatars — same physical
31// folder, same URL prefix. Filenames are uuid-prefixed so site photos and
32// user avatars never collide.
33const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
34fs.mkdirSync(PHOTO_DIR, { recursive: true });
35
36const ALLOWED_PHOTO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
37const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
38const photoUpload = multer({
39 storage: multer.diskStorage({
40 destination: (req, file, cb) => cb(null, PHOTO_DIR),
41 filename: (req, file, cb) => {
42 const ext = path.extname(file.originalname || '').toLowerCase() || '.jpg';
43 cb(null, `site-${uuid()}${ext}`);
44 },
45 }),
46 limits: { fileSize: MAX_PHOTO_BYTES },
47 fileFilter: (req, file, cb) => {
48 const ext = path.extname(file.originalname || '').toLowerCase();
49 if (!ALLOWED_PHOTO_EXT.has(ext)) {
50 return cb(new Error('Alleen JPG/PNG/WebP/GIF toegestaan'));
51 }
52 cb(null, true);
53 },
54});
55
56/** Coerce req.body fields into the JSON profile_links array. */
57function buildProfileLinks(body) {
58 const platforms = body.profile_link_platform || [];
59 const urls = body.profile_link_url || [];
60 const arr = [];
61 const platformsArr = Array.isArray(platforms) ? platforms : [platforms];
62 const urlsArr = Array.isArray(urls) ? urls : [urls];
63 for (let i = 0; i < platformsArr.length; i++) {
64 const p = (platformsArr[i] || '').toString().trim();
65 const u = (urlsArr[i] || '').toString().trim();
66 if (!p || !u) continue;
67 if (!PLATFORMS[p]) continue;
68 if (!/^https?:\/\//i.test(u) && p !== 'email') continue;
69 if (p === 'email' && !/^mailto:|^[^\s@]+@[^\s@]+$/i.test(u)) continue;
70 arr.push({ platform: p, url: u });
71 }
72 return arr.length ? JSON.stringify(arr) : null;
73}
74
75const router = express.Router();
76
77// ==================== UPLOAD PROFILE PHOTO (JSON) ====================
78// POST /admin/sites/upload-photo → { ok: true, url: '/media/avatars/<filename>' }
79// Used by the admin-site-edit form's photo picker. The form itself still
80// holds the URL string in `profile_photo` — this endpoint just stores the
81// file and hands back a URL that the form can paste into the input field.
82router.post('/upload-photo', requireAuth, (req, res) => {
83 photoUpload.single('photo')(req, res, (err) => {
84 if (err) return res.status(400).json({ ok: false, error: err.message });
85 if (!req.file) return res.status(400).json({ ok: false, error: 'Geen bestand ontvangen' });
86 res.json({
87 ok: true,
88 url: `/media/avatars/${toWebp(req.file)}`,
89 size: req.file.size,
90 mime: req.file.mimetype,
91 });
92 });
93});
94
95const RESERVED_SITE_SLUGS = new Set([
96 'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
97 'account', 'sites', 'comments', 'posts', 'media', 'audio',
98 'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
99 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
100 'paid', 'push', 'guardian',
101]);
102
103function siteEditableFields() {
104 return {
105 title: '',
106 description: '',
107 tagline: '',
108 language: 'nl',
109 palette: 'klonkt',
110 accent: '#e8b04b',
111 profile_photo: '',
112 profile_enabled: 1,
113 profile_name: '',
114 profile_bio: '',
115 is_public: 1,
116 robots_index: 1,
117 require_login_to_comment: 1,
118 enable_audio_player: 1,
119 comments_moderation_mode: 'moderate',
120 feed_view_default: 'grid',
121 feed_view_switch: 1,
122 show_search: 1,
123 show_archive_link: 1,
124 title_template: '{title} — {site}',
125 twitter: '',
126 canonical: '',
127 google_verification: '',
128 bing_verification: '',
129 pinterest_verification: '',
130 yandex_verification: '',
131 custom_css: '',
132 custom_head_html: '',
133 custom_foot_html: '',
134 };
135}
136
137/** Valid user-id for owner assignment, or null if empty/unknown. */
138function validOwnerId(raw) {
139 const id = (raw || '').toString().trim();
140 if (!id) return null;
141 return db.prepare('SELECT 1 FROM users WHERE id = ?').get(id) ? id : null;
142}
143
144/** Grant a user admin rights on a site (idempotent upsert). */
145function grantSiteAdmin(siteId, userId) {
146 db.prepare(`
147 INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
148 ON CONFLICT(site_id, user_id) DO UPDATE SET role = 'admin'
149 `).run(siteId, userId);
150}
151
152/** Candidate owners for the owner selector field (god-only). */
153function listOwnerCandidates() {
154 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
155}
156
157// ==================== LIST ====================
158router.get('/', requireGod, (req, res) => {
159 const sites = db.prepare(`
160 SELECT s.id, s.slug, s.title, s.description, s.created_at,
161 s.is_public, s.robots_index, s.is_primary,
162 u.username AS owner_username,
163 (SELECT COUNT(*) FROM posts WHERE site_id = s.id) AS post_count
164 FROM sites s LEFT JOIN users u ON u.id = s.owner_id
165 ORDER BY s.is_primary DESC, s.created_at DESC
166 `).all();
167
168 renderPage(req, res, 'pages/admin-sites', {
169 pageTitleKey: 'admin.t_sites',
170 bodyClass: 'on-admin',
171 sites,
172 success: req.query.success || null,
173 error: req.query.error || null,
174 });
175});
176
177// ==================== NEW (form) ====================
178router.get('/new', requireGod, (req, res) => {
179 renderPage(req, res, 'pages/admin-site-edit', {
180 pageTitleKey: 'admin.t_newsite',
181 bodyClass: 'on-admin',
182 isNew: true,
183 // ?owner=<id> (from the users page: "give this user a Klonkt") is
184 // pre-selected; otherwise defaults to the creating god.
185 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
186 users: listOwnerCandidates(),
187 palettes: ThemeService.listPalettes(),
188 accents: ThemeService.listAccents(),
189 platforms: listPlatforms(),
190 parsedLinks: [],
191 error: null,
192 });
193});
194
195// ==================== CREATE ====================
196router.post('/create', requireGod, (req, res) => {
197 const slug = (req.body.slug || '').toString().toLowerCase().trim();
198 if (!/^[a-z0-9_-]{2,40}$/.test(slug)) {
199 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug: 2-40 chars, letters/numbers/underscore/dash'));
200 }
201 if (RESERVED_SITE_SLUGS.has(slug)) {
202 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('That slug is reserved'));
203 }
204 const existing = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
205 if (existing) {
206 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug already taken'));
207 }
208
209 const f = { ...siteEditableFields(), ...req.body };
210
211 // Owner: god may assign the site to a DIFFERENT user — this is the core of
212 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
213 // creating god themselves.
214 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
215
216 const siteId = uuid();
217 db.prepare(`
218 INSERT INTO sites (
219 id, slug, title, description, tagline, owner_id,
220 language, palette, accent, profile_photo,
221 is_public, robots_index, require_login_to_comment, enable_audio_player,
222 feed_view_default
223 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
224 `).run(
225 siteId, slug,
226 (f.title || slug).slice(0, 200),
227 (f.description || '').slice(0, 500),
228 (f.tagline || '').slice(0, 200),
229 ownerId,
230 f.language || 'nl',
231 f.palette || 'klonkt',
232 ThemeService.validateAccent(f.accent) || '#e8b04b',
233 f.profile_photo || null,
234 f.is_public ? 1 : 0,
235 f.robots_index ? 1 : 0,
236 f.require_login_to_comment ? 1 : 0,
237 (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
238 f.feed_view_default === 'timeline' ? 'timeline' : 'grid',
239 );
240
241 // The OWNER (not necessarily the creator) gets a site_members admin row → this
242 // lets them pass canAdminSite + requireSiteManager gates to manage their site.
243 grantSiteAdmin(siteId, ownerId);
244
245 res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
246});
247
248// ==================== EDIT (form) ====================
249router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
250 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
251 if (!site) return res.redirect('/admin/sites?error=Not+found');
252
253 let parsedLinks = [];
254 if (site.profile_links) {
255 try { parsedLinks = JSON.parse(site.profile_links) || []; } catch {}
256 }
257
258 renderPage(req, res, 'pages/admin-site-edit', {
259 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
260 bodyClass: 'on-admin',
261 isNew: false,
262 site,
263 users: listOwnerCandidates(),
264 palettes: ThemeService.listPalettes(),
265 accents: ThemeService.listAccents(),
266 platforms: listPlatforms(),
267 parsedLinks,
268 success: req.query.success || null,
269 error: req.query.error || null,
270 });
271});
272
273// ==================== SAVE ====================
274router.post('/:slug/save', requireSiteManagerBySlug, (req, res) => {
275 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
276 if (!site) return res.redirect('/admin/sites?error=Not+found');
277
278 const f = req.body;
279 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
280 const profileLinksJson = buildProfileLinks(f);
281
282 // theme_override: only accept the three legal values. Empty string means
283 // "Auto" — defer to user's prefers-color-scheme on first paint.
284 const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
285
286 // accent: only accept colors from the curated ACCENTS list. Falls back to
287 // the orange default if the submitted value isn't recognised.
288 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
289
290 db.prepare(`
291 UPDATE sites SET
292 title = ?, description = ?, tagline = ?, language = ?,
293 palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
294 profile_enabled = ?,
295 profile_links = ?,
296 is_public = ?, robots_index = ?, require_login_to_comment = ?,
297 enable_audio_player = ?,
298 feed_view_default = ?, feed_view_switch = ?,
299 show_search = ?, show_archive_link = ?,
300 custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
301 updated_at = CURRENT_TIMESTAMP
302 WHERE id = ?
303 `).run(
304 (f.title || '').slice(0, 200),
305 (f.description || '').slice(0, 500),
306 (f.tagline || '').slice(0, 200),
307 f.language || 'nl',
308 f.palette || 'klonkt',
309 accent,
310 themeOverride,
311 f.profile_photo || null,
312 f.profile_enabled ? 1 : 0,
313 profileLinksJson,
314 f.is_public ? 1 : 0,
315 f.robots_index ? 1 : 0,
316 f.require_login_to_comment ? 1 : 0,
317 f.enable_audio_player ? 1 : 0,
318 feedViewDef,
319 f.feed_view_switch ? 1 : 0,
320 f.show_search ? 1 : 0,
321 f.show_archive_link ? 1 : 0,
322 f.custom_css || null,
323 f.custom_head_html || null,
324 f.custom_foot_html || null,
325 site.id,
326 );
327
328 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
329 // change the owner (the field is not shown to non-god users either).
330 if (req.session.user.role === 'god') {
331 const newOwner = validOwnerId(req.body.owner_id);
332 if (newOwner) {
333 db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
334 grantSiteAdmin(site.id, newOwner);
335 }
336 }
337
338 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
339});
340
341// ==================== MAKE PRIMARY ====================
342// God chooses which site is the primary/main site (the label/company site in hub;
343// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
344router.post('/:slug/make-primary', requireGod, (req, res) => {
345 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
346 if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
347 db.transaction(() => {
348 db.prepare('UPDATE sites SET is_primary = 0').run();
349 db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
350 })();
351 res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
352});
353
354// ==================== DELETE ====================
355router.post('/:slug/delete', requireGod, (req, res) => {
356 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
357 if (!site) return res.redirect('/admin/sites?error=Not+found');
358
359 const postCount = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE site_id = ?').get(site.id).c;
360 if (postCount > 0) {
361 return res.redirect('/admin/sites?error=' + encodeURIComponent(`Cannot delete: site has ${postCount} post(s). Delete posts first.`));
362 }
363
364 // Clean up site_members and audio_tracks (no posts to worry about).
365 db.prepare('DELETE FROM site_members WHERE site_id = ?').run(site.id);
366 db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(site.id);
367 db.prepare('DELETE FROM sites WHERE id = ?').run(site.id);
368
369 res.redirect('/admin/sites?success=' + encodeURIComponent('Site deleted'));
370});
371
372export default router;
Note: See TracBrowser for help on using the repository browser.