maestro/src/bridge/branding-api.ts
2026-06-03 05:08:00 +00:00

245 lines
8.8 KiB
TypeScript

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<string, unknown>, 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<string, unknown> =
raw && typeof raw === 'object' && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
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<AssetKind, string[]> = {
logo: ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'],
favicon: ['.svg', '.png', '.ico', '.webp'],
};
const MAX_SIZE_BYTES: Record<AssetKind, number> = {
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<string, unknown>)[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) });
}
});
}