diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index f11fcb2..43a8711 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -46,6 +46,7 @@ import { PublicIdModule } from "src/utils/public-id/public-id.module"; import { ClientModule } from "src/client/client.module"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { JwtModule } from "@nestjs/jwt"; +import { MediaPolicyModule } from "src/media-policy/media-policy.module"; @Module({ imports: [ @@ -55,6 +56,7 @@ import { JwtModule } from "@nestjs/jwt"; AiModule, SandHubModule, ClientModule, + MediaPolicyModule, JwtModule.register({}), MongooseModule.forFeature([ { name: ClaimCase.name, schema: ClaimCaseSchema }, diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index 297d612..8b533f1 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -24,6 +24,7 @@ import { GlobalGuard } from "src/auth/guards/global.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; +import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { ClaimRequestManagementService } from "./claim-request-management.service"; import { @@ -53,6 +54,7 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; export class ClaimRequestManagementV2Controller { constructor( private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, ) {} @Get("requests") @@ -300,6 +302,7 @@ export class ClaimRequestManagementV2Controller { if (!Types.ObjectId.isValid(claimRequestId)) { throw new BadRequestException("Invalid claim request id"); } + await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image"); const agreed = typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree); try { @@ -612,6 +615,8 @@ Optional: upload car green card file in the same step. @CurrentUser() user: any, @UploadedFile() file?: Express.Multer.File, ): Promise { + // Green-card photo is optional here — the helper no-ops on missing file. + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.selectOtherPartsV2( claimRequestId, @@ -776,6 +781,7 @@ Returns status of each item (uploaded/captured or not). @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.uploadRequiredDocumentV2( claimRequestId, @@ -870,6 +876,7 @@ Returns status of each item (uploaded/captured or not). @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.capturePartV2( claimRequestId, @@ -927,6 +934,7 @@ Returns status of each item (uploaded/captured or not). @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ) { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.uploadClaimFactorV2( claimRequestId, @@ -996,6 +1004,7 @@ Returns status of each item (uploaded/captured or not). @UploadedFile("file") file: Express.Multer.File, @CurrentUser() user: any, ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video"); try { return await this.claimRequestManagementService.setVideoCaptureV2( claimRequestId, diff --git a/src/client/client.service.ts b/src/client/client.service.ts index 8be15e6..aa4b479 100644 --- a/src/client/client.service.ts +++ b/src/client/client.service.ts @@ -14,6 +14,34 @@ import { ClientDbService } from "./entities/db-service/client.db.service"; */ export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 7; +/** + * Media kinds we enforce upload-size bounds for. Each kind has its own + * default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional + * per-client override under `settings.media.`. + */ +export type MediaKind = "video" | "image" | "voice"; + +export interface MediaLimits { + /** Inclusive lower bound. `0` allows any size from zero up. */ + minBytes: number; + /** Inclusive upper bound. */ + maxBytes: number; +} + +/** + * System defaults applied when a client (or kind) has no explicit override. + * + * Important: each upload route also has a `multer.limits.fileSize` hard + * ceiling — those route ceilings are the absolute maximum the API can + * receive. Per-client `maxBytes` cannot legitimately go above its route's + * multer ceiling, so the defaults below are kept at-or-below those. + */ +export const DEFAULT_MEDIA_LIMITS: Record = { + video: { minBytes: 256 * 1024, maxBytes: 20 * 1024 * 1024 }, + image: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 }, + voice: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 }, +}; + @Injectable() export class ClientService { constructor(private readonly clientDbService: ClientDbService) {} @@ -61,6 +89,43 @@ export class ClientService { return list; } + /** + * Resolve the per-client byte bounds for a media kind. Missing bounds fall + * back to {@link DEFAULT_MEDIA_LIMITS}. If no clientId is supplied (or it + * doesn't resolve to a client document) the defaults are returned. + * + * The returned object is always fully populated — callers can compare + * directly against `file.size`. + */ + async getMediaLimits( + clientId: string | Types.ObjectId | null | undefined, + kind: MediaKind, + ): Promise { + const defaults = DEFAULT_MEDIA_LIMITS[kind]; + if (!clientId) return { ...defaults }; + + const idString = String(clientId); + if (!Types.ObjectId.isValid(idString)) return { ...defaults }; + + const client = await this.clientDbService.findOne({ + _id: new Types.ObjectId(idString), + }); + const override = client?.settings?.media?.[kind]; + const minBytes = + typeof override?.minBytes === "number" && + Number.isFinite(override.minBytes) && + override.minBytes >= 0 + ? override.minBytes + : defaults.minBytes; + const maxBytes = + typeof override?.maxBytes === "number" && + Number.isFinite(override.maxBytes) && + override.maxBytes > 0 + ? override.maxBytes + : defaults.maxBytes; + return { minBytes, maxBytes }; + } + /** * Resolve the per-client CAR_BODY accident-age window (in days). * diff --git a/src/client/entities/schema/client.schema.ts b/src/client/entities/schema/client.schema.ts index 8ab98b2..16aad76 100644 --- a/src/client/entities/schema/client.schema.ts +++ b/src/client/entities/schema/client.schema.ts @@ -2,6 +2,41 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; export type ClientDocument = ClientModel & Document; +/** + * Per-media (video/image/voice) byte bounds. Either bound is optional so a + * tenant can configure only one side (e.g. raise `maxBytes` without setting + * a floor). When a bound is missing the system-wide default is used (see + * {@link ClientService.getMediaLimits} for the defaults table). + */ +export class ClientMediaLimits { + @Prop({ type: Number, required: false }) + minBytes?: number; + + @Prop({ type: Number, required: false }) + maxBytes?: number; +} + +/** + * Bag of per-media-kind limits. Each kind is optional and any unset values + * fall back to the system default (see `ClientService.getMediaLimits`). + * + * Note: the system also keeps an absolute multer ceiling per upload route + * (e.g. 50MB for car-capture videos, 20MB for blame videos, 10MB for + * voice/signature/image). A client-configured `maxBytes` cannot exceed the + * route's multer ceiling because multer rejects oversize requests before + * the request reaches the policy check. + */ +export class ClientMediaSettings { + @Prop({ type: ClientMediaLimits, required: false }) + video?: ClientMediaLimits; + + @Prop({ type: ClientMediaLimits, required: false }) + image?: ClientMediaLimits; + + @Prop({ type: ClientMediaLimits, required: false }) + voice?: ClientMediaLimits; +} + /** * Per-tenant tunables. Add new policy fields here; consumers read them via * `ClientService` with documented defaults so older client documents keep @@ -15,6 +50,13 @@ export class ClientSettings { */ @Prop({ type: Number, required: false }) carBodyAccidentMaxAgeDays?: number; + + /** + * Per-kind media upload bounds (V2 upload endpoints). Unset kinds / + * bounds fall back to the defaults in `ClientService.getMediaLimits`. + */ + @Prop({ type: ClientMediaSettings, required: false }) + media?: ClientMediaSettings; } @Schema({ collection: "clients", versionKey: false }) diff --git a/src/media-policy/media-policy.module.ts b/src/media-policy/media-policy.module.ts new file mode 100644 index 0000000..076ba3b --- /dev/null +++ b/src/media-policy/media-policy.module.ts @@ -0,0 +1,37 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; + +import { ClientModule } from "src/client/client.module"; +import { + ClaimCase, + ClaimCaseSchema, +} from "src/claim-request-management/entites/schema/claim-cases.schema"; +import { + BlameRequest, + BlameRequestSchema, +} from "src/request-management/entities/schema/blame-cases.schema"; + +import { MediaPolicyService } from "./media-policy.service"; + +/** + * Stand-alone module so we can inject `MediaPolicyService` into both the + * blame and claim V2 controllers without dragging the entire + * `RequestManagementModule` / `ClaimRequestManagementModule` graph along + * (which would risk a circular dependency). + * + * The schemas are registered directly via `MongooseModule.forFeature` — + * Mongoose deduplicates models behind the scenes so a second registration + * does not create a separate model. + */ +@Module({ + imports: [ + ClientModule, + MongooseModule.forFeature([ + { name: BlameRequest.name, schema: BlameRequestSchema }, + { name: ClaimCase.name, schema: ClaimCaseSchema }, + ]), + ], + providers: [MediaPolicyService], + exports: [MediaPolicyService], +}) +export class MediaPolicyModule {} diff --git a/src/media-policy/media-policy.service.ts b/src/media-policy/media-policy.service.ts new file mode 100644 index 0000000..6198e37 --- /dev/null +++ b/src/media-policy/media-policy.service.ts @@ -0,0 +1,270 @@ +import { + BadRequestException, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { promises as fs } from "node:fs"; +import { Model, Types } from "mongoose"; + +import { + ClientService, + MediaKind, + MediaLimits, +} from "src/client/client.service"; +import { ClaimCase } from "src/claim-request-management/entites/schema/claim-cases.schema"; +import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema"; + +/** + * Shape we expect from a multer-uploaded file. We type loosely because two + * different multer interceptors are in play (`FileInterceptor` and + * `FileFieldsInterceptor`) and we don't want to drag `Express.Multer.File` + * typings into a service file. + */ +type UploadedFileLike = + | (Express.Multer.File & { path?: string }) + | undefined + | null; + +/** + * Resolves the relevant client for a blame/claim file and enforces the + * client's configured byte bounds against an uploaded file. On violation it + * deletes the offending file from disk (best-effort) and throws a + * `BadRequestException` carrying a structured payload the frontend can + * branch on. + * + * Client resolution: + * - Blame endpoints: any party on the `BlameRequest` that has + * `person.clientId` set. We pick the first one we find. + * - Claim endpoints: `ClaimCase.owner.clientId`. + * + * When no clientId can be resolved (early steps, malformed ids, missing + * documents), the helper falls back to the system defaults declared in + * `DEFAULT_MEDIA_LIMITS` so the gate is still enforced. + */ +@Injectable() +export class MediaPolicyService { + private readonly logger = new Logger(MediaPolicyService.name); + + constructor( + @InjectModel(BlameRequest.name) + private readonly blameRequestModel: Model, + @InjectModel(ClaimCase.name) + private readonly claimCaseModel: Model, + private readonly clientService: ClientService, + ) {} + + /** + * Enforce limits for a file uploaded against a `BlameRequest` (V2). + * Safe to call with a `null`/`undefined` file — it just no-ops, leaving + * "file required" errors to be raised by the existing service handlers. + */ + async assertForBlame( + file: UploadedFileLike, + blameRequestId: string, + kind: MediaKind, + ): Promise { + if (!file) return; + const clientId = await this.resolveBlameClientId(blameRequestId); + await this.assertWithClient(file, clientId, kind); + } + + /** + * Enforce limits for a file uploaded against a `ClaimCase` (V2). + * Safe to call with a `null`/`undefined` file. + */ + async assertForClaim( + file: UploadedFileLike, + claimRequestId: string, + kind: MediaKind, + ): Promise { + if (!file) return; + const clientId = await this.resolveClaimClientId(claimRequestId); + await this.assertWithClient(file, clientId, kind); + } + + /** + * Validate a bag of files (as produced by `FileFieldsInterceptor`) against + * the blame file's client. Each field key is mapped to a `MediaKind` via + * `fieldKindMap`; unmapped keys are ignored. On the first violation we + * delete *every* provided file (the request is being rejected anyway) and + * throw. + */ + async assertForBlameMany( + files: Partial>, + blameRequestId: string, + fieldKindMap: Record, + ): Promise { + const provided = this.flattenProvidedFiles(files); + if (provided.length === 0) return; + + const clientId = await this.resolveBlameClientId(blameRequestId); + await this.assertManyWithClient(provided, clientId, fieldKindMap); + } + + /** + * Same as `assertForBlameMany` but for claim files. Use this when (and + * if) a multi-field claim upload endpoint is added; today's claim + * controllers only upload one file at a time. + */ + async assertForClaimMany( + files: Partial>, + claimRequestId: string, + fieldKindMap: Record, + ): Promise { + const provided = this.flattenProvidedFiles(files); + if (provided.length === 0) return; + + const clientId = await this.resolveClaimClientId(claimRequestId); + await this.assertManyWithClient(provided, clientId, fieldKindMap); + } + + // -- internal helpers --------------------------------------------------- + + private async assertWithClient( + file: NonNullable, + clientId: Types.ObjectId | string | null, + kind: MediaKind, + ): Promise { + const limits = await this.clientService.getMediaLimits(clientId, kind); + const violation = this.checkLimits(file, limits, kind); + if (!violation) return; + + await this.tryDelete(file?.path); + throw new BadRequestException(violation); + } + + private async assertManyWithClient( + provided: Array<{ field: TKeys; file: NonNullable }>, + clientId: Types.ObjectId | string | null, + fieldKindMap: Record, + ): Promise { + const limitsCache = new Map(); + const getLimits = async (kind: MediaKind) => { + if (!limitsCache.has(kind)) { + limitsCache.set( + kind, + await this.clientService.getMediaLimits(clientId, kind), + ); + } + return limitsCache.get(kind) as MediaLimits; + }; + + for (const { field, file } of provided) { + const kind = fieldKindMap[field]; + if (!kind) continue; + const limits = await getLimits(kind); + const violation = this.checkLimits(file, limits, kind, field); + if (!violation) continue; + + // The request is being rejected — clean up all uploaded files so we + // don't leave orphans on disk. + await Promise.all(provided.map(({ file: f }) => this.tryDelete(f?.path))); + throw new BadRequestException(violation); + } + } + + private checkLimits( + file: NonNullable, + { minBytes, maxBytes }: MediaLimits, + kind: MediaKind, + field?: string, + ): Record | null { + const size = Number(file.size ?? 0); + if (!Number.isFinite(size)) { + return { + code: "MEDIA_SIZE_UNKNOWN", + kind, + ...(field ? { field } : {}), + message: `Could not determine the size of the uploaded ${kind} file.`, + }; + } + if (size < minBytes) { + return { + code: "MEDIA_TOO_SMALL", + kind, + ...(field ? { field } : {}), + sizeBytes: size, + minBytes, + maxBytes, + message: + `Uploaded ${kind} is too small (${size} bytes). ` + + `Minimum allowed for this client is ${minBytes} bytes.`, + }; + } + if (size > maxBytes) { + return { + code: "MEDIA_TOO_LARGE", + kind, + ...(field ? { field } : {}), + sizeBytes: size, + minBytes, + maxBytes, + message: + `Uploaded ${kind} is too large (${size} bytes). ` + + `Maximum allowed for this client is ${maxBytes} bytes.`, + }; + } + return null; + } + + private flattenProvidedFiles( + files: Partial>, + ): Array<{ field: TKeys; file: NonNullable }> { + const out: Array<{ field: TKeys; file: NonNullable }> = []; + if (!files) return out; + for (const key of Object.keys(files) as TKeys[]) { + for (const f of files[key] || []) { + if (f) out.push({ field: key, file: f }); + } + } + return out; + } + + private async resolveBlameClientId( + blameRequestId: string, + ): Promise { + if (!blameRequestId || !Types.ObjectId.isValid(blameRequestId)) return null; + + const blame = (await this.blameRequestModel + .findById(blameRequestId) + .select("parties") + .lean() + .exec()) as { parties?: Array<{ person?: { clientId?: Types.ObjectId } }> } | null; + if (!blame?.parties?.length) return null; + + for (const party of blame.parties) { + const clientId = party?.person?.clientId; + if (clientId) return clientId; + } + return null; + } + + private async resolveClaimClientId( + claimRequestId: string, + ): Promise { + if (!claimRequestId || !Types.ObjectId.isValid(claimRequestId)) return null; + + const claim = (await this.claimCaseModel + .findById(claimRequestId) + .select("owner") + .lean() + .exec()) as { owner?: { clientId?: Types.ObjectId } } | null; + return claim?.owner?.clientId ?? null; + } + + private async tryDelete(filePath?: string): Promise { + if (!filePath) return; + try { + await fs.unlink(filePath); + } catch (err) { + // Best-effort cleanup — log and continue. The HTTP response will + // already carry the violation details for the client. + this.logger.warn( + `MediaPolicyService: failed to delete file at ${filePath}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } +} diff --git a/src/request-management/expert-initiated.v2.controller.ts b/src/request-management/expert-initiated.v2.controller.ts index fd4ae4a..9ac3438 100644 --- a/src/request-management/expert-initiated.v2.controller.ts +++ b/src/request-management/expert-initiated.v2.controller.ts @@ -24,6 +24,7 @@ import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { CurrentUser } from "src/decorators/user.decorator"; import { Roles } from "src/decorators/roles.decorator"; +import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { RequestManagementService } from "./request-management.service"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; @@ -53,6 +54,7 @@ export class ExpertInitiatedV2Controller { constructor( private readonly requestManagementService: RequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, ) {} @Get("my-files") @@ -393,6 +395,7 @@ export class ExpertInitiatedV2Controller { @Param("requestId") requestId: string, @UploadedFile() file?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); return this.requestManagementService.expertUploadVideoForBlameV2( expert, requestId, @@ -432,6 +435,7 @@ export class ExpertInitiatedV2Controller { @Param("requestId") requestId: string, @UploadedFile() voice?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); return this.requestManagementService.expertUploadVoiceForBlameV2( expert, requestId, @@ -497,6 +501,8 @@ export class ExpertInitiatedV2Controller { @Body() body: { partyRole?: string; isAccept?: string | boolean }, @UploadedFile() sign?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); + const partyRole = (body.partyRole === "FIRST" || body.partyRole === "SECOND") ? body.partyRole : ("FIRST" as const); diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 23542da..7129abe 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -15,6 +15,7 @@ import { HashModule } from "src/utils/hash/hash.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module"; import { AuthModule } from "src/auth/auth.module"; +import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service"; import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service"; import { UserSignDbService } from "./entities/db-service/sign.db.service"; @@ -64,6 +65,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller"; forwardRef(() => ClaimRequestManagementModule), CronModule, AuthModule, + MediaPolicyModule, ], controllers: [ RequestManagementController, diff --git a/src/request-management/request-management.v2.controller.ts b/src/request-management/request-management.v2.controller.ts index 920ccac..f9924de 100644 --- a/src/request-management/request-management.v2.controller.ts +++ b/src/request-management/request-management.v2.controller.ts @@ -28,6 +28,7 @@ import { GlobalGuard } from "src/auth/guards/global.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; +import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { @@ -53,6 +54,7 @@ import { RequestManagementService } from "./request-management.service"; export class RequestManagementV2Controller { constructor( private readonly requestManagementService: RequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, ) { } @Post() @@ -154,6 +156,7 @@ export class RequestManagementV2Controller { @CurrentUser() user, @UploadedFile() file?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); return this.requestManagementService.uploadFirstPartyVideoV2( requestId, file, @@ -222,6 +225,7 @@ export class RequestManagementV2Controller { @CurrentUser() user, @UploadedFile() voice?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); return this.requestManagementService.uploadVoiceV2(requestId, voice, user); } @@ -338,6 +342,8 @@ export class RequestManagementV2Controller { @CurrentUser() user: any, @UploadedFile() sign: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); + // Convert string "true"/"false" to boolean const acceptDecision = typeof isAccept === "string" ? isAccept === "true" @@ -449,6 +455,18 @@ export class RequestManagementV2Controller { @Body("description") description?: string, @Body("textDescription") textDescription?: string, ) { + // Each multipart field maps to a media kind so the policy gate can pick + // the right per-client bounds. Document images = `image`, audio = `voice`, + // video = `video`. + await this.mediaPolicyService.assertForBlameMany(files, requestId, { + drivingLicense: "image", + carCertificate: "image", + nationalCertificate: "image", + carGreenCard: "image", + voice: "voice", + video: "video", + }); + const descCandidates = [description, textDescription] .map((x) => (x != null ? String(x).trim() : "")) .filter((s) => s.length > 0);