104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
import { createHash, createHmac } from 'node:crypto';
|
||
import { BadRequestException, HttpException } from '@nestjs/common';
|
||
import type { LookupFunction } from 'node:net';
|
||
|
||
/** Keep the validated address pinned while honoring Node's all-address lookup contract. */
|
||
export function pinnedWebhookLookup(address: string, family: number): LookupFunction {
|
||
return (_hostname, options, callback) => {
|
||
if (options.all) callback(null, [{ address, family }]);
|
||
else callback(null, address, family);
|
||
};
|
||
}
|
||
|
||
/** Validate calendar components before Date can normalize an impossible day. */
|
||
export function parseOpenApiDate(value: string): Date {
|
||
const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2}))?$/.exec(
|
||
value,
|
||
);
|
||
if (parts) {
|
||
const year = Number(parts[1]);
|
||
const month = Number(parts[2]);
|
||
const day = Number(parts[3]);
|
||
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||
const zone = parts[8];
|
||
const validZone = !zone || zone === 'Z' || (Number(zone.slice(1, 3)) < 24 && Number(zone.slice(4)) < 60);
|
||
const date = new Date(value);
|
||
if (
|
||
month >= 1 &&
|
||
month <= 12 &&
|
||
day >= 1 &&
|
||
day <= days[month - 1] &&
|
||
Number(parts[4] ?? 0) < 24 &&
|
||
Number(parts[5] ?? 0) < 60 &&
|
||
Number(parts[6] ?? 0) < 60 &&
|
||
validZone &&
|
||
Number.isFinite(date.getTime())
|
||
)
|
||
return date;
|
||
}
|
||
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '时间必须为有效的ISO8601日期或带时区时间' });
|
||
}
|
||
|
||
export type OpenApiProblemResponse = {
|
||
setHeader(name: string, value: string): void;
|
||
status(code: number): { type(value: string): { send(body: unknown): void } };
|
||
};
|
||
|
||
export function sendOpenApiProblem(
|
||
response: OpenApiProblemResponse,
|
||
requestId: string,
|
||
failure: { status: number; code: string; message: string },
|
||
) {
|
||
response.setHeader('X-Request-Id', requestId);
|
||
response
|
||
.status(failure.status)
|
||
.type('application/problem+json')
|
||
.send({
|
||
type: `https://cmpp-platform.local/problems/${failure.code.toLowerCase()}`,
|
||
title: failure.status >= 500 ? 'Internal Server Error' : 'Request failed',
|
||
status: failure.status,
|
||
code: failure.code,
|
||
detail: failure.message,
|
||
requestId,
|
||
});
|
||
}
|
||
|
||
/** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */
|
||
export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) {
|
||
return createHash('sha256')
|
||
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
|
||
.digest('hex');
|
||
}
|
||
|
||
export function openApiSignature(
|
||
secret: string,
|
||
method: string,
|
||
path: string,
|
||
timestamp: string,
|
||
nonce: string,
|
||
bodyHash: string,
|
||
) {
|
||
return createHmac('sha256', secret)
|
||
.update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n'))
|
||
.digest('hex');
|
||
}
|
||
|
||
export function publicOpenApiFailure(error: unknown) {
|
||
if (!(error instanceof HttpException) || error.getStatus() >= 500) {
|
||
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
|
||
}
|
||
const value = error.getResponse();
|
||
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
|
||
const message = object.message ?? value;
|
||
return {
|
||
status: error.getStatus(),
|
||
code: String(object.code ?? 'REQUEST_FAILED'),
|
||
message: Array.isArray(message) ? message.join(';') : String(message),
|
||
};
|
||
}
|
||
|
||
export function webhookJobId(deliveryId: string, attemptNo: number) {
|
||
return `webhook-${createHash('sha256').update(deliveryId).digest('hex')}-${attemptNo}`;
|
||
}
|