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

main
Last change on this file since 8ad1784 was dd856db, checked in by Robin Genis <roboburr@…>, 3 months ago

Remove hub mode (step 1/2): keystone coercion + drop hub UI

getTenancy() now only returns 'solo'|'circle' (legacy 'hub' -> 'solo'), so every
tenancy==='hub' branch is unreachable. Removed the Hub tenancy radio + hub-home
branding form from settings, and the dead hub premium gate. Solo + Circle paths
unchanged. Dead hub code (hub.js, artists.js, hub-home view, /user routing, etc.)
gets deleted in step 2.

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

  • Property mode set to 100644
File size: 8.1 KB
Line 
1/**
2 * Admin: global settings.
3 * - tenancy mode (Solo/Hub)
4 * - hub branding (name/tagline/intro/hero of the generic hub home page)
5 *
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.
10 *
11 * The hub page is generic (belonging to no user); this branding lives in
12 * global settings, not in a 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 { SUPPORTED } from '../services/i18n.js';
25import { mailerStatus, sendMail } from '../config/mailer.js';
26import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
27import { toWebp } from '../services/ImageWebpService.js';
28
29const router = express.Router();
30
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.
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 land in storage/media/hero → accessible as /media/hero/<file>
40// (the /media static handler serves storage/media). Same model as 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// 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).
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 defaultLang: getSetting('default_lang') || '',
81 premium: entitlementStatus(),
82 smtp: mailerStatus(),
83 footerNewsletter: getSetting('footer_newsletter') === '1',
84 success: req.query.success || null,
85 error: req.query.error || null,
86 });
87});
88
89router.post('/', requireGod, (req, res) => {
90 // multer.single processes multipart (hub branding form). For a plain
91 // urlencoded POST (tenancy form) multer does nothing and req.body stays intact.
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') {
98 // Hub mode is removed → setTenancy only accepts solo | circle (coerces the rest).
99 setTenancy(req.body.tenancy);
100 }
101 if (typeof req.body.default_lang !== 'undefined') {
102 // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
103 const dl = (req.body.default_lang || '').toString().toLowerCase();
104 setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
105 }
106 if (typeof req.body.timezone !== 'undefined') {
107 // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC).
108 // Validate with Intl so a nonsense value never breaks date rendering.
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 }
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
124 // Hero: an uploaded image wins; otherwise the URL text field.
125 if (req.file) {
126 const newUrl = `/media/hero/${toWebp(req.file)}`;
127 // Clean up a previously uploaded hero (only if it came from our hero dir).
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 });
143});
144
145// ── SMTP / e-mail-instellingen ────────────────────────────────────
146router.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());
156 // Only overwrite the password if a new value was entered.
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
162// Newsletter sign-up in the footer on/off.
163router.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
168// Send a test email to a specified address (or the logged-in user).
169router.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
187export default router;
Note: See TracBrowser for help on using the repository browser.