source: Klonkt/src/routes/admin-settings.js@ 374fbe9

main
Last change on this file since 374fbe9 was 8f6225c, checked in by roboburr <roboburr@…>, 3 months ago

feat(images): new uploads automatically converted to WebP

Shared ImageWebpService.toWebp() converts uploaded images via cwebp (q82)
and removes the original; cwebp absent/error → original preserved (graceful).
Wired up in all upload routes: post images/cover, avatar, site photo, hub
hero, audio cover. GIF stays GIF, already-webp skipped.

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

  • Property mode set to 100644
File size: 9.3 KB
Line 
1/**
2 * Admin: globale instellingen.
3 * - tenancy-modus (Solo/Hub)
4 * - hub-branding (naam/tagline/intro/hero van de generieke hub-hoofdpagina)
5 *
6 * GET /admin/settings -> toon huidige instellingen
7 * POST /admin/settings -> sla op (god-only). Accepteert nu ook een geuploade
8 * hero-afbeelding (multipart); een upload wint van het
9 * URL-tekstveld. Zonder upload blijft het URL-veld leidend.
10 *
11 * De hub-pagina is generiek (van geen enkele user); deze branding leeft in
12 * globale settings, niet in een site.
13 */
14
15import express from 'express';
16import path from 'path';
17import fs from 'fs';
18import { fileURLToPath } from 'url';
19import multer from 'multer';
20import { v4 as uuid } from 'uuid';
21import { renderPage } from '../middleware/render.js';
22import { requireGod } from '../middleware/auth.js';
23import { getTenancy, setTenancy, getSetting, setSetting } from '../services/SettingsService.js';
24import { mailerStatus, sendMail } from '../config/mailer.js';
25import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
26import { googleConfigured, redirectUri, currentClientId, clientSecretSet } from '../config/google.js';
27import { toWebp } from '../services/ImageWebpService.js';
28
29const router = express.Router();
30
31// Hero dark-overlay: percentage 0-100 (0 = geen overlay, 100 = volledig zwart).
32// Default 45 = de oude hardgecodeerde waarde, zodat bestaande hubs niet wijzigen.
33function clampOverlay(raw) {
34 const v = parseInt(raw, 10);
35 return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 45;
36}
37
38const __dirname = path.dirname(fileURLToPath(import.meta.url));
39// Hero-uploads landen in storage/media/hero → bereikbaar als /media/hero/<file>
40// (de /media static handler serveert storage/media). Zelfde model als avatars.
41const HERO_DIR = path.resolve(
42 process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero')
43);
44fs.mkdirSync(HERO_DIR, { recursive: true });
45
46// Alleen raster-formaten voor de upload. SVG mag bewust NIET via upload (raw
47// SVG kan script bevatten → opgeslagen-XSS bij direct openen); een SVG-hero kan
48// nog steeds via het URL-veld (zoals de meegeleverde demo-placeholder).
49const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
50const MAX_HERO_BYTES = 5 * 1024 * 1024;
51
52const heroUpload = multer({
53 storage: multer.diskStorage({
54 destination: (req, file, cb) => cb(null, HERO_DIR),
55 filename: (req, file, cb) => {
56 const ext = path.extname(file.originalname).toLowerCase();
57 cb(null, `${uuid()}${ext}`);
58 },
59 }),
60 limits: { fileSize: MAX_HERO_BYTES },
61 fileFilter: (req, file, cb) => {
62 const ext = path.extname(file.originalname).toLowerCase();
63 if (!ALLOWED_HERO_EXT.has(ext)) {
64 return cb(new Error('Hero-afbeelding moet jpg/png/webp/gif zijn'));
65 }
66 cb(null, true);
67 },
68});
69
70router.get('/', requireGod, (req, res) => {
71 renderPage(req, res, 'pages/admin-settings', {
72 pageTitle: 'Instellingen',
73 bodyClass: 'on-admin',
74 tenancy: getTenancy(),
75 hubTitle: getSetting('hub_title') || '',
76 hubTagline: getSetting('hub_tagline') || '',
77 hubIntro: getSetting('hub_intro') || '',
78 hubHeroImage: getSetting('hub_hero_image') || '',
79 hubHeroOverlay: clampOverlay(getSetting('hub_hero_overlay')),
80 premium: entitlementStatus(),
81 google: {
82 configured: googleConfigured(),
83 redirectUri: redirectUri(),
84 clientId: currentClientId(),
85 secretSet: clientSecretSet(),
86 },
87 smtp: mailerStatus(),
88 footerNewsletter: getSetting('footer_newsletter') === '1',
89 success: req.query.success || null,
90 error: req.query.error || null,
91 });
92});
93
94router.post('/', requireGod, (req, res) => {
95 // multer.single verwerkt multipart (hub-branding form). Bij een gewone
96 // urlencoded POST (tenancy-form) doet multer niets en blijft req.body intact.
97 heroUpload.single('hub_hero_file')(req, res, (err) => {
98 if (err) {
99 return res.redirect('/admin/settings?error=' + encodeURIComponent(err.message));
100 }
101
102 if (typeof req.body.tenancy !== 'undefined') {
103 // Hub-modus is een premium-feature: alleen naar hub schakelen als premium
104 // ontgrendeld is (premium-laag uit = vrij; aan = Patreon vereist). Al-hub
105 // blijven mag altijd, zodat een instance nooit vastloopt.
106 if (req.body.tenancy === 'hub' && !premiumUnlocked() && getTenancy() !== 'hub') {
107 return res.redirect('/admin/settings?error=' + encodeURIComponent('Hub-modus is een premium-functie — koppel Patreon in Beheer → Instellingen.'));
108 }
109 setTenancy(req.body.tenancy); // valideert naar solo | hub | circle
110 }
111 if (typeof req.body.hub_title !== 'undefined') {
112 setSetting('hub_title', (req.body.hub_title || '').toString().slice(0, 80).trim());
113 }
114 if (typeof req.body.hub_tagline !== 'undefined') {
115 setSetting('hub_tagline', (req.body.hub_tagline || '').toString().slice(0, 120).trim());
116 }
117 if (typeof req.body.hub_intro !== 'undefined') {
118 setSetting('hub_intro', (req.body.hub_intro || '').toString().slice(0, 400).trim());
119 }
120
121 // Hero: een geüploade afbeelding wint; anders het URL-tekstveld.
122 if (req.file) {
123 const newUrl = `/media/hero/${toWebp(req.file)}`;
124 // Ruim een vorige geüploade hero op (alleen als die uit onze hero-map kwam).
125 const old = getSetting('hub_hero_image') || '';
126 if (old.startsWith('/media/hero/')) {
127 try { fs.unlinkSync(path.join(HERO_DIR, path.basename(old))); } catch {}
128 }
129 setSetting('hub_hero_image', newUrl);
130 } else if (typeof req.body.hub_hero_image !== 'undefined') {
131 setSetting('hub_hero_image', (req.body.hub_hero_image || '').toString().slice(0, 300).trim());
132 }
133
134 if (typeof req.body.hub_hero_overlay !== 'undefined') {
135 setSetting('hub_hero_overlay', String(clampOverlay(req.body.hub_hero_overlay)));
136 }
137
138 res.redirect('/admin/settings?success=' + encodeURIComponent('Opgeslagen'));
139 });
140});
141
142// Google-login op een eigen Beheer-pagina (los van de algemene instellingen).
143router.get('/google', requireGod, (req, res) => {
144 renderPage(req, res, 'pages/admin-google', {
145 pageTitle: 'Google-login',
146 bodyClass: 'on-admin',
147 google: {
148 configured: googleConfigured(),
149 redirectUri: redirectUri(),
150 clientId: currentClientId(),
151 secretSet: clientSecretSet(),
152 },
153 success: req.query.success || null,
154 error: req.query.error || null,
155 });
156});
157
158// Google-login (luisteraars) configureren — Client ID + Secret in app_settings.
159// De redirect-URI leiden we af van PUBLIC_BASE_URL (zie config/google.js).
160router.post('/google', requireGod, (req, res) => {
161 if (req.body.clear === '1') {
162 setSetting('google_client_id', '');
163 setSetting('google_client_secret', '');
164 return res.redirect('/admin/settings?success=' + encodeURIComponent('Google-login losgekoppeld'));
165 }
166 setSetting('google_client_id', (req.body.google_client_id || '').toString().trim());
167 // Secret alleen overschrijven als er een nieuwe waarde is ingevoerd (leeg = laat staan).
168 const secret = (req.body.google_client_secret || '').toString().trim();
169 if (secret) setSetting('google_client_secret', secret);
170 res.redirect('/admin/settings?success=' + encodeURIComponent('Google-login opgeslagen'));
171});
172
173// ── SMTP / e-mail-instellingen ────────────────────────────────────
174router.post('/smtp', requireGod, (req, res) => {
175 const b = req.body || {};
176 if (b.clear === '1') {
177 ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from'].forEach((k) => setSetting(k, ''));
178 return res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen gewist'));
179 }
180 setSetting('smtp_host', (b.smtp_host || '').toString().trim());
181 setSetting('smtp_port', (b.smtp_port || '').toString().trim());
182 setSetting('smtp_user', (b.smtp_user || '').toString().trim());
183 setSetting('smtp_from', (b.smtp_from || '').toString().trim());
184 // Wachtwoord alleen overschrijven als er een nieuwe waarde is ingevoerd.
185 const pass = (b.smtp_pass || '').toString();
186 if (pass) setSetting('smtp_pass', pass);
187 res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen opgeslagen'));
188});
189
190// Nieuwsbrief-aanmelding in de footer aan/uit.
191router.post('/footer', requireGod, (req, res) => {
192 setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0');
193 res.redirect('/admin/settings?success=' + encodeURIComponent('Footer-instelling opgeslagen'));
194});
195
196// Testmail sturen naar een opgegeven adres (of de ingelogde gebruiker).
197router.post('/smtp/test', requireGod, async (req, res) => {
198 const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim();
199 if (!to || to.indexOf('@') === -1) {
200 return res.redirect('/admin/settings?error=' + encodeURIComponent('Geef een geldig test-e-mailadres op.'));
201 }
202 try {
203 await sendMail({
204 to,
205 subject: 'Klonkt — SMTP-test',
206 text: 'Gelukt! Je SMTP-instellingen werken. Dit is een testbericht van je Klonkt-site.',
207 html: '<p>Gelukt! Je <strong>SMTP-instellingen werken</strong>. Dit is een testbericht van je Klonkt-site.</p>',
208 });
209 res.redirect('/admin/settings?success=' + encodeURIComponent('Testmail verstuurd naar ' + to));
210 } catch (e) {
211 res.redirect('/admin/settings?error=' + encodeURIComponent('Testmail mislukt: ' + (e.message || e)));
212 }
213});
214
215export default router;
Note: See TracBrowser for help on using the repository browser.