forked from Yara724/api
271 lines
8.8 KiB
TypeScript
271 lines
8.8 KiB
TypeScript
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)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
}
|