Source: services/rail.js

'use strict'

const fs = require('fs')

const express = require('express')
const { stringify } = require('sesajstools')

const log = require('an-log')('$rail')

/**
 * Service de gestion des middlewares
 * @namespace $rail
 */
module.exports = function ($maintenance, $settings) {
  /**
   * Enregistrer un middleware sur le rail Express avec lancement des events beforeRailUse et afterRailUse
   * @fires Lassi#beforeRailUse
   * @fires Lassi#afterRailUse
   * @private
   * @param {string} name Nom du middleware à ajouter sur le rail
   * @param {*} settings sera passé à middlewareGenerator
   * @param {function} middlewareGenerator sera appelé avec settings et devra renvoyer le middleware à ajouter
   */
  function railUse (name, settings, middlewareGenerator) {
    if (!settings) settings = {}
    if (!middlewareGenerator) throw new Error(`middleware ${name} sans callback`)
    const mountPoint = settings.mountPoint || '/'

    /**
     * Évènement déclenché avant chargement d'un middleware.
     * @event Lassi#beforeRailUse
     * @param {Express}  rail express
     * @param {String}  name Le nom du middleware.
     * @param {Object} settings Les réglages qui seront passés au créateur du middleware
     */
    lassi.emit('beforeRailUse', _rail, name, settings)

    const middleware = middlewareGenerator(settings)
    if (!middleware) return
    _rail.use(mountPoint, middleware)

    /**
     * Évènement déclenché après chargement d'un middleware.
     * @event Lassi#afterRailUse
     * @param {Express}  rail express
     * @param {String}  name Le nom du middleware.
     * @param {Object} settings Les réglages qui ont été passés au créateur du middleware
     */
    lassi.emit('afterRailUse', _rail, name, settings, middleware)
  }

  /**
   * Initialisation du service utilisé par lassi lors de la configuration du composant parent.
   * @param {function} next callback de retour
   * @memberof $rail
   */
  function setup (next) {
    try {
      const railConfig = $settings.get('$rail', {})

      // maintenance (qui coupera tout le reste si elle est active)
      railUse('maintenance', {}, () => $maintenance.middleware())

      // compression (de la réponse si y'a du `Accept-Encoding: gzip` dans la requête,
      // il ajoutera aussi le `Vary: Accept-Encoding` dans toutes les réponses)
      railUse('compression', railConfig.compression, require('compression'))

      // cookie
      const sessionKey = $settings.get('$rail.cookie.key')
      if (sessionKey) {
        log('adding cookie management on rail')
        railUse('cookie', sessionKey, require('cookie-parser'))
      } else {
        log.error(new Error('config.$rail.cookie.key missing, => cookie-parser not used'))
      }

      // bodyParser sauf si on demande de pas le faire
      if (railConfig.noBodyParser) {
        log('bodyParser skipped as asked in config (noBodyParser)')
      } else {
        const dateRegExp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/
        const bodyParserSettings = railConfig.bodyParser || {
          reviver: (key, value) => (typeof value === 'string' && dateRegExp.exec(value)) ? new Date(value) : value
        }
        // on met quand même une limite très haute (sinon c'est 100kb par défaut)
        // l'application devrait mettre une limite plus basse en fonction de ce qu'elle attend
        if (!bodyParserSettings.limit) {
          bodyParserSettings.limit = '100mb'
        }
        railUse('body-parser', bodyParserSettings, (settings) => {
          // ne rien préciser est deprecated, on passe l'ancienne valeur par défaut
          // cf http://expressjs.com/en/resources/middleware/body-parser.html
          if (!settings.extended) settings.extended = true
          const jsonMiddleware = express.json(settings)
          const urlencodedMiddleware = express.urlencoded(settings)
          // on wrap bodyParser pour récupérer les erreurs et logguer les url concernées
          return function bodyParserMiddleware (req, res, next) {
            // on pourrait mettre l'erreur en req.bodyParserError, avec un req.body = {} puis appeler next()
            // pour laisser le contrôleur décider du message à afficher (cf commit aeb1364)
            // mais on laisse express envoyer une erreur 400 tout de suite (finalement plus logique)
            // on ajoutant quand même dans le log d'erreur le contenu et l'url qui a provoqué ça
            // (et l'erreur de body-parser avec le body reçu, perdu si on passe ça à next)
            function errorCatcher (error) {
              if (error) {
                const myError = new Error('Invalid content')
                if (error.status) myError.status = error.status
                console.error(`Invalid content received on ${req.method} ${req.originalUrl}`, error)
                // body-parser renvoie "Unexpected token # in JSON at position 0"
                // (un bug car c'est lui qui a mis le #),
                // on veut un message plus intelligible et sans stacktrace pour l'utilisateur
                return next(myError)
              }
              next()
            }
            // c'est un peu idiot d'empiler les 2 middlewares sur pour toutes les requêtes,
            // mais c'est ce que faisait l'ancien body-parser générique
            // en attendant que les applis lassi décident sur chaque route quel parser elles veulent,
            // on continue avec ce comportement (le 2e parser rend la main aussitôt si le premier a fait qqchose)
            jsonMiddleware(req, res, (error) => {
              if (error) return errorCatcher(error)
              urlencodedMiddleware(req, res, errorCatcher)
            })
          }
        })
      }

      // session sauf si on demande de pas le faire
      if (railConfig.noSession) {
        log('session management skipped as asked in config (noSession)')
      } else {
        const sessionSettings = $settings.get('$rail.session', {})
        const { secret } = sessionSettings
        if (secret) {
          log('adding session management on rail')
          // la session lassi a besoin d'un client redis, on prend celui de $cache défini à son configure
          const $cache = lassi.service('$cache')
          const client = $cache.getRedisClient()
          // cf https://github.com/tj/connect-redis/blob/master/migration-to-v4.md
          const sessionMiddlewareFactory = require('express-session')
          const RedisStore = require('connect-redis')(sessionMiddlewareFactory)
          // plusieurs applis peuvent utiliser le même redis, faut un préfixe, ça tombe bien le client redis en a déjà un
          // on en ajoute pas, connect-redis ajoutera son préfixe par défaut (sess)
          const store = new RedisStore({ client })
          const sessionOptions = {
            mountPoint: $settings.get('lassi.settings.$rail.session.mountPoint', '/'),
            resave: false,
            saveUninitialized: sessionSettings.saveUninitialized || false,
            secret,
            store
          }
          const sessionMiddleware = sessionMiddlewareFactory(sessionOptions)
          railUse('session', {}, () => function sessionRedisRetryMiddleware (req, res, next) {
            // on ajoute du retry en cas de perte de connexion redis
            // cf https://github.com/expressjs/session/issues/99#issuecomment-63853989
            function lookupSession (error) {
              if (error) return next(error)
              if (req.session !== undefined) return next()
              tries++
              error = new Error(`pas de session après ${tries} essai${tries > 1 ? 's' : ''}`)
              if (tries > 2) return next(error)
              log.error(error)
              // on retente
              sessionMiddleware(req, res, lookupSession)
            }

            let tries = 0
            sessionMiddleware(req, res, lookupSession)
          })
        } else {
          log.error('settings.$rail.session.secret not set => no session')
        }
      }

      // accessLog si demandé
      if (railConfig.accessLog) {
        const conf = railConfig.accessLog
        if (!conf.logFile) return log.error('settings $rail.accessLog.logFile is mandatory for logging')
        try {
          const fd = fs.openSync(conf.logFile, 'a')
          log(`adding access.log (${conf.logFile})`)
          const writeStream = fs.createWriteStream(null, { fd, flags: 'a' })
          // on a notre fichier, on met un listener pour le fermer
          lassi.on('shutdown', () => writeStream.end())
          // cf https://www.npmjs.com/package/morgan
          const morgan = require('morgan')
          // on met pas la forme réduite "combined", car on veut
          // - pouvoir ajouter des infos
          // - gérer correctement le x-real-ip
          // - avoir un :response-time à notre format (ms avec un seul chiffre après la virgule)
          // Cf https://github.com/expressjs/morgan
          let format = ':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent" :response-time[1] :start'
          // on réécrit le token remote-addr pour prendre x-real-ip en premier s'il existe
          morgan.token('remote-addr', (req) => {
            if (req.headers && req.headers['x-real-ip']) return req.headers['x-real-ip']
            // le reste est la fct originale, cf node_modules/morgan/index.js
            if (req.ip) return req.ip
            if (req._remoteAddress) return req._remoteAddress
            if (req.connection) return req.connection.remoteAddress
            return undefined
          })
          // préciser qui met cette propriété start qui n'a pas l'air d'être une prop express
          // cf http://expressjs.com/en/4x/api.html#req
          morgan.token('start', (req) => req.start)
          if (conf.withSessionTracking) {
            format += ' :sessionId'
            // on veut logguer un id pour tracer la navigation d'une session, mais on le prend pas en entier
            // pour que la lecture du log ne permette pas d'usurper une session
            morgan.token('sessionId', (req) => (req.session && req.session.id && req.session.id.substr(-8)) || '-')
          }
          /** Les options morgan */
          const morganOptions = conf.morgan || {}
          morganOptions.stream = writeStream
          // en dev on ajoute les var postées
          if (/^dev/.test(process.env.NODE_ENV)) {
            morgan.token('post', (req) => req.body ? '' : stringify(req.body))
            format += ' :post'
          }
          railUse('accessLog', true, () => morgan(format, morganOptions))
        } catch (error) {
          log.error(error)
        }
      }

      // les controleurs
      const Controllers = require('../controllers')
      const controllers = new Controllers(this)
      railUse('controllers', {}, () => controllers.middleware())

      // et notre errorHandler par défaut. Il ne servira pas si c'est un contrôleur qui fait du next(error),
      // car dans ce cas ça suit la chaîne jusqu'au transport (cf source/controllers/index.js)
      // cf http://expressjs.com/en/guide/error-handling.html
      _rail.use(function railErrorHandler (error, req, res, next) {
        if (error) console.error(error)
        res.status(error.status || 500)
        const errorMessage = error.toString()
        // la réponse avec res.format(html, json, default)
        // façon https://github.com/expressjs/express/blob/4.x/examples/error-pages/index.js#L64
        // répond en html si y'a pas de header accept, on gère manuellement pour envoyer du text/plain dans ce cas
        if (req.accepts('html')) {
          res.type('html').send(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Error</title></head>
<body><pre>${errorMessage}</pre><p><a href="/">Home</a></p></body></html>`)
        } else if (req.accepts('json')) {
          res.json({ success: false, error: errorMessage })
        } else {
          res.type('txt').send(errorMessage)
        }
      })

      // fin de l'ajout des middleware
      next()
    } catch (error) {
      next(error)
    }
  } // setup

  const _rail = express()

  return {
    setup,
    /**
     * Renvoie le rail express (idem `require('express')()`) avec les middlewares en cours
     * @return {Express} express
     * @memberof $rail
     */
    get: () => _rail
  }
}