a4051ae132
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>
177 lines
4.3 KiB
TypeScript
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 });
|
|
}
|
|
}
|