Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr, Teile, Minifiguren und Thema kommen aus der API, das Bild wird lokal gespeichert und nicht nur verlinkt. Hersteller und Thema werden bei Bedarf angelegt, ein Oberthema inklusive. Alle Aufrufe laufen serverseitig; der Browser spricht nie mit rebrickable.com. Ohne REBRICKABLE_API_KEY bleibt der Import sichtbar, aber abgeschaltet, mit dem Hinweis, was zu tun ist - von Hand anlegen geht unveraendert. Rebrickable liefert keine Anleitungsdateien. Statt das zu verschleiern, legt der Import einen externen Link auf die LEGO-Serviceseite zur Setnummer an. Nebenbei: src/env.ts behandelt leere Werte jetzt wie nicht gesetzt - ein "BASE_URL=" in der .env fuehrte sonst zum Start-Abbruch mit "Invalid URL" statt zum Standardwert. 14 neue Unit-Tests decken Antwortverarbeitung, Setnummern-Varianten und alle Fehlerfaelle der API ab (404, 401, 429, Netzwerk). Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
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);
|
|
return storeImageBytes(Buffer.from(await file.arrayBuffer()), kind, ownerId);
|
|
}
|
|
|
|
/**
|
|
* Same as `storeImage`, but for bytes we already hold - for instance an image
|
|
* the server fetched from an import source. The size limit still applies.
|
|
*/
|
|
export async function storeImageBytes(
|
|
bytes: Buffer,
|
|
kind: UploadKind,
|
|
ownerId: string,
|
|
): Promise<StoredImage> {
|
|
if (bytes.byteLength === 0) throw new UploadError('Die Datei ist leer.');
|
|
if (bytes.byteLength > env.MAX_UPLOAD_BYTES) {
|
|
throw new UploadError(
|
|
`Das Bild ist größer als ${env.MAX_UPLOAD_MB} MB und wurde nicht übernommen.`,
|
|
);
|
|
}
|
|
|
|
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';
|
|
}
|