151 lines
4.8 KiB
TypeScript
151 lines
4.8 KiB
TypeScript
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';
|
|
|
|
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',
|
|
]);
|
|
|
|
/** Slots that correspond to branding config keys */
|
|
const VALID_SLOTS = new Set([
|
|
'faviconUrl',
|
|
'pwaIconUrl',
|
|
'appLogoLightUrl',
|
|
'appLogoDarkUrl',
|
|
'loginLogoLightUrl',
|
|
'loginLogoDarkUrl',
|
|
]);
|
|
|
|
function sanitizeFilename(name: string): string {
|
|
// Strip directory traversal, keep only safe chars
|
|
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
}
|
|
|
|
/**
|
|
* 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")
|
|
*/
|
|
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;
|
|
|
|
if (!file || !slot) {
|
|
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 },
|
|
);
|
|
}
|
|
|
|
// Determine extension from mime type
|
|
const extMap: 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',
|
|
};
|
|
const ext = extMap[file.type] || '.png';
|
|
const safeName = 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
|
|
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);
|
|
|
|
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 }
|
|
*/
|
|
export async function DELETE(request: NextRequest) {
|
|
try {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
|
|
const ip = getClientIP(request);
|
|
const { slot } = await request.json();
|
|
|
|
if (!slot || !VALID_SLOTS.has(slot)) {
|
|
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'];
|
|
let removed = false;
|
|
for (const ext of possibleExts) {
|
|
const filePath = path.join(getBrandingDir(), `${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);
|
|
|
|
await auditLog('branding_delete', { slot, 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 });
|
|
}
|
|
}
|