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

579 lines
23 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 fans out to the scan/preview queues. 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 "@toolctrl/foundation";
import { buildFileStorageKeys, buildUploadChunkKey, uploadChunkPrefix } from "@toolctrl/storage";
import {
FILE_UPLOAD_CHUNK_SIZE_BYTES,
FILE_UPLOAD_MAX_FILE_SIZE_BYTES,
} from "@toolctrl/view-models";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { assembleChunkedUpload, deleteFile, putBufferAsFile, resolveStorageBucket } from "./storageService";
import { enqueueFileScan } from "./fileScanQueue.server";
import { enqueueFilePreview } from "./filePreviewQueue.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";
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",
"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",
]);
export type FileVisibilityContext = {
userId: string;
/** The user's permission names for this tenant (from getPermissionsByUser). */
permissions: readonly string[];
};
function hasPermission(ctx: FileVisibilityContext, permission: string): boolean {
return ctx.permissions.includes(permission);
}
// --- 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");
}
if (!FILES_ALLOWED_MIME_TYPES.has(input.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, (tx) =>
tx.fileUpload.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
originalFilename: input.originalFilename,
totalSizeBytes: input.totalSizeBytes,
mimeType: input.mimeType,
totalChunks,
status: "in_progress",
expiresAt,
},
select: { id: true },
}),
);
return { uploadId: upload.id, chunkSize: FILE_UPLOAD_CHUNK_SIZE_BYTES, totalChunks };
}
export type StoreUploadChunkInput = {
tenantId: string;
userId: string;
uploadId: string;
chunkIndex: number;
bytes: Uint8Array;
};
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, 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 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 },
});
if (manifest.length !== session.totalChunks) {
throw new FileServiceError("files.error.uploadIncomplete");
}
// Verify contiguous 0..totalChunks-1 ordering.
for (let index = 0; index < manifest.length; index += 1) {
if (manifest[index].chunkIndex !== index) {
throw new FileServiceError("files.error.uploadIncomplete");
}
}
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, (tx) => tx.file.update({ where: { id: file.id }, data: { storageKey: original } }));
// 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 } });
});
// Fan out async scan (ClamAV, layer 2) + preview generation. When scanning is
// disabled (dev / SCAN_QUEUE_ENABLED!=true) the enqueue is a no-op; in that
// case mark the file scanStatus=skipped so the fail-closed media proxy (§6)
// still serves it. In prod the file stays scanStatus=pending until the worker
// writes a clean/infected verdict.
const scanResult = await enqueueFileScan({ bucket, key: original, tenantId: input.tenantId, contentType: upload.mimeType, fileId: file.id }).catch(
() => ({ enqueued: false, reason: "queue-unavailable" }) as const,
);
if (!scanResult.enqueued && scanResult.reason === "scan-disabled") {
await withTenantRLS(input.tenantId, (tx) => tx.file.update({ where: { id: file.id }, data: { scanStatus: "skipped" } })).catch(() => {});
}
await enqueueFilePreview({ fileId: file.id, tenantId: input.tenantId, bucket, storageKey: original, mimeType: upload.mimeType }).catch(() => {});
return { id: file.id, displayName: file.displayName, mimeType: file.mimeType, sizeBytes: file.sizeBytes, storageKey: original };
}
// --- 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;
};
/**
* Owner/link visibility for files.read_own (§7). Visible when the user owns or
* is assigned the file, OR holds files.read_all, OR a readable FileEntityLink
* exists. v1 link-readability is minimal (the link's mere existence in-tenant);
* full transitive entity-permission checks are v2.
*/
// 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> {
if (hasPermission(ctx, "files.read_all")) {
return true;
}
if (file.createdByUserId === ctx.userId || file.assignedToUserId === ctx.userId) {
return true;
}
const link = await tx.fileEntityLink.findFirst({
where: { fileId: file.id, tenantId: file.tenantId },
select: { id: true },
});
return Boolean(link);
}
export async function isFileVisible(file: Pick<FileRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId">, ctx: FileVisibilityContext): Promise<boolean> {
// Fast paths need no DB; only the link lookup opens a tenant-scoped transaction.
if (hasPermission(ctx, "files.read_all") || file.createdByUserId === ctx.userId || file.assignedToUserId === ctx.userId) {
return true;
}
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): Promise<FileRow> {
const file = await tx.file.findFirst({ where: { id: fileId, tenantId } });
if (!file) {
throw new CatalogError(foundationErrors.notFound, { fileId });
}
if (!(await isFileVisibleWithTx(tx, file as FileRow, ctx))) {
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 file = await getVisibleFileOrThrowWithTx(tx, tenantId, fileId, ctx);
const links = await tx.fileEntityLink.findMany({
where: { fileId, tenantId },
select: { entityType: true, entityId: true, linkRole: true },
orderBy: { createdAt: "asc" },
});
return { file, links: links as FileLink[] };
});
}
export type ListFilesFilter = {
mimeGroup?: string | null;
tags?: readonly string[];
assignedToUserId?: string | null;
includeArchived?: boolean;
};
const MIME_GROUP_PREFIX: Record<string, string> = { image: "image/", pdf: "application/pdf", text: "text/" };
/**
* List files for the browser (tenant-scoped + read_own visibility), newest
* first, cursor-paginated. Visibility for non-read_all users: owner/assignee or
* any linked file (v1 minimal, §7).
*/
export async function listFilesForBrowser(input: {
tenantId: string;
ctx: FileVisibilityContext;
filter?: ListFilesFilter;
cursor?: string | null;
limit?: number;
}): Promise<{ rows: FileRow[]; nextCursor: string | null }> {
const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
const filter = input.filter ?? {};
const visibility = hasPermission(input.ctx, "files.read_all")
? {}
: {
OR: [
{ createdByUserId: input.ctx.userId },
{ assignedToUserId: input.ctx.userId },
{ links: { some: { tenantId: input.tenantId } } },
],
};
const mimePrefix = filter.mimeGroup ? MIME_GROUP_PREFIX[filter.mimeGroup] : undefined;
const where = {
tenantId: input.tenantId,
...(filter.includeArchived ? {} : { archivedAt: null }),
...(filter.assignedToUserId ? { assignedToUserId: filter.assignedToUserId } : {}),
...(filter.tags && filter.tags.length > 0 ? { tags: { hasEvery: [...filter.tags] } } : {}),
...(mimePrefix ? { mimeType: { startsWith: mimePrefix } } : {}),
...visibility,
};
const rows = await withTenantRLS(input.tenantId, (tx) =>
tx.file.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
}),
);
const nextCursor = rows.length > limit ? rows[limit - 1].id : null;
return { rows: rows.slice(0, limit) as FileRow[], nextCursor };
}
// --- Mutations ------------------------------------------------------------
export async function linkFile(input: {
tenantId: string;
userId: string;
fileId: string;
entityType: FileEntityType;
entityId: string;
linkRole?: string | null;
}): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
// Ensure the file belongs to the tenant before linking.
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.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,
},
});
});
}
export async function unlinkFile(input: { tenantId: string; fileId: string; entityType: FileEntityType; entityId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
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, (tx) =>
tx.file.updateMany({ where: { id: input.fileId, tenantId: input.tenantId }, data: { archivedAt: new Date() } }),
);
}
export async function reactivateFile(input: { tenantId: string; fileId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.file.updateMany({ where: { id: input.fileId, tenantId: input.tenantId }, data: { archivedAt: null } }),
);
}
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, (tx) =>
tx.file.updateMany({
where: { id: input.fileId, tenantId: input.tenantId },
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) } : {}),
},
}),
);
}
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 },
});
if (!row) {
throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId });
}
if (row.previewStatus !== "failed") {
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.
await enqueueFilePreview({ fileId: file.id, tenantId: input.tenantId, bucket, storageKey: file.storageKey, mimeType: file.mimeType }).catch(() => {});
}
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) {
await deleteFile({ bucket, key }).catch(() => {});
}
}
// FileEntityLink rows cascade on File delete (schema onDelete: Cascade).
await withTenantRLS(input.tenantId, (tx) => tx.file.delete({ where: { id: 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);
}