1import { Inject, Injectable } from "@nestjs/common";
2import { randomBytes } from "node:crypto";
3import type { Request } from "express";
4
5
6
7import {
8 buildFileKey,
9 isAllowedMime,
10 parseFileKey,
11 presignPut,
12 sanitizeFilename,
13 sniffVerdict,
14} from "@factory/storage";
15import { LIMITS } from "@factory/shared";
16import { jobTraceMetadata } from "../../common/trace-metadata.js";
17import { PrismaService } from "../../common/prisma.service.js";
18import { AuditService } from "../../common/audit.service.js";
19import { EventBus } from "../../common/event-bus.js";
20import { RedisService } from "../../common/redis.service.js";
21import { ApiError } from "../../common/errors.js";
22import { logger } from "../../common/logger.js";
23import { assertCan } from "../../lib/authorize.js";
24import { loadEnv } from "../../config/env.js";
25import { filesBucketConfig, makeFilesClient, signExpirySeconds } from "./s3.js";
26import {
27 PLAN_USAGE_LIMITS,
28 adjustWorkspaceStorageBytes,
29 authoritativeStoredBytes,
30 effectiveStorageQuota,
31} from "./quota.js";
32import { toFileRecord } from "./record.js";
33import { effectiveMaxFileBytes, workspaceOrgPolicy } from "../org/org-policy.service.js";
34
35
36
37
38
39export { PLAN_USAGE_LIMITS, effectiveStorageQuota } from "./quota.js";
40import type {
41 CancelUploadInput,
42 CompleteUploadInput,
43 CreateUploadInput,
44} from "./dto.js";
45
46
64
65
71export function effectiveMaxFileSize(
72 billingPlan: string,
73 usageLimits: { maxFileSize?: number } | null,
74): number {
75 const plan = PLAN_USAGE_LIMITS[billingPlan] ?? PLAN_USAGE_LIMITS.free!;
76 const override = usageLimits?.maxFileSize;
77 return Math.min(
78 plan.maxFileSize,
79 typeof override === "number" && override > 0 ? override : Infinity,
80 LIMITS.MAX_FILE_SIZE,
81 );
82}
83
84@Injectable()
85export class UploadsService {
86 constructor(
87 @Inject(PrismaService) private readonly prisma: PrismaService,
88 @Inject(AuditService) private readonly audit: AuditService,
89 @Inject(EventBus) private readonly events: EventBus,
90 @Inject(RedisService) private readonly redis: RedisService,
91 ) {}
92
93
100 async create(userId: string, input: CreateUploadInput, req: Request) {
101 const workspaceId = this.workspaceOf(req);
102 await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
103
104 const ws = await this.prisma.workspace.findUnique({ where: { id: workspaceId } });
105 if (!ws || ws.deletedAt) throw ApiError.notFound("Workspace not found.");
106
107
108
109
110 let pageId: string | undefined = input.pageId ?? undefined;
111 let blockId: string | undefined = input.blockId ?? undefined;
112 if (blockId) {
113 const block = await this.prisma.block.findUnique({ where: { id: blockId } });
114 if (!block || block.pageId !== pageId) {
115 throw ApiError.notFound("Anchor block not found in this workspace.");
116 }
117 pageId = block.pageId;
118 }
119 if (pageId) {
120 const page = await this.prisma.page.findUnique({ where: { id: pageId } });
121 if (!page || page.workspaceId !== workspaceId) {
122 throw ApiError.notFound("Anchor page not found in this workspace.");
123 }
124 }
125
126
127 await assertCan(this.prisma, {
128 workspaceId,
129 userId,
130 resource: { pageId },
131 }, "EDIT");
132
133
134 const limits = (ws.usageLimits ?? {}) as { maxFileSize?: number; storageQuota?: number };
135 const planMaxFileSize = effectiveMaxFileSize(ws.billingPlan, limits);
136
137
138
139
140 const { policy } = await workspaceOrgPolicy(this.prisma, workspaceId);
141 const maxFileSize = Math.min(
142 planMaxFileSize,
143 effectiveMaxFileBytes(policy, ws.billingPlan),
144 );
145 if (input.size > maxFileSize) {
146 throw new ApiError("FILE_TOO_LARGE", 413,
147 `File size ${input.size} exceeds the ${maxFileSize}-byte limit for this workspace.`,
148 [{ maxBytes: maxFileSize, plan: ws.billingPlan, orgPolicy: maxFileSize < planMaxFileSize }]);
149 }
150 const quota = effectiveStorageQuota(ws.billingPlan, limits);
151
152
153
154
155
156
157 let used = Number(ws.usageStorageBytes ?? 0n);
158 if (input.size > quota - used) {
159 try {
160 const authoritative = Number(await authoritativeStoredBytes(this.prisma, workspaceId));
161 if (authoritative - used > 1024 ** 3) {
162 used = authoritative;
163 await this.prisma.workspace.update({
164 where: { id: workspaceId },
165 data: { usageStorageBytes: BigInt(authoritative) },
166 });
167 }
168 } catch (err) {
169
170
171 logger.warn(`quota authoritative re-check failed for ws ${workspaceId}: ${(err as Error).message}`);
172 }
173 }
174 if (input.size > quota - used) {
175 throw new ApiError("QUOTA_EXCEEDED", 413,
176 `Uploading this file would exceed the workspace storage quota (${quota} bytes; ${used} used).`,
177 [{ maxBytes: quota, usedBytes: used, remainingBytes: quota - used }]);
178 }
179
180
181 if (!isAllowedMime(input.mimeType)) {
182 throw ApiError.validation("File type is not allowed.", [
183 { field: "mimeType", issue: "mime_not_allowed", received: input.mimeType },
184 ]);
185 }
186
187
188
189
190
191
192
193 const replaceFileId: string | undefined = input.replaceFileId ?? undefined;
194 let target: {
195 id: string;
196 workspaceId: string;
197 name: string;
198 pageId: string | null;
199 blockId: string | null;
200 status: string;
201 deletedAt: Date | null;
202 } | null = null;
203 let nextVersion = 1;
204 if (replaceFileId) {
205 target = await this.prisma.file.findUnique({
206 where: { id: replaceFileId },
207 select: {
208 id: true,
209 workspaceId: true,
210 name: true,
211 pageId: true,
212 blockId: true,
213 status: true,
214 deletedAt: true,
215 },
216 });
217 if (!target || target.workspaceId !== workspaceId || target.deletedAt) {
218 throw ApiError.notFound("File to replace not found in this workspace.");
219 }
220
221 await assertCan(this.prisma, {
222 workspaceId,
223 userId,
224 resource: { fileId: target.id, pageId: target.pageId ?? undefined },
225 }, "EDIT");
226
227
228 if (target.status === "UPLOADING" || target.status === "SCANNING") {
229 throw ApiError.conflict("This file is still processing; wait for it to finish before replacing.", [
230 { reason: "replace_while_processing" },
231 ]);
232 }
233 const latest = await this.prisma.fileVersion.aggregate({
234 where: { fileId: target.id },
235 _max: { version: true },
236 });
237 nextVersion = (latest._max.version ?? 0) + 1;
238
239
240 pageId = target.pageId ?? undefined;
241 blockId = target.blockId ?? undefined;
242 }
243
244 const env = loadEnv();
245 const fileId = target?.id ?? `file_${randomBytes(12).toString("base64url")}`;
246 const key = buildFileKey({
247 workspaceId,
248 fileId,
249 version: nextVersion,
250 name: sanitizeFilename(input.name),
251 });
252 const now = new Date();
253 const expiresSec = signExpirySeconds(env);
254
255 let file;
256 if (target) {
257
258
259 file = await this.prisma.file.update({
260 where: { id: target.id },
261 data: {
262 name: input.name,
263 key,
264 size: input.size,
265 mimeType: input.mimeType,
266 status: "UPLOADING",
267 metadata: {},
268 uploadedBy: userId,
269 },
270 });
271 } else {
272 file = await this.prisma.file.create({
273 data: {
274 id: fileId,
275 workspaceId,
276 pageId: pageId ?? null,
277 blockId: blockId ?? null,
278 name: input.name,
279 key,
280 size: input.size,
281 mimeType: input.mimeType,
282 status: "UPLOADING",
283 metadata: {},
284 uploadedBy: userId,
285 },
286 });
287 }
288
289 const put = presignPut({
290 config: filesBucketConfig(env),
291 key,
292 contentType: input.mimeType,
293 expiresSec,
294 now,
295 });
296 const expiresAt = new Date(now.getTime() + expiresSec * 1000);
297
298 await this.audit.audit(req, req.auth, {
299 action: "file.upload_session_created",
300 resourceType: "file",
301 resourceId: file.id,
302 workspaceId,
303 metadata: { name: input.name, size: input.size, mimeType: input.mimeType, pageId, blockId },
304 });
305
306 return {
307 upload: {
308 file: toFileRecord(file, 1),
309 uploadUrl: put.url,
310 method: "PUT" as const,
311 headers: {
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330 "Content-Type": input.mimeType,
331 },
332 expiresAt: expiresAt.toISOString(),
333 },
334 };
335 }
336
337
345 async complete(uploadId: string, userId: string, _input: CompleteUploadInput, req: Request) {
346 const file = await this.requireUploadable(uploadId, userId, "EDIT");
347 const s3 = makeFilesClient();
348
349 let head: { size: number };
350 try {
351 head = await s3.headObject(file.key);
352 } catch (err) {
353 const status = (err as { status?: number })?.status;
354 if (status === 404) {
355
356
357 throw new ApiError("UPLOAD_EXPIRED", 410,
358 "The upload object is not present (the signed URL expired or the upload never completed).",
359 [{ reason: "upload_expired" }]);
360 }
361 throw err;
362 }
363
364 if (head.size !== file.size) {
365 throw new ApiError("UPLOAD_INCOMPLETE", 422,
366 `Uploaded object size ${head.size} does not match the declared size ${file.size}.`,
367 [{ received: head.size, expected: file.size }]);
368 }
369
370
371
372
373
374 const { prefix } = await s3.readPrefix(file.key, 8 * 1024);
375 const verdict = sniffVerdict(prefix, file.mimeType);
376 if (verdict === "mismatch") {
377 try {
378 await s3.deleteObject(file.key);
379 } catch {
380
381
382 }
383 await this.prisma.file.update({
384 where: { id: file.id },
385 data: {
386 status: "FAILED",
387 metadata: {
388 ...(file.metadata as Record<string, unknown>),
389 pipeline: { error: "mime_mismatch" },
390 },
391 scanReport: { engine: "magic-bytes", result: "mismatch", claimed: file.mimeType },
392 },
393 });
394 await this.audit.audit(req, req.auth, {
395 action: "file.mime_mismatch",
396 resourceType: "file",
397 resourceId: file.id,
398 workspaceId: file.workspaceId,
399 metadata: { name: file.name, claimed: file.mimeType },
400 });
401 throw new ApiError("MIME_MISMATCH", 415,
402 "The uploaded bytes do not match the declared MIME type; the object was deleted.",
403 [{ claimed: file.mimeType, issue: "mime_mismatch" }]);
404 }
405
406
407
408
409
410 let version = 1;
411 try {
412 version = parseFileKey(file.key).version;
413 } catch {
414 version = 1;
415 }
416
417
418
419
420
421
422 const updated = await this.prisma.$transaction(async (tx) => {
423 const row = await tx.file.update({
424 where: { id: file.id },
425 data: { status: "SCANNING", size: head.size },
426 });
427 await adjustWorkspaceStorageBytes(tx, file.workspaceId, BigInt(head.size));
428 return row;
429 });
430
431
432 await this.enqueueProcessFile(file.id, version, file.workspaceId);
433
434 await this.audit.audit(req, req.auth, {
435 action: version > 1 ? "file.version_created" : "file.uploaded",
436 resourceType: "file",
437 resourceId: file.id,
438 workspaceId: file.workspaceId,
439 metadata: { name: file.name, size: head.size, status: updated.status, version },
440 });
441 await this.events.publish("file.uploaded", {
442 workspaceId: file.workspaceId,
443 fileId: file.id,
444 name: file.name,
445 size: head.size,
446 mimeType: file.mimeType,
447 blockId: file.blockId,
448 pageId: file.pageId,
449 version,
450 });
451
452 return { id: file.id, status: updated.status, verifiedBy: "worker", version };
453 }
454
455
459 async cancel(uploadId: string, userId: string, _input: CancelUploadInput, req: Request) {
460 const file = await this.requireUploadable(uploadId, userId, "EDIT");
461 const s3 = makeFilesClient();
462 try {
463 await s3.deleteObject(file.key);
464 } catch {
465
466 }
467 await this.prisma.file.delete({ where: { id: file.id } });
468 await this.audit.audit(req, req.auth, {
469 action: "file.upload_cancelled",
470 resourceType: "file",
471 resourceId: file.id,
472 workspaceId: file.workspaceId,
473 metadata: { name: file.name, key: file.key },
474 });
475 return { deleted: true };
476 }
477
478
479
480 private workspaceOf(req: Request): string {
481 const ws = req.auth?.workspaceId;
482 if (!ws) {
483 throw ApiError.validation("Missing workspace context (x-factory-workspace).", [
484 { field: "workspace", issue: "workspace_required" },
485 ]);
486 }
487 return ws;
488 }
489
490
491 private async requireUploadable(fileId: string, userId: string, need: "VIEW" | "EDIT") {
492 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
493 if (!file || file.deletedAt) throw ApiError.notFound("Upload session not found.");
494 if (file.status === "SCANNING" || file.status === "READY" || file.status === "VIRUS_DETECTED") {
495 throw ApiError.conflict("This upload session has already been completed.", [
496 { reason: "upload_already_completed" },
497 ]);
498 }
499 await assertCan(this.prisma, {
500 workspaceId: file.workspaceId,
501 userId,
502 resource: { pageId: file.pageId ?? undefined },
503 }, need);
504 return file;
505 }
506
507
510 private async enqueueProcessFile(fileId: string, version: number, workspaceId: string): Promise<void> {
511 try {
512 const { Queue } = await import("bullmq");
513 const q = new Queue("files", {
514 connection: this.redis.getRedis().options as never,
515 defaultJobOptions: {
516 attempts: 3,
517 backoff: { type: "exponential", delay: 2_000 },
518 removeOnComplete: 500,
519 removeOnFail: 200,
520 },
521 });
522
523 await q.add("process-file", { fileId, version, workspaceId }, { ...jobTraceMetadata() });
524 await q.close();
525 } catch (err) {
526 logger.warn(`files queue enqueue failed for ${fileId}: ${(err as Error).message}`);
527 }
528 }
529}
530