59694642dd
- Add FileResponse interface and FORMAT_MIME map to service
- Implement convertCert: parses any input format (PEM/DER/PFX/P7B) via
same logic as parseCert; serializes to pem/der/p7b targetFormat
- DER output uses bytesToHex→Buffer.from(hex,'hex') to avoid utf-8
corruption (Pitfall 1 / T-09-06)
- P7B output: pkcs7.createSignedData + pem.encode (PEM-wrapped PKCS7)
- Wrap all forge ops in try/catch → BadRequestException (T-09-01)
- Controller: add @Body('pemText') + reject when neither file nor pemText
- Fix: re-add NotImplementedException import for mergeCerts stub
- All 23 API cert-manager tests green (including 4 new convertCert)
536 lines
21 KiB
TypeScript
536 lines
21 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common';
|
|
import * as forge from 'node-forge';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CertDetails — the structured result returned by parseCert
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CertDetails {
|
|
subject: { cn: string; o: string; ou: string; c: string };
|
|
issuer: { cn: string; o: string; c: string };
|
|
validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number };
|
|
san: string[];
|
|
keyType: string; // "RSA" | "EC"
|
|
keyBits: number; // 2048, 4096, 256, ...
|
|
serialNumber: string;
|
|
signatureAlgorithm: string; // "sha256WithRSAEncryption", etc.
|
|
fingerprint: { sha1: string; sha256: string };
|
|
pemPreview: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SplitResponse — the structured result returned by splitCerts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface SplitEntry {
|
|
index: number;
|
|
filename: string;
|
|
/** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */
|
|
content: string;
|
|
subject: { cn: string };
|
|
validity: { notAfter: string };
|
|
}
|
|
|
|
export interface SplitResponse {
|
|
count: number;
|
|
certs: SplitEntry[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FileResponse — the structured result returned by convertCert / mergeCerts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface FileResponse {
|
|
/** Suggested download filename, e.g. "converted.der" */
|
|
filename: string;
|
|
/** Base64-encoded file content */
|
|
content: string;
|
|
/** MIME type for the download */
|
|
mimeType: string;
|
|
}
|
|
|
|
/** Map from target format key to MIME type */
|
|
const FORMAT_MIME: Record<string, string> = {
|
|
pem: 'application/x-pem-file',
|
|
der: 'application/x-x509-ca-cert',
|
|
p7b: 'application/x-pkcs7-certificates',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reverse OID map (OID string -> human-readable algorithm name)
|
|
// Built once at module load — node-forge's pki.oids is name->OID
|
|
// ---------------------------------------------------------------------------
|
|
function buildReverseOids(): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [name, oid] of Object.entries(forge.pki.oids as Record<string, string>)) {
|
|
result[oid] = name;
|
|
}
|
|
return result;
|
|
}
|
|
const REVERSE_OIDS = buildReverseOids();
|
|
|
|
/**
|
|
* CertManagerService — server-side certificate operations.
|
|
*
|
|
* All cryptographic processing is ephemeral (upload → process → return).
|
|
* No data is persisted to disk or database.
|
|
*
|
|
* SECURITY NOTES:
|
|
* - Binary buffers MUST use toString('binary') for forge (never 'utf-8' — Pitfall 1)
|
|
* - Password parameters are never passed to the logger
|
|
* - All forge operations wrapped in try/catch → BadRequestException
|
|
*/
|
|
@Injectable()
|
|
export class CertManagerService {
|
|
private readonly logger = new Logger(CertManagerService.name);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers (used by all operation methods)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Detect the format of a certificate file from extension + content sniff.
|
|
* .cer is ambiguous — resolved by inspecting the first bytes of the buffer.
|
|
*/
|
|
detectFormat(
|
|
filename: string,
|
|
buffer: Buffer,
|
|
): 'pem' | 'der' | 'pfx' | 'p7b' {
|
|
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
|
const isPemContent = buffer.slice(0, 27).toString('ascii').includes('-----BEGIN');
|
|
|
|
if (ext === 'pfx' || ext === 'p12') return 'pfx';
|
|
if (ext === 'p7b' || ext === 'p7c') return 'p7b';
|
|
if (ext === 'der') return 'der';
|
|
if (ext === 'pem' || ext === 'crt') return 'pem';
|
|
if (ext === 'cer') return isPemContent ? 'pem' : 'der'; // .cer is ambiguous
|
|
// Fallback: sniff content
|
|
return isPemContent ? 'pem' : 'der';
|
|
}
|
|
|
|
/**
|
|
* Convert a Node.js Buffer to a forge ByteStringBuffer using 'binary' encoding.
|
|
*
|
|
* CRITICAL: Always use 'binary' encoding — UTF-8 corrupts DER/PFX/P7B binary data.
|
|
* See RESEARCH.md Pitfall 1.
|
|
*/
|
|
toForgeBuffer(buffer: Buffer): forge.util.ByteStringBuffer {
|
|
return forge.util.createBuffer(buffer.toString('binary'));
|
|
}
|
|
|
|
/**
|
|
* Compute SHA-1 or SHA-256 fingerprint of a certificate.
|
|
* Hash is computed over the DER-encoded bytes, returned as uppercase colon-joined hex.
|
|
*/
|
|
getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string {
|
|
const md = algorithm === 'sha1' ? forge.md.sha1.create() : forge.md.sha256.create();
|
|
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes();
|
|
md.update(der);
|
|
return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Split a PEM string containing one or more concatenated certificates.
|
|
* Returns an array of parsed forge Certificate objects.
|
|
*/
|
|
parsePemChain(pem: string): forge.pki.Certificate[] {
|
|
const blocks =
|
|
pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
|
|
return blocks.map((b) => forge.pki.certificateFromPem(b));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseCert — CERT-01 + CERT-05 (read half)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse a certificate from PEM text or an uploaded file (PEM/DER/PFX/P7B).
|
|
*
|
|
* Security contract (T-09-01, T-09-02):
|
|
* - All forge calls wrapped in try/catch → BadRequestException (never an unhandled 500)
|
|
* - Wrong PFX password → generic 400 message (password value never logged or echoed)
|
|
*/
|
|
async parseCert(input: {
|
|
file?: any;
|
|
pemText?: string;
|
|
password?: string;
|
|
}): Promise<CertDetails> {
|
|
const { file, pemText, password } = input;
|
|
|
|
let cert: forge.pki.Certificate;
|
|
|
|
try {
|
|
if (pemText) {
|
|
// ── PEM text input ──────────────────────────────────────────────────
|
|
const certs = this.parsePemChain(pemText);
|
|
if (certs.length === 0) {
|
|
throw new Error('No certificate block found in PEM text');
|
|
}
|
|
cert = certs[0];
|
|
} else if (file) {
|
|
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
|
|
|
|
if (format === 'pem') {
|
|
// ── PEM file ───────────────────────────────────────────────────────
|
|
const pemStr = (file.buffer as Buffer).toString('utf-8');
|
|
const certs = this.parsePemChain(pemStr);
|
|
if (certs.length === 0) {
|
|
throw new Error('No certificate block found in PEM file');
|
|
}
|
|
cert = certs[0];
|
|
} else if (format === 'der') {
|
|
// ── DER binary file ────────────────────────────────────────────────
|
|
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
|
|
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
|
cert = forge.pki.certificateFromAsn1(asn1);
|
|
} else if (format === 'pfx') {
|
|
// ── PFX/PKCS12 file ───────────────────────────────────────────────
|
|
// wrong password → forge throws → caught below → BadRequestException (T-09-02)
|
|
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
|
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
|
|
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
|
const bags = certBags[forge.pki.oids.certBag] ?? [];
|
|
if (bags.length === 0) {
|
|
throw new Error('No certificate bag found in PFX/PKCS12');
|
|
}
|
|
cert = bags[0].cert!;
|
|
} else {
|
|
// ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ────────
|
|
const isPemP7b = (file.buffer as Buffer)
|
|
.slice(0, 27)
|
|
.toString('ascii')
|
|
.includes('-----BEGIN');
|
|
let p7: any;
|
|
if (isPemP7b) {
|
|
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
|
|
} else {
|
|
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
|
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
|
}
|
|
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
|
|
if (p7Certs.length === 0) {
|
|
throw new Error('No certificate found in P7B/PKCS7');
|
|
}
|
|
cert = p7Certs[0];
|
|
}
|
|
} else {
|
|
// Neither file nor pemText — controller should have rejected this already,
|
|
// but guard here too (BadRequestException is NOT caught by the outer try/catch below)
|
|
throw new BadRequestException('No file or PEM text provided');
|
|
}
|
|
} catch (err) {
|
|
// Re-throw BadRequestException as-is; convert everything else to 400
|
|
if (err instanceof BadRequestException) throw err;
|
|
// Do NOT log the password (T-09-02)
|
|
this.logger.warn('parseCert: failed to parse certificate (format/password error)');
|
|
throw new BadRequestException(
|
|
'Failed to parse certificate: invalid format or wrong password',
|
|
);
|
|
}
|
|
|
|
// ── Build CertDetails ──────────────────────────────────────────────────
|
|
try {
|
|
const notBefore = cert.validity.notBefore;
|
|
const notAfter = cert.validity.notAfter;
|
|
const now = new Date();
|
|
const isExpired = notAfter < now;
|
|
const daysLeft = Math.ceil(
|
|
(notAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
|
);
|
|
|
|
// Key type and size
|
|
const pubKey = cert.publicKey as any;
|
|
let keyType = 'RSA';
|
|
let keyBits = 0;
|
|
if (pubKey.n) {
|
|
keyType = 'RSA';
|
|
keyBits = pubKey.n.bitLength();
|
|
} else if (pubKey.curve) {
|
|
keyType = 'EC';
|
|
// EC key size from curve params — estimate from key length
|
|
keyBits = pubKey.params?.curve?.q?.bitLength() ?? 0;
|
|
}
|
|
|
|
// Subject Alternative Names
|
|
const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName');
|
|
const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
|
|
n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`,
|
|
);
|
|
|
|
// Signature algorithm — OID → human-readable name
|
|
const sigOid = (cert.siginfo as any)?.algorithmOid ?? '';
|
|
const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid;
|
|
|
|
// Fingerprints
|
|
const sha1 = this.getFingerprint(cert, 'sha1');
|
|
const sha256 = this.getFingerprint(cert, 'sha256');
|
|
|
|
return {
|
|
subject: {
|
|
cn: cert.subject.getField('CN')?.value ?? '',
|
|
o: cert.subject.getField('O')?.value ?? '',
|
|
ou: cert.subject.getField('OU')?.value ?? '',
|
|
c: cert.subject.getField('C')?.value ?? '',
|
|
},
|
|
issuer: {
|
|
cn: cert.issuer.getField('CN')?.value ?? '',
|
|
o: cert.issuer.getField('O')?.value ?? '',
|
|
c: cert.issuer.getField('C')?.value ?? '',
|
|
},
|
|
validity: {
|
|
notBefore: notBefore.toISOString(),
|
|
notAfter: notAfter.toISOString(),
|
|
isExpired,
|
|
daysLeft,
|
|
},
|
|
san,
|
|
keyType,
|
|
keyBits,
|
|
serialNumber: cert.serialNumber,
|
|
signatureAlgorithm,
|
|
fingerprint: { sha1, sha256 },
|
|
pemPreview: forge.pki.certificateToPem(cert),
|
|
};
|
|
} catch (err) {
|
|
this.logger.warn('parseCert: failed to extract CertDetails fields');
|
|
throw new BadRequestException('Failed to extract certificate details');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Remaining operation stubs (implemented in later plan slices)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Split a fullchain PEM or P7B/PKCS7 bundle into individual certificates.
|
|
*
|
|
* Security contract (T-09-01):
|
|
* - All forge calls wrapped in try/catch → BadRequestException on malformed input
|
|
*
|
|
* Security contract (T-09-03):
|
|
* - File size limit 5 MB enforced by FileInterceptor in the controller
|
|
*/
|
|
async splitCerts(input: {
|
|
file?: any;
|
|
password?: string;
|
|
}): Promise<SplitResponse> {
|
|
const { file } = input;
|
|
|
|
if (!file) {
|
|
throw new BadRequestException('No file provided');
|
|
}
|
|
|
|
let certs: forge.pki.Certificate[];
|
|
|
|
try {
|
|
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
|
|
|
|
if (format === 'pem') {
|
|
// ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─
|
|
const pemStr = (file.buffer as Buffer).toString('utf-8');
|
|
certs = this.parsePemChain(pemStr);
|
|
if (certs.length === 0) {
|
|
throw new Error('No certificate blocks found in PEM file');
|
|
}
|
|
} else if (format === 'p7b') {
|
|
// ── P7B/PKCS7 bundle — PEM-wrapped or binary DER (Pitfall 4) ─────────
|
|
const isPemP7b = (file.buffer as Buffer)
|
|
.slice(0, 27)
|
|
.toString('ascii')
|
|
.includes('-----BEGIN');
|
|
let p7: any;
|
|
if (isPemP7b) {
|
|
// PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----)
|
|
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
|
|
} else {
|
|
// Binary DER PKCS7
|
|
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
|
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
|
}
|
|
certs = (p7.certificates as forge.pki.Certificate[]) ?? [];
|
|
if (certs.length === 0) {
|
|
throw new Error('No certificates found in P7B/PKCS7 bundle');
|
|
}
|
|
} else {
|
|
// DER / PFX — not a valid chain/bundle format for splitting
|
|
throw new BadRequestException(
|
|
'Only PEM chains (.pem, .crt) and P7B bundles (.p7b) can be split',
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof BadRequestException) throw err;
|
|
this.logger.warn('splitCerts: failed to parse bundle');
|
|
throw new BadRequestException('Failed to split certificates: invalid format or corrupted file');
|
|
}
|
|
|
|
// ── Build SplitResponse ────────────────────────────────────────────────
|
|
const certEntries: SplitEntry[] = certs.map((cert, index) => {
|
|
const pemStr = forge.pki.certificateToPem(cert);
|
|
const content = Buffer.from(pemStr, 'utf-8').toString('base64');
|
|
const cn: string = cert.subject.getField('CN')?.value ?? '';
|
|
const notAfter: string = cert.validity.notAfter.toISOString();
|
|
|
|
return {
|
|
index,
|
|
filename: `cert-${index + 1}.pem`,
|
|
content,
|
|
subject: { cn },
|
|
validity: { notAfter },
|
|
};
|
|
});
|
|
|
|
return {
|
|
count: certEntries.length,
|
|
certs: certEntries,
|
|
};
|
|
}
|
|
|
|
async mergeCerts(_input: {
|
|
files?: any[];
|
|
outputFormat: string;
|
|
password?: string;
|
|
}): Promise<never> {
|
|
throw new NotImplementedException('mergeCerts is not yet implemented');
|
|
}
|
|
|
|
/**
|
|
* Convert a certificate between PEM, DER, and P7B formats.
|
|
*
|
|
* Security contract (T-09-01, T-09-06):
|
|
* - All forge calls wrapped in try/catch → BadRequestException on malformed input
|
|
* - DER output built via bytesToHex → Buffer.from(hex, 'hex') → base64 (never utf-8 round-trip)
|
|
* - Password is never passed to the logger (T-09-02)
|
|
*/
|
|
async convertCert(input: {
|
|
file?: any;
|
|
pemText?: string;
|
|
targetFormat: string;
|
|
password?: string;
|
|
}): Promise<FileResponse> {
|
|
const { file, pemText, targetFormat, password } = input;
|
|
|
|
// ── Validate targetFormat ──────────────────────────────────────────────
|
|
if (!FORMAT_MIME[targetFormat]) {
|
|
throw new BadRequestException(
|
|
`Unsupported target format: "${targetFormat}". Supported: pem, der, p7b`,
|
|
);
|
|
}
|
|
|
|
let cert: forge.pki.Certificate;
|
|
|
|
try {
|
|
// ── Resolve input to a forge Certificate ────────────────────────────
|
|
if (pemText) {
|
|
// PEM text pasted by the user
|
|
const certs = this.parsePemChain(pemText);
|
|
if (certs.length === 0) {
|
|
throw new Error('No certificate block found in PEM text');
|
|
}
|
|
cert = certs[0];
|
|
} else if (file) {
|
|
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
|
|
|
|
if (format === 'pem') {
|
|
const pemStr = (file.buffer as Buffer).toString('utf-8');
|
|
const certs = this.parsePemChain(pemStr);
|
|
if (certs.length === 0) {
|
|
throw new Error('No certificate block found in PEM file');
|
|
}
|
|
cert = certs[0];
|
|
} else if (format === 'der') {
|
|
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
|
|
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
|
cert = forge.pki.certificateFromAsn1(asn1);
|
|
} else if (format === 'pfx') {
|
|
// PFX/PKCS12 — extract first cert bag (wrong password → BadRequestException)
|
|
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
|
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
|
|
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
|
const bags = certBags[forge.pki.oids.certBag] ?? [];
|
|
if (bags.length === 0) {
|
|
throw new Error('No certificate bag found in PFX/PKCS12');
|
|
}
|
|
cert = bags[0].cert!;
|
|
} else {
|
|
// P7B — extract first cert
|
|
const isPemP7b = (file.buffer as Buffer)
|
|
.slice(0, 27)
|
|
.toString('ascii')
|
|
.includes('-----BEGIN');
|
|
let p7: any;
|
|
if (isPemP7b) {
|
|
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
|
|
} else {
|
|
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
|
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
|
}
|
|
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
|
|
if (p7Certs.length === 0) {
|
|
throw new Error('No certificate found in P7B/PKCS7');
|
|
}
|
|
cert = p7Certs[0];
|
|
}
|
|
} else {
|
|
throw new BadRequestException('No file or PEM text provided');
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof BadRequestException) throw err;
|
|
// Password never logged (T-09-02)
|
|
this.logger.warn('convertCert: failed to parse input certificate');
|
|
throw new BadRequestException(
|
|
'Failed to parse certificate: invalid format or wrong password',
|
|
);
|
|
}
|
|
|
|
// ── Serialize to targetFormat ─────────────────────────────────────────
|
|
try {
|
|
let content: string;
|
|
|
|
if (targetFormat === 'pem') {
|
|
// PEM text → base64 via utf-8
|
|
const pemOut = forge.pki.certificateToPem(cert);
|
|
content = Buffer.from(pemOut, 'utf-8').toString('base64');
|
|
} else if (targetFormat === 'der') {
|
|
// DER binary — CRITICAL: bytesToHex → Buffer.from(hex, 'hex') → base64
|
|
// Avoids utf-8 round-trip corruption (RESEARCH Pitfall 1 / T-09-06)
|
|
const derHex = forge.util.bytesToHex(
|
|
forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(),
|
|
);
|
|
content = Buffer.from(derHex, 'hex').toString('base64');
|
|
} else {
|
|
// P7B — PEM-wrapped PKCS7 SignedData containing the certificate
|
|
const p7 = forge.pkcs7.createSignedData();
|
|
p7.addCertificate(cert);
|
|
const p7DerBytes = forge.asn1.toDer(p7.toAsn1()).getBytes();
|
|
const p7PemStr = forge.pem.encode({ type: 'PKCS7', body: p7DerBytes });
|
|
content = Buffer.from(p7PemStr, 'utf-8').toString('base64');
|
|
}
|
|
|
|
return {
|
|
filename: `converted.${targetFormat}`,
|
|
content,
|
|
mimeType: FORMAT_MIME[targetFormat],
|
|
};
|
|
} catch (err) {
|
|
this.logger.warn('convertCert: failed to serialize to target format');
|
|
throw new BadRequestException(
|
|
`Failed to convert certificate to ${targetFormat}: serialization error`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers for later slices
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Wrap a node-forge operation and re-throw as BadRequestException on failure */
|
|
protected _parseOrThrow<T>(fn: () => T, errorMsg: string): T {
|
|
try {
|
|
return fn();
|
|
} catch (_err) {
|
|
this.logger.warn(`Cert parse failed: ${errorMsg}`);
|
|
throw new BadRequestException(errorMsg);
|
|
}
|
|
}
|
|
}
|