// 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('date-format');
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);
console.log(parsed.length);
} 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();