import crypto from 'crypto';
import util from 'util';
import Constants from '../constants';
import FilteringError from '../errors/general/filtering_error';
import InvalidParameterError from '../errors/general/invalid_parameter_error';
import SignatureVerificationError from '../errors/general/signature_verification_error';
/**
* Utility class of various publicly-available helper functions.
* @public
* @type {Utils}
*/
export default class Utils {
/**
* Get the lowest SmartRate from a provided list of SmartRates.
* @public
* @param {Rate[]} smartrates - List of SmartRates to filter through
* @param {number} deliveryDays - The maximum number of days allowed for delivery
* @param {string} deliveryAccuracy - The target level of accuracy for the delivery days (e.g. 'percentile_50')
* @returns {Rate} - The lowest SmartRate
* @throws {FilteringError} - If no applicable rates are found
* @throws {InvalidParameterError} - If the deliveryAccuracy value is invalid
*/
getLowestSmartRate(smartrates, deliveryDays, deliveryAccuracy) {
const validDeliveryAccuracyValues = new Set([
'percentile_50',
'percentile_75',
'percentile_85',
'percentile_90',
'percentile_95',
'percentile_97',
'percentile_99',
]);
let lowestSmartRate = null;
const lowercaseDeliveryAccuracy = deliveryAccuracy.toLowerCase();
if (!validDeliveryAccuracyValues.has(lowercaseDeliveryAccuracy)) {
throw new InvalidParameterError({
message: `Invalid deliveryAccuracy value, must be one of: ${new Array(
...validDeliveryAccuracyValues,
).join(', ')}`,
});
}
for (let i = 0; i < smartrates.length; i += 1) {
const rate = smartrates[i];
if (rate.time_in_transit[lowercaseDeliveryAccuracy] > parseInt(deliveryDays, 10)) {
// eslint-disable-next-line no-continue
continue;
} else if (
lowestSmartRate === null ||
parseFloat(rate.rate) < parseFloat(lowestSmartRate.rate)
) {
lowestSmartRate = rate;
}
}
if (lowestSmartRate === null) {
throw new FilteringError({ message: util.format(Constants.NO_OBJECT_FOUND, 'rates') });
}
return lowestSmartRate;
}
/**
* Get the lowest rate from a provided list of rates.
* @public
* @param {Rate[]} rates - List of rates to filter through
* @param {string[]} [carriers] - List of allowed carriers to filter by
* @param {string[]} [services] - List of allowed services to filter by
* @returns {Rate} - The lowest rate
* @throws {FilteringError} - If no applicable rates are found
*/
getLowestRate(rates, carriers = null, services = null) {
if (carriers) {
const carriersLower = carriers.map((carrier) => carrier.toLowerCase());
// eslint-disable-next-line no-param-reassign
rates = rates.filter((rate) => carriersLower.includes(rate.carrier.toLowerCase()));
}
if (services) {
const servicesLower = services.map((service) => service.toLowerCase());
// eslint-disable-next-line no-param-reassign
rates = rates.filter((rate) => servicesLower.includes(rate.service.toLowerCase()));
}
if (rates.length === 0) {
throw new FilteringError({ message: util.format(Constants.NO_OBJECT_FOUND, 'rates') });
}
return rates.reduce((lowest, rate) => {
if (parseFloat(rate.rate) < parseFloat(lowest.rate)) {
return rate;
}
return lowest;
}, rates[0]);
}
/**
* Validate a webhook by comparing the HMAC signature header sent from EasyPost to your shared secret.
* If the signatures do not match, an error will be raised signifying the webhook either did not originate
* from EasyPost or the secrets do not match. If the signatures do match, the `event_body` will be returned
* as JSON.
* @public
* @param {buffer} eventBody - The raw body of the webhook event
* @param {Object} headers - The headers of the webhook HTTP request
* @param {string} webhookSecret - The webhook secret shared between EasyPost and your application
* @returns {object} - The JSON-parsed webhook event body if the signature could be verified
* @throws {SignatureVerificationError} - If the signature could not be verified
*/
validateWebhook(eventBody, headers, webhookSecret) {
let webhook = {};
const easypostHmacSignature =
headers['X-Hmac-Signature'] ?? headers['x-hmac-signature'] ?? null;
if (easypostHmacSignature != null) {
const normalizedSecret = webhookSecret.normalize('NFKD');
const encodedSecret = Buffer.from(normalizedSecret, 'utf8');
// Fixes Javascript's float to string conversion. See https://github.com/EasyPost/easypost-node/issues/467
const correctedEventBody = Buffer.from(eventBody)
.toString('utf8')
.replace(/("weight":\s*)(\d+)(\s*)(?=,|\})/g, '$1$2.0');
const expectedSignature = crypto
.createHmac('sha256', encodedSecret)
.update(correctedEventBody, 'utf-8')
.digest('hex');
const digest = `hmac-sha256-hex=${expectedSignature}`;
try {
if (
crypto.timingSafeEqual(
Buffer.from(easypostHmacSignature, 'utf8'),
Buffer.from(digest, 'utf8'),
)
) {
webhook = JSON.parse(correctedEventBody);
} else {
throw new SignatureVerificationError({ message: Constants.WEBHOOK_DOES_NOT_MATCH });
}
} catch (e) {
throw new SignatureVerificationError({ message: Constants.WEBHOOK_DOES_NOT_MATCH });
}
} else {
throw new SignatureVerificationError({ message: Constants.INVALID_WEBHOOK_SIGNATURE });
}
return webhook;
}
}