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

main
Last change on this file since d5b9d2a was 2248cd2, checked in by roboburr <roboburr@…>, 3 months ago

Google login configurable in-app: Admin → Settings card (no .env required)

config/google.js now reads Client ID/Secret from app_settings (env = fallback),
dynamically (no restart needed). Redirect URI derived from PUBLIC_BASE_URL.
New "Google login (listeners)" card shows the redirect URI to register + fields
for Client ID/Secret + a disconnect option. Self-hosters no longer need to
edit .env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 6.5 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 { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
25import { googleConfigured, redirectUri, currentClientId, clientSecretSet } from '../config/google.js';
26
27const router = express.Router();
28
29// Hero dark-overlay: percentage 0-100 (0 = geen overlay, 100 = volledig zwart).
30// Default 45 = de oude hardgecodeerde waarde, zodat bestaande hubs niet wijzigen.
31function clampOverlay(raw) {
32 const v = parseInt(raw, 10);
33 return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 45;
34}
35
36const __dirname = path.dirname(fileURLToPath(import.meta.url));
37// Hero-uploads landen in storage/media/hero → bereikbaar als /media/hero/<file>
38// (de /media static handler serveert storage/media). Zelfde model als avatars.
39const HERO_DIR = path.resolve(
40 process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero')
41);
42fs.mkdirSync(HERO_DIR, { recursive: true });
43
44// Alleen raster-formaten voor de upload. SVG mag bewust NIET via upload (raw
45// SVG kan script bevatten → opgeslagen-XSS bij direct openen); een SVG-hero kan
46// nog steeds via het URL-veld (zoals de meegeleverde demo-placeholder).
47const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
48const MAX_HERO_BYTES = 5 * 1024 * 1024;
49
50const heroUpload = multer({
51 storage: multer.diskStorage({
52 destination: (req, file, cb) => cb(null, HERO_DIR),
53 filename: (req, file, cb) => {
54 const ext = path.extname(file.originalname).toLowerCase();
55 cb(null, `${uuid()}${ext}`);
56 },
57 }),
58 limits: { fileSize: MAX_HERO_BYTES },
59 fileFilter: (req, file, cb) => {
60 const ext = path.extname(file.originalname).toLowerCase();
61 if (!ALLOWED_HERO_EXT.has(ext)) {
62 return cb(new Error('Hero-afbeelding moet jpg/png/webp/gif zijn'));
63 }
64 cb(null, true);
65 },
66});
67
68router.get('/', requireGod, (req, res) => {
69 renderPage(req, res, 'pages/admin-settings', {
70 pageTitle: 'Instellingen',
71 bodyClass: 'on-admin',
72 tenancy: getTenancy(),
73 hubTitle: getSetting('hub_title') || '',
74 hubTagline: getSetting('hub_tagline') || '',
75 hubIntro: getSetting('hub_intro') || '',
76 hubHeroImage: getSetting('hub_hero_image') || '',
77 hubHeroOverlay: clampOverlay(getSetting('hub_hero_overlay')),
78 premium: entitlementStatus(),
79 google: {
80 configured: googleConfigured(),
81 redirectUri: redirectUri(),
82 clientId: currentClientId(),
83 secretSet: clientSecretSet(),
84 },
85 success: req.query.success || null,
86 error: req.query.error || null,
87 });
88});
89
90router.post('/', requireGod, (req, res) => {
91 // multer.single verwerkt multipart (hub-branding form). Bij een gewone
92 // urlencoded POST (tenancy-form) doet multer niets en blijft req.body intact.
93 heroUpload.single('hub_hero_file')(req, res, (err) => {
94 if (err) {
95 return res.redirect('/admin/settings?error=' + encodeURIComponent(err.message));
96 }
97
98 if (typeof req.body.tenancy !== 'undefined') {
99 // Hub-modus is een premium-feature: alleen naar hub schakelen als premium
100 // ontgrendeld is (premium-laag uit = vrij; aan = Patreon vereist). Al-hub
101 // blijven mag altijd, zodat een instance nooit vastloopt.
102 if (req.body.tenancy === 'hub' && !premiumUnlocked() && getTenancy() !== 'hub') {
103 return res.redirect('/admin/settings?error=' + encodeURIComponent('Hub-modus is een premium-functie — koppel Patreon in Beheer → Instellingen.'));
104 }
105 setTenancy(req.body.tenancy); // valideert naar solo | hub | circle
106 }
107 if (typeof req.body.hub_title !== 'undefined') {
108 setSetting('hub_title', (req.body.hub_title || '').toString().slice(0, 80).trim());
109 }
110 if (typeof req.body.hub_tagline !== 'undefined') {
111 setSetting('hub_tagline', (req.body.hub_tagline || '').toString().slice(0, 120).trim());
112 }
113 if (typeof req.body.hub_intro !== 'undefined') {
114 setSetting('hub_intro', (req.body.hub_intro || '').toString().slice(0, 400).trim());
115 }
116
117 // Hero: een geüploade afbeelding wint; anders het URL-tekstveld.
118 if (req.file) {
119 const newUrl = `/media/hero/${req.file.filename}`;
120 // Ruim een vorige geüploade hero op (alleen als die uit onze hero-map kwam).
121 const old = getSetting('hub_hero_image') || '';
122 if (old.startsWith('/media/hero/')) {
123 try { fs.unlinkSync(path.join(HERO_DIR, path.basename(old))); } catch {}
124 }
125 setSetting('hub_hero_image', newUrl);
126 } else if (typeof req.body.hub_hero_image !== 'undefined') {
127 setSetting('hub_hero_image', (req.body.hub_hero_image || '').toString().slice(0, 300).trim());
128 }
129
130 if (typeof req.body.hub_hero_overlay !== 'undefined') {
131 setSetting('hub_hero_overlay', String(clampOverlay(req.body.hub_hero_overlay)));
132 }
133
134 res.redirect('/admin/settings?success=' + encodeURIComponent('Opgeslagen'));
135 });
136});
137
138// Google-login (luisteraars) configureren — Client ID + Secret in app_settings.
139// De redirect-URI leiden we af van PUBLIC_BASE_URL (zie config/google.js).
140router.post('/google', requireGod, (req, res) => {
141 if (req.body.clear === '1') {
142 setSetting('google_client_id', '');
143 setSetting('google_client_secret', '');
144 return res.redirect('/admin/settings?success=' + encodeURIComponent('Google-login losgekoppeld'));
145 }
146 setSetting('google_client_id', (req.body.google_client_id || '').toString().trim());
147 // Secret alleen overschrijven als er een nieuwe waarde is ingevoerd (leeg = laat staan).
148 const secret = (req.body.google_client_secret || '').toString().trim();
149 if (secret) setSetting('google_client_secret', secret);
150 res.redirect('/admin/settings?success=' + encodeURIComponent('Google-login opgeslagen'));
151});
152
153export default router;
Note: See TracBrowser for help on using the repository browser.