import express, {NextFunction, Request, Response, Router} from 'express'; import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError"; import {lib} from "nunjucks"; import Logger from "./Logger"; import WebSocketListener from "./WebSocketListener"; import ApplicationComponent from "./ApplicationComponent"; import Controller from "./Controller"; import MysqlConnectionManager from "./db/MysqlConnectionManager"; import Migration from "./db/Migration"; import {Type} from "./Utils"; import LogRequestsComponent from "./components/LogRequestsComponent"; import {ValidationBag} 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 TemplateError = lib.TemplateError; export default abstract class Application { 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(): Type[]; protected abstract async init(): Promise; protected use(thing: Controller | WebSocketListener | ApplicationComponent) { if (thing instanceof Controller) { 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(); const initRouter = express.Router(); const handleRouter = express.Router(); app.use(initRouter); app.use(handleRouter); // Error handlers app.use((err: any, req: Request, res: Response, next: NextFunction) => { if (res.headersSent) return next(err); if (err instanceof ValidationBag) { res.format({ json: () => { res.status(401); res.json({ status: 'error', code: 401, message: 'Invalid form data', messages: err.getMessages(), }); }, text: () => { res.status(401); res.send('Error: ' + err.getMessages()) }, html: () => { req.flash('validation', err.getMessages()); res.redirectBack(); }, }); return; } let errorID: string = 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); } 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) { await component.init(initRouter); await component.handle(handleRouter); } // 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.verbose(); 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.hasGlobalHandlers()) { 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}`); } 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 | undefined { return this.cacheProvider; } public getComponent>(type: Type): T | undefined { return this.components.find(component => component.constructor === type); } }