import "server-only"; import { prisma } from "@/lib/db"; import { QrRequestError } from "@/lib/qr/type-module"; import { formatBytes, kindMaxBytes, UPLOAD_KINDS, uploadPurposeOf, type UploadKind, } from "@/lib/uploads.shared"; // Weiterreichen, damit Aufrufer auf dem Server nur ein Modul kennen müssen. export { UPLOAD_KINDS, formatBytes, isUploadKind, kindMaxBytes, uploadPurposeOf, } from "@/lib/uploads.shared"; export type { UploadKind } from "@/lib/uploads.shared"; /** Voreinstellung, überschreibbar per `MAX_UPLOAD_MB`. */ const DEFAULT_MAX_MB = 10; export function maxUploadBytes(): number { const raw = Number.parseInt(process.env.MAX_UPLOAD_MB ?? "", 10); const mb = Number.isFinite(raw) && raw > 0 && raw <= 200 ? raw : DEFAULT_MAX_MB; return mb * 1024 * 1024; } /** * Dateiart anhand der ersten Bytes bestimmen. * * Der vom Browser gemeldete Typ kommt aus der Dateiendung und lässt sich * beliebig setzen. Was wirklich in der Datei steht, verrät nur ihr Anfang — * und danach richten wir uns, damit unter dem Namen „bild.png“ nichts * anderes ins Haus kommt. */ export function sniffMimeType(bytes: Uint8Array): string | null { const starts = (...signature: number[]) => signature.every((byte, index) => bytes[index] === byte); if (starts(0x25, 0x50, 0x44, 0x46)) return "application/pdf"; // %PDF if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"; if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; // GIF8 // ICO: Reserviert 0, Typ 1, dann die Anzahl der Bilder (mindestens eines). if (starts(0x00, 0x00, 0x01, 0x00) && (bytes[4] | bytes[5]) !== 0) { return "image/x-icon"; } // RIFF....WEBP if ( starts(0x52, 0x49, 0x46, 0x46) && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 ) { return "image/webp"; } // ....ftypavif — die Marke steht ab Byte 4. const brand = String.fromCharCode(...bytes.slice(4, 12)); if (brand.startsWith("ftyp") && brand.slice(4, 8) === "avif") { return "image/avif"; } return null; } /** Zeichen, die in Dateinamen oder in Kopfzeilen Ärger machen. */ const HEIKLE_ZEICHEN = new Set(['"', "*", ":", "<", ">", "?", "|"]); /** * Ein harmloser Dateiname: ohne Pfad, ohne Steuerzeichen, nicht zu lang. * * Der Name landet in der `Content-Disposition`-Kopfzeile beim Herunterladen. * Ein Zeilenumbruch darin könnte weitere Kopfzeilen einschleusen, deshalb * fliegt alles unterhalb des Leerzeichens raus. */ export function sanitizeFilename(name: string, fallback: string): string { const base = name.split(/[\\/]/).pop() ?? ""; const clean = [...base] .filter((zeichen) => { const code = zeichen.codePointAt(0) ?? 0; if (code < 0x20 || code === 0x7f) return false; return !HEIKLE_ZEICHEN.has(zeichen); }) .join("") .trim(); if (clean.length === 0 || clean === "." || clean === "..") return fallback; return clean.slice(0, 120); } export type StoredUpload = { id: string; filename: string; mimeType: string; size: number; }; /** * Nimmt eine Datei entgegen und legt sie ab — noch ohne Bezug zu einem Code. * Verknüpft wird sie erst, wenn der Code gespeichert wird. */ export async function storeUpload( file: File, kind: UploadKind, ): Promise { // Die engere der beiden Grenzen gewinnt: die allgemeine aus MAX_UPLOAD_MB // und die der Dateiart. const eigene = kindMaxBytes(kind); const limit = eigene === null ? maxUploadBytes() : Math.min(eigene, maxUploadBytes()); if (file.size === 0) { throw new QrRequestError("Die Datei ist leer."); } if (file.size > limit) { throw new QrRequestError( `Die Datei ist zu groß (${formatBytes(file.size)}). Erlaubt sind bis zu ${formatBytes(limit)}.`, ); } const bytes = new Uint8Array(await file.arrayBuffer()); // Zweite Prüfung: `file.size` ist nur eine Angabe des Browsers. if (bytes.byteLength > limit) { throw new QrRequestError( `Die Datei ist zu groß. Erlaubt sind bis zu ${formatBytes(limit)}.`, ); } const erkannt = sniffMimeType(bytes); const erlaubt: readonly string[] = UPLOAD_KINDS[kind].mimeTypes; if (!erkannt || !erlaubt.includes(erkannt)) { throw new QrRequestError( kind === "pdf" ? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch." : kind === "favicon" ? "Das ist kein unterstütztes Bild. Erlaubt sind PNG, ICO, WebP, JPEG, GIF und AVIF." : "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.", ); } return prisma.upload.create({ data: { filename: sanitizeFilename( file.name, kind === "pdf" ? "dokument.pdf" : kind === "favicon" ? "favicon" : "bild", ), mimeType: erkannt, size: bytes.byteLength, data: Buffer.from(bytes), purpose: uploadPurposeOf(kind), }, select: { id: true, filename: true, mimeType: true, size: true }, }); } /** * Verknüpft die Dateien eines Codes mit ihm und löst die, die nicht mehr * dazugehören. * * Wird beim Anlegen und bei jeder Änderung aufgerufen. Eine ausgetauschte * Datei verliert dadurch ihren Bezug und fällt später dem Aufräumen zum * Opfer — sofort löschen wäre riskanter, falls das Speichern doch scheitert. */ export async function attachUploads( qrCodeId: string, uploadIds: readonly string[], ): Promise { await prisma.upload.updateMany({ where: { qrCodeId, id: { notIn: [...uploadIds] } }, data: { qrCodeId: null }, }); if (uploadIds.length === 0) return; await prisma.upload.updateMany({ // Nur freie Dateien oder solche, die schon zu diesem Code gehören: sonst // ließe sich eine fremde Datei durch Angabe ihrer Id an den eigenen Code // hängen — und damit aus dem fremden Code entfernen. where: { id: { in: [...uploadIds] }, OR: [{ qrCodeId: null }, { qrCodeId }], }, data: { qrCodeId }, }); } /** * Hängt **eine** Datei an einen Code, ohne die übrigen anzurühren. * * Gebraucht für das Logo aus der Gestaltung: Ändert sich nur das Aussehen * eines Codes, sollen die Dateien seines Inhalts unberührt bleiben. */ export async function attachUpload( qrCodeId: string, uploadId: string, ): Promise { await prisma.upload.updateMany({ // Dieselbe Bedingung wie oben: keine fremde Datei an sich ziehen. where: { id: uploadId, OR: [{ qrCodeId: null }, { qrCodeId }] }, data: { qrCodeId }, }); } /** * Löst eine Datei wieder vom Code — sie wird danach als herrenlos weggeräumt. */ export async function detachUpload( qrCodeId: string, uploadId: string, ): Promise { await prisma.upload.updateMany({ where: { id: uploadId, qrCodeId }, data: { qrCodeId: null }, }); } /** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */ const ORPHAN_HOURS = 24; /** * Räumt Dateien weg, die zu keinem Code gehören. * * Das passiert, wenn jemand eine Datei auswählt und den Code dann doch nicht * speichert. Aufgerufen wird das beim nächsten Upload — ein eigener Dienst * dafür wäre bei diesen Mengen übertrieben. */ export async function cleanupOrphanedUploads(): Promise { const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000); const { count } = await prisma.upload.deleteMany({ // Nur Dateien, die zu einem Code gehören sollten. Logo und Favicon haben // nie einen Code und wären sonst nach einem Tag verschwunden. where: { qrCodeId: null, purpose: "code", createdAt: { lt: grenze } }, }); return count; }