Source: src/web/proxy.js

// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (web@campbellcrowley.com)
const fs = require('fs');
const http = require('http');
const https = require('https');
const httpProxy = require('http-proxy');
const socketIo = require('socket.io');
const sIOClient = require('socket.io-client');
const querystring = require('querystring');
const auth = require('../../auth.js');
const crypto = require('crypto');
const dateFormat = require('dateformat');

const clientId = '444293534720458753';
const clientSecret = auth.webSecret;
require('../subModule.js').extend(WebProxy);  // Extends the SubModule class.

delete require.cache[require.resolve('./WebUserData.js')];
const WebUserData = require('./WebUserData.js');

const proxyOpts = {
  ws: true,
  xfwd: false,
};

/**
 * @classdesc Proxy for account authentication.
 * @class
 * @augments SubModule
 */
function WebProxy() {
  const self = this;

  /** @inheritdoc */
  this.myName = 'Proxy';

  /**
   * The url to send a received `code` to via `POST` to receive a user's
   * tokens.
   *
   * @private
   * @default
   * @type {{host: string, path: string, protocol: string}}
   * @constant
   */
  const tokenHost = {
    protocol: 'https:',
    host: 'discordapp.com',
    path: '/api/oauth2/token',
    method: 'POST',
    headers: {
      'User-Agent': require('../common.js').ua,
    },
  };
  /**
   * The url to send a request to the discord api.
   *
   * @private
   * @default
   * @type {{host: string, path: string, protocol: string}}
   * @constant
   */
  const apiHost = {
    protocol: 'https:',
    host: 'discordapp.com',
    path: '/api',
    method: 'GET',
    headers: {
      'User-Agent': require('../common.js').ua,
    },
  };

  const pathPorts = {
    '/socket.io/dev/hg/': 8013,
    '/socket.io/hg/': 8011,
    '/socket.io/dev/account/': 8015,
    '/socket.io/account/': 8014,
    '/socket.io/dev/control/': 8021,
    '/socket.io/control/': 8020,
    '/socket.io/master/': 8024,
    '/socket.io/dev/master/': 8025,
    '/dev': 8023,
    '_fallback': 8022,
    '/master/': 8024,
    '/dev/master/': 8025,
  };

  /**
   * The current OAuth2 access information for a single session.
   *
   * @typedef LoginState
   *
   * @property {string} access_token The current token for api requests.
   * @property {string} token_type The type of token (Usually 'Bearer').
   * @property {number} expires_in Number of seconds after the token is
   * authorized at which it becomes invalid.
   * @property {string} refresh_token Token used to refresh the expired
   * access_token.
   * @property {string} scope The scopes that the access_token has access to.
   * @property {number} expires_at The unix timestamp when the access_token
   * expires.
   * @property {number} expiration_date The unix timestamp when we consider the
   * session to have expired, and the session is deleted.
   * @property {string} session The 64 byte base64 string that identifies this
   * session to the client.
   * @property {?Timeout} refreshTimeout The current timeout registered for
   * refreshing the access_token.
   */

  /**
   * Stores the tokens and associated data for all clients connected while data
   * is valid. Mapped by session id.
   *
   * @private
   * @type {object.<LoginState>}
   */
  let loginInfo = {};
  const currentSessions = {};
  /**
   * Cache of requests to the Discord API to reduce duplicate calls and reduce
   * rate limiting. Mapped by user ID and request path. If user ID is unknown,
   * requests are not cached.
   *
   * @private
   * @type {object.<Function[]>}
   */
  const reqCache = {};

  /**
   * File storing website rate limit specifications.
   *
   * @private
   * @type {string}
   */
  const rateLimitFile = './save/webRateLimits.json';

  /**
   * Object storing parsed rate limit info from {@link rateLimitFile}.
   *
   * @private
   * @type {object}
   * @default
   */
  let rateLimits = {
    commands: {
      'restore': 'auth',
      'authorize': 'auth',
    },
    groups: {
      auth: {num: 1, delta: 2},
      global: {num: 2, delta: 2},
    },
  };

  /** @inheritdoc */
  this.initialize = function() {
    if (self.common.isSlave) {
      self.error('Proxy not starting due to this being a slave shard.');
      return;
    }
    app.listen(self.common.isRelease ? 8010 : 8012, '127.0.0.1');
    self.common.connectSQL();
  };

  /**
   * Causes a full shutdown of all servers.
   *
   * @public
   */
  this.shutdown = function() {
    if (io) io.close();
    if (app) app.close();
    clearInterval(purgeInterval);
    fs.unwatchFile(rateLimitFile);
    loginInfo = {};
  };

  /** @inheritdoc */
  this.save = function(opt) {
    const toSave = {};
    for (const i in loginInfo) {
      if (!loginInfo[i]) continue;
      toSave[i] = Object.assign({}, loginInfo[i]);
      if (toSave[i].refreshTimeout) delete toSave[i].refreshTimeout;
    }
    if (opt === 'async') {
      fs.writeFile('./save/webClients.json', JSON.stringify(toSave), (err) => {
        if (!err) return;
        self.error('Failed to write webClients.json');
        console.error(err);
      });
    } else {
      fs.writeFileSync('./save/webClients.json', JSON.stringify(toSave));
    }
  };

  /** @inheritdoc */
  this.unloadable = function() {
    return true;
  };

  /**
   * Parse rate limits from file.
   *
   * @private
   */
  function updateRateLimits() {
    fs.readFile(rateLimitFile, (err, data) => {
      if (err) {
        self.error('Failed to read ' + rateLimitFile);
        return;
      }
      try {
        const parsed = JSON.parse(data);
        if (!parsed) return;
        rateLimits = parsed;
      } catch (e) {
        console.error(e);
      }
    });
  }
  updateRateLimits();
  fs.watchFile(rateLimitFile, {persistent: false}, (curr, prev) => {
    if (curr.mtime == prev.mtime) return;
    if (self.initialized) {
      self.debug('Re-reading rate limits from file');
    } else {
      console.log('WebProxy: Re-reading rate limits from file');
    }
    updateRateLimits();
  });

  // TODO: Move loginInfo into multiple files to prevent all sessions being kept
  // in memory at all times across all shards.
  fs.readFile('./save/webClients.json', (err, data) => {
    if (aborted) return;
    if (err) {
      if (err.code !== 'ENOENT') {
        console.error(err);
      }
      loginInfo = {};
      return;
    }
    try {
      loginInfo = JSON.parse(data);
      self.debug(Object.keys(loginInfo).length + ' sessions loaded from file.');
    } catch (err) {
      console.error('Failed to parse webClients.json', err);
    }
  });
  const purgeInterval = setInterval(purgeSessions, 60 * 60 * 1000);

  /**
   * Purge stale data from loginInfo.
   *
   * @private
   */
  function purgeSessions() {
    const keys = Object.keys(loginInfo);
    const now = Date.now();
    for (const i in keys) {
      if (loginInfo[keys[i]].expirationDate < now) {
        clearTimeout(loginInfo[keys[i]].refreshTimeout);
        delete loginInfo[keys[i]];
      }
    }
  }

  const app = http.createServer(handler);
  const proxy = httpProxy.createProxyServer(proxyOpts);
  const io = socketIo(app, {path: '/socket.io/'});

  let aborted = false;

  app.on('error', function(err) {
    if (err.code === 'EADDRINUSE') {
      aborted = true;
      self.shutdown(true);
      self.debug(
          'Proxy failed to bind to port because it is in use. (' + err.port +
          ')');
    } else {
      self.error('Proxy failed to bind to port for unknown reason.', err);
    }
  });

  /**
   * Handler for all http requests. Proxies all requests to file server.
   *
   * @private
   * @param {http.IncomingMessage} req The client's request.
   * @param {http.ServerResponse} res Our response to the client.
   */
  function handler(req, res) {
    if (pathPorts[req.url]) {
      proxy.web(req, res, {target: `http://localhost:${pathPorts[req.url]}`});
    } else if (req.url.match(/^\/(www|kamino).spikeybot.com\/dev/)) {
      proxy.web(req, res, {target: `http://localhost:${pathPorts['/dev']}`});
    } else {
      proxy.web(req, res, {target: `http://localhost:${pathPorts._fallback}`});
    }
  }

  /**
   * Map of all currently connected sockets.
   *
   * @private
   * @type {object.<Socket>}
   */
  const sockets = {};

  io.on('connection', socketConnection);
  /**
   * Handler for a new socket connecting.
   *
   * @private
   * @param {socketIo~Socket} socket The socket.io socket that connected.
   */
  function socketConnection(socket) {
    // x-forwarded-for is trusted because the last process this jumps through is
    // our local proxy.
    const ipName = self.common.getIPName(
        socket.handshake.headers['x-forwarded-for'] ||
        socket.handshake.address);

    const reqPath = socket.handshake.url.split('?')[0];

    /**
     * @description User data to inject alongside requests. Null if user isn't
     * signed in yet, or is generally unknown.
     * @private
     * @type {?WebUserData}
     * @default
     */
    let userData = null;
    let session;
    do {
      session = crypto.randomBytes(64).toString('base64');
    } while (loginInfo[session]);
    let restoreAttempt = false;
    /**
     * A number representing how abusive the client is being. This is the
     * previous calculated value.
     *
     * At different levels we will react to messages differently.
     * Level 0: All requests will be handled normally.
     * Level 1: Requests will be handled normally, with an additional warning.
     * Level 2: All requests will receive a http 429 equivalent reply.
     * Level 3: All requests are ignored and no response will be provided.
     * Level 4: The connection will be closed immediately.
     *
     * @private
     * @type {number}
     * @default
     */
    let rateLevel = 0;
    /**
     * All requests from the client that are still relevant to a rate limit
     * group.
     *
     * @private
     * @type {Array.<{time: number, cmd: string}>}
     */
    const history = [];
    /**
     * The historic quantities for each rate limit group.
     *
     * @private
     * @type {object.<number>}
     */
    const rateHistory = {};

    self.common.logDebug(
        'Socket connected (' + Object.keys(sockets).length + '): ' + reqPath +
            ' ' + ipName,
        socket.id);
    if (!pathPorts[reqPath]) {
      self.common.error(
          'Client requested unknown endpoint: ' + reqPath, socket.id);
      socket.disconnect();
      return;
    }
    sockets[socket.id] = socket;
    const server = sIOClient('http://localhost:' + pathPorts[reqPath], {
      path: reqPath,
      extraHeaders:
          {'x-forwarded-for': socket.handshake.headers['x-forwarded-for']},
    });

    // Add custom semi-wildcard listeners.
    const sonevent = server.onevent;
    server.onevent = function(packet) {
      const args = packet.data || [];
      if (server.listeners(args[0]).length) {
        sonevent.call(this, packet);
      } else {
        packet.data = ['*'].concat(args);
        sonevent.call(this, packet);
      }
    };
    server.on('connect', () => {
      socket.on('*', (...args) => {
        server.emit(
            ...[args[0], userData && userData.serializable].concat(
                args.slice(1)));
      });
    });
    server.on('*', (...args) => {
      socket.emit(...args);
    });
    server.on('disconnect', () => {
      socket.disconnect();
    });
    const onevent = socket.onevent;
    socket.onevent = function(packet) {
      const args = packet.data || [];
      rateLevel = updateRateLevel(args[0]);
      if (rateLevel > 1) return;
      if (socket.listenerCount(args[0])) {
        onevent.call(this, packet);
      } else {
        packet.data = ['*'].concat(args);
        onevent.call(this, packet);
      }
    };

    socket.on('restore', (sess) => {
      if (restoreAttempt /* || currentSessions[sess]*/) {
        socket.emit('authorized', 'Restore Failed', null);
        // console.error(restoreAttempt, sess);
        return;
      }
      currentSessions[sess] = true;
      restoreAttempt = true;
      if (loginInfo[sess]) {
        session = sess;
        // Refresh the token if it has expired, or is close to expiring (within
        // 24 hours).
        if (loginInfo[session].expires_at - 1 * 24 * 60 * 60 * 1000 <
            Date.now()) {
          const info = loginInfo[session];
          refreshToken(info.refresh_token, info.scope, (err, data) => {
            if (!err) {
              let parsed;
              try {
                parsed = JSON.parse(data);
                self.log('Refreshed token');
              } catch (err) {
                self.error(
                    'Failed to parse request from discord token refresh: ' +
                    err);
                console.error('Parsing failed', sess);
                socket.emit('authorized', 'Restore Failed', null);
                return;
              }
              receivedLoginInfo(parsed);
              fetchIdentity(loginInfo[session], (identity) => {
                userData = identity;
                if (userData) {
                  socket.emit('authorized', null, userData.serializable);
                  self.common.logDebug('Authorized ' + userData.id, socket.id);
                } else {
                  socket.emit('authorized', 'Getting user data failed', null);
                  self.common.logWarning('Failed to authorize', socket.id);
                  logout();
                }
              });
            } else {
              self.warn('Refreshing token failed');
              console.error(err, loginInfo[session]);
              socket.emit('authorized', 'Restore Failed', null);
            }
          });
        } else {
          fetchIdentity(loginInfo[session], (identity) => {
            userData = identity;
            if (userData) {
              socket.emit('authorized', null, userData.serializable);
              self.common.logDebug('Authorized ' + userData.id, socket.id);
            } else {
              socket.emit('authorized', 'Getting user data failed', null);
              self.common.logWarning('Failed to fetch identity', socket.id);
              logout();
            }
          });
        }
      } else {
        self.common.logWarning('Nothing to restore ' + sess, socket.id);
        socket.emit('authorized', 'Restore Failed', null);
      }
    });

    socket.on('authorize', (code) => {
      currentSessions[session] = true;
      authorizeRequest(code, (err, res) => {
        if (err) {
          socket.emit('authorized', 'Failed to authorize', null);
          console.error(err);
          logout();
        } else {
          receivedLoginInfo(JSON.parse(res));
          fetchIdentity(loginInfo[session], (identity) => {
            userData = identity;
            socket.emit('authorized', null, userData && userData.serializable);
            if (userData) {
              self.common.logDebug('Authorized ' + userData.id, socket.id);
            } else {
              self.common.logWarning('Failed to authorize', socket.id);
              logout();
            }
          });
        }
      });
    });

    socket.on('logout', logout);

    socket.on('disconnect', () => {
      self.common.logDebug(
          'Socket disconnected (' + (Object.keys(sockets).length - 1) + '): ' +
              ipName,
          socket.id);
      if (loginInfo[session]) clearTimeout(loginInfo[session].refreshTimeout);
      delete sockets[socket.id];
      if (server) server.close();
    });

    /**
     * Cause the current user session to logout.
     *
     * @private
     */
    function logout() {
      if (loginInfo[session]) {
        clearTimeout(loginInfo[session].refreshTimeout);
        const token = loginInfo[session].refresh_token;
        delete loginInfo[session];
        revokeToken(token, (err) => {
          delete currentSessions[session];
          if (err) {
            self.warn(
                'Failed to revoke refresh token, but user has already ' +
                    'signed out: ' + err,
                socket.id);
          }
        });
      } else {
        delete currentSessions[session];
      }
      socket.disconnect();
    }

    /**
     * Received the login credentials for user, lets store it for this
     * session, and refresh the tokens when necessary.
     *
     * @private
     * @param {object} data User data.
     */
    function receivedLoginInfo(data) {
      if (data) {
        data.expires_at = data.expires_in * 1000 + Date.now();
        data.expiration_date = Date.now() + (1000 * 60 * 60 * 24 * 30);
        data.session = session;
        if (loginInfo[session] && loginInfo[session].refresh_token &&
            !data.refresh_token) {
          self.debug(
              'New oauth data does not contain refresh token, but loginInfo ' +
              'still contains a refresh token.');
        }
        loginInfo[session] = Object.assign(loginInfo[session] || {}, data);
        if (!loginInfo[session].refresh_token) {
          self.debug('loginInfo did not have a refresh token.');
        }
        makeRefreshTimeout(loginInfo[session], receivedLoginInfo);
      }
    }

    /**
     * Check if this current connection or user is being rate limited.
     *
     * @see {@link rateLevel}
     *
     * Level 0: <75% of limit.
     * Level 1: >75% <100%
     * Level 2: >100% <125%
     * Level 3: >125% <200%
     * Level 4: >200%
     *
     * @private
     * @param {string} [cmd] The command being attempted. Otherwise uses global
     * rate limits.
     * @returns {number} Current rate level for the given command.
     */
    function updateRateLevel(cmd) {
      const now = Date.now();
      const group = rateLimits.commands[cmd] || 'global';
      if (!rateHistory[group]) rateHistory[group] = 0;
      rateHistory[group]++;
      for (let i = 0; i < history.length; i++) {
        const group = rateLimits.commands[history[i].cmd] || 'global';
        const limits = rateLimits.groups[group] || rateLimits.groups['global'];
        if (now - history[i].time > limits.delta * 1000) {
          rateHistory[group]--;
          history.splice(i, 1);
          i--;
        }
      }
      history.push({time: now, cmd: cmd});
      const limit = rateLimits.groups[group].num;
      const percent = rateHistory[group] / limit;
      if (percent <= 0.75) {
        return 0;
      } else if (percent <= 1) {
        socket.emit('rateLimit', {
          limit: limit,
          current: rateHistory[group],
          request: cmd,
          group: group,
          level: 1,
        });
        return 1;
      } else if (percent <= 1.25) {
        socket.emit('rateLimit', {
          limit: limit,
          current: rateHistory[group],
          request: cmd,
          group: group,
          level: 2,
        });
        return 2;
      } else if (percent <= 2) {
        return 3;
      } else {
        logout();
        return 4;
      }
    }
  }
  /**
   * Fetches the identity of the user we have the token of.
   *
   * @private
   * @param {LoginInfo} loginInfo The credentials of the session user.
   * @param {singleCB} cb The callback storing the user's data, or null if
   * something went wrong.
   */
  function fetchIdentity(loginInfo, cb) {
    apiRequest(loginInfo, '/users/@me', (err, data) => {
      if (!err) {
        const ud = WebUserData.from(JSON.parse(data));
        ud.setSession(loginInfo.session, loginInfo.expiration_date);

        const now = dateFormat(new Date(), 'yyyy-mm-dd HH:MM:ss');
        const toSend = global.sqlCon.format(
            'INSERT INTO Discord (id) values (?) ON DUPLICATE KEY UPDATE ?',
            [ud.id, {lastLogin: now}]);
        global.sqlCon.query(toSend, (err) => err && self.error(err));
        loginInfo.userId = ud.id;
        const afterPatreon = function() {
          if (loginInfo.scope && loginInfo.scope.indexOf('guilds') > -1) {
            fetchGuilds(loginInfo, (data) => {
              if (data) ud.setGuilds(data);
              cb(ud);
            });
          } else {
            cb(ud);
          }
        };
        if (!self.bot.patreon) {
          afterPatreon();
          return;
        }
        self.bot.patreon.getAllPerms(ud.id, null, null, (err, info) => {
          if (err) {
            if (err !==
                'User has not connected their Patreon account ' +
                    'to their Discord account.') {
              self.error(err);
            }
            loginInfo.isPatron = null;
          } else if (info && info.status) {
            loginInfo.isPatron = true;
            ud.patreonStatus = info.status;
          } else {
            loginInfo.isPatron = false;
          }
          ud.isPatron = loginInfo.isPatron;
          afterPatreon();
        });
      } else {
        cb(null);
      }
    });
  }
  /**
   * Fetches the guild information of the user we have the token of.
   *
   * @private
   * @param {LoginInfo} loginInfo The credentials of the session user.
   * @param {singleCB} cb The callback storing the user's data, or null if
   * something went wrong.
   */
  function fetchGuilds(loginInfo, cb) {
    apiRequest(loginInfo, '/users/@me/guilds', (err, data) => {
      if (!err) {
        const parsed = JSON.parse(data);
        cb(parsed);
      } else {
        cb(null);
      }
    });
  }

  /**
   * Formats a request to the discord api at the given path.
   *
   * @private
   * @param {LoginInfo} loginInfo The credentials of the user we are sending the
   * request for.
   * @param {string} path The path for the api request to send.
   * @param {basicCallback} cb The response from the https request with error
   * and data arguments.
   */
  function apiRequest(loginInfo, path, cb) {
    const reqId = `${loginInfo.userId}${path}`;
    if (reqCache[reqId]) {
      reqCache[reqId].push(cb);
      if (reqId) return;
    } else {
      reqCache[reqId] = [cb];
    }
    const host = apiHost;
    host.path = `/api${path}`;
    host.headers = {
      'Authorization': `${loginInfo.token_type} ${loginInfo.access_token}`,
      'User-Agent': self.common.ua,
    };
    discordRequest('', (err, res) => {
      const split = reqCache[reqId].splice(0);
      for (const it of split) {
        try {
          it(err, res);
        } catch (err) {
          self.error('Discord API request callback failed');
          console.error(err);
        }
      }
      if (reqCache[reqId].length === 0) delete reqCache[reqId];
    }, host);
  }

  /**
   * Send a https request to discord.
   *
   * @private
   * @param {?object|string} data The data to send in the request.
   * @param {basicCallback} cb Callback with error, and data arguments.
   * @param {?object} host Request object to override the default with.
   */
  function discordRequest(data, cb, host) {
    host = host || tokenHost;
    const req = https.request(host, (response) => {
      let content = '';
      response.on('data', (chunk) => content += chunk);
      response.on('end', () => {
        if (response.statusCode == 200) {
          cb(null, content);
        } else {
          self.error(response.statusCode + ': ' + content);
          console.error(host, data);
          cb(response.statusCode + ' from discord');
        }
      });
    });
    req.setHeader('Content-Type', 'application/x-www-form-urlencoded');
    if (data) {
      req.end(querystring.stringify(data));
    } else {
      req.end();
    }
    req.on('error', console.error);
  }

  /**
   * Refreshes the given token once it expires.
   *
   * @private
   * @param {LoginInfo} loginInfo The credentials to refresh.
   * @param {singleCB} cb The callback that is fired storing the new credentials
   * once they are refreshed.
   */
  function makeRefreshTimeout(loginInfo, cb) {
    clearTimeout(loginInfo.refreshTimeout);
    const maxDelay = 2 * 7 * 24 * 60 * 60 * 1000;
    const delay = loginInfo.expires_at - Date.now();
    if (delay > maxDelay) {
      loginInfo.refreshTimeout = setTimeout(function() {
        makeRefreshTimeout(loginInfo, cb);
      }, maxDelay);
    } else {
      loginInfo.refreshTimeout = setTimeout(function() {
        self.debug('Refreshing token for session: ' + loginInfo.session);
        refreshToken(loginInfo.refresh_token, loginInfo.scope, (err, data) => {
          let parsed;
          if (!err) {
            try {
              parsed = JSON.parse(data);
            } catch (err) {
              self.error(
                  'Failed to parse request from discord token refresh: ' + err);
            }
          }
          cb(parsed);
        });
      }, delay);
    }
  }

  /**
   * Request new credentials with refresh token from discord.
   *
   * @private
   * @param {string} refreshToken_ The refresh token used for refreshing
   * credentials.
   * @param {string} scope Scope to refresh.
   * @param {basicCallback} cb The callback from the https request, with an
   * error argument, and a data argument.
   */
  function refreshToken(refreshToken_, scope, cb) {
    const data = {
      client_id: clientId,
      client_secret: clientSecret,
      grant_type: 'refresh_token',
      refresh_token: refreshToken_,
      redirect_uri: 'https://www.spikeybot.com/redirect',
      scope: scope,
    };
    discordRequest(data, cb);
  }

  /**
   * Revoke a current refresh token from discord.
   *
   * @private
   * @param {string} token The refresh token to revoke.
   * @param {basicCallback} cb The callback from the https request, with an
   * error argument, and a data argument.
   */
  function revokeToken(token, cb) {
    const host = Object.assign({}, tokenHost);
    host.path += '/revoke';
    const data = {
      client_id: clientId,
      client_secret: clientSecret,
      token_type_hint: 'refresh_token',
      token: token,
    };
    discordRequest(data, cb, host);
  }

  /**
   * Authenticate with the discord server using a login code.
   *
   * @private
   * @param {string} code The login code received from our client.
   * @param {basicCallback} cb The response from the https request with error
   * and data arguments.
   */
  function authorizeRequest(code, cb) {
    const data = {
      client_id: clientId,
      client_secret: clientSecret,
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: 'https://www.spikeybot.com/redirect',
    };
    discordRequest(data, cb);
  }
}

module.exports = new WebProxy();