forked from Yara724/api
YARA-898
This commit is contained in:
37
src/media-policy/media-policy.module.ts
Normal file
37
src/media-policy/media-policy.module.ts
Normal file
@@ -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 {}
|
||||
270
src/media-policy/media-policy.service.ts
Normal file
270
src/media-policy/media-policy.service.ts
Normal file
@@ -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<BlameRequest>,
|
||||
@InjectModel(ClaimCase.name)
|
||||
private readonly claimCaseModel: Model<ClaimCase>,
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<TKeys extends string>(
|
||||
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
|
||||
blameRequestId: string,
|
||||
fieldKindMap: Record<TKeys, MediaKind | undefined>,
|
||||
): Promise<void> {
|
||||
const provided = this.flattenProvidedFiles<TKeys>(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<TKeys extends string>(
|
||||
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
|
||||
claimRequestId: string,
|
||||
fieldKindMap: Record<TKeys, MediaKind | undefined>,
|
||||
): Promise<void> {
|
||||
const provided = this.flattenProvidedFiles<TKeys>(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<UploadedFileLike>,
|
||||
clientId: Types.ObjectId | string | null,
|
||||
kind: MediaKind,
|
||||
): Promise<void> {
|
||||
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<TKeys extends string>(
|
||||
provided: Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }>,
|
||||
clientId: Types.ObjectId | string | null,
|
||||
fieldKindMap: Record<TKeys, MediaKind | undefined>,
|
||||
): Promise<void> {
|
||||
const limitsCache = new Map<MediaKind, MediaLimits>();
|
||||
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<UploadedFileLike>,
|
||||
{ minBytes, maxBytes }: MediaLimits,
|
||||
kind: MediaKind,
|
||||
field?: string,
|
||||
): Record<string, unknown> | 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<TKeys extends string>(
|
||||
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
|
||||
): Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }> {
|
||||
const out: Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }> = [];
|
||||
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<Types.ObjectId | null> {
|
||||
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<Types.ObjectId | null> {
|
||||
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<void> {
|
||||
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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user