| [6351545] | 1 | /**
|
|---|
| [834bcc3] | 2 | * Admin: global settings.
|
|---|
| 3 | * - tenancy mode (Solo/Hub)
|
|---|
| 4 | * - hub branding (name/tagline/intro/hero of the generic hub home page)
|
|---|
| [6351545] | 5 | *
|
|---|
| [834bcc3] | 6 | * GET /admin/settings -> show current settings
|
|---|
| 7 | * POST /admin/settings -> save (god-only). Also accepts an uploaded
|
|---|
| 8 | * hero image (multipart); an upload wins over the
|
|---|
| 9 | * URL text field. Without an upload the URL field is leading.
|
|---|
| [6351545] | 10 | *
|
|---|
| [834bcc3] | 11 | * The hub page is generic (belonging to no user); this branding lives in
|
|---|
| 12 | * global settings, not in a site.
|
|---|
| [6351545] | 13 | */
|
|---|
| 14 |
|
|---|
| 15 | import express from 'express';
|
|---|
| [63edb0d] | 16 | import path from 'path';
|
|---|
| 17 | import fs from 'fs';
|
|---|
| 18 | import { fileURLToPath } from 'url';
|
|---|
| 19 | import multer from 'multer';
|
|---|
| 20 | import { v4 as uuid } from 'uuid';
|
|---|
| [6351545] | 21 | import { renderPage } from '../middleware/render.js';
|
|---|
| 22 | import { requireGod } from '../middleware/auth.js';
|
|---|
| [6e0249f] | 23 | import { getTenancy, setTenancy, getSetting, setSetting } from '../services/SettingsService.js';
|
|---|
| [5e61b17] | 24 | import { SUPPORTED } from '../services/i18n.js';
|
|---|
| [ca507ef] | 25 | import { mailerStatus, sendMail } from '../config/mailer.js';
|
|---|
| [029f047] | 26 | import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
|
|---|
| [8f6225c] | 27 | import { toWebp } from '../services/ImageWebpService.js';
|
|---|
| [6351545] | 28 |
|
|---|
| 29 | const router = express.Router();
|
|---|
| 30 |
|
|---|
| [834bcc3] | 31 | // Hero dark overlay: percentage 0-100 (0 = no overlay, 100 = fully black).
|
|---|
| 32 | // Default 45 = the old hard-coded value, so existing hubs don't change appearance.
|
|---|
| [63edb0d] | 33 | function clampOverlay(raw) {
|
|---|
| 34 | const v = parseInt(raw, 10);
|
|---|
| 35 | return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 45;
|
|---|
| 36 | }
|
|---|
| 37 |
|
|---|
| 38 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| [834bcc3] | 39 | // Hero uploads land in storage/media/hero → accessible as /media/hero/<file>
|
|---|
| 40 | // (the /media static handler serves storage/media). Same model as avatars.
|
|---|
| [63edb0d] | 41 | const HERO_DIR = path.resolve(
|
|---|
| 42 | process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero')
|
|---|
| 43 | );
|
|---|
| 44 | fs.mkdirSync(HERO_DIR, { recursive: true });
|
|---|
| 45 |
|
|---|
| [834bcc3] | 46 | // Only raster formats for upload. SVG is intentionally NOT allowed via upload
|
|---|
| 47 | // (raw SVG can contain scripts → stored-XSS when opened directly); an SVG hero
|
|---|
| 48 | // can still be set via the URL field (like the bundled demo placeholder).
|
|---|
| [63edb0d] | 49 | const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
|---|
| 50 | const MAX_HERO_BYTES = 5 * 1024 * 1024;
|
|---|
| 51 |
|
|---|
| 52 | const 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 |
|
|---|
| [6351545] | 70 | router.get('/', requireGod, (req, res) => {
|
|---|
| 71 | renderPage(req, res, 'pages/admin-settings', {
|
|---|
| 72 | pageTitle: 'Instellingen',
|
|---|
| 73 | bodyClass: 'on-admin',
|
|---|
| 74 | tenancy: getTenancy(),
|
|---|
| [6e0249f] | 75 | hubTitle: getSetting('hub_title') || '',
|
|---|
| 76 | hubTagline: getSetting('hub_tagline') || '',
|
|---|
| 77 | hubIntro: getSetting('hub_intro') || '',
|
|---|
| [4c5169f] | 78 | hubHeroImage: getSetting('hub_hero_image') || '',
|
|---|
| [63edb0d] | 79 | hubHeroOverlay: clampOverlay(getSetting('hub_hero_overlay')),
|
|---|
| [5e61b17] | 80 | defaultLang: getSetting('default_lang') || '',
|
|---|
| [1b4d5dd] | 81 | premium: entitlementStatus(),
|
|---|
| [ca507ef] | 82 | smtp: mailerStatus(),
|
|---|
| [da6fd02] | 83 | footerNewsletter: getSetting('footer_newsletter') === '1',
|
|---|
| [6351545] | 84 | success: req.query.success || null,
|
|---|
| [63edb0d] | 85 | error: req.query.error || null,
|
|---|
| [6351545] | 86 | });
|
|---|
| 87 | });
|
|---|
| 88 |
|
|---|
| 89 | router.post('/', requireGod, (req, res) => {
|
|---|
| [834bcc3] | 90 | // multer.single processes multipart (hub branding form). For a plain
|
|---|
| 91 | // urlencoded POST (tenancy form) multer does nothing and req.body stays intact.
|
|---|
| [63edb0d] | 92 | heroUpload.single('hub_hero_file')(req, res, (err) => {
|
|---|
| 93 | if (err) {
|
|---|
| 94 | return res.redirect('/admin/settings?error=' + encodeURIComponent(err.message));
|
|---|
| 95 | }
|
|---|
| 96 |
|
|---|
| 97 | if (typeof req.body.tenancy !== 'undefined') {
|
|---|
| [dd856db] | 98 | // Hub mode is removed → setTenancy only accepts solo | circle (coerces the rest).
|
|---|
| 99 | setTenancy(req.body.tenancy);
|
|---|
| [63edb0d] | 100 | }
|
|---|
| [5e61b17] | 101 | if (typeof req.body.default_lang !== 'undefined') {
|
|---|
| [834bcc3] | 102 | // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
|
|---|
| [5e61b17] | 103 | const dl = (req.body.default_lang || '').toString().toLowerCase();
|
|---|
| 104 | setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
|
|---|
| 105 | }
|
|---|
| [421c2d4] | 106 | if (typeof req.body.timezone !== 'undefined') {
|
|---|
| [834bcc3] | 107 | // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC).
|
|---|
| 108 | // Validate with Intl so a nonsense value never breaks date rendering.
|
|---|
| [421c2d4] | 109 | const tz = (req.body.timezone || '').toString().trim();
|
|---|
| 110 | let valid = '';
|
|---|
| 111 | if (tz) { try { Intl.DateTimeFormat('en-US', { timeZone: tz }); valid = tz; } catch { valid = ''; } }
|
|---|
| 112 | setSetting('timezone', valid);
|
|---|
| 113 | }
|
|---|
| [63edb0d] | 114 | if (typeof req.body.hub_title !== 'undefined') {
|
|---|
| 115 | setSetting('hub_title', (req.body.hub_title || '').toString().slice(0, 80).trim());
|
|---|
| 116 | }
|
|---|
| 117 | if (typeof req.body.hub_tagline !== 'undefined') {
|
|---|
| 118 | setSetting('hub_tagline', (req.body.hub_tagline || '').toString().slice(0, 120).trim());
|
|---|
| 119 | }
|
|---|
| 120 | if (typeof req.body.hub_intro !== 'undefined') {
|
|---|
| 121 | setSetting('hub_intro', (req.body.hub_intro || '').toString().slice(0, 400).trim());
|
|---|
| 122 | }
|
|---|
| 123 |
|
|---|
| [834bcc3] | 124 | // Hero: an uploaded image wins; otherwise the URL text field.
|
|---|
| [63edb0d] | 125 | if (req.file) {
|
|---|
| [8f6225c] | 126 | const newUrl = `/media/hero/${toWebp(req.file)}`;
|
|---|
| [834bcc3] | 127 | // Clean up a previously uploaded hero (only if it came from our hero dir).
|
|---|
| [63edb0d] | 128 | const old = getSetting('hub_hero_image') || '';
|
|---|
| 129 | if (old.startsWith('/media/hero/')) {
|
|---|
| 130 | try { fs.unlinkSync(path.join(HERO_DIR, path.basename(old))); } catch {}
|
|---|
| 131 | }
|
|---|
| 132 | setSetting('hub_hero_image', newUrl);
|
|---|
| 133 | } else if (typeof req.body.hub_hero_image !== 'undefined') {
|
|---|
| 134 | setSetting('hub_hero_image', (req.body.hub_hero_image || '').toString().slice(0, 300).trim());
|
|---|
| 135 | }
|
|---|
| 136 |
|
|---|
| 137 | if (typeof req.body.hub_hero_overlay !== 'undefined') {
|
|---|
| 138 | setSetting('hub_hero_overlay', String(clampOverlay(req.body.hub_hero_overlay)));
|
|---|
| 139 | }
|
|---|
| 140 |
|
|---|
| 141 | res.redirect('/admin/settings?success=' + encodeURIComponent('Opgeslagen'));
|
|---|
| 142 | });
|
|---|
| [09ecc5f] | 143 | });
|
|---|
| 144 |
|
|---|
| [ca507ef] | 145 | // ── SMTP / e-mail-instellingen ────────────────────────────────────
|
|---|
| 146 | router.post('/smtp', requireGod, (req, res) => {
|
|---|
| 147 | const b = req.body || {};
|
|---|
| 148 | if (b.clear === '1') {
|
|---|
| 149 | ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from'].forEach((k) => setSetting(k, ''));
|
|---|
| 150 | return res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen gewist'));
|
|---|
| 151 | }
|
|---|
| 152 | setSetting('smtp_host', (b.smtp_host || '').toString().trim());
|
|---|
| 153 | setSetting('smtp_port', (b.smtp_port || '').toString().trim());
|
|---|
| 154 | setSetting('smtp_user', (b.smtp_user || '').toString().trim());
|
|---|
| 155 | setSetting('smtp_from', (b.smtp_from || '').toString().trim());
|
|---|
| [834bcc3] | 156 | // Only overwrite the password if a new value was entered.
|
|---|
| [ca507ef] | 157 | const pass = (b.smtp_pass || '').toString();
|
|---|
| 158 | if (pass) setSetting('smtp_pass', pass);
|
|---|
| 159 | res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen opgeslagen'));
|
|---|
| 160 | });
|
|---|
| 161 |
|
|---|
| [834bcc3] | 162 | // Newsletter sign-up in the footer on/off.
|
|---|
| [da6fd02] | 163 | router.post('/footer', requireGod, (req, res) => {
|
|---|
| 164 | setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0');
|
|---|
| 165 | res.redirect('/admin/settings?success=' + encodeURIComponent('Footer-instelling opgeslagen'));
|
|---|
| 166 | });
|
|---|
| 167 |
|
|---|
| [834bcc3] | 168 | // Send a test email to a specified address (or the logged-in user).
|
|---|
| [ca507ef] | 169 | router.post('/smtp/test', requireGod, async (req, res) => {
|
|---|
| 170 | const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim();
|
|---|
| 171 | if (!to || to.indexOf('@') === -1) {
|
|---|
| 172 | return res.redirect('/admin/settings?error=' + encodeURIComponent('Geef een geldig test-e-mailadres op.'));
|
|---|
| 173 | }
|
|---|
| 174 | try {
|
|---|
| 175 | await sendMail({
|
|---|
| 176 | to,
|
|---|
| 177 | subject: 'Klonkt — SMTP-test',
|
|---|
| 178 | text: 'Gelukt! Je SMTP-instellingen werken. Dit is een testbericht van je Klonkt-site.',
|
|---|
| 179 | html: '<p>Gelukt! Je <strong>SMTP-instellingen werken</strong>. Dit is een testbericht van je Klonkt-site.</p>',
|
|---|
| 180 | });
|
|---|
| 181 | res.redirect('/admin/settings?success=' + encodeURIComponent('Testmail verstuurd naar ' + to));
|
|---|
| 182 | } catch (e) {
|
|---|
| 183 | res.redirect('/admin/settings?error=' + encodeURIComponent('Testmail mislukt: ' + (e.message || e)));
|
|---|
| 184 | }
|
|---|
| 185 | });
|
|---|
| 186 |
|
|---|
| [6351545] | 187 | export default router;
|
|---|