import { type Application, type Request, type Response, type RequestHandler } from 'express'; import express from 'express'; import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'fs'; import { join, extname, basename } from 'path'; import { randomBytes } from 'crypto'; import type { ConfigManager } from '../config-manager.js'; import { logger } from '../logger.js'; export interface PublicBranding { appName: string; primaryColor: string; loginPageTitle: string; logoUrl: string | null; faviconUrl: string | null; footerText: string | null; } const DEFAULTS: PublicBranding = { appName: 'MAESTRO', primaryColor: '#2563eb', loginPageTitle: 'MAESTRO', logoUrl: null, faviconUrl: null, footerText: null, }; function pickString(obj: Record, key: string): string | null { const v = obj[key]; if (typeof v === 'string' && v.trim().length > 0) return v.trim(); return null; } export function resolveBranding(configManager: ConfigManager | undefined): PublicBranding { const raw = configManager?.getConfigForApi()?.config?.branding; const cfg: Record = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; const appName = pickString(cfg, 'appName') ?? DEFAULTS.appName; const primaryColor = pickString(cfg, 'primaryColor') ?? DEFAULTS.primaryColor; const loginPageTitle = pickString(cfg, 'loginPageTitle') ?? appName; const logoUrl = pickString(cfg, 'logoUrl'); const faviconUrl = pickString(cfg, 'faviconUrl'); const footerText = pickString(cfg, 'footerText'); return { appName, primaryColor, loginPageTitle, logoUrl, faviconUrl, footerText }; } // ── Upload handling ────────────────────────────────────────────────────────── type AssetKind = 'logo' | 'favicon'; const ALLOWED_KINDS: AssetKind[] = ['logo', 'favicon']; const ALLOWED_EXTENSIONS: Record = { logo: ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'], favicon: ['.svg', '.png', '.ico', '.webp'], }; const MAX_SIZE_BYTES: Record = { logo: 2 * 1024 * 1024, // 2 MB favicon: 256 * 1024, // 256 KB }; function sanitizeExt(filename: string): string { const ext = extname(filename).toLowerCase(); // Defensive: reject anything with path separators / null bytes if (/[\\/\0]/.test(ext)) return ''; // Extension must be in a safe set if (!/^\.[a-z0-9]{1,5}$/.test(ext)) return ''; return ext; } function removeExistingAsset(brandingDir: string, kind: AssetKind): void { if (!existsSync(brandingDir)) return; for (const name of readdirSync(brandingDir)) { if (name.startsWith(`${kind}-`)) { try { unlinkSync(join(brandingDir, name)); } catch (e) { logger.warn(`[branding] failed to remove old asset ${name}: ${e}`); } } } } function assetUrlFromConfig(configManager: ConfigManager | undefined, kind: AssetKind): string | null { const b = configManager?.getConfigForApi()?.config?.branding; if (!b || typeof b !== 'object') return null; const key = kind === 'logo' ? 'logoUrl' : 'faviconUrl'; const v = (b as Record)[key]; return typeof v === 'string' ? v : null; } function removeAssetByUrl(brandingDir: string, url: string | null): void { if (!url) return; if (!url.startsWith('/branding/')) return; const name = basename(url); // Defense in depth: basename strips any `..` components if (!name || name.includes('..')) return; const fullPath = join(brandingDir, name); if (existsSync(fullPath)) { try { unlinkSync(fullPath); } catch (e) { logger.warn(`[branding] failed to remove asset ${name}: ${e}`); } } } export interface MountBrandingOptions { /** Absolute or relative path where branding assets (logos, favicons) are stored. Created on demand. */ brandingDir: string; /** Admin-only middleware. When auth is disabled, pass a passthrough. */ adminGuard: RequestHandler; } export function mountBrandingApi( app: Application, configManager: ConfigManager | undefined, opts?: MountBrandingOptions, ): void { // Public GET — no auth required. UI fetches this at startup (even on login page). app.get('/api/branding', (_req: Request, res: Response) => { res.json(resolveBranding(configManager)); }); if (!opts) return; const { brandingDir, adminGuard } = opts; // Serve uploaded assets. Directory is created lazily if first write happens; // express.static handles the not-exists case by falling through to 404. app.use('/branding', express.static(brandingDir, { maxAge: '7d', fallthrough: true, })); // Upload endpoint: admin only. Body is JSON with base64 content // (same pattern as task attachment upload in local-tasks-api.ts). const uploadJson = express.json({ limit: '4mb' }); app.post('/api/branding/upload', uploadJson, adminGuard, (req: Request, res: Response) => { if (!configManager) { res.status(503).json({ ok: false, error: 'ConfigManager unavailable' }); return; } try { const { kind, filename, contentBase64 } = req.body ?? {}; if (!ALLOWED_KINDS.includes(kind)) { res.status(400).json({ ok: false, error: 'kind must be "logo" or "favicon"' }); return; } if (typeof filename !== 'string' || typeof contentBase64 !== 'string') { res.status(400).json({ ok: false, error: 'filename and contentBase64 are required strings' }); return; } const ext = sanitizeExt(filename); if (!ext || !ALLOWED_EXTENSIONS[kind as AssetKind].includes(ext)) { res.status(400).json({ ok: false, error: `extension must be one of ${ALLOWED_EXTENSIONS[kind as AssetKind].join(', ')}`, }); return; } let buf: Buffer; try { buf = Buffer.from(contentBase64, 'base64'); } catch { res.status(400).json({ ok: false, error: 'invalid base64 content' }); return; } if (buf.length === 0) { res.status(400).json({ ok: false, error: 'empty file' }); return; } if (buf.length > MAX_SIZE_BYTES[kind as AssetKind]) { res.status(413).json({ ok: false, error: `file too large (max ${MAX_SIZE_BYTES[kind as AssetKind]} bytes)`, }); return; } // Create branding directory on first upload if (!existsSync(brandingDir)) { mkdirSync(brandingDir, { recursive: true }); } // Clean up any previously stored asset of this kind so we don't // accumulate orphaned files when the admin re-uploads. removeExistingAsset(brandingDir, kind as AssetKind); // Hash-suffixed filename provides unique URL for cache busting const hash = randomBytes(6).toString('hex'); const storedName = `${kind}-${hash}${ext}`; writeFileSync(join(brandingDir, storedName), buf); const publicUrl = `/branding/${storedName}`; const configKey = kind === 'logo' ? 'logoUrl' : 'faviconUrl'; const result = configManager.updateConfig({ branding: { [configKey]: publicUrl } }); if (!result.ok) { // Rollback: delete the file we just wrote, otherwise the config and // filesystem disagree forever. try { unlinkSync(join(brandingDir, storedName)); } catch { /* best effort */ } res.status(500).json({ ok: false, error: 'Failed to persist config', detail: result }); return; } logger.info(`[branding] uploaded ${kind} -> ${publicUrl} (${buf.length} bytes)`); res.json({ ok: true, kind, url: publicUrl }); } catch (e) { logger.warn(`[branding] upload failed: ${e}`); res.status(500).json({ ok: false, error: String(e) }); } }); app.delete('/api/branding/upload', adminGuard, (req: Request, res: Response) => { if (!configManager) { res.status(503).json({ ok: false, error: 'ConfigManager unavailable' }); return; } try { const kind = req.query.kind; if (typeof kind !== 'string' || !ALLOWED_KINDS.includes(kind as AssetKind)) { res.status(400).json({ ok: false, error: 'query.kind must be "logo" or "favicon"' }); return; } const currentUrl = assetUrlFromConfig(configManager, kind as AssetKind); removeAssetByUrl(brandingDir, currentUrl); const configKey = kind === 'logo' ? 'logoUrl' : 'faviconUrl'; const result = configManager.updateConfig({ branding: { [configKey]: '' } }); if (!result.ok) { res.status(500).json({ ok: false, error: 'Failed to persist config', detail: result }); return; } logger.info(`[branding] cleared ${kind}`); res.json({ ok: true }); } catch (e) { logger.warn(`[branding] delete failed: ${e}`); res.status(500).json({ ok: false, error: String(e) }); } }); }