import config from "config"; import express, {NextFunction, Request, Response, Router} from 'express'; import * as fs from "fs"; import nunjucks from "nunjucks"; import * as path from "path"; import ApplicationComponent from "./ApplicationComponent.js"; import CacheProvider from "./CacheProvider.js"; import {route, setPublicUrl} from "./common/Routing.js"; import FrontendToolsComponent from "./components/FrontendToolsComponent.js"; import LogRequestsComponent from "./components/LogRequestsComponent.js"; import RedisComponent from "./components/RedisComponent.js"; import Controller from "./Controller.js"; import Migration, {MigrationType} from "./db/Migration.js"; import MysqlConnectionManager from "./db/MysqlConnectionManager.js"; import {ValidationBag, ValidationError} from "./db/Validator.js"; import Extendable, {MissingComponentError} from "./Extendable.js"; import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError.js"; import {logger, loggingContextMiddleware} from "./Logger.js"; import SecurityError from "./SecurityError.js"; import {doesFileExist, Type} from "./Utils.js"; import WebSocketListener from "./WebSocketListener.js"; import TemplateError = nunjucks.lib.TemplateError; import AppLocalsCoreComponents from "./components/core/AppLocalsCoreComponents.js"; import LazyLocalsCoreComponent from "./components/core/LazyLocalsCoreComponent.js"; export default abstract class Application implements Extendable> { private readonly version: string; private coreVersion: string = 'unknown'; 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; private started: boolean = false; private busy: boolean = false; protected constructor(version: string, ignoreCommandLine: boolean = false) { this.version = version; this.ignoreCommandLine = ignoreCommandLine; setPublicUrl(config.get('app.public_url')); } 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 { if (this.started) throw new Error('Application already started'); if (this.busy) throw new Error('Application busy'); this.busy = true; // Load core version const file = await this.isInNodeModules() ? 'node_modules/swaf/package.json' : 'package.json'; try { this.coreVersion = JSON.parse(fs.readFileSync(file).toString()).version; } catch (e) { logger.warn('Couldn\'t determine coreVersion.', e); } logger.info(`${config.get('app.name')} v${this.version} | swaf v${this.coreVersion}`); // Catch interrupt signals const exitHandler = () => { this.stop().catch(console.error); }; process.once('exit', exitHandler); process.once('SIGINT', exitHandler); process.once('SIGUSR1', exitHandler); process.once('SIGUSR2', exitHandler); process.once('SIGTERM', exitHandler); process.once('uncaughtException', exitHandler); // Register migrations MysqlConnectionManager.registerMigrations(this.getMigrations()); // Register and initialize all components and alike this.use(new AppLocalsCoreComponents()); this.use(new LazyLocalsCoreComponent()); await this.init(); for (const component of this.components) { await component.init?.(); } // Process command line if (!this.ignoreCommandLine) { let result: boolean; try { result = await this.processCommandLine(); } catch (err) { logger.error(err); process.exit(1); } if (result) { this.started = true; this.busy = false; return; } } // 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() || 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: () => { const locals = { error_code: httpError.errorCode, error_message: httpError.message, error_instructions: httpError.instructions, error_id: errorId, }; res.formatViewData('errors/' + httpError.errorCode, locals, (err: Error | undefined, html) => { if (err) { res.formatViewData('templates/ErrorTemplate', locals); } else { res.send(html); } }); }, 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}`); }, }); }); // Components routes for (const component of this.components) { if (component.initRoutes) { component.setCurrentRouter(initRouter); await component.initRoutes(initRouter); } if (component.handleRoutes) { component.setCurrentRouter(handleRouter); await component.handleRoutes(handleRouter); } component.setCurrentRouter(null); } // Start components for (const component of this.components) { await component.start?.(app); } // Routes this.routes(initRouter, handleRouter); this.ready = true; this.started = true; this.busy = false; } protected async processCommandLine(): Promise { const args = process.argv; // Flags const flags = { verbose: false, fullHttpRequests: false, watch: false, }; let mainCommand: string | null = null; const mainCommandArgs: string[] = []; for (let i = 2; i < args.length; i++) { switch (args[i]) { case '--verbose': flags.verbose = true; break; case '--full-http-requests': flags.fullHttpRequests = true; break; case '--watch': flags.watch = true; break; case 'migration': case 'pre-compile-views': if (mainCommand === null) mainCommand = args[i]; else throw new Error(`Only one main command can be used at once (${mainCommand},${args[i]})`); break; default: if (mainCommand) { mainCommandArgs.push(args[i]); } else { logger.fatal('Unrecognized argument', args[i]); return true; } break; } } if (flags.verbose) logger.setSettings({minLevel: "trace"}); if (flags.fullHttpRequests) LogRequestsComponent.logFullHttpRequests(); if (mainCommand) { switch (mainCommand) { case 'migration': await MysqlConnectionManager.migrationCommand(mainCommandArgs); await this.stop(); break; case 'pre-compile-views': { // Prepare migrations for (const migration of this.getMigrations()) { new migration().registerModels?.(); } // Prepare routes for (const controller of this.controllers) { controller.setupRoutes(); } const frontendToolsComponent = this.as(FrontendToolsComponent); await frontendToolsComponent.preCompileViews(flags.watch); break; } default: logger.fatal('Unimplemented main command', mainCommand); break; } 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 { if (this.started && !this.busy) { this.busy = true; logger.info('Stopping application...'); for (const component of this.components) { await component.stop?.(); } logger.info(`${this.constructor.name} stopped properly.`); this.started = false; this.busy = false; } } 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 getWebSocketListeners(): { [p: string]: WebSocketListener } { return this.webSocketListeners; } public getCache(): CacheProvider | null { return this.cacheProvider || null; } public getComponents(): ApplicationComponent[] { return [...this.components]; } 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; } public has>(type: Type): boolean { return !!this.asOptional(type); } public require>(type: Type): void { if (!this.has(type)) { throw new MissingComponentError(type); } } public async isInNodeModules(): Promise { return await doesFileExist('node_modules/swaf'); } public isReady(): boolean { return this.ready; } public getVersion(): string { return this.version; } public getCoreVersion(): string { return this.coreVersion; } }