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