Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/fileService.server.ts
T

1170 lines
46 KiB
TypeScript

// 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<string> = 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<CreateUploadSessionResult> {
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<CompletedFile> {
// 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<OwnedFileScanStatus> {
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<FileRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId">,
ctx: FileVisibilityContext
): Promise<boolean> {
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<FileRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId">,
ctx: FileVisibilityContext
): Promise<boolean> {
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<FileRow> {
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<FileRow> {
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<string, string> = { 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<FileBrowserPage> {
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<FileBrowserPage> {
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<FileNeighbours> {
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<FileFilterRows> {
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<ReturnType<typeof getTenantUsers>>): 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<FileFilterOptions> {
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<Awaited<ReturnType<typeof getTenantUsers>>>([]),
]);
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<FileBrowserPage & { filterOptions: FileFilterOptions }> {
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<FileFilterRows>({ tagRows: [], entityTypeRows: [] }),
]);
return { browser, filterRows };
}),
canReadFiles && canListFileAssignees(input.ctx) ? getTenantUsers(input.tenantId) : Promise.resolve<Awaited<ReturnType<typeof getTenantUsers>>>([]),
]);
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<Uint8Array> {
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<BulkZipResult> {
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<string>();
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>): 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const bucket = resolveStorageBucket();
await Promise.all(chunkKeys.map((key) => deleteFile({ bucket, key }).catch(() => {})));
void uploadChunkPrefix(tenantId, uploadId);
}