Files
personal-dashboard/src/app/api/uploads/logo/route.ts
T
Claude a4051ae132 Initial commit: Personal Dashboard
Next.js 16 dashboard with configurable widgets (favorites, notes, calendar,
clock, calculator, search, domain-check), multi-tab support, user auth,
dark mode, and Docker deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:02:05 +02:00

177 lines
4.3 KiB
TypeScript

import { randomBytes } from "crypto";
import { mkdir, unlink, writeFile } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const MAX_LOGO_SIZE_BYTES = 2 * 1024 * 1024;
const LOGO_UPLOAD_DIR = process.env.LOGO_UPLOAD_DIR ?? "/data/uploads/logos";
type ImageType = {
extension: "png" | "jpg" | "webp";
contentType: "image/png" | "image/jpeg" | "image/webp";
};
function detectImageType(buffer: Buffer): ImageType | null {
const isPng =
buffer.length >= 8 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a;
if (isPng) {
return {
extension: "png",
contentType: "image/png"
};
}
const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
if (isJpeg) {
return {
extension: "jpg",
contentType: "image/jpeg"
};
}
const isWebp =
buffer.length >= 12 &&
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
buffer.subarray(8, 12).toString("ascii") === "WEBP";
if (isWebp) {
return {
extension: "webp",
contentType: "image/webp"
};
}
return null;
}
function getUploadedLogoFilename(logoUrl: string | null): string | null {
if (!logoUrl) {
return null;
}
const prefix = "/api/uploads/logo/";
if (!logoUrl.startsWith(prefix)) {
return null;
}
const filename = logoUrl.slice(prefix.length);
if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
return null;
}
return filename;
}
async function deleteOldUploadedLogo(logoUrl: string | null, newFilename: string): Promise<void> {
const oldFilename = getUploadedLogoFilename(logoUrl);
if (!oldFilename || oldFilename === newFilename) {
return;
}
const oldPath = path.join(LOGO_UPLOAD_DIR, oldFilename);
try {
await unlink(oldPath);
} catch {
// Altes Logo muss nicht existieren. Der Upload soll dadurch nicht fehlschlagen.
}
}
export async function POST(request: Request) {
try {
const user = await requireCurrentUser();
const formData = await request.formData();
const uploadedFile = formData.get("logo");
if (!(uploadedFile instanceof File)) {
return NextResponse.json({ error: "Keine Logo-Datei erhalten." }, { status: 400 });
}
if (uploadedFile.size <= 0) {
return NextResponse.json({ error: "Die Datei ist leer." }, { status: 400 });
}
if (uploadedFile.size > MAX_LOGO_SIZE_BYTES) {
return NextResponse.json({ error: "Das Logo darf maximal 2 MB groß sein." }, { status: 400 });
}
const arrayBuffer = await uploadedFile.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const imageType = detectImageType(buffer);
if (!imageType) {
return NextResponse.json(
{
error: "Ungültiges Dateiformat. Erlaubt sind PNG, JPG und WebP."
},
{ status: 400 }
);
}
const existingSettings = await prisma.settings.upsert({
where: {
userId: user.id
},
create: {
userId: user.id,
dashboardTitle: "Personal Dashboard",
logoUrl: "/logo.svg"
},
update: {}
});
await mkdir(LOGO_UPLOAD_DIR, {
recursive: true
});
const filename = `${user.id}-${Date.now()}-${randomBytes(8).toString("hex")}.${imageType.extension}`;
const absolutePath = path.join(LOGO_UPLOAD_DIR, filename);
await writeFile(absolutePath, buffer, {
flag: "wx"
});
const logoUrl = `/api/uploads/logo/${filename}`;
const settings = await prisma.settings.update({
where: {
userId: user.id
},
data: {
logoUrl
}
});
await deleteOldUploadedLogo(existingSettings.logoUrl, filename);
return NextResponse.json({
settings
});
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
return NextResponse.json({ error: "Logo konnte nicht hochgeladen werden." }, { status: 500 });
}
}