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,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