Files
qrcode-generator/src/lib/uploads.ts
T
Florian Wilde c06f87e6d9 Logo in der Mitte des QR-Codes
Zur Wahl stehen zwei Quellen: das Logo der Installation aus dem
Erscheinungsbild oder ein eigenes je Code. Die Groesse ist zwischen 10 und
25 Prozent der Kantenlaenge einstellbar, hinter dem Bild liegt eine Flaeche
in der Hintergrundfarbe.

Mit Logo gilt die Fehlerkorrektur H, unabhaengig von der Auswahl — was das
Bild verdeckt, muss sie ausgleichen. Die Auswahl steht dann fest auf H und
sagt auch warum; heimlich geschieht das nicht. Ab 22 Prozent kommt eine
Warnung dazu.

Gerendert wird weiterhin serverseitig, damit Vorschau und Download
uebereinstimmen: das SVG bekommt ein eingebettetes Bild, das PNG wird mit
sharp zusammengesetzt. sharp lag ohnehin als Abhaengigkeit von Next im
Baum und steht jetzt ausdruecklich in der package.json.

Ein eigenes Logo gehoert zur Gestaltung, nicht zum Inhalt — es muss
trotzdem an den Code gehaengt werden. Sonst raeumt die Aufraeumroutine es
nach einem Tag weg und der Code verliert sein Bild.

Refs #8
2026-08-21 03:22:36 +02:00

241 lines
7.5 KiB
TypeScript

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<StoredUpload> {
// 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<void> {
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<void> {
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<void> {
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<number> {
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;
}