/** * STUB (lane L5 „Berichte") for the shared file contract ARCHITEKTUR §4.3 * `src/server/services/documents/store.ts#storeFile` (not yet provided by the architect / documents lane). * * Minimal implementation behind the contracted interface: allowlist + magic bytes + size limit, * SHA-256, storage.put, Document row, lineage versioning. At merge the import in * services/reports/*.ts is switched to the real store and this file is deleted. */ import { createHash, randomUUID } from "node:crypto"; import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client"; import { storage } from "@/server/storage/adapter"; import { ServiceError, type ServiceCtx } from "@/server/services/context"; export type StoreFileInput = { bytes: Uint8Array; fileName: string; declaredMime: string; category: DocumentCategory; visibility: DocumentVisibility; links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null }; lineageId?: string; title?: string | null; }; const LIMITS: Record = { "application/pdf": 25 * 1024 * 1024, "image/png": 15 * 1024 * 1024, "image/jpeg": 15 * 1024 * 1024, "image/webp": 15 * 1024 * 1024 }; export function sniffMime(bytes: Uint8Array): string | null { const b = bytes; if (b.length >= 5 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) return "application/pdf"; if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"; if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp"; return null; } export function sha256Hex(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise { const mime = sniffMime(input.bytes); if (!mime || mime !== input.declaredMime) throw new ServiceError("invalid", "file type not allowed or does not match content"); if (input.bytes.byteLength === 0 || input.bytes.byteLength > (LIMITS[mime] ?? 0)) throw new ServiceError("invalid", "file size not allowed"); const fileName = input.fileName.normalize("NFC").replace(/[^\w.\- ]+/g, "_").slice(0, 120) || "datei"; const checksum = sha256Hex(input.bytes); const put = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: mime, bytes: input.bytes }); let version = 1; const lineageId = input.lineageId ?? randomUUID(); if (input.lineageId) { const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } }); version = (last?.version ?? 0) + 1; } return ctx.db.document.create({ data: { tenantId: ctx.tenantId, customerId: input.links.customerId ?? null, siteId: input.links.siteId ?? null, workOrderId: input.links.workOrderId ?? null, category: input.category, title: input.title ?? null, fileName, storageKey: put.storageKey, mimeType: mime, fileSize: input.bytes.byteLength, checksum, version, lineageId, visibility: input.visibility, uploadStatus: "uploaded", uploadedById: ctx.userId, }, }); } /** Read the bytes of a stored document (worker/PDF rendering). null if the backend has no bytes. */ export async function readFileBytes(storageKey: string): Promise { const obj = await storage.get(storageKey); if (!obj) return null; const chunks: Uint8Array[] = []; const reader = obj.stream.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; if (value) chunks.push(value); } return Buffer.concat(chunks); }