import type { IncomingMessage, ServerResponse } from 'http'; import _ from 'lodash'; import { TcSocketIOService } from '../../mixins/socketio.mixin'; import { TcService, UserJWTPayload, config, t, parseLanguageFromHead, builtinAuthWhitelist, PureContext, ApiGatewayMixin, ApiGatewayErrors, } from 'tailchat-server-sdk'; import { TcHealth } from '../../mixins/health.mixin'; import type { Readable } from 'stream'; import { checkPathMatch } from '../../lib/utils'; import serve from 'serve-static'; import accepts from 'accepts'; import send from 'send'; export default class ApiService extends TcService { authWhitelist = []; get serviceName() { return 'gateway'; } onInit() { this.registerMixin(ApiGatewayMixin); this.registerMixin( TcSocketIOService({ userAuth: async (token) => { const user: UserJWTPayload = await this.broker.call( 'user.resolveToken', { token, } ); return user; }, }) ); this.registerMixin(TcHealth()); // More info about settings: https://moleculer.services/docs/0.14/moleculer-web.html this.registerSetting('port', config.port); this.registerSetting('routes', this.getRoutes()); // Do not log client side errors (does not log an error response when the error.code is 400<=X<500) this.registerSetting('log4XXResponses', false); // Logging the request parameters. Set to any log level to enable it. E.g. "info" this.registerSetting('logRequestParams', null); // Logging the response data. Set to any log level to enable it. E.g. "info" this.registerSetting('logResponseData', null); // Serve assets from "public" folder // this.registerSetting('assets', { // folder: 'public', // // Options to `server-static` module // options: {}, // }); this.registerSetting('cors', { // Configures the Access-Control-Allow-Origin CORS header. origin: '*', // Configures the Access-Control-Allow-Methods CORS header. methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE'], // Configures the Access-Control-Allow-Headers CORS header. allowedHeaders: ['X-Token', 'Content-Type'], // Configures the Access-Control-Expose-Headers CORS header. exposedHeaders: [], // Configures the Access-Control-Allow-Credentials CORS header. credentials: false, // Configures the Access-Control-Max-Age CORS header. maxAge: 3600, }); // this.registerSetting('rateLimit', { // // How long to keep record of requests in memory (in milliseconds). // // Defaults to 60000 (1 min) // window: 60 * 1000, // // Max number of requests during window. Defaults to 30 // limit: 60, // // Set rate limit headers to response. Defaults to false // headers: true, // // Function used to generate keys. Defaults to: // key: (req) => { // return ( // req.headers['x-forwarded-for'] || // req.connection.remoteAddress || // req.socket.remoteAddress || // req.connection.socket.remoteAddress // ); // }, // }); this.registerMethod('authorize', this.authorize); this.registerEventListener( 'gateway.auth.addWhitelists', ({ urls = [] }) => { this.logger.info('Add auth whitelist:', urls); this.authWhitelist.push(...urls); } ); } getRoutes() { return [ // /api { path: '/api', whitelist: [ // Access to any actions in all services under "/api" URL '**', ], // Route-level Express middlewares. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Middlewares use: [], // Enable/disable parameter merging method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Disable-merging mergeParams: true, // Enable authentication. Implement the logic into `authenticate` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authentication authentication: false, // Enable authorization. Implement the logic into `authorize` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authorization authorization: true, // The auto-alias feature allows you to declare your route alias directly in your services. // The gateway will dynamically build the full routes from service schema. autoAliases: true, aliases: {}, /** * Before call hook. You can check the request. * @param {PureContext} ctx * @param {Object} route * @param {IncomingMessage} req * @param {ServerResponse} res * @param {Object} data*/ onBeforeCall( ctx: PureContext, route: object, req: IncomingMessage, res: ServerResponse ) { // Set request headers to context meta ctx.meta.userAgent = req.headers['user-agent']; ctx.meta.language = parseLanguageFromHead( req.headers['accept-language'] ); }, /** * After call hook. You can modify the data. * @param {PureContext} ctx * @param {Object} route * @param {IncomingMessage} req * @param {ServerResponse} res * @param {Object} data * */ onAfterCall( ctx: PureContext, route: object, req: IncomingMessage, res: ServerResponse, data: object ) { // Async function which return with Promise res.setHeader('X-Node-ID', ctx.nodeID); if (data && data['__raw']) { // 如果返回值有__raw, 则视为返回了html片段 if (data['header']) { Object.entries(data['header']).forEach(([key, value]) => { res.setHeader(key, String(value)); }); } res.write(data['html'] ?? ''); res.end(); return; } return { code: res.statusCode, data }; }, // Calling options. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Calling-options callingOptions: {}, bodyParsers: { json: { strict: false, limit: '1MB', }, urlencoded: { extended: true, limit: '1MB', }, }, // Mapping policy setting. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Mapping-policy mappingPolicy: 'all', // Available values: "all", "restrict" // Enable/disable logging logging: true, }, // /upload { // Reference: https://github.com/moleculerjs/moleculer-web/blob/master/examples/file/index.js path: '/upload', // You should disable body parsers bodyParsers: { json: false, urlencoded: false, }, authentication: false, authorization: true, aliases: { // File upload from HTML form 'POST /': { type: 'multipart', action: 'file.save', }, // File upload from AJAX or cURL 'PUT /': { type: 'stream', action: 'file.save', }, }, // https://github.com/mscdex/busboy#busboy-methods busboyConfig: { limits: { files: 1, fileSize: config.storage.limit, }, onPartsLimit(busboy, alias, svc) { this.logger.info('Busboy parts limit!', busboy); }, onFilesLimit(busboy, alias, svc) { this.logger.info('Busboy file limit!', busboy); }, onFieldsLimit(busboy, alias, svc) { this.logger.info('Busboy fields limit!', busboy); }, }, callOptions: { meta: { a: 5, }, }, mappingPolicy: 'restrict', }, // /health { path: '/health', aliases: { 'GET /': 'gateway.health', }, mappingPolicy: 'restrict', }, // /static 对象存储文件代理 { path: '/static', authentication: false, authorization: false, aliases: { async 'GET /:objectName+'( this: TcService, req: IncomingMessage, res: ServerResponse ) { const objectName = _.get(req, '$params.objectName'); try { const result: Readable = await this.broker.call( 'file.get', { objectName, }, { parentCtx: _.get(req, '$ctx'), } ); result.pipe(res); } catch (err) { this.logger.error(err); res.write('static file not found'); res.end(); } }, }, mappingPolicy: 'restrict', }, // 静态文件代理 { path: '/', authentication: false, authorization: false, use: [ serve('public', { setHeaders(res: ServerResponse, path: string, stat: any) { res.setHeader('Access-Control-Allow-Origin', '*'); // 允许跨域 }, }), ], onError(req: IncomingMessage, res: ServerResponse, err) { if ( String(req.method).toLowerCase() === 'get' && // get请求 accepts(req).types(['html']) && // 且请求html页面 err.code === 404 ) { // 如果没有找到, 则返回index.html(for spa) this.logger.info('fallback to fe entry file'); send(req, './public/index.html', { root: process.cwd() }).pipe(res); } }, whitelist: [], autoAliases: false, }, ]; } /** * 获取鉴权白名单 * 在白名单中的路由会被跳过 */ getAuthWhitelist() { return _.uniq([...builtinAuthWhitelist, ...this.authWhitelist]); } /** * jwt秘钥 */ get jwtSecretKey() { return config.secret; } async authorize( ctx: PureContext<{}, any>, route: unknown, req: IncomingMessage ) { if (checkPathMatch(this.getAuthWhitelist(), req.url)) { return null; } const token = req.headers['x-token'] as string; if (typeof token !== 'string') { throw new ApiGatewayErrors.UnAuthorizedError( ApiGatewayErrors.ERR_NO_TOKEN, { error: 'No Token', } ); } // Verify JWT token try { const user: UserJWTPayload = await ctx.call('user.resolveToken', { token, }); if (user && user._id) { this.logger.info('[Web] Authenticated via JWT: ', user.nickname); // Reduce user fields (it will be transferred to other nodes) ctx.meta.user = _.pick(user, ['_id', 'nickname', 'email', 'avatar']); ctx.meta.token = token; ctx.meta.userId = user._id; } else { throw new Error(t('Token不合规')); } } catch (err) { throw new ApiGatewayErrors.UnAuthorizedError( ApiGatewayErrors.ERR_INVALID_TOKEN, { error: 'Invalid Token:' + String(err), } ); } } }