From 49c5ad0e33e0f7fdf9de36e801854ef7154131fb Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:26:27 +0200 Subject: [PATCH] L1 Stammdaten: Dokumentenablage-Service und Download per documentId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storeFile (Allowlist, Magic Bytes, Größenlimits, Dateinamen-Normalisierung, SHA-256, Versionierung über lineageId), FileScanner mit optionalem ClamAV-Hook, Sichtbarkeits-/Scope-Autorisierung, Upload-Route und Umbau der Download-Route von files/[...key] auf files/[documentId]. Co-Authored-By: Claude Opus 5 --- src/app/(app)/documents/upload/route.ts | 84 +++++++++ src/app/(app)/files/[documentId]/route.ts | 49 ++++++ src/server/actions/documents/documents.ts | 44 +++++ src/server/services/documents/access.ts | 183 ++++++++++++++++++++ src/server/services/documents/scanner.ts | 158 +++++++++++++++++ src/server/services/documents/store.ts | 201 ++++++++++++++++++++++ 6 files changed, 719 insertions(+) create mode 100644 src/app/(app)/documents/upload/route.ts create mode 100644 src/app/(app)/files/[documentId]/route.ts create mode 100644 src/server/actions/documents/documents.ts create mode 100644 src/server/services/documents/access.ts create mode 100644 src/server/services/documents/scanner.ts create mode 100644 src/server/services/documents/store.ts diff --git a/src/app/(app)/documents/upload/route.ts b/src/app/(app)/documents/upload/route.ts new file mode 100644 index 0000000..341eee7 --- /dev/null +++ b/src/app/(app)/documents/upload/route.ts @@ -0,0 +1,84 @@ +import { DocumentCategory, DocumentVisibility } from "@prisma/client"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { ApiError, toErrorResponse } from "@/server/api/respond"; +import { ServiceError } from "@/server/services/context"; +import { storeFile } from "@/server/services/documents/store"; + +/** + * Multipart upload for the backoffice document tabs (customer, site, /documents). + * A route handler instead of a server action because server action bodies are limited to 1 MB. + * Note: with the proxy active, Next.js buffers at most `proxyClientMaxBodySize` (default 10 MB); + * larger bodies fail to parse and are answered with `too_large`. + * + * Browser forms get a 303 redirect back to `returnTo` with `?docOk=1` or `?docError=`; + * clients sending `Accept: application/json` get JSON (`{ data: { id } }` or the error format). + */ +export async function POST(req: Request) { + const wantsJson = (req.headers.get("accept") ?? "").includes("application/json"); + let returnTo = "/documents"; + try { + assertSameOrigin(req); + const ctx = await requireApiContext("documents"); + + let form: FormData; + try { + form = await req.formData(); + } catch { + throw new ServiceError("invalid", "unreadable upload", { reason: "too_large" }); + } + returnTo = safeReturnTo(form.get("returnTo")); + + const file = form.get("file"); + if (!(file instanceof File)) throw new ServiceError("invalid", "file missing", { reason: "empty_file" }); + const str = (k: string) => { + const v = form.get(k); + return typeof v === "string" && v.trim() !== "" ? v.trim() : null; + }; + const category = str("category"); + const visibility = str("visibility"); + if (!category || !(category in DocumentCategory)) throw new ServiceError("invalid", "category", { reason: "invalid_category" }); + if (!visibility || !(visibility in DocumentVisibility)) throw new ServiceError("invalid", "visibility", { reason: "visibility_not_allowed" }); + + const document = await storeFile(ctx, { + bytes: new Uint8Array(await file.arrayBuffer()), + fileName: file.name, + declaredMime: file.type || "application/octet-stream", + category: category as DocumentCategory, + visibility: visibility as DocumentVisibility, + title: str("title"), + lineageId: str("lineageId"), + links: { customerId: str("customerId"), siteId: str("siteId"), workOrderId: str("workOrderId") }, + }); + + if (wantsJson) return Response.json({ data: { id: document.id, version: document.version, lineageId: document.lineageId } }, { status: 201 }); + return redirectTo(returnTo, { docOk: "1" }); + } catch (err) { + if (wantsJson) return toErrorResponse(err); + if (err instanceof ApiError && err.code === "unauthorized") return redirectTo("/login", {}); + const reason = + err instanceof ServiceError + ? String((err.details as { reason?: string } | undefined)?.reason ?? err.code) + : err instanceof ApiError + ? err.code + : "generic"; + if (!(err instanceof ServiceError) && !(err instanceof ApiError)) console.error("[documents/upload] failed", err); + return redirectTo(returnTo, { docError: reason }); + } +} + +/** Only same-app relative paths; everything else falls back to /documents (no open redirect). */ +function safeReturnTo(value: FormDataEntryValue | null): string { + if (typeof value !== "string") return "/documents"; + if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/documents"; + return value.slice(0, 500); +} + +function redirectTo(path: string, params: Record): Response { + const [pathname, query = ""] = path.split("?"); + const sp = new URLSearchParams(query); + sp.delete("docOk"); + sp.delete("docError"); + for (const [k, v] of Object.entries(params)) sp.set(k, v); + const qs = sp.toString(); + return new Response(null, { status: 303, headers: { Location: `${pathname}${qs ? `?${qs}` : ""}` } }); +} diff --git a/src/app/(app)/files/[documentId]/route.ts b/src/app/(app)/files/[documentId]/route.ts new file mode 100644 index 0000000..db582de --- /dev/null +++ b/src/app/(app)/files/[documentId]/route.ts @@ -0,0 +1,49 @@ +import { requireApiContext } from "@/server/api/context"; +import { ApiError } from "@/server/api/respond"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { openDocumentContent } from "@/server/services/documents/access"; + +/** + * Document download by id (ARCHITEKTUR §4.3). Replaces the former storage-key route. + * + * Authorization on EVERY request (no public or long-lived links): + * session + DB-authoritative `document:read` → document in the tenant (dbForTenant) → + * visibility allowed for the user → work order in scope (`requireVisibleWorkOrder` semantics via + * `workOrderScope`) or site/customer in `siteScope`/`customerScope`. Everything else → 404 + * without revealing existence. + * + * Delivered as attachment with nosniff (F-07); images still render in tags. + */ +export async function GET(_req: Request, { params }: { params: Promise<{ documentId: string }> }) { + let ctx: ServiceCtx; + try { + ctx = await requireApiContext(null, "document:read"); + } catch (err) { + const status = err instanceof ApiError && err.code === "unauthorized" ? 401 : 403; + return new Response(status === 401 ? "Nicht angemeldet." : "Kein Zugriff.", { status, headers: { "Cache-Control": "no-store" } }); + } + + const { documentId } = await params; + if (!documentId || documentId.length > 64) return notFound(); + + try { + const { document, content } = await openDocumentContent(ctx, documentId); + const asciiName = document.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const headers = new Headers({ + "Content-Type": document.mimeType || content.contentType || "application/octet-stream", + "Content-Disposition": `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(document.fileName)}`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, no-store", + }); + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); + } catch (err) { + if (err instanceof ServiceError || (err instanceof Error && /Tenant isolation violation/.test(err.message))) return notFound(); + console.error("[files] download failed", err); + return new Response("Datei nicht verfügbar.", { status: 500, headers: { "Cache-Control": "no-store" } }); + } +} + +function notFound() { + return new Response("Nicht gefunden.", { status: 404, headers: { "Cache-Control": "no-store" } }); +} diff --git a/src/server/actions/documents/documents.ts b/src/server/actions/documents/documents.ts new file mode 100644 index 0000000..348164a --- /dev/null +++ b/src/server/actions/documents/documents.ts @@ -0,0 +1,44 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { formObject, toActionError, type ActionState } from "@/server/api/action-state"; +import { deleteDocument, updateDocumentMeta } from "@/server/services/documents/access"; +import type { DocumentCategory, DocumentVisibility } from "@prisma/client"; + +const guard = moduleGuard("documents"); + +/** Only revalidate same-app paths passed by our own pages. */ +function safePath(path: string): string { + return path.startsWith("/") && !path.startsWith("//") ? path.split("?")[0] : "/documents"; +} + +export async function updateDocumentAction(documentId: string, returnPath: string, _prev: ActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("document:write")); + const v = formObject(fd, ["title", "category", "visibility"]); + await updateDocumentMeta(ctx, documentId, { + title: v.title ?? null, + category: v.category as DocumentCategory | undefined, + visibility: v.visibility as DocumentVisibility | undefined, + }); + } catch (err) { + return toActionError(err); + } + revalidatePath(safePath(returnPath)); + revalidatePath("/documents"); + return { status: "ok" }; +} + +export async function deleteDocumentAction(documentId: string, returnPath: string): Promise { + try { + const ctx = ctxFromGuard(await guard("document:write")); + await deleteDocument(ctx, documentId); + } catch (err) { + return toActionError(err); + } + revalidatePath(safePath(returnPath)); + revalidatePath("/documents"); + return { status: "ok" }; +} diff --git a/src/server/services/documents/access.ts b/src/server/services/documents/access.ts new file mode 100644 index 0000000..3e73858 --- /dev/null +++ b/src/server/services/documents/access.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; +import { DocumentCategory, DocumentVisibility, type Prisma } from "@prisma/client"; +import { writeAuditLog } from "@/server/audit"; +import { storage, type StoredContent } from "@/server/storage/adapter"; +import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { allowedDocumentVisibility, customerScope, siteScope, workOrderScope } from "@/server/services/work-orders/visibility"; + +/** + * Read side of the document service: visibility filter, download authorization, listing, + * metadata changes and soft delete (spec §24.3, ARCHITEKTUR §2 "Dokument-Sichtbarkeit"). + */ + +/** + * Documents the user may read: + * - `document:read` required; + * - visibility ∈ allowedDocumentVisibility(ctx) (backoffice_only needs document:read_internal); + * - users without `work_order:read_all`: linked work order in `workOrderScope`, or — for documents + * without an order — site in `siteScope` / customer in `customerScope`. Unlinked documents + * (e.g. import originals) are backoffice-only. + */ +export async function documentReadWhere(ctx: ServiceCtx): Promise { + if (!can(ctx, "document:read")) return { id: "__none__" }; + const base: Prisma.DocumentWhereInput = { + deletedAt: null, + uploadStatus: "uploaded", + visibility: { in: allowedDocumentVisibility(ctx) }, + }; + if (can(ctx, "work_order:read_all")) return base; + const [wo, site, customer] = await Promise.all([workOrderScope(ctx), siteScope(ctx), customerScope(ctx)]); + return { + AND: [ + base, + { + OR: [ + { workOrderId: { not: null }, workOrder: { is: wo } }, + { workOrderId: null, siteId: { not: null }, site: { is: site } }, + { workOrderId: null, siteId: null, customerId: { not: null }, customer: { is: customer } }, + ], + }, + ], + }; +} + +/** Load a document the user may read, or throw `not_found` (never reveals existence). */ +export async function authorizeDocumentAccess(ctx: ServiceCtx, documentId: string) { + const document = await ctx.db.document.findFirst({ where: { AND: [{ id: documentId }, await documentReadWhere(ctx)] } }); + if (!document) throw new ServiceError("not_found", "document not found"); + return document; +} + +/** Internal download link — authorization happens again on every request to the route. */ +export async function getDownloadUrl(ctx: ServiceCtx, documentId: string): Promise { + const document = await authorizeDocumentAccess(ctx, documentId); + return documentHref(document.id); +} + +export function documentHref(documentId: string): string { + return `/files/${encodeURIComponent(documentId)}`; +} + +/** Authorize and open the stored bytes (used by /files/[documentId]). */ +export async function openDocumentContent(ctx: ServiceCtx, documentId: string): Promise<{ document: Awaited>; content: StoredContent }> { + const document = await authorizeDocumentAccess(ctx, documentId); + // defense in depth: the key must carry the tenant prefix + if (!document.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document content not available"); + const content = await storage.get(document.storageKey); + if (!content) throw new ServiceError("not_found", "document content not available"); + return { document, content }; +} + +export type DocumentListFilter = { + category?: DocumentCategory; + customerId?: string; + siteId?: string; + workOrderId?: string; + q?: string; + /** Only the newest version of each lineage. */ + latestOnly?: boolean; + page?: number; + pageSize?: number; +}; + +export async function listDocuments(ctx: ServiceCtx, filter: DocumentListFilter = {}) { + const page = Math.max(1, filter.page ?? 1); + const pageSize = Math.min(500, Math.max(1, filter.pageSize ?? 25)); + const and: Prisma.DocumentWhereInput[] = [await documentReadWhere(ctx)]; + if (filter.category) and.push({ category: filter.category }); + if (filter.workOrderId) and.push({ workOrderId: filter.workOrderId }); + if (filter.siteId) and.push({ OR: [{ siteId: filter.siteId }, { workOrder: { is: { siteId: filter.siteId } } }] }); + if (filter.customerId) { + and.push({ + OR: [ + { customerId: filter.customerId }, + { site: { is: { customerId: filter.customerId } } }, + { workOrder: { is: { customerId: filter.customerId } } }, + ], + }); + } + if (filter.q?.trim()) { + const q = filter.q.trim(); + and.push({ OR: [{ fileName: { contains: q, mode: "insensitive" } }, { title: { contains: q, mode: "insensitive" } }] }); + } + + let where: Prisma.DocumentWhereInput = { AND: and }; + if (filter.latestOnly) { + const groups = await ctx.db.document.groupBy({ by: ["lineageId"], where, _max: { version: true } }); + where = { AND: [where, { OR: groups.map((g) => ({ lineageId: g.lineageId, version: g._max.version ?? 1 })) }] }; + if (groups.length === 0) return { items: [], total: 0, page, pageSize }; + } + + const [total, items] = await Promise.all([ + ctx.db.document.count({ where }), + ctx.db.document.findMany({ + where, + orderBy: [{ createdAt: "desc" }, { version: "desc" }], + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + title: true, + fileName: true, + category: true, + visibility: true, + mimeType: true, + fileSize: true, + checksum: true, + version: true, + lineageId: true, + approvalStatus: true, + uploadedById: true, + createdAt: true, + updatedAt: true, + customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true } }, + site: { select: { id: true, name: true } }, + workOrder: { select: { id: true, number: true, title: true } }, + }, + }), + ]); + return { items, total, page, pageSize }; +} + +const metaPatchSchema = z.object({ + title: z.preprocess((v) => (typeof v === "string" && v.trim() === "" ? null : v), z.string().trim().max(300).nullable().optional()), + category: z.enum(DocumentCategory).optional(), + visibility: z.enum(DocumentVisibility).optional(), +}); + +export async function updateDocumentMeta(ctx: ServiceCtx, documentId: string, input: z.input) { + assertCan(ctx, "document:write"); + const data = metaPatchSchema.parse(input); + const before = await authorizeDocumentAccess(ctx, documentId); + if (data.visibility && !allowedDocumentVisibility(ctx).includes(data.visibility)) { + throw new ServiceError("invalid", "visibility not allowed", { field: "visibility", reason: "visibility_not_allowed" }); + } + const after = await ctx.db.document.update({ where: { id: documentId }, data }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "update", + entity: "document", + entityId: documentId, + before: { title: before.title, category: before.category, visibility: before.visibility }, + after: { title: after.title, category: after.category, visibility: after.visibility }, + }); + return after; +} + +/** Soft delete of one document version. */ +export async function deleteDocument(ctx: ServiceCtx, documentId: string) { + assertCan(ctx, "document:write"); + const before = await authorizeDocumentAccess(ctx, documentId); + const after = await ctx.db.document.update({ where: { id: documentId }, data: { deletedAt: new Date() } }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "delete", + entity: "document", + entityId: documentId, + before: { fileName: before.fileName, version: before.version, lineageId: before.lineageId }, + after: { deletedAt: after.deletedAt }, + }); + return after; +} diff --git a/src/server/services/documents/scanner.ts b/src/server/services/documents/scanner.ts new file mode 100644 index 0000000..50b3419 --- /dev/null +++ b/src/server/services/documents/scanner.ts @@ -0,0 +1,158 @@ +import { connect } from "node:net"; + +/** + * File scanning (ARCHITEKTUR §4.3, spec §27.4). MVP: magic-byte/type verification against an + * allowlist. If CLAMAV_HOST is set, the bytes are additionally streamed to clamd (INSTREAM). + * Scanners never throw for bad content — they return a structured verdict. + */ + +export type DetectedKind = "pdf" | "image" | "audio"; + +export type ScanVerdict = + | { ok: true; detectedMime: string; kind: DetectedKind } + | { ok: false; reason: "unsupported_type" | "type_mismatch" | "malware" | "scanner_unavailable"; detail?: string }; + +export interface FileScanner { + name: string; + scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise; +} + +/** Allowlisted MIME types → kind. */ +export const ALLOWED_MIME: Record = { + "application/pdf": "pdf", + "image/jpeg": "image", + "image/png": "image", + "image/webp": "image", + "image/heic": "image", + "audio/webm": "audio", + "audio/ogg": "audio", + "audio/mp4": "audio", + "audio/mpeg": "audio", + "audio/wav": "audio", +}; + +const MIME_ALIASES: Record = { + "image/jpg": "image/jpeg", + "image/pjpeg": "image/jpeg", + "image/heif": "image/heic", + "audio/x-wav": "audio/wav", + "audio/wave": "audio/wav", + "audio/x-m4a": "audio/mp4", + "audio/m4a": "audio/mp4", + "audio/mp3": "audio/mpeg", + "video/webm": "audio/webm", // MediaRecorder often labels audio-only recordings as video/webm +}; + +export function canonicalMime(mime: string): string { + const base = mime.split(";")[0].trim().toLowerCase(); + return MIME_ALIASES[base] ?? base; +} + +function startsWith(bytes: Uint8Array, sig: number[], offset = 0): boolean { + if (bytes.length < offset + sig.length) return false; + return sig.every((b, i) => bytes[offset + i] === b); +} + +function ascii(bytes: Uint8Array, start: number, end: number): string { + return String.fromCharCode(...bytes.slice(start, end)); +} + +/** Detect the real MIME type from the leading bytes; null if not on the allowlist. */ +export function detectMime(bytes: Uint8Array): string | null { + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"; // %PDF- + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"; + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"; + if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp"; + if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WAVE") return "audio/wav"; + if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return "audio/webm"; // EBML (WebM/Matroska) + if (ascii(bytes, 0, 4) === "OggS") return "audio/ogg"; + if (ascii(bytes, 0, 3) === "ID3" || startsWith(bytes, [0xff, 0xfb]) || startsWith(bytes, [0xff, 0xf3])) return "audio/mpeg"; + if (ascii(bytes, 4, 8) === "ftyp") { + const brand = ascii(bytes, 8, 12); + if (["heic", "heix", "mif1", "msf1", "heim", "heis"].includes(brand)) return "image/heic"; + if (["M4A ", "mp42", "isom", "dash", "iso5", "iso6"].includes(brand)) return "audio/mp4"; + } + return null; +} + +export class MagicByteScanner implements FileScanner { + name = "magic-bytes"; + + async scan({ bytes, declaredMime }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { + const declared = canonicalMime(declaredMime); + if (!ALLOWED_MIME[declared]) return { ok: false, reason: "unsupported_type", detail: declared }; + const detected = detectMime(bytes); + if (!detected) return { ok: false, reason: "type_mismatch", detail: "unknown signature" }; + if (detected !== declared) return { ok: false, reason: "type_mismatch", detail: `${declared} ≠ ${detected}` }; + return { ok: true, detectedMime: detected, kind: ALLOWED_MIME[detected] }; + } +} + +/** clamd INSTREAM client (only active if CLAMAV_HOST is configured). */ +export class ClamAvScanner implements FileScanner { + name = "clamav"; + constructor( + private host: string, + private port: number, + private timeoutMs = 15_000, + ) {} + + scan({ bytes }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { + return new Promise((resolve) => { + const socket = connect({ host: this.host, port: this.port }); + let response = ""; + const done = (v: ScanVerdict) => { + socket.destroy(); + resolve(v); + }; + socket.setTimeout(this.timeoutMs, () => done({ ok: false, reason: "scanner_unavailable", detail: "timeout" })); + socket.on("error", (err) => done({ ok: false, reason: "scanner_unavailable", detail: err.message })); + socket.on("data", (chunk) => (response += chunk.toString("utf8"))); + socket.on("end", () => { + if (/OK\s*\0?$/.test(response.trim())) done({ ok: true, detectedMime: "", kind: "pdf" }); + else if (/FOUND/.test(response)) done({ ok: false, reason: "malware", detail: response.trim() }); + else done({ ok: false, reason: "scanner_unavailable", detail: response.trim() }); + }); + socket.on("connect", () => { + socket.write("zINSTREAM\0"); + const chunkSize = 64 * 1024; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + const len = Buffer.alloc(4); + len.writeUInt32BE(chunk.length, 0); + socket.write(len); + socket.write(chunk); + } + socket.write(Buffer.alloc(4)); // zero-length chunk terminates the stream + }); + }); + } +} + +/** Magic bytes first (cheap, authoritative for the stored MIME), then optional ClamAV. */ +export class CompositeScanner implements FileScanner { + name: string; + constructor(private primary: FileScanner, private extra: FileScanner[]) { + this.name = [primary.name, ...extra.map((s) => s.name)].join("+"); + } + + async scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { + const first = await this.primary.scan(input); + if (!first.ok) return first; + for (const s of this.extra) { + const v = await s.scan(input); + if (!v.ok) return v; + } + return first; + } +} + +let scanner: FileScanner | null = null; + +export function getFileScanner(): FileScanner { + if (scanner) return scanner; + const host = process.env.CLAMAV_HOST?.trim(); + const magic = new MagicByteScanner(); + scanner = host ? new CompositeScanner(magic, [new ClamAvScanner(host, Number(process.env.CLAMAV_PORT ?? 3310))]) : magic; + return scanner; +} diff --git a/src/server/services/documents/store.ts b/src/server/services/documents/store.ts new file mode 100644 index 0000000..d5c6640 --- /dev/null +++ b/src/server/services/documents/store.ts @@ -0,0 +1,201 @@ +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; +}