import express, {NextFunction, Request, Response, Router} from 'express'; import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError"; import {lib} from "nunjucks"; import WebSocketListener from "./WebSocketListener"; import ApplicationComponent from "./ApplicationComponent"; import Controller from "./Controller"; import MysqlConnectionManager from "./db/MysqlConnectionManager"; import Migration, {MigrationType} from "./db/Migration"; import {Type} from "./Utils"; import LogRequestsComponent from "./components/LogRequestsComponent"; import {ValidationBag, ValidationError} from "./db/Validator"; import config from "config"; import * as fs from "fs"; import SecurityError from "./SecurityError"; import * as path from "path"; import CacheProvider from "./CacheProvider"; import RedisComponent from "./components/RedisComponent"; import Extendable from "./Extendable"; import {logger, loggingContextMiddleware} from "./Logger"; import TemplateError = lib.TemplateError; export default abstract class Application implements Extendable> { private readonly version: string; private readonly ignoreCommandLine: boolean; private readonly controllers: Controller[] = []; private readonly webSocketListeners: { [p: string]: WebSocketListener } = {}; private readonly components: ApplicationComponent[] = []; private cacheProvider?: CacheProvider; private ready: boolean = false; protected constructor(version: string, ignoreCommandLine: boolean = false) { this.version = version; this.ignoreCommandLine = ignoreCommandLine; } protected abstract getMigrations(): MigrationType[]; protected abstract init(): Promise; protected use(thing: Controller | WebSocketListener | ApplicationComponent): void { if (thing instanceof Controller) { thing.setApp(this); this.controllers.push(thing); } else if (thing instanceof WebSocketListener) { const path = thing.path(); this.webSocketListeners[path] = thing; thing.init(this); logger.info(`Added websocket listener on ${path}`); } else { thing.setApp(this); this.components.push(thing); if (thing instanceof RedisComponent) { this.cacheProvider = thing; } } } public async start(): Promise { logger.info(`${config.get('app.name')} v${this.version} - hi`); process.once('SIGINT', () => { this.stop().catch(console.error); }); // Register migrations MysqlConnectionManager.registerMigrations(this.getMigrations()); // Process command line if (!this.ignoreCommandLine && await this.processCommandLine()) { await this.stop(); return; } // Register all components and alike await this.init(); // Security if (process.env.NODE_ENV === 'production') { await this.checkSecuritySettings(); } // Init express const app = express(); // Logging context app.use(loggingContextMiddleware); // Routers const initRouter = express.Router(); const handleRouter = express.Router(); app.use(initRouter); app.use(handleRouter); // Error handlers app.use((err: unknown, req: Request, res: Response, next: NextFunction) => { if (res.headersSent) return next(err); // Transform single validation errors into a validation bag for convenience if (err instanceof ValidationError) { const bag = new ValidationBag(); bag.addMessage(err); err = bag; } if (err instanceof ValidationBag) { const bag = err; res.format({ json: () => { res.status(400); res.json({ status: 'error', code: 400, message: 'Invalid form data', messages: bag.getMessages(), }); }, text: () => { res.status(400); res.send('Error: ' + bag.getMessages()); }, html: () => { req.flash('validation', bag.getMessages()); res.redirect(req.getPreviousUrl() || Controller.route('home')); }, }); return; } const errorId = LogRequestsComponent.logRequest(req, res, err, '500 Internal Error', err instanceof BadRequestError || err instanceof ServiceUnavailableHttpError); let httpError: HttpError; if (err instanceof HttpError) { httpError = err; } else if (err instanceof TemplateError && err.cause instanceof HttpError) { httpError = err.cause; } else { httpError = new ServerError('Internal server error.', err instanceof Error ? err : undefined); } res.status(httpError.errorCode); res.format({ html: () => { res.render('errors/' + httpError.errorCode + '.njk', { error_code: httpError.errorCode, error_message: httpError.message, error_instructions: httpError.instructions, error_id: errorId, }); }, json: () => { res.json({ status: 'error', code: httpError.errorCode, message: httpError.message, instructions: httpError.instructions, error_id: errorId, }); }, default: () => { res.type('txt').send(`${httpError.errorCode} - ${httpError.message}\n\n${httpError.instructions}\n\nError ID: ${errorId}`); }, }); }); // Start components for (const component of this.components) { await component.start?.(app); } // Components routes for (const component of this.components) { if (component.init) { component.setCurrentRouter(initRouter); await component.init(initRouter); } if (component.handle) { component.setCurrentRouter(handleRouter); await component.handle(handleRouter); } component.setCurrentRouter(null); } // Routes this.routes(initRouter, handleRouter); this.ready = true; } protected async processCommandLine(): Promise { const args = process.argv; for (let i = 2; i < args.length; i++) { switch (args[i]) { case '--verbose': logger.setSettings({minLevel: "trace"}); break; case '--full-http-requests': LogRequestsComponent.logFullHttpRequests(); break; case 'migration': await MysqlConnectionManager.migrationCommand(args.slice(i + 1)); return true; default: logger.warn('Unrecognized argument', args[i]); return true; } } return false; } private async checkSecuritySettings(): Promise { // Check config file permissions const configDir = 'config'; for (const file of fs.readdirSync(configDir)) { const fullPath = path.resolve(configDir, file); const stats = fs.lstatSync(fullPath); if (stats.uid !== process.getuid()) throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid()}).`); const mode = (stats.mode & parseInt('777', 8)).toString(8); if (mode !== '400') throw new SecurityError(`${fullPath} is ${mode}; should be 400.`); } // Check security fields for (const component of this.components) { await component.checkSecuritySettings?.(); } } public async stop(): Promise { logger.info('Stopping application...'); for (const component of this.components) { await component.stop?.(); } logger.info(`${this.constructor.name} v${this.version} - bye`); } private routes(initRouter: Router, handleRouter: Router) { for (const controller of this.controllers) { if (controller.hasGlobalMiddlewares()) { controller.setupGlobalHandlers(handleRouter); logger.info(`Registered global middlewares for controller ${controller.constructor.name}`); } } for (const controller of this.controllers) { const {mainRouter, fileUploadFormRouter} = controller.setupRoutes(); initRouter.use(controller.getRoutesPrefix(), fileUploadFormRouter); handleRouter.use(controller.getRoutesPrefix(), mainRouter); logger.info(`> Registered routes for controller ${controller.constructor.name} at ${controller.getRoutesPrefix()}`); } handleRouter.use((req: Request) => { throw new NotFoundHttpError('page', req.originalUrl); }); } public isReady(): boolean { return this.ready; } public getVersion(): string { return this.version; } public getWebSocketListeners(): { [p: string]: WebSocketListener } { return this.webSocketListeners; } public getCache(): CacheProvider | null { return this.cacheProvider || null; } public as>(type: Type): C { const module = this.components.find(component => component.constructor === type) || Object.values(this.webSocketListeners).find(listener => listener.constructor === type); if (!module) throw new Error(`This app doesn't have a ${type.name} component.`); return module as C; } public asOptional>(type: Type): C | null { const module = this.components.find(component => component.constructor === type) || Object.values(this.webSocketListeners).find(listener => listener.constructor === type); return module ? module as C : null; } }