diff --git a/README.md b/README.md index 2cf08e5..0bd64f3 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Make a file named `config.json5` in the top-level directory. It should be someth "host": "", "weatherAPIkey": "", "fixerAPIkey": "", + "jwtSecret": "", "cvAPIkey": "", "mongoURL": "", "rootURL": "" diff --git a/dashboard/README.md b/dashboard/README.md index 93eb193..cb72aee 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -4,14 +4,17 @@ The dashboard component for IveBot. This contains a configuration panel for user ## Configuration -Starting the dashboard itself is identical to IveBot itself i.e. via `yarn build` and `yarn start`, except you must run these in this `dashboard` folder. You must create a file `config.json` in the `dashboard` folder like so: +Starting the dashboard itself is identical to IveBot itself i.e. via `yarn build` and `yarn start`, except you must run these in the `dashboard` folder. You must also get the dependencies in the parent IveBot folder, as these are shared between the two. + +The dashboard relies on a private HTTP API IveBot provides. Without this API, it falls back to using `botToken` and hits rate limits, slowing down the real bot as well as the dashboard *hugely*. You must ensure IveBot has access to the internet and is able to port forward `7331` (configurable using `IVEBOT_API_PORT` environment variable when running IveBot). You must also put the same JWT secret the dashboard uses in IveBot's `config.json5` for secured communication. You must create a file `config.json` in the `dashboard` folder like so: ```json { + "botApiUrl": ": and no / at the end>", "botToken": "", "clientId": "", "clientSecret": "", - "jwtSecret": "", + "jwtSecret": "", "mongoUrl": "", "host": "", "rootUrl": "" diff --git a/dashboard/imports/resolvers.ts b/dashboard/imports/resolvers.ts index 0e4b41e..d7537a2 100644 --- a/dashboard/imports/resolvers.ts +++ b/dashboard/imports/resolvers.ts @@ -1,10 +1,12 @@ import { Client } from 'eris' +import { promisify } from 'util' import { MongoClient, Db, Document } from 'mongodb' import { JwtPayload, verify, sign } from 'jsonwebtoken' import { NextApiRequest, NextApiResponse } from 'next' +import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'crypto' import { ApolloError, AuthenticationError, ForbiddenError } from 'apollo-server-micro' import config from '../config.json' -const { host, rootUrl, mongoUrl, jwtSecret, clientId, clientSecret, botToken } = config +const { host, rootUrl, mongoUrl, jwtSecret, clientId, clientSecret, botToken, botApiUrl } = config // Create a MongoDB instance. let db: Db @@ -27,9 +29,48 @@ const getServerSettings = async (id: string): Promise => { return serverSettings || { id } } -interface ResolverContext { - req: NextApiRequest - res: NextApiResponse +const encryptionKey = createHash('sha256').update(jwtSecret).digest() +const encrypt = async (data: Buffer): Promise => { + const iv = await promisify(randomBytes)(16) + const cipher = createCipheriv('aes-256-ctr', encryptionKey, iv) + return Buffer.concat([iv, cipher.update(data), cipher.final()]) +} +const decrypt = (data: Buffer): Buffer => { + const cipher = createDecipheriv('aes-256-ctr', encryptionKey, data.slice(0, 16)) + return Buffer.concat([cipher.update(data.slice(16)), cipher.final()]) +} +const getMutualPermissionGuilds = async (id: string, guilds: string[], host = false +): Promise> => { + if (botApiUrl) { + let body: Buffer + try { + body = await encrypt(Buffer.from(JSON.stringify({ id, guilds, host }))) + } catch (e) { throw new ApolloError('Failed to encrypt IveBot request!') } + try { + const request = await fetch(`${botApiUrl}/private`, { method: 'POST', body }) + if (!request.ok) throw new ApolloError('Failed to make request to IveBot private API!') + return JSON.parse(decrypt(Buffer.from(await request.arrayBuffer())).toString('utf8')) + } catch (e) { throw new ApolloError('Failed to make request to IveBot private API!') } + } else { + const mutualGuildsWithPerm: Array<{ id: string, perm: boolean }> = [] + await Promise.all(guilds.map(async guild => { + try { + const fullGuild = await botClient.getRESTGuild(guild) + const selfMember = await botClient.getRESTGuildMember(guild, id) + mutualGuildsWithPerm.push({ + id: guild, perm: host || fullGuild.permissionsOf(selfMember).has('manageGuild') + }) + } catch (e) { + if (e.name === 'DiscordHTTPError') throw new ApolloError('Failed to make Discord request!') + } + })) + return mutualGuildsWithPerm + } +} +const checkUserGuildPerm = async (id: string, guild: string, host = false): Promise => { + if (host) return true + const mutuals = await getMutualPermissionGuilds(id, [guild]) + return mutuals.length === 1 && mutuals[0].perm } const secure = rootUrl.startsWith('https') && process.env.NODE_ENV !== 'development' ? '; Secure' : '' @@ -79,22 +120,19 @@ const authenticateRequest = async (req: NextApiRequest, res: NextApiResponse): P } // Set up resolvers. +interface ResolverContext { + req: NextApiRequest + res: NextApiResponse +} + export default { Query: { getServerSettings: async (parent: string, { id }: { id: string }, context: ResolverContext) => { const accessToken = await authenticateRequest(context.req, context.res) const client = new Client(`Bearer ${accessToken}`, { restMode: true }) const self = await client.getSelf() - let hasPerm = false - try { - const fullGuild = await botClient.getRESTGuild(id) - const selfMember = await botClient.getRESTGuildMember(id, self.id) - hasPerm = fullGuild.permissionsOf(selfMember).has('manageGuild') - } catch (e) { - if (e.name === 'DiscordHTTPError') throw new ApolloError('Failed to make Discord request!') - throw new ForbiddenError('You are not allowed to access this server\'s settings!') - } - if (hasPerm || host === self.id) { + const hasPerm = await checkUserGuildPerm(self.id, id, host === self.id) + if (hasPerm) { const serverSettings = await getServerSettings(id) // Insert default values for all properties. const defaultJoinMsgs = { channel: '', joinMessage: '', leaveMessage: '', banMessage: '' } @@ -123,25 +161,19 @@ export default { const client = new Client(`Bearer ${accessToken}`, { restMode: true }) const guilds = await client.getRESTGuilds() const self = await client.getSelf() - return (await Promise.all(guilds - .map(async guild => { - /* TODO: Make a custom storage of all guilds IveBot is in to narrow down mutuals before - asking Discord. Current solution is slow and hits rate limits for users in many servers. */ - let hasPerm = false - try { - const fullGuild = await botClient.getRESTGuild(guild.id) - const selfMember = await botClient.getRESTGuildMember(guild.id, self.id) - hasPerm = fullGuild.permissionsOf(selfMember).has('manageGuild') - } catch (e) { return } - return { - id: guild.id, - name: guild.name, - icon: guild.iconURL || 'no icon', - channels: guild.channels.filter(i => i.type === 0) - .map(i => ({ id: i.id, name: i.name })), - perms: host === self.id || hasPerm - } - }))).filter(e => !!e) + const mutuals = await getMutualPermissionGuilds(self.id, guilds.map(guild => guild.id), host === self.id) + return mutuals.map(mutual => { + const guild = guilds.find(e => e.id === mutual.id) + if (!guild) return null // Should never be hit. + return { + id: guild.id, + name: guild.name, + icon: guild.iconURL || 'no icon', + channels: guild.channels.filter(i => i.type === 0) + .map(i => ({ id: i.id, name: i.name })), + perms: mutual.perm + } + }).filter(e => !!e) } }, Mutation: { @@ -161,16 +193,8 @@ export default { const accessToken = await authenticateRequest(context.req, context.res) const client = new Client(`Bearer ${accessToken}`, { restMode: true }) const self = await client.getSelf() - let hasPerm = false - try { - const fullGuild = await botClient.getRESTGuild(id) - const selfMember = await botClient.getRESTGuildMember(id, self.id) - hasPerm = fullGuild.permissionsOf(selfMember).has('manageGuild') - } catch (e) { - if (e.name === 'DiscordHTTPError') throw new ApolloError('Failed to make Discord request!') - throw new ForbiddenError('You are not allowed to access this server\'s settings!') - } - if (hasPerm || host === self.id) { + const hasPerm = await checkUserGuildPerm(self.id, id, host === self.id) + if (hasPerm) { const serverSettings = await getServerSettings(id) // Insert default values for all properties. const defaultJoinMsgs = { channel: '', joinMessage: '', leaveMessage: '', banMessage: '' } diff --git a/src/config.ts b/src/config.ts index 7aab4ef..694b422 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ interface Config { NASAtoken: string oxfordAPI: { appKey: string, appId: string } testPilots: string[] + jwtSecret: string mongoURL: string rootURL: string token: string @@ -17,7 +18,17 @@ interface Config { const config: Config = json5.parse(await readFile('config.json5', { encoding: 'utf8' })) export const { - weatherAPIkey, fixerAPIkey, cvAPIkey, host, NASAtoken, oxfordAPI, testPilots, mongoURL, rootURL, token + weatherAPIkey, + fixerAPIkey, + cvAPIkey, + host, + NASAtoken, + oxfordAPI, + testPilots, + jwtSecret, + mongoURL, + rootURL, + token } = config export default Config diff --git a/src/index.ts b/src/index.ts index 8c9682c..4c80851 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,13 +6,15 @@ import { MongoClient } from 'mongodb' // Import fs. import { readdir, stat } from 'fs/promises' import { inspect } from 'util' +import http from 'http' +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto' // Import types. import { DB, Command } from './imports/types.js' // Import the bot. import CommandParser from './client.js' import { guildMemberAdd, guildMemberRemove, guildDelete, guildBanAdd } from './events.js' // Get the token needed. -import { token, mongoURL } from './config.js' +import { token, mongoURL, jwtSecret } from './config.js' // If production is explicitly specified via flag.. if (process.argv[2] === '--production') process.env.NODE_ENV = 'production' @@ -110,3 +112,59 @@ client.on('error', (err: Error, id: string) => { // Connect to Discord. await client.connect() + +// Start private HTTP API for the dashboard. +if (jwtSecret) { + const key = createHash('sha256').update(jwtSecret).digest() + const headers = (body: NodeJS.ArrayBufferView | string): {} => ({ + 'Content-Length': Buffer.byteLength(body), 'Content-Type': 'application/json' + }) + const server = http.createServer((req, res) => { + if (req.method !== 'POST' || req.url !== '/private') return + let buffer = Buffer.from([]) + req.on('data', chunk => { + buffer = Buffer.concat([buffer, Buffer.from(chunk)]) + if (buffer.byteLength > 1024 * 8) req.destroy() // 8 kB limit + }) + req.on('end', () => { + (async () => { + try { + const decipher = createDecipheriv('aes-256-ctr', key, buffer.slice(0, 16)) + const data = Buffer.concat([decipher.update(buffer.slice(16)), decipher.final()]) + const valid: Array<{ id: string, perm: boolean }> = [] + const parsed: { id: string, host: boolean, guilds: string[] } = JSON.parse(data.toString('utf8')) + if (typeof parsed.id !== 'string' || !Array.isArray(parsed.guilds)) throw new Error() + await Promise.all(parsed.guilds.map(async id => { + if (typeof id !== 'string' || id.length <= 16) return + const guild = client.guilds.get(id) + if (!guild) return + else if (parsed.host) return valid.push({ id, perm: true }) // Fast path. + let member = guild.members.get(parsed.id) + if (!member) { + try { + member = await client.getRESTGuildMember(id, parsed.id) + guild.members.add(member) // Cache the member for faster lookups. + } catch (e) {} // TODO: Unable to retrieve member for the guild. Hm? + } + if (member) valid.push({ id, perm: guild.permissionsOf(member).has('manageGuild') }) + })) + randomBytes(16, (err, iv) => { + if (err) { + const error = '{"error":"Internal Server Error!"}' + return res.writeHead(500, headers(error)).end(error) + } + const cipher = createCipheriv('aes-256-ctr', key, iv) + const data = Buffer.from(JSON.stringify(valid)) + const aesData = Buffer.concat([iv, cipher.update(data), cipher.final()]) + return res.writeHead(200, headers(aesData)).end(aesData) + }) + } catch (e) { + const error = '{"error":"Invalid body!"}' + return res.writeHead(400, headers(error)).end(error) + } + })().catch(console.error) + }) + }).listen(isNaN(+process.env.IVEBOT_API_PORT) ? 7331 : +process.env.IVEBOT_API_PORT, () => { + console.log('Listening for IveBot dashboard requests on', server.address()) + }) +}