feat: allow file uploads on the wizard branding step
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user