import { Router } from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { getDb } from '../config/database.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { getOAuthConfig } from '../config/oauth.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const router = Router(); const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/; // Validate that a URL uses a safe scheme (http/https only) function isSafeUrl(url) { try { const parsed = new URL(url); return parsed.protocol === 'https:' || parsed.protocol === 'http:'; } catch { return false; } } // Ensure uploads/branding directory exists const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding'); if (!fs.existsSync(brandingDir)) { fs.mkdirSync(brandingDir, { recursive: true }); } // Multer config for logo upload const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, brandingDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.png'; cb(null, `logo${ext}`); }, }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, // 5MB fileFilter: (req, file, cb) => { const allowed = /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i; const mimeAllowed = /^image\/(jpeg|png|gif|svg\+xml|webp|x-icon|vnd\.microsoft\.icon)$/; if (allowed.test(path.extname(file.originalname)) && mimeAllowed.test(file.mimetype)) { cb(null, true); } else { cb(new Error('Only image files are allowed')); } }, }); // Helper: get setting from DB async function getSetting(key) { const db = getDb(); const row = await db.get('SELECT value FROM settings WHERE key = ?', [key]); return row?.value || null; } // Helper: set setting in DB async function setSetting(key, value) { const db = getDb(); // Try update first, then insert if nothing was updated const result = await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]); if (result.changes === 0) { // Use INSERT with a dummy RETURNING to satisfy PG adapter, or just use exec-style await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]); } } // Helper: delete setting from DB async function deleteSetting(key) { const db = getDb(); await db.run('DELETE FROM settings WHERE key = ?', [key]); } // Helper: find current logo file on disk function findLogoFile() { if (!fs.existsSync(brandingDir)) return null; const files = fs.readdirSync(brandingDir); const logo = files.find(f => f.startsWith('logo.')); return logo ? path.join(brandingDir, logo) : null; } // GET /api/branding - Get branding settings (public) router.get('/', async (req, res) => { try { const appName = await getSetting('app_name'); const defaultTheme = await getSetting('default_theme'); const logoFile = findLogoFile(); const registrationMode = await getSetting('registration_mode'); const imprintUrl = await getSetting('imprint_url'); const privacyUrl = await getSetting('privacy_url'); // OAuth: expose whether OAuth is enabled + display name for login page let oauthEnabled = false; let oauthDisplayName = null; try { const oauthConfig = await getOAuthConfig(); if (oauthConfig) { oauthEnabled = true; oauthDisplayName = oauthConfig.displayName || 'SSO'; } } catch { /* not configured */ } res.json({ appName: appName || 'Redlight', hasLogo: !!logoFile, logoUrl: logoFile ? '/api/branding/logo' : null, defaultTheme: defaultTheme || null, registrationMode: registrationMode || 'open', imprintUrl: imprintUrl || null, privacyUrl: privacyUrl || null, oauthEnabled, oauthDisplayName, }); } catch (err) { log.branding.error('Get branding error:', err); res.status(500).json({ error: 'Could not load branding' }); } }); // GET /api/branding/logo - Serve logo file (public) router.get('/logo', (req, res) => { const logoFile = findLogoFile(); if (!logoFile) { return res.status(404).json({ error: 'No logo found' }); } // H5: serve SVG as attachment (Content-Disposition) to prevent in-browser script execution. // For non-SVG images, inline display is fine. const ext = path.extname(logoFile).toLowerCase(); if (ext === '.svg') { res.setHeader('Content-Type', 'image/svg+xml'); res.setHeader('Content-Disposition', 'attachment; filename="logo.svg"'); res.setHeader('X-Content-Type-Options', 'nosniff'); return res.sendFile(logoFile); } res.sendFile(logoFile); }); // POST /api/branding/logo - Upload logo (admin only) router.post('/logo', authenticateToken, requireAdmin, (req, res) => { upload.single('logo')(req, res, async (err) => { if (err) { if (err instanceof multer.MulterError) { return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 5MB)' : err.message }); } return res.status(400).json({ error: err.message }); } if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } // Remove old logo files that don't match the new extension const files = fs.readdirSync(brandingDir); for (const f of files) { if (f.startsWith('logo.') && f !== req.file.filename) { fs.unlinkSync(path.join(brandingDir, f)); } } res.json({ logoUrl: '/api/branding/logo', message: 'Logo uploaded', }); }); }); // DELETE /api/branding/logo - Remove logo (admin only) router.delete('/logo', authenticateToken, requireAdmin, async (req, res) => { try { const logoFile = findLogoFile(); if (logoFile) { fs.unlinkSync(logoFile); } res.json({ message: 'Logo removed' }); } catch (err) { log.branding.error('Delete logo error:', err); res.status(500).json({ error: 'Could not remove logo' }); } }); // PUT /api/branding/name - Update app name (admin only) router.put('/name', authenticateToken, requireAdmin, async (req, res) => { try { const { appName } = req.body; if (!appName || !appName.trim()) { return res.status(400).json({ error: 'App name is required' }); } if (appName.trim().length > 100) { return res.status(400).json({ error: 'App name must not exceed 100 characters' }); } await setSetting('app_name', appName.trim()); res.json({ appName: appName.trim() }); } catch (err) { log.branding.error('Update app name error:', err); res.status(500).json({ error: 'Could not update app name' }); } }); // PUT /api/branding/default-theme - Set default theme for unauthenticated pages (admin only) router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) => { try { const { defaultTheme } = req.body; if (!defaultTheme || !defaultTheme.trim()) { return res.status(400).json({ error: 'defaultTheme is required' }); } // Basic format validation for theme ID if (!SAFE_ID_RE.test(defaultTheme.trim())) { return res.status(400).json({ error: 'Invalid theme' }); } await setSetting('default_theme', defaultTheme.trim()); res.json({ defaultTheme: defaultTheme.trim() }); } catch (err) { log.branding.error('Update default theme error:', err); res.status(500).json({ error: 'Could not update default theme' }); } }); // PUT /api/branding/registration-mode - Set registration mode (admin only) router.put('/registration-mode', authenticateToken, requireAdmin, async (req, res) => { try { const { registrationMode } = req.body; if (!registrationMode || !['open', 'invite'].includes(registrationMode)) { return res.status(400).json({ error: 'registrationMode must be "open" or "invite"' }); } await setSetting('registration_mode', registrationMode); res.json({ registrationMode }); } catch (err) { log.branding.error('Update registration mode error:', err); res.status(500).json({ error: 'Could not update registration mode' }); } }); // PUT /api/branding/imprint-url - Set imprint URL (admin only) router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => { try { const { imprintUrl } = req.body; if (imprintUrl && imprintUrl.length > 500) { return res.status(400).json({ error: 'URL must not exceed 500 characters' }); } if (imprintUrl && imprintUrl.trim() && !isSafeUrl(imprintUrl.trim())) { return res.status(400).json({ error: 'URL must start with http:// or https://' }); } if (imprintUrl && imprintUrl.trim()) { await setSetting('imprint_url', imprintUrl.trim()); } else { await deleteSetting('imprint_url'); } res.json({ imprintUrl: imprintUrl?.trim() || null }); } catch (err) { log.branding.error('Update imprint URL error:', err); res.status(500).json({ error: 'Could not update imprint URL' }); } }); // PUT /api/branding/privacy-url - Set privacy policy URL (admin only) router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) => { try { const { privacyUrl } = req.body; if (privacyUrl && privacyUrl.length > 500) { return res.status(400).json({ error: 'URL must not exceed 500 characters' }); } if (privacyUrl && privacyUrl.trim() && !isSafeUrl(privacyUrl.trim())) { return res.status(400).json({ error: 'URL must start with http:// or https://' }); } if (privacyUrl && privacyUrl.trim()) { await setSetting('privacy_url', privacyUrl.trim()); } else { await deleteSetting('privacy_url'); } res.json({ privacyUrl: privacyUrl?.trim() || null }); } catch (err) { log.branding.error('Update privacy URL error:', err); res.status(500).json({ error: 'Could not update privacy URL' }); } }); export default router;