UNPKG

22.3 kBPlain TextView Raw
1import { Express } from "express";
2import * as express from "express";
3const http = require("http");
4import * as nunjucks from "nunjucks";
5import * as fs from "fs";
6import "reflect-metadata";
7import { createConnection } from "typeorm";
8import * as session from "express-session";
9import * as bodyParser from "body-parser";
10import * as multer from "multer";
11import * as cors from "cors";
12import * as moment from "moment";
13const flash = require("express-flash");
14import { graphqlExpress, graphiqlExpress } from "apollo-server-express";
15import Config from "./config";
16import BaseModule from "./base.module";
17import Migration from "./migration";
18import MigrationEntity from "./entities/migration.entity";
19import ErrorController from "./error.controller";
20
21import * as expressGenerator from "./express-generator";
22import * as graphqlGenerator from "./graphql/generator";
23
24import { setup } from "./entities/setup";
25import User from "./entities/user.entity";
26import { sign } from "jsonwebtoken";
27
28const translations: any = {};
29const routes: any = {};
30
31import { logger } from "./logger";
32import {APIResponseWrapper, DefaultAPIResponseWrapper} from "./api-response-wrapper";
33
34/**
35 * Utility function to check if we are in the production environment.
36 * @return true if the NODE_ENV is set to "production", false otherwise
37 */
38export function isProduction(): boolean {
39 return process.env.NODE_ENV === "production";
40}
41
42/**
43 * Retrieve the preferred language from an express request, using the accepts-languages
44 * header value.
45 * @param req the express request
46 * @return the two letter lower-cased language, or "*" (wildcard), or null
47 */
48export function getLanguageFromRequest(req: express.Request): string {
49 let lang = req.acceptsLanguages()[0];
50 if (lang.indexOf("-") !== -1) {
51 lang = lang.split("-")[0];
52 }
53 if (lang) {
54 lang = lang.trim().toLowerCase();
55 }
56 return lang;
57}
58
59/**
60 * This function shall be called with the nunjucks environment as self parameter!
61 * It retrieve the language of the current request, using the default
62 * language set in the app as fallback.
63 */
64function retrieveLanguage(self: any): string {
65 let lang = null;
66 try {
67 const req: express.Request = self.ctx.req;
68 lang = getLanguageFromRequest(req);
69 if (lang === "*") {
70 lang = null;
71 }
72 } catch (e) {}
73 if (!lang) {
74 lang = self.getVariables()["lang"];
75 }
76 if (!lang) {
77 let app: App = self.ctx.req.app.get("app");
78 lang = app.config.defaultLanguage;
79 }
80 return lang;
81}
82
83/**
84 * Implementation of the tr filer for the nunjucks engine.
85 * It trying to understand the current language from the request. The fallback
86 * uses the defaultLanguage set on the app.
87 */
88function translate(str: string): string {
89 try {
90 let lang = retrieveLanguage(this);
91 return performTranslation(str, translations[lang]);
92 } catch (e) {
93 logger.info(e);
94 logger.info(this);
95 }
96 return str;
97}
98
99function performTranslation(str: string, translations: any): string {
100 let translation = translations[str];
101 if (translation) {
102 return translation;
103 }
104 const start = str.indexOf("{{");
105 const end = str.indexOf("}}");
106 if (start != -1 && end != -1) {
107 let key = str.substring(start + 2, end);
108 translation = translations[key.trim()];
109 return str.replace("{{" + key + "}}", translation);
110 }
111 return str;
112}
113/**
114 * Implementation of the date filter using moment.
115 * The default implementation uses the "lll" string format, resulting in
116 * Feb 19, 2018 4:57 PM in English.
117 * @param d the date to format
118 * @param format the string to format the date, default to lll
119 * @return the formatted date
120 */
121function date(d: Date, format?: string): string {
122 let lang = retrieveLanguage(this);
123 let m = moment(d).locale(lang);
124 if (!format) {
125 format = "lll";
126 }
127 return m.format(format);
128}
129
130/**
131 * Apply parameters to an URL. If a parameter is not found as path parameter,
132 * it is added as query parameter.
133 * @param url the url to compile
134 * @param parameters a plain object containing the parameters
135 * @return the compiled url
136 */
137function applyParametersToUrl(url: string, parameters: any): string {
138 if (!parameters) {
139 return url;
140 }
141 if (url.indexOf("?") == -1) {
142 url += "?";
143 } else {
144 url += "&";
145 }
146 for (let key in parameters) {
147 if (url.indexOf(":" + key) == -1) {
148 url += `${key}=${parameters[key]}&`;
149 } else {
150 url = url.replace(":" + key, parameters[key]);
151 }
152 }
153 if (url.endsWith("?") || url.endsWith("&")) {
154 url = url.substring(0, url.length - 1);
155 }
156 return url;
157}
158
159/**
160 * Transform the route name to a URL and compile it with the given parameters.
161 * @param name the route name (or, eventually, the path)
162 * @param parameters a plain object containing the parameters
163 * @return the compiled url
164 */
165function route(name: string, parameters?: any): string {
166 let url = name;
167 if (routes[name]) {
168 url = routes[name];
169 }
170 return applyParametersToUrl(url, parameters);
171}
172
173/**
174 * Implementation of the old filter function. This function returns the previous
175 * value of the input form. Fallback to the defaultValue.
176 * @param name the name used in the form
177 * @param defaultValue a fallback value
178 * @return the previous value or defaultValue
179 */
180function old(name: string, defaultValue?: any): string {
181 const req = this.ctx.req;
182 if (req.body && req.body[name]) {
183 return req.body[name];
184 }
185 if (req.query[name]) {
186 return req.query[name];
187 }
188 return defaultValue;
189}
190
191/**
192 * Implementation of the format filter function to format a float number.
193 * By default, the number is formatted with 2 decimal numbers.
194 * @param val the number to format
195 * @param decimal the number of decimal number
196 * @return the formatted number as a string
197 */
198function format(val: number, decimal: number = 2): string {
199 return Number(val).toFixed(decimal);
200}
201
202/**
203 * Implementation of the resolvePath global function. Using this function, it is
204 * possible to refer to any views with a virtual folder containing all the available
205 * views.
206 * @param path the virtual absolute path of the view
207 * @return the absolute path of the view if resolved, or the original path otherwise
208 */
209function resolvePath(path: string): string {
210 let normalizedPath = path;
211 if (normalizedPath.endsWith(".njk")) {
212 normalizedPath = normalizedPath.substring(0, normalizedPath.length - 4);
213 }
214 let app: App = this.ctx.req.app.get("app");
215 let resolved = app.templateMap[path];
216 if (resolved) {
217 return resolved;
218 }
219 return path;
220}
221
222/**
223 * This function returns the current server url, with the used protocol and port.
224 * This function is available as global function on nunjucks.
225 */
226function currentHost(): string {
227 let req = this.ctx.req;
228 return req.protocol + '://' + req.get('host');
229}
230
231export let app: App;
232
233/**
234 * The App class contains the initialization code for a Lynx application.
235 */
236export default class App {
237 public express: Express;
238 public httpServer: any;
239 private readonly _config: Config;
240 private readonly _nunjucksEnvironment: nunjucks.Environment;
241 private readonly _upload: multer.Instance;
242 private _templateMap: any;
243 private _modules: Set<BaseModule> = new Set();
244 private _errorController: ErrorController;
245 public apiResponseWrapper: APIResponseWrapper = new DefaultAPIResponseWrapper();
246
247 get config(): Config {
248 return this._config;
249 }
250
251 get templateMap(): any {
252 return this._templateMap;
253 }
254
255 get nunjucksEnvironment(): nunjucks.Environment {
256 return this._nunjucksEnvironment;
257 }
258
259 get upload(): multer.Instance {
260 return this._upload;
261 }
262
263 /**
264 * This property allow the customization of the standard error controller.
265 * You need to create the controller using its standard constructor:
266 * new MyCustomErrorController(app)
267 */
268 set customErrorController(ctrl: ErrorController) {
269 this._errorController = ctrl;
270 }
271
272 constructor(config: Config, modules?: BaseModule[]) {
273 this._config = config;
274 app = this;
275
276 if (modules) {
277 this._modules = new Set(modules);
278 this._modules.forEach(module => module.mount(this._config));
279 }
280
281 config.db.entities.unshift(__dirname + "/entities/*.entity.js");
282 config.middlewaresFolders.unshift(__dirname + "/middlewares");
283 config.viewFolders.unshift(__dirname + "/views");
284
285 if (!config.disabledDb) {
286 createConnection(<any>config.db)
287 .then(_ => {
288 // here you can start to work with your entities
289 logger.info("Connection to the db established!");
290 setup(config.db.entities)
291 .then(_ => {
292 this._modules.forEach(module =>
293 module.onDatabaseConnected()
294 );
295 if (!config.disableMigrations) {
296 this.executeMigrations()
297 .catch(err => {
298 logger.error(err);
299 process.exit(1);
300 })
301 .then(() => {
302 if (this._config.onDatabaseInit) {
303 this._config.onDatabaseInit();
304 }
305 });
306 } else if (this._config.onDatabaseInit) {
307 this._config.onDatabaseInit();
308 }
309 })
310 .catch(error => {
311 logger.error(error);
312 process.exit(1);
313 });
314 })
315 .catch(error => {
316 logger.error(error);
317 process.exit(1);
318 });
319 } else {
320 logger.debug("The DB service is disabled");
321 }
322 this.express = express();
323 this.httpServer = http.createServer(this.express);
324 this.express.set("app", this);
325 this.express.use((_, res, next) => {
326 res.setHeader('X-Powered-By', 'lynx-framework/express');
327 next();
328 });
329
330 this.express.use("/api/*", cors());
331 if (this.config.jsonLimit) {
332 this.express.use(bodyParser.json({ limit: this.config.jsonLimit }));
333 } else {
334 this.express.use(bodyParser.json());
335 }
336
337 this.express.use(bodyParser.urlencoded({ extended: true }));
338
339 let app_session_options: any = {
340 secret: config.sessionSecret,
341 resave: false,
342 saveUninitialized: true
343 };
344 if (config.sessionStore) {
345 app_session_options.store = config.sessionStore;
346 }
347 let app_session = session(app_session_options);
348 this.express.use(app_session);
349 this.express.use(flash());
350
351 this._upload = multer({ dest: config.uploadPath });
352 fs.exists(config.cachePath, exists => {
353 if (!exists) {
354 fs.mkdir(config.cachePath, err => {
355 if (err) {
356 logger.error(
357 "Error creating the local cache directory",
358 err
359 );
360 }
361 });
362 }
363 });
364
365 for (let folder of config.publicFolders) {
366 this.express.use(express.static(folder));
367 }
368
369 this.generateTemplateMap(config.viewFolders);
370 this._nunjucksEnvironment = nunjucks.configure(config.viewFolders, {
371 autoescape: true,
372 watch: true,
373 express: this.express
374 });
375 this._nunjucksEnvironment.addFilter("tr", translate);
376 this._nunjucksEnvironment.addFilter("json", JSON.stringify);
377 this._nunjucksEnvironment.addFilter("format", format);
378 this._nunjucksEnvironment.addFilter("date", date);
379 this.loadTranslations(config.translationFolders);
380 this._nunjucksEnvironment.addGlobal("route", route);
381 this._nunjucksEnvironment.addGlobal("old", old);
382 this._nunjucksEnvironment.addGlobal("resolvePath", resolvePath);
383 this._nunjucksEnvironment.addGlobal("currentHost", currentHost);
384
385 for (let path of config.middlewaresFolders) {
386 this.loadMiddlewares(path);
387 }
388 for (let path of config.controllersFolders) {
389 this.loadControllers(path);
390 }
391
392 if (!config.disabledDb && !config.disabledGraphQL) {
393 const schema = graphqlGenerator.generateSchema(config.db.entities);
394 // The GraphQL endpoint
395 this.express.use(
396 "/graphql",
397 bodyParser.json(),
398 graphqlExpress({ schema })
399 );
400
401 // GraphiQL, a visual editor for queries
402 this.express.use(
403 "/graphiql",
404 graphiqlExpress({ endpointURL: "/graphql" })
405 );
406 }
407
408 this._errorController = new ErrorController(this);
409 }
410
411 private recursiveGenerateTemplateMap(path: string, currentPath: string) {
412 const files = fs.readdirSync(path);
413 for (let index in files) {
414 let currentFilePath = path + "/" + files[index];
415 if (fs.lstatSync(currentFilePath).isDirectory()) {
416 this.recursiveGenerateTemplateMap(
417 currentFilePath,
418 currentPath + files[index] + "/"
419 );
420 continue;
421 }
422 let name = files[index].replace(".njk", "");
423 this._templateMap[currentPath + name] = currentFilePath;
424 }
425 }
426
427 private generateTemplateMap(paths: string[]) {
428 this._templateMap = {};
429 for (let path of paths) {
430 this.recursiveGenerateTemplateMap(path, "/");
431 }
432 }
433
434 private async recursiveExecuteMigrations(path: string) {
435 if (!fs.existsSync(path)) {
436 logger.warn("The migration folder " + path + " doesn't exists!");
437 return;
438 }
439 const files = fs.readdirSync(path).sort((a, b) => a.localeCompare(b));
440 for (let index in files) {
441 let currentFilePath = path + "/" + files[index];
442 if (fs.lstatSync(currentFilePath).isDirectory()) {
443 await this.recursiveExecuteMigrations(currentFilePath);
444 continue;
445 }
446 if (currentFilePath.endsWith("ts")) continue;
447 const m = require(currentFilePath);
448 if (!m.default) {
449 throw new Error(
450 "Please define the migration as the export default class in file " +
451 currentFilePath +
452 "."
453 );
454 }
455 let entity = await MigrationEntity.findByName(currentFilePath);
456 if (entity && entity.wasExecuted()) {
457 continue;
458 }
459 if (!entity) {
460 entity = new MigrationEntity();
461 entity.name = currentFilePath;
462 await entity.save();
463 }
464 let migration = new m.default() as Migration;
465 try {
466 await migration.up();
467 entity.setExecuted();
468 await entity.save();
469 logger.info("Migration " + currentFilePath + " executed!");
470 } catch (e) {
471 entity.setFailed();
472 await entity.save();
473 logger.error(
474 "Error executing the migration " + currentFilePath
475 );
476 throw e;
477 }
478 }
479 }
480
481 /**
482 * This method will execute the migrations.
483 * By default, this method will be executed automatically during the app
484 * startup. In some scenario, like hight-scalability, this behaviour could
485 * be unwanted. Thus, it is possibly otherwise to explicitly call this method
486 * in some other way (for example, connecting it to a standard http route).
487 */
488 public async executeMigrations() {
489 for (let path of this._config.migrationsFolders) {
490 await this.recursiveExecuteMigrations(path);
491 }
492 }
493
494 private loadTranslations(paths: string[]) {
495 for (let path of paths) {
496 const files = fs.readdirSync(path);
497 for (let index in files) {
498 let nameWithExtension: string = files[index];
499 if (!nameWithExtension.endsWith("json")) continue;
500 let name = nameWithExtension.substring(
501 0,
502 nameWithExtension.indexOf(".")
503 );
504 let tmp = JSON.parse(
505 fs.readFileSync(path + "/" + nameWithExtension, "utf8")
506 );
507 if (!translations[name]) {
508 translations[name] = {};
509 }
510 for (let key in tmp) {
511 translations[name][key] = tmp[key];
512 }
513 }
514 }
515 }
516
517 private loadMiddlewares(path: string) {
518 if (!fs.existsSync(path)) {
519 logger.warn("The middleares folder " + path + " doesn't exists!");
520 return;
521 }
522 const middlewares = fs.readdirSync(path);
523 for (let index in middlewares) {
524 let currentFilePath = path + "/" + middlewares[index];
525 if (fs.lstatSync(currentFilePath).isDirectory()) {
526 this.loadMiddlewares(currentFilePath);
527 continue;
528 }
529 if (middlewares[index].endsWith("ts")) continue;
530 const midd = require(currentFilePath);
531 if (!midd.default) {
532 throw new Error(
533 "Please define the middleware as the export default class in file " +
534 currentFilePath +
535 "."
536 );
537 }
538 expressGenerator.useMiddleware(this, midd.default);
539 }
540 }
541
542 private loadControllers(path: string) {
543 const files = fs.readdirSync(path);
544 for (let index in files) {
545 let currentFilePath = path + "/" + files[index];
546 if (fs.lstatSync(currentFilePath).isDirectory()) {
547 this.loadControllers(currentFilePath);
548 continue;
549 }
550 if (files[index].endsWith("ts")) continue;
551 const ctrl = require(currentFilePath);
552 if (!ctrl.default) {
553 throw new Error(
554 "Please define the controller as the export default class in file " +
555 currentFilePath +
556 "."
557 );
558 }
559 expressGenerator.useController(this, ctrl.default, routes);
560 }
561 }
562
563 public startServer(port: number) {
564 this.express.use((req, res) => {
565 this._errorController
566 .onNotFound(req as any)
567 .then((r: any) => {
568 if (!res.headersSent) {
569 res.status(404);
570 }
571 r.performResponse(req, res);
572 })
573 .catch(err => {
574 if (!res.headersSent) {
575 res.status(404);
576 }
577 res.send(err);
578 });
579 });
580 this.express.use((error: Error, req: any, res: any, _: any) => {
581 this._errorController
582 .onError(error, req as any)
583 .then((r: any) => {
584 if (!res.headersSent) {
585 res.status(500);
586 }
587 r.performResponse(req, res);
588 })
589 .catch(err => {
590 if (!res.headersSent) {
591 res.status(500);
592 }
593 res.send(err);
594 });
595 });
596
597 this.httpServer.listen(port, () => {
598 logger.info(`server is listening on ${port}`);
599 });
600 }
601
602 public route(name: string, parameters?: any) {
603 return route(name, parameters);
604 }
605
606 public translate(str: string, req: express.Request): string {
607 try {
608 let lang = getLanguageFromRequest(req);
609 if (!lang) {
610 lang = this._config.defaultLanguage;
611 }
612 return performTranslation(str, translations[lang]);
613 } catch (e) {
614 logger.info(e);
615 }
616 return str;
617 }
618
619 public generateTokenForUser(user: User): string {
620 return sign({ id: user.id }, this._config.tokenSecret, {
621 expiresIn: "1y"
622 });
623 }
624}
625
626declare global {
627 interface Array<T> {
628 serialize(): Array<any>;
629 removeHiddenField(field: string): void;
630 addHiddenField(field: string): void;
631 }
632}
633
634Object.defineProperty(Array.prototype, "serialize", {
635 enumerable: false,
636 configurable: true,
637 value: function() {
638 let r = [];
639 for (let el of this) {
640 if (el.serialize) {
641 r.push(el.serialize());
642 } else {
643 r.push(el);
644 }
645 }
646 return r;
647 }
648});
649
650Object.defineProperty(Array.prototype, "addHiddenField", {
651 enumerable: false,
652 configurable: true,
653 value: function(field: string) {
654 for (let el of this) {
655 if (el.addHiddenField) {
656 el.addHiddenField(field);
657 }
658 }
659 }
660});
661
662Object.defineProperty(Array.prototype, "removeHiddenField", {
663 enumerable: false,
664 configurable: true,
665 value: function(field: string) {
666 for (let el of this) {
667 if (el.removeHiddenField) {
668 el.removeHiddenField(field);
669 }
670 }
671 }
672});