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