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" } });
}