import { createHash, randomUUID } from "node:crypto"; import { z } from "zod"; import { DocumentCategory, DocumentVisibility, type Document } from "@prisma/client"; import { writeAuditLog } from "@/server/audit"; import { storage } from "@/server/storage/adapter"; import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; import { allowedDocumentVisibility, requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; import { getFileScanner, type DetectedKind, type FileScanner } from "@/server/services/documents/scanner"; import { documentReadWhere } from "@/server/services/documents/access"; /** * Document storage service (ARCHITEKTUR §4.3, spec §24, §27.4). Owned by lane "stammdaten"; * every lane stores files ONLY through `storeFile` and links downloads via `getDownloadUrl` * (see ./access.ts). */ const MB = 1024 * 1024; /** Size limits per detected kind (ARCHITEKTUR §4.3). */ export const SIZE_LIMITS: Record = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB }; export const MAX_UPLOAD_BYTES = Math.max(...Object.values(SIZE_LIMITS)); export const DOCUMENT_CATEGORIES = Object.values(DocumentCategory); export const DOCUMENT_VISIBILITIES = Object.values(DocumentVisibility); export type StoreFileInput = { bytes: Uint8Array; fileName: string; declaredMime: string; category: DocumentCategory; visibility: DocumentVisibility; title?: string | null; links?: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null }; /** Existing lineage → stored as the next version of that document. */ lineageId?: string | null; approvalStatus?: "draft" | "approved" | null; }; const metaSchema = z.object({ fileName: z.string().min(1).max(500), declaredMime: z.string().min(1).max(200), category: z.enum(DocumentCategory), visibility: z.enum(DocumentVisibility), title: z.string().trim().max(300).nullable().optional(), lineageId: z.string().min(1).max(64).nullable().optional(), approvalStatus: z.enum(["draft", "approved"]).nullable().optional(), links: z .object({ customerId: z.string().min(1).nullable().optional(), siteId: z.string().min(1).nullable().optional(), workOrderId: z.string().min(1).nullable().optional(), }) .optional(), }); /** * Normalize a user-supplied file name: strip any path, control and reserved characters, unify * Unicode (NFC), collapse whitespace, keep the extension, limit length. Never empty. */ export function normalizeFileName(name: string): string { const base = name.split(/[\\/]/).pop() ?? ""; const cleaned = base .normalize("NFC") .replace(/[\x00-\x1f\x7f]/g, "") .replace(/[<>:"|?*]/g, "_") .replace(/\s+/g, " ") .replace(/^[.\s]+/, "") .trim(); if (!cleaned) return "datei"; const MAX = 180; if (cleaned.length <= MAX) return cleaned; const dot = cleaned.lastIndexOf("."); const ext = dot > 0 && cleaned.length - dot <= 10 ? cleaned.slice(dot) : ""; return cleaned.slice(0, MAX - ext.length) + ext; } export function sha256Hex(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } /** Who may attach a file where (the caller's own action guard stays in place in addition). */ async function assertMayAttach(ctx: ServiceCtx, links: NonNullable) { if (links.workOrderId) { // field roles attach photos/voice notes/signatures to orders in their scope if (!["document:write", "field:execute", "report:write", "emergency:create"].some((p) => can(ctx, p))) { throw new ServiceError("forbidden", "missing permission to attach documents"); } await requireVisibleWorkOrder(ctx, links.workOrderId, { id: true }); } else if (links.siteId || links.customerId) { assertCan(ctx, "document:write"); } else if (!can(ctx, "document:write") && !can(ctx, "import:write")) { // unlinked originals (e.g. PDF imports) are backoffice material throw new ServiceError("forbidden", "missing permission document:write"); } if (links.siteId) { const site = await ctx.db.site.findFirst({ where: { id: links.siteId, deletedAt: null }, select: { id: true } }); if (!site) throw new ServiceError("invalid", "site not found", { field: "siteId", reason: "site_not_found" }); } if (links.customerId) { const customer = await ctx.db.customer.findFirst({ where: { id: links.customerId, deletedAt: null }, select: { id: true } }); if (!customer) throw new ServiceError("invalid", "customer not found", { field: "customerId", reason: "customer_not_found" }); } } /** * Validate and store a file, creating a `Document` row. * Order: metadata → size → magic bytes/scanner → permission/links → visibility → storage → DB → audit. * Rejections are `ServiceError("invalid", …, { reason })` with reason * `empty_file | too_large | unsupported_type | type_mismatch | malware | scanner_unavailable | * visibility_not_allowed | lineage_not_found`. */ export async function storeFile(ctx: ServiceCtx, input: StoreFileInput, deps: { scanner?: FileScanner } = {}): Promise { const meta = metaSchema.parse({ ...input, bytes: undefined }); const bytes = input.bytes; if (!bytes || bytes.byteLength === 0) throw new ServiceError("invalid", "empty file", { field: "file", reason: "empty_file" }); if (bytes.byteLength > MAX_UPLOAD_BYTES) throw new ServiceError("invalid", "file too large", { field: "file", reason: "too_large" }); const fileName = normalizeFileName(meta.fileName); const verdict = await (deps.scanner ?? getFileScanner()).scan({ bytes, declaredMime: meta.declaredMime, fileName }); if (!verdict.ok) throw new ServiceError("invalid", `file rejected: ${verdict.reason}`, { field: "file", reason: verdict.reason }); if (bytes.byteLength > SIZE_LIMITS[verdict.kind]) { throw new ServiceError("invalid", "file too large", { field: "file", reason: "too_large", limit: SIZE_LIMITS[verdict.kind] }); } let links = { customerId: meta.links?.customerId ?? null, siteId: meta.links?.siteId ?? null, workOrderId: meta.links?.workOrderId ?? null }; let lineageId: string = randomUUID(); let version = 1; if (meta.lineageId) { // a new version is only possible for a document the user may read const previous = await ctx.db.document.findFirst({ where: { AND: [{ lineageId: meta.lineageId }, await documentReadWhere(ctx)] }, orderBy: { version: "desc" }, }); if (!previous) throw new ServiceError("invalid", "lineage not found", { field: "lineageId", reason: "lineage_not_found" }); lineageId = previous.lineageId; version = previous.version + 1; if (!links.customerId && !links.siteId && !links.workOrderId) { links = { customerId: previous.customerId, siteId: previous.siteId, workOrderId: previous.workOrderId }; } } await assertMayAttach(ctx, links); if (!allowedDocumentVisibility(ctx).includes(meta.visibility)) { throw new ServiceError("invalid", "visibility not allowed", { field: "visibility", reason: "visibility_not_allowed" }); } const checksum = sha256Hex(bytes); const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: verdict.detectedMime, bytes }); let document: Document | null = null; for (let attempt = 0; attempt < 2 && !document; attempt++) { try { document = await ctx.db.document.create({ data: { tenantId: ctx.tenantId, ...links, category: meta.category, title: meta.title ?? null, fileName, storageKey: stored.storageKey, mimeType: verdict.detectedMime, fileSize: bytes.byteLength, checksum, version, lineageId, visibility: meta.visibility, approvalStatus: meta.approvalStatus ?? null, uploadStatus: "uploaded", uploadedById: ctx.userId, }, }); } catch (err) { // concurrent new version of the same lineage → take the next number once if ((err as { code?: string }).code !== "P2002" || attempt > 0 || !meta.lineageId) throw err; const latest = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } }); version = (latest?.version ?? version) + 1; } } if (!document) throw new ServiceError("conflict", "could not store document version"); await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "document", entityId: document.id, after: { fileName, category: document.category, visibility: document.visibility, mimeType: document.mimeType, fileSize: document.fileSize, checksum, version, lineageId, links, scanner: (deps.scanner ?? getFileScanner()).name, }, }); return document; }