OurBigBook
web/app.js
// https://stackoverflow.com/questions/7697038/more-than-10-lines-in-a-node-js-stack-error
Error.stackTraceLimit = Infinity;

const bodyParser = require('body-parser')
const cors = require('cors')
const express = require('express')
const http = require('http')
const UnauthorizedError = require('express-jwt/lib/errors/UnauthorizedError');
const methods = require('methods')
const morgan = require('morgan')
const next = require('next')
const passport = require('passport')
const passport_local = require('passport-local');
const session = require('express-session')

const api = require('./api')
const apilib = require('./api/lib')
const back_js = require('./back/js')
const models = require('./models')
const config = require('./front/config')

async function start(port, startNext, cb) {
  const app = express()
  let nextApp
  let nextHandle
  if (startNext) {
    nextApp = next({ dev: !config.isProductionNext })
    nextHandle = nextApp.getRequestHandler()
  }

  const sequelize = models.getSequelize(__dirname)
  // https://stackoverflow.com/questions/57467589/req-protocol-is-always-http-and-not-https
  // req.protocol was fixed to HTTP instead of HTTPS, leading to emails sent from HTTPS having HTTP links.
  app.enable('trust proxy')

  // Enforce HTTPS.
  // https://github.com/ourbigbook/ourbigbook/issues/258
  app.use(function (req, res, next) {
    if (config.isProduction && req.headers['x-forwarded-proto'] === 'http') {
      res.redirect(301, `https://${req.hostname}${req.url}`);
      return;
    }
    next()
  })

  app.set('sequelize', sequelize)
  passport.use(
    new passport_local.Strategy(
      {
        usernameField: 'user[username]',
        passwordField: 'user[password]'
      },
      async function(usernameOrEmail, password, done) {
        let field
        if (usernameOrEmail.indexOf('@') === -1) {
          field = 'username'
        } else {
          field = 'email'
        }
        const user = await sequelize.models.User.findOne({ where: { [field]: usernameOrEmail } })
        if (!user || !sequelize.models.User.validPassword(user, password)) {
          return done(null, false, { errors: { 'username or password': 'is invalid' } })
        }
        return done(null, user)
      }
    )
  )
  app.use(cors())

  // Normal express config defaults
  if (config.verbose) {
    // https://stackoverflow.com/questions/42099925/logging-all-requests-in-node-js-express/64668730#64668730
    app.use(morgan('combined'))
  }
  app.use(bodyParser.urlencoded({ extended: false }))
  app.use(bodyParser.json({
    // Happens due to our huge input files!
    // https://stackoverflow.com/questions/19917401/error-request-entity-too-large
    limit: '16mb'
  }))
  app.use(require('method-override')())

  // Next handles anythiung outside of /api.
  app.get(new RegExp('^(?!' + config.apiPath + '(/|$))'), function (req, res) {
    // We pass the sequelize that we have already created and connected to the database
    // so that the Next.js backend can just use that connection. This is in particular mandatory
    // if we wish to use SQLite in-memory database, because there is no way to make two separate
    // connections to the same in-memory database. In memory databases are used by the test system.
    req.sequelize = sequelize
    return nextHandle(req, res);
  });
  app.use(session({ secret: config.secret, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }))

  // Handle API routes.
  {
    // This is not visible on frontend unfortunately, we just have to redo it there again.
    config.convertOptions.katex_macros = back_js.preloadKatex()
    const router = express.Router()
    router.use(config.apiPath, api)
    app.use(router)
  }

  // 404 handler.
  app.use(function (req, res, next) {
    res.status(404).send('error: 404 Not Found ' + req.path)
  })

  // Error handlers
  app.use(function(err, req, res, next) {
    // Automatiaclly handle Sequelize validation errors.
    if (err instanceof sequelize.Sequelize.ValidationError) {
      if (!config.isProduction && !config.isTest) {
        // The fuller errors can be helpful during development.
        console.error(err);
      }
      const errors = {}
      for (let errItem of err.errors) {
        let errorsForColumn = errors[errItem.path]
        if (errorsForColumn === undefined) {
          errorsForColumn = []
          errors[errItem.path] = errorsForColumn
        }
        errorsForColumn.push(errItem.message)
      }
      const ret = { errors }
      if (!config.isProduction) {
        // err.errors can be empty in some cases, e.g. NOT NULL constraint faiures on SQLite
        // In those cases, the actual error appears under "parent".
        ret.fullError = err
      }
      return res.status(422).json(ret)
    } else if (err instanceof apilib.ValidationError) {
      return res.status(err.status).json({
        errors: err.errors,
      })
    } else if (err instanceof UnauthorizedError) {
      return res.status(err.status).json({
        errors: err.message,
      })
    }
    return next(err)
  })

  if (startNext) {
    await nextApp.prepare()
  }
  await sequelize.authenticate()

  // Just a convenience DB create so we don't have to force new users to do it manually.
  await models.sync(sequelize)
  return new Promise((resolve, reject) => {
    const server = app.listen(port, async function () {
      try {
        cb && (await cb(server, sequelize))
      } catch (e) {
        reject(e)
        this.close()
        throw e
      }
    })
    server.on('close', async function () {
      if (startNext) {
        await nextApp.close()
      }
      await sequelize.close()
      resolve()
    })
  })
}

if (require.main === module) {
  start(config.port, !config.disableFrontend, (server) => {
    console.log('Listening on: http://localhost:' + server.address().port)
  })
}

module.exports = { start }