// P4 Files (AK-P4a) §3/§7 — domain service for the Files module. // // Pure-ish server logic: builds/queries File rows (always tenant-scoped), // orchestrates chunked upload + assembly, enforces visibility (404, not 403), // and starts the scan -> clean -> preview queue chain. NO React/UI imports. Routes call // these functions from their loaders/actions and build the View-Model from the // returned rows. // // Tenant isolation is enforced THREEFOLD: every query carries `where: { tenantId }` // (app-level scoping), the storage keys carry the `tenants/{tenantId}/` prefix // (assertFileKeyBelongsToTenant), and every DB access runs inside withTenantRLS() // (R-P2b) so `app.tenant_id` is set for the policies shipped in // local/AK-P4a-merge-migration.sql. Until that RLS block is activated (P5 cutover) // the wrapper is a harmless no-op (a transaction that sets a GUC no policy reads); // it makes Files RLS-ready without changing runtime behaviour. // // Wrapping rules (so activation is safe): slow I/O (S3, queue) stays OUTSIDE the // transaction, and functions never nest withTenantRLS() — internal composition // goes through *WithTx(tx, ...) helpers that take the active transaction client. import { CatalogError, foundationErrors } from "@hivegrid/foundation"; import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox"; import { buildPortalDocumentReadyOutboxEvent } from "@hivegrid/queue"; import { filesIndex } from "@hivegrid/search"; import { buildFileStorageKeys, buildUploadChunkKey, uploadChunkPrefix } from "@hivegrid/storage"; import { FILE_UPLOAD_CHUNK_SIZE_BYTES, FILE_UPLOAD_MAX_FILE_SIZE_BYTES } from "@hivegrid/view-models"; import { publishFileSearchUpsert, publishSearchProjectionDelete } from "~/services/search/searchIndexProjection.server"; import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server"; import { getTenantUsers } from "~/utils/db/tenants.db.server"; import { fullName } from "~/utils/app/UserDisplayUtils"; import { createMediaDownloadUrl } from "./mediaSigning.server"; import type { Prisma } from "@hivegrid/db/prisma-browser"; import { assembleChunkedUpload, deleteFile, putBufferAsFile, resolveStorageBucket, storeFile } from "./storageService"; import { enqueueFileScan } from "./fileScanQueue.server"; import { enqueueFilePreview } from "./filePreviewQueue.server"; import { assertTenantStorageReservation, TenantStorageCapacityError } from "~/services/billing/storage-capacity.server"; import { createMagicBytesScanAdapter, DEFAULT_ALLOW_LIST, type PreUploadScanAdapter, type ScanContext } from "@hivegrid/storage"; import { assertFileLinkTargetVisibleWithTx, createFileVisibilityQueryState, fileReadScopeWhereWithTx, filterVisibleFileLinksWithTx, type FileVisibilityContext, type FileVisibilityQueryState, } from "./fileVisibility.server"; export type { FileVisibilityContext } from "./fileVisibility.server"; /** * Domain error carrying an i18n message key (M002 Klartext). Routes map * `messageKey` to a catalog error / VM error branch — never a bare HTTP code. */ export class FileServiceError extends Error { readonly messageKey: string; constructor(messageKey: string) { super(messageKey); this.name = "FileServiceError"; this.messageKey = messageKey; } } export type FileEntityType = | "contact" | "company" | "lead" | "deal" | "project" | "user" | "form_submission" | "order" | "task" | "appointment" | "signature_document" | "email_message" | "chat_message"; export type FileSource = "manual_upload" | "customer_portal" | "form_submission" | "email_attachment" | "automation" | "api"; /** * Curated upload allow-list for the Files module. Deliberately broader than the * shared scan `DEFAULT_ALLOW_LIST` (which is per-context and lacks HEIC) — Files * is a general document store and HEIC is mandatory (iPhone photos, AK-P4a). * The shared allow-list stays untouched (mergeable boilerplate). Layer-2 ClamAV * still scans the stored bytes regardless of declared MIME. */ export const FILES_ALLOWED_MIME_TYPES: ReadonlySet = new Set([ "image/png", "image/jpeg", "image/webp", "image/gif", "image/heic", "image/heif", "audio/webm", "audio/ogg", "audio/mpeg", "audio/mp4", "audio/wav", "application/pdf", "text/plain", "text/markdown", "text/csv", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", ]); const filesMagicBytesScanner: PreUploadScanAdapter = createMagicBytesScanAdapter({ allowList: { ...DEFAULT_ALLOW_LIST, formUpload: [...FILES_ALLOWED_MIME_TYPES], }, enabled: process.env.STORAGE_SCAN_LAYER1_ENABLED !== "false", }); // --- Upload session ------------------------------------------------------- export type CreateUploadSessionInput = { tenantId: string; userId: string; originalFilename: string; mimeType: string; totalSizeBytes: number; }; export type CreateUploadSessionResult = { uploadId: string; chunkSize: number; totalChunks: number; }; export async function createUploadSession(input: CreateUploadSessionInput): Promise { if (input.totalSizeBytes <= 0) { throw new FileServiceError("files.error.emptyFile"); } if (input.totalSizeBytes > FILE_UPLOAD_MAX_FILE_SIZE_BYTES) { throw new FileServiceError("files.error.tooLarge"); } const mimeType = normalizeUploadMimeType(input.mimeType); if (!FILES_ALLOWED_MIME_TYPES.has(mimeType)) { throw new FileServiceError("files.error.mimeBlocked"); } const totalChunks = Math.max(1, Math.ceil(input.totalSizeBytes / FILE_UPLOAD_CHUNK_SIZE_BYTES)); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); const upload = await withTenantRLS(input.tenantId, async (tx) => { try { await assertTenantStorageReservation(tx, input.tenantId, input.totalSizeBytes); } catch (error) { if (error instanceof TenantStorageCapacityError) { throw new FileServiceError( error.code === "STORAGE_PLAN_REQUIRED" ? "files.error.storagePlanRequired" : "files.error.storageQuotaExceeded", ); } throw error; } return tx.fileUpload.create({ data: { tenantId: input.tenantId, createdByUserId: input.userId, originalFilename: input.originalFilename, totalSizeBytes: input.totalSizeBytes, mimeType, totalChunks, status: "in_progress", expiresAt, }, select: { id: true }, }); }); return { uploadId: upload.id, chunkSize: FILE_UPLOAD_CHUNK_SIZE_BYTES, totalChunks }; } /** Browsers commonly append codec parameters to MediaRecorder MIME values. */ export function normalizeUploadMimeType(value: string): string { const mimeType = value.split(";", 1)[0]?.trim().toLowerCase() ?? ""; if (mimeType === "audio/x-m4a") return "audio/mp4"; if (mimeType === "audio/x-wav") return "audio/wav"; return mimeType; } export type StoreUploadChunkInput = { tenantId: string; userId: string; uploadId: string; chunkIndex: number; bytes: Uint8Array; }; export function expectedUploadChunkSize({ totalSizeBytes, totalChunks, chunkIndex, }: { totalSizeBytes: number; totalChunks: number; chunkIndex: number; }): number { if ( !Number.isSafeInteger(totalSizeBytes) || totalSizeBytes <= 0 || !Number.isSafeInteger(totalChunks) || totalChunks <= 0 || !Number.isSafeInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= totalChunks ) { throw new FileServiceError("files.error.uploadIncomplete"); } const expectedTotalChunks = Math.ceil(totalSizeBytes / FILE_UPLOAD_CHUNK_SIZE_BYTES); if (totalChunks !== expectedTotalChunks) { throw new FileServiceError("files.error.uploadIncomplete"); } if (chunkIndex < totalChunks - 1) { return FILE_UPLOAD_CHUNK_SIZE_BYTES; } return totalSizeBytes - FILE_UPLOAD_CHUNK_SIZE_BYTES * (totalChunks - 1); } export function assertUploadChunkManifest({ totalSizeBytes, totalChunks, chunks, }: { totalSizeBytes: number; totalChunks: number; chunks: readonly { chunkIndex: number; sizeBytes: number }[]; }): void { if (chunks.length !== totalChunks) { throw new FileServiceError("files.error.uploadIncomplete"); } let receivedSizeBytes = 0; for (let index = 0; index < chunks.length; index += 1) { const chunk = chunks[index]; if (chunk.chunkIndex !== index || chunk.sizeBytes !== expectedUploadChunkSize({ totalSizeBytes, totalChunks, chunkIndex: index })) { throw new FileServiceError("files.error.uploadIncomplete"); } receivedSizeBytes += chunk.sizeBytes; } if (receivedSizeBytes !== totalSizeBytes) { throw new FileServiceError("files.error.uploadIncomplete"); } } export async function storeUploadChunk(input: StoreUploadChunkInput): Promise<{ receivedChunks: number; totalChunks: number }> { const upload = await withTenantRLS(input.tenantId, (tx) => tx.fileUpload.findFirst({ where: { id: input.uploadId, tenantId: input.tenantId, createdByUserId: input.userId }, select: { id: true, status: true, totalChunks: true, totalSizeBytes: true, originalFilename: true, mimeType: true, expiresAt: true, }, }) ); if (!upload) { throw new CatalogError(foundationErrors.notFound, { uploadId: input.uploadId }); } if (upload.status !== "in_progress") { throw new FileServiceError("files.error.uploadNotActive"); } if (upload.expiresAt.getTime() < Date.now()) { throw new FileServiceError("files.error.uploadExpired"); } if (input.chunkIndex < 0 || input.chunkIndex >= upload.totalChunks) { throw new FileServiceError("files.error.chunkIndexOutOfRange"); } const expectedSizeBytes = expectedUploadChunkSize({ totalSizeBytes: upload.totalSizeBytes, totalChunks: upload.totalChunks, chunkIndex: input.chunkIndex, }); if (input.bytes.byteLength !== expectedSizeBytes) { throw new FileServiceError("files.error.uploadIncomplete"); } if (input.chunkIndex === 0) { const scanResult = await filesMagicBytesScanner.scan({ buffer: input.bytes, declaredMimeType: upload.mimeType, filename: upload.originalFilename, context: "formUpload", }); if (scanResult.status === "rejected") { throw new FileServiceError("files.error.mimeBlocked"); } } const bucket = resolveStorageBucket(); const chunkKey = buildUploadChunkKey(input.tenantId, input.uploadId, input.chunkIndex); // S3 PUT stays OUTSIDE the RLS transaction (no slow I/O inside a tx). await putBufferAsFile({ bucket, key: chunkKey, body: input.bytes, contentType: "application/octet-stream" }); // Persist the chunk + recount in one tenant-scoped transaction. const received = await withTenantRLS(input.tenantId, async (tx) => { // Upsert so a retried chunk does not double-count receivedChunks. const existing = await tx.fileUploadChunk.findUnique({ where: { uploadId_chunkIndex: { uploadId: input.uploadId, chunkIndex: input.chunkIndex } }, select: { id: true }, }); if (existing) { await tx.fileUploadChunk.update({ where: { uploadId_chunkIndex: { uploadId: input.uploadId, chunkIndex: input.chunkIndex } }, data: { storageKey: chunkKey, sizeBytes: input.bytes.byteLength }, }); } else { await tx.fileUploadChunk.create({ data: { tenantId: input.tenantId, uploadId: input.uploadId, chunkIndex: input.chunkIndex, storageKey: chunkKey, sizeBytes: input.bytes.byteLength, }, }); } const count = await tx.fileUploadChunk.count({ where: { uploadId: input.uploadId, tenantId: input.tenantId } }); await tx.fileUpload.update({ where: { id: input.uploadId }, data: { receivedChunks: count } }); return count; }); return { receivedChunks: received, totalChunks: upload.totalChunks }; } export type CompleteUploadInput = { tenantId: string; userId: string; uploadId: string; displayName?: string; source?: FileSource; createdByPortalUserId?: string | null; isDemoData?: boolean; }; export type CompletedFile = { id: string; displayName: string; mimeType: string; sizeBytes: number; storageKey: string; }; export async function completeUpload(input: CompleteUploadInput): Promise { // Segment 1 (DB): validate the session, read the chunk manifest and create the // pending File row. The File id is needed for the storage key, so the row is // created here (before S3 assembly). All contiguous DB work, no slow I/O. const { upload, chunks, file } = await withTenantRLS(input.tenantId, async (tx) => { const session = await tx.fileUpload.findFirst({ where: { id: input.uploadId, tenantId: input.tenantId, createdByUserId: input.userId }, select: { id: true, status: true, totalChunks: true, originalFilename: true, mimeType: true, totalSizeBytes: true }, }); if (!session) { throw new CatalogError(foundationErrors.notFound, { uploadId: input.uploadId }); } if (session.status !== "in_progress") { throw new FileServiceError("files.error.uploadNotActive"); } const manifest = await tx.fileUploadChunk.findMany({ where: { uploadId: input.uploadId, tenantId: input.tenantId }, orderBy: { chunkIndex: "asc" }, select: { chunkIndex: true, storageKey: true, sizeBytes: true }, }); assertUploadChunkManifest({ totalSizeBytes: session.totalSizeBytes, totalChunks: session.totalChunks, chunks: manifest, }); const created = await tx.file.create({ data: { tenantId: input.tenantId, createdByUserId: input.userId, displayName: (input.displayName ?? session.originalFilename).slice(0, 255), mimeType: session.mimeType, sizeBytes: session.totalSizeBytes, storageKey: "", // set after assembly below source: input.source ?? "manual_upload", createdByPortalUserId: input.createdByPortalUserId ?? null, isDemoData: input.isDemoData ?? false, previewStatus: "pending", scanStatus: "pending", }, select: { id: true, displayName: true, mimeType: true, sizeBytes: true }, }); return { upload: session, chunks: manifest, file: created }; }); const bucket = resolveStorageBucket(); const { original } = buildFileStorageKeys(input.tenantId, file.id); try { // S3 assembly stays OUTSIDE the RLS transaction. await assembleChunkedUpload({ bucket, targetKey: original, chunkSourceKeys: chunks.map((chunk) => chunk.storageKey), contentType: upload.mimeType, }); } catch (error) { // Roll the half-created File back so a failed assembly leaves no orphan row. await withTenantRLS(input.tenantId, async (tx) => { await tx.file.delete({ where: { id: file.id } }).catch(() => {}); await tx.fileUpload.update({ where: { id: input.uploadId }, data: { status: "failed", failureReason: "assembly-failed" } }).catch(() => {}); }).catch(() => {}); throw error instanceof FileServiceError ? error : new FileServiceError("files.error.assemblyFailed"); } await withTenantRLS(input.tenantId, async (tx) => { const updated = await tx.file.update({ where: { id: file.id }, data: { storageKey: original } }); await publishFileSearchUpsert(tx, updated); }); // Clean up temp chunk objects (best-effort, S3 outside the tx) + mark the // session done in one tenant-scoped transaction. await Promise.all(chunks.map((chunk) => deleteFile({ bucket, key: chunk.storageKey }).catch(() => {}))); await withTenantRLS(input.tenantId, async (tx) => { await tx.fileUploadChunk.deleteMany({ where: { uploadId: input.uploadId, tenantId: input.tenantId } }).catch(() => {}); await tx.fileUpload.update({ where: { id: input.uploadId }, data: { status: "completed", targetFileId: file.id } }); }); // Enqueue only ClamAV here. The scan worker persists a clean verdict before // it enqueues the first preview, so untrusted bytes can never race an image // or PDF decoder. Queue failures leave the file pending/non-deliverable. await enqueueFileScan({ bucket, key: original, tenantId: input.tenantId, contentType: upload.mimeType, fileId: file.id }).catch(() => undefined); return { id: file.id, displayName: file.displayName, mimeType: file.mimeType, sizeBytes: file.sizeBytes, storageKey: original }; } export type CreateFileFromBytesInput = { tenantId: string; userId: string; filename: string; mimeType: string; bytes: Uint8Array; /** Pre-upload scan context (magic-bytes allow-list); defaults to mediaLibrary. */ scanContext?: ScanContext; isDemoData?: boolean; }; /** * Single-request upload: create a File from an in-memory byte buffer (no chunked * session). Mirrors the finalize tail of {@link completeUpload} — pending row → * fail-closed magic-bytes scan + storage PUT → finalize storageKey + search * upsert → enqueue ClamAV (layer 2), which schedules preview only after a * clean verdict. Callers gate on their OWN * permission (e.g. HR self-upload gates on hr.read_own, NOT files.write). A * rejected scan throws {@link StorageScanRejectedError} and leaves no orphan row. */ export async function createFileFromBytes(input: CreateFileFromBytesInput): Promise<{ id: string; displayName: string; mimeType: string; sizeBytes: number }> { const displayName = (input.filename || "upload").slice(0, 255); const mimeType = input.mimeType || "application/octet-stream"; const sizeBytes = input.bytes.byteLength; // 1. Pending File row (id needed for the storage key). const file = await withTenantRLS(input.tenantId, async (tx) => { try { await assertTenantStorageReservation(tx, input.tenantId, sizeBytes); } catch (error) { if (error instanceof TenantStorageCapacityError) { throw new FileServiceError( error.code === "STORAGE_PLAN_REQUIRED" ? "files.error.storagePlanRequired" : "files.error.storageQuotaExceeded", ); } throw error; } return tx.file.create({ data: { tenantId: input.tenantId, createdByUserId: input.userId, displayName, mimeType, sizeBytes, storageKey: "", source: "manual_upload", isDemoData: input.isDemoData ?? false, previewStatus: "pending", scanStatus: "pending", }, select: { id: true, displayName: true, mimeType: true, sizeBytes: true }, }); }); const bucket = resolveStorageBucket(); const { original } = buildFileStorageKeys(input.tenantId, file.id); // 2. Scan (fail-closed) + store the bytes. storeFile fetches the data: URI, // runs the magic-bytes allow-list and PUTs to `original`; a rejected verdict // throws StorageScanRejectedError. Roll the pending row back on any failure. const dataUri = `data:${mimeType};base64,${Buffer.from(input.bytes).toString("base64")}`; try { await storeFile({ bucket, id: original, content: dataUri, replace: true, throwOnFailure: true, scan: { context: input.scanContext ?? "mediaLibrary", declaredMimeType: mimeType, filename: displayName, tenantId: input.tenantId }, }); } catch (error) { await withTenantRLS(input.tenantId, (tx) => tx.file.delete({ where: { id: file.id } }).catch(() => {})).catch(() => {}); throw error; } // 3. Finalize the storage key + publish to the search index. await withTenantRLS(input.tenantId, async (tx) => { const updated = await tx.file.update({ where: { id: file.id }, data: { storageKey: original } }); await publishFileSearchUpsert(tx, updated); }); // 4. Enqueue ClamAV only. The clean-verdict worker schedules the preview. await enqueueFileScan({ bucket, key: original, tenantId: input.tenantId, contentType: mimeType, fileId: file.id }).catch(() => undefined); return { id: file.id, displayName: file.displayName, mimeType: file.mimeType, sizeBytes: file.sizeBytes }; } // --- Visibility / read ---------------------------------------------------- export type FileRow = { id: string; tenantId: string; createdByUserId: string; assignedToUserId: string | null; displayName: string; mimeType: string; sizeBytes: number; storageKey: string; previewStorageKey: string | null; thumbnailStorageKey: string | null; previewStatus: string; scanStatus: string; tags: string[]; notes: string | null; source: string; createdAt: Date; archivedAt: Date | null; }; export type OwnedFileScanStatus = "pending" | "clean" | "infected" | "skipped"; /** Narrow post-upload status lookup for callers that created the File. It is * tenant-scoped, owner-scoped and RLS-bound so polling cannot become a generic * File enumeration side channel. */ export async function getOwnedFileScanStatus(input: { tenantId: string; userId: string; fileId: string }): Promise { const file = await withTenantRLS(input.tenantId, (tx) => tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId, createdByUserId: input.userId, archivedAt: null, }, select: { scanStatus: true }, }) ); if (!file) throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); return file.scanStatus as OwnedFileScanStatus; } // Internal: visibility check on an already-loaded file, using the active RLS // transaction client (no own transaction → safe to call inside withTenantRLS). async function isFileVisibleWithTx( tx: TenantRlsClient, file: Pick, ctx: FileVisibilityContext ): Promise { const visibility = await fileReadScopeWhereWithTx(tx, file.tenantId, ctx, [file.id]); const visible = await tx.file.findFirst({ where: { id: file.id, tenantId: file.tenantId, ...visibility }, select: { id: true }, }); return Boolean(visible); } export async function isFileVisible( file: Pick, ctx: FileVisibilityContext ): Promise { return withTenantRLS(file.tenantId, (tx) => isFileVisibleWithTx(tx, file, ctx)); } // Internal: load + visibility on the active transaction client (no own tx). async function getVisibleFileOrThrowWithTx( tx: TenantRlsClient, tenantId: string, fileId: string, ctx: FileVisibilityContext, queryState?: FileVisibilityQueryState ): Promise { const visibility = await fileReadScopeWhereWithTx(tx, tenantId, ctx, [fileId], queryState); const file = await tx.file.findFirst({ where: { id: fileId, tenantId, ...visibility } }); if (!file) { throw new CatalogError(foundationErrors.notFound, { fileId }); } return file as FileRow; } /** Load a single file (tenant-scoped) and enforce visibility, else 404. */ export async function getVisibleFileOrThrow(tenantId: string, fileId: string, ctx: FileVisibilityContext): Promise { return withTenantRLS(tenantId, (tx) => getVisibleFileOrThrowWithTx(tx, tenantId, fileId, ctx)); } export type FileLink = { entityType: string; entityId: string; linkRole: string | null }; /** Load a file with its entity links (tenant-scoped + visibility), else 404. */ export async function getVisibleFileWithLinks(tenantId: string, fileId: string, ctx: FileVisibilityContext): Promise<{ file: FileRow; links: FileLink[] }> { return withTenantRLS(tenantId, async (tx) => { const queryState = createFileVisibilityQueryState(); const file = await getVisibleFileOrThrowWithTx(tx, tenantId, fileId, ctx, queryState); const links = await tx.fileEntityLink.findMany({ where: { fileId, tenantId }, select: { entityType: true, entityId: true, linkRole: true }, orderBy: { createdAt: "asc" }, }); const visibleLinks = await filterVisibleFileLinksWithTx(tx, tenantId, links, ctx, queryState); return { file, links: visibleLinks }; }); } export type ListFilesFilter = { entityType?: string | null; mimeGroup?: string | null; tags?: readonly string[]; assignedToUserId?: string | null; includeArchived?: boolean; /** Free-text search over the display name (case-insensitive substring). */ q?: string | null; }; const MIME_GROUP_PREFIX: Record = { image: "image/", pdf: "application/pdf", text: "text/" }; const FILE_TAG_SCAN_LIMIT = 500; /** Rows per browser page — one screenful of grid tiles, not an endless table. */ export const FILE_BROWSER_PAGE_SIZE = 24; function canListFileAssignees(ctx: FileVisibilityContext): boolean { return ["files.read_all", "files.admin", "app.settings.members.view"].some((permission) => ctx.permissions.includes(permission)); } export type ListFilesForBrowserInput = { tenantId: string; ctx: FileVisibilityContext; filter?: ListFilesFilter; /** 1-based page of the browser listing. Out-of-range values clamp to page 1. */ page?: number | null; limit?: number; }; /** Rows of one page plus what a page footer needs to say where the user is. */ export type FileBrowserPage = { rows: FileRow[]; total: number; page: number; pageCount: number; pageSize: number }; function buildFileBrowserWhere(input: ListFilesForBrowserInput, visibility: Prisma.FileWhereInput): Prisma.FileWhereInput { const filter = input.filter ?? {}; const mimePrefix = filter.mimeGroup ? MIME_GROUP_PREFIX[filter.mimeGroup] : undefined; // Keep optional filters on a typed mutable object so they compose cleanly // with the central visibility scope. const where: Prisma.FileWhereInput = { tenantId: input.tenantId }; if (!filter.includeArchived) where.archivedAt = null; if (filter.assignedToUserId) where.assignedToUserId = filter.assignedToUserId; if (filter.tags && filter.tags.length > 0) where.tags = { hasEvery: [...filter.tags] }; if (mimePrefix) where.mimeType = { startsWith: mimePrefix }; // The search box matches what the browser actually shows — the display name. // A blank query is no filter at all, not a match on the empty string. const q = filter.q?.trim(); if (q) where.displayName = { contains: q, mode: "insensitive" }; if (filter.entityType) { where.links = { some: { tenantId: input.tenantId, entityType: filter.entityType as Prisma.FileEntityLinkWhereInput["entityType"] } }; } Object.assign(where, visibility); return where; } async function listFilesForBrowserWithTx( tx: TenantRlsClient, input: ListFilesForBrowserInput, visibility: Prisma.FileWhereInput ): Promise { const pageSize = Math.min(Math.max(input.limit ?? FILE_BROWSER_PAGE_SIZE, 1), 100); const where = buildFileBrowserWhere(input, visibility); const total = await tx.file.count({ where }); const pageCount = Math.max(1, Math.ceil(total / pageSize)); // A page number past the end (stale link, filter narrowed after paging) must // land on a page that exists instead of on an empty list that reads like // "no files". const requested = Math.trunc(input.page ?? 1); const page = Math.min(Math.max(Number.isFinite(requested) ? requested : 1, 1), pageCount); const rows = await tx.file.findMany({ where, // `id` breaks ties: two files uploaded in the same millisecond would // otherwise be free to swap places between two page queries and appear // twice, or not at all. orderBy: [{ createdAt: "desc" }, { id: "desc" }], skip: (page - 1) * pageSize, take: pageSize, }); return { rows: rows as FileRow[], total, page, pageCount, pageSize }; } /** * List one page of files for the browser (tenant-scoped + read_own visibility), * newest first. Project links only extend visibility when their target project * is readable under the same project and pipeline access rules. */ export async function listFilesForBrowser(input: ListFilesForBrowserInput): Promise { return withTenantRLS(input.tenantId, async (tx) => { const visibility = await fileReadScopeWhereWithTx(tx, input.tenantId, input.ctx); return listFilesForBrowserWithTx(tx, input, visibility); }); } /** Ids of the files sitting next to one file in the browser's own ordering. */ export type FileNeighbours = { previousId: string | null; nextId: string | null }; /** * The file before and after `fileId` in the browser's order (newest first, id as * tie-break), so the detail view can page through the library without going back * to the list. Same visibility scope as the browser: a neighbour the reader may * not open is not a neighbour. Archived files stay among their own kind — from * an archived file the reader pages through the archive, not into the live list. */ export async function findFileNeighbours(input: { tenantId: string; ctx: FileVisibilityContext; fileId: string }): Promise { return withTenantRLS(input.tenantId, async (tx) => { const visibility = await fileReadScopeWhereWithTx(tx, input.tenantId, input.ctx); const current = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId, ...visibility }, select: { id: true, createdAt: true, archivedAt: true }, }); if (!current) return { previousId: null, nextId: null }; const base: Prisma.FileWhereInput = { tenantId: input.tenantId, ...(current.archivedAt ? { NOT: { archivedAt: null } } : { archivedAt: null }), ...visibility, }; // Composite (createdAt, id) comparison — `createdAt` alone would skip or // repeat files uploaded within the same millisecond. const [previous, next] = await Promise.all([ tx.file.findFirst({ where: { ...base, OR: [{ createdAt: { gt: current.createdAt } }, { createdAt: current.createdAt, id: { gt: current.id } }] }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { id: true }, }), tx.file.findFirst({ where: { ...base, OR: [{ createdAt: { lt: current.createdAt } }, { createdAt: current.createdAt, id: { lt: current.id } }] }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], select: { id: true }, }), ]); return { previousId: previous?.id ?? null, nextId: next?.id ?? null }; }); } export type FileFilterOptions = { /** Distinct entity types that are actually linked to files in this tenant. */ entityTypes: string[]; /** Distinct tags across (recent) tenant files, sorted. */ tags: string[]; /** Active tenant members, usable as assignee (Verantwortlicher) filter. */ assignees: { id: string; name: string }[]; }; type FileFilterRows = { tagRows: Array<{ tags: string[] }>; entityTypeRows: Array<{ entityType: string }>; }; async function loadFileFilterRowsWithTx(tx: TenantRlsClient, tenantId: string, visibility: Prisma.FileWhereInput): Promise { const [tagRows, entityTypeRows] = await Promise.all([ tx.file.findMany({ where: { tenantId, ...visibility }, select: { tags: true }, orderBy: { createdAt: "desc" }, take: FILE_TAG_SCAN_LIMIT, }), tx.fileEntityLink.findMany({ where: { tenantId, file: { is: { tenantId, ...visibility } } }, select: { entityType: true }, distinct: ["entityType"], orderBy: { entityType: "asc" }, }), ]); return { tagRows, entityTypeRows }; } function buildFileFilterOptions(rows: FileFilterRows, members: Awaited>): FileFilterOptions { const tags = Array.from(new Set(rows.tagRows.flatMap((row) => row.tags))).sort((a, b) => a.localeCompare(b)); const entityTypes = rows.entityTypeRows.map((row) => row.entityType); const assignees = members.map((member) => ({ id: member.user.id, name: fullName(member.user) })).sort((a, b) => a.name.localeCompare(b.name)); return { entityTypes, tags, assignees }; } /** * Build the option lists for the browser filter dropdowns (tenant-scoped). * Tags are scanned over the most recent files (capped) so the dropdown stays * bounded; entity types come from the FileEntityLink distinct set; assignees are * the tenant's active members. */ export async function listFileFilterOptions(input: { tenantId: string; ctx: FileVisibilityContext }): Promise { if (!input.ctx.permissions.some((permission) => ["files.read_own", "files.read_all"].includes(permission))) { return { entityTypes: [], tags: [], assignees: [] }; } const [rows, members] = await Promise.all([ withTenantRLS(input.tenantId, async (tx) => { const visibility = await fileReadScopeWhereWithTx(tx, input.tenantId, input.ctx); return loadFileFilterRowsWithTx(tx, input.tenantId, visibility); }), canListFileAssignees(input.ctx) ? getTenantUsers(input.tenantId) : Promise.resolve>>([]), ]); return buildFileFilterOptions(rows, members); } /** * Load browser rows and filter metadata from one visibility snapshot. This * avoids rebuilding project/file visibility independently for the two queries. */ export async function loadFileBrowserData( input: ListFilesForBrowserInput ): Promise { const canReadFiles = input.ctx.permissions.some((permission) => ["files.read_own", "files.read_all"].includes(permission)); const [scopedData, members] = await Promise.all([ withTenantRLS(input.tenantId, async (tx) => { const visibility = await fileReadScopeWhereWithTx(tx, input.tenantId, input.ctx); const [browser, filterRows] = await Promise.all([ listFilesForBrowserWithTx(tx, input, visibility), canReadFiles ? loadFileFilterRowsWithTx(tx, input.tenantId, visibility) : Promise.resolve({ tagRows: [], entityTypeRows: [] }), ]); return { browser, filterRows }; }), canReadFiles && canListFileAssignees(input.ctx) ? getTenantUsers(input.tenantId) : Promise.resolve>>([]), ]); return { ...scopedData.browser, filterOptions: buildFileFilterOptions(scopedData.filterRows, members), }; } // --- Bulk ZIP ------------------------------------------------------------- /** Max files a single bulk-ZIP may contain. */ export const BULK_ZIP_MAX_FILES = 50; /** Max total original bytes a single bulk-ZIP may pull. */ export const BULK_ZIP_MAX_TOTAL_BYTES = 200 * 1024 * 1024; export const BULK_ZIP_DOWNLOAD_CONCURRENCY = 4; export const BULK_ZIP_DOWNLOAD_TIMEOUT_MS = 15_000; export type BulkZipEntry = { name: string; bytes: Uint8Array }; export type BulkZipResult = { /** Zip entries for the buildable (visible + clean) files. */ entries: BulkZipEntry[]; /** Files that were requested but skipped (not visible / not clean / over the cap). */ skippedCount: number; }; export async function readBulkZipResponseBytes(response: Response, maxBytes: number): Promise { if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { throw new TypeError("Bulk ZIP response limit must be a non-negative safe integer."); } const contentLength = response.headers.get("content-length"); if (contentLength && /^\d+$/u.test(contentLength) && Number(contentLength) > maxBytes) { await response.body?.cancel().catch(() => undefined); throw new FileServiceError("files.errors.downloadFailed"); } if (!response.body) { return new Uint8Array(); } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let totalBytes = 0; while (true) { const chunk = await reader.read(); if (chunk.done) break; totalBytes += chunk.value.byteLength; if (totalBytes > maxBytes) { await reader.cancel().catch(() => undefined); throw new FileServiceError("files.errors.downloadFailed"); } chunks.push(chunk.value); } const bytes = new Uint8Array(totalBytes); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes; } /** * Collect the byte payloads for a bulk-ZIP download (tenant-scoped, fail-closed). * Only files that are visible to the user AND scan-clean are included; an * infected/pending/invisible/over-cap file is silently dropped (never leaked into * the archive). Bytes are pulled via short-lived signed URLs (AD-23, no public * bucket). Entry names are de-duplicated so the zip stays valid. */ export async function collectFilesForZip(input: { tenantId: string; ctx: FileVisibilityContext; fileIds: readonly string[] }): Promise { const ids = Array.from(new Set(input.fileIds)).slice(0, BULK_ZIP_MAX_FILES); if (ids.length === 0) { return { entries: [], skippedCount: 0 }; } const where: Prisma.FileWhereInput = { tenantId: input.tenantId, id: { in: ids }, scanStatus: "clean", }; const files = await withTenantRLS(input.tenantId, async (tx) => { Object.assign(where, await fileReadScopeWhereWithTx(tx, input.tenantId, input.ctx, ids)); return tx.file.findMany({ where, select: { id: true, displayName: true, storageKey: true, sizeBytes: true } }); }); const candidates: typeof files = []; let reservedBytes = 0; let skippedCount = ids.length - files.length; for (const file of files) { if (!file.storageKey || file.sizeBytes < 0 || reservedBytes + file.sizeBytes > BULK_ZIP_MAX_TOTAL_BYTES) { skippedCount += 1; continue; } reservedBytes += file.sizeBytes; candidates.push(file); } const downloaded: Array<{ file: (typeof files)[number]; bytes: Uint8Array } | null> = []; for (let offset = 0; offset < candidates.length; offset += BULK_ZIP_DOWNLOAD_CONCURRENCY) { const batch = candidates.slice(offset, offset + BULK_ZIP_DOWNLOAD_CONCURRENCY); downloaded.push( ...(await Promise.all( batch.map(async (file) => { try { const url = await createMediaDownloadUrl({ key: file.storageKey! }); const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(BULK_ZIP_DOWNLOAD_TIMEOUT_MS), }); if (!response.ok) { await response.body?.cancel().catch(() => undefined); return null; } return { file, bytes: await readBulkZipResponseBytes(response, file.sizeBytes) }; } catch { return null; } }) )) ); } const usedNames = new Set(); const entries: BulkZipEntry[] = []; for (const result of downloaded) { if (!result) { skippedCount += 1; continue; } entries.push({ name: uniqueZipName(result.file.displayName, usedNames), bytes: result.bytes }); } return { entries, skippedCount }; } /** Ensure a zip entry name is unique (append " (n)" before the extension). */ function uniqueZipName(rawName: string, used: Set): string { const safe = (rawName || "datei").replace(/[/\\]/g, "_"); if (!used.has(safe)) { used.add(safe); return safe; } const dot = safe.lastIndexOf("."); const base = dot > 0 ? safe.slice(0, dot) : safe; const ext = dot > 0 ? safe.slice(dot) : ""; let index = 2; let candidate = `${base} (${index})${ext}`; while (used.has(candidate)) { index += 1; candidate = `${base} (${index})${ext}`; } used.add(candidate); return candidate; } // --- Mutations ------------------------------------------------------------ export async function linkFile(input: { tenantId: string; userId: string; fileId: string; entityType: FileEntityType; entityId: string; linkRole?: string | null; ctx: FileVisibilityContext; }): Promise { await withTenantRLS(input.tenantId, async (tx) => { await getVisibleFileOrThrowWithTx(tx, input.tenantId, input.fileId, input.ctx); await assertFileLinkTargetVisibleWithTx(tx, input.tenantId, input.entityType, input.entityId, input.ctx); const link = await tx.fileEntityLink.upsert({ where: { fileId_entityType_entityId: { fileId: input.fileId, entityType: input.entityType, entityId: input.entityId } }, update: { linkRole: input.linkRole ?? null }, create: { tenantId: input.tenantId, createdByUserId: input.userId, fileId: input.fileId, entityType: input.entityType, entityId: input.entityId, linkRole: input.linkRole ?? null, }, select: { id: true, createdAt: true }, }); if (input.entityType === "project") { const readyInternalFile = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId, archivedAt: null, scanStatus: "clean", createdByPortalUserId: null, source: { not: "customer_portal" }, }, select: { id: true }, }); if (readyInternalFile) { await publishOutboxEvent( tx as unknown as OutboxPublisherTransaction, buildPortalDocumentReadyOutboxEvent({ tenantId: input.tenantId, projectId: input.entityId, fileId: input.fileId, fileEntityLinkId: link.id, linkVersion: link.createdAt, userId: input.userId, }) ); } } }); } export async function unlinkFile(input: { tenantId: string; fileId: string; entityType: FileEntityType; entityId: string; ctx: FileVisibilityContext; }): Promise { await withTenantRLS(input.tenantId, async (tx) => { await getVisibleFileOrThrowWithTx(tx, input.tenantId, input.fileId, input.ctx); await assertFileLinkTargetVisibleWithTx(tx, input.tenantId, input.entityType, input.entityId, input.ctx); await tx.fileEntityLink.deleteMany({ where: { tenantId: input.tenantId, fileId: input.fileId, entityType: input.entityType, entityId: input.entityId }, }); }); } export async function archiveFile(input: { tenantId: string; fileId: string }): Promise { await withTenantRLS(input.tenantId, async (tx) => { const file = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true } }); if (!file) { throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); } await tx.file.update({ where: { id: file.id }, data: { archivedAt: new Date() } }); await publishSearchProjectionDelete(tx, { schema: filesIndex, tenantId: input.tenantId, entityType: "file", entityId: file.id }); }); } export async function reactivateFile(input: { tenantId: string; fileId: string }): Promise { await withTenantRLS(input.tenantId, async (tx) => { const file = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true } }); if (!file) { throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); } const updated = await tx.file.update({ where: { id: file.id }, data: { archivedAt: null } }); await publishFileSearchUpsert(tx, updated); }); } export async function updateFileMeta(input: { tenantId: string; fileId: string; tags?: string[]; notes?: string | null; assignedToUserId?: string | null; displayName?: string; }): Promise { await withTenantRLS(input.tenantId, async (tx) => { const file = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true } }); if (!file) { throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); } const updated = await tx.file.update({ where: { id: file.id }, data: { ...(input.tags !== undefined ? { tags: input.tags } : {}), ...(input.notes !== undefined ? { notes: input.notes } : {}), ...(input.assignedToUserId !== undefined ? { assignedToUserId: input.assignedToUserId } : {}), ...(input.displayName !== undefined ? { displayName: input.displayName.slice(0, 255) } : {}), }, }); await publishFileSearchUpsert(tx, updated); }); } export async function regenerateFilePreview(input: { tenantId: string; fileId: string }): Promise { const file = await withTenantRLS(input.tenantId, async (tx) => { const row = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true, storageKey: true, mimeType: true, previewStatus: true, scanStatus: true }, }); if (!row) { throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); } if (row.previewStatus !== "failed") { throw new FileServiceError("files.error.previewNotRegenerable"); } if (row.scanStatus !== "clean") { throw new FileServiceError("files.error.previewNotRegenerable"); } await tx.file.update({ where: { id: row.id }, data: { previewStatus: "pending" } }); return row; }); const bucket = resolveStorageBucket(); // Enqueue stays OUTSIDE the RLS transaction. try { const result = await enqueueFilePreview({ fileId: file.id, tenantId: input.tenantId, bucket, storageKey: file.storageKey, mimeType: file.mimeType }); if (!result.enqueued) { throw new Error("File preview queue is unavailable"); } } catch { await withTenantRLS(input.tenantId, (tx) => tx.file.updateMany({ where: { id: file.id, tenantId: input.tenantId, previewStatus: "pending" }, data: { previewStatus: "failed" } }) ).catch(() => {}); throw new FileServiceError("files.error.previewNotRegenerable"); } } export async function hardDeleteFile(input: { tenantId: string; fileId: string }): Promise { const file = await withTenantRLS(input.tenantId, (tx) => tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true, storageKey: true, previewStorageKey: true, thumbnailStorageKey: true }, }) ); if (!file) { throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId }); } const bucket = resolveStorageBucket(); // S3 deletes stay OUTSIDE the RLS transaction. for (const key of [file.storageKey, file.previewStorageKey, file.thumbnailStorageKey]) { if (key) { // Keep the database row (and therefore the tenant's quota accounting) // until every durable object has been removed. S3 deletes are // idempotent, so a failed request can safely be retried without leaving // an untracked, still-billable object behind. await deleteFile({ bucket, key }); } } // FileEntityLink rows cascade on File delete (schema onDelete: Cascade). await withTenantRLS(input.tenantId, async (tx) => { await tx.file.delete({ where: { id: file.id } }); await publishSearchProjectionDelete(tx, { schema: filesIndex, tenantId: input.tenantId, entityType: "file", entityId: file.id }); }); } /** Best-effort cleanup of all temp chunk objects of an upload session. */ export async function purgeUploadChunks(tenantId: string, uploadId: string, chunkKeys: readonly string[]): Promise { const bucket = resolveStorageBucket(); await Promise.all(chunkKeys.map((key) => deleteFile({ bucket, key }).catch(() => {}))); void uploadChunkPrefix(tenantId, uploadId); }