import { NextRequest, NextResponse } from 'next/server'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { configManager } from '@/lib/admin/config-manager'; import { getConfigDir } from '@/lib/admin/paths'; import { parseDomainBranding, type DomainBrandingEntry, type BrandingOverrideKey, } from '@/lib/admin/domain-branding'; import { logger } from '@/lib/logger'; import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; function getBrandingDir(): string { return path.join(getConfigDir(), 'branding'); } const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB const ALLOWED_MIME_TYPES = new Set([ 'image/svg+xml', 'image/png', 'image/jpeg', 'image/webp', 'image/x-icon', 'image/vnd.microsoft.icon', ]); type UploadSlot = BrandingOverrideKey; /** Slots that correspond to branding config keys */ const VALID_SLOTS = new Set([ 'faviconUrl', 'pwaIconUrl', 'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl', 'pwaScreenshotMobileUrl', 'pwaScreenshotDesktopUrl', ]); const EXT_BY_MIME: Record = { 'image/svg+xml': '.svg', 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/x-icon': '.ico', 'image/vnd.microsoft.icon': '.ico', }; const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico']; // Exact hostnames only (no wildcards): wildcards can't be uploaded against // because we'd need a real subdomain to serve the file from. const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; function sanitizeFilename(name: string): string { // Strip directory traversal, keep only safe chars return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_'); } function normalizeHost(raw: string): string { return raw.trim().toLowerCase().replace(/\.+$/, ''); } /** Filename used to store a per-host uploaded asset. */ function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string { return sanitizeFilename(`domain__${host}__${slot}${ext}`); } /** True if the file belongs to the given host+slot (any extension). */ function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean { const prefix = sanitizeFilename(`domain__${host}__${slot}.`); return filename.startsWith(prefix); } /** Merge a per-host update into the existing domainBranding array. */ function mergeDomainEntry( current: DomainBrandingEntry[], host: string, patch: Partial, ): DomainBrandingEntry[] { const next = current.slice(); const idx = next.findIndex(e => e.host === host); if (idx === -1) { next.push({ host, ...patch }); } else { next[idx] = { ...next[idx], ...patch }; } return next; } /** Remove keys from a host's entry. If the entry has nothing left besides * `host`, drop it entirely. */ function clearDomainKeys( current: DomainBrandingEntry[], host: string, keys: BrandingOverrideKey[], ): DomainBrandingEntry[] { const idx = current.findIndex(e => e.host === host); if (idx === -1) return current; const entry = { ...current[idx] }; for (const key of keys) delete (entry as Record)[key]; const next = current.slice(); if (Object.keys(entry).filter(k => k !== 'host').length === 0) { next.splice(idx, 1); } else { next[idx] = entry; } return next; } /** * POST /api/admin/branding - Upload a branding image file * * Expects multipart/form-data with: * - file: the image file * - slot: which branding field this is for (e.g. "faviconUrl") * - host (optional): when set, the upload is stored against the * per-domain entry for that hostname instead of the global default. */ export async function POST(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; const ip = getClientIP(request); const formData = await request.formData(); const file = formData.get('file') as File | null; const slot = formData.get('slot') as string | null; const rawHost = (formData.get('host') as string | null) ?? ''; if (!file || !slot) { return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 }); } if (!VALID_SLOTS.has(slot as UploadSlot)) { return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 }); } const host = rawHost ? normalizeHost(rawHost) : ''; if (host && !EXACT_HOST_RE.test(host)) { return NextResponse.json( { error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` }, { status: 400 }, ); } if (file.size > MAX_FILE_SIZE) { return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 }); } if (!ALLOWED_MIME_TYPES.has(file.type)) { return NextResponse.json( { error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` }, { status: 400 }, ); } const ext = EXT_BY_MIME[file.type] ?? '.png'; const safeName = host ? domainAssetName(host, slot as BrandingOverrideKey, ext) : sanitizeFilename(`${slot}${ext}`); const filePath = path.join(getBrandingDir(), safeName); if (!existsSync(getBrandingDir())) { await mkdir(getBrandingDir(), { recursive: true }); } // Strip any prior asset for the same slot but a different extension so // the directory doesn't accumulate orphan files on re-upload. const dir = getBrandingDir(); const allFiles = await readdir(dir).catch(() => [] as string[]); for (const f of allFiles) { if (f === safeName) continue; const isSame = host ? isDomainAssetFor(f, host, slot as BrandingOverrideKey) : POSSIBLE_EXTS.some(e => f === `${slot}${e}`); if (isSame) { try { await unlink(path.join(dir, f)); } catch { /* ignore */ } } } const buffer = Buffer.from(await file.arrayBuffer()); await writeFile(filePath, buffer); const servedUrl = `/api/admin/branding/${safeName}`; await configManager.ensureLoaded(); if (host) { const current = parseDomainBranding(configManager.get('domainBranding', [])); const next = mergeDomainEntry(current, host, { [slot]: servedUrl }); await configManager.setAdminConfig({ domainBranding: next }); } else { await configManager.setAdminConfig({ [slot]: servedUrl }); } await auditLog('branding_upload', { slot, host: host || undefined, filename: safeName, size: file.size, mimeType: file.type, }, ip); return NextResponse.json({ url: servedUrl, filename: safeName }); } catch (error) { logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } /** * DELETE /api/admin/branding - Remove an uploaded branding file * * Expects JSON body: { slot: string, host?: string } * * When `host` is provided, only the per-domain asset for that host+slot is * removed (and the override in `domainBranding[host][slot]` is cleared). * Otherwise the global asset and config override are removed. */ export async function DELETE(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; const ip = getClientIP(request); const body = await request.json().catch(() => ({})) as { slot?: string; host?: string }; const slot = body.slot; const rawHost = body.host ?? ''; if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) { return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 }); } const host = rawHost ? normalizeHost(rawHost) : ''; if (host && !EXACT_HOST_RE.test(host)) { return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 }); } const dir = getBrandingDir(); let removed = false; if (host) { const allFiles = await readdir(dir).catch(() => [] as string[]); for (const f of allFiles) { if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) { try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ } } } } else { for (const ext of POSSIBLE_EXTS) { const filePath = path.join(dir, `${slot}${ext}`); if (existsSync(filePath)) { await unlink(filePath); removed = true; } } } await configManager.ensureLoaded(); if (host) { const current = parseDomainBranding(configManager.get('domainBranding', [])); const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]); await configManager.setAdminConfig({ domainBranding: next }); } else { await configManager.removeAdminOverride(slot); } await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip); return NextResponse.json({ success: true }); } catch (error) { logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }