245 lines
8.8 KiB
TypeScript
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) });
|
|
}
|
|
});
|
|
}
|