From 876ea370e4ca325928bff739375ab1ffa10f136e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 9 May 2026 21:40:39 +0200 Subject: [PATCH] feat: allow file uploads on the wizard branding step --- app/api/admin/branding/[filename]/route.ts | 11 +- app/api/admin/branding/route.ts | 13 +- app/api/setup/branding/route.ts | 167 +++++++++++++++ app/setup/page.tsx | 229 +++++++++++++++++++-- proxy.ts | 3 + 5 files changed, 392 insertions(+), 31 deletions(-) create mode 100644 app/api/setup/branding/route.ts diff --git a/app/api/admin/branding/[filename]/route.ts b/app/api/admin/branding/[filename]/route.ts index d4994c50..1e05bc64 100644 --- a/app/api/admin/branding/[filename]/route.ts +++ b/app/api/admin/branding/[filename]/route.ts @@ -1,8 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; +import { getConfigDir } from '@/lib/admin/paths'; -const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding'); +function getBrandingDir(): string { + return path.join(getConfigDir(), 'branding'); +} const MIME_TYPES: Record = { '.svg': 'image/svg+xml', @@ -38,11 +41,11 @@ export async function GET( return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }); } - const filePath = path.join(BRANDING_DIR, safe); + const filePath = path.join(getBrandingDir(), safe); - // Ensure resolved path is still within BRANDING_DIR + // Ensure resolved path is still within getBrandingDir() const resolved = path.resolve(filePath); - if (!resolved.startsWith(path.resolve(BRANDING_DIR))) { + if (!resolved.startsWith(path.resolve(getBrandingDir()))) { return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }); } diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index a538e565..9057bdc1 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -2,12 +2,15 @@ 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 { logger } from '@/lib/logger'; import { writeFile, unlink, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; -const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding'); +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', @@ -79,11 +82,11 @@ export async function POST(request: NextRequest) { }; const ext = extMap[file.type] || '.png'; const safeName = sanitizeFilename(`${slot}${ext}`); - const filePath = path.join(BRANDING_DIR, safeName); + const filePath = path.join(getBrandingDir(), safeName); // Ensure branding directory exists - if (!existsSync(BRANDING_DIR)) { - await mkdir(BRANDING_DIR, { recursive: true }); + if (!existsSync(getBrandingDir())) { + await mkdir(getBrandingDir(), { recursive: true }); } // Write file to disk @@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) { const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico']; let removed = false; for (const ext of possibleExts) { - const filePath = path.join(BRANDING_DIR, `${slot}${ext}`); + const filePath = path.join(getBrandingDir(), `${slot}${ext}`); if (existsSync(filePath)) { await unlink(filePath); removed = true; diff --git a/app/api/setup/branding/route.ts b/app/api/setup/branding/route.ts new file mode 100644 index 00000000..f8ddb2ce --- /dev/null +++ b/app/api/setup/branding/route.ts @@ -0,0 +1,167 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { writeFile, unlink, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest } from '@/lib/setup/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { getConfigDir, assertWritable } from '@/lib/admin/paths'; +import { logger } from '@/lib/logger'; + +export const dynamic = 'force-dynamic'; + +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', +]); + +const VALID_SLOTS = new Set([ + 'faviconUrl', + 'appLogoLightUrl', + 'appLogoDarkUrl', + 'loginLogoLightUrl', + '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', +}; + +function getBrandingDir(): string { + return path.join(getConfigDir(), 'branding'); +} + +function sanitizeFilename(name: string): string { + return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +/** + * POST /api/setup/branding — wizard branding upload. + * + * Multipart form fields: + * file — the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB) + * slot — which branding key (faviconUrl, loginLogoLightUrl, etc.) + * + * Mirrors /api/admin/branding but authenticates via the wizard cookie + * instead of admin session — admin auth doesn't exist yet during bootstrap. + * Files land in the same directory; the public read endpoint at + * /api/admin/branding/ serves both wizard- and admin-uploaded + * assets after setup. + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + if (!(await authenticateWizardRequest())) { + return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); + } + + try { + assertWritable('upload branding asset'); + + const formData = await request.formData(); + const file = formData.get('file'); + const slot = formData.get('slot'); + + if (!(file instanceof File) || typeof slot !== 'string') { + return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 }); + } + if (!VALID_SLOTS.has(slot)) { + return NextResponse.json({ error: `Invalid slot: ${slot}` }, { 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 = sanitizeFilename(`${slot}${ext}`); + + const dir = getBrandingDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + + // Remove any existing file for this slot with a different extension so + // the wizard doesn't leave orphan files behind on re-upload. + for (const otherExt of Object.values(EXT_BY_MIME)) { + if (otherExt === ext) continue; + const oldPath = path.join(dir, `${slot}${otherExt}`); + if (existsSync(oldPath)) { + try { await unlink(oldPath); } catch { /* ignore */ } + } + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const filePath = path.join(dir, safeName); + await writeFile(filePath, buffer); + + const servedUrl = `/api/admin/branding/${safeName}`; + await configManager.ensureLoaded(); + await configManager.setAdminConfig({ [slot]: servedUrl }); + + return NextResponse.json({ url: servedUrl, filename: safeName }); + } catch (error) { + logger.error('Wizard branding upload failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); + } +} + +/** + * DELETE /api/setup/branding — remove an uploaded asset and clear the + * config override so the slot falls back to the system default. + * + * Body: { slot: string } + */ +export async function DELETE(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + if (!(await authenticateWizardRequest())) { + return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); + } + + try { + assertWritable('remove branding asset'); + const { slot } = (await request.json()) as { slot?: string }; + if (!slot || !VALID_SLOTS.has(slot)) { + return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 }); + } + + const dir = getBrandingDir(); + for (const ext of Object.values(EXT_BY_MIME)) { + const filePath = path.join(dir, `${slot}${ext}`); + if (existsSync(filePath)) { + try { await unlink(filePath); } catch { /* ignore */ } + } + } + + await configManager.ensureLoaded(); + await configManager.removeAdminOverride(slot); + + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Wizard branding delete failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json({ error: 'Delete failed' }, { status: 500 }); + } +} diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 77c7da5c..1db6ebfd 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -1010,17 +1010,23 @@ function LoggingStep({ config, setConfig, onNext, onBack }: Pick) { const [submitting, setSubmitting] = useState(false); + async function handle(e: FormEvent) { e.preventDefault(); setSubmitting(true); try { - // Only send fields the operator actually filled in. Saving an empty - // string would create an admin override that shadows the system - // default — a blank "Login logo" field would suppress the default - // Bulwark logo on the login page, which is never what we want from - // the wizard. + // Only send fields with a value. Empty strings would create an admin + // override that shadows the system default and suppress the bundled + // Bulwark logo on the login page. const allFields = { faviconUrl: config.faviconUrl, appLogoLightUrl: config.appLogoLightUrl, @@ -1041,29 +1047,57 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick - + setConfig({ ...config, loginCompanyName: v })} /> - - setConfig({ ...config, faviconUrl: v })} /> - -
- - setConfig({ ...config, loginLogoLightUrl: v })} /> - - - setConfig({ ...config, loginLogoDarkUrl: v })} /> - - - setConfig({ ...config, appLogoLightUrl: v })} /> - - - setConfig({ ...config, appLogoDarkUrl: v })} /> - + +
+ setConfig({ ...config, faviconUrl: v })} + /> + setConfig({ ...config, loginLogoLightUrl: v })} + /> + setConfig({ ...config, loginLogoDarkUrl: v })} + previewBg="dark" + /> + setConfig({ ...config, appLogoLightUrl: v })} + /> + setConfig({ ...config, appLogoDarkUrl: v })} + previewBg="dark" + />
+ setConfig({ ...config, loginWebsiteUrl: v })} type="url" /> @@ -1083,6 +1117,157 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick void; + previewBg?: 'light' | 'dark'; +}) { + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [showUrlField, setShowUrlField] = useState(false); + const [dragOver, setDragOver] = useState(false); + + async function handleFile(file: File) { + setUploadError(null); + setUploading(true); + try { + const fd = new FormData(); + fd.append('file', file); + fd.append('slot', slot); + const res = await apiFetch('/api/setup/branding', { + method: 'POST', + body: fd, + }); + const data = await res.json(); + if (!res.ok) { + setUploadError(data?.error ?? `Upload failed (HTTP ${res.status})`); + return; + } + onChange(data.url); + } catch (e) { + setUploadError(humanError(e)); + } finally { + setUploading(false); + } + } + + async function clearAsset() { + setUploadError(null); + try { + await apiFetch('/api/setup/branding', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slot }), + }).catch(() => null); + } finally { + onChange(''); + } + } + + const previewClasses = + 'shrink-0 w-16 h-16 rounded-md border border-border flex items-center justify-center overflow-hidden transition-colors ' + + (previewBg === 'dark' ? 'bg-zinc-900' : 'bg-muted/40') + + (dragOver ? ' ring-2 ring-primary border-primary' : ''); + + return ( +
+
+ +
+
+
{label}
+ {value && ( + + )} +
+ {hint &&

{hint}

} +
+ {uploading ? ( + Uploading… + ) : value ? ( + + {value.startsWith('/api/') ? 'Uploaded file' : value} + + ) : ( + SVG, PNG, JPEG, WebP or ICO · max 2 MB + )} + +
+
+
+ + {showUrlField && ( +
+ +
+ )} + + {uploadError && ( +

{uploadError}

+ )} +
+ ); +} + // ─── Review / finish step ───────────────────────────────────────────────── function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) { diff --git a/proxy.ts b/proxy.ts index f4b69f68..92a29aa0 100644 --- a/proxy.ts +++ b/proxy.ts @@ -37,6 +37,9 @@ export async function proxy(request: NextRequest) { pathname === "/api/health" || pathname.startsWith("/_next/") || pathname.startsWith("/branding/") || + // Public read endpoint — serves wizard-uploaded branding assets so + // image previews work during the wizard. No auth on the GET route. + pathname.startsWith("/api/admin/branding/") || /\.[^/]+$/.test(pathname); if (!allowed) {