L1 Stammdaten: Dokumentenablage-Service und Download per documentId

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:26:27 +02:00
co-authored by Claude Opus 5
parent 1f8e6413fe
commit 49c5ad0e33
6 changed files with 719 additions and 0 deletions
+84
View File
@@ -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=<reason>`;
* 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<string, string>): 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}` : ""}` } });
}
+49
View File
@@ -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 <img> 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" } });
}
+44
View File
@@ -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<ActionState> {
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<ActionState> {
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" };
}
+183
View File
@@ -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<Prisma.DocumentWhereInput> {
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<string> {
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<ReturnType<typeof authorizeDocumentAccess>>; 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<typeof metaPatchSchema>) {
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;
}
+158
View File
@@ -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<ScanVerdict>;
}
/** Allowlisted MIME types → kind. */
export const ALLOWED_MIME: Record<string, DetectedKind> = {
"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<string, string> = {
"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<ScanVerdict> {
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<ScanVerdict> {
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<ScanVerdict> {
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;
}
+201
View File
@@ -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<DetectedKind, number> = { 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<StoreFileInput["links"]>) {
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<Document> {
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;
}