diff --git a/common/types.ts b/common/types.ts index 95c4a43..13bba18 100644 --- a/common/types.ts +++ b/common/types.ts @@ -3,7 +3,7 @@ export const enum LiteralTypes { Integer, Float, Boolean, - Vector + Vector, } type EnumTypeName = string; @@ -56,7 +56,7 @@ export type Redeem = { args: Parameter[]; announce?: boolean; moderated?: boolean; - + image: string; price: number; sku: string; @@ -68,7 +68,6 @@ export type Config = { version: number; enums?: { [name: string]: string[] }; redeems?: { [id: string]: Redeem }; - banned?: string[]; message?: string; }; @@ -89,13 +88,44 @@ export type Transaction = { }; export type PubSubMessage = { - type: string; + type: "config_refreshed" | "banned"; data: string; }; +export type BannedData = { + id: string; + banned: boolean; +}; + export type LogMessage = { transactionToken: string | null; userIdInsecure: string | null; important: boolean; fields: { header: string; content: any }[]; }; + +export type User = { + id: string; + login?: string; + displayName?: string; + banned: boolean; +}; + +export type OrderState = + | "rejected" + | "prepurchase" + | "cancelled" + | "paid" // waiting for game + | "failed" // game failed/timed out + | "succeeded"; + +export type Order = { + id: string; + userId: string; + state: OrderState; + cart?: Cart; + receipt?: string; + result?: string; + createdAt: number; + updatedAt: number; +}; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4073aa4..4e864d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: MYSQL_PASSWORD: ebs volumes: - ./_volumes/db:/var/lib/mysql + - ./scripts/sql/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql nginx-proxy: image: nginxproxy/nginx-proxy:latest diff --git a/ebs/src/index.ts b/ebs/src/index.ts index 8eb6c8a..308946d 100644 --- a/ebs/src/index.ts +++ b/ebs/src/index.ts @@ -1,20 +1,18 @@ -import { config as dotenv } from "dotenv"; +import "dotenv/config"; import cors from "cors"; import express from "express"; import expressWs from "express-ws"; import bodyParser from "body-parser"; -import { privateApiAuth, publicApiAuth } from "./util/middleware"; +import { asyncCatch, privateApiAuth, publicApiAuth } from "./util/middleware"; import { initDb } from "./util/db"; import { sendToLogger } from "./util/logger"; -dotenv(); - const port = 3000; export const { app } = expressWs(express()); app.use(cors({ origin: "*" })); app.use(bodyParser.json()); -app.use("/public/*", publicApiAuth); +app.use("/public/*", asyncCatch(publicApiAuth)); app.use("/private/*", privateApiAuth); app.get("/", (_, res) => { @@ -28,7 +26,7 @@ async function main() { console.log("Listening on port " + port); require("./modules/config"); - require("./modules/transactions"); + require("./modules/orders"); require("./modules/game"); require("./modules/twitch"); @@ -82,4 +80,4 @@ async function main() { }); } -main().catch(console.error); \ No newline at end of file +main().catch(console.error); diff --git a/ebs/src/modules/config.ts b/ebs/src/modules/config.ts index 9ff9113..c04f486 100644 --- a/ebs/src/modules/config.ts +++ b/ebs/src/modules/config.ts @@ -2,7 +2,6 @@ import { Config } from "common/types"; import { app } from ".."; import { sendPubSubMessage } from "../util/pubsub"; import { compressSync, strFromU8, strToU8 } from "fflate"; -import { getBannedUsers } from "../util/db"; import { asyncCatch } from "../util/middleware"; import { Webhooks } from "@octokit/webhooks"; import { sendToLogger } from "../util/logger"; @@ -22,10 +21,6 @@ async function fetchConfig(): Promise { const data: Config = JSON.parse(atob(responseData.content)) - console.log(data); - - data.banned = await getBannedUsers(); - return data; } catch (e: any) { console.error("Error when fetching config from api URL, falling back to raw URL"); @@ -48,10 +43,6 @@ async function fetchConfig(): Promise { const response = await fetch(url); const data: Config = await response.json(); - console.log(data) - - data.banned = await getBannedUsers(); - return data; } catch (e: any) { console.error("Error when fetching config from raw URL, panic"); diff --git a/ebs/src/modules/game/connection.ts b/ebs/src/modules/game/connection.ts index 1679639..97ae706 100644 --- a/ebs/src/modules/game/connection.ts +++ b/ebs/src/modules/game/connection.ts @@ -1,9 +1,9 @@ import { Message, MessageType, TwitchUser } from "./messages"; -import { ResultMessage, GameMessage } from "./messages.game"; +import { GameMessage, ResultMessage } from "./messages.game"; import * as ServerWS from "ws"; import { v4 as uuid } from "uuid"; import { CommandInvocationSource, RedeemMessage, ServerMessage } from "./messages.server"; -import { Cart, Redeem } from "common/types"; +import { Redeem, Order } from "common/types"; import { setIngame } from "../config"; const VERSION = "0.1.0"; @@ -34,7 +34,7 @@ export class GameConnection { console.log("Connected to game"); this.handshake = false; this.resendIntervalHandle = +setInterval(() => this.tryResendFromQueue(), this.resendInterval); - ws.on('message', async (message) => { + ws.on("message", async (message) => { const msgText = message.toString(); let msg: GameMessage; try { @@ -43,21 +43,20 @@ export class GameConnection { console.error("Could not parse message" + msgText); return; } - if (msg.messageType !== MessageType.Ping) - console.log(`Got message ${JSON.stringify(msg)}`); + if (msg.messageType !== MessageType.Ping) console.log(`Got message ${JSON.stringify(msg)}`); this.processMessage(msg); }); ws.on("close", (code, reason) => { - const reasonStr = reason ? `reason '${reason}'` : "no reason" + const reasonStr = reason ? `reason '${reason}'` : "no reason"; console.log(`Game socket closed with code ${code} and ${reasonStr}`); setIngame(false); if (this.resendIntervalHandle) { clearInterval(this.resendIntervalHandle); } - }) + }); ws.on("error", (error) => { console.log(`Game socket error\n${error}`); - }) + }); } public processMessage(msg: GameMessage) { switch (msg.messageType) { @@ -66,11 +65,15 @@ export class GameConnection { const reply = { ...this.makeMessage(MessageType.HelloBack), allowed: msg.version == VERSION, - } - this.sendMessage(reply).then().catch(e => e); + }; + this.sendMessage(reply) + .then() + .catch((e) => e); break; case MessageType.Ping: - this.sendMessage(this.makeMessage(MessageType.Pong)).then().catch(e => e); + this.sendMessage(this.makeMessage(MessageType.Pong)) + .then() + .catch((e) => e); break; case MessageType.Result: if (!this.outstandingRedeems.has(msg.guid)) { @@ -116,8 +119,7 @@ export class GameConnection { reject(err); return; } - if (msg.messageType !== MessageType.Pong) - console.debug(`Sent message ${JSON.stringify(msg)}`); + if (msg.messageType !== MessageType.Pong) console.debug(`Sent message ${JSON.stringify(msg)}`); resolve(); }); }); @@ -126,27 +128,27 @@ export class GameConnection { return { messageType: type, guid: guid ?? uuid(), - timestamp: Date.now() - } + timestamp: Date.now(), + }; } - public redeem(redeem: Redeem, cart: Cart, user: TwitchUser, transactionId: string) : Promise { + public redeem(redeem: Redeem, order: Order, user: TwitchUser): Promise { return Promise.race([ - new Promise((_, reject) => setTimeout(() => reject(`Timed out waiting for result. The redeem may still go through later, contact Alexejhero if it doesn't.`), GameConnection.resultWaitTimeout)), + new Promise((_, reject) => + setTimeout( + () => reject(`Timed out waiting for result. The redeem may still go through later, contact AlexejheroDev if it doesn't.`), + GameConnection.resultWaitTimeout + ) + ), new Promise((resolve, reject) => { - if (!transactionId) { - reject(`Tried to redeem without transaction ID`); - return; - } - const msg: RedeemMessage = { ...this.makeMessage(MessageType.Redeem), - guid: transactionId, + guid: order.id, source: CommandInvocationSource.Swarm, command: redeem.id, title: redeem.title, announce: redeem.announce ?? true, - args: cart.args, - user + args: order.cart!.args, + user, } as RedeemMessage; if (this.outstandingRedeems.has(msg.guid)) { reject(`Redeeming ${msg.guid} more than once`); @@ -154,9 +156,11 @@ export class GameConnection { } this.outstandingRedeems.set(msg.guid, msg); this.resultHandlers.set(msg.guid, resolve); - - this.sendMessage(msg).then().catch(e => e); // will get queued to re-send later - }) + + this.sendMessage(msg) + .then() + .catch((e) => e); // will get queued to re-send later + }), ]); } @@ -178,7 +182,9 @@ export class GameConnection { } console.log(`Re-sending message ${JSON.stringify(msg)}`); - this.sendMessage(msg).then().catch(e => e); + this.sendMessage(msg) + .then() + .catch((e) => e); } public stressTestSetHandshake(handshake: boolean) { this.handshake = handshake; @@ -190,4 +196,16 @@ export class GameConnection { public getOutstanding() { return Array.from(this.outstandingRedeems.values()); } + + public onResult(guid: string, resolve: (result: ResultMessage) => void) { + const existing = this.resultHandlers.get(guid); + if (existing) { + this.resultHandlers.set(guid, (result: ResultMessage) => { + existing(result); + resolve(result); + }); + } else { + this.resultHandlers.set(guid, resolve); + } + } } diff --git a/ebs/src/modules/orders/index.ts b/ebs/src/modules/orders/index.ts new file mode 100644 index 0000000..ce9ebd4 --- /dev/null +++ b/ebs/src/modules/orders/index.ts @@ -0,0 +1,317 @@ +import { Cart, LogMessage, Transaction, Order } from "common/types"; +import { app } from "../.."; +import { parseJWT, verifyJWT } from "../../util/jwt"; +import { BitsTransactionPayload } from "../../types"; +import { getConfig } from "../config"; +import { createOrder, getOrder, saveOrder, updateUserTwitchInfo } from "../../util/db"; +import { sendToLogger } from "../../util/logger"; +import { connection } from "../game"; +import { TwitchUser } from "../game/messages"; +import { asyncCatch } from "../../util/middleware"; +import { sendShock } from "../../util/pishock"; +import { validatePrepurchase } from "./prepurchase"; +import { setUserBanned } from "./user"; + +require('./user'); + +app.post( + "/public/prepurchase", + asyncCatch(async (req, res) => { + const cart = req.body as Cart; + const userId = req.user.id; + + const logContext: LogMessage = { + transactionToken: null, + userIdInsecure: userId, + important: false, + fields: [ + { + header: "", + content: "", + }, + ], + }; + const logMessage = logContext.fields[0]; + + if (!connection.isConnected()) { + res.status(502).send("Game connection is not available"); + return; + } + let order: Order; + try { + order = await createOrder(userId, { cart }); + } catch (e: any) { + logContext.important = true; + logMessage.header = "Failed to register prepurchase"; + logMessage.content = { cart, userId, error: e }; + sendToLogger(logContext).then(); + throw e; + } + + logMessage.header = "Created prepurchase"; + logMessage.content = { order }; + sendToLogger(logContext).then(); + + let validationError: string | null; + try { + validationError = await validatePrepurchase(order); + } catch (e: any) { + res.status(500).send("Failed to register prepurchase"); + return; + } + if (typeof validationError === "string") { + res.status(409).send(validationError); + return; + } + + order.state = "prepurchase"; + await saveOrder(order); + res.status(200).send(order.id); + }) +); + +app.post( + "/public/transaction", + asyncCatch(async (req, res) => { + const transaction = req.body as Transaction; + + const logContext: LogMessage = { + transactionToken: transaction.token, + userIdInsecure: req.user.id, + important: true, + fields: [ + { + header: "", + content: transaction, + }, + ], + }; + const logMessage = logContext.fields[0]; + + if (!transaction.receipt) { + logMessage.header = "Missing receipt"; + sendToLogger(logContext).then(); + res.status(400).send("Missing receipt"); + return; + } + + if (!verifyJWT(transaction.receipt)) { + logMessage.header = "Invalid receipt"; + sendToLogger(logContext).then(); + setUserBanned(req.user, true); + res.status(403).send("Invalid receipt."); + return; + } + + const payload = parseJWT(transaction.receipt) as BitsTransactionPayload; + + if (!payload.data.transactionId) { + logMessage.header = "Missing transaction ID"; + sendToLogger(logContext).then(); + res.status(400).send("Missing transaction ID"); + return; + } + let order: Order | null; + try { + order = await getOrder(transaction.token); + } catch (e: any) { + logContext.important = true; + logMessage.header = "Failed to get order"; + logMessage.content = { + transaction: transaction, + error: e, + }; + sendToLogger(logContext).then(); + res.status(500).send("Failed to get transaction"); + return; + } + if (!order) { + logMessage.header = "Transaction not found"; + sendToLogger(logContext).then(); + res.status(404).send("Transaction not found"); + return; + } + if (order.state != "prepurchase") { + logMessage.header = "Transaction already processed"; + sendToLogger(logContext).then(); + res.status(409).send("Transaction already processed"); + return; + } + + if (!order.cart) { + logMessage.header = "Invalid transaction"; + sendToLogger(logContext).then(); + res.status(500).send("Invalid transaction"); + return; + } + + order.state = "paid"; + order.receipt = transaction.receipt; + await saveOrder(order); + + if (order.userId != req.user.id) { + // paying for somebody else, how generous + logContext.important = true; + logMessage.header = "Mismatched user ID"; + logMessage.content = { + user: req.user, + order, + transaction, + }; + sendToLogger(logContext).then(); + } + + const currentConfig = await getConfig(); + if (order.cart.version != currentConfig.version) { + logContext.important = true; + logMessage.header = "Mismatched config version"; + logMessage.content = { + config: currentConfig.version, + order, + transaction, + }; + sendToLogger(logContext).then(); + } + + console.log(transaction); + console.log(order.cart); + + const redeem = currentConfig.redeems?.[order.cart.id]; + if (!redeem) { + logContext.important = true; + logMessage.header = "Redeem not found"; + logMessage.content = { + configVersion: currentConfig.version, + order, + }; + sendToLogger(logContext).then(); + res.status(500).send("Redeem could not be found"); + return; + } + + let userInfo: TwitchUser = { + id: req.user.id, + login: req.user.login ?? req.user.id, + displayName: req.user.displayName ?? req.user.id, + }; + if (!req.user.login || !req.user.displayName) { + try { + await updateUserTwitchInfo(req.user); + userInfo.login = req.user.login!; + userInfo.displayName = req.user.displayName!; + } catch (error) { + logContext.important = true; + logMessage.header = "Could not get Twitch user info"; + logMessage.content = { + configVersion: currentConfig.version, + order, + }; + sendToLogger(logContext).then(); + // very much not ideal but they've already paid... so... + console.log(`Error while trying to get Twitch user info: ${error}`); + } + } + + if (redeem.id == "redeem_pishock") { + const success = await sendShock(50, 100); + order.state = success ? "succeeded" : "failed"; + await saveOrder(order); + if (success) { + res.status(200).send("Your transaction was successful!"); + } else { + res.status(500).send("Redeem failed"); + } + return; + } + try { + const resMsg = await connection.redeem(redeem, order, userInfo); + order.state = resMsg.success ? "succeeded" : "failed"; + order.result = resMsg.message; + await saveOrder(order); + if (resMsg?.success) { + console.log(`[${resMsg.guid}] Redeem succeeded: ${JSON.stringify(resMsg)}`); + let msg = "Your transaction was successful! Your redeem will appear on stream soon."; + if (resMsg.message) { + msg += "\n\n" + resMsg.message; + } + res.status(200).send(msg); + } else { + logContext.important = true; + logMessage.header = "Redeem did not succeed"; + logMessage.content = resMsg; + sendToLogger(logContext).then(); + res.status(500).send(resMsg?.message ?? "Redeem failed"); + } + } catch (error) { + logContext.important = true; + logMessage.header = "Failed to send redeem"; + logMessage.content = { + config: currentConfig.version, + order, + error, + }; + sendToLogger(logContext).then(); + connection.onResult(order.id, (res) => { + console.log(`Got late result (from re-send?) for ${order.id}`); + order.state = res.success ? "succeeded" : "failed"; + order.result = res.message; + saveOrder(order).then(); + }); + res.status(500).send(`Failed to process redeem - ${error}`); + } + }) +); + +app.post( + "/public/transaction/cancel", + asyncCatch(async (req, res) => { + const guid = req.body.token as string; + const logContext: LogMessage = { + transactionToken: guid, + userIdInsecure: req.user.id, + important: true, + fields: [ + { + header: "", + content: "", + } + ] + }; + const logMessage = logContext.fields[0]; + + try { + const order = await getOrder(guid); + + if (!order) { + res.status(404).send("Transaction not found"); + return; + } + + if (order.userId != req.user.id) { + logMessage.header = "Unauthorized transaction cancel"; + logMessage.content = { + order, + user: req.user, + }; + sendToLogger(logContext); + res.status(403).send("This transaction doesn't belong to you"); + return; + } + + if (order.state !== "prepurchase") { + res.status(409).send("Cannot cancel this transaction"); + return; + } + + order.state = "cancelled"; + await saveOrder(order); + res.sendStatus(200); + } catch (error) { + logMessage.header = "Failed to cancel order"; + logMessage.content = error; + sendToLogger(logContext).then(); + + res.sendStatus(500); + } + }) +); diff --git a/ebs/src/modules/orders/prepurchase.ts b/ebs/src/modules/orders/prepurchase.ts new file mode 100644 index 0000000..a64f1a1 --- /dev/null +++ b/ebs/src/modules/orders/prepurchase.ts @@ -0,0 +1,137 @@ +import { Cart, Config, LogMessage, Order } from "common/types"; +import { sendToLogger } from "../../util/logger"; +import { getConfig } from "../config"; + +export async function validatePrepurchase(order: Order) : Promise { + const logContext: LogMessage = { + transactionToken: null, + userIdInsecure: order.userId, + important: false, + fields: [ + { + header: "", + content: "", + }, + ], + }; + const logMessage = logContext.fields[0]; + + const cart = order.cart!; + + const config = await getConfig(); + if (cart.version != config.version) { + logMessage.header = "Invalid config version"; + logMessage.content = `Received: ${cart.version}\nExpected: ${config.version}`; + await sendToLogger(logContext); + return "Invalid config version"; + } + + const redeem = config.redeems?.[cart.id]; + if (!redeem || redeem.sku != cart.sku || redeem.disabled || redeem.hidden) { + logMessage.header = "Invalid redeem"; + logMessage.content = `Received: ${JSON.stringify(cart)}\nRedeem in config: ${JSON.stringify(redeem)}`; + await sendToLogger(logContext); + return "Invalid redeem"; + } + + const valError = validateArgs(config, cart, logContext); + if (valError) { + logMessage.header = "Arg validation failed"; + logMessage.content = { + error: valError, + redeem: cart.id, + expected: redeem.args, + provided: cart.args, + }; + await sendToLogger(logContext); + return "Invalid arguments"; + } + + return null; +} + +function validateArgs(config: Config, cart: Cart, logContext: LogMessage): string | undefined { + const redeem = config.redeems![cart.id]; + + for (const arg of redeem.args) { + const value = cart.args[arg.name]; + if (!value) { + if (!arg.required) continue; + + // LiteralTypes.Boolean + if (arg.type === 3) { + // HTML form conventions - false is not transmitted, true is "on" (to save 2 bytes i'm guessing) + continue; + } + + return `Missing required argument ${arg.name}`; + } + let parsed: number; + switch (arg.type) { + // esbuild dies if you use enums + // so we have to use their pure values instead + case 0: // LiteralTypes.String + if (typeof value !== "string") { + return `Argument ${arg.name} not a string`; + } + const minLength = arg.minLength ?? 0; + const maxLength = arg.maxLength ?? 255; + if (value.length < minLength || value.length > maxLength) { + return `Text length out of range for ${arg.name}`; + } + break; + case 1: // LiteralTypes.Integer + case 2: // LiteralTypes.Float + parsed = parseInt(value); + if (Number.isNaN(parsed)) { + return `Argument ${arg.name} is not a number`; + } + // LiteralTypes.Integer + if (arg.type === 1 && parseFloat(value) != parsed) { + return `Argument ${arg.name} is not an integer`; + } + if ((arg.min !== undefined && parsed < arg.min) || (arg.max !== undefined && parsed > arg.max)) { + return `Number ${arg.name} out of range`; + } + break; + case 3: // LiteralTypes.Boolean + if (typeof value !== "boolean" && value !== "true" && value !== "false" && value !== "on") { + return `Argument ${arg.name} not a boolean`; + } + if (value === "on") { + cart.args[arg.name] = true; + } + break; + case 4: // LiteralTypes.Vector + if (!Array.isArray(value) || value.length < 3) { + return `Vector3 ${arg.name} not a 3-elem array`; + } + // workaround for #49 + const lastThree = value.slice(value.length - 3); + for (const v of lastThree) { + parsed = parseFloat(v); + if (Number.isNaN(parsed)) { + return `Vector3 ${arg.name} components not all floats`; + } + } + cart!.args[arg.name] = lastThree; + break; + default: + const argEnum = config.enums?.[arg.type]; + if (!argEnum) { + return `No such enum ${arg.type}`; + } + parsed = parseInt(value); + if (Number.isNaN(parsed) || parsed != parseFloat(value)) { + return `Enum value ${value} (for enum ${arg.type}) not an integer`; + } + if (parsed < 0 || parsed >= argEnum.length) { + return `Enum value ${value} (for enum ${arg.type}) out of range`; + } + if (argEnum[parsed].startsWith("[DISABLED]")) { + return `Enum value ${value} (for enum ${arg.type}) is disabled`; + } + break; + } + } +} \ No newline at end of file diff --git a/ebs/src/modules/orders/user.ts b/ebs/src/modules/orders/user.ts new file mode 100644 index 0000000..89c1ebf --- /dev/null +++ b/ebs/src/modules/orders/user.ts @@ -0,0 +1,54 @@ +import { User } from "common/types"; +import { app } from "../.."; +import { lookupUser, saveUser, updateUserTwitchInfo } from "../../util/db"; +import { asyncCatch } from "../../util/middleware"; +import { sendPubSubMessage } from "../../util/pubsub"; + +export async function setUserBanned(user: User, banned: boolean) { + user.banned = banned; + await saveUser(user); + await sendPubSubMessage({ + type: "banned", + data: JSON.stringify({ id: user.id, banned }), + }); +} + +app.post( + "/public/authorized", + asyncCatch(async (req, res) => { + res.sendStatus(200); + updateUserTwitchInfo(req.user).then().catch(console.error); + }) +); + +app.get("/private/user/:idOrName", asyncCatch(async (req, res) => { + res.json(await lookupUser(req.params["idOrName"])); +})); + +app.post( + "/private/user/:idOrName/ban", + asyncCatch(async (req, res) => { + const user = await lookupUser(req.params["idOrName"]); + if (!user) { + res.sendStatus(404); + return; + } + + await setUserBanned(user, true); + res.sendStatus(200); + }) +); + +app.delete( + "/private/user/:idOrName/ban", + asyncCatch(async (req, res) => { + const user = await lookupUser(req.params["idOrName"]); + if (!user) { + res.sendStatus(404); + return; + } + + await setUserBanned(user, false); + res.sendStatus(200); + }) +); diff --git a/ebs/src/modules/transactions.ts b/ebs/src/modules/transactions.ts deleted file mode 100644 index 565d697..0000000 --- a/ebs/src/modules/transactions.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { Cart, Config, LogMessage, Transaction } from "common/types"; -import { app } from ".."; -import { parseJWT, verifyJWT } from "../util/jwt"; -import { BitsTransactionPayload } from "../types"; -import { getConfig } from "./config"; -import { addFulfilledTransaction, deletePrepurchase, getPrepurchase, isReceiptUsed, isUserBanned, registerPrepurchase } from "../util/db"; -import { sendToLogger } from "../util/logger"; -import { connection } from "./game"; -import { TwitchUser } from "./game/messages"; -import { asyncCatch } from "../util/middleware"; -import { sendShock } from "../util/pishock"; -import { getTwitchUser } from "./twitch"; - -app.post( - "/public/prepurchase", - asyncCatch(async (req, res) => { - const cart = req.body as Cart; - const idCart = { ...cart, userId: req.twitchAuthorization!.user_id! }; - - if (await isUserBanned(req.twitchAuthorization!.user_id!)) { - res.status(403).send("You are banned from using this extension."); - return; - } - - if (await isUserBanned(req.twitchAuthorization!.opaque_user_id!)) { - res.status(403).send("You are banned from using this extension."); - return; - } - - if (!connection.isConnected()) { - res.status(502).send("Game connection is not available"); - return; - } - - const logContext: LogMessage = { - transactionToken: null, - userIdInsecure: idCart.userId, - important: false, - fields: [ - { - header: "", - content: "", - }, - ], - }; - const logMessage = logContext.fields[0]; - - const config = await getConfig(); - if (cart.version != config.version) { - logMessage.header = "Invalid config version"; - logMessage.content = `Received: ${cart.version}\nExpected: ${config.version}`; - sendToLogger(logContext).then(); - res.status(409).send(`Invalid config version (${cart.version}/${config.version})`); - return; - } - - const redeem = config.redeems?.[cart.id]; - if (!redeem || redeem.sku != cart.sku || redeem.disabled || redeem.hidden) { - logMessage.header = "Invalid redeem"; - logMessage.content = `Received: ${JSON.stringify(cart)}\nRedeem in config: ${JSON.stringify(redeem)}`; - sendToLogger(logContext).then(); - res.status(409).send(`Invalid redeem`); - return; - } - - const valError = validateArgs(config, cart, logContext); - if (valError) { - logMessage.header = "Arg validation failed"; - logMessage.content = { - error: valError, - redeem: cart.id, - expected: redeem.args, - provided: cart.args, - }; - sendToLogger(logContext).then(); - res.status(409).send("Invalid arguments"); - return; - } - - let token: string; - try { - token = await registerPrepurchase(idCart); - } catch (e: any) { - logContext.important = true; - logMessage.header = "Failed to register prepurchase"; - logMessage.content = { cart: idCart, error: e }; - sendToLogger(logContext).then(); - res.status(500).send("Failed to register prepurchase"); - return; - } - - logMessage.header = "Created prepurchase"; - logMessage.content = { cart: idCart }; - sendToLogger(logContext).then(); - - res.status(200).send(token); - }) -); - -app.post( - "/public/transaction", - asyncCatch(async (req, res) => { - const transaction = req.body as Transaction; - - const logContext: LogMessage = { - transactionToken: transaction.token, - userIdInsecure: req.twitchAuthorization!.user_id!, - important: true, - fields: [ - { - header: "", - content: "", - }, - ], - }; - const logMessage = logContext.fields[0]; - - if (!transaction.receipt) { - logMessage.header = "Missing receipt"; - logMessage.content = transaction; - sendToLogger(logContext).then(); - res.status(400).send("Missing receipt"); - return; - } - - if (!verifyJWT(transaction.receipt)) { - logMessage.header = "Invalid receipt"; - logMessage.content = transaction; - sendToLogger(logContext).then(); - res.status(403).send("Invalid receipt."); - return; - } - - const payload = parseJWT(transaction.receipt) as BitsTransactionPayload; - - if (await isReceiptUsed(payload.data.transactionId)) { - logMessage.header = "Transaction already processed"; - logMessage.content = transaction; - sendToLogger(logContext).then(); - res.status(409).send("Transaction already processed"); - return; - } - - const cart = await getPrepurchase(transaction.token); - - if (!cart) { - logMessage.header = "Invalid transaction token"; - logMessage.content = transaction; - sendToLogger(logContext).then(); - res.status(404).send("Invalid transaction token"); - return; - } - - await addFulfilledTransaction(transaction.receipt, transaction.token, req.twitchAuthorization!.user_id!); - - if (cart.userId != req.twitchAuthorization!.user_id!) { - logContext.important = true; - logMessage.header = "Mismatched user ID"; - logMessage.content = { - auth: req.twitchAuthorization, - cart, - transaction, - }; - sendToLogger(logContext).then(); - } - - const currentConfig = await getConfig(); - if (cart.version != currentConfig.version) { - logContext.important = true; - logMessage.header = "Mismatched config version"; - logMessage.content = { - config: currentConfig.version, - cart: cart, - transaction: transaction, - }; - sendToLogger(logContext).then(); - } - - console.log(transaction); - console.log(cart); - - const redeem = currentConfig.redeems?.[cart.id]; - if (!redeem) { - logContext.important = true; - logMessage.header = "Redeem not found"; - logMessage.content = { - config: currentConfig.version, - cart: cart, - transaction: transaction, - }; - sendToLogger(logContext).then(); - res.status(500).send("Redeem could not be found"); - return; - } - - let userInfo: TwitchUser | null; - try { - userInfo = await getTwitchUser(cart.userId); - } catch { - userInfo = null; - } - if (!userInfo) { - logContext.important = true; - logMessage.header = "Could not get Twitch user info"; - logMessage.content = { - config: currentConfig.version, - cart: cart, - transaction: transaction, - error: userInfo, - }; - sendToLogger(logContext).then(); - // very much not ideal but they've already paid... so... - userInfo = { - id: cart.userId, - login: cart.userId, - displayName: cart.userId, - }; - } - try { - if (redeem.id == "redeem_pishock") { - const success = await sendShock(50, 100); - if (success) { - res.status(200).send("Your transaction was successful!"); - } else { - res.status(500).send("Redeem failed"); - } - return; - } - - const resMsg = await connection.redeem(redeem, cart, userInfo, transaction.token); - if (resMsg?.success) { - console.log(`[${resMsg.guid}] Redeem succeeded: ${JSON.stringify(resMsg)}`); - let msg = "Your transaction was successful! Your redeem will appear on stream soon."; - if (resMsg.message) { - msg += "\n\n" + resMsg.message; - } - res.status(200).send(msg); - } else { - logContext.important = true; - logMessage.header = "Redeem did not succeed"; - logMessage.content = resMsg; - sendToLogger(logContext).then(); - res.status(500).send(resMsg?.message ?? "Redeem failed"); - } - } catch (error) { - logContext.important = true; - logMessage.header = "Failed to send redeem"; - logMessage.content = { - config: currentConfig.version, - cart: cart, - transaction: transaction, - error: error, - }; - sendToLogger(logContext).then(); - res.status(500).send(`Failed to process redeem - ${error}`); - } - }) -); - -app.post( - "/public/transaction/cancel", - asyncCatch(async (req, res) => { - const token = req.body.token as string; - - // remove transaction from db - try { - await deletePrepurchase(token); - - res.sendStatus(200); - } catch (error) { - sendToLogger({ - transactionToken: token, - userIdInsecure: req.twitchAuthorization!.user_id!, - important: false, - fields: [ - { - header: "Error deleting transaction", - content: { - error: error, - }, - }, - ], - }).then(); - - res.sendStatus(404); - } - }) -); - -function validateArgs(config: Config, cart: Cart, logContext: LogMessage): string | undefined { - const redeem = config.redeems![cart.id]; - - for (const arg of redeem.args) { - const value = cart.args[arg.name]; - if (!value) { - if (!arg.required) continue; - - // LiteralTypes.Boolean - if (arg.type === 3) { - // HTML form conventions - false is not transmitted, true is "on" (to save 2 bytes i'm guessing) - continue; - } - - return `Missing required argument ${arg.name}`; - } - let parsed: number; - switch (arg.type) { - // esbuild dies if you use enums - // so we have to use their pure values instead - case 0: // LiteralTypes.String - if (typeof value !== "string") { - return `Argument ${arg.name} not a string`; - } - const minLength = arg.minLength ?? 0; - const maxLength = arg.maxLength ?? 255; - if (value.length < minLength || value.length > maxLength) { - return `Text length out of range for ${arg.name}`; - } - break; - case 1: // LiteralTypes.Integer - case 2: // LiteralTypes.Float - parsed = parseInt(value); - if (Number.isNaN(parsed)) { - return `Argument ${arg.name} is not a number`; - } - // LiteralTypes.Integer - if (arg.type === 1 && parseFloat(value) != parsed) { - return `Argument ${arg.name} is not an integer`; - } - if ((arg.min !== undefined && parsed < arg.min) || (arg.max !== undefined && parsed > arg.max)) { - return `Number ${arg.name} out of range`; - } - break; - case 3: // LiteralTypes.Boolean - if (typeof value !== "boolean" && value !== "true" && value !== "false" && value !== "on") { - return `Argument ${arg.name} not a boolean`; - } - if (value === "on") { - cart.args[arg.name] = true; - } - break; - case 4: // LiteralTypes.Vector - if (!Array.isArray(value) || value.length < 3) { - return `Vector3 ${arg.name} not a 3-elem array`; - } - // workaround for #49 - const lastThree = value.slice(value.length - 3); - for (const v of lastThree) { - parsed = parseFloat(v); - if (Number.isNaN(parsed)) { - return `Vector3 ${arg.name} components not all floats`; - } - } - cart!.args[arg.name] = lastThree; - break; - default: - const argEnum = config.enums?.[arg.type]; - if (!argEnum) { - return `No such enum ${arg.type}`; - } - parsed = parseInt(value); - if (Number.isNaN(parsed) || parsed != parseFloat(value)) { - return `Enum value ${value} (for enum ${arg.type}) not an integer`; - } - if (parsed < 0 || parsed >= argEnum.length) { - return `Enum value ${value} (for enum ${arg.type}) out of range`; - } - if (argEnum[parsed].startsWith("[DISABLED]")) { - return `Enum value ${value} (for enum ${arg.type}) is disabled`; - } - break; - } - } -} diff --git a/ebs/src/modules/twitch.ts b/ebs/src/modules/twitch.ts index 1643881..fdfcaf9 100644 --- a/ebs/src/modules/twitch.ts +++ b/ebs/src/modules/twitch.ts @@ -1,20 +1,15 @@ -import { app } from ".."; -import { asyncCatch } from "../util/middleware"; import { getHelixUser } from "../util/twitch"; import { TwitchUser } from "./game/messages"; -app.get("/private/user/:id", asyncCatch(async (req, res) => { - res.json(await getTwitchUser(req.params["id"])); -})); - export async function getTwitchUser(id: string): Promise { const user = await getHelixUser(id); if (!user) { + console.warn(`Twitch user ${id} was not found`); return null; } return { id: user.id, - displayName: user.displayName, login: user.name, + displayName: user.displayName, }; } \ No newline at end of file diff --git a/ebs/src/util/db.ts b/ebs/src/util/db.ts index a4d44d8..3a215ac 100644 --- a/ebs/src/util/db.ts +++ b/ebs/src/util/db.ts @@ -1,15 +1,12 @@ import { RowDataPacket } from "mysql2"; import mysql from "mysql2/promise"; -import { IdentifiableCart } from "common/types"; import { v4 as uuid } from "uuid"; +import { User, Order } from "common/types"; +import { getTwitchUser } from "../modules/twitch"; export let db: mysql.Connection; export async function initDb() { - if (!process.env.MYSQL_HOST) { - console.warn("No MYSQL_HOST specified (assuming local testing/development), skipping database setup"); - return; - } while (!db) { try { db = await mysql.createConnection({ @@ -17,125 +14,150 @@ export async function initDb() { user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, + namedPlaceholders: true, }); } catch { console.log("Failed to connect to database. Retrying in 5 seconds..."); await new Promise((resolve) => setTimeout(resolve, 5000)); } } - - await setupDb(); -} - -export async function setupDb() { - await db.query(` - CREATE TABLE IF NOT EXISTS transactions ( - receipt VARCHAR(1024) PRIMARY KEY, - token VARCHAR(255) NOT NULL, - userId VARCHAR(255) NOT NULL - ); - `); - - await db.query(` - CREATE TABLE IF NOT EXISTS prepurchases ( - token VARCHAR(255) PRIMARY KEY, - cart JSON NOT NULL, - userId VARCHAR(255) NOT NULL - ); - `); - - await db.query(` - CREATE TABLE IF NOT EXISTS bans ( - userId VARCHAR(255) PRIMARY KEY - ); - `); - - await db.query(` - CREATE TABLE IF NOT EXISTS logs ( - id INT PRIMARY KEY AUTO_INCREMENT, - userId VARCHAR(255), - transactionToken VARCHAR(255), - data TEXT NOT NULL, - fromBackend BOOLEAN NOT NULL, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - `); } -export async function isReceiptUsed(receipt: string): Promise { +export async function getOrder(guid: string) { try { - const [rows] = (await db.query("SELECT COUNT(*) FROM transactions WHERE receipt = ?", [receipt])) as [RowDataPacket[], any]; - return rows[0]["COUNT(*)"] != 0; + const [rows] = (await db.query("SELECT * FROM orders WHERE id = ?", [guid])) as [RowDataPacket[], any]; + if (!rows.length) { + return null; + } + return rows[0] as Order; } catch (e: any) { - console.error("Database query failed (isReceiptUsed)"); + console.error("Database query failed (getOrder)"); console.error(e); throw e; } } -export async function addFulfilledTransaction(receipt: string, token: string, userId: string) { +export async function createOrder(userId: string, initialState?: Omit, "id" | "userId" | "createdAt" | "updatedAt">) { + const order: Order = { + state: "rejected", + ...initialState, + id: uuid(), + userId, + createdAt: Date.now(), + updatedAt: Date.now(), + }; try { - await db.query("INSERT INTO transactions (receipt, token, userId) VALUES (?, ?, ?)", [receipt, token, userId]); - await db.query("DELETE FROM prepurchases WHERE token = ?", [token]); + await db.query(` + INSERT INTO orders (id, userId, state, cart, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?)`, + [order.id, order.userId, order.state, JSON.stringify(order.cart), order.createdAt, order.updatedAt]); + return order; } catch (e: any) { - console.error("Database query failed (addFulfilledTransaction)"); + console.error("Database query failed (createOrder)"); console.error(e); throw e; } } -export async function registerPrepurchase(cart: IdentifiableCart): Promise { +export async function saveOrder(order: Order) { + order.updatedAt = Date.now(); + await db.query( + ` + UPDATE orders + SET state = ?, cart = ?, receipt = ?, result = ?, updatedAt = ? + WHERE id = ? + `, + [order.state, JSON.stringify(order.cart), order.receipt, order.result, order.updatedAt, order.id] + ); +} + +export async function getOrAddUser(id: string): Promise { try { - const token = uuid(); - await db.query("INSERT INTO prepurchases (token, cart, userId) VALUES (?, ?, ?)", [token, JSON.stringify(cart), cart.userId]); - return token; + const [rows] = (await db.query("SELECT * FROM users WHERE id = ?", [id])) as [RowDataPacket[], any]; + if (!rows.length) { + return await createUser(id); + } + return rows[0] as User; } catch (e: any) { - console.error("Database query failed (registerPrepurchase)"); + console.error("Database query failed (getOrAddUser)"); console.error(e); throw e; } } -export async function getPrepurchase(token: string): Promise { +export async function lookupUser(idOrName: string) : Promise { try { - const [rows] = (await db.query("SELECT cart FROM prepurchases WHERE token = ?", [token])) as [RowDataPacket[], any]; - if (rows.length === 0) return undefined; - return rows[0].cart as IdentifiableCart; + const [rows] = (await db.query("SELECT * FROM users WHERE id = :idOrName OR login LIKE :idOrName OR displayName LIKE :idOrName", {idOrName})) as [RowDataPacket[], any]; + if (!rows.length) { + return null; + } + return rows[0] as User; } catch (e: any) { - console.error("Database query failed (isPrepurchaseValid)"); + console.error("Database query failed (getUser)"); console.error(e); throw e; } } -export async function deletePrepurchase(token: string) { +async function createUser(id: string): Promise { + const user: User = { + id, + banned: false, + }; try { - await db.query("DELETE FROM prepurchases WHERE token = ?", [token]); + await db.query( + ` + INSERT INTO users (id, login, displayName, banned) + VALUES (:id, :login, :displayName, :banned)`, + user + ); } catch (e: any) { - console.error("Database query failed (deletePrepurchase)"); + console.error("Database query failed (createUser)"); console.error(e); throw e; } + return user; } -export async function isUserBanned(userId: string): Promise { +export async function saveUser(user: User) { try { - const [rows] = (await db.query("SELECT COUNT(*) FROM bans WHERE userId = ?", [userId])) as [RowDataPacket[], any]; - return rows[0]["COUNT(*)"] != 0; + await db.query( + ` + UPDATE users + SET login = :login, displayName = :displayName, banned = :banned + WHERE id = :id`, + { ...user } + ); } catch (e: any) { - console.error("Database query failed (isBanned)"); + console.error("Database query failed (saveUser)"); console.error(e); throw e; } } -export async function getBannedUsers(): Promise { +export async function updateUserTwitchInfo(user: User): Promise { try { - const [rows] = (await db.query("SELECT userId FROM bans")) as [RowDataPacket[], any]; - return rows.map((row) => row.userId); + user = { + ...user, + ...await getTwitchUser(user.id), + }; + } catch (e: any) { + console.error("Twitch API GetUsers call failed (updateUserTwitchInfo)"); + console.error(e); + throw e; + } + try { + await db.query( + ` + UPDATE users + SET login = :login, displayName = :displayName + WHERE id = :id`, + { ...user } + ); } catch (e: any) { - console.error("Database query failed (getBannedUsers)"); + console.error("Database query failed (updateUserTwitchInfo)"); console.error(e); throw e; } + return user; } diff --git a/ebs/src/util/jwt.ts b/ebs/src/util/jwt.ts index cdb0ce9..b34c9fd 100644 --- a/ebs/src/util/jwt.ts +++ b/ebs/src/util/jwt.ts @@ -22,8 +22,7 @@ export function parseJWT(token: string) { } function getJwtSecretBuffer() { - if (cachedBuffer) return cachedBuffer; - return cachedBuffer = Buffer.from(process.env.JWT_SECRET!, "base64"); + return cachedBuffer ??= Buffer.from(process.env.JWT_SECRET!, "base64"); } export function signJWT(payload: object, buffer: Buffer = getJwtSecretBuffer()) { diff --git a/ebs/src/util/middleware.ts b/ebs/src/util/middleware.ts index df73d32..547c3cb 100644 --- a/ebs/src/util/middleware.ts +++ b/ebs/src/util/middleware.ts @@ -2,8 +2,10 @@ import { NextFunction, Request, Response } from "express"; import { parseJWT, verifyJWT } from "./jwt"; import { AuthorizationPayload } from "../types"; import { sendToLogger } from "./logger"; +import { User } from "common/types"; +import { getOrAddUser } from "./db"; -export function publicApiAuth(req: Request, res: Response, next: NextFunction) { +export async function publicApiAuth(req: Request, res: Response, next: NextFunction) { const auth = req.header("Authorization"); if (!auth || !auth.startsWith("Bearer ")) { @@ -17,9 +19,9 @@ export function publicApiAuth(req: Request, res: Response, next: NextFunction) { return; } - req.twitchAuthorization = parseJWT(token) as AuthorizationPayload; + const twitchAuthorization = parseJWT(token) as AuthorizationPayload; - if (!req.twitchAuthorization.user_id) { + if (!twitchAuthorization.user_id) { sendToLogger({ transactionToken: null, userIdInsecure: null, @@ -27,7 +29,7 @@ export function publicApiAuth(req: Request, res: Response, next: NextFunction) { fields: [ { header: "Missing user ID in JWT", - content: req.twitchAuthorization, + content: twitchAuthorization, }, ], }).then(); @@ -35,6 +37,13 @@ export function publicApiAuth(req: Request, res: Response, next: NextFunction) { return; } + req.user = await getOrAddUser(twitchAuthorization.user_id); + + if (req.user.banned) { + res.status(403).send("You are banned from using this extension"); + return; + } + next(); } @@ -51,7 +60,7 @@ export function privateApiAuth(req: Request, res: Response, next: NextFunction) declare global { namespace Express { export interface Request { - twitchAuthorization?: AuthorizationPayload; + user: User; } } } diff --git a/ebs/src/util/twitch.ts b/ebs/src/util/twitch.ts index b7a9019..976e39c 100644 --- a/ebs/src/util/twitch.ts +++ b/ebs/src/util/twitch.ts @@ -3,9 +3,9 @@ import { RefreshingAuthProvider } from "@twurple/auth"; const authProvider = new RefreshingAuthProvider({ clientId: process.env.TWITCH_API_CLIENT_ID!, - clientSecret: process.env.TWITCH_API_CLIENT_SECRET! + clientSecret: process.env.TWITCH_API_CLIENT_SECRET!, }); -const api = new ApiClient({authProvider, batchDelay: 100}); +const api = new ApiClient({ authProvider, batchDelay: 100 }); export async function getHelixUser(userId: string): Promise { return api.users.getUserByIdBatched(userId); diff --git a/frontend/www/src/modules/auth.ts b/frontend/www/src/modules/auth.ts index 658689a..386aa73 100644 --- a/frontend/www/src/modules/auth.ts +++ b/frontend/www/src/modules/auth.ts @@ -3,16 +3,43 @@ import { ebsFetch } from "../util/ebs"; import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, transactionToken } from "./modal"; import { logToDiscord } from "../util/logger"; import { renderRedeemButtons } from "./redeems"; +import { refreshConfig, setConfig } from "../util/config"; const $loginPopup = document.getElementById("onboarding")!; const $loginButton = document.getElementById("twitch-login")!; document.addEventListener("DOMContentLoaded", () => ($loginButton.onclick = Twitch.ext.actions.requestIdShare)); +let _banned = false; +export function getBanned() { + return _banned; +} + +export async function setBanned(banned: boolean) { + if (_banned === banned) return; + + _banned = banned; + if (banned) { + setConfig({ version: -1, message: "You have been banned from using this extension." }); + renderRedeemButtons().then(); + } else { + await refreshConfig(); + renderRedeemButtons().then(); + } +} + Twitch.ext.onAuthorized(() => { $loginPopup.style.display = Twitch.ext.viewer.id ? "none" : ""; if (Twitch.ext.viewer.id) { - renderRedeemButtons().then(); + ebsFetch("public/authorized", { + method: "POST", + body: JSON.stringify({ userId: Twitch.ext.viewer.id }), + }).then((res) => { + if (res.status === 403) { + setBanned(true); + } + renderRedeemButtons().then(); + }); } }); @@ -108,10 +135,10 @@ Twitch.ext.bits.onTransactionCancelled(async () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ "token": transactionToken }), + body: JSON.stringify({ token: transactionToken }), }); } - + hideProcessingModal(); showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken}`); }); diff --git a/frontend/www/src/modules/modal.ts b/frontend/www/src/modules/modal.ts index cbdd67e..2b2fa92 100644 --- a/frontend/www/src/modules/modal.ts +++ b/frontend/www/src/modules/modal.ts @@ -2,6 +2,7 @@ import { BooleanParam, Cart, EnumParam, LiteralTypes, NumericParam, Parameter, R import { ebsFetch } from "../util/ebs"; import { getConfig } from "../util/config"; import { logToDiscord } from "../util/logger"; +import { setBanned } from "./auth"; document.body.addEventListener("dblclick", (e) => { e.stopPropagation(); @@ -247,6 +248,7 @@ async function prePurchase() { if (!response.ok) { hideProcessingModal(); if (response.status == 403) { + setBanned(true); showErrorModal("You are banned from using this extension.", `${response.status} ${response.statusText} - ${await response.text()}\n`); } else { showErrorModal( diff --git a/frontend/www/src/modules/pubsub.ts b/frontend/www/src/modules/pubsub.ts index f7cd3ee..06530b4 100644 --- a/frontend/www/src/modules/pubsub.ts +++ b/frontend/www/src/modules/pubsub.ts @@ -1,19 +1,28 @@ -import { Config, PubSubMessage } from "common/types"; -import { postProcessConfig, setConfig } from "../util/config"; +import { BannedData, Config, PubSubMessage } from "common/types"; +import { setConfig } from "../util/config"; import { renderRedeemButtons } from "./redeems"; import { strToU8, decompressSync, strFromU8 } from "fflate"; +import { getBanned, setBanned } from "./auth"; Twitch.ext.listen("global", async (_t, _c, message) => { - const pubSubMessage = JSON.parse(message) as PubSubMessage; + const fullMessage = JSON.parse(message) as PubSubMessage; - console.log(pubSubMessage); + console.log(fullMessage); - switch (pubSubMessage.type) { + switch (fullMessage.type) { case "config_refreshed": - const config = JSON.parse(strFromU8(decompressSync(strToU8(pubSubMessage.data, true)))) as Config; - // console.log(config); - await setConfig(postProcessConfig(config)); - await renderRedeemButtons(); + const config = JSON.parse(strFromU8(decompressSync(strToU8(fullMessage.data, true)))) as Config; + setConfig(config); + if (!getBanned()) { + await renderRedeemButtons(); + } + break; + case "banned": + const data = JSON.parse(fullMessage.data) as BannedData; + const bannedId = data.id; + if (bannedId === Twitch.ext.viewer.id) { + setBanned(data.banned); + } break; } }); diff --git a/frontend/www/src/util/config.ts b/frontend/www/src/util/config.ts index cbdaacc..220f070 100644 --- a/frontend/www/src/util/config.ts +++ b/frontend/www/src/util/config.ts @@ -3,36 +3,29 @@ import { Config } from "common/types"; let config: Config; -export function postProcessConfig(config: Config): Config { - if (config.banned && config.banned.includes(Twitch.ext.viewer.id!)) { - return { - version: -1, - redeems: {}, - enums: {}, - banned: [Twitch.ext.viewer.id!], - message: "You are banned from using this extension", - } satisfies Config; - } - - return config; -} +const emptyConfig: Config = { + version: -1, + redeems: {}, + enums: {}, +}; async function fetchConfig() { const response = await ebsFetch("/public/config"); if (!response.ok) { return { - version: -1, - redeems: {}, - enums: {}, - banned: [], + ...emptyConfig, message: `An error occurred while fetching the config\n${response.status} ${response.statusText} - ${await response.text()}`, } satisfies Config; } const config: Config = await response.json(); - return postProcessConfig(config); + return config; +} + +export async function refreshConfig() { + config = await fetchConfig(); } export async function getConfig(): Promise { @@ -43,6 +36,6 @@ export async function getConfig(): Promise { return config; } -export async function setConfig(newConfig: Config) { +export function setConfig(newConfig: Config) { config = newConfig; } diff --git a/frontend/www/src/util/logger.ts b/frontend/www/src/util/logger.ts index b93c264..8f8aa7b 100644 --- a/frontend/www/src/util/logger.ts +++ b/frontend/www/src/util/logger.ts @@ -15,12 +15,12 @@ export async function logToDiscord(data: LogMessage) { }); if (!result.ok) { - console.error("Failed to log to Discord"); + console.error("Failed to log to backend"); console.error(await result.text()); console.log(data); } } catch (e: any) { - console.error("Error when logging to Discord"); + console.error("Error when logging to backend"); console.error(e); console.log(data); } diff --git a/logger/src/index.ts b/logger/src/index.ts index 262e5a5..552afb7 100644 --- a/logger/src/index.ts +++ b/logger/src/index.ts @@ -1,10 +1,8 @@ -import { config as dotenv } from "dotenv"; +import "dotenv/config"; import cors from "cors"; import express from "express"; import bodyParser from "body-parser"; -import mysql from "mysql2/promise"; - -dotenv(); +import { initDb } from "./util/db"; const port = 3000; @@ -16,23 +14,8 @@ app.get("/", (_, res) => { res.send("YOU ARE TRESPASSING ON PRIVATE PROPERTY YOU HAVE 5 SECONDS TO GET OUT OR I WILL CALL THE POLICE"); }); -export let db: mysql.Connection; - async function main() { - while (true) { - try { - db = await mysql.createConnection({ - host: process.env.MYSQL_HOST, - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD, - database: process.env.MYSQL_DATABASE, - }); - break; - } catch { - console.log("Failed to connect to database. Retrying in 5 seconds..."); - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - } + await initDb(); app.listen(port, () => { console.log("Listening on port " + port); diff --git a/logger/src/modules/endpoints.ts b/logger/src/modules/endpoints.ts index c67e88f..943569d 100644 --- a/logger/src/modules/endpoints.ts +++ b/logger/src/modules/endpoints.ts @@ -1,29 +1,28 @@ import { app } from ".."; import { logToDiscord } from "../util/discord"; -import { LogMessage } from "common/types"; -import { canLog, getUserIdFromTransactionToken, isUserBanned, logToDatabase } from "../util/db"; +import { LogMessage, OrderState } from "common/types"; +import { getOrderById, getUserById, logToDatabase } from "../util/db"; + +// prevent replaying completed transactions +const orderStatesCanLog: { [key in OrderState]: boolean } = { + rejected: false, // completed + prepurchase: true, // idk some js errors or something + cancelled: false, // completed + paid: true, // log timeout response + failed: true, // log error + succeeded: false, // completed +}; +const rejectLogsWithNoToken = true; app.post("/log", async (req, res) => { try { const logMessage = req.body as LogMessage & { backendToken?: string }; const isBackendRequest = process.env.PRIVATE_LOGGER_TOKEN == logMessage.backendToken; - if (!isBackendRequest) { - const validTransactionToken = await canLog(logMessage.transactionToken); - if (!validTransactionToken) { - res.status(403).send("Invalid transaction token."); - return; - } - - // Even if the transaction token is valid, this might be a malicious request using a previously created token. - // In the eventuality that this happens, we also check for extension bans here. - - const userId = await getUserIdFromTransactionToken(logMessage.transactionToken!); - - if (userId && (await isUserBanned(userId))) { - res.status(403).send("User is banned."); - return; - } + const logDenied = await canLog(logMessage, isBackendRequest); + if (logDenied) { + res.status(logDenied.status).send(logDenied.reason); + return; } await logToDatabase(logMessage, isBackendRequest); @@ -39,3 +38,65 @@ app.post("/log", async (req, res) => { res.status(500).send("Failed to log"); } }); + +type LogDenied = { + status: number; + reason: string; +}; +async function canLog(logMessage: LogMessage, isBackendRequest: boolean): Promise { + if (isBackendRequest) return null; + + if (!logMessage.transactionToken && rejectLogsWithNoToken) return { status: 400, reason: "Invalid transaction token." }; + + const claimedUser = await getUserById(logMessage.userIdInsecure); + if (!claimedUser) { + return { status: 403, reason: "Invalid user id." }; + } + if (claimedUser.banned) { + return { status: 403, reason: "User is banned." }; + } + + const order = await getOrderById(logMessage.transactionToken); + if (!order || !orderStatesCanLog[order.state]) { + return { status: 400, reason: "Invalid transaction token." }; + } + + const errorContext: LogMessage = { + transactionToken: logMessage.transactionToken, + userIdInsecure: logMessage.userIdInsecure, + important: true, + fields: [{ header: "", content: {} }], + }; + const errorMessage = errorContext.fields[0]; + + const user = await getUserById(order.userId); + if (!user) { + errorMessage.header = "Tried to log for order whose userId is not in users table"; + errorMessage.content = { + orderUser: order.userId, + order: order.id, + logMessage, + }; + logToDiscord(errorContext, false); + logToDatabase(errorContext, false).then(); + return { status: 500, reason: "Invalid user id in transaction." }; + } + if (user.id != logMessage.userIdInsecure) { + errorMessage.header = "Someone tried to bamboozle the logger user id check"; + errorMessage.content = { + claimedUser: logMessage.userIdInsecure, + orderUser: user.id, + order: order.id, + logMessage, + }; + logToDiscord(errorContext, false); + logToDatabase(errorContext, false).then(); + return { status: 403, reason: "Invalid user id." }; + } + + if (user.banned) { + return { status: 403, reason: "User is banned." }; + } + + return null; +} diff --git a/logger/src/util/db.ts b/logger/src/util/db.ts index 49bdb7e..819fc67 100644 --- a/logger/src/util/db.ts +++ b/logger/src/util/db.ts @@ -1,52 +1,46 @@ -import { db } from ".."; import { RowDataPacket } from "mysql2"; -import { LogMessage } from "common/types"; +import { LogMessage, Order, User } from "common/types"; import { stringify } from "./stringify"; import { logToDiscord } from "./discord"; +import mysql from "mysql2/promise"; -export async function canLog(token: string | null): Promise { - try { - if (!token) return false; - - const [rows] = (await db.query("SELECT COUNT(*) FROM prepurchases WHERE token = ?", [token])) as any; - if (rows[0]["COUNT(*)"] != 0) return true; +export let db: mysql.Connection; - const [rows2] = (await db.query("SELECT COUNT(*) FROM transactions WHERE token = ?", [token])) as any; - if (rows2[0]["COUNT(*)"] != 0) return true; - - return false; - } catch (e: any) { - console.error("Database query failed (canLog)"); - console.error(e); - return true; +export async function initDb() { + while (!db) { + try { + db = await mysql.createConnection({ + host: process.env.MYSQL_HOST, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, + namedPlaceholders: true, + }); + } catch { + console.log("Failed to connect to database. Retrying in 5 seconds..."); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } } } -export async function getUserIdFromTransactionToken(token: string): Promise { +async function getById(table: string, id: string | null): Promise { try { - try { - const [rows] = (await db.query("SELECT userId FROM prepurchases WHERE token = ?", [token])) as [RowDataPacket[], any]; - return rows[0].userId; - } catch (e: any) { - const [rows] = (await db.query("SELECT userId FROM transactions WHERE token = ?", [token])) as [RowDataPacket[], any]; - return rows[0].userId; - } + if (!id) return null; + const [rows] = (await db.query(`SELECT * FROM ${table} WHERE id = ?`, [id])) as [RowDataPacket[], any]; + return (rows[0] as T) || null; } catch (e: any) { - console.error("Database query failed (getUserIdFromTransactionToken)"); + console.error(`Database query failed (getById from ${table})`); console.error(e); return null; } } -export async function isUserBanned(userId: string): Promise { - try { - const [rows] = (await db.query("SELECT COUNT(*) FROM bans WHERE userId = ?", [userId])) as [RowDataPacket[], any]; - return rows[0]["COUNT(*)"] != 0; - } catch (e: any) { - console.error("Database query failed (isBanned)"); - console.error(e); - return false; - } +export async function getOrderById(orderId: string | null): Promise { + return getById("orders", orderId); +} + +export async function getUserById(userId: string | null): Promise { + return getById("users", userId); } export async function logToDatabase(logMessage: LogMessage, isFromBackend: boolean) { diff --git a/scripts/sql/init_db.sql b/scripts/sql/init_db.sql new file mode 100644 index 0000000..a037ba4 --- /dev/null +++ b/scripts/sql/init_db.sql @@ -0,0 +1,28 @@ +USE ebs; + +CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(255) PRIMARY KEY, + login VARCHAR(255), + displayName VARCHAR(255), + banned BOOLEAN +); + +CREATE TABLE IF NOT EXISTS orders ( + id VARCHAR(36) PRIMARY KEY, + userId VARCHAR(255) NOT NULL, + state ENUM('rejected', 'prepurchase', 'cancelled', 'paid', 'failed', 'succeeded'), + cart JSON, + receipt VARCHAR(1024), + result TEXT, + createdAt BIGINT, + updatedAt BIGINT +); + +CREATE TABLE IF NOT EXISTS logs ( + id INT PRIMARY KEY AUTO_INCREMENT, + userId VARCHAR(255), + transactionToken VARCHAR(255), + data TEXT NOT NULL, + fromBackend BOOLEAN NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP +);