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 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 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; Logger.info(`Added websocket listener on ${path}`); } else { thing.setApp(this); this.components.push(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(); // Init express const app = express(); const mainRouter = express.Router(); const fileUploadFormRouter = express.Router(); app.use(fileUploadFormRouter); app.use(mainRouter); // Error handler 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 all components for (const component of this.components) { await component.start(app, mainRouter); } // Routes this.routes(mainRouter, fileUploadFormRouter); 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; } 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(mainRootRouter: Router, rootFileUploadFormRouter: Router) { for (const controller of this.controllers) { if (controller.hasGlobalHandlers()) { controller.setupGlobalHandlers(mainRootRouter); Logger.info(`Registered global middlewares for controller ${controller.constructor.name}`); } } for (const controller of this.controllers) { const {mainRouter, fileUploadFormRouter} = controller.setupRoutes(); mainRootRouter.use(controller.getRoutesPrefix(), mainRouter); rootFileUploadFormRouter.use(controller.getRoutesPrefix(), fileUploadFormRouter); Logger.info(`> Registered routes for controller ${controller.constructor.name}`); } mainRootRouter.use((req: Request) => { throw new NotFoundHttpError('page', req.originalUrl); }); } public getWebSocketListeners(): { [p: string]: WebSocketListener } { return this.webSocketListeners; } public isReady(): boolean { return this.ready; } public getVersion(): string { return this.version; } }