From eb7eeae1ac33bb7da856730887513f9260d75482 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 20:21:25 +0200 Subject: [PATCH] feat: per-domain branding editor in admin panel #332 --- app/(main)/admin/_tabs/branding.tsx | 353 ++++++++++++++++++++++++---- app/api/admin/branding/route.ts | 187 ++++++++++++--- 2 files changed, 467 insertions(+), 73 deletions(-) diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index b9426697..09bd2ce7 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -1,8 +1,14 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; +import { + BRANDING_OVERRIDE_KEYS, + parseDomainBranding, + type BrandingOverrideKey, + type DomainBrandingEntry, +} from '@/lib/admin/domain-branding'; interface ConfigEntry { value?: unknown; @@ -16,42 +22,65 @@ const IMAGE_FIELDS = [ { key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, { key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' }, { key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, -]; +] as const; const TEXT_FIELDS = [ { key: 'loginCompanyName', label: 'Company Name' }, { key: 'loginImprintUrl', label: 'Imprint URL' }, { key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' }, { key: 'loginWebsiteUrl', label: 'Company Website URL' }, -]; +] as const; const PWA_IMAGE_FIELDS = [ { key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' }, -]; +] as const; const PWA_TEXT_FIELDS = [ { key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' }, { key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' }, -]; +] as const; const PWA_COLOR_FIELDS = [ { key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' }, { key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' }, -]; +] as const; + +// Accepts exact hosts and one-level wildcards (e.g. *.example.com). +const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; +// Tighter rule for uploads: wildcards can only point to externally-hosted +// URLs, since we'd have no concrete subdomain to serve a file from. +const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; export function BrandingTab() { const [config, setConfig] = useState>({}); - const [edits, setEdits] = useState>({}); + const [edits, setEdits] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [selectedHost, setSelectedHost] = useState(null); + const [addingHost, setAddingHost] = useState(false); + const [newHostInput, setNewHostInput] = useState(''); + const [newHostError, setNewHostError] = useState(null); const fileInputRefs = useRef>({}); useEffect(() => { fetchConfig(); }, []); + const domainEntries = useMemo( + () => parseDomainBranding(config['domainBranding']?.value), + [config], + ); + + // Drop selection if the host disappeared from the config (e.g. concurrent edit). + useEffect(() => { + if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) { + setSelectedHost(null); + setEdits({}); + } + }, [domainEntries, selectedHost]); + async function fetchConfig() { setLoading(true); const res = await apiFetch('/api/admin/config'); @@ -59,29 +88,81 @@ export function BrandingTab() { setLoading(false); } + function selectedEntry(): DomainBrandingEntry | null { + if (!selectedHost) return null; + return domainEntries.find(e => e.host === selectedHost) ?? null; + } + function handleChange(key: string, value: string) { setEdits(prev => ({ ...prev, [key]: value })); setMessage(null); } function currentValue(key: string): string { - if (key in edits) return edits[key] as string; + if (key in edits) return edits[key]; + if (selectedHost) { + const entry = selectedEntry(); + return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? ''; + } return (config[key]?.value as string) ?? ''; } + function isOverriddenInScope(key: string): boolean { + if (selectedHost) { + const entry = selectedEntry(); + const v = entry?.[key as BrandingOverrideKey]; + return typeof v === 'string' && v.length > 0; + } + return config[key]?.source === 'admin'; + } + + const isUploadedFile = (key: string): boolean => { + const val = currentValue(key); + return val.startsWith('/api/admin/branding/'); + }; + + function buildUpdatedDomainBranding(merge: Record): DomainBrandingEntry[] { + if (!selectedHost) return domainEntries; + const next = domainEntries.slice(); + const idx = next.findIndex(e => e.host === selectedHost); + const base: DomainBrandingEntry = + idx === -1 ? { host: selectedHost } : { ...next[idx] }; + const writable = base as unknown as Record; + for (const [key, value] of Object.entries(merge)) { + if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue; + if (typeof value === 'string' && value.length > 0) { + writable[key] = value; + } else { + delete writable[key]; + } + } + if (idx === -1) next.push(base); + else next[idx] = base; + return next; + } + async function handleSave() { if (Object.keys(edits).length === 0) return; setSaving(true); setMessage(null); + const payload = selectedHost + ? { domainBranding: buildUpdatedDomainBranding(edits) } + : edits; + const res = await apiFetch('/api/admin/config', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(edits), + body: JSON.stringify(payload), }); if (res.ok) { - setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' }); + setMessage({ + type: 'success', + text: selectedHost + ? `Branding for ${selectedHost} updated. Changes visible on next page load.` + : 'Branding updated. Changes visible on next page load.', + }); setEdits({}); await fetchConfig(); } else { @@ -92,12 +173,20 @@ export function BrandingTab() { } async function handleUpload(slot: string, file: File) { + if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) { + setMessage({ + type: 'error', + text: 'Wildcard hosts cannot upload files. Enter a URL instead.', + }); + return; + } setUploading(slot); setMessage(null); const formData = new FormData(); formData.append('file', file); formData.append('slot', slot); + if (selectedHost) formData.append('host', selectedHost); const res = await apiFetch('/api/admin/branding', { method: 'POST', @@ -112,10 +201,9 @@ export function BrandingTab() { delete next[slot]; return next; }); - setConfig(prev => ({ - ...prev, - [slot]: { value: data.url, source: 'admin' }, - })); + // Refresh from server so domainBranding entries reflect the upload. + await fetchConfig(); + void data; } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Upload failed' }); @@ -126,10 +214,13 @@ export function BrandingTab() { async function handleDeleteUpload(slot: string) { setMessage(null); + const body: { slot: string; host?: string } = { slot }; + if (selectedHost) body.host = selectedHost; + const res = await apiFetch('/api/admin/branding', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slot }), + body: JSON.stringify(body), }); if (res.ok) { @@ -147,6 +238,25 @@ export function BrandingTab() { } async function handleRevert(key: string) { + if (selectedHost) { + // Domain scope: drop the field from the entry and PATCH the array. + const updated = buildUpdatedDomainBranding({ [key]: '' }); + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: updated }), + }); + if (res.ok) { + setEdits(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + await fetchConfig(); + } + return; + } + // Default scope: revert via DELETE /api/admin/config const res = await apiFetch('/api/admin/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, @@ -162,12 +272,71 @@ export function BrandingTab() { } } - const isUploadedFile = (key: string): boolean => { - const val = currentValue(key); - return val.startsWith('/api/admin/branding/'); - }; + async function handleAddDomain() { + const host = newHostInput.trim().toLowerCase().replace(/\.+$/, ''); + if (!host) { + setNewHostError('Enter a hostname'); + return; + } + if (!HOST_RE.test(host)) { + setNewHostError('Invalid hostname. Use foo.example.com or *.example.com'); + return; + } + if (domainEntries.some(e => e.host === host)) { + setNewHostError('A branding entry for this host already exists'); + return; + } + setNewHostError(null); + + const next: DomainBrandingEntry[] = [...domainEntries, { host }]; + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: next }), + }); + if (res.ok) { + setNewHostInput(''); + setAddingHost(false); + setSelectedHost(host); + setEdits({}); + await fetchConfig(); + } else { + const data = await res.json(); + setNewHostError(data.error || 'Failed to add domain'); + } + } + + async function handleDeleteDomain() { + if (!selectedHost) return; + if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) { + return; + } + const next = domainEntries.filter(e => e.host !== selectedHost); + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: next }), + }); + if (res.ok) { + setSelectedHost(null); + setEdits({}); + await fetchConfig(); + setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` }); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to remove domain' }); + } + } + + function handleScopeChange(host: string | null) { + if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return; + setSelectedHost(host); + setEdits({}); + setMessage(null); + } const hasEdits = Object.keys(edits).length > 0; + const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost); if (loading) { return
Loading...
; @@ -192,6 +361,102 @@ export function BrandingTab() { )} + {/* Scope picker */} +
+
+ +

