Source: app.js

'use strict';

/**
 * Main entry point.
 * @module AppServer
 * @license MIT
 * @author Kai KRETSCHMANN <kai@kretschmann.consulting>
 */

// Sentry
const Sentry = require('@sentry/node');
const sentryDSN = process.env.SENTRY_DSN || process.env.SENTRY;

Sentry.init({
  dsn: sentryDSN,
  environment: process.env.NODE_ENV || /* istanbul ignore next */ 'production',
  sendDefaultPii: true,
  debug: false,
  release: 'bintra@' + process.env.npm_package_version,
  tracesSampleRate: 1.0
});

const path = require('path');
const http = require('http');

require('custom-env').env(true);

const log4js = require('log4js');
const logger = log4js.getLogger();
logger.level = process.env.LOGLEVEL || /* istanbul ignore next */ 'warn';

logger.info(`SENTRY dsn=${sentryDSN}`);

// Start of gRPC block
if (process.env.GRPC_BIND_PORT) {
  const PROTO_PATH = path.join(__dirname, 'proto/route_guide.proto');
  const grpc = require('@grpc/grpc-js');
  const protoLoader = require('@grpc/proto-loader');
  const addReflection = require('grpc-server-reflection').addReflection;
  const protoOptions = {
    keepCase: true,
    longs: String,
    enums: String,
    defaults: true,
    oneofs: true
  };
  const packageDefinition = protoLoader.loadSync(PROTO_PATH, protoOptions);
  const apiProto = grpc.loadPackageDefinition(packageDefinition);
  const grpcControllerAdmins = require('./grpc-controllers/Admins');
  const grpcServer = new grpc.Server();
  grpcServer.addService(apiProto.routeguide.ApiService.service, {
    getCount: grpcControllerAdmins.serviceCountPackage
  });
  addReflection(grpcServer, 'descriptor_set.bin');
  const grpcServerHost = process.env.GRPC_BIND_HOST || '127.0.0.1';
  const grpcServerPort = process.env.GRPC_BIND_PORT || /* istanbul ignore next */ 8081;
  grpcServer.bindAsync(
    `${grpcServerHost}:${grpcServerPort}`,
    grpc.ServerCredentials.createInsecure(),
    (error, port) => {
      if (error) logger.error(error);
      logger.info(`gRPC server starting at grpc://${grpcServerHost}:${grpcServerPort}`);
    }
  );
} else {
  logger.warn('gRPC disabled, no GRPC_BIND_PORT was defined');
} // if GRPC_BIND_PORT set, it is enabled

// Start of WEB block
const favicon = require('serve-favicon');
const serveStatic = require('serve-static');
const oas3Tools = require('./myoas/'); // here was required the oas3-tools before
const mongoose = require('mongoose');
const auth = require('./utils/auth');
const webFilterOK = require('./utils/webfilter').webFilterOK;
const express = require('express');
const app = express();
app.disable('x-powered-by');
app.didStart = false;

/* add BEHINDPROXY=uniquelocal to .env for private IP detection of proxy */
const behindProxy = process.env.BEHINDPROXY || '';
if (behindProxy === '') {
  logger.warn('Direct serving, no proxy');
} else {
  logger.warn(`Behind proxy ${behindProxy}`);
  app.set('trust proxy', behindProxy);
}

// Prometheus section
const client = require('prom-client');
const defaultLabels = { NODE_APP_INSTANCE: process.env.NODE_APP_INSTANCE };
const Registry = client.Registry;
const register = new Registry();
register.setDefaultLabels(defaultLabels);
const collectDefaultMetrics = client.collectDefaultMetrics;
const appCounter = new client.Counter({
  name: 'bintra_app_requests_counter',
  help: 'all api requests',
  registers: [register]
});

const pj = require('./package.json');
const myversion = pj.version;
const versionCounter = new client.Gauge({
  name: 'bintra_app_version',
  help: 'source code version',
  registers: [register],
  labelNames: ['version']
});
versionCounter.set({ version: myversion }, 1);

collectDefaultMetrics({ prefix: 'bintra_', register });

// next section
const toobusy = require('toobusy-js');
const hpp = require('hpp');
const cors = require('cors');

const swaggerUi = require('swagger-ui-express');
const YAML = require('yamljs');

const pfilter = require('./controllers/pfilter');

const eventEmitter = require('./utils/eventer').em;
eventEmitter.setMaxListeners(0); // temp solution somehow
require('./subscribers/matomo');
require('./subscribers/toot');
require('./subscribers/mqttclient.js');
require('./subscribers/prometheus.js');

const myworker = require('./worker/main');

// Connect to mongo DB
const computeDBurl = require('./conf').computeDBurl;
const mongoUrl = computeDBurl();
logger.debug(`DB used: ${mongoUrl}`);
const db = mongoose.connection;
mongoose.set('strictQuery', true);
db.on('error', console.error.bind(console, 'MongoDB connection error:'));
db.on('connecting', err => { if (err) { logger.error(err); } logger.info('connecting'); });
db.on('connected', err => { if (err) { logger.error(err); } logger.info('DB connected'); });
db.on('open', err => {
  if (err) { logger.error(err); }
  logger.info('DB opened');
  app.emit('dbready');
});
mongoose.connect(mongoUrl, { });

