Source: src/web/fileServer.js

// Copyright 2019-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (web@campbellcrowley.com)
const fs = require('fs');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const SubModule = require('../subModule.js');

const forbidden = [
  path.resolve(__dirname + '/../../auth.js'),
  path.resolve(__dirname + '/../../gApiCredentials.json'),
  path.resolve(__dirname + '/../../save/patreonCreatorTokens.json'),
];

const ccMaxAge = 30 * 7 * 24 * 60 * 60; // 30 days

/**
 * @description Serves files from web requests.
 * @class
 * @augments SubModule
 */
class FileServer extends SubModule {
  /**
   * @description Serves files from web requests.
   */
  constructor() {
    super();
    /** @inheritdoc **/
    this.myName = 'FileServer';
  }

  /** @inheritdoc */
  initialize() {
    this._app = http.createServer((...args) => this._handler(...args));
    this._app.on('error', (err) => {
      if (err.code === 'EADDRINUSE') {
        this.debug('Port already in use. Shutting down.');
        return;
      }
      this.error('A webserver error occurred!');
      console.error(err);
    });
    const port = this.common.isRelease ? 8022 : 8023;
    this._app.listen(port, '127.0.0.1');
    this.debug('Starting file server on port: ' + port);
  }
  /** @inheritdoc */
  shutdown() {
    if (this._app) this._app.close();
  }

  /**
   * @description Handles all http requests.
   *
   * @private
   * @param {http.IncomingMessage} req The client's request.
   * @param {http.ServerResponse} res Our response to the client.
   */
  _handler(req, res) {
    const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress ||
        'ERROR';
    const url = req.url.replace(/^\/(www|kamino).spikeybot.com(?:\/dev)?/, '');
    const rootIntent = (url.startsWith('/avatars/') && './save/users') ||
        (url.startsWith('/hg/events/') && './save') || null;
    if (rootIntent && req.method === 'GET') {
      const file = path.resolve(`${rootIntent}${url}`);
      if (!this._isPathAcceptable(file)) {
        this.common.logDebug(`Unacceptable path ${url}`, ip);
        res.writeHead(403);
        res.end();
        return;
      }
      fs.stat(file, (err, stats) => {
        if (err) {
          if (err.code === 'ENOENT') {
            this.common.logDebug(`File not found ${file}`, ip);
            res.writeHead(404);
            res.end();
            return;
          }
          res.writeHead(500);
          res.end('500 Internal Server Error (FS)');
          this.common.error(`Failed to stat file: ${file}`, ip);
          console.log(err);
          return;
        }

        const ETag = crypto.createHash('sha1')
            .update(`ETAG-${file}${stats.mtime.getTime()}`)
            .digest('hex');
        res.setHeader('ETag', ETag);
        if (req.headers['etag'] === ETag) {
          res.writeHead(
              304, {'Cache-Control': `max-age=${ccMaxAge}`, 'ETag': ETag});
          res.end();
          return;
        }

        const mimeTypes = {
          '.png': 'image/png',
          '.jpg': 'image/jpg',
          '.gif': 'image/gif',
          '.json': 'application/json',
        };
        const ext = path.extname(file);
        const mime = mimeTypes[ext];

        if (!mime) {
          res.writeHead(404);
          res.end();
          this.common.logDebug(`404 (Bad MIME) ${req.method} ${url}`, ip);
        } else {
          res.writeHead(200, {
            'Content-Type': mime,
            'Content-Length': stats.size,
          });
          fs.createReadStream(file).pipe(res);
        }
      });
    } else {
      res.writeHead(404);
      res.end();
      this.debug(`404 ${req.method} ${url}`, ip);
    }
  }

  /**
   * @description Check if given path to a file is allowed to be served to a
   * client request.
   * @private
   * @param {string} file The filename to check.
   * @returns {boolean} If the filename is allowed.
   */
  _isPathAcceptable(file) {
    file = path.resolve(file);
    const proj = path.resolve(__dirname + '/../../');
    if (forbidden.includes(proj)) return false;
    return file.startsWith(proj);
  }
}

module.exports = new FileServer();