mirror of https://github.com/msgbyte/tailchat
parent
dd358ac79b
commit
c3deea8925
@ -0,0 +1,3 @@
|
||||
Fork from https://github.com/moleculerjs/moleculer-web
|
||||
|
||||
Hash: f375dbb4f8bff8aa16e95024e5c65463b626fa45
|
@ -0,0 +1,312 @@
|
||||
/*
|
||||
* moleculer
|
||||
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
|
||||
* MIT Licensed
|
||||
*/
|
||||
|
||||
import { pathToRegexp } from 'path-to-regexp';
|
||||
import Busboy from '@fastify/busboy';
|
||||
import kleur from 'kleur';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { PayloadTooLarge } from './errors';
|
||||
import { Errors } from 'moleculer';
|
||||
const { MoleculerClientError } = Errors;
|
||||
import {
|
||||
removeTrailingSlashes,
|
||||
addSlashes,
|
||||
decodeParam,
|
||||
compose,
|
||||
} from './utils';
|
||||
|
||||
export class Alias {
|
||||
service;
|
||||
route;
|
||||
type = 'call';
|
||||
method = '*';
|
||||
path = null;
|
||||
handler = null;
|
||||
action = null;
|
||||
fullPath;
|
||||
keys;
|
||||
re;
|
||||
busboyConfig;
|
||||
|
||||
/**
|
||||
* Constructor of Alias
|
||||
*
|
||||
* @param {Service} service
|
||||
* @param {Object} route
|
||||
* @param {Object} opts
|
||||
* @param {any} action
|
||||
*/
|
||||
constructor(service, route, opts, action) {
|
||||
this.service = service;
|
||||
this.route = route;
|
||||
|
||||
if (_.isString(opts)) {
|
||||
// Parse alias string
|
||||
if (opts.indexOf(' ') !== -1) {
|
||||
const p = opts.split(/\s+/);
|
||||
this.method = p[0];
|
||||
this.path = p[1];
|
||||
} else {
|
||||
this.path = opts;
|
||||
}
|
||||
} else if (_.isObject(opts)) {
|
||||
Object.assign(this, _.cloneDeep(opts));
|
||||
}
|
||||
|
||||
if (_.isString(action)) {
|
||||
// Parse type from action name
|
||||
if (action.indexOf(':') > 0) {
|
||||
const p = action.split(':');
|
||||
this.type = p[0];
|
||||
this.action = p[1];
|
||||
} else {
|
||||
this.action = action;
|
||||
}
|
||||
} else if (_.isFunction(action)) {
|
||||
this.handler = action;
|
||||
this.action = null;
|
||||
} else if (Array.isArray(action)) {
|
||||
const mws = _.compact(
|
||||
action.map((mw) => {
|
||||
if (_.isString(mw)) this.action = mw;
|
||||
else if (_.isFunction(mw)) return mw;
|
||||
})
|
||||
);
|
||||
this.handler = compose.call(service, ...mws);
|
||||
} else if (action != null) {
|
||||
Object.assign(this, _.cloneDeep(action));
|
||||
}
|
||||
|
||||
this.type = this.type || 'call';
|
||||
|
||||
this.path = removeTrailingSlashes(this.path);
|
||||
|
||||
this.fullPath = this.fullPath || addSlashes(this.route.path) + this.path;
|
||||
if (this.fullPath !== '/' && this.fullPath.endsWith('/')) {
|
||||
this.fullPath = this.fullPath.slice(0, -1);
|
||||
}
|
||||
|
||||
this.keys = [];
|
||||
this.re = pathToRegexp(
|
||||
this.fullPath,
|
||||
this.keys,
|
||||
route.opts.pathToRegexpOptions || {}
|
||||
); // Options: https://github.com/pillarjs/path-to-regexp#usage
|
||||
|
||||
if (this.type == 'multipart') {
|
||||
// Handle file upload in multipart form
|
||||
this.handler = this.multipartHandler.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} url
|
||||
*/
|
||||
match(url) {
|
||||
const m = this.re.exec(url);
|
||||
if (!m) return false;
|
||||
|
||||
const params = {};
|
||||
|
||||
let key, param;
|
||||
for (let i = 0; i < this.keys.length; i++) {
|
||||
key = this.keys[i];
|
||||
param = m[i + 1];
|
||||
if (!param) continue;
|
||||
|
||||
params[key.name] = decodeParam(param);
|
||||
|
||||
if (key.repeat) params[key.name] = params[key.name].split(key.delimiter);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} method
|
||||
*/
|
||||
isMethod(method) {
|
||||
return this.method === '*' || this.method === method;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
printPath() {
|
||||
/* istanbul ignore next */
|
||||
return `${this.method} ${this.fullPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
toString() {
|
||||
return (
|
||||
kleur.magenta(_.padStart(this.method, 6)) +
|
||||
' ' +
|
||||
kleur.cyan(this.fullPath) +
|
||||
kleur.grey(' => ') +
|
||||
(this.handler != null && this.type !== 'multipart'
|
||||
? '<Function>'
|
||||
: this.action)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
multipartHandler(req, res) {
|
||||
const ctx = req.$ctx;
|
||||
ctx.meta.$multipart = {};
|
||||
const promises = [];
|
||||
|
||||
let numOfFiles = 0;
|
||||
let hasField = false;
|
||||
|
||||
const busboyOptions = _.defaultsDeep(
|
||||
{ headers: req.headers },
|
||||
this.busboyConfig,
|
||||
this.route.opts.busboyConfig
|
||||
);
|
||||
const busboy = new Busboy(busboyOptions);
|
||||
busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
|
||||
file.on('limit', () => {
|
||||
// This file reached the file size limit.
|
||||
if (_.isFunction(busboyOptions.onFileSizeLimit)) {
|
||||
busboyOptions.onFileSizeLimit.call(this.service, file, busboy);
|
||||
}
|
||||
file.destroy(
|
||||
new PayloadTooLarge({ fieldname, filename, encoding, mimetype })
|
||||
);
|
||||
});
|
||||
numOfFiles++;
|
||||
promises.push(
|
||||
ctx
|
||||
.call(
|
||||
this.action,
|
||||
file,
|
||||
_.defaultsDeep({}, this.route.opts.callOptions, {
|
||||
meta: {
|
||||
fieldname: fieldname,
|
||||
filename: filename,
|
||||
encoding: encoding,
|
||||
mimetype: mimetype,
|
||||
$params: req.$params,
|
||||
},
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
file.resume(); // Drain file stream to continue processing form
|
||||
busboy.emit('error', err);
|
||||
return err;
|
||||
})
|
||||
);
|
||||
});
|
||||
busboy.on('field', (field, value) => {
|
||||
hasField = true;
|
||||
ctx.meta.$multipart[field] = value;
|
||||
});
|
||||
|
||||
busboy.on('finish', async () => {
|
||||
/* istanbul ignore next */
|
||||
if (!busboyOptions.empty && numOfFiles == 0)
|
||||
return this.service.sendError(
|
||||
req,
|
||||
res,
|
||||
new MoleculerClientError('File missing in the request')
|
||||
);
|
||||
|
||||
// Call the action if no files but multipart fields
|
||||
if (numOfFiles == 0 && hasField) {
|
||||
promises.push(
|
||||
ctx.call(
|
||||
this.action,
|
||||
{},
|
||||
_.defaultsDeep({}, this.route.opts.callOptions, {
|
||||
meta: {
|
||||
$params: req.$params,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let data = await this.service.Promise.all(promises);
|
||||
const fileLimit =
|
||||
busboyOptions.limits && busboyOptions.limits.files != null
|
||||
? busboyOptions.limits.files
|
||||
: null;
|
||||
if (numOfFiles == 1 && fileLimit == 1) {
|
||||
// Remove the array wrapping
|
||||
data = data[0];
|
||||
}
|
||||
if (this.route.onAfterCall)
|
||||
data = await this.route.onAfterCall.call(
|
||||
this,
|
||||
ctx,
|
||||
this.route,
|
||||
req,
|
||||
res,
|
||||
data
|
||||
);
|
||||
|
||||
this.service.sendResponse(req, res, data, {});
|
||||
} catch (err) {
|
||||
/* istanbul ignore next */
|
||||
this.service.sendError(req, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/* istanbul ignore next */
|
||||
busboy.on('error', (err) => {
|
||||
req.unpipe(req.busboy);
|
||||
req.resume();
|
||||
this.service.sendError(req, res, err);
|
||||
});
|
||||
|
||||
// Add limit event handlers
|
||||
if (_.isFunction(busboyOptions.onPartsLimit)) {
|
||||
busboy.on('partsLimit', () =>
|
||||
busboyOptions.onPartsLimit.call(
|
||||
this.service,
|
||||
busboy,
|
||||
this,
|
||||
this.service
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (_.isFunction(busboyOptions.onFilesLimit)) {
|
||||
busboy.on('filesLimit', () =>
|
||||
busboyOptions.onFilesLimit.call(
|
||||
this.service,
|
||||
busboy,
|
||||
this,
|
||||
this.service
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (_.isFunction(busboyOptions.onFieldsLimit)) {
|
||||
busboy.on('fieldsLimit', () =>
|
||||
busboyOptions.onFieldsLimit.call(
|
||||
this.service,
|
||||
busboy,
|
||||
this,
|
||||
this.service
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
req.pipe(busboy);
|
||||
}
|
||||
}
|
@ -0,0 +1,218 @@
|
||||
/*
|
||||
* moleculer
|
||||
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
|
||||
* MIT Licensed
|
||||
*/
|
||||
|
||||
import { Errors } from 'moleculer';
|
||||
|
||||
const { MoleculerError, MoleculerClientError } = Errors;
|
||||
|
||||
export { MoleculerError, MoleculerClientError };
|
||||
|
||||
const ERR_NO_TOKEN = 'NO_TOKEN';
|
||||
const ERR_INVALID_TOKEN = 'INVALID_TOKEN';
|
||||
const ERR_UNABLE_DECODE_PARAM = 'UNABLE_DECODE_PARAM';
|
||||
const ERR_ORIGIN_NOT_FOUND = 'ORIGIN_NOT_FOUND';
|
||||
const ERR_ORIGIN_NOT_ALLOWED = 'ORIGIN_NOT_ALLOWED';
|
||||
|
||||
/**
|
||||
* Invalid request body
|
||||
*
|
||||
* @class InvalidRequestBodyError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class InvalidRequestBodyError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of InvalidRequestBodyError.
|
||||
*
|
||||
* @param {any} body
|
||||
* @param {any} error
|
||||
*
|
||||
* @memberOf InvalidRequestBodyError
|
||||
*/
|
||||
constructor(body, error) {
|
||||
super('Invalid request body', 400, 'INVALID_REQUEST_BODY', {
|
||||
body,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid response type
|
||||
*
|
||||
* @class InvalidResponseTypeError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class InvalidResponseTypeError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of InvalidResponseTypeError.
|
||||
*
|
||||
* @param {String} dataType
|
||||
*
|
||||
* @memberOf InvalidResponseTypeError
|
||||
*/
|
||||
constructor(dataType) {
|
||||
super(`Invalid response type '${dataType}'`, 500, 'INVALID_RESPONSE_TYPE', {
|
||||
dataType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthorized HTTP error
|
||||
*
|
||||
* @class UnAuthorizedError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class UnAuthorizedError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of UnAuthorizedError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf UnAuthorizedError
|
||||
*/
|
||||
constructor(type, data) {
|
||||
super('Unauthorized', 401, type || ERR_INVALID_TOKEN, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forbidden HTTP error
|
||||
*
|
||||
* @class ForbiddenError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class ForbiddenError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of ForbiddenError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf ForbiddenError
|
||||
*/
|
||||
constructor(type, data?) {
|
||||
super('Forbidden', 403, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bad request HTTP error
|
||||
*
|
||||
* @class BadRequestError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class BadRequestError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of BadRequestError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf BadRequestError
|
||||
*/
|
||||
constructor(type, data) {
|
||||
super('Bad request', 400, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not found HTTP error
|
||||
*
|
||||
* @class NotFoundError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class NotFoundError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of NotFoundError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf NotFoundError
|
||||
*/
|
||||
constructor(type?, data?) {
|
||||
super('Not found', 404, type || 'NOT_FOUND', data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload is too large HTTP error
|
||||
*
|
||||
* @class PayloadTooLarge
|
||||
* @extends {Error}
|
||||
*/
|
||||
class PayloadTooLarge extends MoleculerClientError {
|
||||
/**
|
||||
* Creates an instance of PayloadTooLarge.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf PayloadTooLarge
|
||||
*/
|
||||
constructor(data) {
|
||||
super('Payload too large', 413, 'PAYLOAD_TOO_LARGE', data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit exceeded HTTP error
|
||||
*
|
||||
* @class RateLimitExceeded
|
||||
* @extends {Error}
|
||||
*/
|
||||
class RateLimitExceeded extends MoleculerClientError {
|
||||
/**
|
||||
* Creates an instance of RateLimitExceeded.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf RateLimitExceeded
|
||||
*/
|
||||
constructor(type?, data?) {
|
||||
super('Rate limit exceeded', 429, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service unavailable HTTP error
|
||||
*
|
||||
* @class ForbiddenError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class ServiceUnavailableError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of ForbiddenError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf ForbiddenError
|
||||
*/
|
||||
constructor(type?, data?) {
|
||||
super('Service unavailable', 503, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
InvalidRequestBodyError,
|
||||
InvalidResponseTypeError,
|
||||
UnAuthorizedError,
|
||||
ForbiddenError,
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
PayloadTooLarge,
|
||||
RateLimitExceeded,
|
||||
ServiceUnavailableError,
|
||||
ERR_NO_TOKEN,
|
||||
ERR_INVALID_TOKEN,
|
||||
ERR_UNABLE_DECODE_PARAM,
|
||||
ERR_ORIGIN_NOT_FOUND,
|
||||
ERR_ORIGIN_NOT_ALLOWED,
|
||||
};
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Memory store for Rate limiter
|
||||
*
|
||||
* Inspired by https://github.com/dotcypress/micro-ratelimit/
|
||||
*
|
||||
* @class MemoryStore
|
||||
*/
|
||||
export class MemoryStore {
|
||||
hits;
|
||||
resetTime;
|
||||
timer;
|
||||
|
||||
/**
|
||||
* Creates an instance of MemoryStore.
|
||||
*
|
||||
* @param {Number} clearPeriod
|
||||
* @memberof MemoryStore
|
||||
*/
|
||||
constructor(clearPeriod) {
|
||||
this.hits = new Map();
|
||||
this.resetTime = Date.now() + clearPeriod;
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.resetTime = Date.now() + clearPeriod;
|
||||
this.reset();
|
||||
}, clearPeriod);
|
||||
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the counter by key
|
||||
*
|
||||
* @param {String} key
|
||||
* @returns {Number}
|
||||
* @memberof MemoryStore
|
||||
*/
|
||||
inc(key) {
|
||||
let counter = this.hits.get(key) || 0;
|
||||
counter++;
|
||||
this.hits.set(key, counter);
|
||||
return counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all counters
|
||||
*
|
||||
* @memberof MemoryStore
|
||||
*/
|
||||
reset() {
|
||||
this.hits.clear();
|
||||
}
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
/*
|
||||
* moleculer
|
||||
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
|
||||
* MIT Licensed
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import fresh from 'fresh';
|
||||
import etag from 'etag';
|
||||
|
||||
import {
|
||||
BadRequestError,
|
||||
ERR_UNABLE_DECODE_PARAM,
|
||||
MoleculerError,
|
||||
} from './errors';
|
||||
|
||||
/**
|
||||
* Decode URI encoded param
|
||||
* @param {String} param
|
||||
*/
|
||||
function decodeParam(param) {
|
||||
try {
|
||||
return decodeURIComponent(param);
|
||||
} catch (_) {
|
||||
/* istanbul ignore next */
|
||||
throw new BadRequestError(ERR_UNABLE_DECODE_PARAM, { param });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove slashes "/" from the left & right sides and remove double "//" slashes
|
||||
function removeTrailingSlashes(s) {
|
||||
if (s.startsWith('/')) s = s.slice(1);
|
||||
if (s.endsWith('/')) s = s.slice(0, -1);
|
||||
return s; //.replace(/\/\//g, "/");
|
||||
}
|
||||
|
||||
// Add slashes "/" to the left & right sides
|
||||
function addSlashes(s) {
|
||||
return (s.startsWith('/') ? '' : '/') + s + (s.endsWith('/') ? '' : '/');
|
||||
}
|
||||
|
||||
// Normalize URL path (remove multiple slashes //)
|
||||
function normalizePath(s) {
|
||||
return s.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose middlewares
|
||||
*
|
||||
* @param {...Function} mws
|
||||
*/
|
||||
function compose(...mws) {
|
||||
const self = this as any;
|
||||
return (req, res, done) => {
|
||||
const next = (i, err?) => {
|
||||
if (i >= mws.length) {
|
||||
if (_.isFunction(done)) return done.call(self, err);
|
||||
|
||||
/* istanbul ignore next */
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
// Call only error middlewares (err, req, res, next)
|
||||
if (mws[i].length == 4)
|
||||
mws[i].call(self, err, req, res, (err) => next(i + 1, err));
|
||||
else next(i + 1, err);
|
||||
} else {
|
||||
if (mws[i].length < 4)
|
||||
mws[i].call(self, req, res, (err) => next(i + 1, err));
|
||||
else next(i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return next(0);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose middlewares and return Promise
|
||||
* @param {...Function} mws
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function composeThen(req, res, ...mws) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
compose.call(this, ...mws)(req, res, (err) => {
|
||||
if (err) {
|
||||
/* istanbul ignore next */
|
||||
if (err instanceof MoleculerError) return reject(err);
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (err instanceof Error)
|
||||
return reject(
|
||||
new MoleculerError(
|
||||
err.message,
|
||||
(err as any).code || (err as any).status,
|
||||
(err as any).type
|
||||
)
|
||||
); // TODO err.stack
|
||||
|
||||
/* istanbul ignore next */
|
||||
return reject(new MoleculerError(err));
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ETag from content.
|
||||
*
|
||||
* @param {any} body
|
||||
* @param {Boolean|String|Function?} opt
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
function generateETag(body, opt) {
|
||||
if (_.isFunction(opt)) return opt.call(this, body);
|
||||
|
||||
const buf = !Buffer.isBuffer(body) ? Buffer.from(body) : body;
|
||||
|
||||
return etag(buf, opt === true || opt === 'weak' ? { weak: true } : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the data freshness.
|
||||
*
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isFresh(req, res) {
|
||||
if (
|
||||
(res.statusCode >= 200 && res.statusCode < 300) ||
|
||||
304 === res.statusCode
|
||||
) {
|
||||
return fresh(req.headers, {
|
||||
etag: res.getHeader('ETag'),
|
||||
'last-modified': res.getHeader('Last-Modified'),
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export {
|
||||
removeTrailingSlashes,
|
||||
addSlashes,
|
||||
normalizePath,
|
||||
decodeParam,
|
||||
compose,
|
||||
composeThen,
|
||||
generateETag,
|
||||
isFresh,
|
||||
};
|
Loading…
Reference in New Issue