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:
@@ -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}` : ""}` } });
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
Reference in New Issue
Block a user