feat: allow file uploads on the wizard branding step

This commit is contained in:
Linus Rath
2026-05-09 21:40:39 +02:00
parent 1dcdeeae86
commit 876ea370e4
5 changed files with 392 additions and 31 deletions
+7 -4
View File
@@ -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<string, string> = {
'.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 });
}
+8 -5
View File
@@ -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;
+167
View File
@@ -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<string, string> = {
'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/<filename> 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 });
}
}
+207 -22
View File
@@ -1010,17 +1010,23 @@ function LoggingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'con
// ─── Branding step ───────────────────────────────────────────────────────
type BrandingSlot =
| 'faviconUrl'
| 'appLogoLightUrl'
| 'appLogoDarkUrl'
| 'loginLogoLightUrl'
| 'loginLogoDarkUrl';
function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'config' | 'setConfig' | 'onNext' | 'onBack'>) {
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<StepProps, 'co
setSubmitting(false);
}
}
return (
<form onSubmit={handle} className="space-y-4">
<StepHeader title="Branding" subtitle="All fields optional. Skip any field to use defaults." />
<StepHeader
title="Branding"
subtitle="All fields optional. Upload a file or paste a URL — defaults are used for anything you skip."
/>
<Field label="Company / organization name">
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
</Field>
<Field label="Favicon URL" hint="SVG recommended. Absolute URL or path under /public.">
<Input value={config.faviconUrl} onChange={(v) => setConfig({ ...config, faviconUrl: v })} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Login logo (light)">
<Input value={config.loginLogoLightUrl} onChange={(v) => setConfig({ ...config, loginLogoLightUrl: v })} />
</Field>
<Field label="Login logo (dark)">
<Input value={config.loginLogoDarkUrl} onChange={(v) => setConfig({ ...config, loginLogoDarkUrl: v })} />
</Field>
<Field label="Sidebar logo (light)">
<Input value={config.appLogoLightUrl} onChange={(v) => setConfig({ ...config, appLogoLightUrl: v })} />
</Field>
<Field label="Sidebar logo (dark)">
<Input value={config.appLogoDarkUrl} onChange={(v) => setConfig({ ...config, appLogoDarkUrl: v })} />
</Field>
<div className="space-y-2">
<BrandingAsset
label="Favicon"
hint="Browser tab icon. SVG recommended."
slot="faviconUrl"
value={config.faviconUrl}
onChange={(v) => setConfig({ ...config, faviconUrl: v })}
/>
<BrandingAsset
label="Login logo (light mode)"
hint="Shown on the sign-in page, light backgrounds."
slot="loginLogoLightUrl"
value={config.loginLogoLightUrl}
onChange={(v) => setConfig({ ...config, loginLogoLightUrl: v })}
/>
<BrandingAsset
label="Login logo (dark mode)"
hint="Shown on the sign-in page, dark backgrounds."
slot="loginLogoDarkUrl"
value={config.loginLogoDarkUrl}
onChange={(v) => setConfig({ ...config, loginLogoDarkUrl: v })}
previewBg="dark"
/>
<BrandingAsset
label="Sidebar logo (light mode)"
hint="Shown after sign-in. Leave blank for none."
slot="appLogoLightUrl"
value={config.appLogoLightUrl}
onChange={(v) => setConfig({ ...config, appLogoLightUrl: v })}
/>
<BrandingAsset
label="Sidebar logo (dark mode)"
hint="Dark mode variant of the sidebar logo."
slot="appLogoDarkUrl"
value={config.appLogoDarkUrl}
onChange={(v) => setConfig({ ...config, appLogoDarkUrl: v })}
previewBg="dark"
/>
</div>
<Field label="Website URL">
<Input value={config.loginWebsiteUrl} onChange={(v) => setConfig({ ...config, loginWebsiteUrl: v })} type="url" />
</Field>
@@ -1083,6 +1117,157 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
);
}
/**
* One branding asset slot: shows a thumbnail preview if a value is set,
* a file picker (uploads to /api/setup/branding), and a URL field for
* operators who'd rather paste a link. Upload and URL are mutually
* compatible — the URL field always reflects the persisted value.
*/
function BrandingAsset({
label,
hint,
slot,
value,
onChange,
previewBg = 'light',
}: {
label: string;
hint?: string;
slot: BrandingSlot;
value: string;
onChange: (v: string) => void;
previewBg?: 'light' | 'dark';
}) {
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(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 (
<div className="rounded-lg border border-border bg-card/50 p-3">
<div className="flex items-center gap-3">
<label
className={previewClasses + (uploading ? ' opacity-50' : ' cursor-pointer hover:border-foreground/30')}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) void handleFile(f);
}}
>
<input
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/x-icon,image/vnd.microsoft.icon"
disabled={uploading}
className="sr-only"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
e.target.value = '';
}}
/>
{value ? (
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
) : (
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
)}
</label>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium truncate">{label}</div>
{value && (
<button
type="button"
onClick={clearAsset}
className="text-xs text-muted-foreground hover:text-destructive shrink-0"
>
Remove
</button>
)}
</div>
{hint && <p className="text-xs text-muted-foreground mt-0.5">{hint}</p>}
<div className="mt-1.5 flex items-center gap-2 text-xs">
{uploading ? (
<span className="text-muted-foreground">Uploading</span>
) : value ? (
<span className="text-muted-foreground truncate">
{value.startsWith('/api/') ? 'Uploaded file' : value}
</span>
) : (
<span className="text-muted-foreground">SVG, PNG, JPEG, WebP or ICO · max 2 MB</span>
)}
<button
type="button"
onClick={() => setShowUrlField((v) => !v)}
className="text-muted-foreground hover:text-foreground underline shrink-0"
>
{showUrlField ? 'Hide URL' : 'Use URL'}
</button>
</div>
</div>
</div>
{showUrlField && (
<div className="mt-3 pl-[4.75rem]">
<Input
value={value}
onChange={onChange}
placeholder="https://… or /branding/file.svg"
/>
</div>
)}
{uploadError && (
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
)}
</div>
);
}
// ─── Review / finish step ─────────────────────────────────────────────────
function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) {
+3
View File
@@ -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) {