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

main
Last change on this file since bfa6fa1 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: 8.6 KB
RevLine 
[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
15import express from 'express';
[63edb0d]16import path from 'path';
17import fs from 'fs';
18import multer from 'multer';
19import { v4 as uuid } from 'uuid';
[6351545]20import { renderPage } from '../middleware/render.js';
21import { requireGod } from '../middleware/auth.js';
[6e0249f]22import { getTenancy, setTenancy, getSetting, setSetting } from '../services/SettingsService.js';
[5e61b17]23import { SUPPORTED } from '../services/i18n.js';
[ca507ef]24import { mailerStatus, sendMail } from '../config/mailer.js';
[029f047]25import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
[8f6225c]26import { toWebp } from '../services/ImageWebpService.js';
[e2c3d09]27import { mediaDir } from '../config/paths.js';
[6351545]28
29const 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]33function clampOverlay(raw) {
34 const v = parseInt(raw, 10);
35 return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 45;
36}
37
[834bcc3]38// Hero uploads land in storage/media/hero → accessible as /media/hero/<file>
39// (the /media static handler serves storage/media). Same model as avatars.
[e2c3d09]40const HERO_DIR = mediaDir('HERO_PATH', 'hero');
[63edb0d]41fs.mkdirSync(HERO_DIR, { recursive: true });
42
[834bcc3]43// Only raster formats for upload. SVG is intentionally NOT allowed via upload
44// (raw SVG can contain scripts → stored-XSS when opened directly); an SVG hero
45// can still be set via the URL field (like the bundled demo placeholder).
[63edb0d]46const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
47const MAX_HERO_BYTES = 5 * 1024 * 1024;
48
49const heroUpload = multer({
50 storage: multer.diskStorage({
51 destination: (req, file, cb) => cb(null, HERO_DIR),
52 filename: (req, file, cb) => {
53 const ext = path.extname(file.originalname).toLowerCase();
54 cb(null, `${uuid()}${ext}`);
55 },
56 }),
57 limits: { fileSize: MAX_HERO_BYTES },
58 fileFilter: (req, file, cb) => {
59 const ext = path.extname(file.originalname).toLowerCase();
60 if (!ALLOWED_HERO_EXT.has(ext)) {
61 return cb(new Error('Hero-afbeelding moet jpg/png/webp/gif zijn'));
62 }
63 cb(null, true);
64 },
65});
66
[6351545]67router.get('/', requireGod, (req, res) => {
68 renderPage(req, res, 'pages/admin-settings', {
[3487567]69 pageTitleKey: 'admin.t_settings',
[6351545]70 bodyClass: 'on-admin',
71 tenancy: getTenancy(),
[6e0249f]72 hubTitle: getSetting('hub_title') || '',
73 hubTagline: getSetting('hub_tagline') || '',
74 hubIntro: getSetting('hub_intro') || '',
[4c5169f]75 hubHeroImage: getSetting('hub_hero_image') || '',
[63edb0d]76 hubHeroOverlay: clampOverlay(getSetting('hub_hero_overlay')),
[5e61b17]77 defaultLang: getSetting('default_lang') || '',
[1b4d5dd]78 premium: entitlementStatus(),
[ca507ef]79 smtp: mailerStatus(),
[da6fd02]80 footerNewsletter: getSetting('footer_newsletter') === '1',
[283f618]81 apEnabledSetting: getSetting('ap_enabled', '1') !== '0',
[6351545]82 success: req.query.success || null,
[63edb0d]83 error: req.query.error || null,
[6351545]84 });
85});
86
87router.post('/', requireGod, (req, res) => {
[834bcc3]88 // multer.single processes multipart (hub branding form). For a plain
89 // urlencoded POST (tenancy form) multer does nothing and req.body stays intact.
[63edb0d]90 heroUpload.single('hub_hero_file')(req, res, (err) => {
91 if (err) {
92 return res.redirect('/admin/settings?error=' + encodeURIComponent(err.message));
93 }
94
95 if (typeof req.body.tenancy !== 'undefined') {
[dd856db]96 // Hub mode is removed → setTenancy only accepts solo | circle (coerces the rest).
97 setTenancy(req.body.tenancy);
[63edb0d]98 }
[5e61b17]99 if (typeof req.body.default_lang !== 'undefined') {
[834bcc3]100 // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
[5e61b17]101 const dl = (req.body.default_lang || '').toString().toLowerCase();
102 setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
103 }
[421c2d4]104 if (typeof req.body.timezone !== 'undefined') {
[834bcc3]105 // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC).
106 // Validate with Intl so a nonsense value never breaks date rendering.
[421c2d4]107 const tz = (req.body.timezone || '').toString().trim();
108 let valid = '';
109 if (tz) { try { Intl.DateTimeFormat('en-US', { timeZone: tz }); valid = tz; } catch { valid = ''; } }
110 setSetting('timezone', valid);
111 }
[63edb0d]112 if (typeof req.body.hub_title !== 'undefined') {
113 setSetting('hub_title', (req.body.hub_title || '').toString().slice(0, 80).trim());
114 }
115 if (typeof req.body.hub_tagline !== 'undefined') {
116 setSetting('hub_tagline', (req.body.hub_tagline || '').toString().slice(0, 120).trim());
117 }
118 if (typeof req.body.hub_intro !== 'undefined') {
119 setSetting('hub_intro', (req.body.hub_intro || '').toString().slice(0, 400).trim());
120 }
121
[834bcc3]122 // Hero: an uploaded image wins; otherwise the URL text field.
[63edb0d]123 if (req.file) {
[8f6225c]124 const newUrl = `/media/hero/${toWebp(req.file)}`;
[834bcc3]125 // Clean up a previously uploaded hero (only if it came from our hero dir).
[63edb0d]126 const old = getSetting('hub_hero_image') || '';
127 if (old.startsWith('/media/hero/')) {
128 try { fs.unlinkSync(path.join(HERO_DIR, path.basename(old))); } catch {}
129 }
130 setSetting('hub_hero_image', newUrl);
131 } else if (typeof req.body.hub_hero_image !== 'undefined') {
132 setSetting('hub_hero_image', (req.body.hub_hero_image || '').toString().slice(0, 300).trim());
133 }
134
135 if (typeof req.body.hub_hero_overlay !== 'undefined') {
136 setSetting('hub_hero_overlay', String(clampOverlay(req.body.hub_hero_overlay)));
137 }
138
139 res.redirect('/admin/settings?success=' + encodeURIComponent('Opgeslagen'));
140 });
[09ecc5f]141});
142
[ca507ef]143// ── SMTP / e-mail-instellingen ────────────────────────────────────
144router.post('/smtp', requireGod, (req, res) => {
145 const b = req.body || {};
146 if (b.clear === '1') {
147 ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from'].forEach((k) => setSetting(k, ''));
148 return res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen gewist'));
149 }
150 setSetting('smtp_host', (b.smtp_host || '').toString().trim());
151 setSetting('smtp_port', (b.smtp_port || '').toString().trim());
152 setSetting('smtp_user', (b.smtp_user || '').toString().trim());
153 setSetting('smtp_from', (b.smtp_from || '').toString().trim());
[834bcc3]154 // Only overwrite the password if a new value was entered.
[ca507ef]155 const pass = (b.smtp_pass || '').toString();
156 if (pass) setSetting('smtp_pass', pass);
157 res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen opgeslagen'));
158});
159
[834bcc3]160// Newsletter sign-up in the footer on/off.
[da6fd02]161router.post('/footer', requireGod, (req, res) => {
162 setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0');
163 res.redirect('/admin/settings?success=' + encodeURIComponent('Footer-instelling opgeslagen'));
164});
165
[eacd3d6]166// Site mode: Solo (ap off → no federation, no comments) or Circles (ap on).
167// Driven by a radio (mode=solo|cirkels); legacy ap_enabled checkbox still accepted.
[283f618]168router.post('/ap', requireGod, (req, res) => {
[eacd3d6]169 let enabled;
170 if (typeof req.body.mode !== 'undefined') enabled = req.body.mode === 'solo' ? '0' : '1';
171 else enabled = req.body.ap_enabled ? '1' : '0';
172 setSetting('ap_enabled', enabled);
173 res.redirect('/admin/settings?success=' + encodeURIComponent('Modus opgeslagen'));
[283f618]174});
175
[834bcc3]176// Send a test email to a specified address (or the logged-in user).
[ca507ef]177router.post('/smtp/test', requireGod, async (req, res) => {
178 const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim();
179 if (!to || to.indexOf('@') === -1) {
180 return res.redirect('/admin/settings?error=' + encodeURIComponent('Geef een geldig test-e-mailadres op.'));
181 }
182 try {
183 await sendMail({
184 to,
185 subject: 'Klonkt — SMTP-test',
186 text: 'Gelukt! Je SMTP-instellingen werken. Dit is een testbericht van je Klonkt-site.',
187 html: '<p>Gelukt! Je <strong>SMTP-instellingen werken</strong>. Dit is een testbericht van je Klonkt-site.</p>',
188 });
189 res.redirect('/admin/settings?success=' + encodeURIComponent('Testmail verstuurd naar ' + to));
190 } catch (e) {
191 res.redirect('/admin/settings?error=' + encodeURIComponent('Testmail mislukt: ' + (e.message || e)));
192 }
193});
194
[6351545]195export default router;
Note: See TracBrowser for help on using the repository browser.