import { Inject, Injectable } from "@nestjs/common"; import { randomBytes } from "node:crypto"; import type { Request } from "express"; // Root-barrel import (NOT `@factory/storage/…` subpaths): the API compiles // with `moduleResolution: node10`, which resolves a package's `main`/`types` // but not its `exports` subpaths. The barrel re-exports every submodule. import { buildFileKey, isAllowedMime, parseFileKey, presignPut, sanitizeFilename, sniffVerdict, } from "@factory/storage"; import { LIMITS } from "@factory/shared"; import { jobTraceMetadata } from "../../common/trace-metadata.js"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { RedisService } from "../../common/redis.service.js"; import { ApiError } from "../../common/errors.js"; import { logger } from "../../common/logger.js"; import { assertCan } from "../../lib/authorize.js"; import { loadEnv } from "../../config/env.js"; import { filesBucketConfig, makeFilesClient, signExpirySeconds } from "./s3.js"; import { PLAN_USAGE_LIMITS, adjustWorkspaceStorageBytes, authoritativeStoredBytes, effectiveStorageQuota, } from "./quota.js"; import { toFileRecord } from "./record.js"; import { effectiveMaxFileBytes, workspaceOrgPolicy } from "../org/org-policy.service.js"; // Re-exported so existing importers (files-limits.test.ts) keep resolving the // plan-limit helpers from the module that historically owned them; the // canonical home is now modules/files/quota.ts (TASK-0104 §4 — shared by the // workspace usage endpoint too). export { PLAN_USAGE_LIMITS, effectiveStorageQuota } from "./quota.js"; import type { CancelUploadInput, CompleteUploadInput, CreateUploadInput, } from "./dto.js"; /** * TASK-0101 — upload sessions (the API half of the two-phase upload). * * POST /files/uploads → 201 { upload } * PUT /files/uploads/:id/complete → HEAD + 8 KiB sniff → SCANNING + job * POST /files/uploads/:id/cancel → delete partial object + row * * State machine (enforced here; the worker owns SCANNING → READY|…): * UPLOADING → SCANNING (complete) → (worker) READY | VIRUS_DETECTED | FAILED * UPLOADING → FAILED / deleted (cancel) * * Security model (spec §Security): the raw object key is NEVER returned to * the client; only a query-string signed PUT URL (bound to the key + the * declared content-type) leaves the server. The claimed mimeType is checked * against the closed allow-list at session creation; the BYTES are checked * against magic-byte sniffing at completion (sniffing is authoritative — * mismatch = FAILED `MIME_MISMATCH` + object deleted, never "trust and warn"). */ /** * Effective per-file size limit = min(usageLimits.maxFileSize, plan default, * hard ceiling) — usageLimits is an admin OVERRIDE that may only tighten a * plan's allowance, never exceed it. (The plan ceiling + the storage-quota * min-rule now live in `./quota.ts`; re-exported above.) */ export function effectiveMaxFileSize( billingPlan: string, usageLimits: { maxFileSize?: number } | null, ): number { const plan = PLAN_USAGE_LIMITS[billingPlan] ?? PLAN_USAGE_LIMITS.free!; const override = usageLimits?.maxFileSize; return Math.min( plan.maxFileSize, typeof override === "number" && override > 0 ? override : Infinity, LIMITS.MAX_FILE_SIZE, ); } @Injectable() export class UploadsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, @Inject(RedisService) private readonly redis: RedisService, ) {} /** * Open an upload session. Auth + EDIT on the anchor (page/block, or the * workspace root when unattached), plan size limit (413 FILE_TOO_LARGE), * storage quota (413 QUOTA_EXCEEDED), closed MIME allow-list * (400 mime_not_allowed). Creates the File row (UPLOADING) and returns a * query-string signed PUT URL (900 s, content-type bound). */ async create(userId: string, input: CreateUploadInput, req: Request) { const workspaceId = this.workspaceOf(req); await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); const ws = await this.prisma.workspace.findUnique({ where: { id: workspaceId } }); if (!ws || ws.deletedAt) throw ApiError.notFound("Workspace not found."); // Resolve the anchor (block ⇒ its page) and require EDIT there — the // file inherits the anchor's permission scope (spec req. 3). `null` (an // explicit "not attached", CreateUploadDto) is normalized to `undefined`. let pageId: string | undefined = input.pageId ?? undefined; let blockId: string | undefined = input.blockId ?? undefined; if (blockId) { const block = await this.prisma.block.findUnique({ where: { id: blockId } }); if (!block || block.pageId !== pageId) { throw ApiError.notFound("Anchor block not found in this workspace."); } pageId = block.pageId; } if (pageId) { const page = await this.prisma.page.findUnique({ where: { id: pageId } }); if (!page || page.workspaceId !== workspaceId) { throw ApiError.notFound("Anchor page not found in this workspace."); } } // A block anchors to its page (authorization scopes by page — blocks // have no scope of their own); `blockId` is kept for the File row + audit. await assertCan(this.prisma, { workspaceId, userId, resource: { pageId }, }, "EDIT"); // ── plan limits ──────────────────────────────────────────────────── const limits = (ws.usageLimits ?? {}) as { maxFileSize?: number; storageQuota?: number }; const planMaxFileSize = effectiveMaxFileSize(ws.billingPlan, limits); // TASK-0133 enforcement #3: an org `maxFileSizeMB` policy CAPS the plan // allowance (org cap ∩ plan cap — the policy can only TIGHTEN, never // raise). Authoritative (413) — the file picker's warning is UX only. // (The policy read is FRESH, no cache — a security check.) const { policy } = await workspaceOrgPolicy(this.prisma, workspaceId); const maxFileSize = Math.min( planMaxFileSize, effectiveMaxFileBytes(policy, ws.billingPlan), ); if (input.size > maxFileSize) { throw new ApiError("FILE_TOO_LARGE", 413, `File size ${input.size} exceeds the ${maxFileSize}-byte limit for this workspace.`, [{ maxBytes: maxFileSize, plan: ws.billingPlan, orgPolicy: maxFileSize < planMaxFileSize }]); } const quota = effectiveStorageQuota(ws.billingPlan, limits); // TASK-0104 §4/§Security: the counter is ADVISORY-FAST (O(1), never a // live sum on the happy path). Only at the DENIAL boundary is the // authoritative sum re-checked, and only a stale-LOW counter (drift > // 1 GB — e.g. the one-time `Σ File.size` backfill vs the FileVersion // ledger) is corrected to the sum before denying. A stale-HIGH counter // (conservative) is repaired by the maintenance reconciler. let used = Number(ws.usageStorageBytes ?? 0n); if (input.size > quota - used) { try { const authoritative = Number(await authoritativeStoredBytes(this.prisma, workspaceId)); if (authoritative - used > 1024 ** 3) { used = authoritative; await this.prisma.workspace.update({ where: { id: workspaceId }, data: { usageStorageBytes: BigInt(authoritative) }, }); } } catch (err) { // The re-check is defensive; a blip keeps the counter decision // (the conservative one — it can only over-deny, never under-deny). logger.warn(`quota authoritative re-check failed for ws ${workspaceId}: ${(err as Error).message}`); } } if (input.size > quota - used) { throw new ApiError("QUOTA_EXCEEDED", 413, `Uploading this file would exceed the workspace storage quota (${quota} bytes; ${used} used).`, [{ maxBytes: quota, usedBytes: used, remainingBytes: quota - used }]); } // ── closed MIME allow-list (claimed type) ────────────────────────── if (!isAllowedMime(input.mimeType)) { throw ApiError.validation("File type is not allowed.", [ { field: "mimeType", issue: "mime_not_allowed", received: input.mimeType }, ]); } // ── File row + signed PUT URL ────────────────────────────────────── // TASK-0104 §1: `replaceFileId` makes the session a NEW VERSION of an // existing file (the UI "Replace file" affordance) instead of a fresh // file. The existing File row is re-pointed at the new versioned key; // the old object is RETAINED (a FileVersion row for it stays until purge // or "delete old versions"). const replaceFileId: string | undefined = input.replaceFileId ?? undefined; let target: { id: string; workspaceId: string; name: string; pageId: string | null; blockId: string | null; status: string; deletedAt: Date | null; } | null = null; let nextVersion = 1; if (replaceFileId) { target = await this.prisma.file.findUnique({ where: { id: replaceFileId }, select: { id: true, workspaceId: true, name: true, pageId: true, blockId: true, status: true, deletedAt: true, }, }); if (!target || target.workspaceId !== workspaceId || target.deletedAt) { throw ApiError.notFound("File to replace not found in this workspace."); } // A replace is an EDIT of the file (file + page scope, TASK-0104). await assertCan(this.prisma, { workspaceId, userId, resource: { fileId: target.id, pageId: target.pageId ?? undefined }, }, "EDIT"); // The file must be in a stable state before a new version is layered on // (a still-scanning version's bytes would race the re-point). if (target.status === "UPLOADING" || target.status === "SCANNING") { throw ApiError.conflict("This file is still processing; wait for it to finish before replacing.", [ { reason: "replace_while_processing" }, ]); } const latest = await this.prisma.fileVersion.aggregate({ where: { fileId: target.id }, _max: { version: true }, }); nextVersion = (latest._max.version ?? 0) + 1; // A replace re-anchors to the EXISTING file's page/block (the request's // anchors are ignored — the file already has a home). pageId = target.pageId ?? undefined; blockId = target.blockId ?? undefined; } const env = loadEnv(); const fileId = target?.id ?? `file_${randomBytes(12).toString("base64url")}`; const key = buildFileKey({ workspaceId, fileId, version: nextVersion, name: sanitizeFilename(input.name), }); const now = new Date(); const expiresSec = signExpirySeconds(env); let file; if (target) { // Re-point the existing row at the new version (it re-enters the // pipeline via complete → SCANNING; the worker upserts the FileVersion). file = await this.prisma.file.update({ where: { id: target.id }, data: { name: input.name, key, size: input.size, mimeType: input.mimeType, status: "UPLOADING", metadata: {}, uploadedBy: userId, }, }); } else { file = await this.prisma.file.create({ data: { id: fileId, workspaceId, pageId: pageId ?? null, blockId: blockId ?? null, name: input.name, key, size: input.size, mimeType: input.mimeType, status: "UPLOADING", metadata: {}, uploadedBy: userId, }, }); } const put = presignPut({ config: filesBucketConfig(env), key, contentType: input.mimeType, expiresSec, now, }); const expiresAt = new Date(now.getTime() + expiresSec * 1000); await this.audit.audit(req, req.auth, { action: "file.upload_session_created", resourceType: "file", resourceId: file.id, workspaceId, metadata: { name: input.name, size: input.size, mimeType: input.mimeType, pageId, blockId }, }); return { upload: { file: toFileRecord(file, 1), uploadUrl: put.url, method: "PUT" as const, headers: { // The SIGNED header set the client must send on the PUT. // // content-type is the ONE signed client header: it is signed into // the URL (the "wrong Content-Type → 403" guarantee), so the client // must send it back EXACTLY. Do NOT also spread `put.headers` — it // carries the same content-type in lower case and the client would // send a DUPLICATE header (undici joins the values, breaking the // signature → MinIO 403 SignatureDoesNotMatch). // // No Content-Length here, deliberately: this signer does NOT // content-length-lock the presigned PUT (the spec allows the lock // only "when the S3 SDK supports it"), and returning // `Content-Length: ` would make strict HTTP clients // (undici) REFUSE to send a body whose size differs from the // declared one — which is precisely the "PUT fewer bytes than // declared" case that must reach `complete` and 422 // UPLOAD_INCOMPLETE. undici/fetch set Content-Length from the // actual body automatically. "Content-Type": input.mimeType, }, expiresAt: expiresAt.toISOString(), }, }; } /** * Complete a session: HEAD the object (size must match the declaration — * otherwise 422 UPLOAD_INCOMPLETE), read the first 8 KiB and run * `sniffMagicBytes`; a mismatch with the CLAIMED type marks the file * FAILED (`MIME_MISMATCH`) and deletes the object (the bytes never * become downloadable). On success: status SCANNING, enqueue the * `files` queue job `process-file { fileId, version }`, audit + publish. */ async complete(uploadId: string, userId: string, _input: CompleteUploadInput, req: Request) { const file = await this.requireUploadable(uploadId, userId, "EDIT"); const s3 = makeFilesClient(); let head: { size: number }; try { head = await s3.headObject(file.key); } catch (err) { const status = (err as { status?: number })?.status; if (status === 404) { // Object never landed (signed window closed, or the object was // already reaped): the session can no longer complete. throw new ApiError("UPLOAD_EXPIRED", 410, "The upload object is not present (the signed URL expired or the upload never completed).", [{ reason: "upload_expired" }]); } throw err; } if (head.size !== file.size) { throw new ApiError("UPLOAD_INCOMPLETE", 422, `Uploaded object size ${head.size} does not match the declared size ${file.size}.`, [{ received: head.size, expected: file.size }]); } // Authoritative magic-byte check on the FIRST 8 KiB (the verdict rules — // signature-bearing claims must match, signature-less claims pass — live // in the storage package so the worker and the API can't drift). // Storage blip on the sniff read propagates as-is (retryable at transport). const { prefix } = await s3.readPrefix(file.key, 8 * 1024); const verdict = sniffVerdict(prefix, file.mimeType); if (verdict === "mismatch") { try { await s3.deleteObject(file.key); } catch { // The row is FAILED and never signed; a stray object is reaped by // the orphan sweeper (TASK-0104). } await this.prisma.file.update({ where: { id: file.id }, data: { status: "FAILED", metadata: { ...(file.metadata as Record), pipeline: { error: "mime_mismatch" }, }, scanReport: { engine: "magic-bytes", result: "mismatch", claimed: file.mimeType }, }, }); await this.audit.audit(req, req.auth, { action: "file.mime_mismatch", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, claimed: file.mimeType }, }); throw new ApiError("MIME_MISMATCH", 415, "The uploaded bytes do not match the declared MIME type; the object was deleted.", [{ claimed: file.mimeType, issue: "mime_mismatch" }]); } // TASK-0104: the version is encoded in the object key (buildFileKey puts // it at the third path segment). For a fresh upload it is 1; for a // `replaceFileId` session it is n+1. The worker upserts the FileVersion // row at finalize using this version. let version = 1; try { version = parseFileKey(file.key).version; } catch { version = 1; // legacy/malformed key — fall back to the original behavior } // TASK-0104 §4: the bytes land in the bucket at `complete` (they are // durable from here — the worker finalizes metadata/thumbs, but the // object already exists). Count them in the SAME transaction as the // SCANNING transition (no drift window): +head.size. A later prune/purge // decrements (files.service); restore changes nothing (bytes retained). const updated = await this.prisma.$transaction(async (tx) => { const row = await tx.file.update({ where: { id: file.id }, data: { status: "SCANNING", size: head.size }, }); await adjustWorkspaceStorageBytes(tx, file.workspaceId, BigInt(head.size)); return row; }); // Enqueue the pipeline job (worker does scan/metadata/thumbnails). await this.enqueueProcessFile(file.id, version, file.workspaceId); await this.audit.audit(req, req.auth, { action: version > 1 ? "file.version_created" : "file.uploaded", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, size: head.size, status: updated.status, version }, }); await this.events.publish("file.uploaded", { workspaceId: file.workspaceId, fileId: file.id, name: file.name, size: head.size, mimeType: file.mimeType, blockId: file.blockId, pageId: file.pageId, version, }); return { id: file.id, status: updated.status, verifiedBy: "worker", version }; } /** * Cancel a session: delete the partial object (if any) and the File row * (hard — the session never became a file). */ async cancel(uploadId: string, userId: string, _input: CancelUploadInput, req: Request) { const file = await this.requireUploadable(uploadId, userId, "EDIT"); const s3 = makeFilesClient(); try { await s3.deleteObject(file.key); } catch { // Object absent — nothing to delete. } await this.prisma.file.delete({ where: { id: file.id } }); await this.audit.audit(req, req.auth, { action: "file.upload_cancelled", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, key: file.key }, }); return { deleted: true }; } // ── internals ──────────────────────────────────────────────────────── private workspaceOf(req: Request): string { const ws = req.auth?.workspaceId; if (!ws) { throw ApiError.validation("Missing workspace context (x-factory-workspace).", [ { field: "workspace", issue: "workspace_required" }, ]); } return ws; } /** The upload session row, or 404; completed sessions cannot be re-driven. */ private async requireUploadable(fileId: string, userId: string, need: "VIEW" | "EDIT") { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || file.deletedAt) throw ApiError.notFound("Upload session not found."); if (file.status === "SCANNING" || file.status === "READY" || file.status === "VIRUS_DETECTED") { throw ApiError.conflict("This upload session has already been completed.", [ { reason: "upload_already_completed" }, ]); } await assertCan(this.prisma, { workspaceId: file.workspaceId, userId, resource: { pageId: file.pageId ?? undefined }, }, need); return file; } /** Enqueue `process-file` on the `files` BullMQ queue. Degrades on a * Redis blip (logged) — the maintenance sweeper re-drives stranded * SCANNING rows, so the completion itself stays available. */ private async enqueueProcessFile(fileId: string, version: number, workspaceId: string): Promise { try { const { Queue } = await import("bullmq"); const q = new Queue("files", { connection: this.redis.getRedis().options as never, defaultJobOptions: { attempts: 3, backoff: { type: "exponential", delay: 2_000 }, removeOnComplete: 500, removeOnFail: 200, }, }); // TASK-0135: continue the enqueueing request's trace into the job. await q.add("process-file", { fileId, version, workspaceId }, { ...jobTraceMetadata() }); await q.close(); } catch (err) { logger.warn(`files queue enqueue failed for ${fileId}: ${(err as Error).message}`); } } }