// default CORS domain
const corsWhitelist = ['https://api.bintra.directory', 'https://api.binarytransparency.net', 'https://bintra.directory', 'http://192.168.0.249:8080', 'http://127.0.0.1:8087'];
const corsOptions = {
  methods: ['GET', 'HEAD', 'POST', 'DELETE', 'PUT', 'PATCH'],
  origin: function (origin, callback) {
    if (!(origin)) {
      callback(null, true);
    } else {
      logger.info(`cors check on ${origin}`);
      if (corsWhitelist.indexOf(origin) !== -1) {
        callback(null, true);
      } else {
        callback(new Error('Not allowed by CORS'));
      }
    }
  }
};

app.use(hpp());
app.use(cors(corsOptions));

// Add Sentry to app object
app.use(function (req, res, next) {
  let realip = req.ip;
  if (typeof req.headers['x-real-ip'] !== 'undefined') {
    realip = req.headers['x-real-ip'];
  }

  Sentry.setUser({
    ip_address: realip
  });
  if (typeof req.headers['geoip-country-code'] !== 'undefined') {
    Sentry.setContext('GeoIP', {
      country: req.headers['geoip-country-code'],
      city: req.headers['geoip-city-name'],
      zip: req.headers['geoip-zip'],
      statecode: req.headers['geoip-state-code']
    });
  } // if
  req.sentry = Sentry;
  req.realip = realip;
  next();
});

// Redirect root to docs UI
app.get('/', function doRedir (req, res, next) {
  if (req.url !== '/') {
    next();
  } else {
    res.writeHead(301, {
      Location: '/docs/'
    });
    res.end();
  }
});

toobusy.maxLag(parseInt(process.env.BUSY_LAG) || /* istanbul ignore next */ 70);
toobusy.interval(parseInt(process.env.BUSY_INTERVAL) || /* istanbul ignore next */ 500);
const currentMaxLag = toobusy.maxLag();
const interval = toobusy.interval();
logger.info(`configure toobusy with maxLag=${currentMaxLag}, interval=${interval}`);
app.use(function (req, res, next) {
  if (toobusy()) {
    const error = new Error('Too busy');
    req.sentry.captureException(error);
    res.status(503).send("I'm busy right now, sorry.");
  } else {
    next();
  }
});
toobusy.onLag(function (currentLag) {
  const sTmp = `Event loop lag detected! Latency: ${currentLag} ms`;
  Sentry.captureMessage(sTmp, 'warning');
  logger.warn(sTmp);
});

// filter bad requests early
app.use(function (req, res, next) {
  logger.debug('use webFilter');
  if (!webFilterOK(req)) {
    logger.warn('Skip bad request');
    const error = new Error('Bad request gets filtered out');
    req.sentry.captureException(error);
    res.status(400).send('Bad request');
  } else {
    next();
  }
});

app.get('/feed.(rss|atom|json)', (req, res) => res.redirect('/v1/feed.' + req.params[0]));

app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

app.use(favicon(path.join(__dirname, 'static', 'favicon.ico')));
app.use(serveStatic(path.join(__dirname, 'static')));

// Add some mongoose data to request for later use
app.use(function (req, res, next) {
  req.mcdadmin = mongoose.connection;
  req.appCounter = appCounter;
  next();
});

// The Swagger document (require it, build it programmatically, fetch it from a URL, ...)
const swaggerDocJson = YAML.load(path.join(__dirname, 'api/swagger.yaml'));

const uioptions = {
  customCss: '.swagger-ui .topbar { display: none }',
  customJs: '/matomo.js',
  customSiteTitle: 'Bintra directory API - binarytransparency',
  customfavIcon: '/favicon.ico'
};
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocJson, uioptions));

// swaggerRouter configuration
const options = {
  routing: {
    controllers: path.join(__dirname, 'controllers')
  },
  logging: {
    format: 'combined',
    errorLimit: 400
  },
  theapp: app,
  openApiValidator: {
    validateSecurity: {
      handlers: {
        bearerauth: auth.verifyToken
      }
    },
    validateRequests: true,
    validateResponses: false
  }
};

// No "/v1" pattern, so it is wrong from here on
app.use(/^(?!\/v1).+/, function (req, res) {
  res.status(404);
  res.send('No API call');
  req.sentry.captureMessage('No API call');
});

// Filter all parameters known
app.use(pfilter);

oas3Tools.expressAppConfig(path.join(__dirname, 'api/swagger.yaml'), options);

// Attach Sentry to express app
Sentry.setupExpressErrorHandler(app);

/**
 * Start the server
 */
let server;
app.on('dbready', function () {
  logger.info('db connection seems ready');
  logger.info(`MaxSockets: ${http.globalAgent.maxSockets}`);
  const serverPort = process.env.BIND_PORT;
  const serverHost = process.env.BIND_HOST;
  logger.info(`Bind to ${serverHost}:${serverPort}`);
  server = http.createServer(app).listen(serverPort, serverHost, function () {
    logger.info(`Your server is listening on port ${serverPort} (http://${serverHost}:${serverPort})`);
    logger.info(`Swagger-ui is available on http://${serverHost}:${serverPort}/docs`);
    app.didStart = true;
    app.emit('appStarted');
  });
});

/* istanbul ignore next */
async function workerStop () {
  await myworker.queue.end();
  await myworker.Scheduler.end();
  await myworker.Worker.end();
}

process.on('SIGINT', function () /* istanbul ignore next */ {
  logger.error('SIGINT received, quit');
  app.didStart = false;
  server.close();
  (async () => workerStop())();
  // calling .shutdown allows your process to exit normally
  toobusy.shutdown();
  process.exit();
});

module.exports = {
  app,
  mongoose
};