Scope

+
+
+
+ + {domainEntries.map(entry => ( + + ))} + {!addingHost && ( + + )} +
+ {addingHost && ( +
+ { setNewHostInput(e.target.value); setNewHostError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }} + placeholder="mail.example.com or *.example.com" + className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + + + {newHostError && {newHostError}} +
+ )} + {selectedHost ? ( +
+

+ Editing overrides for {selectedHost}. + Unset fields fall back to the Default values. + {wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'} +

+ +
+ ) : ( +

+ Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname. +

+ )} +
+
+ {message && (
{message.text} @@ -209,9 +474,9 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( - {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + {isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} )}
@@ -220,7 +485,7 @@ export function BrandingTab() { type="text" value={currentValue(field.key)} onChange={(e) => handleChange(field.key, e.target.value)} - placeholder="Enter URL or upload a file" + placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'} className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> @@ -251,7 +516,7 @@ export function BrandingTab() { )} - {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + {isOverriddenInScope(field.key) && !isUploadedFile(field.key) && ( @@ -287,9 +552,9 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( - {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + {isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} )}
@@ -298,7 +563,7 @@ export function BrandingTab() { type="text" value={currentValue(field.key)} onChange={(e) => handleChange(field.key, e.target.value)} - placeholder="Enter URL or upload a file" + placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'} className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> @@ -329,7 +594,7 @@ export function BrandingTab() { )} - {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + {isOverriddenInScope(field.key) && !isUploadedFile(field.key) && ( @@ -355,8 +620,10 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( - admin + {isOverriddenInScope(field.key) && ( + + {selectedHost ? 'domain' : 'admin'} + )}
@@ -367,7 +634,7 @@ export function BrandingTab() { placeholder={field.placeholder} className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> - {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( @@ -381,8 +648,10 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( - admin + {isOverriddenInScope(field.key) && ( + + {selectedHost ? 'domain' : 'admin'} + )}
@@ -400,7 +669,7 @@ export function BrandingTab() { placeholder={field.defaultValue} className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> - {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( @@ -421,8 +690,10 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( - admin + {isOverriddenInScope(field.key) && ( + + {selectedHost ? 'domain' : 'admin'} + )}
@@ -433,7 +704,7 @@ export function BrandingTab() { placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'} className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> - {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index de9b4457..60dcead7 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -3,8 +3,13 @@ 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 } from 'node:fs/promises'; +import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; @@ -22,7 +27,7 @@ const ALLOWED_MIME_TYPES = new Set([ ]); /** Slots that correspond to branding config keys */ -const VALID_SLOTS = new Set([ +const VALID_SLOTS = new Set([ 'faviconUrl', 'pwaIconUrl', 'appLogoLightUrl', @@ -31,17 +36,85 @@ const VALID_SLOTS = new Set([ 'loginLogoDarkUrl', ]); +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 { @@ -52,15 +125,24 @@ export async function POST(request: NextRequest) { 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)) { + if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) { 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 }); } @@ -72,34 +154,51 @@ export async function POST(request: NextRequest) { ); } - // Determine extension from mime type - const extMap: Record = { - 'image/svg+xml': '.svg', - 'image/png': '.png', - 'image/jpeg': '.jpg', - 'image/webp': '.webp', - 'image/x-icon': '.ico', - 'image/vnd.microsoft.icon': '.ico', - }; - const ext = extMap[file.type] || '.png'; - const safeName = sanitizeFilename(`${slot}${ext}`); + 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); - // Ensure branding directory exists if (!existsSync(getBrandingDir())) { await mkdir(getBrandingDir(), { recursive: true }); } - // Write file to disk + // 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); - // Update config to point to the served URL const servedUrl = `/api/admin/branding/${safeName}`; await configManager.ensureLoaded(); - await configManager.setAdminConfig({ [slot]: servedUrl }); - await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip); + 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) { @@ -111,7 +210,11 @@ export async function POST(request: NextRequest) { /** * DELETE /api/admin/branding - Remove an uploaded branding file * - * Expects JSON body: { slot: string } + * 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 { @@ -119,28 +222,48 @@ export async function DELETE(request: NextRequest) { if ('error' in result) return result.error; const ip = getClientIP(request); - const { slot } = await request.json(); + 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)) { + if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) { return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 }); } - // Find and remove matching files for this slot - const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico']; + 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; - for (const ext of possibleExts) { - const filePath = path.join(getBrandingDir(), `${slot}${ext}`); - if (existsSync(filePath)) { - await unlink(filePath); - removed = true; + 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; + } } } - // Clear the config override so it falls back to default/env await configManager.ensureLoaded(); - await configManager.removeAdminOverride(slot); + 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, fileRemoved: removed }, ip); + await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip); return NextResponse.json({ success: true }); } catch (error) {