Source: web/account.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 socketIo = require('socket.io');
const auth = require('../../auth.js');
const patreon = require('patreon');
const mkdirp = require('mkdirp'); // mkdir -p
const querystring = require('querystring');
const crypto = require('crypto');

const PATREON_CLIENT_ID = auth.patreonClientId;
const PATREON_CLIENT_SECRET = auth.patreonClientSecret;
const redirectURL = 'https://www.spikeybot.com/redirect/';

const patreonAPI = patreon.patreon;
const patreonOAuthClient =
    patreon.oauth(PATREON_CLIENT_ID, PATREON_CLIENT_SECRET);

require('../subModule.js').extend(WebAccount);  // Extends the SubModule class.

/**
 * @classdesc Manages the account webpage.
 * @class
 * @augments SubModule
 */
function WebAccount() {
  const self = this;
  this.myName = 'WebAccount';

  let app;
  let io;
  if (!self.common.isSlave) {
    app = http.createServer(handler);
    io = socketIo(app, {path: '/socket.io/'});
    io.on('connection', socketConnection);

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

  /**
   * The filename in the user's directory of the file where the settings related
   * to Patreon rewards are stored.
   *
   * @private
   * @constant
   * @default
   * @type {string}
   */
  const patreonSettingsFilename = '/patreonSettings.json';
  /**
   * File where the template for the Patreon settings is stored.
   *
   * @see {@link WebAccount~patreonSettingsTemplate}
   *
   * @private
   * @constant
   * @default
   * @type {string}
   */
  const patreonSettingsTemplateFile = './save/patreonSettingTemplate.json';
  /**
   * The parsed data from {@link WebAccount~patreonSettingsTemplateFile}. Data
   * that outlines the available options that can be changed, and their possible
   * values.
   *
   * @private
   *
   * @default
   * @type {object.<object>}
   */
  let patreonSettingsTemplate = {};

  const defaultSpotifyTokenReq = {
    protocol: 'https:',
    host: 'accounts.spotify.com',
    path: '/api/token',
    method: 'POST',
    headers: {
      'Authorization':
          'Basic ' + (Buffer.from(auth.spotifyId + ':' + auth.spotifySecret)
              .toString('base64')),
      'Content-Type': 'application/x-www-form-urlencoded',
      'User-Agent': require('../common.js').ua,
    },
  };

  const defaultSpotifyUserReq = {
    protocol: 'https:',
    host: 'api.spotify.com',
    path: '/v1/me',
    method: 'GET',
    headers: {
      'User-Agent': require('../common.js').ua,
    },
  };

  /**
   * Parse template from file.
   *
   * @see {@link WebAccount~patreonSettingsTemplate}
   * @private
   */
  function updatePatreonSettingsTemplate() {
    fs.readFile(patreonSettingsTemplateFile, (err, data) => {
      if (err) {
        self.error('Failed to read ' + patreonSettingsTemplateFile);
        return;
      }
      try {
        const parsed = JSON.parse(data);
        if (!parsed) return;
        patreonSettingsTemplate = parsed;
      } catch (e) {
        self.error('Failed to parse ' + patreonSettingsTemplateFile);
        console.error(e);
      }
    });
  }
  updatePatreonSettingsTemplate();
  fs.watchFile(
      patreonSettingsTemplateFile, {persistent: false}, (curr, prev) => {
        if (curr.mtime == prev.mtime) return;
        if (self.initialized) {
          self.log('Re-reading Patreon setting template information from file');
        } else {
          console.log(
              'WebAccount: Re-reading setting template information from file');
        }
        updatePatreonSettingsTemplate();
      });


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

  const toExport = {};

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

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

  /**
   * Handler for all http requests. Should never be called.
   *
   * @private
   * @param {http.IncomingMessage} req The client's request.
   * @param {http.ServerResponse} res Our response to the client.
   */
  function handler(req, res) {
    res.writeHead(418);
    res.end('TEAPOT');
  }

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

  /**
   * 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);

    self.common.log(
        'Socket connected (' + Object.keys(sockets).length + '): ' + ipName,
        socket.id);
    sockets[socket.id] = socket;

    socket.on('getAccountInfo', (userData, cb) => {
      if (typeof cb !== 'function') {
        self.error('NO CB');
        return;
      }
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      fetchDiscordSQL();
      /**
       * Fetch the Discord table data from our SQL server.
       *
       * @private
       */
      function fetchDiscordSQL() {
        const toSend = global.sqlCon.format(
            'SELECT * FROM Discord WHERE id=? LIMIT 1', [userData.id]);
        global.sqlCon.query(toSend, (err, rows) => {
          if (err) {
            self.error(err);
            cb('Server Error', null);
            return;
          }
          fetchPatreonSQL((rows && rows[0]) || {});
        });
      }
      /**
       * Fetch the Patreon info from our SQL server.
       *
       * @private
       *
       * @param {object} data The data previously received to add the Patreon
       * info onto.
       */
      function fetchPatreonSQL(data) {
        if (!data.patreonId) {
          fetchSpotifySQL(data);
          return;
        }
        const toSend = global.sqlCon.format(
            'SELECT * FROM Patreon WHERE id=? LIMIT 1', [data.patreonId]);
        global.sqlCon.query(toSend, (err, rows) => {
          if (err) {
            self.error(err);
            cb('Server Error', null);
            return;
          }
          if (rows && rows.length > 0) {
            data.patreon = rows[0];
          }
          fetchSpotifySQL(data);
        });
      }
      /**
       * Fetch the Spotify info from our SQL server.
       *
       * @private
       *
       * @param {object} data The data previously received to add the Spotify
       * info onto.
       */
      function fetchSpotifySQL(data) {
        if (!data.spotifyId) {
          fetchDiscordBot(data);
          return;
        }
        const toSend = global.sqlCon.format(
            'SELECT * FROM Spotify WHERE id=? LIMIT 1', [data.spotifyId]);
        global.sqlCon.query(toSend, (err, rows) => {
          if (err) {
            self.error(err);
            cb('Server Error', null);
            return;
          }
          if (rows && rows.length > 0) {
            data.spotify = {
              id: rows[0].id,
              haveToken: rows[0].access_token !== null,
              name: rows[0].name,
            };
          }
          fetchDiscordBot(data);
        });
      }
      /**
       * Fetch the Discord user information through the Discord bot API.
       *
       * @private
       *
       * @param {object} data The data previously received to add the Discord
       * user info onto, then send to the client.
       */
      function fetchDiscordBot(data) {
        const onData = (user) => {
          data.username = user.username;
          data.avatarURL = user.displayAvatarURL({dynamic: true});
          data.createdAt = user.createdAt;
          data.discriminator = user.discriminator;
          data.activity = user.presence.activity;
          cb(null, data);
        };
        const onError = (err) => {
          cb('Server Error', null);
          self.error('Failed to fetch user data from discord.');
          console.error(err);
        };
        if (self.common.isMaster) {
          self.client.shard
              .broadcastEval(`this.users.resolve('${userData.id}')`)
              .then(
                  (res) => onData(
                      new self.Discord.User(self.client, res.find((el) => el))))
              .catch(onError);
        } else {
          self.client.users.fetch(userData.id)
              .then(onData)
              .catch(onError);
        }
      }
    });

    socket.on('linkPatreon', (userData, code, cb) => {
      if (typeof cb !== 'function') cb = function() {};
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      validatePatreonCode(code, userData.id, socket.id, cb);
    });
    socket.on('unlinkPatreon', (userData, cb) => {
      if (typeof cb !== 'function') cb = function() {};
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      updateUserPatreonId(userData.id, null, cb);
    });
    socket.on('linkSpotify', (userData, code, cb) => {
      if (typeof cb !== 'function') cb = function() {};
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      validateSpotifyCode(code, userData.id, socket.id, cb);
    });
    socket.on('unlinkSpotify', (userData, cb) => {
      if (typeof cb !== 'function') cb = function() {};
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      updateUserSpotifyId(userData.id, null, cb);
    });
    socket.on('getSettingsTemplate', (userData, cb) => {
      if (typeof cb !== 'function') {
        self.error('Requested setting template without callback.', socket.id);
        return;
      }
      cb(patreonSettingsTemplate);
    });
    socket.on('getUserSettings', (userData, cb) => {
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      getPatreonSettings(userData.id, cb);
    });
    socket.on('getUserPerms', (userData, cb) => {
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      if (!self.bot.patreon) {
        self.error('Patreon submodule has not been loaded!');
        cb('Internal Error', null);
      } else {
        self.bot.patreon.getAllPerms(userData.id, null, null, cb);
      }
    });
    socket.on('changeSetting', (userData, setting, value, cb) => {
      if (typeof cb !== 'function') cb = function() {};
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      changePatreonSetting(userData.id, setting, value, cb);
    });
    socket.on('fetchApiToken', (userData, cb) => {
      if (typeof cb !== 'function') return;
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      const toSend = global.sqlCon.format(
          'SELECT apiToken FROM Discord WHERE id=?', [userData.id]);
      global.sqlCon.query(toSend, (err, rows) => {
        if (err) {
          self.error('Failed to fetch apiToken from database.');
          console.error(err);
          cb('Internal Server Error');
          return;
        }
        cb(null, rows && rows[0] && rows[0].apiToken);
      });
    });
    socket.on('resetApiToken', (userData, cb) => {
      if (typeof cb !== 'function') cb = function() {};
      if (!userData) {
        cb('Not signed in.', null);
        return;
      }
      const token = crypto.randomBytes(128).toString('base64');
      const toSend = global.sqlCon.format(
          'UPDATE Discord SET apiToken=? WHERE id=?', [token, userData.id]);
      global.sqlCon.query(toSend, (err) => {
        if (err) {
          self.error('Failed to reset apiToken in database.');
          console.error(err);
          cb('Internal Server Error');
          return;
        }
        cb(null, token);
      });
    });

    socket.on('disconnect', () => {
      self.common.log(
          'Socket disconnected (' + (Object.keys(sockets).length - 1) + '): ' +
              ipName,
          socket.id);
      delete sockets[socket.id];
    });
  }
  /**
   * @description Validate a code received from the client, then use it to
   * retrieve the user ID associated with it.
   *
   * @private
   * @param {string} code The code received from Patreon OAuth2 flow.
   * @param {string|number} userid The Discord user ID associated with this code
   * in order to link accounts.
   * @param {string} ip The unique identifier for this connection for logging
   * purposes.
   * @param {Function} cb Callback with a single parameter. The parameter is a
   * string if there was an error, or null if no error.
   */
  function validatePatreonCode(code, userid, ip, cb) {
    patreonOAuthClient.getTokens(code, redirectURL)
        .then(function(tokensResponse) {
          const patreonAPIClient = patreonAPI(tokensResponse.access_token);
          return patreonAPIClient('/current_user');
        })
        .then(function(result) {
          const store = result.store;
          const users = store.findAll('user').map((user) => user.serialize());
          if (!users || users.length < 1 || !users[0].data ||
              !users[0].data.id) {
            self.common.error('Failed to get patreonid', ip);
            cb('Internal Server Error');
            return;
          }
          updateUserPatreonId(userid, users[0].data.id, cb);
        })
        .catch(function(err) {
          self.common.error('Failed to get patreonId');
          console.error(err);
          cb('Internal Server Error');
        });
  }
  /**
   * @description Validate a code received from the client, then use it to
   * retrieve the user ID associated with it.
   *
   * @private
   * @param {string} code The code received from Patreon OAuth2 flow.
   * @param {string|number} userid The Discord user ID associated with this code
   * in order to link accounts.
   * @param {string} ip The unique identifier for this connection for logging
   * purposes.
   * @param {Function} cb Callback with a single parameter. The parameter is a
   * string if there was an error, or null if no error.
   */
  function validateSpotifyCode(code, userid, ip, cb) {
    const req = https.request(defaultSpotifyTokenReq, (res) => {
      let content = '';
      res.on('data', (chunk) => {
        content += chunk;
      });
      res.on('end', () => {
        if (res.statusCode == 200) {
          handleSpotifyTokenResponse(userid, content, ip, cb);
        } else {
          self.common.error(content, ip);
          cb('Internal Server Error');
          return;
        }
      });
    });
    req.end(querystring.stringify({
      code: code,
      redirect_uri: redirectURL,
      grant_type: 'authorization_code',
    }));
  }

  /**
   * Handle the response after successfully requesting the user's tokens.
   *
   * @private
   *
   * @param {string|number} userid Discord user id.
   * @param {string} content The response from Spotify.
   * @param {string} ip Unique identifier for the client that caused this to
   * happen. Used for logging.
   * @param {Function} cb Callback with single parameter, string if error, null
   * if no error.
   */
  function handleSpotifyTokenResponse(userid, content, ip, cb) {
    let parsed;
    try {
      parsed = JSON.parse(content);
    } catch (err) {
      cb('Internal Server Error');
      self.common.error('Failed to parse token response from Spotify.', ip);
      console.error(err);
      return;
    }
    const vals = {
      accessToken: parsed.access_token,
      expiresIn: parsed.expires_in,
      expiresAt: dateToSQL(Date.now() + parsed.expires_in * 1000),
    };
    if (parsed.refresh_token) {
      vals.refreshToken = parsed.refresh_token;
    }
    const req = https.request(defaultSpotifyUserReq, (res) => {
      let content = '';
      res.on('data', (chunk) => {
        content += chunk;
      });
      res.on('end', () => {
        if (res.statusCode == 200) {
          handleSpotifyUserResponse(userid, content, vals, ip, cb);
        } else {
          self.common.error(content, ip);
          cb('Internal Server Error');
          return;
        }
      });
    });
    req.setHeader('Authorization', 'Bearer ' + vals.accessToken);
    req.end();
  }

  /**
   * @description Handle the response after successfully requesting the user's
   * basic account information.
   * @private
   *
   * @param {string|number} userid Discord user id.
   * @param {string} content The response from Spotify.
   * @param {{accessToken: string, expiresIn: number, expiresAt: string,
   * refreshToken: string}} vals The object storing user session information.
   * @param {string} ip Unique identifier for the client that caused this to
   * happen. Used for logging.
   * @param {Function} cb Callback with single parameter, string if error, null
   * if no error.
   */
  function handleSpotifyUserResponse(userid, content, vals, ip, cb) {
    let parsed;
    try {
      parsed = JSON.parse(content);
    } catch (err) {
      self.common.error('Failed to parse user response from Spotify.', ip);
      console.error(err);
      cb('Internal Server Error');
      return;
    }
    vals.id = parsed.id;
    vals.name = parsed.display_name;
    const toSend = global.sqlCon.format(
        'INSERT INTO Spotify (id,name,accessToken,refreshToken,tokenExpiresAt' +
            ') VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE accessToken=?,token' +
            'ExpiresAt=?',
        [
          vals.id,
          vals.name,
          vals.accessToken,
          vals.refreshToken,
          vals.expiresAt,
          vals.accessToken,
          vals.expiresAt,
        ]);
    global.sqlCon.query(toSend, (err) => {
      if (err) {
        self.common.error('Failed to update Spotify table with user data.', ip);
        console.error(err);
        cb('Internal Server Error');
        return;
      }
      updateUserSpotifyId(userid, vals.id, cb);
    });
  }
  /**
   * @description Update our Discord table with the retrieved patreon account ID
   * for the Discord user.
   *
   * @private
   * @param {string|number} userid The Discord ID of the user to link to the
   * patreonid.
   * @param {string|number} patreonid The Patreon id of the account to link to
   * the Discord ID.
   * @param {Function} cb Callback with single argument that is string if error,
   * or null if no error.
   */
  function updateUserPatreonId(userid, patreonid, cb) {
    const toSend = global.sqlCon.format(
        'UPDATE Discord SET patreonId=? WHERE id=?', [patreonid, userid]);
    global.sqlCon.query(toSend, (err) => {
      if (err) {
        self.common.error('Failed to update patreonId in Discord table.');
        console.log(err);
        cb('Internal Server Error');
      } else {
        cb(null);
      }
    });
  }
  /**
   * @description Update our Discord table with the retrieved spotify account ID
   * for the Discord user. Deletes row from Spotify table if the userId is
   * falsey.
   *
   * @private
   * @param {string|number} userid The Discord ID of the user to link to the
   * patreonid.
   * @param {string|number} spotifyid The Spotify id of the account to link to
   * the Discord ID.
   * @param {Function} cb Callback with single argument that is string if error,
   * or null if no error.
   */
  function updateUserSpotifyId(userid, spotifyid, cb) {
    if (!spotifyid) {
      const toSendGet = global.sqlCon.format(
          'SELECT spotifyId FROM Discord WHERE id=?', [userid]);
      global.sqlCon.query(toSendGet, (err, rows) => {
        if (err) {
          self.common.error('Failed to fetch spotifyId from Discord table.');
          console.log(err);
          cb('Internal Server Error');
        } else {
          const toSend2 = global.sqlCon.format(
              'DELETE FROM Spotify WHERE id=?', [rows[0].spotifyId]);
          global.sqlCon.query(toSend2, (err) => {
            if (err) {
              self.common.error(
                  'Failed to delete spotifyId from Spotify table.');
              console.log(err);
              cb('Internal Server Error');
            } else {
              setId();
            }
          });
        }
      });
    } else {
      setId();
    }

    /**
     * Send request to sql server.
     */
    function setId() {
      const toSend = global.sqlCon.format(
          'UPDATE Discord SET spotifyId=? WHERE id=?', [spotifyid, userid]);
      global.sqlCon.query(toSend, (err) => {
        if (err) {
          self.common.error('Failed to update spotifyId in Discord table.');
          console.log(err);
          cb('Internal Server Error');
        } else {
          cb(null);
        }
      });
    }
  }
  /**
   * Fetch a user's current patreon settings from file.
   *
   * @private
   *
   * @param {string|number} userid Thd Discord id of the user to lookup.
   * @param {Function} cb Callback with 2 parameters, the first is the error
   * string or null if no error, the second will be the settings object if there
   * is no error.
   */
  function getPatreonSettings(userid, cb) {
    fs.readFile(
        self.common.userSaveDir + userid + patreonSettingsFilename,
        (err, data) => {
          if (err) {
            cb(err, null);
            return;
          }
          try {
            cb(null, JSON.parse(data));
          } catch (e) {
            cb(e, null);
          }
        });
  }
  /**
   * Change a user's setting that is related to Patreon rewards.
   *
   * @private
   *
   * @param {string|number} userid The Discord id of the user to change the
   * setting for.
   * @param {string} setting The name of the setting to change.
   * @param {string} value The value to set the setting to.
   * @param {Function} cb Callback that is called once the operations are
   * complete with a single parameter for errors, string if error, null if none.
   */
  function changePatreonSetting(userid, setting, value, cb) {
    const dirname = self.common.userSaveDir + userid;
    const filename = dirname + patreonSettingsFilename;
    const split = setting.split(' ');
    setting = split[0];
    /**
     * Make the directory for writing the user's settings if it does not exist
     * already.
     *
     * @private
     * @param {?Error} err The error in readin the existing file.
     * @param {?string} data The data read from the existing file if any.
     */
    function makeDirectory(err, data) {
      if (err) {
        mkdirp(dirname)
            .then(() => writeFile(null, data))
            .catch((err) => writeFile(err, null));
      } else {
        writeFile(null, data);
      }
    }
    /**
     * Checks that the setting that was requested to be changed is a valid
     * setting to change.
     *
     * @private
     * @param {object} obj The template object to compare the request against.
     * @param {string[]} s The array of each setting key that was a part of
     * the request.
     * @param {string|number} value The value to change the setting to.
     * @returns {boolean} True if the request was invalid in some way, or false
     * if everything is fine.
     */
    function isInvalid(obj, s, value) {
      const type = obj.type;
      let valid = false;
      if (type === 'select') {
        for (let i = 0; i < obj.values.length; i++) {
          if (obj.values[i] == value) {
            valid = true;
            break;
          }
        }
      } else if (type === 'number') {
        if (!isNaN(Number(value))) valid = true;
        if (valid && obj.range) {
          if (value < obj.range.min || value > obj.range.max) {
            valid = false;
          }
        }
      } else if (type === 'string') {
        valid = true;
      } else if (type === 'color') {
        valid =
            typeof value === 'string' && value.match(/^0x[0-9a-fA-f]{6,9}$/);
      } else if (type === 'boolean') {
        valid = typeof value === 'boolean' ||
            (typeof value === 'string' && (value.toLowerCase() === 'false' ||
                                           value.toLowerCase() === 'true'));
      } else if (type === 'object') {
        return isInvalid(obj.values[s[0]], s.slice(1), value);
      }
      if (!valid) {
        cb('Invalid Value', {status: type || 'NOTYPE', message: value});
        return true;
      } else {
        return false;
      }
    }
    /**
     * Write the modified data to file.
     *
     * @private
     *
     * @param {?Error} err The error in creating the directory.
     * @param {?string} file The current file data that was read.
     */
    function writeFile(err, file) {
      let parsed = {};
      if (file != null) {
        try {
          parsed = JSON.parse(file);
        } catch (e) {
          self.error(
              'Failed to parse ' + self.common.userSaveDir + userid +
              patreonSettingsFilename);
          console.error(e);
          cb('Internal Error');
          return;
        }
      }
      if (split.length > 1) {
        if (!parsed[setting]) parsed[setting] = {};
        let obj = parsed[setting];
        while (split.length > 2) {
          const next = split.splice(1, 1)[0];
          if (!obj[next]) obj[next] = {};
          obj = obj[next];
        }
        obj[split[1]] = value;
      } else {
        parsed[setting] = value;
      }

      fs.writeFile(filename, JSON.stringify(parsed), (err) => {
        if (!err) {
          cb(null);
          return;
        }
        self.error('Failed to write user settings to file: ' + filename);
        console.error(err);
        cb('Internal Error');
      });
    }

    if (patreonSettingsTemplate[setting] == null) {
      cb('Invalid Setting');
      return;
    } else {
      if (isInvalid(patreonSettingsTemplate[setting], split.slice(1), value)) {
        return;
      }
    }
    fs.readFile(filename, makeDirectory);
  }

  /**
   * Get a current access token for a given discord user to make a request to
   * the Spotify API.
   *
   * @public
   *
   * @param {string|number} uId The Discord user id to get the token for.
   * @param {Function} cb Callback with a single argument that is the token, or
   * null if no token is available.
   */
  toExport.getSpotifyToken = function(uId, cb) {
    let firstAttempt = true;
    let sId;
    const toSend = global.sqlCon.format(
        'SELECT spotifyId FROM Discord WHERE id=? LIMIT 1', [uId]);
    global.sqlCon.query(toSend, (err, rows) => {
      if (err) {
        self.error(err);
        cb(null);
        return;
      }
      if (rows[0]) {
        fetchSpotifySQL(rows[0].spotifyId);
      } else {
        fetchSpotifySQL(null);
      }
    });
    /**
     * Request the user's Spotify info from our SQL server.
     *
     * @private
     *
     * @param {string} id The spotify ID of the user to fetch.
     */
    function fetchSpotifySQL(id) {
      if (!id) {
        cb(null);
        return;
      }
      sId = id;
      const toSend = global.sqlCon.format(
          'SELECT * FROM Spotify WHERE id=? LIMIT 1', [sId]);
      global.sqlCon.query(toSend, (err, rows) => {
        if (err) {
          self.error(err);
          cb(null);
          return;
        }
        const expiresAt = new Date(rows[0].tokenExpiresAt);
        if (Date.now() - expiresAt.getTime() > 0) {
          refreshSpotifyToken(rows[0].refreshToken);
        } else {
          cb(rows[0].accessToken);
        }
      });
    }
    /**
     * Use the user's refresh token to request a new access token. Only
     * attempted once.
     *
     * @private
     *
     * @param {string} token The refresh token to use.
     */
    function refreshSpotifyToken(token) {
      if (!firstAttempt || !token) {
        cb(null);
        return;
      }
      firstAttempt = false;
      const req = https.request(defaultSpotifyTokenReq, (res) => {
        let content = '';
        res.on('data', (chunk) => {
          content += chunk;
        });
        res.on('end', () => {
          if (res.statusCode == 200) {
            handleSpotifyTokenResponse(uId, content, null, (err) => {
              if (err) {
                cb(null);
              } else {
                fetchSpotifySQL(sId);
              }
            });
          } else {
            self.error(content);
            cb(null);
            return;
          }
        });
      });
      req.end(querystring.stringify({
        refresh_token: token,
        grant_type: 'refresh_token',
      }));
    }
  };
  /**
   * Convert the given date into a format that SQL can understand.
   *
   * @private
   * @param {*} date Something that `new Date()` can interpret.
   * @returns {string} Formatted Datetime string not including fractions of a
   * second.
   */
  function dateToSQL(date) {
    date = new Date(date);
    return date.getFullYear() + '-' + (date.getMonth() + 1) + '-' +
        date.getDate() + ' ' + date.getHours() + ':' + date.getMinutes() + ':' +
        date.getSeconds();
  }
}

module.exports = new WebAccount();