From 31349c5f51f07b585699859b07a0f615dff6efb2 Mon Sep 17 00:00:00 2001 From: "Ean Milligan (Bastion)" Date: Thu, 14 Jan 2021 08:47:18 -0500 Subject: [PATCH] DB Added Optional logging of rolls added API Key Verification Added API Authorization/Authentication Added --- README.md | 8 +-- config.example.ts | 10 +++- initDB.ts | 70 ++++++++++++++++++++++++++ mod.ts | 122 ++++++++++++++++++++++++++++++++++++++++------ src/solver.d.ts | 1 + src/solver.ts | 2 + 6 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 initDB.ts diff --git a/README.md b/README.md index e375556..fa1bceb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # The Artificer - A Dice Rolling Discord Bot -Version 1.1.1 - 2020/01/11 +Version 1.2.0 - 2020/01/14 The Artificer is a Discord bot that specializes in rolling dice. The bot utilizes the compact [Roll20 formatting](https://roll20.zendesk.com/hc/en-us/articles/360037773133-Dice-Reference) for ease of use and will correctly perform any needed math on the roll (limited to basic algebra). -This bot was developed to replace the Sidekick discord bot after it went offline many times for extended periods. This was also developed to fix some annoyances that were found with Sidekick, specifically its vague error messages (such as `"Tarantallegra!"`, what is that supposed to mean) and its inability to handle implicit mulitplication (such as `4(12 + 20)`). +This bot was developed to replace the Sidekick discord bot after it went offline many times for extended periods. This was also developed to fix some annoyances that were found with Sidekick, specifically its vague error messages (such as `"Tarantallegra!"`, what is that supposed to mean) and its inability to handle implicit multiplication (such as `4(12 + 20)`). ## Using The Artificer I am hosting this bot for public use and you may find its invite link below. If you would like to host this bot yourself, details of how to do so are found at the end of this README, but I do not recommend this unless you are experienced with running Discord bots. @@ -65,13 +65,13 @@ The Artificer comes with a few supplemental commands to the main rolling command * `[[((d20+20) - 10) / 5]]` will roll a d20, add 20 to that roll, subtract off 10, and finally divide by 5. ## The Artificer API -API is currently in development, details on usage and how to gain privilaged access will be added here when the API is feature complete and secured. +API is currently in development, details on usage and how to gain privileged access will be added here when the API is feature complete and secured. ## Problems? Feature requests? If you run into any errors or problems with the bot, or think you have a good idea to add to the bot, please submit a new GitHub issue detailing it. If you don't have a GitHub account, a report command (detailed above) is provided for use in Discord. --- -## Running The Artificer +## Self Hosting The Artificer The Artificer was built on Deno `v1.6.3` using Discodeno `v10.0.0`. If you choose to run this yourself, you will need to rename `config.example.ts` to `config.ts` and edit some values. You will need to create a new [Discord Application](https://discord.com/developers/applications) and copy the newly generated token into the `"token"` key. If you want to utilize some of the bots dev features, you will need to fill in the keys `"logChannel"` and `"reportChannel"` with text channel IDs and `"devServer"` with a guild ID. Starting the bot is simply done with `deno run --allow-net .\mod.ts`. diff --git a/config.example.ts b/config.example.ts index 74eadb3..e3cca34 100644 --- a/config.example.ts +++ b/config.example.ts @@ -1,6 +1,6 @@ export const config = { "name": "The Artificer", - "version": "1.1.1", + "version": "1.2.0", "token": "the_bot_token", "prefix": "[[", "postfix": "]]", @@ -9,6 +9,14 @@ export const config = { "port": 8080, "supportURL": "your_support_url_for_api_abuse" }, + "db": { + "host": "", + "port": 3306, + "username": "", + "password": "", + "name": "" + }, + "logRolls": true, "logChannel": "the_log_channel", "reportChannel": "the_report_channel", "devServer": "the_dev_server", diff --git a/initDB.ts b/initDB.ts new file mode 100644 index 0000000..964cae6 --- /dev/null +++ b/initDB.ts @@ -0,0 +1,70 @@ +import { Client } from "https://deno.land/x/mysql/mod.ts"; + +import config from "./config.ts"; + +// Log into the MySQL DB +const dbClient = await new Client().connect({ + hostname: config.db.host, + port: config.db.port, + username: config.db.username, + password: config.db.password, +}); + +console.log("Attempting to create DB"); +await dbClient.execute(`CREATE SCHEMA IF NOT EXISTS ${config.db.name};`); +await dbClient.execute(`USE ${config.db.name}`); +console.log("DB created"); + +console.log("Attempt to drop all tables"); +await dbClient.execute(`DROP TABLE IF EXISTS allowed_channels;`); +await dbClient.execute(`DROP TABLE IF EXISTS all_keys;`); +await dbClient.execute(`DROP TABLE IF EXISTS roll_log;`); +console.log("Tables dropped"); + +console.log("Attempting to create table roll_log"); +await dbClient.execute(` + CREATE TABLE roll_log ( + id int unsigned NOT NULL AUTO_INCREMENT, + input text NOT NULL, + resultid bigint, + result longtext NOT NULL, + api tinyint(1) NOT NULL, + error tinyint(1) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY roll_log_id_UNIQUE (id), + UNIQUE KEY roll_log_resultid_UNIQUE (resultid) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +`); +console.log("Table created"); + +console.log("Attempting to create table all_keys"); +await dbClient.execute(` + CREATE TABLE all_keys ( + userid bigint unsigned NOT NULL, + apiKey char(25) NOT NULL, + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + active tinyint(1) NOT NULL DEFAULT 1, + banned tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (userid), + UNIQUE KEY api_key_userid_UNIQUE (userid), + UNIQUE KEY api_key_apiKey_UNIQUE (apiKey) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +`); +console.log("Table created"); + +console.log("Attempting to create table allowed_channels"); +await dbClient.execute(` + CREATE TABLE allowed_channels ( + userid bigint unsigned NOT NULL, + channelid bigint unsigned NOT NULL, + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + active tinyint(1) NOT NULL DEFAULT 1, + banned tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (userid, channelid), + CONSTRAINT allowed_channels_userid_FK FOREIGN KEY (userid) REFERENCES all_keys (userid) ON DELETE RESTRICT ON UPDATE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +`); +console.log("Table created"); + +await dbClient.close(); +console.log("Done!"); diff --git a/mod.ts b/mod.ts index 02fe0f7..c41683f 100644 --- a/mod.ts +++ b/mod.ts @@ -15,14 +15,28 @@ import { Message, Guild, sendMessage, sendDirectMessage, cache } from "https://deno.land/x/discordeno@10.0.0/mod.ts"; + import { serve } from "https://deno.land/std@0.83.0/http/server.ts"; import { Status, STATUS_TEXT } from "https://deno.land/std@0.83.0/http/http_status.ts"; +import { Client } from "https://deno.land/x/mysql@v2.7.0/mod.ts"; + +import nanoid from "https://deno.land/x/nanoid@v3.0.0/mod.ts" + import utils from "./src/utils.ts"; import solver from "./src/solver.ts"; import config from "./config.ts"; +const dbClient = await new Client().connect({ + hostname: config.db.host, + port: config.db.port, + db: config.db.name, + username: config.db.username, + password: config.db.password, +}); + +// Start up the Discord Bot startBot({ token: config.token, intents: [Intents.GUILD_MESSAGES, Intents.DIRECT_MESSAGES, Intents.GUILDS], @@ -140,6 +154,8 @@ startBot({ // Rest of this command is in a try-catch to protect all sends/edits from erroring out try { + const originalCommand = config.prefix + command + " " + args.join(" "); + const m = await utils.sendIndirectMessage(message, "Rolling...", sendMessage, sendDirectMessage); const modifiers = { @@ -190,6 +206,13 @@ startBot({ if (modifiers.gms.length < 1) { // If -gm is on and none were found, throw an error m.edit("Error: Must specifiy at least one GM by mentioning them"); + + if (config.logRolls) { + // If enabled, log rolls so we can verify the bots math + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,0,1)", [originalCommand, "NoGMsFound", m.id]).catch(e => { + console.log("Failed to insert into database 00", e); + }); + } return; } @@ -204,12 +227,19 @@ startBot({ // maxRoll and nominalRoll cannot both be on, throw an error if (modifiers.maxRoll && modifiers.nominalRoll) { m.edit("Error: Cannot maximise and nominise the roll at the same time"); + + if (config.logRolls) { + // If enabled, log rolls so we can verify the bots math + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,0,1)", [originalCommand, "MaxAndNominal", m.id]).catch(e => { + console.log("Failed to insert into database 01", e); + }); + } return; } // Rejoin all of the args and send it into the solver, if solver returns a falsy item, an error object will be substituded in const rollCmd = command + " " + args.join(" "); - const returnmsg = solver.parseRoll(rollCmd, config.prefix, config.postfix, modifiers.maxRoll, modifiers.nominalRoll) || { error: true, errorMsg: "Error: Empty message", line1: "", line2: "", line3: "" }; + const returnmsg = solver.parseRoll(rollCmd, config.prefix, config.postfix, modifiers.maxRoll, modifiers.nominalRoll) || { error: true, errorCode: "EmptyMessage", errorMsg: "Error: Empty message", line1: "", line2: "", line3: "" }; let returnText = ""; @@ -217,6 +247,13 @@ startBot({ if (returnmsg.error) { returnText = returnmsg.errorMsg; m.edit(returnText); + + if (config.logRolls) { + // If enabled, log rolls so we can verify the bots math + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,0,1)", [originalCommand, returnmsg.errorCode, m.id]).catch(e => { + console.log("Failed to insert into database 02", e); + }); + } return; } else { // Else format the output using details from the solver @@ -248,6 +285,13 @@ startBot({ // Finally send the text m.edit(normalText); + + if (config.logRolls) { + // If enabled, log rolls so we can verify the bots math + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,0,0)", [originalCommand, returnText, m.id]).catch(e => { + console.log("Failed to insert into database 03", e); + }); + } } else { // When not a GM roll, make sure the message is not too big if (returnText.length > 2000) { @@ -270,6 +314,13 @@ startBot({ // Finally send the text m.edit(returnText); + + if (config.logRolls) { + // If enabled, log rolls so we can verify the bots math + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,0,0)", [originalCommand, returnText, m.id]).catch(e => { + console.log("Failed to insert into database 04", e); + }); + } } } catch (err) { console.error("Something failed 71"); @@ -291,14 +342,26 @@ if (config.api.enable) { // Catching every request made to the server for await (const request of server) { + // Check if user is authenticated to be using this API + let authenticated = false; + let apiUserid = 0; - // Super secure authentication - const authenticated = true; + // Check the requests API key + if (request.headers.has("X-Api-Key")) { + // Get the userid and flags for the specific key + const dbApiQuery = await dbClient.query("SELECT userid, active, banned FROM all_keys WHERE apiKey = ?", [request.headers.get("X-Api-Key")]); + + // If only one user returned, is not banned, and is currently active, mark as authenticated + if (dbApiQuery.length === 1 && dbApiQuery[0].active && !dbApiQuery[0].banned) { + apiUserid = dbApiQuery[0].userid; + authenticated = true; + } + } if (authenticated) { // Get path and query as a string const [path, tempQ] = request.url.split("?"); - + // Turn the query into a map (if it exists) const query = new Map(); if (tempQ !== undefined) { @@ -323,8 +386,14 @@ if (config.api.enable) { break; } - // Super secure authorization - const authorized = true; + // Check if user is authenticated to use this endpoint + let authorized = false; + + // Check if the db has the requested userid/channelid combo, and that the requested userid matches the userid linked with the api key + const dbChannelQuery = await dbClient.query("SELECT active, banned FROM allowed_channels WHERE userid = ? AND channelid = ?", [parseInt(query.get("user") || ""), parseInt(query.get("channel") || "")]) + if (dbChannelQuery.length === 1 && (apiUserid === parseInt(query.get("user") || "")) && dbChannelQuery[0].active && !dbChannelQuery[0].banned) { + authorized = true; + } if (authorized) { // Rest of this command is in a try-catch to protect all sends/edits from erroring out @@ -337,6 +406,11 @@ if (config.api.enable) { if (rollCmd.length === 0) { // Alert API user that they messed up request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [rollCmd, "EmptyInput", null]).catch(e => { + console.log("Failed to insert into database 10", e); + }); break; } @@ -348,11 +422,16 @@ if (config.api.enable) { // Alert users why this message just appeared and how they can report abues pf this feature const apiPrefix = "The following roll was conducted using my built in API. If someone in this channel did not request this roll, please report API abuse here: <" + config.api.supportURL + ">\n\n"; - let returnText = ""; + let m, returnText = ""; // Handle sending the error message to whoever called the api if (returnmsg.error) { request.respond({ status: Status.InternalServerError, body: returnmsg.errorMsg }); + + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [rollCmd, returnmsg.errorCode, null]).catch(e => { + console.log("Failed to insert into database 11", e); + }); break; } else { returnText = apiPrefix + "<@" + query.get("user") + ">" + returnmsg.line1 + "\n" + returnmsg.line2; @@ -378,6 +457,11 @@ if (config.api.enable) { if (gms.length === 0) { // Alert API user that they messed up request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [rollCmd, "NoGMsSent", null]).catch(e => { + console.log("Failed to insert into database 12", e); + }); break; } @@ -389,12 +473,12 @@ if (config.api.enable) { // Send the return message as a DM or normal message depening on if the channel is set if ((query.get("channel") || "").length > 0) { - await sendMessage(query.get("channel") || "", normalText).catch(() => { + m = await sendMessage(query.get("channel") || "", normalText).catch(() => { request.respond({ status: Status.InternalServerError, body: "Message 00 failed to send." }); errorOut = true; }); } else { - await sendDirectMessage(query.get("user") || "", normalText).catch(() => { + m = await sendDirectMessage(query.get("user") || "", normalText).catch(() => { request.respond({ status: Status.InternalServerError, body: "Message 01 failed to send." }); errorOut = true; }); @@ -405,17 +489,17 @@ if (config.api.enable) { const msgs = utils.split2k(returnText); const failedDMs = []; for (let i = 0; ((failedDMs.indexOf(e) === -1) && (i < msgs.length)); i++) { - await sendDirectMessage(e, msgs[i]).catch( async () => { + await sendDirectMessage(e, msgs[i]).catch(async () => { failedDMs.push(e); const failedSend = "WARNING: <@" + e + "> could not be messaged. If this issue persists, make sure direct messages are allowed from this server." // Send the return message as a DM or normal message depening on if the channel is set if ((query.get("channel") || "").length > 0) { - await sendMessage(query.get("channel") || "", failedSend).catch(() => { + m = await sendMessage(query.get("channel") || "", failedSend).catch(() => { request.respond({ status: Status.InternalServerError, body: "Message 10 failed to send." }); errorOut = true; }); } else { - await sendDirectMessage(query.get("user") || "", failedSend).catch(() => { + m = await sendDirectMessage(query.get("user") || "", failedSend).catch(() => { request.respond({ status: Status.InternalServerError, body: "Message 11 failed to send." }); errorOut = true; }); @@ -424,6 +508,11 @@ if (config.api.enable) { } }); + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,0)", [rollCmd, returnText, ((typeof m === "object") ? m.id : null)]).catch(e => { + console.log("Failed to insert into database 13", e); + }); + // Handle closing the request out if (errorOut) { break; @@ -453,17 +542,22 @@ if (config.api.enable) { // Send the return message as a DM or normal message depening on if the channel is set if ((query.get("channel") || "").length > 0) { - await sendMessage(query.get("channel") || "", returnText).catch(() => { + m = await sendMessage(query.get("channel") || "", returnText).catch(() => { request.respond({ status: Status.InternalServerError, body: "Message 20 failed to send." }); errorOut = true; }); } else { - await sendDirectMessage(query.get("user") || "", returnText).catch(() => { + m = await sendDirectMessage(query.get("user") || "", returnText).catch(() => { request.respond({ status: Status.InternalServerError, body: "Message 21 failed to send." }); errorOut = true; }); } + // If enabled, log rolls so we can verify the bots math + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,0)", [rollCmd, returnText, ((typeof m === "object") ? m.id : null)]).catch(e => { + console.log("Failed to insert into database 14", e); + }); + // Handle closing the request out if (errorOut) { break; diff --git a/src/solver.d.ts b/src/solver.d.ts index 37c2d92..f11adba 100644 --- a/src/solver.d.ts +++ b/src/solver.d.ts @@ -23,6 +23,7 @@ export type SolvedStep = { export type SolvedRoll = { error: boolean, errorMsg: string, + errorCode: string, line1: string, line2: string, line3: string diff --git a/src/solver.ts b/src/solver.ts index cee3c88..417f0c0 100644 --- a/src/solver.ts +++ b/src/solver.ts @@ -725,6 +725,7 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m const returnmsg = { error: false, errorMsg: "", + errorCode: "", line1: "", line2: "", line3: "" @@ -960,6 +961,7 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m // Fill in the return block returnmsg.error = true; + returnmsg.errorCode = solverError.message; returnmsg.errorMsg = errorMsg; }