2020-06-14 21:48:50 +02:00
|
|
|
import express, {NextFunction, Request, Response, Router} from 'express';
|
2020-04-23 15:43:15 +02:00
|
|
|
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError";
|
2020-04-22 15:52:17 +02:00
|
|
|
import {lib} from "nunjucks";
|
|
|
|
import Logger from "./Logger";
|
|
|
|
import WebSocketListener from "./WebSocketListener";
|
|
|
|
import ApplicationComponent from "./ApplicationComponent";
|
|
|
|
import Controller from "./Controller";
|
2020-04-23 15:43:15 +02:00
|
|
|
import MysqlConnectionManager from "./db/MysqlConnectionManager";
|
|
|
|
import Migration from "./db/Migration";
|
|
|
|
import {Type} from "./Utils";
|
2020-06-14 11:43:00 +02:00
|
|
|
import LogRequestsComponent from "./components/LogRequestsComponent";
|
2020-06-14 21:48:50 +02:00
|
|
|
import {ValidationBag} from "./db/Validator";
|
2020-06-27 17:11:31 +02:00
|
|
|
import config from "config";
|
2020-07-15 15:13:40 +02:00
|
|
|
import * as fs from "fs";
|
|
|
|
import SecurityError from "./SecurityError";
|
2020-07-08 09:52:08 +02:00
|
|
|
import TemplateError = lib.TemplateError;
|
2020-04-22 15:52:17 +02:00
|
|
|
|
|
|
|
export default abstract class Application {
|
|
|
|
private readonly version: string;
|
2020-07-08 09:52:08 +02:00
|
|
|
private readonly ignoreCommandLine: boolean;
|
2020-04-22 15:52:17 +02:00
|
|
|
private readonly controllers: Controller[] = [];
|
|
|
|
private readonly webSocketListeners: { [p: string]: WebSocketListener } = {};
|
|
|
|
private readonly components: ApplicationComponent<any>[] = [];
|
|
|
|
|
|
|
|
private ready: boolean = false;
|
|
|
|
|
2020-07-08 09:52:08 +02:00
|
|
|
protected constructor(version: string, ignoreCommandLine: boolean = false) {
|
2020-04-22 15:52:17 +02:00
|
|
|
this.version = version;
|
2020-07-08 09:52:08 +02:00
|
|
|
this.ignoreCommandLine = ignoreCommandLine;
|
2020-04-22 15:52:17 +02:00
|
|
|
}
|
|
|
|
|
2020-04-23 15:43:15 +02:00
|
|
|
protected abstract getMigrations(): Type<Migration>[];
|
|
|
|
|
2020-04-22 15:52:17 +02:00
|
|
|
protected abstract async init(): Promise<void>;
|
|
|
|
|
|
|
|
protected use(thing: Controller | WebSocketListener | ApplicationComponent<any>) {
|
|
|
|
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 {
|
2020-04-25 09:34:02 +02:00
|
|
|
thing.setApp(this);
|
2020-04-22 15:52:17 +02:00
|
|
|
this.components.push(thing);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public async start(): Promise<void> {
|
2020-06-27 17:11:31 +02:00
|
|
|
Logger.info(`${config.get('app.name')} v${this.version} - hi`);
|
2020-04-22 15:52:17 +02:00
|
|
|
process.once('SIGINT', () => {
|
|
|
|
this.stop().catch(console.error);
|
|
|
|
});
|
|
|
|
|
2020-04-23 15:43:15 +02:00
|
|
|
// Register migrations
|
|
|
|
MysqlConnectionManager.registerMigrations(this.getMigrations());
|
|
|
|
|
2020-06-05 14:32:39 +02:00
|
|
|
// Process command line
|
2020-07-08 09:52:08 +02:00
|
|
|
if (!this.ignoreCommandLine && await this.processCommandLine()) {
|
2020-06-05 14:32:39 +02:00
|
|
|
await this.stop();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-04-22 15:52:17 +02:00
|
|
|
// Register all components and alike
|
|
|
|
await this.init();
|
|
|
|
|
2020-07-15 15:06:13 +02:00
|
|
|
// Security
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
|
|
await this.checkSecuritySettings();
|
|
|
|
}
|
|
|
|
|
2020-04-22 15:52:17 +02:00
|
|
|
// Init express
|
|
|
|
const app = express();
|
2020-07-11 11:46:16 +02:00
|
|
|
const initRouter = express.Router();
|
|
|
|
const handleRouter = express.Router();
|
|
|
|
app.use(initRouter);
|
|
|
|
app.use(handleRouter);
|
2020-04-22 15:52:17 +02:00
|
|
|
|
2020-07-11 11:46:16 +02:00
|
|
|
// Error handlers
|
2020-04-22 15:52:17 +02:00
|
|
|
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
2020-07-11 11:46:16 +02:00
|
|
|
if (res.headersSent) return next(err);
|
2020-04-22 15:52:17 +02:00
|
|
|
|
2020-06-14 21:48:50 +02:00
|
|
|
if (err instanceof ValidationBag) {
|
2020-06-15 12:58:15 +02:00
|
|
|
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();
|
|
|
|
},
|
|
|
|
});
|
2020-06-14 21:48:50 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-06-14 11:43:00 +02:00
|
|
|
let errorID: string = LogRequestsComponent.logRequest(req, res, err, '500 Internal Error', err instanceof BadRequestError || err instanceof ServiceUnavailableHttpError);
|
2020-04-22 15:52:17 +02:00
|
|
|
|
|
|
|
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}`);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2020-07-11 11:46:16 +02:00
|
|
|
// Start components
|
|
|
|
for (const component of this.components) {
|
|
|
|
await component.start(app);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Components routes
|
2020-04-22 15:52:17 +02:00
|
|
|
for (const component of this.components) {
|
2020-07-11 11:46:16 +02:00
|
|
|
await component.init(initRouter);
|
|
|
|
await component.handle(handleRouter);
|
2020-04-22 15:52:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Routes
|
2020-07-11 11:46:16 +02:00
|
|
|
this.routes(initRouter, handleRouter);
|
2020-04-22 15:52:17 +02:00
|
|
|
|
|
|
|
this.ready = true;
|
|
|
|
}
|
|
|
|
|
2020-06-05 14:32:39 +02:00
|
|
|
protected async processCommandLine(): Promise<boolean> {
|
|
|
|
const args = process.argv;
|
|
|
|
for (let i = 2; i < args.length; i++) {
|
|
|
|
switch (args[i]) {
|
|
|
|
case '--verbose':
|
|
|
|
Logger.verbose();
|
|
|
|
break;
|
2020-06-14 11:43:00 +02:00
|
|
|
case '--full-http-requests':
|
|
|
|
LogRequestsComponent.logFullHttpRequests();
|
|
|
|
break;
|
2020-06-05 14:32:39 +02:00
|
|
|
case 'migration':
|
|
|
|
await MysqlConnectionManager.migrationCommand(args.slice(i + 1));
|
|
|
|
return true;
|
|
|
|
default:
|
|
|
|
Logger.warn('Unrecognized argument', args[i]);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2020-07-15 15:06:13 +02:00
|
|
|
private async checkSecuritySettings(): Promise<void> {
|
2020-07-15 15:13:40 +02:00
|
|
|
// Check config file permissions
|
|
|
|
for (const file of fs.readdirSync('config')) {
|
|
|
|
const stats = fs.lstatSync(file);
|
|
|
|
if (stats.uid !== process.getuid())
|
|
|
|
throw new SecurityError(file + ' is not owned by this process (' + process.getuid() + ').');
|
|
|
|
if (stats.mode !== 400)
|
|
|
|
throw new SecurityError(file + ' is not chmod 400.');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check security fields
|
2020-07-15 15:06:13 +02:00
|
|
|
for (const component of this.components) {
|
|
|
|
await component.checkSecuritySettings();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-22 15:52:17 +02:00
|
|
|
async stop(): Promise<void> {
|
|
|
|
Logger.info('Stopping application...');
|
|
|
|
|
|
|
|
for (const component of this.components) {
|
|
|
|
await component.stop();
|
|
|
|
}
|
|
|
|
|
|
|
|
Logger.info(`${this.constructor.name} v${this.version} - bye`);
|
|
|
|
}
|
|
|
|
|
2020-07-11 11:46:16 +02:00
|
|
|
private routes(initRouter: Router, handleRouter: Router) {
|
2020-04-22 15:52:17 +02:00
|
|
|
for (const controller of this.controllers) {
|
|
|
|
if (controller.hasGlobalHandlers()) {
|
2020-07-11 11:46:16 +02:00
|
|
|
controller.setupGlobalHandlers(handleRouter);
|
2020-04-22 15:52:17 +02:00
|
|
|
|
|
|
|
Logger.info(`Registered global middlewares for controller ${controller.constructor.name}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const controller of this.controllers) {
|
2020-07-11 11:08:57 +02:00
|
|
|
const {mainRouter, fileUploadFormRouter} = controller.setupRoutes();
|
2020-07-11 11:46:16 +02:00
|
|
|
initRouter.use(controller.getRoutesPrefix(), fileUploadFormRouter);
|
|
|
|
handleRouter.use(controller.getRoutesPrefix(), mainRouter);
|
2020-04-22 15:52:17 +02:00
|
|
|
|
|
|
|
Logger.info(`> Registered routes for controller ${controller.constructor.name}`);
|
|
|
|
}
|
|
|
|
|
2020-07-11 11:46:16 +02:00
|
|
|
handleRouter.use((req: Request) => {
|
2020-04-22 15:52:17 +02:00
|
|
|
throw new NotFoundHttpError('page', req.originalUrl);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public getWebSocketListeners(): { [p: string]: WebSocketListener } {
|
|
|
|
return this.webSocketListeners;
|
|
|
|
}
|
|
|
|
|
|
|
|
public isReady(): boolean {
|
|
|
|
return this.ready;
|
|
|
|
}
|
2020-04-25 09:34:02 +02:00
|
|
|
|
|
|
|
public getVersion(): string {
|
|
|
|
return this.version;
|
|
|
|
}
|
2020-04-22 15:52:17 +02:00
|
|
|
}
|