diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0aff84d6..6197b714 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -765,7 +765,6 @@ importers: moleculer: 0.14.23 moleculer-cli: ^0.7.1 moleculer-minio: ^2.0.0 - moleculer-web: ^0.10.4 moment: ^2.29.1 mongodb: 4.2.1 mongoose: 6.1.1 @@ -810,7 +809,6 @@ importers: mime: 2.6.0 mkdirp: 1.0.4 moleculer-minio: 2.0.0_77awcwzrgh47fhn6qqq4ghcfau_moleculer@0.14.23 - moleculer-web: 0.10.4_moleculer@0.14.23 moment: 2.29.4 mongodb: 4.2.1 mongoose: 6.1.1 @@ -908,14 +906,19 @@ importers: server/packages/sdk: specifiers: + '@fastify/busboy': ^1.1.0 '@typegoose/typegoose': 9.3.1 accept-language: ^3.0.18 + body-parser: ^1.20.1 crc: ^3.8.0 dotenv: ^10.0.0 + etag: ^1.8.1 fastest-validator: ^1.12.0 + fresh: ^0.5.2 i18next: ^20.3.5 i18next-fs-backend: ^1.1.1 ioredis: ^4.27.6 + isstream: ^0.1.2 kleur: ^4.1.4 lodash: ^4.17.21 moleculer: 0.14.23 @@ -924,16 +927,22 @@ importers: moment: ^2.29.1 mongodb: 4.2.1 mongoose: 6.1.1 + path-to-regexp: ^6.2.1 typescript: ^4.3.3 dependencies: + '@fastify/busboy': 1.1.0 '@typegoose/typegoose': 9.3.1_mongoose@6.1.1 accept-language: 3.0.18 + body-parser: 1.20.1 crc: 3.8.0 dotenv: 10.0.0 + etag: 1.8.1 fastest-validator: 1.13.0 + fresh: 0.5.2 i18next: 20.6.1 i18next-fs-backend: 1.1.5 ioredis: 4.28.5 + isstream: 0.1.2 kleur: 4.1.5 lodash: 4.17.21 moleculer: 0.14.23_ioredis@4.28.5 @@ -942,6 +951,7 @@ importers: moment: 2.29.4 mongodb: 4.2.1 mongoose: 6.1.1 + path-to-regexp: 6.2.1 devDependencies: typescript: 4.7.4 @@ -13758,7 +13768,6 @@ packages: unpipe: 1.0.0 transitivePeerDependencies: - supports-color - dev: true /bonjour-service/1.0.13: resolution: {integrity: sha512-LWKRU/7EqDUC9CTAQtuZl5HzBALoCYwtLhffW3et7vZMwv3bWLpJf8bRYlMD5OCcDpTfnPgNCV4yo9ZIaJGMiA==} @@ -17381,7 +17390,7 @@ packages: es-to-primitive: 1.2.1 function-bind: 1.1.1 function.prototype.name: 1.1.5 - get-intrinsic: 1.1.2 + get-intrinsic: 1.1.3 get-symbol-description: 1.0.0 has: 1.0.3 has-property-descriptors: 1.0.0 @@ -19691,6 +19700,7 @@ packages: function-bind: 1.1.1 has: 1.0.3 has-symbols: 1.0.3 + dev: true /get-intrinsic/1.1.3: resolution: {integrity: sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==} @@ -21299,7 +21309,7 @@ packages: resolution: {integrity: sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.1.2 + get-intrinsic: 1.1.3 has: 1.0.3 side-channel: 1.0.4 @@ -22228,7 +22238,7 @@ packages: pretty-format: 27.5.1 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.1_nzafxra4mdyuer2ejmql6rdadq + ts-node: 10.9.1_t4lrjbt3sxauai4t5o275zsepa transitivePeerDependencies: - bufferutil - canvas @@ -24815,28 +24825,6 @@ packages: yargs-parser: 21.1.1 dev: false - /moleculer-web/0.10.4_moleculer@0.14.23: - resolution: {integrity: sha512-WU5jZRoH53D3Cx2eBPFPlY5+7RI4teb5nlupRZO0N9vkRblGIFm4ySahZxBN4xVXvFSF5EWt/j1BdQr5rBocVw==} - engines: {node: '>= 10.x.x'} - peerDependencies: - moleculer: ^0.13.0 || ^0.14.0 - dependencies: - '@fastify/busboy': 1.1.0 - body-parser: 1.20.0 - es6-error: 4.1.1 - etag: 1.8.1 - fresh: 0.5.2 - isstream: 0.1.2 - kleur: 4.1.5 - lodash: 4.17.21 - moleculer: 0.14.23_v2nojfag4zngjtjfzx3xixwhsy - path-to-regexp: 3.2.0 - qs: 6.11.0 - serve-static: 1.15.0 - transitivePeerDependencies: - - supports-color - dev: false - /moleculer/0.14.23_ioredis@4.28.5: resolution: {integrity: sha512-DE84fy8+1QiA7kUkF9ce4nyDdptBDlLFQfsaUccTScMGAbEjye5e+zfoI9iSA9rznwvshC8tgLhbOcnQBMn/HA==} engines: {node: '>= 10.x.x'} @@ -26639,10 +26627,6 @@ packages: resolution: {integrity: sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==} dev: false - /path-to-regexp/3.2.0: - resolution: {integrity: sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==} - dev: false - /path-to-regexp/6.2.1: resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} dev: false @@ -34122,7 +34106,6 @@ packages: typescript: 4.7.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - dev: false /ts-node/10.9.1_typescript@4.7.4: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} diff --git a/server/package.json b/server/package.json index 148417b6..cf61d3b8 100644 --- a/server/package.json +++ b/server/package.json @@ -57,7 +57,6 @@ "mime": "^2.5.2", "mkdirp": "^1.0.4", "moleculer-minio": "^2.0.0", - "moleculer-web": "^0.10.4", "moment": "^2.29.1", "mongodb": "4.2.1", "mongoose": "6.1.1", diff --git a/server/packages/sdk/package.json b/server/packages/sdk/package.json index 9e4c2833..3f56751f 100644 --- a/server/packages/sdk/package.json +++ b/server/packages/sdk/package.json @@ -32,14 +32,19 @@ "typescript": "^4.3.3" }, "dependencies": { + "@fastify/busboy": "^1.1.0", "@typegoose/typegoose": "9.3.1", "accept-language": "^3.0.18", + "body-parser": "^1.20.1", "crc": "^3.8.0", "dotenv": "^10.0.0", + "etag": "^1.8.1", "fastest-validator": "^1.12.0", + "fresh": "^0.5.2", "i18next": "^20.3.5", "i18next-fs-backend": "^1.1.1", "ioredis": "^4.27.6", + "isstream": "^0.1.2", "kleur": "^4.1.4", "lodash": "^4.17.21", "moleculer": "0.14.23", @@ -47,6 +52,7 @@ "moleculer-repl": "^0.7.2", "moment": "^2.29.1", "mongodb": "4.2.1", - "mongoose": "6.1.1" + "mongoose": "6.1.1", + "path-to-regexp": "^6.2.1" } } diff --git a/server/packages/sdk/src/index.ts b/server/packages/sdk/src/index.ts index e5660c66..de2ea3cf 100644 --- a/server/packages/sdk/src/index.ts +++ b/server/packages/sdk/src/index.ts @@ -12,6 +12,8 @@ export type { } from './services/types'; export { parseLanguageFromHead } from './services/lib/i18n/parser'; export { t } from './services/lib/i18n'; +export { ApiGatewayMixin } from './services/lib/moleculer-web'; +export * as ApiGatewayErrors from './services/lib/moleculer-web/errors'; export * from './services/lib/errors'; export { PERMISSION, allPermission } from './services/lib/role'; export { call } from './services/lib/call'; diff --git a/server/packages/sdk/src/services/lib/moleculer-web/README.md b/server/packages/sdk/src/services/lib/moleculer-web/README.md new file mode 100644 index 00000000..fd14d78e --- /dev/null +++ b/server/packages/sdk/src/services/lib/moleculer-web/README.md @@ -0,0 +1,3 @@ +Fork from https://github.com/moleculerjs/moleculer-web + +Hash: f375dbb4f8bff8aa16e95024e5c65463b626fa45 diff --git a/server/packages/sdk/src/services/lib/moleculer-web/alias.ts b/server/packages/sdk/src/services/lib/moleculer-web/alias.ts new file mode 100644 index 00000000..700bd9f2 --- /dev/null +++ b/server/packages/sdk/src/services/lib/moleculer-web/alias.ts @@ -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' + ? '' + : 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); + } +} diff --git a/server/packages/sdk/src/services/lib/moleculer-web/errors.ts b/server/packages/sdk/src/services/lib/moleculer-web/errors.ts new file mode 100644 index 00000000..4470c154 --- /dev/null +++ b/server/packages/sdk/src/services/lib/moleculer-web/errors.ts @@ -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, +}; diff --git a/server/packages/sdk/src/services/lib/moleculer-web/index.ts b/server/packages/sdk/src/services/lib/moleculer-web/index.ts new file mode 100644 index 00000000..50ea4d35 --- /dev/null +++ b/server/packages/sdk/src/services/lib/moleculer-web/index.ts @@ -0,0 +1,1934 @@ +/* + * moleculer + * Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer) + * MIT Licensed + */ + +import http from 'http'; +import http2 from 'http2'; +import https from 'https'; +import queryString from 'qs'; +import os from 'os'; +import kleur from 'kleur'; + +import _ from 'lodash'; +import bodyParser from 'body-parser'; +import serveStatic from 'serve-static'; +import { isReadable as isReadableStream } from 'isstream'; + +import { Errors, ServiceSchema, Utils } from 'moleculer'; +const { MoleculerError, MoleculerServerError, ServiceNotFoundError } = Errors; +const match = Utils.match; +import { + ServiceUnavailableError, + NotFoundError, + ForbiddenError, + RateLimitExceeded, + ERR_ORIGIN_NOT_ALLOWED, +} from './errors'; + +import { Alias } from './alias'; +import { MemoryStore } from './memory-store'; + +import { + removeTrailingSlashes, + addSlashes, + normalizePath, + composeThen, + generateETag, + isFresh, +} from './utils'; + +const MAPPING_POLICY_ALL = 'all'; +const MAPPING_POLICY_RESTRICT = 'restrict'; + +const pkg = { + name: 'moleculer-web', + version: '0.10.5', + repository: { + type: 'git', + url: 'https://github.com/moleculerjs/moleculer-web.git', + }, +}; + +function getServiceFullname(svc) { + if (svc.version != null && svc.settings.$noVersionPrefix !== true) + return ( + (typeof svc.version == 'number' ? 'v' + svc.version : svc.version) + + '.' + + svc.name + ); + + return svc.name; +} + +const SLASH_REGEX = new RegExp(/\./g); + +/** + * Official API Gateway service for Moleculer microservices framework. + * + * @service + */ +export const ApiGatewayMixin: ServiceSchema = { + // Default service name + name: 'api', + + // Default settings + settings: { + // Exposed port + port: process.env.PORT || 3000, + + // Exposed IP + ip: process.env.IP || '0.0.0.0', + + // Used server instance. If null, it will create a new HTTP(s)(2) server + // If false, it will start without server in middleware mode + server: true, + + // Routes + routes: [], + + // Log each request (default to "info" level) + logRequest: 'info', + + // Log the request ctx.params (default to "debug" level) + logRequestParams: 'debug', + + // Log each response (default to "info" level) + logResponse: 'info', + + // Log the response data (default to disable) + logResponseData: null, + + // If set to true, it will log 4xx client errors, as well + log4XXResponses: false, + + // Log the route registration/aliases related activity + logRouteRegistration: 'info', + + // Use HTTP2 server (experimental) + http2: false, + + // HTTP Server Timeout + httpServerTimeout: null, + + // Request Timeout. More info: https://github.com/moleculerjs/moleculer-web/issues/206 + requestTimeout: 300000, // Sets node.js v18 default timeout: https://nodejs.org/api/http.html#serverrequesttimeout + + // Optimize route order + optimizeOrder: true, + + // CallOption for the root action `api.rest` + rootCallOptions: null, + + // Debounce wait time before call to regenerate aliases when received event "$services.changed" + debounceTime: 500, + }, + + // Service's metadata + metadata: { + $category: 'gateway', + $description: 'Official API Gateway service', + $official: true, + $package: { + name: pkg.name, + version: pkg.version, + repo: pkg.repository ? pkg.repository.url : null, + }, + }, + + actions: { + /** + * REST request handler + */ + rest: { + visibility: 'private', + tracing: { + tags: { + params: ['req.url', 'req.method'], + }, + spanName: (ctx) => `${ctx.params.req.method} ${ctx.params.req.url}`, + }, + timeout: 0, + handler(ctx) { + const req = ctx.params.req; + const res = ctx.params.res; + + // Set pointers to Context + req.$ctx = ctx; + res.$ctx = ctx; + + if (ctx.requestID) res.setHeader('X-Request-ID', ctx.requestID); + + if (!req.originalUrl) req.originalUrl = req.url; + + // Split URL & query params + const parsed = this.parseQueryString(req); + let url = parsed.url; + + // Trim trailing slash + if (url.length > 1 && url.endsWith('/')) url = url.slice(0, -1); + + req.parsedUrl = url; + + if (!req.query) req.query = parsed.query; + + // Skip if no routes + if (!this.routes || this.routes.length == 0) return null; + + let method = req.method; + if (method == 'OPTIONS') { + method = req.headers['access-control-request-method']; + } + + // Check aliases + const found = this.resolveAlias(url, method); + if (found) { + const route = found.alias.route; + // Update URLs for middlewares + req.baseUrl = route.path; + req.url = req.originalUrl.substring(route.path.length); + if (req.url.length == 0 || req.url[0] !== '/') + req.url = '/' + req.url; + + return this.routeHandler(ctx, route, req, res, found); + } + + // Check routes + for (let i = 0; i < this.routes.length; i++) { + const route = this.routes[i]; + + if (url.startsWith(route.path)) { + // Update URLs for middlewares + req.baseUrl = route.path; + req.url = req.originalUrl.substring(route.path.length); + if (req.url.length == 0 || req.url[0] !== '/') + req.url = '/' + req.url; + + return this.routeHandler(ctx, route, req, res); + } + } + + return null; + }, + }, + + listAliases: { + rest: 'GET /list-aliases', + params: { + grouping: { type: 'boolean', optional: true, convert: true }, + withActionSchema: { type: 'boolean', optional: true, convert: true }, + }, + handler(ctx) { + const grouping = !!ctx.params.grouping; + const withActionSchema = !!ctx.params.withActionSchema; + + const actionList = withActionSchema + ? this.broker.registry.getActionList({}) + : null; + + const res = []; + + this.aliases.forEach((alias) => { + const obj: any = { + actionName: alias.action, + path: alias.path, + fullPath: alias.fullPath, + methods: alias.method, + routePath: alias.route.path, + }; + + if (withActionSchema && alias.action) { + const actionSchema = actionList.find( + (item) => item.name == alias.action + ); + if (actionSchema && actionSchema.action) { + obj.action = _.omit(actionSchema.action, ['handler']); + } + } + + if (grouping) { + const r = res.find((item) => item.route == alias.route); + if (r) r.aliases.push(obj); + else { + res.push({ + route: alias.route, + aliases: [obj], + }); + } + } else { + res.push(obj); + } + }); + + if (grouping) { + res.forEach((item) => { + item.path = item.route.path; + delete item.route; + }); + } + + return res; + }, + }, + + addRoute: { + params: { + route: { type: 'object' }, + toBottom: { type: 'boolean', optional: true, default: true }, + }, + visibility: 'public', + handler(ctx) { + return this.addRoute(ctx.params.route, ctx.params.toBottom); + }, + }, + + removeRoute: { + params: { + name: { type: 'string', optional: true }, + path: { type: 'string', optional: true }, + }, + visibility: 'public', + handler(ctx) { + if (ctx.params.name != null) + return this.removeRouteByName(ctx.params.name); + + return this.removeRoute(ctx.params.path); + }, + }, + }, + + methods: { + /** + * Create HTTP server + */ + createServer() { + /* istanbul ignore next */ + if (this.server) return; + + if ( + this.settings.https && + this.settings.https.key && + this.settings.https.cert + ) { + this.server = this.settings.http2 + ? http2.createSecureServer(this.settings.https, this.httpHandler) + : https.createServer(this.settings.https, this.httpHandler); + this.isHTTPS = true; + } else { + this.server = this.settings.http2 + ? http2.createServer(this.httpHandler) + : http.createServer(this.httpHandler); + this.isHTTPS = false; + } + + // HTTP server timeout + if (this.settings.httpServerTimeout) { + this.logger.debug( + 'Override default http(s) server timeout:', + this.settings.httpServerTimeout + ); + this.server.setTimeout(this.settings.httpServerTimeout); + } + + this.server.requestTimeout = this.settings.requestTimeout; + this.logger.debug( + 'Setting http(s) server request timeout to:', + this.settings.requestTimeout + ); + }, + + /** + * Default error handling behaviour + * + * @param {HttpRequest} req + * @param {HttpResponse} res + * @param {Error} err + */ + errorHandler(req, res, err) { + // don't log client side errors unless it's configured + if ( + this.settings.log4XXResponses || + (err && !_.inRange(err.code, 400, 500)) + ) { + this.logger.error( + ' Request error!', + err.name, + ':', + err.message, + '\n', + err.stack, + '\nData:', + err.data + ); + } + this.sendError(req, res, err); + }, + + corsHandler(settings, req, res) { + // CORS headers + if (settings.cors) { + // Set CORS headers to `res` + this.writeCorsHeaders(settings, req, res, true); + + // Is it a Preflight request? + if ( + req.method == 'OPTIONS' && + req.headers['access-control-request-method'] + ) { + // 204 - No content + res.writeHead(204, { + 'Content-Length': '0', + }); + res.end(); + + if (settings.logging) { + this.logResponse(req, res); + } + + return true; + } + } + + return false; + }, + + /** + * HTTP request handler. It is called from native NodeJS HTTP server. + * + * @param {HttpRequest} req + * @param {HttpResponse} res + * @param {Function} next Call next middleware (for Express) + * @returns {Promise} + */ + async httpHandler(req, res, next) { + // Set pointers to service + req.$startTime = process.hrtime(); + req.$service = this; + req.$next = next; + + res.$service = this; + res.locals = res.locals || {}; + + let requestID = req.headers['x-request-id']; + if (req.headers['x-correlation-id']) + requestID = req.headers['x-correlation-id']; + + const options = { requestID }; + if (this.settings.rootCallOptions) { + if (_.isPlainObject(this.settings.rootCallOptions)) { + Object.assign(options, this.settings.rootCallOptions); + } else if (_.isFunction(this.settings.rootCallOptions)) { + this.settings.rootCallOptions.call(this, options, req, res); + } + } + + try { + const result = await this.actions.rest({ req, res }, options); + if (result == null) { + // Not routed. + + const shouldBreak = this.corsHandler(this.settings, req, res); // check cors settings first + if (shouldBreak) { + return; + } + + // Serve assets static files + if (this.serve) { + this.serve(req, res, (err) => { + this.logger.debug(err); + this.send404(req, res); + }); + return; + } + + // If not routed and not served static asset, send 404 + this.send404(req, res); + } + } catch (err) { + this.errorHandler(req, res, err); + } + }, + + /** + * Handle request in the matched route. + * + * @param {Context} ctx + * @param {Route} route + * @param {HttpRequest} req + * @param {HttpResponse} res + * @param {Object} foundAlias + */ + routeHandler(ctx, route, req, res, foundAlias) { + // Pointer to the matched route + req.$route = route; + res.$route = route; + + this.logRequest(req); + + return new this.Promise(async (resolve, reject) => { + res.once('finish', () => resolve(true)); + res.once('close', () => resolve(true)); + res.once('error', (err) => reject(err)); + + try { + await composeThen.call(this, req, res, ...route.middlewares); + let params: any = {}; + + const shouldBreak = this.corsHandler(route, req, res); + if (shouldBreak) { + return resolve(true); + } + + // Merge params + if (route.opts.mergeParams === false) { + params = { body: req.body, query: req.query }; + } else { + const body = _.isObject(req.body) ? req.body : {}; + Object.assign(params, body, req.query); + } + req.$params = params; // eslint-disable-line require-atomic-updates + + // Resolve action name + let urlPath = req.parsedUrl.slice(route.path.length); + if (urlPath.startsWith('/')) urlPath = urlPath.slice(1); + + // Resolve internal services + urlPath = urlPath.replace(this._isscRe, '$'); + let action = urlPath; + + // Resolve aliases + if (foundAlias) { + const alias = foundAlias.alias; + this.logger.debug(' Alias:', alias.toString()); + + if (route.opts.mergeParams === false) { + params.params = foundAlias.params; + } else { + Object.assign(params, foundAlias.params); + } + + req.$alias = alias; // eslint-disable-line require-atomic-updates + + // Alias handler + return resolve(await this.aliasHandler(req, res, alias)); + } else if (route.mappingPolicy == MAPPING_POLICY_RESTRICT) { + // Blocking direct access + return resolve(null); + } + + if (!action) return resolve(null); + + // Not found alias, call services by action name + action = action.replace(/\//g, '.'); + if (route.opts.camelCaseNames) { + action = action.split('.').map(_.camelCase).join('.'); + } + + // Alias handler + const result = await this.aliasHandler(req, res, { + action, + _notDefined: true, + }); + resolve(result); + } catch (err) { + reject(err); + } + }); + }, + + /** + * Alias handler. Call action or call custom function + * - check whitelist + * - Rate limiter + * - Resolve endpoint + * - onBeforeCall + * - Authentication + * - Authorization + * - Call the action + * + * @param {HttpRequest} req + * @param {HttpResponse} res + * @param {Object} alias + * @returns + */ + async aliasHandler(req, res, alias) { + const route = req.$route; + const ctx = req.$ctx; + + // Whitelist check + if (alias.action && route.hasWhitelist) { + if (!this.checkWhitelist(route, alias.action)) { + this.logger.debug( + ` The '${alias.action}' action is not in the whitelist!` + ); + throw new ServiceNotFoundError({ action: alias.action }); + } + } + + // Rate limiter + if (route.rateLimit) { + const opts = route.rateLimit; + const store = route.rateLimit.store; + + const key = opts.key(req); + if (key) { + const remaining = opts.limit - (await store.inc(key)); + if (opts.headers) { + res.setHeader('X-Rate-Limit-Limit', opts.limit); + res.setHeader('X-Rate-Limit-Remaining', Math.max(0, remaining)); + res.setHeader('X-Rate-Limit-Reset', store.resetTime); + } + if (remaining < 0) { + throw new RateLimitExceeded(); + } + } + } + + // Resolve endpoint by action name + if (alias.action) { + const endpoint = this.broker.findNextActionEndpoint( + alias.action, + route.callOptions, + ctx + ); + if (endpoint instanceof Error) { + if (!alias._notDefined && endpoint instanceof ServiceNotFoundError) { + throw new ServiceUnavailableError(); + } + + throw endpoint; + } + + if ( + endpoint.action.visibility != null && + endpoint.action.visibility != 'published' + ) { + // Action can't be published + throw new ServiceNotFoundError({ action: alias.action }); + } + + req.$endpoint = endpoint; + req.$action = endpoint.action; + } + + // onBeforeCall handling + if (route.onBeforeCall) { + await route.onBeforeCall.call(this, ctx, route, req, res, alias); + } + + // Authentication + if (route.authentication) { + const user = await route.authentication.call( + this, + ctx, + route, + req, + res, + alias + ); + if (user) { + this.logger.debug('Authenticated user', user); + ctx.meta.user = user; + } else { + this.logger.debug('Anonymous user'); + ctx.meta.user = null; + } + } + + // Authorization + if (route.authorization) { + await route.authorization.call(this, ctx, route, req, res, alias); + } + + // Call the action or alias + if (_.isFunction(alias.handler)) { + // Call custom alias handler + if ( + route.logging && + this.settings.logRequest && + this.settings.logRequest in this.logger + ) + this.logger[this.settings.logRequest]( + ` Call custom function in '${alias.toString()}' alias` + ); + + await new this.Promise((resolve, reject) => { + alias.handler.call(this, req, res, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + if (alias.action) + return this.callAction( + route, + alias.action, + req, + res, + alias.type == 'stream' ? req : req.$params + ); + else + throw new MoleculerServerError( + 'No alias handler', + 500, + 'NO_ALIAS_HANDLER', + { path: req.originalUrl, alias: _.pick(alias, ['method', 'path']) } + ); + } else if (alias.action) { + return this.callAction( + route, + alias.action, + req, + res, + alias.type == 'stream' ? req : req.$params + ); + } + }, + + /** + * Call an action via broker + * + * @param {Object} route Route options + * @param {String} actionName Name of action + * @param {HttpRequest} req Request object + * @param {HttpResponse} res Response object + * @param {Object} params Incoming params from request + * @returns {Promise} + */ + async callAction(route, actionName, req, res, params) { + const ctx = req.$ctx; + + try { + // Logging params + if (route.logging) { + if ( + this.settings.logRequest && + this.settings.logRequest in this.logger + ) + this.logger[this.settings.logRequest]( + ` Call '${actionName}' action` + ); + if ( + this.settings.logRequestParams && + this.settings.logRequestParams in this.logger + ) + this.logger[this.settings.logRequestParams](' Params:', params); + } + + // Pass the `req` & `res` vars to ctx.params. + if (req.$alias && req.$alias.passReqResToParams) { + params.$req = req; + params.$res = res; + } + + const opts = route.callOptions ? { ...route.callOptions } : {}; + if (params && params.$params) { + // Transfer URL parameters via meta in case of stream + if (!opts.meta) opts.meta = { $params: params.$params }; + else opts.meta.$params = params.$params; + } + + // Call the action + let data = await ctx.call(req.$endpoint, params, opts); + + // Post-process the response + + // onAfterCall handling + if (route.onAfterCall) + data = await route.onAfterCall.call(this, ctx, route, req, res, data); + + // Send back the response + this.sendResponse(req, res, data, req.$endpoint.action); + + if (route.logging) this.logResponse(req, res, data); + + return true; + } catch (err) { + /* istanbul ignore next */ + if (!err) return; // Cancelling promise chain, no error + + throw err; + } + }, + + /** + * Encode response data + * + * @param {HttpIncomingMessage} req + * @param {HttpResponse} res + * @param {any} data + */ + encodeResponse(req, res, data) { + return JSON.stringify(data); + }, + + /** + * Convert data & send back to client + * + * @param {HttpIncomingMessage} req + * @param {HttpResponse} res + * @param {any} data + * @param {Object?} action + */ + sendResponse(req, res, data: any, action) { + const ctx = req.$ctx; + const route = req.$route; + + /* istanbul ignore next */ + if (res.headersSent) { + this.logger.warn('Headers have already sent.', { + url: req.url, + action, + }); + return; + } + + /* istanbul ignore next */ + if (!res.statusCode) res.statusCode = 200; + + // Status code & message + if (ctx.meta.$statusCode) { + res.statusCode = ctx.meta.$statusCode; + } + if (ctx.meta.$statusMessage) { + res.statusMessage = ctx.meta.$statusMessage; + } + + // Redirect + if ( + res.statusCode == 201 || + (res.statusCode >= 300 && + res.statusCode < 400 && + res.statusCode !== 304) + ) { + const location = ctx.meta.$location; + /* istanbul ignore next */ + if (!location) { + this.logger.warn( + `The 'ctx.meta.$location' is missing for status code '${res.statusCode}'!` + ); + } else { + res.setHeader('Location', location); + } + } + + // Override responseType from action schema + let responseType; + /* istanbul ignore next */ + if (action && action.responseType) { + responseType = action.responseType; + } + + // Custom headers from action schema + /* istanbul ignore next */ + if (action && action.responseHeaders) { + Object.keys(action.responseHeaders).forEach((key) => { + res.setHeader(key, action.responseHeaders[key]); + if (key == 'Content-Type' && !responseType) + responseType = action.responseHeaders[key]; + }); + } + + // Custom responseType from ctx.meta + if (ctx.meta.$responseType) { + responseType = ctx.meta.$responseType; + } + + // Custom headers from ctx.meta + if (ctx.meta.$responseHeaders) { + Object.keys(ctx.meta.$responseHeaders).forEach((key) => { + if (key == 'Content-Type' && !responseType) + responseType = ctx.meta.$responseHeaders[key]; + else res.setHeader(key, ctx.meta.$responseHeaders[key]); + }); + } + if (data == null) return res.end(); + + let chunk; + // Buffer + if (Buffer.isBuffer(data)) { + res.setHeader( + 'Content-Type', + responseType || 'application/octet-stream' + ); + res.setHeader('Content-Length', data.length); + chunk = data; + } + // Buffer from Object + else if (_.isObject(data) && (data as any).type == 'Buffer') { + const buf = Buffer.from(data as any); + res.setHeader( + 'Content-Type', + responseType || 'application/octet-stream' + ); + res.setHeader('Content-Length', buf.length); + chunk = buf; + } + // Stream + else if (isReadableStream(data)) { + res.setHeader( + 'Content-Type', + responseType || 'application/octet-stream' + ); + chunk = data; + } + // Object or Array (stringify) + else if (_.isObject(data) || Array.isArray(data)) { + res.setHeader( + 'Content-Type', + responseType || 'application/json; charset=utf-8' + ); + chunk = this.encodeResponse(req, res, data); + } + // Other (stringify or raw text) + else { + if (!responseType) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + chunk = this.encodeResponse(req, res, data); + } else { + res.setHeader('Content-Type', responseType); + if (_.isString(data)) chunk = data; + else chunk = data.toString(); + } + } + + // Auto generate & add ETag + if ( + route.etag && + chunk && + !res.getHeader('ETag') && + !isReadableStream(chunk) + ) { + res.setHeader('ETag', generateETag.call(this, chunk, route.etag)); + } + + // Freshness + if (isFresh(req, res)) res.statusCode = 304; + + if (res.statusCode === 204 || res.statusCode === 304) { + res.removeHeader('Content-Type'); + res.removeHeader('Content-Length'); + res.removeHeader('Transfer-Encoding'); + + chunk = ''; + } + + if (req.method === 'HEAD') { + // skip body for HEAD + res.end(); + } else { + // respond + if (isReadableStream(data)) { + //Stream response + data.pipe(res); + } else { + res.end(chunk); + } + } + }, + + /** + * Middleware for ExpressJS + * + * @returns {Function} + */ + express() { + return (req, res, next) => this.httpHandler(req, res, next); + }, + + /** + * Send 404 response + * + * @param {HttpIncomingMessage} req + * @param {HttpResponse} res + */ + send404(req, res) { + if (req.$next) return req.$next(); + + this.sendError(req, res, new NotFoundError()); + }, + + /** + * Send an error response + * + * @param {HttpIncomingMessage} req + * @param {HttpResponse} res + * @param {Error} err + */ + sendError(req, res, err) { + // Route error handler + if (req.$route && _.isFunction(req.$route.onError)) + return req.$route.onError.call(this, req, res, err); + + // Global error handler + if (_.isFunction(this.settings.onError)) + return this.settings.onError.call(this, req, res, err); + + // --- Default error handler + + // In middleware mode call the next(err) + if (req.$next) return req.$next(err); + + /* istanbul ignore next */ + if (res.headersSent) { + this.logger.warn('Headers have already sent', req.url, err); + return; + } + + /* istanbul ignore next */ + if (!err || !(err instanceof Error)) { + res.writeHead(500); + res.end('Internal Server Error'); + + this.logResponse(req, res); + return; + } + + /* istanbul ignore next */ + if (!(err instanceof MoleculerError)) { + const e = err as any; + err = new MoleculerError(e.message, e.code || e.status, e.type, e.data); + err.name = e.name; + } + + const ctx = req.$ctx; + let responseType = 'application/json; charset=utf-8'; + + if (ctx) { + if (ctx.meta.$responseType) { + responseType = ctx.meta.$responseType; + } + if (ctx.meta.$responseHeaders) { + Object.keys(ctx.meta.$responseHeaders).forEach((key) => { + if (key === 'Content-Type' && !responseType) + responseType = ctx.meta.$responseHeaders[key]; + else res.setHeader(key, ctx.meta.$responseHeaders[key]); + }); + } + } + + // Return with the error as JSON object + res.setHeader('Content-type', responseType); + + const code = + _.isNumber(err.code) && _.inRange(err.code, 400, 599) ? err.code : 500; + res.writeHead(code); + const errObj = this.reformatError(err, req, res); + res.end( + errObj !== undefined ? this.encodeResponse(req, res, errObj) : undefined + ); + + this.logResponse(req, res); + }, + + /** + * Reformatting the error object to response + * @param {Error} err + * @param {HttpIncomingMessage} req + * @param {HttpResponse} res + @returns {Object} + */ + reformatError(err /*, req, res*/) { + return _.pick(err, ['name', 'message', 'code', 'type', 'data']); + }, + + /** + * Send 302 Redirect + * + * @param {HttpResponse} res + * @param {String} url + * @param {Number} status code + */ + sendRedirect(res, url, code = 302) { + res.writeHead(code, { + Location: url, + 'Content-Length': '0', + }); + res.end(); + //this.logResponse(req, res); + }, + + /** + * Split the URL and resolve vars from querystring + * + * @param {any} req + * @returns + */ + parseQueryString(req) { + // Split URL & query params + let url = req.url; + let query = {}; + const questionIdx = req.url.indexOf('?', 1); + if (questionIdx !== -1) { + query = queryString.parse(req.url.substring(questionIdx + 1)); + url = req.url.substring(0, questionIdx); + } + return { query, url }; + }, + + /** + * Log the request + * + * @param {HttpIncomingMessage} req + */ + logRequest(req) { + if (req.$route && !req.$route.logging) return; + + if (this.settings.logRequest && this.settings.logRequest in this.logger) + this.logger[this.settings.logRequest](`=> ${req.method} ${req.url}`); + }, + + /** + * Return with colored status code + * + * @param {any} code + * @returns + */ + coloringStatusCode(code) { + if (code >= 500) return kleur.red().bold(code); + if (code >= 400 && code < 500) return kleur.red().bold(code); + if (code >= 300 && code < 400) return kleur.cyan().bold(code); + if (code >= 200 && code < 300) return kleur.green().bold(code); + + /* istanbul ignore next */ + return code; + }, + + /** + * Log the response + * + * @param {HttpIncomingMessage} req + * @param {HttpResponse} res + * @param {any} data + */ + logResponse(req, res, data) { + if (req.$route && !req.$route.logging) return; + + let time = ''; + if (req.$startTime) { + const diff = process.hrtime(req.$startTime); + const duration = (diff[0] + diff[1] / 1e9) * 1000; + if (duration > 1000) + time = kleur.red(`[+${Number(duration / 1000).toFixed(3)} s]`); + else time = kleur.grey(`[+${Number(duration).toFixed(3)} ms]`); + } + + if (this.settings.logResponse && this.settings.logResponse in this.logger) + this.logger[this.settings.logResponse]( + `<= ${this.coloringStatusCode(res.statusCode)} ${ + req.method + } ${kleur.bold(req.originalUrl)} ${time}` + ); + + /* istanbul ignore next */ + if ( + this.settings.logResponseData && + this.settings.logResponseData in this.logger + ) { + this.logger[this.settings.logResponseData](' Data:', data); + } + }, + + /** + * Check origin(s) + * + * @param {String} origin + * @param {String|Array} settings + * @returns {Boolean} + */ + checkOrigin(origin, settings) { + if (_.isString(settings)) { + if (settings.indexOf(origin) !== -1) return true; + + if (settings.indexOf('*') !== -1) { + // Based on: https://github.com/hapijs/hapi + // eslint-disable-next-line + const wildcard = new RegExp( + `^${_.escapeRegExp(settings) + .replace(/\\\*/g, '.*') + .replace(/\\\?/g, '.')}$` + ); + return origin.match(wildcard); + } + } else if (_.isFunction(settings)) { + return settings.call(this, origin); + } else if (Array.isArray(settings)) { + for (let i = 0; i < settings.length; i++) { + if (this.checkOrigin(origin, settings[i])) { + return true; + } + } + } + + return false; + }, + + /** + * Write CORS header + * + * Based on: https://github.com/expressjs/cors + * + * @param {Object} route + * @param {HttpIncomingMessage} req + * @param {HttpResponse} res + * @param {Boolean} isPreFlight + */ + writeCorsHeaders(route, req, res, isPreFlight) { + /* istanbul ignore next */ + if (!route.cors) return; + + const origin = req.headers['origin']; + // It's not presented, when it's a local request (origin and target same) + if (!origin) return; + + // Access-Control-Allow-Origin + if (!route.cors.origin || route.cors.origin === '*') { + res.setHeader('Access-Control-Allow-Origin', '*'); + } else if (this.checkOrigin(origin, route.cors.origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } else { + throw new ForbiddenError(ERR_ORIGIN_NOT_ALLOWED); + } + + // Access-Control-Allow-Credentials + if (route.cors.credentials === true) { + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + + // Access-Control-Expose-Headers + if (_.isString(route.cors.exposedHeaders)) { + res.setHeader( + 'Access-Control-Expose-Headers', + route.cors.exposedHeaders + ); + } else if (Array.isArray(route.cors.exposedHeaders)) { + res.setHeader( + 'Access-Control-Expose-Headers', + route.cors.exposedHeaders.join(', ') + ); + } + + if (isPreFlight) { + // Access-Control-Allow-Headers + if (_.isString(route.cors.allowedHeaders)) { + res.setHeader( + 'Access-Control-Allow-Headers', + route.cors.allowedHeaders + ); + } else if (Array.isArray(route.cors.allowedHeaders)) { + res.setHeader( + 'Access-Control-Allow-Headers', + route.cors.allowedHeaders.join(', ') + ); + } else { + // AllowedHeaders doesn't specified, so we send back from req headers + const allowedHeaders = req.headers['access-control-request-headers']; + if (allowedHeaders) { + res.setHeader('Vary', 'Access-Control-Request-Headers'); + res.setHeader('Access-Control-Allow-Headers', allowedHeaders); + } + } + + // Access-Control-Allow-Methods + if (_.isString(route.cors.methods)) { + res.setHeader('Access-Control-Allow-Methods', route.cors.methods); + } else if (Array.isArray(route.cors.methods)) { + res.setHeader( + 'Access-Control-Allow-Methods', + route.cors.methods.join(', ') + ); + } + + // Access-Control-Max-Age + if (route.cors.maxAge) { + res.setHeader('Access-Control-Max-Age', route.cors.maxAge.toString()); + } + } + }, + + /** + * Check the action name in whitelist + * + * @param {Object} route + * @param {String} action + * @returns {Boolean} + */ + checkWhitelist(route, action) { + // Rewrite to for iterator (faster) + return ( + route.whitelist.find((mask) => { + if (_.isString(mask)) return match(action, mask); + else if (_.isRegExp(mask)) return mask.test(action); + }) != null + ); + }, + + /** + * Resolve alias names + * + * @param {String} url + * @param {string} [method="GET"] + * @returns {Object} Resolved alas & params + */ + resolveAlias(url, method = 'GET') { + for (let i = 0; i < this.aliases.length; i++) { + const alias = this.aliases[i]; + if (alias.isMethod(method)) { + const params = alias.match(url); + if (params) { + return { alias, params }; + } + } + } + return false; + }, + + /** + * Add & prepare route from options + * @param {Object} opts + * @param {Boolean} [toBottom=true] + */ + addRoute(opts, toBottom = true) { + const route = this.createRoute(opts); + const idx = this.routes.findIndex((r) => this.isEqualRoutes(r, route)); + if (idx !== -1) { + // Replace the previous + this.routes[idx] = route; + } else { + // Add new route + if (toBottom) this.routes.push(route); + else this.routes.unshift(route); + + // Reordering routes + if (this.settings.optimizeOrder) this.optimizeRouteOrder(); + } + + return route; + }, + + /** + * Remove a route by path + * @param {String} path + */ + removeRoute(path) { + const idx = this.routes.findIndex((r) => r.opts.path == path); + if (idx !== -1) { + const route = this.routes[idx]; + + // Clean global aliases for this route + this.aliases = this.aliases.filter((a) => a.route != route); + + // Remote route + this.routes.splice(idx, 1); + + return true; + } + return false; + }, + + /** + * Remove a route by name + * @param {String} name + */ + removeRouteByName(name) { + const idx = this.routes.findIndex((r) => r.opts.name == name); + if (idx !== -1) { + const route = this.routes[idx]; + + // Clean global aliases for this route + this.aliases = this.aliases.filter((a) => a.route != route); + + // Remote route + this.routes.splice(idx, 1); + + return true; + } + return false; + }, + + /** + * Optimize route order by route path depth + */ + optimizeRouteOrder() { + this.routes.sort((a, b) => { + let c = + addSlashes(b.path).split('/').length - + addSlashes(a.path).split('/').length; + if (c == 0) { + // Second level ordering (considering URL params) + c = a.path.split(':').length - b.path.split(':').length; + } + + return c; + }); + + this.logger.debug( + 'Optimized path order: ', + this.routes.map((r) => r.path) + ); + }, + + /** + * Create route object from options + * + * @param {Object} opts + * @returns {Object} + */ + createRoute(opts) { + this.logRouteRegistration(`Register route to '${opts.path}'`); + const route: any = { + name: opts.name, + opts, + middlewares: [], + }; + if (opts.authorization) { + let fn = this.authorize; + if (_.isString(opts.authorization)) fn = this[opts.authorization]; + + if (!_.isFunction(fn)) { + this.logger.warn( + "Define 'authorize' method in the service to enable authorization." + ); + route.authorization = null; + } else route.authorization = fn; + } + if (opts.authentication) { + let fn = this.authenticate; + if (_.isString(opts.authentication)) fn = this[opts.authentication]; + + if (!_.isFunction(fn)) { + this.logger.warn( + "Define 'authenticate' method in the service to enable authentication." + ); + route.authentication = null; + } else route.authentication = fn; + } + + // Call options + route.callOptions = opts.callOptions; + + // Create body parsers as middlewares + if (opts.bodyParsers == null || opts.bodyParsers === true) { + // Set default JSON body-parser + opts.bodyParsers = { + json: true, + }; + } + if (opts.bodyParsers) { + const bps = opts.bodyParsers; + Object.keys(bps).forEach((key) => { + const opts = _.isObject(bps[key]) ? bps[key] : undefined; + if (bps[key] !== false && key in bodyParser) + route.middlewares.push(bodyParser[key](opts)); + }); + } + + // Logging + route.logging = opts.logging != null ? opts.logging : true; + + // ETag + route.etag = opts.etag != null ? opts.etag : this.settings.etag; + + // Middlewares + const mw: any[] = []; + if ( + this.settings.use && + Array.isArray(this.settings.use) && + this.settings.use.length > 0 + ) + mw.push(...this.settings.use); + + if (opts.use && Array.isArray(opts.use) && opts.use.length > 0) + mw.push(...opts.use); + + if (mw.length > 0) { + route.middlewares.push(...mw); + this.logRouteRegistration(` Registered ${mw.length} middlewares.`); + } + + // CORS + if (this.settings.cors || opts.cors) { + // Merge cors settings + route.cors = Object.assign( + {}, + { + origin: '*', + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], + }, + this.settings.cors, + opts.cors + ); + } else { + route.cors = null; + } + + // Rate limiter (Inspired by https://github.com/dotcypress/micro-ratelimit/) + const rateLimit = opts.rateLimit || this.settings.rateLimit; + if (rateLimit) { + const opts = Object.assign( + {}, + { + window: 60 * 1000, + limit: 30, + headers: false, + key: (req) => { + return ( + req.headers['x-forwarded-for'] || + req.connection.remoteAddress || + req.socket.remoteAddress || + req.connection.socket.remoteAddress + ); + }, + }, + rateLimit + ); + + route.rateLimit = opts; + + if (opts.StoreFactory) { + route.rateLimit.store = new opts.StoreFactory( + opts.window, + opts, + this.broker + ); + } else { + route.rateLimit.store = new MemoryStore(opts.window); + } + } + + // Handle whitelist + route.whitelist = opts.whitelist; + route.hasWhitelist = Array.isArray(route.whitelist); + + // `onBeforeCall` handler + if (opts.onBeforeCall) route.onBeforeCall = opts.onBeforeCall; + + // `onAfterCall` handler + if (opts.onAfterCall) route.onAfterCall = opts.onAfterCall; + + // `onError` handler + if (opts.onError) route.onError = opts.onError; + + // Create URL prefix + const globalPath = + this.settings.path && this.settings.path != '/' + ? this.settings.path + : ''; + route.path = addSlashes(globalPath) + (opts.path || ''); + route.path = normalizePath(route.path); + + // Create aliases + this.createRouteAliases(route, opts.aliases); + + // Optimize aliases order + if (this.settings.optimizeOrder) { + this.optimizeAliasesOrder(); + } + + // Set alias mapping policy + route.mappingPolicy = opts.mappingPolicy; + if (!route.mappingPolicy) { + const hasAliases = + _.isObject(opts.aliases) && Object.keys(opts.aliases).length > 0; + route.mappingPolicy = + hasAliases || opts.autoAliases + ? MAPPING_POLICY_RESTRICT + : MAPPING_POLICY_ALL; + } + + this.logRouteRegistration(''); + + return route; + }, + + /** + * Create all aliases for route. + * + * @param {Object} route + * @param {Object} aliases + */ + createRouteAliases(route, aliases) { + // Clean previous aliases for this route + this.aliases = this.aliases.filter( + (a) => !this.isEqualRoutes(a.route, route) + ); + + // Process aliases definitions from route settings + _.forIn(aliases, (action, matchPath) => { + if (matchPath.startsWith('REST ')) { + this.aliases.push( + ...this.generateRESTAliases(route, matchPath, action) + ); + } else { + this.aliases.push(this.createAlias(route, matchPath, action)); + } + }); + + if (route.opts.autoAliases) { + this.regenerateAutoAliases(route); + } + }, + + /** + * Checks whether the routes are same. + * + * @param {Object} routeA + * @param {Object} routeB + * @returns {Boolean} + */ + isEqualRoutes(routeA, routeB) { + if (routeA.name != null && routeB.name != null) { + return routeA.name === routeB.name; + } + return routeA.path === routeB.path; + }, + + /** + * Generate aliases for REST. + * + * @param {Route} route + * @param {String} path + * @param {*} action + * + * @returns Array + */ + generateRESTAliases(route, path, action) { + const p = path.split(/\s+/); + const pathName = p[1]; + const pathNameWithoutEndingSlash = pathName.endsWith('/') + ? pathName.slice(0, -1) + : pathName; + const aliases = { + list: `GET ${pathName}`, + get: `GET ${pathNameWithoutEndingSlash}/:id`, + create: `POST ${pathName}`, + update: `PUT ${pathNameWithoutEndingSlash}/:id`, + patch: `PATCH ${pathNameWithoutEndingSlash}/:id`, + remove: `DELETE ${pathNameWithoutEndingSlash}/:id`, + }; + let actions = ['list', 'get', 'create', 'update', 'patch', 'remove']; + + if (typeof action !== 'string' && (action.only || action.except)) { + if (action.only) { + actions = actions.filter((item) => action.only.includes(item)); + } + + if (action.except) { + actions = actions.filter((item) => !action.except.includes(item)); + } + + action = action.action; + } + + return actions.map((item) => + this.createAlias(route, aliases[item], `${action}.${item}`) + ); + }, + + /** + * Regenerate aliases automatically if service registry has been changed. + * + * @param {Route} route + */ + regenerateAutoAliases(route) { + this.logRouteRegistration( + `♻ Generate aliases for '${route.path}' route...` + ); + + // Clean previous aliases for this route + this.aliases = this.aliases.filter( + (alias) => alias.route != route || !alias._generated + ); + + const processedServices = new Set(); + + const services = this.broker.registry.getServiceList({ + withActions: true, + grouping: true, + }); + services.forEach((service) => { + if (!service.settings) return; + const serviceName = service.fullName || getServiceFullname(service); + + let basePaths = []; + if (_.isString(service.settings.rest)) { + basePaths = [service.settings.rest]; + } else if (_.isArray(service.settings.rest)) { + basePaths = service.settings.rest; + } else { + basePaths = [serviceName.replace(SLASH_REGEX, '/')]; + } + + // Skip multiple instances of services + if (processedServices.has(serviceName)) return; + + for (let basePath of basePaths) { + basePath = addSlashes( + _.isString(basePath) + ? basePath + : serviceName.replace(SLASH_REGEX, '/') + ); + + _.forIn(service.actions, (action) => { + if (action.rest) { + // Check visibility + if (action.visibility != null && action.visibility != 'published') + return; + + // Check whitelist + if ( + route.hasWhitelist && + !this.checkWhitelist(route, action.name) + ) + return; + + let restRoutes = []; + if (!_.isArray(action.rest)) { + restRoutes = [action.rest]; + } else { + restRoutes = action.rest; + } + + for (const restRoute of restRoutes) { + let alias = null; + + if (_.isString(restRoute)) { + alias = this.parseActionRestString(restRoute, basePath); + } else if (_.isObject(restRoute)) { + alias = this.parseActionRestObject( + restRoute, + action.rawName, + basePath + ); + } + + if (alias) { + alias.path = removeTrailingSlashes(normalizePath(alias.path)); + alias._generated = true; + this.aliases.push( + this.createAlias(route, alias, action.name) + ); + } + } + } + + processedServices.add(serviceName); + }); + } + }); + + if (this.settings.optimizeOrder) { + this.optimizeAliasesOrder(); + } + }, + + /** + * + */ + parseActionRestString(restRoute, basePath) { + if (restRoute.indexOf(' ') !== -1) { + // Handle route: "POST /import" + const p = restRoute.split(/\s+/); + return { + method: p[0], + path: basePath + p[1], + }; + } + // Handle route: "/import". In this case apply to all methods as "* /import" + return { + method: '*', + path: basePath + restRoute, + }; + }, + + /** + * + */ + parseActionRestObject(restRoute, rawName, basePath) { + // Handle route: { method: "POST", path: "/other", basePath: "newBasePath" } + return Object.assign({}, restRoute, { + method: restRoute.method || '*', + path: + (restRoute.basePath ? restRoute.basePath : basePath) + + (restRoute.path ? restRoute.path : rawName), + }); + }, + + /** + * Optimize order of alias path. + */ + optimizeAliasesOrder() { + this.aliases.sort((a, b) => { + let c = + addSlashes(b.path).split('/').length - + addSlashes(a.path).split('/').length; + if (c == 0) { + // Second level ordering (considering URL params) + c = a.path.split(':').length - b.path.split(':').length; + } + + if (c == 0) { + c = a.path.localeCompare(b.path); + } + + return c; + }); + }, + + /** + * Create alias for route. + * + * @param {Object} route + * @param {String|Object} matchPath + * @param {String|Object} action + */ + createAlias(route, path, action) { + const alias = new Alias(this, route, path, action); + this.logRouteRegistration(' ' + alias.toString()); + return alias; + }, + + /** + * Set log level and log registration route related activities + * + * @param {*} message + */ + logRouteRegistration(message) { + if ( + this.settings.logRouteRegistration && + this.settings.logRouteRegistration in this.logger + ) + this.logger[this.settings.logRouteRegistration](message); + }, + }, + + events: { + '$services.changed'() { + this.regenerateAllAutoAliases(); + }, + }, + + /** + * Service created lifecycle event handler + */ + created() { + if (this.settings.server !== false) { + if (_.isObject(this.settings.server)) { + // Use an existing server instance + this.server = this.settings.server; + } else { + // Create a new HTTP/HTTPS/HTTP2 server instance + this.createServer(); + } + + /* istanbul ignore next */ + this.server.on('error', (err) => { + this.logger.error('Server error', err); + }); + + this.logger.info('API Gateway server created.'); + } + + // Special char for internal services + const specChar = + this.settings.internalServiceSpecialChar != null + ? this.settings.internalServiceSpecialChar + : '~'; + this._isscRe = new RegExp(specChar); + + // Create static server middleware + if (this.settings.assets) { + const opts = this.settings.assets.options || {}; + this.serve = serveStatic(this.settings.assets.folder, opts); + } + + // Alias store + this.aliases = []; + + // Add default route + if ( + Array.isArray(this.settings.routes) && + this.settings.routes.length == 0 + ) { + this.settings.routes = [ + { + path: '/', + }, + ]; + } + + // Process routes + this.routes = []; + if (Array.isArray(this.settings.routes)) + this.settings.routes.forEach((route) => this.addRoute(route)); + + // Regenerate all auto aliases routes + const debounceTime = + this.settings.debounceTime > 0 + ? parseInt(this.settings.debounceTime) + : 500; + this.regenerateAllAutoAliases = _.debounce(() => { + /* istanbul ignore next */ + this.routes.forEach( + (route) => route.opts.autoAliases && this.regenerateAutoAliases(route) + ); + + this.broker.broadcast('$api.aliases.regenerated'); + }, debounceTime); + }, + + /** + * Service started lifecycle event handler + */ + started() { + if (this.settings.server === false) return this.Promise.resolve(); + + /* istanbul ignore next */ + return new this.Promise((resolve, reject) => { + this.server.listen(this.settings.port, this.settings.ip, (err) => { + if (err) return reject(err); + + const addr = this.server.address(); + const listenAddr = + addr.address == '0.0.0.0' && os.platform() == 'win32' + ? 'localhost' + : addr.address; + this.logger.info( + `API Gateway listening on ${ + this.isHTTPS ? 'https' : 'http' + }://${listenAddr}:${addr.port}` + ); + resolve(); + }); + }); + }, + + /** + * Service stopped lifecycle event handler + */ + stopped() { + if (this.settings.server !== false && this.server.listening) { + /* istanbul ignore next */ + return new this.Promise((resolve, reject) => { + this.server.close((err) => { + if (err) return reject(err); + + this.logger.info('API Gateway stopped!'); + resolve(); + }); + }); + } + + return this.Promise.resolve(); + }, + + bodyParser, + serveStatic, + + Errors: require('./errors'), + RateLimitStores: { + MemoryStore: require('./memory-store'), + }, +}; diff --git a/server/packages/sdk/src/services/lib/moleculer-web/memory-store.ts b/server/packages/sdk/src/services/lib/moleculer-web/memory-store.ts new file mode 100644 index 00000000..ea6b0b80 --- /dev/null +++ b/server/packages/sdk/src/services/lib/moleculer-web/memory-store.ts @@ -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(); + } +} diff --git a/server/packages/sdk/src/services/lib/moleculer-web/utils.ts b/server/packages/sdk/src/services/lib/moleculer-web/utils.ts new file mode 100644 index 00000000..a379131b --- /dev/null +++ b/server/packages/sdk/src/services/lib/moleculer-web/utils.ts @@ -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((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, +}; diff --git a/server/services/core/gateway.service.ts b/server/services/core/gateway.service.ts index 4e63c262..731a7e7c 100644 --- a/server/services/core/gateway.service.ts +++ b/server/services/core/gateway.service.ts @@ -1,5 +1,4 @@ import type { IncomingMessage, ServerResponse } from 'http'; -import ApiGateway from 'moleculer-web'; import _ from 'lodash'; import { TcSocketIOService } from '../../mixins/socketio.mixin'; import { @@ -10,6 +9,8 @@ import { parseLanguageFromHead, builtinAuthWhitelist, PureContext, + ApiGatewayMixin, + ApiGatewayErrors, } from 'tailchat-server-sdk'; import { TcHealth } from '../../mixins/health.mixin'; import type { Readable } from 'stream'; @@ -26,7 +27,7 @@ export default class ApiService extends TcService { } onInit() { - this.registerMixin(ApiGateway); + this.registerMixin(ApiGatewayMixin); this.registerMixin( TcSocketIOService({ userAuth: async (token) => { @@ -355,8 +356,8 @@ export default class ApiService extends TcService { const token = req.headers['x-token'] as string; if (typeof token !== 'string') { - throw new ApiGateway.Errors.UnAuthorizedError( - ApiGateway.Errors.ERR_NO_TOKEN, + throw new ApiGatewayErrors.UnAuthorizedError( + ApiGatewayErrors.ERR_NO_TOKEN, { error: 'No Token', } @@ -379,8 +380,8 @@ export default class ApiService extends TcService { throw new Error(t('Token不合规')); } } catch (err) { - throw new ApiGateway.Errors.UnAuthorizedError( - ApiGateway.Errors.ERR_INVALID_TOKEN, + throw new ApiGatewayErrors.UnAuthorizedError( + ApiGatewayErrors.ERR_INVALID_TOKEN, { error: 'Invalid Token:' + String(err), } diff --git a/server/services/openapi/oidc/oidc.service.ts b/server/services/openapi/oidc/oidc.service.ts index 62cb6d6f..d8246890 100644 --- a/server/services/openapi/oidc/oidc.service.ts +++ b/server/services/openapi/oidc/oidc.service.ts @@ -1,6 +1,5 @@ -import ApiGateway from 'moleculer-web'; import { Provider, Configuration, InteractionResults } from 'oidc-provider'; -import { config, TcService } from 'tailchat-server-sdk'; +import { config, TcService, ApiGatewayMixin } from 'tailchat-server-sdk'; import type { IncomingMessage, ServerResponse } from 'http'; import ejs from 'ejs'; import path from 'path'; @@ -93,7 +92,7 @@ class OIDCService extends TcService { } protected onInit(): void { - this.registerMixin(ApiGateway); + this.registerMixin(ApiGatewayMixin); this.registerSetting('port', PORT); this.registerSetting('routes', this.getRoutes());