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

main
Last change on this file since b27cde6 was 421c2d4, checked in by roboburr <roboburr@…>, 3 months ago

feat(instellingen): site timezone — all dates + scheduling in chosen zone

Admin → Settings gets a Timezone picker (IANA, validated via Intl; empty =
server/UTC). Applied to: server-side date display (formatDate/formatDateTime
receive timeZone), and post scheduling (datetime-local is now interpreted/shown
in the site's zone instead of browser-local — wall↔UTC via Intl offset).
'Scheduled for' shows the time in the site's zone. NL/EN/DE.

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

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