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"; /** * STUB (lane L4) — stands in for `src/server/services/documents/store.ts#storeFile` (ARCHITEKTUR §4.3), * which does not exist on the base commit. Same signature; validates allowlist, size limit per * kind, magic bytes, normalises the file name, computes SHA-256 and stores via the storage adapter. * On merge of the documents contract: delete this file and import the shared implementation. */ export type StoreFileInput = { bytes: Buffer; fileName: string; declaredMime: string; category: DocumentCategory; visibility: DocumentVisibility; links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null }; lineageId?: string; title?: string | null; }; const MB = 1024 * 1024; type Kind = "image" | "pdf" | "audio"; const LIMITS: Record = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB }; /** Detects the real MIME type from magic bytes (null = not allowed). */ export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null { const b = bytes; const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v); const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s; if (b.length < 12) return null; if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" }; if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" }; if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" }; if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" }; if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" }; if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" }; if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" }; if (ascii(4, "ftyp")) { const brand = b.toString("latin1", 8, 12); if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" }; return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder } if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" }; return null; } export function normalizeFileName(name: string): string { const base = name.split(/[\\/]/).pop() ?? "datei"; return base.normalize("NFC").replace(/[\u0000-\u001f<>:"|?*]+/g, "_").trim().slice(0, 180) || "datei"; } export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise { const sniffed = sniffMime(input.bytes); if (!sniffed) throw new ServiceError("invalid", "file type not allowed"); if (input.bytes.byteLength > LIMITS[sniffed.kind]) throw new ServiceError("invalid", "file too large"); const declaredBase = input.declaredMime.split(";")[0].trim().toLowerCase(); // declared type must at least belong to the same family (image/*, audio/*, video/webm|mp4 for audio containers, pdf) const family = declaredBase.split("/")[0]; const familyOk = sniffed.kind === "pdf" ? declaredBase === "application/pdf" : sniffed.kind === "image" ? family === "image" : family === "audio" || declaredBase === "video/webm" || declaredBase === "video/mp4"; if (!familyOk) throw new ServiceError("invalid", "declared MIME type does not match content"); const fileName = normalizeFileName(input.fileName); const checksum = createHash("sha256").update(input.bytes).digest("hex"); const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed.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 } }); if (last) version = last.version + 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: stored.storageKey, mimeType: sniffed.mime, fileSize: input.bytes.byteLength, checksum, version, lineageId, visibility: input.visibility, uploadStatus: "uploaded", uploadedById: ctx.userId, }, }); }