feat(katalog): set-katalog mit filtern, detailseite und uploads

Kachelliste mit Suche ueber Setnummer, Name und EAN sowie Filtern nach
Hersteller, Thema, Jahr und Besitzstatus. Besitz kommt aus genau einer
Stelle: src/lib/ownership.ts definiert "hat er nicht" als Katalogeintrag
ohne collection_item mit Status ungleich sold, als korrelierte
Subquery, damit mehrere Exemplare die Liste nicht verdoppeln.

Detailseite mit Metadaten, Anleitungen (PDF-Upload plus pdf.js-Viewer
ohne CDN, oder externer Link), Fotos, Sammlungseintraegen und den
Aktionen "Zur Sammlung" und "Auf Wunschliste".

Upload-Pipeline prueft Magic Bytes statt des gemeldeten MIME-Typs,
erzeugt Dateinamen serverseitig, wandelt Bilder in WebP inklusive
Thumbnail und liefert Dateien ueber /api/files aus - Set-Bilder
oeffentlich, Fotos und PDFs nur mit Session.

Dazu das Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets und
4 Sammlungseintraegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 22:59:26 +02:00
co-authored by Claude Opus 5
parent af0ae039cb
commit 856800b4e4
29 changed files with 4273 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
import 'server-only';
import fs from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { env } from '@/env';
/**
* Everything that writes into DATA_DIR/files.
*
* Rules that hold for every upload:
* - the file name is generated here, never taken from the client;
* - the type is verified by magic bytes, not by the declared MIME type;
* - the resulting path is checked to stay inside FILES_DIR.
*/
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
export const PDF_MIME_TYPE = 'application/pdf';
export type UploadKind = 'set-image' | 'photo' | 'instruction';
export class UploadError extends Error {}
type Signature = { mime: string; test: (bytes: Uint8Array) => boolean };
const SIGNATURES: Signature[] = [
{
mime: 'application/pdf',
test: (b) => b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46,
},
{
mime: 'image/jpeg',
test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
},
{
mime: 'image/png',
test: (b) =>
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d &&
b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a,
},
{
mime: 'image/webp',
test: (b) =>
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
},
];
/** Reads the real type from the first bytes. Returns null for anything else. */
export function detectMimeType(bytes: Uint8Array): string | null {
return SIGNATURES.find((signature) => signature.test(bytes))?.mime ?? null;
}
function assertSize(file: File) {
if (file.size === 0) throw new UploadError('Die Datei ist leer.');
if (file.size > env.MAX_UPLOAD_BYTES) {
throw new UploadError(
`Die Datei ist größer als ${env.MAX_UPLOAD_MB} MB. Größere Dateien sind nicht erlaubt.`,
);
}
}
/** Absolute path for a path stored in the database, with traversal check. */
export function resolveFilePath(relativePath: string): string {
const absolute = path.resolve(env.FILES_DIR, relativePath);
const root = path.resolve(env.FILES_DIR);
if (absolute !== root && !absolute.startsWith(root + path.sep)) {
throw new UploadError('Ungültiger Dateipfad.');
}
return absolute;
}
async function writeFile(relativePath: string, data: Buffer): Promise<void> {
const absolute = resolveFilePath(relativePath);
await fs.mkdir(path.dirname(absolute), { recursive: true });
await fs.writeFile(absolute, data);
}
export type StoredImage = {
/** Path relative to FILES_DIR, as stored in the database. */
filePath: string;
thumbnailPath: string;
};
/**
* Normalises an uploaded image to WebP plus a thumbnail. Large photos from a
* phone camera shrink from several MB to a few hundred KB, which matters on a
* self-hosted box with a small disk.
*/
export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise<StoredImage> {
assertSize(file);
const bytes = Buffer.from(await file.arrayBuffer());
const mime = detectMimeType(bytes);
if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) {
throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.');
}
const id = crypto.randomUUID();
const folder = kind === 'set-image' ? `images/sets/${ownerId}` : `photos/${ownerId}`;
const filePath = `${folder}/${id}.webp`;
const thumbnailPath = `${folder}/${id}.thumb.webp`;
const [full, thumbnail] = await Promise.all([
sharp(bytes).rotate().resize(1600, 1600, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(),
sharp(bytes).rotate().resize(400, 400, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 70 }).toBuffer(),
]);
await writeFile(filePath, full);
await writeFile(thumbnailPath, thumbnail);
return { filePath, thumbnailPath };
}
export type StoredPdf = {
filePath: string;
fileSizeBytes: number;
};
export async function storePdf(file: File, setId: string): Promise<StoredPdf> {
assertSize(file);
const bytes = Buffer.from(await file.arrayBuffer());
if (detectMimeType(bytes) !== PDF_MIME_TYPE) {
throw new UploadError('Anleitungen müssen PDF-Dateien sein.');
}
const filePath = `instructions/${setId}/${crypto.randomUUID()}.pdf`;
await writeFile(filePath, bytes);
return { filePath, fileSizeBytes: bytes.byteLength };
}
/** Removes a stored file. Missing files are not an error - the row is going away anyway. */
export async function deleteStoredFile(relativePath: string | null | undefined): Promise<void> {
if (!relativePath) return;
try {
await fs.unlink(resolveFilePath(relativePath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[uploads] Datei konnte nicht gelöscht werden', relativePath, error);
}
}
}
/** Files below images/ are readable without a session (see CLAUDE.md). */
export function isPublicFilePath(relativePath: string): boolean {
return relativePath.startsWith('images/');
}
const CONTENT_TYPES: Record<string, string> = {
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.pdf': 'application/pdf',
};
export function contentTypeFor(relativePath: string): string {
return CONTENT_TYPES[path.extname(relativePath).toLowerCase()] ?? 'application/octet-stream';
}