From 8399c297dcd24a4ae07591ae8ea5e1d028ebdc0b Mon Sep 17 00:00:00 2001 From: Govorunb Date: Wed, 10 Jul 2024 15:38:23 +1000 Subject: [PATCH 01/22] initial setup promisify twitch extension helper --- frontend/www/src/modules/auth.ts | 139 ++++-------------------- frontend/www/src/modules/modal.ts | 9 +- frontend/www/src/modules/transaction.ts | 104 ++++++++++++++++++ frontend/www/src/util/twitch.ts | 35 ++++++ 4 files changed, 167 insertions(+), 120 deletions(-) create mode 100644 frontend/www/src/modules/transaction.ts create mode 100644 frontend/www/src/util/twitch.ts diff --git a/frontend/www/src/modules/auth.ts b/frontend/www/src/modules/auth.ts index 386aa73..9b1fd82 100644 --- a/frontend/www/src/modules/auth.ts +++ b/frontend/www/src/modules/auth.ts @@ -1,14 +1,30 @@ -import { Transaction } from "common/types"; 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"; +import { twitchAuth } from "../util/twitch"; const $loginPopup = document.getElementById("onboarding")!; const $loginButton = document.getElementById("twitch-login")!; -document.addEventListener("DOMContentLoaded", () => ($loginButton.onclick = Twitch.ext.actions.requestIdShare)); +document.addEventListener("DOMContentLoaded", () => { + $loginButton.onclick = async () => { + const res = await twitchAuth(); + if (res === "revoked") { + $loginPopup.style.display = ""; + return; + } + $loginPopup.style.display = "none"; + ebsFetch("public/authorized", { + method: "POST", + body: JSON.stringify({ channelId: res.channelId, userId: res.userId }), + }).then((res) => { + if (res.status === 403) { + setBanned(true); + } + renderRedeemButtons().then(); + }); + } +}); let _banned = false; export function getBanned() { @@ -27,118 +43,3 @@ export async function setBanned(banned: boolean) { renderRedeemButtons().then(); } } - -Twitch.ext.onAuthorized(() => { - $loginPopup.style.display = Twitch.ext.viewer.id ? "none" : ""; - if (Twitch.ext.viewer.id) { - ebsFetch("public/authorized", { - method: "POST", - body: JSON.stringify({ userId: Twitch.ext.viewer.id }), - }).then((res) => { - if (res.status === 403) { - setBanned(true); - } - renderRedeemButtons().then(); - }); - } -}); - -Twitch.ext.bits.onTransactionComplete(async (transaction) => { - if (!transactionToken) { - logToDiscord({ - transactionToken: null, - userIdInsecure: Twitch.ext.viewer.id!, - important: true, - fields: [ - { - header: "Missing transaction token", - content: transaction, - }, - ], - }).then(); - await openModal(null); - hideProcessingModal(); - showErrorModal( - "An error occurred.", - "If you made a purchase from another tab/browser/mobile, you can safely ignore this message. Otherwise, please contant a moderator (preferably AlexejheroDev) about this!" - ); - return; - } - - logToDiscord({ - transactionToken: transactionToken, - userIdInsecure: Twitch.ext.viewer.id!, - important: false, - fields: [ - { - header: "Transaction complete", - content: transaction, - }, - ], - }).then(); - - const transactionObject: Transaction = { - token: transactionToken, - receipt: transaction.transactionReceipt, - }; - - const result = await ebsFetch("/public/transaction", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(transactionObject), - }); - - setTimeout(() => hideProcessingModal(), 250); - - const text = await result.text(); - if (result.ok) { - // Transaction token can no longer be used to log - showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken}`); - } else { - const errorText = `${result.status} ${result.statusText} - ${text}`; - logToDiscord({ - transactionToken: transactionToken, - userIdInsecure: Twitch.ext.viewer.id!, - important: true, - fields: [ - { - header: "Transaction failed (frontend)", - content: errorText, - }, - ], - }).then(); - showErrorModal( - "An error occurred.", - `${errorText}\nPlease contact a moderator (preferably AlexejheroDev) about this!\nTransaction ID: ${transactionToken}` - ); - } -}); - -Twitch.ext.bits.onTransactionCancelled(async () => { - if (transactionToken) { - logToDiscord({ - transactionToken: transactionToken, - userIdInsecure: Twitch.ext.viewer.id!, - important: false, - fields: [ - { - header: "Transaction cancelled", - content: "User cancelled the transaction.", - }, - ], - }).then(); - - await ebsFetch("/public/transaction/cancel", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - 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 2b2fa92..0bc1de0 100644 --- a/frontend/www/src/modules/modal.ts +++ b/frontend/www/src/modules/modal.ts @@ -3,6 +3,8 @@ import { ebsFetch } from "../util/ebs"; import { getConfig } from "../util/config"; import { logToDiscord } from "../util/logger"; import { setBanned } from "./auth"; +import { twitchUseBits } from "../util/twitch"; +import { transactionCancelled, transactionComplete } from "./transaction"; document.body.addEventListener("dblclick", (e) => { e.stopPropagation(); @@ -233,7 +235,12 @@ async function confirmPurchase() { ], }).then(); - Twitch.ext.bits.useBits(cart!.sku); + const res = await twitchUseBits(cart!.sku); + if (res === "cancelled") { + await transactionCancelled(); + } else { + await transactionComplete(res); + } } async function prePurchase() { diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts new file mode 100644 index 0000000..37955b1 --- /dev/null +++ b/frontend/www/src/modules/transaction.ts @@ -0,0 +1,104 @@ +import { Transaction } from "common/types"; +import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, transactionToken } from "./modal"; +import { logToDiscord } from "../util/logger"; +import { ebsFetch } from "../util/ebs"; + +export async function transactionComplete(transaction: Twitch.ext.BitsTransaction) { + if (!transactionToken) { + logToDiscord({ + transactionToken: null, + userIdInsecure: Twitch.ext.viewer.id!, + important: true, + fields: [ + { + header: "Missing transaction token", + content: transaction, + }, + ], + }).then(); + await openModal(null); + hideProcessingModal(); + showErrorModal( + "An error occurred.", + "If you made a purchase from another tab/browser/mobile, you can safely ignore this message. Otherwise, please contant a moderator (preferably AlexejheroDev) about this!" + ); + return; + } + + logToDiscord({ + transactionToken: transactionToken, + userIdInsecure: Twitch.ext.viewer.id!, + important: false, + fields: [ + { + header: "Transaction complete", + content: transaction, + }, + ], + }).then(); + + const transactionObject: Transaction = { + token: transactionToken, + receipt: transaction.transactionReceipt, + }; + + const result = await ebsFetch("/public/transaction", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(transactionObject), + }); + + setTimeout(() => hideProcessingModal(), 250); + + const text = await result.text(); + if (result.ok) { + // Transaction token can no longer be used to log + showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken}`); + } else { + const errorText = `${result.status} ${result.statusText} - ${text}`; + logToDiscord({ + transactionToken: transactionToken, + userIdInsecure: Twitch.ext.viewer.id!, + important: true, + fields: [ + { + header: "Transaction failed (frontend)", + content: errorText, + }, + ], + }).then(); + showErrorModal( + "An error occurred.", + `${errorText}\nPlease contact a moderator (preferably AlexejheroDev) about this!\nTransaction ID: ${transactionToken}` + ); + } +}; + +export async function transactionCancelled() { + if (transactionToken) { + logToDiscord({ + transactionToken: transactionToken, + userIdInsecure: Twitch.ext.viewer.id!, + important: false, + fields: [ + { + header: "Transaction cancelled", + content: "User cancelled the transaction.", + }, + ], + }).then(); + + await ebsFetch("/public/transaction/cancel", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: transactionToken }), + }); + } + + hideProcessingModal(); + showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken}`); +}; diff --git a/frontend/www/src/util/twitch.ts b/frontend/www/src/util/twitch.ts new file mode 100644 index 0000000..2d2ac09 --- /dev/null +++ b/frontend/www/src/util/twitch.ts @@ -0,0 +1,35 @@ + +type AuthResponse = Twitch.ext.Authorized | "revoked"; +type TransactionResponse = Twitch.ext.BitsTransaction | "cancelled"; + +const authCallbacks: ((viewer: AuthResponse) => void)[] = []; +const transactionCallbacks: ((transaction: TransactionResponse) => void)[] = []; + +export async function twitchAuth(): Promise { + // authorized if id is set + if (!Twitch.ext.viewer.id) { + Twitch.ext.actions.requestIdShare(); + } + return new Promise(resolve => authCallbacks.push(resolve)); +} + +export async function twitchUseBits(sku: string): Promise { + Twitch.ext.bits.useBits(sku); + return new Promise(resolve => transactionCallbacks.push(resolve)); +} + +Twitch.ext.onAuthorized((auth) => { + const res: AuthResponse = auth.userId ? auth : "revoked"; + authCallbacks.forEach((callback) => callback(res)); + authCallbacks.splice(0, authCallbacks.length); +}) + +Twitch.ext.bits.onTransactionComplete((transaction) => { + transactionCallbacks.forEach((callback) => callback(transaction)); + transactionCallbacks.splice(0, transactionCallbacks.length); +}); + +Twitch.ext.bits.onTransactionCancelled(() => { + transactionCallbacks.forEach((callback) => callback("cancelled")); + transactionCallbacks.splice(0, transactionCallbacks.length); +}) \ No newline at end of file From 0e10ba1fd88558f291f3e08b518d214a5844ac78 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Wed, 10 Jul 2024 15:49:07 +1000 Subject: [PATCH 02/22] add credit column --- common/types.ts | 1 + ebs/src/modules/orders/index.ts | 2 +- ebs/src/util/db.ts | 42 ++++++++++++++++++++------------- scripts/sql/init_db.sql | 3 ++- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/common/types.ts b/common/types.ts index 13bba18..9129e89 100644 --- a/common/types.ts +++ b/common/types.ts @@ -109,6 +109,7 @@ export type User = { login?: string; displayName?: string; banned: boolean; + credit: number; }; export type OrderState = diff --git a/ebs/src/modules/orders/index.ts b/ebs/src/modules/orders/index.ts index ce9ebd4..a29b15d 100644 --- a/ebs/src/modules/orders/index.ts +++ b/ebs/src/modules/orders/index.ts @@ -39,7 +39,7 @@ app.post( } let order: Order; try { - order = await createOrder(userId, { cart }); + order = await createOrder(userId, cart); } catch (e: any) { logContext.important = true; logMessage.header = "Failed to register prepurchase"; diff --git a/ebs/src/util/db.ts b/ebs/src/util/db.ts index 3a215ac..c98e28b 100644 --- a/ebs/src/util/db.ts +++ b/ebs/src/util/db.ts @@ -1,7 +1,7 @@ import { RowDataPacket } from "mysql2"; import mysql from "mysql2/promise"; import { v4 as uuid } from "uuid"; -import { User, Order } from "common/types"; +import { User, Order, Cart } from "common/types"; import { getTwitchUser } from "../modules/twitch"; export let db: mysql.Connection; @@ -37,20 +37,21 @@ export async function getOrder(guid: string) { } } -export async function createOrder(userId: string, initialState?: Omit, "id" | "userId" | "createdAt" | "updatedAt">) { +export async function createOrder(userId: string, cart: Cart) { const order: Order = { state: "rejected", - ...initialState, + cart, id: uuid(), userId, createdAt: Date.now(), updatedAt: Date.now(), }; try { - await db.query(` - INSERT INTO orders (id, userId, state, cart, createdAt, updatedAt) + 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]); + [order.id, order.userId, order.state, JSON.stringify(order.cart), order.createdAt, order.updatedAt] + ); return order; } catch (e: any) { console.error("Database query failed (createOrder)"); @@ -62,11 +63,9 @@ export async function createOrder(userId: string, initialState?: Omit { } } -export async function lookupUser(idOrName: string) : Promise { +export async function lookupUser(idOrName: string): Promise { try { - const [rows] = (await db.query("SELECT * FROM users WHERE id = :idOrName OR login LIKE :idOrName OR displayName LIKE :idOrName", {idOrName})) as [RowDataPacket[], any]; + const lookupStr = `%${idOrName}%`; + const [rows] = (await db.query( + `SELECT * FROM users + WHERE id = :idOrName + OR login LIKE :lookupStr + OR displayName LIKE :lookupStr`, + { idOrName, lookupStr } + )) as [RowDataPacket[], any]; if (!rows.length) { return null; } @@ -103,12 +109,13 @@ async function createUser(id: string): Promise { const user: User = { id, banned: false, + credit: 0, }; try { await db.query( ` - INSERT INTO users (id, login, displayName, banned) - VALUES (:id, :login, :displayName, :banned)`, + INSERT INTO users (id, login, displayName, banned, credit) + VALUES (:id, :login, :displayName, :banned, :credit)`, user ); } catch (e: any) { @@ -124,7 +131,10 @@ export async function saveUser(user: User) { await db.query( ` UPDATE users - SET login = :login, displayName = :displayName, banned = :banned + SET login = :login, + displayName = :displayName, + banned = :banned, + credit = :credit WHERE id = :id`, { ...user } ); @@ -139,7 +149,7 @@ export async function updateUserTwitchInfo(user: User): Promise { try { user = { ...user, - ...await getTwitchUser(user.id), + ...(await getTwitchUser(user.id)), }; } catch (e: any) { console.error("Twitch API GetUsers call failed (updateUserTwitchInfo)"); diff --git a/scripts/sql/init_db.sql b/scripts/sql/init_db.sql index a037ba4..f013cea 100644 --- a/scripts/sql/init_db.sql +++ b/scripts/sql/init_db.sql @@ -4,7 +4,8 @@ CREATE TABLE IF NOT EXISTS users ( id VARCHAR(255) PRIMARY KEY, login VARCHAR(255), displayName VARCHAR(255), - banned BOOLEAN + banned BOOLEAN NOT NULL DEFAULT 0, + credit INT NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS orders ( From 45a58608b4931200735ed9a2016d56d265735e5d Mon Sep 17 00:00:00 2001 From: Govorunb Date: Wed, 10 Jul 2024 23:36:22 +1000 Subject: [PATCH 03/22] refactor move endpoints to separate files --- ebs/src/index.ts | 4 +- ebs/src/modules/config/endpoints.ts | 48 +++ .../modules/{config.ts => config/index.ts} | 121 +++---- ebs/src/modules/game/endpoints.ts | 80 +++++ ebs/src/modules/game/index.ts | 78 +---- ebs/src/modules/orders/endpoints/index.ts | 2 + ebs/src/modules/orders/endpoints/private.ts | 6 + ebs/src/modules/orders/endpoints/public.ts | 319 ++++++++++++++++++ ebs/src/modules/orders/index.ts | 318 +---------------- .../modules/{twitch.ts => twitch/index.ts} | 4 +- .../{orders/user.ts => user/endpoints.ts} | 16 +- ebs/src/modules/user/index.ts | 14 + 12 files changed, 520 insertions(+), 490 deletions(-) create mode 100644 ebs/src/modules/config/endpoints.ts rename ebs/src/modules/{config.ts => config/index.ts} (64%) create mode 100644 ebs/src/modules/game/endpoints.ts create mode 100644 ebs/src/modules/orders/endpoints/index.ts create mode 100644 ebs/src/modules/orders/endpoints/private.ts create mode 100644 ebs/src/modules/orders/endpoints/public.ts rename ebs/src/modules/{twitch.ts => twitch/index.ts} (77%) rename ebs/src/modules/{orders/user.ts => user/endpoints.ts} (71%) create mode 100644 ebs/src/modules/user/index.ts diff --git a/ebs/src/index.ts b/ebs/src/index.ts index 308946d..05feb4d 100644 --- a/ebs/src/index.ts +++ b/ebs/src/index.ts @@ -25,10 +25,12 @@ async function main() { app.listen(port, () => { console.log("Listening on port " + port); + // add endpoints require("./modules/config"); - require("./modules/orders"); require("./modules/game"); + require("./modules/orders"); require("./modules/twitch"); + require("./modules/user"); const { setIngame } = require("./modules/config"); diff --git a/ebs/src/modules/config/endpoints.ts b/ebs/src/modules/config/endpoints.ts new file mode 100644 index 0000000..0bd2ed5 --- /dev/null +++ b/ebs/src/modules/config/endpoints.ts @@ -0,0 +1,48 @@ +import { Webhooks } from "@octokit/webhooks"; +import { getConfig, getRawConfigData, sendRefresh } from "."; +import { app } from "../.."; +import { asyncCatch } from "../../util/middleware"; + +const webhooks = new Webhooks({ + secret: process.env.PRIVATE_API_KEY!, +}); + +app.get( + "/public/config", + asyncCatch(async (req, res) => { + const config = await getConfig(); + res.send(JSON.stringify(config)); + }) +); + +app.post( + "/webhook/refresh", + asyncCatch(async (req, res) => { + // github webhook + const signature = req.headers["x-hub-signature-256"] as string; + const body = JSON.stringify(req.body); + + if (!(await webhooks.verify(body, signature))) { + res.sendStatus(403); + return; + } + + // only refresh if the config.json file was changed + if (req.body.commits.some((commit: any) => commit.modified.includes("config.json"))) { + sendRefresh(); + + res.status(200).send("Config refreshed."); + } else { + res.status(200).send("Config not refreshed."); + } + }) +); + +app.get( + "/private/refresh", + asyncCatch(async (_, res) => { + sendRefresh(); + + res.send(await getRawConfigData()); + }) +); \ No newline at end of file diff --git a/ebs/src/modules/config.ts b/ebs/src/modules/config/index.ts similarity index 64% rename from ebs/src/modules/config.ts rename to ebs/src/modules/config/index.ts index c04f486..8b24ef6 100644 --- a/ebs/src/modules/config.ts +++ b/ebs/src/modules/config/index.ts @@ -1,17 +1,22 @@ import { Config } from "common/types"; -import { app } from ".."; -import { sendPubSubMessage } from "../util/pubsub"; +import { sendPubSubMessage } from "../../util/pubsub"; import { compressSync, strFromU8, strToU8 } from "fflate"; -import { asyncCatch } from "../util/middleware"; -import { Webhooks } from "@octokit/webhooks"; -import { sendToLogger } from "../util/logger"; +import { sendToLogger } from "../../util/logger"; -let activeConfig: Config | undefined; let configData: Config | undefined; +let activeConfig: Config | undefined; +let ingameState = false; const apiURL = "https://api.github.com/repos/vedalai/swarm-control/contents/config.json"; const rawURL = "https://raw.githubusercontent.com/VedalAI/swarm-control/main/config.json"; +require("./endpoints"); + +(async () => { + const config = await getConfig(); + await broadcastConfigRefresh(config); +})().then(); + async function fetchConfig(): Promise { let url = `${apiURL}?${Date.now()}`; @@ -26,17 +31,17 @@ async function fetchConfig(): Promise { console.error("Error when fetching config from api URL, falling back to raw URL"); console.error(e); - sendToLogger({ - transactionToken: null, - userIdInsecure: null, - important: true, - fields: [ - { - header: "Error when fetching config from api URL, falling back to raw URL", - content: e.toString(), - }, - ], - }).then(); + sendToLogger({ + transactionToken: null, + userIdInsecure: null, + important: true, + fields: [ + { + header: "Error when fetching config from api URL, falling back to raw URL", + content: e.toString(), + }, + ], + }).then(); try { url = `${rawURL}?${Date.now()}`; @@ -47,7 +52,7 @@ async function fetchConfig(): Promise { } catch (e: any) { console.error("Error when fetching config from raw URL, panic"); console.error(e); - + sendToLogger({ transactionToken: null, userIdInsecure: null, @@ -66,7 +71,16 @@ async function fetchConfig(): Promise { }; } } +} + +export function isIngame() { + return ingameState; +} +export function setIngame(newIngame: boolean) { + if (ingameState == newIngame) return; + ingameState = newIngame; + setActiveConfig(configData!).then(); } function processConfig(data: Config) { @@ -85,6 +99,14 @@ export async function getConfig(): Promise { return activeConfig!; } +export async function getRawConfigData(): Promise { + if (!configData) { + await refreshConfig(); + } + + return configData!; +} + export async function setActiveConfig(data: Config) { activeConfig = processConfig(data); await broadcastConfigRefresh(activeConfig); @@ -97,74 +119,13 @@ export async function broadcastConfigRefresh(config: Config) { }); } -let ingameState: boolean = false; - -export function isIngame() { - return ingameState; -} - -export function setIngame(newIngame: boolean) { - if (ingameState == newIngame) return; - ingameState = newIngame; - setActiveConfig(configData!).then(); -} - async function refreshConfig() { configData = await fetchConfig(); activeConfig = processConfig(configData); } -app.get( - "/private/refresh", - asyncCatch(async (_, res) => { - sendRefresh(); - - res.send(configData); - }) -); - -const webhooks = new Webhooks({ - secret: process.env.PRIVATE_API_KEY!, -}); - -app.post( - "/webhook/refresh", - asyncCatch(async (req, res) => { - // github webhook - const signature = req.headers["x-hub-signature-256"] as string; - const body = JSON.stringify(req.body); - - if (!(await webhooks.verify(body, signature))) { - res.sendStatus(403); - return; - } - - // only refresh if the config.json file was changed - if (req.body.commits.some((commit: any) => commit.modified.includes("config.json"))) { - sendRefresh(); - - res.status(200).send("Config refreshed."); - } else { - res.status(200).send("Config not refreshed."); - } - }) -); - -async function sendRefresh() { +export async function sendRefresh() { await refreshConfig(); console.log("Refreshed config, new config version is ", activeConfig!.version); await broadcastConfigRefresh(activeConfig!); } - -app.get( - "/public/config", - asyncCatch(async (req, res) => { - const config = await getConfig(); - res.send(JSON.stringify(config)); - }) -); - -(async () => { - const config = await getConfig(); - await broadcastConfigRefresh(config); -})().then(); diff --git a/ebs/src/modules/game/endpoints.ts b/ebs/src/modules/game/endpoints.ts new file mode 100644 index 0000000..83a1967 --- /dev/null +++ b/ebs/src/modules/game/endpoints.ts @@ -0,0 +1,80 @@ +import { app } from "../.."; +import { connection } from "."; +import { asyncCatch } from "../../util/middleware"; +import { MessageType } from "./messages"; +import { ResultMessage } from "./messages.game"; +import { CommandInvocationSource, RedeemMessage } from "./messages.server"; +import { isStressTesting, startStressTest, StressTestRequest } from "./stresstest"; + +app.post( + "/private/redeem", + asyncCatch(async (req, res) => { + //console.log(req.body); + const msg = { + ...connection.makeMessage(MessageType.Redeem), + source: CommandInvocationSource.Dev, + ...req.body, + } as RedeemMessage; + if (!connection.isConnected()) { + res.status(500).send("Not connected"); + return; + } + + try { + await connection.sendMessage(msg); + res.status(201).send(JSON.stringify(msg)); + } catch (e) { + res.status(500).send(e); + } + }) +); + +app.post("/private/setresult", (req, res) => { + const msg = { + ...connection.makeMessage(MessageType.Result), + ...req.body, + } as ResultMessage; + if (!connection.isConnected()) { + res.status(500).send("Not connected"); + return; + } + + connection.processMessage(msg); + res.sendStatus(200); +}); + +app.post("/private/stress", (req, res) => { + if (!process.env.ENABLE_STRESS_TEST) { + res.status(501).send("Disabled unless you set the ENABLE_STRESS_TEST env var\nREMEMBER TO REMOVE IT FROM PROD"); + return; + } + + if (isStressTesting()) { + res.status(400).send("Already stress testing"); + return; + } + + if (!connection.isConnected()) { + res.status(500).send("Not connected"); + return; + } + + const reqObj = req.body as StressTestRequest; + if (reqObj.type === undefined || reqObj.duration === undefined || reqObj.interval === undefined) { + res.status(400).send("Must have type, duration, and interval"); + return; + } + console.log(reqObj); + startStressTest(reqObj.type, reqObj.duration, reqObj.interval); + res.sendStatus(200); +}); + +app.get("/private/unsent", (req, res) => { + const unsent = connection.getUnsent(); + res.send(JSON.stringify(unsent)); +}); + +app.get("/private/outstanding", (req, res) => { + const outstanding = connection.getOutstanding(); + res.send(JSON.stringify(outstanding)); +}); diff --git a/ebs/src/modules/game/index.ts b/ebs/src/modules/game/index.ts index ccf898f..7ceca2b 100644 --- a/ebs/src/modules/game/index.ts +++ b/ebs/src/modules/game/index.ts @@ -1,10 +1,5 @@ import { app } from "../.."; -import { asyncCatch } from "../../util/middleware"; import { GameConnection } from "./connection"; -import { MessageType } from "./messages"; -import { ResultMessage } from "./messages.game"; -import { CommandInvocationSource, RedeemMessage } from "./messages.server"; -import { isStressTesting, startStressTest, StressTestRequest } from "./stresstest"; export let connection: GameConnection = new GameConnection(); @@ -12,75 +7,4 @@ app.ws("/private/socket", (ws) => { connection.setSocket(ws); }); -app.post( - "/private/redeem", - asyncCatch(async (req, res) => { - //console.log(req.body); - const msg = { - ...connection.makeMessage(MessageType.Redeem), - source: CommandInvocationSource.Dev, - ...req.body, - } as RedeemMessage; - if (!connection.isConnected()) { - res.status(500).send("Not connected"); - return; - } - - try { - await connection.sendMessage(msg); - res.status(201).send(JSON.stringify(msg)); - } catch (e) { - res.status(500).send(e); - } - }) -); - -app.post("/private/setresult", (req, res) => { - const msg = { - ...connection.makeMessage(MessageType.Result), - ...req.body, - } as ResultMessage; - if (!connection.isConnected()) { - res.status(500).send("Not connected"); - return; - } - - connection.processMessage(msg); - res.sendStatus(200); -}); - -app.post("/private/stress", (req, res) => { - if (!process.env.ENABLE_STRESS_TEST) { - res.status(501).send("Disabled unless you set the ENABLE_STRESS_TEST env var\nREMEMBER TO REMOVE IT FROM PROD"); - return; - } - - if (isStressTesting()) { - res.status(400).send("Already stress testing"); - return; - } - - if (!connection.isConnected()) { - res.status(500).send("Not connected"); - return; - } - - const reqObj = req.body as StressTestRequest; - if (reqObj.type === undefined || reqObj.duration === undefined || reqObj.interval === undefined) { - res.status(400).send("Must have type, duration, and interval"); - return; - } - console.log(reqObj); - startStressTest(reqObj.type, reqObj.duration, reqObj.interval); - res.sendStatus(200); -}); - -app.get("/private/unsent", (req, res) => { - const unsent = connection.getUnsent(); - res.send(JSON.stringify(unsent)); -}); - -app.get("/private/outstanding", (req, res) => { - const outstanding = connection.getOutstanding(); - res.send(JSON.stringify(outstanding)); -}); +require("./endpoints"); diff --git a/ebs/src/modules/orders/endpoints/index.ts b/ebs/src/modules/orders/endpoints/index.ts new file mode 100644 index 0000000..8ff1865 --- /dev/null +++ b/ebs/src/modules/orders/endpoints/index.ts @@ -0,0 +1,2 @@ +require("./public"); +require("./private"); diff --git a/ebs/src/modules/orders/endpoints/private.ts b/ebs/src/modules/orders/endpoints/private.ts new file mode 100644 index 0000000..7d26143 --- /dev/null +++ b/ebs/src/modules/orders/endpoints/private.ts @@ -0,0 +1,6 @@ +import { app } from "../../.."; +import { getOrder } from "../../../util/db"; + +app.get("/private/order/:guid", (req, res) => { + return getOrder(req.params["guid"]); +}); diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts new file mode 100644 index 0000000..5f5622c --- /dev/null +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -0,0 +1,319 @@ +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"; + +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; + let validationError: string | null; + let fail = "register"; + try { + order = await createOrder(userId, cart); + fail = "validate"; + validationError = await validatePrepurchase(order); + } catch (e: any) { + logContext.important = true; + logMessage.header = `Failed to ${fail} prepurchase`; + logMessage.content = { cart, userId, error: e }; + sendToLogger(logContext).then(); + + res.status(500).send("Failed to register prepurchase"); + return; + } + + if (validationError) { + logMessage.header = "Prepurchase failed validation"; + logMessage.content = { orderId: order.id }; + sendToLogger(logContext).then(); + res.status(409).send(validationError); + return; + } + + order.state = "prepurchase"; + await saveOrder(order); + + logMessage.header = "Created prepurchase"; + logMessage.content = { orderId: order.id }; + sendToLogger(logContext).then(); + + 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); + } + }) +); \ No newline at end of file diff --git a/ebs/src/modules/orders/index.ts b/ebs/src/modules/orders/index.ts index a29b15d..3f0a68a 100644 --- a/ebs/src/modules/orders/index.ts +++ b/ebs/src/modules/orders/index.ts @@ -1,317 +1 @@ -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); - } - }) -); +require("./endpoints"); diff --git a/ebs/src/modules/twitch.ts b/ebs/src/modules/twitch/index.ts similarity index 77% rename from ebs/src/modules/twitch.ts rename to ebs/src/modules/twitch/index.ts index fdfcaf9..97a8bab 100644 --- a/ebs/src/modules/twitch.ts +++ b/ebs/src/modules/twitch/index.ts @@ -1,5 +1,5 @@ -import { getHelixUser } from "../util/twitch"; -import { TwitchUser } from "./game/messages"; +import { getHelixUser } from "../../util/twitch"; +import { TwitchUser } from "../game/messages"; export async function getTwitchUser(id: string): Promise { const user = await getHelixUser(id); diff --git a/ebs/src/modules/orders/user.ts b/ebs/src/modules/user/endpoints.ts similarity index 71% rename from ebs/src/modules/orders/user.ts rename to ebs/src/modules/user/endpoints.ts index 89c1ebf..1d224b4 100644 --- a/ebs/src/modules/orders/user.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -1,17 +1,7 @@ -import { User } from "common/types"; import { app } from "../.."; -import { lookupUser, saveUser, updateUserTwitchInfo } from "../../util/db"; +import { updateUserTwitchInfo, lookupUser } 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 }), - }); -} +import { setUserBanned } from "."; app.post( "/public/authorized", @@ -51,4 +41,4 @@ app.delete( await setUserBanned(user, false); res.sendStatus(200); }) -); +); \ No newline at end of file diff --git a/ebs/src/modules/user/index.ts b/ebs/src/modules/user/index.ts new file mode 100644 index 0000000..b5e4db3 --- /dev/null +++ b/ebs/src/modules/user/index.ts @@ -0,0 +1,14 @@ +import { User } from "common/types"; +import { saveUser } from "../../util/db"; +import { sendPubSubMessage } from "../../util/pubsub"; + +require("./endpoints"); + +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 }), + }); +} From 760d8ae61db2cb79f35f437ebc03ce96cc695f86 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Wed, 10 Jul 2024 23:48:31 +1000 Subject: [PATCH 04/22] additional refactor --- ebs/src/modules/game/stresstest.ts | 90 ++++++++++++++++-------------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/ebs/src/modules/game/stresstest.ts b/ebs/src/modules/game/stresstest.ts index 370d03f..d65155b 100644 --- a/ebs/src/modules/game/stresstest.ts +++ b/ebs/src/modules/game/stresstest.ts @@ -1,7 +1,6 @@ -import { IdentifiableCart } from "common/types"; +import { Order } from "common/types"; import { connection } from "."; import { getConfig } from "../config"; -import { v4 as uuid } from "uuid"; import { signJWT } from "../../util/jwt"; import { AuthorizationPayload, BitsTransactionPayload } from "../../types"; @@ -58,21 +57,27 @@ const user = { login: "stresstest", displayName: "Stress Test", }; -const cart: IdentifiableCart = { +const order: Order = { + id: "stress", + state: "paid", userId: "stress", - version: 1, - id: redeemId, - sku: "bits1", - args: { - "creature": "0", - "behind": false, - } + cart: { + version: 1, + id: redeemId, + sku: "bits1", + args: { + "creature": "0", + "behind": false, + } + }, + createdAt: Date.now(), + updatedAt: Date.now(), }; async function sendSpawnRedeem() { const config = await getConfig(); const redeem = config.redeems![redeemId]; - connection.redeem(redeem, cart, user, uuid()).then().catch(err => { + connection.redeem(redeem, order, user).then().catch(err => { console.log(err); }); } @@ -95,6 +100,26 @@ const validAuth: AuthorizationPayload = { const signedValidJWT = signJWT(validAuth); const signedInvalidJWT = signJWT(invalidAuth); const invalidJWT = "trust me bro"; +const variants = [ + { + name: "signed valid", + token: signedValidJWT, + shouldSucceed: true, + error: "Valid JWT should have succeeded" + }, + { + name: "signed invalid", + token: signedInvalidJWT, + shouldSucceed: false, + error: "JWT without user ID should have failed" + }, + { + name: "unsigned", + token: invalidJWT, + shouldSucceed: false, + error: "Invalid bearer token should have failed" + }, +]; async function sendTransaction() { // we have to go through the http flow because the handler is scuffed @@ -103,11 +128,10 @@ async function sendTransaction() { const urlTransaction = "http://localhost:3000/public/transaction"; const jwtChoice = Math.floor(3*Math.random()); - const token = jwtChoice == 0 ? signedValidJWT - : jwtChoice == 1 ? signedInvalidJWT - : invalidJWT; + const variant = variants[jwtChoice]; + const token = variant.token; const auth = `Bearer ${token}`; - console.log(`Prepurchasing with ${jwtChoice == 0 ? "signed valid" : jwtChoice == 1 ? "signed invalid" : "unsigned invalid"} JWT`); + console.log(`Prepurchasing with ${variant.name}`); const prepurchase = await fetch(urlPrepurchase, { method: "POST", @@ -115,21 +139,11 @@ async function sendTransaction() { "Authorization": auth, "Content-Type": "application/json", }, - body: JSON.stringify(cart), + body: JSON.stringify(order.cart), }); - switch (jwtChoice) { - case 0: - if (!prepurchase.ok) - console.error("Valid JWT should have succeeded"); - break; - case 1: - if (prepurchase.ok) - console.error("JWT without user ID should have failed"); - break; - case 2: - if (prepurchase.ok) - console.error("Invalid bearer token should have failed"); - break; + let succeeded = prepurchase.ok; + if (succeeded != variant.shouldSucceed) { + console.error(`${variant.error} (prepurchase)`); } const transactionId = await prepurchase.text(); @@ -152,7 +166,7 @@ async function sendTransaction() { } }; - console.log(`Sending transaction (${jwtChoice})`); + console.log(`Sending transaction (${variant.name})`); const transaction = await fetch(urlTransaction, { method: "POST", headers: { @@ -164,18 +178,8 @@ async function sendTransaction() { receipt: signJWT(receipt), }), }); - switch (jwtChoice) { - case 0: - if (prepurchase.ok && !transaction.ok) - console.error("Valid JWT should have succeeded"); - break; - case 1: - if (transaction.ok) - console.error("JWT without user ID should have failed"); - break; - case 2: - if (transaction.ok) - console.error("Invalid bearer token should have failed"); - break; + succeeded = transaction.ok; + if (succeeded != variant.shouldSucceed) { + console.error(`${variant.error} (transaction)`); } } From 36b99bd796219321661e15b8f219e284645a8276 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Thu, 11 Jul 2024 00:00:13 +1000 Subject: [PATCH 05/22] lint --- ebs/src/modules/config/endpoints.ts | 2 +- ebs/src/modules/game/messages.game.ts | 37 ++++++++++----------- ebs/src/modules/game/messages.server.ts | 32 +++++++++--------- ebs/src/modules/game/messages.ts | 16 ++++----- ebs/src/modules/game/stresstest.ts | 2 +- ebs/src/modules/orders/endpoints/private.ts | 10 ++++-- ebs/src/modules/orders/endpoints/public.ts | 2 +- ebs/src/modules/orders/prepurchase.ts | 6 ++-- ebs/src/modules/user/endpoints.ts | 11 +++--- ebs/src/types.ts | 6 ++-- ebs/src/util/pubsub.ts | 6 ++-- 11 files changed, 68 insertions(+), 62 deletions(-) diff --git a/ebs/src/modules/config/endpoints.ts b/ebs/src/modules/config/endpoints.ts index 0bd2ed5..c5b1132 100644 --- a/ebs/src/modules/config/endpoints.ts +++ b/ebs/src/modules/config/endpoints.ts @@ -45,4 +45,4 @@ app.get( res.send(await getRawConfigData()); }) -); \ No newline at end of file +); diff --git a/ebs/src/modules/game/messages.game.ts b/ebs/src/modules/game/messages.game.ts index ba93afd..e900d0e 100644 --- a/ebs/src/modules/game/messages.game.ts +++ b/ebs/src/modules/game/messages.game.ts @@ -1,36 +1,35 @@ import { Message as MessageBase, MessageType } from "./messages"; -export type GameMessage -= HelloMessage +export type GameMessage = + | HelloMessage | PingMessage | LogMessage | ResultMessage | IngameStateChangedMessage; - // | CommandAvailabilityChangedMessage; type GameMessageBase = MessageBase; // no extra properties export type HelloMessage = GameMessageBase & { - messageType: MessageType.Hello, - version: string, -} + messageType: MessageType.Hello; + version: string; +}; export type PingMessage = GameMessageBase & { - messageType: MessageType.Ping -} + messageType: MessageType.Ping; +}; export type LogMessage = GameMessageBase & { - messageType: MessageType.Log, - important: boolean, - message: string, -} + messageType: MessageType.Log; + important: boolean; + message: string; +}; export type ResultMessage = GameMessageBase & { - messageType: MessageType.Result, - success: boolean, - message?: string, -} + messageType: MessageType.Result; + success: boolean; + message?: string; +}; export type IngameStateChangedMessage = GameMessageBase & { - messageType: MessageType.IngameStateChanged, + messageType: MessageType.IngameStateChanged; // disable all redeems if false - ingame: boolean, -} + ingame: boolean; +}; diff --git a/ebs/src/modules/game/messages.server.ts b/ebs/src/modules/game/messages.server.ts index 4e50552..c8bbb57 100644 --- a/ebs/src/modules/game/messages.server.ts +++ b/ebs/src/modules/game/messages.server.ts @@ -2,27 +2,27 @@ import { MessageType, Message, TwitchUser } from "./messages"; export type ServerMessage = Message & { /** User that triggered the message. e.g. for redeems, the user who bought the redeem. */ - user?: TwitchUser -} + user?: TwitchUser; +}; export type HelloBackMessage = ServerMessage & { - messageType: MessageType.HelloBack, - allowed: boolean -} + messageType: MessageType.HelloBack; + allowed: boolean; +}; export type ConsoleInputMessage = ServerMessage & { - messageType: MessageType.ConsoleInput, - input: string -} + messageType: MessageType.ConsoleInput; + input: string; +}; export enum CommandInvocationSource { Swarm, - Dev + Dev, } export type RedeemMessage = ServerMessage & { - messageType: MessageType.Redeem, - source: CommandInvocationSource, - command: string, - title?: string, - announce: boolean, - args: any -} \ No newline at end of file + messageType: MessageType.Redeem; + source: CommandInvocationSource; + command: string; + title?: string; + announce: boolean; + args: any; +}; diff --git a/ebs/src/modules/game/messages.ts b/ebs/src/modules/game/messages.ts index 70b9810..dab72dd 100644 --- a/ebs/src/modules/game/messages.ts +++ b/ebs/src/modules/game/messages.ts @@ -18,16 +18,16 @@ export type Guid = string; export type UnixTimestampUtc = number; export type Message = { - messageType: MessageType, - guid: Guid, - timestamp: UnixTimestampUtc -} + messageType: MessageType; + guid: Guid; + timestamp: UnixTimestampUtc; +}; export type TwitchUser = { /** Numeric user id */ - id: string, + id: string; /** Twitch username (login name) */ - login: string, + login: string; /** User's chosen display name. */ - displayName: string -} \ No newline at end of file + displayName: string; +}; diff --git a/ebs/src/modules/game/stresstest.ts b/ebs/src/modules/game/stresstest.ts index d65155b..cac7dda 100644 --- a/ebs/src/modules/game/stresstest.ts +++ b/ebs/src/modules/game/stresstest.ts @@ -25,7 +25,7 @@ export function isStressTesting(): boolean { let activeInterval: number; export async function startStressTest(type: StressTestType, duration: number, interval: number) { - console.log(`Starting stress test ${StressTestType[type]} for ${duration}ms`) + console.log(`Starting stress test ${StressTestType[type]} for ${duration}ms`); switch (type) { case StressTestType.GameSpawnQueue: activeInterval = +setInterval(() => sendSpawnRedeem().then(), interval); diff --git a/ebs/src/modules/orders/endpoints/private.ts b/ebs/src/modules/orders/endpoints/private.ts index 7d26143..a22c09b 100644 --- a/ebs/src/modules/orders/endpoints/private.ts +++ b/ebs/src/modules/orders/endpoints/private.ts @@ -1,6 +1,10 @@ import { app } from "../../.."; import { getOrder } from "../../../util/db"; +import { asyncCatch } from "../../../util/middleware"; -app.get("/private/order/:guid", (req, res) => { - return getOrder(req.params["guid"]); -}); +app.get( + "/private/order/:guid", + asyncCatch(async (req, res) => { + res.json(await getOrder(req.params["guid"])); + }) +); diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index 5f5622c..ba5163e 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -316,4 +316,4 @@ app.post( res.sendStatus(500); } }) -); \ No newline at end of file +); diff --git a/ebs/src/modules/orders/prepurchase.ts b/ebs/src/modules/orders/prepurchase.ts index a64f1a1..5251069 100644 --- a/ebs/src/modules/orders/prepurchase.ts +++ b/ebs/src/modules/orders/prepurchase.ts @@ -2,7 +2,7 @@ import { Cart, Config, LogMessage, Order } from "common/types"; import { sendToLogger } from "../../util/logger"; import { getConfig } from "../config"; -export async function validatePrepurchase(order: Order) : Promise { +export async function validatePrepurchase(order: Order): Promise { const logContext: LogMessage = { transactionToken: null, userIdInsecure: order.userId, @@ -46,7 +46,7 @@ export async function validatePrepurchase(order: Order) : Promise await sendToLogger(logContext); return "Invalid arguments"; } - + return null; } @@ -134,4 +134,4 @@ function validateArgs(config: Config, cart: Cart, logContext: LogMessage): strin break; } } -} \ No newline at end of file +} diff --git a/ebs/src/modules/user/endpoints.ts b/ebs/src/modules/user/endpoints.ts index 1d224b4..9945b64 100644 --- a/ebs/src/modules/user/endpoints.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -11,9 +11,12 @@ app.post( }) ); -app.get("/private/user/:idOrName", asyncCatch(async (req, res) => { - res.json(await lookupUser(req.params["idOrName"])); -})); +app.get( + "/private/user/:idOrName", + asyncCatch(async (req, res) => { + res.json(await lookupUser(req.params["idOrName"])); + }) +); app.post( "/private/user/:idOrName/ban", @@ -41,4 +44,4 @@ app.delete( await setUserBanned(user, false); res.sendStatus(200); }) -); \ No newline at end of file +); diff --git a/ebs/src/types.ts b/ebs/src/types.ts index d74add1..674b5a1 100644 --- a/ebs/src/types.ts +++ b/ebs/src/types.ts @@ -9,7 +9,7 @@ export type AuthorizationPayload = { listen: string[]; send: string[]; }; -} +}; export type BitsTransactionPayload = { topic: string; @@ -25,7 +25,7 @@ export type BitsTransactionPayload = { cost: { amount: number; type: "bits"; - } + }; }; - } + }; }; diff --git a/ebs/src/util/pubsub.ts b/ebs/src/util/pubsub.ts index 1dabcfe..7bb25ac 100644 --- a/ebs/src/util/pubsub.ts +++ b/ebs/src/util/pubsub.ts @@ -1,11 +1,11 @@ -import {EbsCallConfig, sendExtensionPubSubGlobalMessage} from "@twurple/ebs-helper"; -import {PubSubMessage} from "common/types"; +import { EbsCallConfig, sendExtensionPubSubGlobalMessage } from "@twurple/ebs-helper"; +import { PubSubMessage } from "common/types"; const config: EbsCallConfig = { clientId: process.env.CLIENT_ID!, ownerId: process.env.OWNER_ID!, secret: process.env.JWT_SECRET!, -} +}; export async function sendPubSubMessage(message: PubSubMessage) { return sendExtensionPubSubGlobalMessage(config, JSON.stringify(message)); From e4adb67aa7fad3041daf275e64d6cd3b86f58e26 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Thu, 11 Jul 2024 05:27:42 +1000 Subject: [PATCH 06/22] listen for pubsub whispers on frontend --- common/types.ts | 4 +- frontend/www/src/index.ts | 1 + frontend/www/src/modules/auth.ts | 8 ++-- frontend/www/src/modules/pubsub.ts | 15 +++++-- frontend/www/src/util/twitch.ts | 66 ++++++++++++++++++++---------- 5 files changed, 64 insertions(+), 30 deletions(-) diff --git a/common/types.ts b/common/types.ts index 9129e89..2b6c7bd 100644 --- a/common/types.ts +++ b/common/types.ts @@ -129,4 +129,6 @@ export type Order = { result?: string; createdAt: number; updatedAt: number; -}; \ No newline at end of file +}; + +export type Callback = (data: T) => void; diff --git a/frontend/www/src/index.ts b/frontend/www/src/index.ts index 1fa7b1a..b78a8f1 100644 --- a/frontend/www/src/index.ts +++ b/frontend/www/src/index.ts @@ -11,3 +11,4 @@ import "./modules/auth"; import "./modules/modal"; import "./modules/pubsub"; import "./modules/redeems"; +import "./modules/transaction"; diff --git a/frontend/www/src/modules/auth.ts b/frontend/www/src/modules/auth.ts index 9b1fd82..eb3104a 100644 --- a/frontend/www/src/modules/auth.ts +++ b/frontend/www/src/modules/auth.ts @@ -9,10 +9,6 @@ const $loginButton = document.getElementById("twitch-login")!; document.addEventListener("DOMContentLoaded", () => { $loginButton.onclick = async () => { const res = await twitchAuth(); - if (res === "revoked") { - $loginPopup.style.display = ""; - return; - } $loginPopup.style.display = "none"; ebsFetch("public/authorized", { method: "POST", @@ -23,10 +19,11 @@ document.addEventListener("DOMContentLoaded", () => { } renderRedeemButtons().then(); }); - } + }; }); let _banned = false; +const callbacks: (() => void)[] = []; export function getBanned() { return _banned; } @@ -36,6 +33,7 @@ export async function setBanned(banned: boolean) { _banned = banned; if (banned) { + callbacks.forEach((c) => c()); setConfig({ version: -1, message: "You have been banned from using this extension." }); renderRedeemButtons().then(); } else { diff --git a/frontend/www/src/modules/pubsub.ts b/frontend/www/src/modules/pubsub.ts index 06530b4..b1af6e7 100644 --- a/frontend/www/src/modules/pubsub.ts +++ b/frontend/www/src/modules/pubsub.ts @@ -3,8 +3,17 @@ import { setConfig } from "../util/config"; import { renderRedeemButtons } from "./redeems"; import { strToU8, decompressSync, strFromU8 } from "fflate"; import { getBanned, setBanned } from "./auth"; +import { onTwitchAuth } from "../util/twitch"; -Twitch.ext.listen("global", async (_t, _c, message) => { +Twitch.ext.listen("global", onPubsubMessage); + +let whisperListenTarget: string; +onTwitchAuth((auth) => { + whisperListenTarget = `whisper-${auth.userId}`; + Twitch.ext.listen(whisperListenTarget, onPubsubMessage); +}); + +async function onPubsubMessage(target: string, contentType: string, message: string) { const fullMessage = JSON.parse(message) as PubSubMessage; console.log(fullMessage); @@ -20,9 +29,9 @@ Twitch.ext.listen("global", async (_t, _c, message) => { case "banned": const data = JSON.parse(fullMessage.data) as BannedData; const bannedId = data.id; - if (bannedId === Twitch.ext.viewer.id) { + if (bannedId === Twitch.ext.viewer.id || bannedId === Twitch.ext.viewer.opaqueId) { setBanned(data.banned); } break; } -}); +} diff --git a/frontend/www/src/util/twitch.ts b/frontend/www/src/util/twitch.ts index 2d2ac09..687f6ac 100644 --- a/frontend/www/src/util/twitch.ts +++ b/frontend/www/src/util/twitch.ts @@ -1,35 +1,59 @@ +import { Callback } from "common/types"; -type AuthResponse = Twitch.ext.Authorized | "revoked"; +type AuthResponse = Twitch.ext.Authorized; type TransactionResponse = Twitch.ext.BitsTransaction | "cancelled"; -const authCallbacks: ((viewer: AuthResponse) => void)[] = []; -const transactionCallbacks: ((transaction: TransactionResponse) => void)[] = []; +class Callbacks { + persistent: Callback[] = []; + transient: Callback[] = []; -export async function twitchAuth(): Promise { - // authorized if id is set - if (!Twitch.ext.viewer.id) { - Twitch.ext.actions.requestIdShare(); + addPersistent(callback: Callback) { + this.persistent.push(callback); } - return new Promise(resolve => authCallbacks.push(resolve)); -} -export async function twitchUseBits(sku: string): Promise { - Twitch.ext.bits.useBits(sku); - return new Promise(resolve => transactionCallbacks.push(resolve)); + addTransient(callback: Callback) { + this.transient.push(callback); + } + + call(data: T) { + this.persistent.forEach((cb) => cb(data)); + this.transient.forEach((cb) => cb(data)); + this.transient.splice(0, this.transient.length); + } } +const authCallbacks: Callbacks = new Callbacks(); +const transactionCallbacks: Callbacks = new Callbacks(); + Twitch.ext.onAuthorized((auth) => { - const res: AuthResponse = auth.userId ? auth : "revoked"; - authCallbacks.forEach((callback) => callback(res)); - authCallbacks.splice(0, authCallbacks.length); -}) + authCallbacks.call(auth); +}); Twitch.ext.bits.onTransactionComplete((transaction) => { - transactionCallbacks.forEach((callback) => callback(transaction)); - transactionCallbacks.splice(0, transactionCallbacks.length); + transactionCallbacks.call(transaction); }); Twitch.ext.bits.onTransactionCancelled(() => { - transactionCallbacks.forEach((callback) => callback("cancelled")); - transactionCallbacks.splice(0, transactionCallbacks.length); -}) \ No newline at end of file + transactionCallbacks.call("cancelled"); +}); + +export async function twitchAuth(requestIdShare = true): Promise { + // if id is set, we're authorized + if (!Twitch.ext.viewer.id && requestIdShare) { + Twitch.ext.actions.requestIdShare(); + } + return new Promise(authCallbacks.addTransient); +} + +export async function twitchUseBits(sku: string): Promise { + Twitch.ext.bits.useBits(sku); + return new Promise(transactionCallbacks.addTransient); +} + +export function onTwitchAuth(callback: Callback) { + authCallbacks.addPersistent(callback); +} + +export function onTwitchBits(callback: Callback) { + transactionCallbacks.addPersistent(callback); +} From 6b6658111ff19bb34aac7d4c6f1c3049f2e2dd13 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Thu, 11 Jul 2024 11:14:12 +1000 Subject: [PATCH 07/22] credit purchase flow for backend (first iteration) (todo all the ui for credit balance/purchase confirmation) pull a bit more out of the /public/transaction monstrosity add "proper" hooking into redeems (for pishock) --- common/types.ts | 11 +- ebs/src/modules/game/connection.ts | 79 ++++++---- ebs/src/modules/game/messages.game.ts | 4 +- ebs/src/modules/orders/endpoints/public.ts | 136 +++++------------- ebs/src/modules/orders/transaction.ts | 118 +++++++++++++++ .../pishock.ts => modules/pishock/index.ts} | 37 +++++ frontend/www/src/modules/modal.ts | 7 +- frontend/www/src/modules/pubsub.ts | 6 + frontend/www/src/modules/transaction.ts | 22 +++ 9 files changed, 286 insertions(+), 134 deletions(-) create mode 100644 ebs/src/modules/orders/transaction.ts rename ebs/src/{util/pishock.ts => modules/pishock/index.ts} (51%) diff --git a/common/types.ts b/common/types.ts index 2b6c7bd..cd629d7 100644 --- a/common/types.ts +++ b/common/types.ts @@ -83,12 +83,15 @@ export type IdentifiableCart = Cart & { }; export type Transaction = { - receipt: string; token: string; -}; + type: "bits" | "credit"; + // for type:"bits", this is a BitsTransactionPayload (JWT signed by Twitch) + // for type:"credit", this should be null/not present since verification will be done serverside + receipt?: string; +} export type PubSubMessage = { - type: "config_refreshed" | "banned"; + type: "config_refreshed" | "banned" | "balance_update"; data: string; }; @@ -124,7 +127,7 @@ export type Order = { id: string; userId: string; state: OrderState; - cart?: Cart; + cart: Cart; receipt?: string; result?: string; createdAt: number; diff --git a/ebs/src/modules/game/connection.ts b/ebs/src/modules/game/connection.ts index 97ae706..83d9940 100644 --- a/ebs/src/modules/game/connection.ts +++ b/ebs/src/modules/game/connection.ts @@ -8,6 +8,7 @@ import { setIngame } from "../config"; const VERSION = "0.1.0"; +type RedeemHandler = (redeem: Redeem, order: Order, user: TwitchUser) => Promise; type ResultHandler = (result: ResultMessage) => any; export class GameConnection { @@ -19,6 +20,7 @@ export class GameConnection { static resultWaitTimeout: number = 10000; private resendIntervalHandle?: number; private resendInterval = 500; + private redeemHandlers: RedeemHandler[] = [this.sendRedeemToGame]; public isConnected() { return this.socket?.readyState == ServerWS.OPEN; @@ -140,30 +142,44 @@ export class GameConnection { ) ), new Promise((resolve, reject) => { - const msg: RedeemMessage = { - ...this.makeMessage(MessageType.Redeem), - guid: order.id, - source: CommandInvocationSource.Swarm, - command: redeem.id, - title: redeem.title, - announce: redeem.announce ?? true, - args: order.cart!.args, - user, - } as RedeemMessage; - if (this.outstandingRedeems.has(msg.guid)) { - reject(`Redeeming ${msg.guid} more than once`); - return; - } - 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.runRedeemHandlers(redeem, order, user) + .then(handlersResult => { + if (handlersResult) { + resolve(handlersResult); + } else { + reject("Unhandled redeem"); + } + }) + .catch(e => reject(e)); }), ]); } + private sendRedeemToGame(redeem: Redeem, order: Order, user: TwitchUser): Promise { + return new Promise((resolve, reject) => { + const msg: RedeemMessage = { + ...this.makeMessage(MessageType.Redeem), + guid: order.id, + source: CommandInvocationSource.Swarm, + command: redeem.id, + title: redeem.title, + announce: redeem.announce ?? true, + args: order.cart!.args, + user, + } as RedeemMessage; + if (this.outstandingRedeems.has(msg.guid)) { + reject(`Redeeming ${msg.guid} more than once`); + return; + } + 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 + }); + } + private logMessage(msg: Message, message: string) { console.log(`[${msg.guid}] ${message}`); } @@ -176,7 +192,7 @@ export class GameConnection { private tryResendFromQueue() { const msg = this.unsentQueue.shift(); - if (msg === undefined) { + if (!msg) { //console.log("Nothing to re-send"); return; } @@ -197,15 +213,30 @@ export class GameConnection { return Array.from(this.outstandingRedeems.values()); } - public onResult(guid: string, resolve: (result: ResultMessage) => void) { + public onResult(guid: string, callback: ResultHandler) { const existing = this.resultHandlers.get(guid); if (existing) { this.resultHandlers.set(guid, (result: ResultMessage) => { existing(result); - resolve(result); + callback(result); }); } else { - this.resultHandlers.set(guid, resolve); + this.resultHandlers.set(guid, callback); + } + } + + public addRedeemHandler(handler: RedeemHandler) { + this.redeemHandlers.push(handler); + } + + private async runRedeemHandlers(redeem: Redeem, order: Order, user: TwitchUser) { + for (let i = this.redeemHandlers.length - 1; i >= 0; i--) { + const handler = this.redeemHandlers[i]; + const res = await handler(redeem, order, user); + if (!res) continue; + + return res; } + return null; } } diff --git a/ebs/src/modules/game/messages.game.ts b/ebs/src/modules/game/messages.game.ts index e900d0e..6229dfe 100644 --- a/ebs/src/modules/game/messages.game.ts +++ b/ebs/src/modules/game/messages.game.ts @@ -22,9 +22,11 @@ export type LogMessage = GameMessageBase & { message: string; }; +export type ResultKind = "success" | "error" | "deny"; + export type ResultMessage = GameMessageBase & { messageType: MessageType.Result; - success: boolean; + status: ResultKind; message?: string; }; diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index ba5163e..8c51f76 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -1,16 +1,14 @@ 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"; +import { checkBitsTransaction, getAndCheckOrder, processRedeemResult } from "../transaction"; app.post( "/public/prepurchase", @@ -22,12 +20,7 @@ app.post( transactionToken: null, userIdInsecure: userId, important: false, - fields: [ - { - header: "", - content: "", - }, - ], + fields: [{ header: "", content: "" }], }; const logMessage = logContext.fields[0]; @@ -81,41 +74,35 @@ app.post( transactionToken: transaction.token, userIdInsecure: req.user.id, important: true, - fields: [ - { - header: "", - content: transaction, - }, - ], + fields: [{ header: "", content: "" }], }; 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; + if (transaction.type === "bits") { + const validationResult = checkBitsTransaction(transaction); + if (validationResult) { + logMessage.header = validationResult.logHeaderOverride ?? validationResult.message; + logMessage.content = validationResult.logContents ?? { transaction }; + if (validationResult.status === 403) { + setUserBanned(req.user, true); + } + res.status(validationResult.status).send(validationResult.message); + 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; + let order: Order; try { - order = await getOrder(transaction.token); + const orderMaybe = await getAndCheckOrder(transaction, req.user); + if ("status" in orderMaybe) { + const checkRes = orderMaybe; + logMessage.header = checkRes.logHeaderOverride ?? checkRes.message; + logMessage.content = checkRes.logContents ?? { transaction }; + res.status(orderMaybe.status).send(orderMaybe.message); + return; + } else { + order = orderMaybe; + } } catch (e: any) { logContext.important = true; logMessage.header = "Failed to get order"; @@ -127,29 +114,6 @@ app.post( 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 @@ -157,7 +121,7 @@ app.post( logMessage.header = "Mismatched user ID"; logMessage.content = { user: req.user, - order, + order: order.id, transaction, }; sendToLogger(logContext).then(); @@ -214,52 +178,26 @@ app.post( } } - 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"); - } + const result = await connection.redeem(redeem, order, userInfo); + const processedResult = await processRedeemResult(order, result); + logContext.important = processedResult.status !== 200; + logMessage.header = processedResult.logHeaderOverride ?? processedResult.message; + logMessage.content = processedResult.logContents ?? { transaction }; + sendToLogger(logContext).then(); + res.status(processedResult.status).send(processedResult.message); + return; } catch (error) { logContext.important = true; logMessage.header = "Failed to send redeem"; - logMessage.content = { - config: currentConfig.version, - order, - error, - }; + 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(); + processRedeemResult(order, res).then(); }); res.status(500).send(`Failed to process redeem - ${error}`); + return; } }) ); diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts new file mode 100644 index 0000000..7a893dd --- /dev/null +++ b/ebs/src/modules/orders/transaction.ts @@ -0,0 +1,118 @@ +import { Order, Transaction, User, OrderState } from "common/types"; +import { BitsTransactionPayload } from "../../types"; +import { verifyJWT, parseJWT } from "../../util/jwt"; +import { getOrAddUser, getOrder, saveOrder, saveUser } from "../../util/db"; +import { ResultKind, ResultMessage } from "../game/messages.game"; +import { sendToLogger } from "../../util/logger"; + +type HttpResult = { + status: number; + message: string; + logHeaderOverride?: string; + logContents?: any; +}; + +export function checkBitsTransaction(transaction: Transaction): HttpResult | null { + // bits transactions get verified earlier + if (transaction.type === "bits") { + if (!transaction.receipt) { + return { status: 400, message: "Missing receipt" }; + } + if (!verifyJWT(transaction.receipt)) { + return { status: 403, message: "Invalid receipt" }; + } + + const payload = parseJWT(transaction.receipt) as BitsTransactionPayload; + + if (!payload.data.transactionId) { + return { status: 400, message: "Missing transaction ID" }; + } + } else if (transaction.type !== "credit" || transaction.receipt) { + return { status: 400, message: "Invalid transaction" }; + } + return null; +} + +export async function getAndCheckOrder(transaction: Transaction, user: User): Promise { + const orderMaybe = await getOrder(transaction.token); + if (!orderMaybe) { + return { status: 404, message: "Transaction not found" }; + } + const order = orderMaybe; + if (order.state != "prepurchase") { + return { status: 409, message: "Transaction already processed" }; + } + + if (!order.cart) { + return { status: 500, message: "Invalid transaction", logHeaderOverride: "Missing cart", logContents: { order: order.id } }; + } + + if (transaction.type === "credit") { + const sku = order.cart.sku; + const cost = parseInt(sku.substring(4)); // bitsXXX + if (!isFinite(cost) || cost <= 0) { + return { status: 500, message: "Invalid transaction", logHeaderOverride: "Bad SKU", logContents: { order: order.id, sku } }; + } + if (user.credit < cost) { + return { + status: 409, + message: "Insufficient credit", + logHeaderOverride: "Insufficient credit", + logContents: { user: user.id, order: order.id, cost, credit: user.credit }, + }; + } + user.credit -= cost; // good thing node is single threaded :^) + order.state = "paid"; + order.receipt = `credit (${user.credit + cost} - ${cost} = ${user.credit})`; + } else { + // for bits transactions, verifying the receipt JWT verifies the fact the person paid + order.state = "paid"; + order.receipt = transaction.receipt; + } + + await saveOrder(order); + await saveUser(user); + + return order; +} + +const orderStateMap: { [k in ResultKind]: OrderState } = { + success: "succeeded", + error: "failed", + deny: "rejected", +}; + +export async function processRedeemResult(order: Order, result: ResultMessage): Promise { + order.state = orderStateMap[result.status]; + order.result = result.message; + await saveOrder(order); + if (result.status === "success") { + console.log(`[${result.guid}] Redeem succeeded: ${JSON.stringify(result)}`); + let msg = "Your transaction was successful! Your redeem will appear on stream soon."; + if (result.message) { + msg += "\n\n" + result.message; + } + return { status: 200, message: msg, logHeaderOverride: "Redeem succeeded", logContents: { order: order.id } }; + } else { + await refund(order); + return { status: 500, message: result.message ?? "Redeem failed", logHeaderOverride: "Redeem failed", logContents: { order: order.id } }; + } +} + +export async function refund(order: Order) { + try { + let user = await getOrAddUser(order.userId); + const cost = parseInt(order.cart.sku.substring(4)); + user.credit += cost; + await saveUser(user); + } catch (e) { + console.error(`Could not refund order ${order.id}`); + console.error(e); + sendToLogger({ + transactionToken: order.id, + userIdInsecure: order.userId, + important: true, + fields: [{ header: "Failed to refund", content: { order: order.id, error: e } }], + }); + } +} diff --git a/ebs/src/util/pishock.ts b/ebs/src/modules/pishock/index.ts similarity index 51% rename from ebs/src/util/pishock.ts rename to ebs/src/modules/pishock/index.ts index 0cf00b8..0c0d7d3 100644 --- a/ebs/src/util/pishock.ts +++ b/ebs/src/modules/pishock/index.ts @@ -1,3 +1,40 @@ +import { Order, Redeem } from "common/types"; +import { ResultMessage } from "../game/messages.game"; +import { MessageType, TwitchUser } from "../game/messages"; +import { connection } from "../game"; +import { sendToLogger } from "../../util/logger"; + +const pishockRedeemId = "redeem_pishock"; + +require('./game'); // init connection just in case import order screwed us over + +connection.addRedeemHandler(pishockRedeem) + +export async function pishockRedeem(redeem: Redeem, order: Order, user: TwitchUser): Promise { + if (redeem.id != pishockRedeemId) { + return null; + } + + sendToLogger({ + transactionToken: order.id, + userIdInsecure: order.userId, + important: false, + fields: [{ header: "PiShock Redeem", content: `${user.displayName} redeemed PiShock` }] + }); + + const success = await sendShock(50, 100); + const result: ResultMessage = { + messageType: MessageType.Result, + guid: order.id, + timestamp: Date.now(), + status: success ? "success" : "error", + }; + if (!success) { + result.message = "Failed to send PiShock operation"; + } + return result; +} + const apiUrl: string = "https://do.pishock.com/api/apioperate/"; async function sendOperation(op: number, intensity: number, duration: number) { diff --git a/frontend/www/src/modules/modal.ts b/frontend/www/src/modules/modal.ts index 0bc1de0..b3aa7e9 100644 --- a/frontend/www/src/modules/modal.ts +++ b/frontend/www/src/modules/modal.ts @@ -227,12 +227,7 @@ async function confirmPurchase() { transactionToken: transactionToken!, userIdInsecure: Twitch.ext.viewer.id!, important: false, - fields: [ - { - header: "Transaction started", - content: cart, - }, - ], + fields: [{ header: "Transaction started", content: cart }], }).then(); const res = await twitchUseBits(cart!.sku); diff --git a/frontend/www/src/modules/pubsub.ts b/frontend/www/src/modules/pubsub.ts index b1af6e7..277bf95 100644 --- a/frontend/www/src/modules/pubsub.ts +++ b/frontend/www/src/modules/pubsub.ts @@ -4,12 +4,14 @@ import { renderRedeemButtons } from "./redeems"; import { strToU8, decompressSync, strFromU8 } from "fflate"; import { getBanned, setBanned } from "./auth"; import { onTwitchAuth } from "../util/twitch"; +import { updateClientsideBalance } from "./transaction"; Twitch.ext.listen("global", onPubsubMessage); let whisperListenTarget: string; onTwitchAuth((auth) => { whisperListenTarget = `whisper-${auth.userId}`; + console.log(`Listening to ${whisperListenTarget}`); Twitch.ext.listen(whisperListenTarget, onPubsubMessage); }); @@ -33,5 +35,9 @@ async function onPubsubMessage(target: string, contentType: string, message: str setBanned(data.banned); } break; + case "balance_update": + const balance = JSON.parse(fullMessage.data) as number; + updateClientsideBalance(balance); + break; } } diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index 37955b1..60f1e17 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -2,6 +2,22 @@ import { Transaction } from "common/types"; import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, transactionToken } from "./modal"; import { logToDiscord } from "../util/logger"; import { ebsFetch } from "../util/ebs"; +import { twitchUseBits } from "../util/twitch"; + +type TransactionResponse = Twitch.ext.BitsTransaction | "usedCredit" | "cancelled"; + +let myCredit = 0; + +export async function promptTransaction(sku: string): Promise { + // highly advanced technology (sku names are all "bitsXXX") + const bitsPrice = parseInt(sku.substring(4)); + if (myCredit > bitsPrice) { + // TODO: "use credit?" confirmation modal + return "usedCredit"; + } else { + return await twitchUseBits(sku); + } +} export async function transactionComplete(transaction: Twitch.ext.BitsTransaction) { if (!transactionToken) { @@ -38,6 +54,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio }).then(); const transactionObject: Transaction = { + type: "bits", token: transactionToken, receipt: transaction.transactionReceipt, }; @@ -102,3 +119,8 @@ export async function transactionCancelled() { hideProcessingModal(); showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken}`); }; + +export async function updateClientsideBalance(credit: number) { + myCredit = credit; + // TODO: update UI (when there is UI) +} From 18d25a0b59b4aec14bd8205ba823380435833904 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Thu, 11 Jul 2024 11:22:28 +1000 Subject: [PATCH 08/22] pull form stuff out --- frontend/www/src/modules/modal.ts | 410 ------------------------ frontend/www/src/modules/modal/form.ts | 196 +++++++++++ frontend/www/src/modules/modal/index.ts | 223 +++++++++++++ 3 files changed, 419 insertions(+), 410 deletions(-) delete mode 100644 frontend/www/src/modules/modal.ts create mode 100644 frontend/www/src/modules/modal/form.ts create mode 100644 frontend/www/src/modules/modal/index.ts diff --git a/frontend/www/src/modules/modal.ts b/frontend/www/src/modules/modal.ts deleted file mode 100644 index b3aa7e9..0000000 --- a/frontend/www/src/modules/modal.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { BooleanParam, Cart, EnumParam, LiteralTypes, NumericParam, Parameter, Redeem, TextParam, VectorParam } from "common/types"; -import { ebsFetch } from "../util/ebs"; -import { getConfig } from "../util/config"; -import { logToDiscord } from "../util/logger"; -import { setBanned } from "./auth"; -import { twitchUseBits } from "../util/twitch"; -import { transactionCancelled, transactionComplete } from "./transaction"; - -document.body.addEventListener("dblclick", (e) => { - e.stopPropagation(); - e.preventDefault(); -}); - -/* Containers */ -const $modalWrapper = document.getElementById("modal-wrapper")!; -const $modal = document.getElementById("modal-wrapper")!.getElementsByClassName("modal")[0]!; -const $modalInsideWrapper = $modal.getElementsByClassName("modal-inside-wrapper")[0]!; - -/* Descriptors */ -const $modalTitle = document.getElementById("modal-title")!; -const $modalDescription = document.getElementById("modal-description")!; -const $modalImage = document.getElementById("modal-image")! as HTMLImageElement; - -/* Price */ -const $modalPrice = document.getElementById("modal-bits")!; - -/* Buttons */ -const $modalConfirm = document.getElementById("modal-confirm")! as HTMLButtonElement; -const $modalCancel = document.getElementById("modal-cancel")! as HTMLButtonElement; - -/* Options */ -const $modalOptionsForm = document.getElementById("modal-options-form")! as HTMLFormElement; -const $modalOptions = document.getElementById("modal-options")!; -const $paramToggle = document.getElementById("modal-toggle")!; -const $paramText = document.getElementById("modal-text")!; -const $paramNumber = document.getElementById("modal-number")!; -const $paramDropdown = document.getElementById("modal-dropdown")!; -const $paramVector = document.getElementById("modal-vector")!; - -const $paramTemplates = { - text: $paramText, - number: $paramNumber, - dropdown: $paramDropdown, - toggle: $paramToggle, - vector: $paramVector, -}; - -/* Modal overlays */ -const $modalProcessing = document.getElementById("modal-processing")!; -const $modalProcessingDescription = document.getElementById("modal-processing-description")!; -const $modalProcessingClose = document.getElementById("modal-processing-close")!; - -const $modalError = document.getElementById("modal-error")!; -const $modalErrorTitle = document.getElementById("modal-error-title")!; -const $modalErrorDescription = document.getElementById("modal-error-description")!; -const $modalErrorClose = document.getElementById("modal-error-close")!; - -const $modalSuccess = document.getElementById("modal-success")!; -const $modalSuccessTitle = document.getElementById("modal-success-title")!; -const $modalSuccessDescription = document.getElementById("modal-success-description")!; -const $modalSuccessClose = document.getElementById("modal-success-close")!; - -export let cart: Cart | undefined; -export let transactionToken: string | undefined; - -let processingTimeout: number | undefined; - -document.addEventListener("DOMContentLoaded", () => { - $modalConfirm.onclick = confirmPurchase; - $modalCancel.onclick = closeModal; - $modalOptionsForm.oninput = checkForm; - $modalOptionsForm.onsubmit = (e) => { - e.preventDefault(); - setCartArgsFromForm(e.target as HTMLFormElement); - }; - - $modalWrapper.onclick = (e) => { - if (e.target !== $modalWrapper) return; - if ($modalProcessing.style.opacity == "1") return; - - closeModal(); - }; -}); - -export async function openModal(redeem: Redeem | null) { - if (redeem == null) { - $modalWrapper.style.opacity = "1"; - $modalWrapper.style.pointerEvents = "unset"; - setTimeout(() => $modal.classList.add("active-modal"), 10); - return; - } - if (redeem.disabled) return; - - const config = await getConfig(); - - cart = { version: config.version, sku: redeem.sku, id: redeem.id, args: {} }; - - $modalWrapper.style.opacity = "1"; - $modalWrapper.style.pointerEvents = "unset"; - - $modalTitle.textContent = redeem.title; - $modalDescription.textContent = redeem.description; - $modalPrice.textContent = redeem.price.toString(); - $modalImage.src = redeem.image; - - // scroll to top of modal - $modalInsideWrapper.scrollTop = 0; - - setTimeout(() => $modal.classList.add("active-modal"), 10); - - hideProcessingModal(); - hideSuccessModal(); - hideErrorModal(); - - for (let node of Array.from($modalOptionsForm.childNodes)) $modalOptionsForm.removeChild(node); - - $modalOptions.style.display = (redeem.args || []).length === 0 ? "none" : "flex"; - - addOptionsFields($modalOptionsForm, redeem); - checkForm(); -} - -export function showProcessingModal() { - $modalProcessing.style.opacity = "1"; - $modalProcessing.style.pointerEvents = "unset"; - - $modalProcessingDescription.style.display = "none"; - $modalProcessingClose.style.display = "none"; - - if (processingTimeout) clearTimeout(processingTimeout); - - processingTimeout = +setTimeout(() => { - $modalProcessingDescription.style.display = "unset"; - $modalProcessingDescription.textContent = "This is taking longer than expected."; - - $modalProcessingClose.style.display = "unset"; - $modalProcessingClose.onclick = () => { - hideProcessingModal(); - closeModal(); - }; - }, 30 * 1000); -} - -export function showErrorModal(title: string, description: string) { - $modalError.style.opacity = "1"; - $modalError.style.pointerEvents = "unset"; - $modalErrorTitle.textContent = title; - $modalErrorDescription.innerText = description; - $modalErrorClose.onclick = () => hideErrorModal(true); -} - -export function showSuccessModal(title: string, description: string, onClose?: () => void) { - $modalSuccess.style.opacity = "1"; - $modalSuccess.style.pointerEvents = "unset"; - $modalSuccessTitle.textContent = title; - $modalSuccessDescription.innerText = description; - $modalSuccessClose.onclick = () => { - hideSuccessModal(true); - onClose?.(); - }; -} - -function closeModal() { - cart = undefined; - transactionToken = undefined; - - $modal.classList.remove("active-modal"); - - setTimeout(() => { - $modalWrapper.style.opacity = "0"; - $modalWrapper.style.pointerEvents = "none"; - }, 250); -} - -export function hideProcessingModal() { - $modalProcessing.style.opacity = "0"; - $modalProcessing.style.pointerEvents = "none"; - - if (processingTimeout) clearTimeout(processingTimeout); -} - -function hideErrorModal(closeMainModal = false) { - $modalError.style.opacity = "0"; - $modalError.style.pointerEvents = "none"; - - if (closeMainModal) closeModal(); -} - -function hideSuccessModal(closeMainModal = false) { - $modalSuccess.style.opacity = "0"; - $modalSuccess.style.pointerEvents = "none"; - - if (closeMainModal) closeModal(); -} - -function checkForm() { - $modalConfirm.ariaDisabled = $modalOptionsForm.checkValidity() ? null : ""; -} - -function setCartArgsFromForm(form: HTMLFormElement) { - const formData = new FormData(form); - formData.forEach((val, name) => { - const match = /(?\w+)\[(?\d{1,2})\]$/.exec(name); - if (!match?.length) { - cart!.args[name] = val; - } else { - const paramName = match.groups!["paramName"]; - cart!.args[paramName] ??= []; - const index = parseInt(match.groups!["index"]); - cart!.args[paramName][index] = val; - } - }); -} - -async function confirmPurchase() { - setCartArgsFromForm($modalOptionsForm); - if (!$modalOptionsForm.reportValidity()) { - return; - } - showProcessingModal(); - - if (!(await prePurchase())) { - return; - } - - logToDiscord({ - transactionToken: transactionToken!, - userIdInsecure: Twitch.ext.viewer.id!, - important: false, - fields: [{ header: "Transaction started", content: cart }], - }).then(); - - const res = await twitchUseBits(cart!.sku); - if (res === "cancelled") { - await transactionCancelled(); - } else { - await transactionComplete(res); - } -} - -async function prePurchase() { - const response = await ebsFetch("/public/prepurchase", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(cart), - }); - - 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( - "Invalid transaction, please try again.", - `${response.status} ${response.statusText} - ${await response.text()}\nIf this problem persists, please refresh the page or contact a moderator (preferably AlexejheroDev).` - ); - } - return false; - } - - transactionToken = await response.text(); - - return true; -} - -function addOptionsFields(modal: HTMLFormElement, redeem: Redeem) { - for (const param of redeem.args || []) { - switch (param.type) { - case LiteralTypes.String: - addText(modal, param); - break; - case LiteralTypes.Integer: - case LiteralTypes.Float: - addNumeric(modal, param); - break; - case LiteralTypes.Boolean: - addCheckbox(modal, param); - break; - case LiteralTypes.Vector: - addVector(modal, param); - break; - default: - addDropdown(modal, param).then(); - break; - } - } -} - -function addText(modal: HTMLElement, param: TextParam) { - const field = $paramTemplates.text.cloneNode(true) as HTMLSelectElement; - const input = field.querySelector("input")!; - setupField(field, input, param); - input.minLength = param.minLength ?? param.required ? 1 : 0; - input.maxLength = param.maxLength ?? 255; - if (param.defaultValue !== undefined) { - input.value = param.defaultValue; - } - modal.appendChild(field); -} - -function addNumeric(modal: HTMLElement, param: NumericParam) { - const field = $paramTemplates.number.cloneNode(true) as HTMLSelectElement; - const input = field.querySelector("input")!; - input.type = "number"; - if (param.type == LiteralTypes.Integer) { - input.step = "1"; - } else if (param.type == LiteralTypes.Float) { - input.step = "0.01"; - } - input.min = param.min?.toString() ?? ""; - input.max = param.max?.toString() ?? ""; - setupField(field, input, param); - - if (Number.isFinite(param.defaultValue)) input.value = param.defaultValue!.toString(); - - modal.appendChild(field); -} - -function addCheckbox(modal: HTMLElement, param: BooleanParam) { - const field = $paramTemplates.toggle.cloneNode(true) as HTMLSelectElement; - const input = field.querySelector("input")!; - setupField(field, input, param); - if (param.defaultValue !== undefined) { - input.checked = param.defaultValue; - } - // browser says "required" means "must be checked" - input.required = false; - modal.appendChild(field); -} - -async function addDropdown(modal: HTMLElement, param: EnumParam) { - let options: string[] | undefined = []; - - options = (await getConfig()).enums?.[param.type]; - if (!options) return; // someone's messing with the config, screw em - - const field = $paramTemplates.dropdown.cloneNode(true) as HTMLSelectElement; - const select = field.querySelector("select")!; - - setupField(field, select, param); - for (let i = 0; i < options.length; i++) { - const option = document.createElement("option"); - const name = options[i]; - option.value = i.toString(); - option.disabled = name.startsWith('[DISABLED] '); - option.textContent = name.substring(option.disabled ? 11 : 0); - select.appendChild(option); - } - const firstEnabled = Array.from(select.options).findIndex(op => !op.disabled); - if (firstEnabled < 0 || firstEnabled >= select.options.length) { - console.error(`No enabled options in enum ${param.type}`); - showErrorModal("Config error", `This redeem is misconfigured, please message AlexejheroDev\nError: ${param.type} has no enabled options`); - return; - } - - if (param.defaultValue !== undefined) { - select.value = param.defaultValue; - } else { - select.value = select.options[firstEnabled].value; - } - modal.appendChild(field); -} - -function addVector(modal: HTMLElement, param: VectorParam) { - const field = $paramTemplates.vector.cloneNode(true) as HTMLSelectElement; - const inputs = Array.from(field.querySelectorAll("input")!) as HTMLInputElement[]; - - for (let i = 0; i < 3; i++) { - const input = inputs[i]; - - input.step = "1"; - - input.min = param.min?.toString() ?? ""; - input.max = param.max?.toString() ?? ""; - - setupField(field, input, param, i); - - const defVal = param.defaultValue?.[i]; - input.value = Number.isFinite(defVal) - ? defVal!.toString() - : "0"; - } - - modal.appendChild(field); -} - -function setupField(field: HTMLElement, inputElem: HTMLSelectElement | HTMLInputElement, param: Parameter, arrayIndex?: number) { - const label = field.querySelector("label")!; - - field.id += "-" + param.name; - - if (param.description) { - field.title = param.description; - } - - inputElem.id += "-" + param.name; - inputElem.name = param.name.concat(arrayIndex !== undefined ? `[${arrayIndex}]` : ""); - - label.id += "-" + param.name; - label.htmlFor = inputElem.id; - label.textContent = param.title ?? param.name; - - if (param.required) { - inputElem.required = true; - label.ariaRequired = ""; - } -} diff --git a/frontend/www/src/modules/modal/form.ts b/frontend/www/src/modules/modal/form.ts new file mode 100644 index 0000000..3a162c1 --- /dev/null +++ b/frontend/www/src/modules/modal/form.ts @@ -0,0 +1,196 @@ +import { BooleanParam, EnumParam, LiteralTypes, NumericParam, Parameter, Redeem, TextParam, VectorParam } from "common/types"; +import { $modalConfirm, cart, showErrorModal } from "."; +import { getConfig } from "../../util/config"; + +/* Options */ +export const $modalOptionsForm = document.getElementById("modal-options-form")! as HTMLFormElement; +const $modalOptions = document.getElementById("modal-options")!; +const $paramToggle = document.getElementById("modal-toggle")!; +const $paramText = document.getElementById("modal-text")!; +const $paramNumber = document.getElementById("modal-number")!; +const $paramDropdown = document.getElementById("modal-dropdown")!; +const $paramVector = document.getElementById("modal-vector")!; + +const $paramTemplates = { + text: $paramText, + number: $paramNumber, + dropdown: $paramDropdown, + toggle: $paramToggle, + vector: $paramVector, +}; + +document.addEventListener("DOMContentLoaded", () => { + $modalOptionsForm.oninput = checkForm; + $modalOptionsForm.onsubmit = (e) => { + e.preventDefault(); + setCartArgsFromForm(e.target as HTMLFormElement); + }; +}); + +export function setupForm(redeem: Redeem) { + for (let node of Array.from($modalOptionsForm.childNodes)) $modalOptionsForm.removeChild(node); + + $modalOptions.style.display = (redeem.args || []).length === 0 ? "none" : "flex"; + + addOptionsFields($modalOptionsForm, redeem); +} + +export function checkForm() { + $modalConfirm.ariaDisabled = $modalOptionsForm.checkValidity() ? null : ""; +} + +export function setCartArgsFromForm(form: HTMLFormElement) { + const formData = new FormData(form); + formData.forEach((val, name) => { + const match = /(?\w+)\[(?\d{1,2})\]$/.exec(name); + if (!match?.length) { + cart!.args[name] = val; + } else { + const paramName = match.groups!["paramName"]; + cart!.args[paramName] ??= []; + const index = parseInt(match.groups!["index"]); + cart!.args[paramName][index] = val; + } + }); +} + +export function addOptionsFields(modal: HTMLFormElement, redeem: Redeem) { + for (const param of redeem.args || []) { + switch (param.type) { + case LiteralTypes.String: + addText(modal, param); + break; + case LiteralTypes.Integer: + case LiteralTypes.Float: + addNumeric(modal, param); + break; + case LiteralTypes.Boolean: + addCheckbox(modal, param); + break; + case LiteralTypes.Vector: + addVector(modal, param); + break; + default: + addDropdown(modal, param).then(); + break; + } + } +} + +function addText(modal: HTMLElement, param: TextParam) { + const field = $paramTemplates.text.cloneNode(true) as HTMLSelectElement; + const input = field.querySelector("input")!; + setupField(field, input, param); + input.minLength = param.minLength ?? param.required ? 1 : 0; + input.maxLength = param.maxLength ?? 255; + if (param.defaultValue !== undefined) { + input.value = param.defaultValue; + } + modal.appendChild(field); +} + +function addNumeric(modal: HTMLElement, param: NumericParam) { + const field = $paramTemplates.number.cloneNode(true) as HTMLSelectElement; + const input = field.querySelector("input")!; + input.type = "number"; + if (param.type == LiteralTypes.Integer) { + input.step = "1"; + } else if (param.type == LiteralTypes.Float) { + input.step = "0.01"; + } + input.min = param.min?.toString() ?? ""; + input.max = param.max?.toString() ?? ""; + setupField(field, input, param); + + if (Number.isFinite(param.defaultValue)) input.value = param.defaultValue!.toString(); + + modal.appendChild(field); +} + +function addCheckbox(modal: HTMLElement, param: BooleanParam) { + const field = $paramTemplates.toggle.cloneNode(true) as HTMLSelectElement; + const input = field.querySelector("input")!; + setupField(field, input, param); + if (param.defaultValue !== undefined) { + input.checked = param.defaultValue; + } + // browser says "required" means "must be checked" + input.required = false; + modal.appendChild(field); +} + +async function addDropdown(modal: HTMLElement, param: EnumParam) { + let options: string[] | undefined = []; + + options = (await getConfig()).enums?.[param.type]; + if (!options) return; // someone's messing with the config, screw em + + const field = $paramTemplates.dropdown.cloneNode(true) as HTMLSelectElement; + const select = field.querySelector("select")!; + + setupField(field, select, param); + for (let i = 0; i < options.length; i++) { + const option = document.createElement("option"); + const name = options[i]; + option.value = i.toString(); + option.disabled = name.startsWith("[DISABLED] "); + option.textContent = name.substring(option.disabled ? 11 : 0); + select.appendChild(option); + } + const firstEnabled = Array.from(select.options).findIndex((op) => !op.disabled); + if (firstEnabled < 0 || firstEnabled >= select.options.length) { + console.error(`No enabled options in enum ${param.type}`); + showErrorModal("Config error", `This redeem is misconfigured, please message AlexejheroDev\nError: ${param.type} has no enabled options`); + return; + } + + if (param.defaultValue !== undefined) { + select.value = param.defaultValue; + } else { + select.value = select.options[firstEnabled].value; + } + modal.appendChild(field); +} + +function addVector(modal: HTMLElement, param: VectorParam) { + const field = $paramTemplates.vector.cloneNode(true) as HTMLSelectElement; + const inputs = Array.from(field.querySelectorAll("input")!) as HTMLInputElement[]; + + for (let i = 0; i < 3; i++) { + const input = inputs[i]; + + input.step = "1"; + + input.min = param.min?.toString() ?? ""; + input.max = param.max?.toString() ?? ""; + + setupField(field, input, param, i); + + const defVal = param.defaultValue?.[i]; + input.value = Number.isFinite(defVal) ? defVal!.toString() : "0"; + } + + modal.appendChild(field); +} + +function setupField(field: HTMLElement, inputElem: HTMLSelectElement | HTMLInputElement, param: Parameter, arrayIndex?: number) { + const label = field.querySelector("label")!; + + field.id += "-" + param.name; + + if (param.description) { + field.title = param.description; + } + + inputElem.id += "-" + param.name; + inputElem.name = param.name.concat(arrayIndex !== undefined ? `[${arrayIndex}]` : ""); + + label.id += "-" + param.name; + label.htmlFor = inputElem.id; + label.textContent = param.title ?? param.name; + + if (param.required) { + inputElem.required = true; + label.ariaRequired = ""; + } +} diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts new file mode 100644 index 0000000..a8fe1bc --- /dev/null +++ b/frontend/www/src/modules/modal/index.ts @@ -0,0 +1,223 @@ +import { Cart, Redeem } from "common/types"; +import { ebsFetch } from "../../util/ebs"; +import { getConfig } from "../../util/config"; +import { logToDiscord } from "../../util/logger"; +import { setBanned } from "../auth"; +import { twitchUseBits } from "../../util/twitch"; +import { transactionCancelled, transactionComplete } from "../transaction"; +import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; + +document.body.addEventListener("dblclick", (e) => { + e.stopPropagation(); + e.preventDefault(); +}); + +/* Containers */ +const $modalWrapper = document.getElementById("modal-wrapper")!; +const $modal = document.getElementById("modal-wrapper")!.getElementsByClassName("modal")[0]!; +const $modalInsideWrapper = $modal.getElementsByClassName("modal-inside-wrapper")[0]!; + +/* Descriptors */ +const $modalTitle = document.getElementById("modal-title")!; +const $modalDescription = document.getElementById("modal-description")!; +const $modalImage = document.getElementById("modal-image")! as HTMLImageElement; + +/* Price */ +const $modalPrice = document.getElementById("modal-bits")!; + +/* Buttons */ +export const $modalConfirm = document.getElementById("modal-confirm")! as HTMLButtonElement; +export const $modalCancel = document.getElementById("modal-cancel")! as HTMLButtonElement; + +/* Modal overlays */ +const $modalProcessing = document.getElementById("modal-processing")!; +const $modalProcessingDescription = document.getElementById("modal-processing-description")!; +const $modalProcessingClose = document.getElementById("modal-processing-close")!; + +const $modalError = document.getElementById("modal-error")!; +const $modalErrorTitle = document.getElementById("modal-error-title")!; +const $modalErrorDescription = document.getElementById("modal-error-description")!; +const $modalErrorClose = document.getElementById("modal-error-close")!; + +const $modalSuccess = document.getElementById("modal-success")!; +const $modalSuccessTitle = document.getElementById("modal-success-title")!; +const $modalSuccessDescription = document.getElementById("modal-success-description")!; +const $modalSuccessClose = document.getElementById("modal-success-close")!; + +export let cart: Cart | undefined; +export let transactionToken: string | undefined; + +let processingTimeout: number | undefined; + +document.addEventListener("DOMContentLoaded", () => { + $modalConfirm.onclick = confirmPurchase; + $modalCancel.onclick = closeModal; + + $modalWrapper.onclick = (e) => { + if (e.target !== $modalWrapper) return; + if ($modalProcessing.style.opacity == "1") return; + + closeModal(); + }; +}); + +export async function openModal(redeem: Redeem | null) { + if (redeem == null) { + $modalWrapper.style.opacity = "1"; + $modalWrapper.style.pointerEvents = "unset"; + setTimeout(() => $modal.classList.add("active-modal"), 10); + return; + } + if (redeem.disabled) return; + + const config = await getConfig(); + + cart = { version: config.version, sku: redeem.sku, id: redeem.id, args: {} }; + + $modalWrapper.style.opacity = "1"; + $modalWrapper.style.pointerEvents = "unset"; + + $modalTitle.textContent = redeem.title; + $modalDescription.textContent = redeem.description; + $modalPrice.textContent = redeem.price.toString(); + $modalImage.src = redeem.image; + + // scroll to top of modal + $modalInsideWrapper.scrollTop = 0; + + setTimeout(() => $modal.classList.add("active-modal"), 10); + + hideProcessingModal(); + hideSuccessModal(); + hideErrorModal(); + + setupForm(redeem); + checkForm(); +} + +export function showProcessingModal() { + $modalProcessing.style.opacity = "1"; + $modalProcessing.style.pointerEvents = "unset"; + + $modalProcessingDescription.style.display = "none"; + $modalProcessingClose.style.display = "none"; + + if (processingTimeout) clearTimeout(processingTimeout); + + processingTimeout = +setTimeout(() => { + $modalProcessingDescription.style.display = "unset"; + $modalProcessingDescription.textContent = "This is taking longer than expected."; + + $modalProcessingClose.style.display = "unset"; + $modalProcessingClose.onclick = () => { + hideProcessingModal(); + closeModal(); + }; + }, 30 * 1000); +} + +export function showErrorModal(title: string, description: string) { + $modalError.style.opacity = "1"; + $modalError.style.pointerEvents = "unset"; + $modalErrorTitle.textContent = title; + $modalErrorDescription.innerText = description; + $modalErrorClose.onclick = () => hideErrorModal(true); +} + +export function showSuccessModal(title: string, description: string, onClose?: () => void) { + $modalSuccess.style.opacity = "1"; + $modalSuccess.style.pointerEvents = "unset"; + $modalSuccessTitle.textContent = title; + $modalSuccessDescription.innerText = description; + $modalSuccessClose.onclick = () => { + hideSuccessModal(true); + onClose?.(); + }; +} + +function closeModal() { + cart = undefined; + transactionToken = undefined; + + $modal.classList.remove("active-modal"); + + setTimeout(() => { + $modalWrapper.style.opacity = "0"; + $modalWrapper.style.pointerEvents = "none"; + }, 250); +} + +export function hideProcessingModal() { + $modalProcessing.style.opacity = "0"; + $modalProcessing.style.pointerEvents = "none"; + + if (processingTimeout) clearTimeout(processingTimeout); +} + +function hideErrorModal(closeMainModal = false) { + $modalError.style.opacity = "0"; + $modalError.style.pointerEvents = "none"; + + if (closeMainModal) closeModal(); +} + +function hideSuccessModal(closeMainModal = false) { + $modalSuccess.style.opacity = "0"; + $modalSuccess.style.pointerEvents = "none"; + + if (closeMainModal) closeModal(); +} + +async function confirmPurchase() { + setCartArgsFromForm($modalOptionsForm); + if (!$modalOptionsForm.reportValidity()) { + return; + } + showProcessingModal(); + + if (!(await prePurchase())) { + return; + } + + logToDiscord({ + transactionToken: transactionToken!, + userIdInsecure: Twitch.ext.viewer.id!, + important: false, + fields: [{ header: "Transaction started", content: cart }], + }).then(); + + const res = await twitchUseBits(cart!.sku); + if (res === "cancelled") { + await transactionCancelled(); + } else { + await transactionComplete(res); + } +} + +async function prePurchase() { + const response = await ebsFetch("/public/prepurchase", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(cart), + }); + + 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( + "Invalid transaction, please try again.", + `${response.status} ${response.statusText} - ${await response.text()}\nIf this problem persists, please refresh the page or contact a moderator (preferably AlexejheroDev).` + ); + } + return false; + } + + transactionToken = await response.text(); + + return true; +} From 873b1ca9a21ed0af48b720c79c6302156cb90918 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Thu, 11 Jul 2024 11:25:57 +1000 Subject: [PATCH 09/22] why were we targeting 2009 js in the first place --- frontend/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index d057767..89b4663 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2018", "module": "es6", "outDir": "./dist/", "noImplicitAny": true, From 71c6b07352db46e24845f8aeaf8eb213f4f37fbf Mon Sep 17 00:00:00 2001 From: Govorunb Date: Thu, 11 Jul 2024 14:03:35 +1000 Subject: [PATCH 10/22] crappy fixes fix error if game connects before config refresh make refunds actually work (barely) --- ebs/src/modules/config/index.ts | 4 +- ebs/src/modules/game/connection.ts | 6 +-- ebs/src/modules/orders/endpoints/public.ts | 2 +- ebs/src/modules/orders/transaction.ts | 19 +++++-- ebs/src/modules/user/endpoints.ts | 6 ++- frontend/www/src/index.ts | 1 + frontend/www/src/modules/auth.ts | 11 +++- frontend/www/src/modules/modal/index.ts | 5 +- frontend/www/src/modules/transaction.ts | 62 ++++++++++++++-------- frontend/www/src/util/twitch.ts | 14 ++--- 10 files changed, 84 insertions(+), 46 deletions(-) diff --git a/ebs/src/modules/config/index.ts b/ebs/src/modules/config/index.ts index 8b24ef6..85b73fc 100644 --- a/ebs/src/modules/config/index.ts +++ b/ebs/src/modules/config/index.ts @@ -77,10 +77,10 @@ export function isIngame() { return ingameState; } -export function setIngame(newIngame: boolean) { +export async function setIngame(newIngame: boolean) { if (ingameState == newIngame) return; ingameState = newIngame; - setActiveConfig(configData!).then(); + await setActiveConfig(await getRawConfigData()); } function processConfig(data: Config) { diff --git a/ebs/src/modules/game/connection.ts b/ebs/src/modules/game/connection.ts index 83d9940..99b2dc7 100644 --- a/ebs/src/modules/game/connection.ts +++ b/ebs/src/modules/game/connection.ts @@ -20,7 +20,7 @@ export class GameConnection { static resultWaitTimeout: number = 10000; private resendIntervalHandle?: number; private resendInterval = 500; - private redeemHandlers: RedeemHandler[] = [this.sendRedeemToGame]; + private redeemHandlers: RedeemHandler[] = [GameConnection.prototype.sendRedeemToGame.bind(this)]; public isConnected() { return this.socket?.readyState == ServerWS.OPEN; @@ -51,7 +51,7 @@ export class GameConnection { ws.on("close", (code, reason) => { const reasonStr = reason ? `reason '${reason}'` : "no reason"; console.log(`Game socket closed with code ${code} and ${reasonStr}`); - setIngame(false); + setIngame(false).then(); if (this.resendIntervalHandle) { clearInterval(this.resendIntervalHandle); } @@ -92,7 +92,7 @@ export class GameConnection { this.resultHandlers.delete(msg.guid); break; case MessageType.IngameStateChanged: - setIngame(msg.ingame); + setIngame(msg.ingame).then(); break; default: this.logMessage(msg, `Unknown message type ${msg.messageType}`); diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index 8c51f76..a5c0f06 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -181,7 +181,7 @@ app.post( try { const result = await connection.redeem(redeem, order, userInfo); const processedResult = await processRedeemResult(order, result); - logContext.important = processedResult.status !== 200; + logContext.important = processedResult.status === 500; logMessage.header = processedResult.logHeaderOverride ?? processedResult.message; logMessage.content = processedResult.logContents ?? { transaction }; sendToLogger(logContext).then(); diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts index 7a893dd..a826ae2 100644 --- a/ebs/src/modules/orders/transaction.ts +++ b/ebs/src/modules/orders/transaction.ts @@ -86,16 +86,29 @@ export async function processRedeemResult(order: Order, result: ResultMessage): order.state = orderStateMap[result.status]; order.result = result.message; await saveOrder(order); + let msg = result.message; + const res = { logContents: { order: order.id, cart: order.cart } }; if (result.status === "success") { console.log(`[${result.guid}] Redeem succeeded: ${JSON.stringify(result)}`); - let msg = "Your transaction was successful! Your redeem will appear on stream soon."; + msg = "Your transaction was successful! Your redeem will appear on stream soon."; if (result.message) { msg += "\n\n" + result.message; } - return { status: 200, message: msg, logHeaderOverride: "Redeem succeeded", logContents: { order: order.id } }; + return { status: 200, message: msg, logHeaderOverride: "Redeem succeeded", ...res }; } else { await refund(order); - return { status: 500, message: result.message ?? "Redeem failed", logHeaderOverride: "Redeem failed", logContents: { order: order.id } }; + let status: number; + let header: string; + if (result.status === "deny") { + status = 400; + msg ??= "The redeem could not complete."; + header = "Redeem denied"; + } else { + status = 500; + msg ??= "Redeem failed."; + header = "Redeem failed"; + } + return { status: status, message: msg, logHeaderOverride: header, ...res }; } } diff --git a/ebs/src/modules/user/endpoints.ts b/ebs/src/modules/user/endpoints.ts index 9945b64..686469f 100644 --- a/ebs/src/modules/user/endpoints.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -6,8 +6,10 @@ import { setUserBanned } from "."; app.post( "/public/authorized", asyncCatch(async (req, res) => { - res.sendStatus(200); - updateUserTwitchInfo(req.user).then().catch(console.error); + const user = await updateUserTwitchInfo(req.user); + //console.log(`${user.displayName} has ${user.credit}`); + res.status(200).send({ credit: user.credit }); + return; }) ); diff --git a/frontend/www/src/index.ts b/frontend/www/src/index.ts index b78a8f1..5ffa535 100644 --- a/frontend/www/src/index.ts +++ b/frontend/www/src/index.ts @@ -12,3 +12,4 @@ import "./modules/modal"; import "./modules/pubsub"; import "./modules/redeems"; import "./modules/transaction"; +import "./util/twitch"; diff --git a/frontend/www/src/modules/auth.ts b/frontend/www/src/modules/auth.ts index eb3104a..24be8ca 100644 --- a/frontend/www/src/modules/auth.ts +++ b/frontend/www/src/modules/auth.ts @@ -2,21 +2,28 @@ import { ebsFetch } from "../util/ebs"; import { renderRedeemButtons } from "./redeems"; import { refreshConfig, setConfig } from "../util/config"; import { twitchAuth } from "../util/twitch"; +import { updateClientsideBalance } from "./transaction"; const $loginPopup = document.getElementById("onboarding")!; const $loginButton = document.getElementById("twitch-login")!; document.addEventListener("DOMContentLoaded", () => { $loginButton.onclick = async () => { - const res = await twitchAuth(); + const auth = await twitchAuth(); $loginPopup.style.display = "none"; ebsFetch("public/authorized", { method: "POST", - body: JSON.stringify({ channelId: res.channelId, userId: res.userId }), + body: JSON.stringify({ channelId: auth.channelId, userId: Twitch.ext.viewer.id! }), }).then((res) => { if (res.status === 403) { setBanned(true); } + res.json().then( + (resp: {credit: number}) => { + console.log(`Balance: ${resp.credit}`); + updateClientsideBalance(resp.credit); + } + ); renderRedeemButtons().then(); }); }; diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index a8fe1bc..263d630 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -3,8 +3,7 @@ import { ebsFetch } from "../../util/ebs"; import { getConfig } from "../../util/config"; import { logToDiscord } from "../../util/logger"; import { setBanned } from "../auth"; -import { twitchUseBits } from "../../util/twitch"; -import { transactionCancelled, transactionComplete } from "../transaction"; +import { promptTransaction, transactionCancelled, transactionComplete } from "../transaction"; import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; document.body.addEventListener("dblclick", (e) => { @@ -186,7 +185,7 @@ async function confirmPurchase() { fields: [{ header: "Transaction started", content: cart }], }).then(); - const res = await twitchUseBits(cart!.sku); + const res = await promptTransaction(cart!.sku); if (res === "cancelled") { await transactionCancelled(); } else { diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index 60f1e17..e0b3e71 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -11,6 +11,7 @@ let myCredit = 0; export async function promptTransaction(sku: string): Promise { // highly advanced technology (sku names are all "bitsXXX") const bitsPrice = parseInt(sku.substring(4)); + console.log(`Purchasing ${sku} for ${bitsPrice} bits (have ${myCredit})`); if (myCredit > bitsPrice) { // TODO: "use credit?" confirmation modal return "usedCredit"; @@ -19,7 +20,7 @@ export async function promptTransaction(sku: string): Promise hideProcessingModal(), 250); const text = await result.text(); + // Transaction token can no longer be used to log if (result.ok) { - // Transaction token can no longer be used to log showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken}`); } else { - const errorText = `${result.status} ${result.statusText} - ${text}`; - logToDiscord({ - transactionToken: transactionToken, - userIdInsecure: Twitch.ext.viewer.id!, - important: true, - fields: [ - { - header: "Transaction failed (frontend)", - content: errorText, - }, - ], - }).then(); - showErrorModal( - "An error occurred.", - `${errorText}\nPlease contact a moderator (preferably AlexejheroDev) about this!\nTransaction ID: ${transactionToken}` - ); + if (result.status === 400) { + logToDiscord({ + transactionToken: transactionToken, + userIdInsecure: Twitch.ext.viewer.id!, + important: true, + fields: [{ header: "Redeem denied", content: text }], + }).then(); + showErrorModal( + "Redeem not available", + `${text} + You have been credited the redeem cost, so you may try again later. + Transaction ID: ${transactionToken}` + ); + } else if (result.status === 500) { + const errorText = `${result.status} ${result.statusText} - ${text}`; + logToDiscord({ + transactionToken: transactionToken, + userIdInsecure: Twitch.ext.viewer.id!, + important: true, + fields: [{ header: "Redeem failed", content: errorText }], + }).then(); + showErrorModal( + "An error occurred.", + `${errorText} + Please contact a moderator (preferably AlexejheroDev) about the error! + You have been credited the redeem cost, so you may try again later. + Transaction ID: ${transactionToken} + ` + ); + } } -}; +} export async function transactionCancelled() { if (transactionToken) { @@ -118,7 +134,7 @@ export async function transactionCancelled() { hideProcessingModal(); showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken}`); -}; +} export async function updateClientsideBalance(credit: number) { myCredit = credit; diff --git a/frontend/www/src/util/twitch.ts b/frontend/www/src/util/twitch.ts index 687f6ac..735934b 100644 --- a/frontend/www/src/util/twitch.ts +++ b/frontend/www/src/util/twitch.ts @@ -4,18 +4,18 @@ type AuthResponse = Twitch.ext.Authorized; type TransactionResponse = Twitch.ext.BitsTransaction | "cancelled"; class Callbacks { - persistent: Callback[] = []; - transient: Callback[] = []; + private persistent: Callback[] = []; + private transient: Callback[] = []; - addPersistent(callback: Callback) { + public addPersistent(callback: Callback) { this.persistent.push(callback); } - addTransient(callback: Callback) { + public addTransient(callback: Callback) { this.transient.push(callback); } - call(data: T) { + public call(data: T) { this.persistent.forEach((cb) => cb(data)); this.transient.forEach((cb) => cb(data)); this.transient.splice(0, this.transient.length); @@ -42,12 +42,12 @@ export async function twitchAuth(requestIdShare = true): Promise { if (!Twitch.ext.viewer.id && requestIdShare) { Twitch.ext.actions.requestIdShare(); } - return new Promise(authCallbacks.addTransient); + return new Promise(Callbacks.prototype.addTransient.bind(authCallbacks)); } export async function twitchUseBits(sku: string): Promise { Twitch.ext.bits.useBits(sku); - return new Promise(transactionCallbacks.addTransient); + return new Promise(Callbacks.prototype.addTransient.bind(transactionCallbacks)); } export function onTwitchAuth(callback: Callback) { From ada8be948ab05ec880b7819ba4394daa7a72d61f Mon Sep 17 00:00:00 2001 From: Govorunb Date: Thu, 11 Jul 2024 14:13:18 +1000 Subject: [PATCH 11/22] add small grace period for extension logs --- logger/src/modules/endpoints.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/logger/src/modules/endpoints.ts b/logger/src/modules/endpoints.ts index 943569d..cbbddfa 100644 --- a/logger/src/modules/endpoints.ts +++ b/logger/src/modules/endpoints.ts @@ -12,6 +12,9 @@ const orderStatesCanLog: { [key in OrderState]: boolean } = { failed: true, // log error succeeded: false, // completed }; +// allow frontend to send logs for orders that were just completed +// since frontend always finds out about errors after the ebs +const completedOrderLogGracePeriod = 5 * 1000; const rejectLogsWithNoToken = true; app.post("/log", async (req, res) => { @@ -57,7 +60,7 @@ async function canLog(logMessage: LogMessage, isBackendRequest: boolean): Promis } const order = await getOrderById(logMessage.transactionToken); - if (!order || !orderStatesCanLog[order.state]) { + if (!order || (!orderStatesCanLog[order.state] && Date.now() - order.updatedAt > completedOrderLogGracePeriod)) { return { status: 400, reason: "Invalid transaction token." }; } From 81669bff80360a3d02f6b42bb426af1c3ecd4f46 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Fri, 12 Jul 2024 02:57:56 +1000 Subject: [PATCH 12/22] nvm pubsub whispers are way too much of a hassle --- common/types.ts | 2 +- ebs/src/modules/orders/endpoints/public.ts | 4 +++- frontend/www/src/modules/modal/index.ts | 11 +++++++++-- frontend/www/src/modules/pubsub.ts | 13 ------------- frontend/www/src/modules/transaction.ts | 1 - 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/common/types.ts b/common/types.ts index cd629d7..c003b6e 100644 --- a/common/types.ts +++ b/common/types.ts @@ -91,7 +91,7 @@ export type Transaction = { } export type PubSubMessage = { - type: "config_refreshed" | "banned" | "balance_update"; + type: "config_refreshed" | "banned"; data: string; }; diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index a5c0f06..6b2f8e5 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -61,7 +61,9 @@ app.post( logMessage.content = { orderId: order.id }; sendToLogger(logContext).then(); - res.status(200).send(order.id); + // TODO: maybe send a signed JWT w/ credit and cost? + // would allow some leniency in terms of config changing + res.status(200).json({ transactionToken: order.id, credit: req.user.credit }); }) ); diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index 263d630..3e0c906 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -3,7 +3,7 @@ import { ebsFetch } from "../../util/ebs"; import { getConfig } from "../../util/config"; import { logToDiscord } from "../../util/logger"; import { setBanned } from "../auth"; -import { promptTransaction, transactionCancelled, transactionComplete } from "../transaction"; +import { promptTransaction, transactionCancelled, transactionComplete, updateClientsideBalance } from "../transaction"; import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; document.body.addEventListener("dblclick", (e) => { @@ -193,6 +193,11 @@ async function confirmPurchase() { } } +type PrepurchaseResponse = { + transactionToken: string; + credit: number; +} + async function prePurchase() { const response = await ebsFetch("/public/prepurchase", { method: "POST", @@ -216,7 +221,9 @@ async function prePurchase() { return false; } - transactionToken = await response.text(); + const resp = await response.json() as PrepurchaseResponse; + transactionToken = resp.transactionToken; + updateClientsideBalance(resp.credit); return true; } diff --git a/frontend/www/src/modules/pubsub.ts b/frontend/www/src/modules/pubsub.ts index 277bf95..00e9575 100644 --- a/frontend/www/src/modules/pubsub.ts +++ b/frontend/www/src/modules/pubsub.ts @@ -3,18 +3,9 @@ import { setConfig } from "../util/config"; import { renderRedeemButtons } from "./redeems"; import { strToU8, decompressSync, strFromU8 } from "fflate"; import { getBanned, setBanned } from "./auth"; -import { onTwitchAuth } from "../util/twitch"; -import { updateClientsideBalance } from "./transaction"; Twitch.ext.listen("global", onPubsubMessage); -let whisperListenTarget: string; -onTwitchAuth((auth) => { - whisperListenTarget = `whisper-${auth.userId}`; - console.log(`Listening to ${whisperListenTarget}`); - Twitch.ext.listen(whisperListenTarget, onPubsubMessage); -}); - async function onPubsubMessage(target: string, contentType: string, message: string) { const fullMessage = JSON.parse(message) as PubSubMessage; @@ -35,9 +26,5 @@ async function onPubsubMessage(target: string, contentType: string, message: str setBanned(data.banned); } break; - case "balance_update": - const balance = JSON.parse(fullMessage.data) as number; - updateClientsideBalance(balance); - break; } } diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index e0b3e71..7ad5e1f 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -72,7 +72,6 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio setTimeout(() => hideProcessingModal(), 250); const text = await result.text(); - // Transaction token can no longer be used to log if (result.ok) { showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken}`); } else { From 58afc51de41a66402e243413510d6c37cb38cbfb Mon Sep 17 00:00:00 2001 From: Govorunb Date: Fri, 12 Jul 2024 13:40:41 +1000 Subject: [PATCH 13/22] software surgery part 1 send a JWT as the transaction token, expecting the extension to echo it back on purchase expire transaction token (for credit purchases) prevent replaying Twitch bit transactions (it was possible the whole time holy monka) --- common/types.ts | 60 +++++++++-- ebs/src/modules/game/stresstest.ts | 4 +- ebs/src/modules/orders/endpoints/public.ts | 28 ++---- ebs/src/modules/orders/transaction.ts | 110 +++++++++++++++++---- ebs/src/types.ts | 19 ---- ebs/src/util/jwt.ts | 4 +- frontend/www/src/modules/modal/index.ts | 11 ++- frontend/www/src/modules/transaction.ts | 35 +++---- frontend/www/src/util/jwt.ts | 16 +++ 9 files changed, 197 insertions(+), 90 deletions(-) create mode 100644 frontend/www/src/util/jwt.ts diff --git a/common/types.ts b/common/types.ts index c003b6e..c943bf5 100644 --- a/common/types.ts +++ b/common/types.ts @@ -78,17 +78,59 @@ export type Cart = { args: { [name: string]: any }; }; -export type IdentifiableCart = Cart & { - userId: string; -}; +export type IdentifiableCart = Cart & { userId: string }; -export type Transaction = { - token: string; +export type Transaction = BitsTransaction | CreditTransaction; + +export type TransactionBase = { + token: string; // JWT with TransactionToken (given by EBS on prepurchase) type: "bits" | "credit"; - // for type:"bits", this is a BitsTransactionPayload (JWT signed by Twitch) - // for type:"credit", this should be null/not present since verification will be done serverside - receipt?: string; -} +}; +export type BitsTransaction = TransactionBase & { + type: "bits"; + receipt: string; // JWT with BitsTransactionPayload (coming from Twitch) +}; +export type CreditTransaction = TransactionBase & { type: "credit" }; + +export type DecodedTransaction = { + token: TransactionTokenPayload; +} & ({ type: "credit" } | { type: "bits"; receipt: BitsTransactionPayload }); + +export type TransactionToken = { + id: string; + time: number; // Unix millis + user: { + id: string; // user channel id + credit: number; + }; + product: { + sku: string; + cost: number; + }; +}; +export type TransactionTokenPayload = { + exp: number; + data: TransactionToken; +}; + +export type BitsTransactionPayload = { + topic: string; + exp: number; + data: { + transactionId: string; + time: string; + userId: string; + product: { + domainId: string; + sku: string; + displayName: string; + cost: { + amount: number; + type: "bits"; + }; + }; + }; +}; export type PubSubMessage = { type: "config_refreshed" | "banned"; diff --git a/ebs/src/modules/game/stresstest.ts b/ebs/src/modules/game/stresstest.ts index cac7dda..11c564b 100644 --- a/ebs/src/modules/game/stresstest.ts +++ b/ebs/src/modules/game/stresstest.ts @@ -1,8 +1,8 @@ -import { Order } from "common/types"; +import { BitsTransactionPayload, Order } from "common/types"; import { connection } from "."; import { getConfig } from "../config"; import { signJWT } from "../../util/jwt"; -import { AuthorizationPayload, BitsTransactionPayload } from "../../types"; +import { AuthorizationPayload } from "../../types"; export enum StressTestType { GameSpawnQueue, diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index 6b2f8e5..f6a803b 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -8,7 +8,8 @@ import { TwitchUser } from "../../game/messages"; import { asyncCatch } from "../../../util/middleware"; import { validatePrepurchase } from "../prepurchase"; import { setUserBanned } from "../../user"; -import { checkBitsTransaction, getAndCheckOrder, processRedeemResult } from "../transaction"; +import { getAndCheckOrder, jwtExpirySeconds, makeTransactionToken, processRedeemResult } from "../transaction"; +import { signJWT } from "../../../util/jwt"; app.post( "/public/prepurchase", @@ -57,13 +58,14 @@ app.post( order.state = "prepurchase"; await saveOrder(order); + const transactionToken = makeTransactionToken(order, req.user); + const transactionTokenJWT = signJWT(transactionToken, { expiresIn: jwtExpirySeconds }); + logMessage.header = "Created prepurchase"; - logMessage.content = { orderId: order.id }; + logMessage.content = { orderId: order.id, token: transactionTokenJWT }; sendToLogger(logContext).then(); - // TODO: maybe send a signed JWT w/ credit and cost? - // would allow some leniency in terms of config changing - res.status(200).json({ transactionToken: order.id, credit: req.user.credit }); + res.status(200).send(transactionTokenJWT); }) ); @@ -80,19 +82,6 @@ app.post( }; const logMessage = logContext.fields[0]; - if (transaction.type === "bits") { - const validationResult = checkBitsTransaction(transaction); - if (validationResult) { - logMessage.header = validationResult.logHeaderOverride ?? validationResult.message; - logMessage.content = validationResult.logContents ?? { transaction }; - if (validationResult.status === 403) { - setUserBanned(req.user, true); - } - res.status(validationResult.status).send(validationResult.message); - return; - } - } - let order: Order; try { const orderMaybe = await getAndCheckOrder(transaction, req.user); @@ -100,6 +89,9 @@ app.post( const checkRes = orderMaybe; logMessage.header = checkRes.logHeaderOverride ?? checkRes.message; logMessage.content = checkRes.logContents ?? { transaction }; + if (checkRes.status === 403) { + setUserBanned(req.user, true); + } res.status(orderMaybe.status).send(orderMaybe.message); return; } else { diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts index a826ae2..2759140 100644 --- a/ebs/src/modules/orders/transaction.ts +++ b/ebs/src/modules/orders/transaction.ts @@ -1,9 +1,9 @@ -import { Order, Transaction, User, OrderState } from "common/types"; -import { BitsTransactionPayload } from "../../types"; +import { Order, Transaction, User, OrderState, TransactionToken, TransactionTokenPayload, DecodedTransaction, BitsTransactionPayload } from "common/types"; import { verifyJWT, parseJWT } from "../../util/jwt"; import { getOrAddUser, getOrder, saveOrder, saveUser } from "../../util/db"; import { ResultKind, ResultMessage } from "../game/messages.game"; import { sendToLogger } from "../../util/logger"; +import { JwtPayload } from "jsonwebtoken"; type HttpResult = { status: number; @@ -12,29 +12,75 @@ type HttpResult = { logContents?: any; }; -export function checkBitsTransaction(transaction: Transaction): HttpResult | null { - // bits transactions get verified earlier +export const jwtExpirySeconds = 60; +const jwtExpiryToleranceMillis = 15 * 1000; +const usedBitsTransactionIds: Set = new Set(); +const defaultResult: HttpResult = { status: 403, message: "Invalid transaction" }; + +export function decodeJWTPayloads(transaction: Transaction): HttpResult | DecodedTransaction { + if (transaction.type !== "bits" && transaction.type !== "credit") { + return { ...defaultResult, logHeaderOverride: "Invalid type" }; + } + if (!transaction.token || !verifyJWT(transaction.token)) { + return { ...defaultResult, logHeaderOverride: "Invalid token" }; + } + const token = parseJWT(transaction.token) as TransactionTokenPayload; if (transaction.type === "bits") { - if (!transaction.receipt) { - return { status: 400, message: "Missing receipt" }; - } - if (!verifyJWT(transaction.receipt)) { - return { status: 403, message: "Invalid receipt" }; + if (!transaction.receipt || !verifyJWT(transaction.receipt)) { + return { ...defaultResult, logHeaderOverride: "Invalid receipt" }; } + return { + type: "bits", + token, + receipt: parseJWT(transaction.receipt) as BitsTransactionPayload, + }; + } + return { + type: "credit", + token, + }; +} - const payload = parseJWT(transaction.receipt) as BitsTransactionPayload; +export function verifyTransaction(transaction: Transaction): HttpResult | TransactionToken { + const decoded = decodeJWTPayloads(transaction); + if ("status" in decoded) { + return decoded; + } + const token = decoded.token; - if (!payload.data.transactionId) { - return { status: 400, message: "Missing transaction ID" }; + if (decoded.type === "bits") { + // for bits purchases, we don't care if our token JWT expired + // because if the bits t/a is valid, the person already paid the money + const receipt = decoded.receipt; + if (receipt.topic != "bits_transaction_receipt") { + // e.g. someone trying to put a token JWT in the receipt field + return { ...defaultResult, logHeaderOverride: "Invalid receipt topic" }; + } + if (usedBitsTransactionIds.has(receipt.data.transactionId)) { + return { ...defaultResult, logHeaderOverride: "Transaction replay" }; + } + usedBitsTransactionIds.add(receipt.data.transactionId); + if (receipt.exp < Date.now() - jwtExpiryToleranceMillis) { + // status 403 and not 400 because bits JWTs have an expiry of 1 hour + // if you're sending a transaction 1 hour after it happened... you're sus + return { ...defaultResult, logHeaderOverride: "Bits receipt expired" }; + } + } else if (decoded.type === "credit") { + if (token.exp < Date.now() - jwtExpiryToleranceMillis) { + return { ...defaultResult, status: 400, message: "Transaction expired, try again", logHeaderOverride: "Credit receipt expired" }; } - } else if (transaction.type !== "credit" || transaction.receipt) { - return { status: 400, message: "Invalid transaction" }; } - return null; + + return token.data; } export async function getAndCheckOrder(transaction: Transaction, user: User): Promise { - const orderMaybe = await getOrder(transaction.token); + const token = verifyTransaction(transaction); + if ("status" in token) { + return token; + } + + const orderMaybe = await getOrder(token.id); if (!orderMaybe) { return { status: 404, message: "Transaction not found" }; } @@ -44,14 +90,22 @@ export async function getAndCheckOrder(transaction: Transaction, user: User): Pr } if (!order.cart) { - return { status: 500, message: "Invalid transaction", logHeaderOverride: "Missing cart", logContents: { order: order.id } }; + return { status: 500, message: "Internal error", logHeaderOverride: "Missing cart", logContents: { order: order.id } }; + } + if (order.cart.sku != token.product.sku) { + return { + status: 400, + message: "Invalid transaction", + logHeaderOverride: "SKU mismatch", + logContents: { cartSku: order.cart.sku, tokenSku: token.product.sku }, + }; } if (transaction.type === "credit") { const sku = order.cart.sku; const cost = parseInt(sku.substring(4)); // bitsXXX if (!isFinite(cost) || cost <= 0) { - return { status: 500, message: "Invalid transaction", logHeaderOverride: "Bad SKU", logContents: { order: order.id, sku } }; + return { status: 500, message: "Internal configuration error", logHeaderOverride: "Bad SKU", logContents: { order: order.id, sku } }; } if (user.credit < cost) { return { @@ -101,7 +155,7 @@ export async function processRedeemResult(order: Order, result: ResultMessage): let header: string; if (result.status === "deny") { status = 400; - msg ??= "The redeem could not complete."; + msg ??= "The game is not ready to process this redeem."; header = "Redeem denied"; } else { status = 500; @@ -129,3 +183,21 @@ export async function refund(order: Order) { }); } } + +export function makeTransactionToken(order: Order, user: User): TransactionToken { + const sku = order.cart.sku; + const cost = parseInt(sku.substring(4)); + if (!isFinite(cost) || cost <= 0) { + throw new Error(`Bad SKU ${sku}`); + } + + return { + id: order.id, + time: Date.now(), + user: { + id: user.id, + credit: user.credit, + }, + product: { sku, cost }, + }; +} diff --git a/ebs/src/types.ts b/ebs/src/types.ts index 674b5a1..e074f80 100644 --- a/ebs/src/types.ts +++ b/ebs/src/types.ts @@ -10,22 +10,3 @@ export type AuthorizationPayload = { send: string[]; }; }; - -export type BitsTransactionPayload = { - topic: string; - exp: number; - data: { - transactionId: string; - time: string; - userId: string; - product: { - domainId: string; - sku: string; - displayName: string; - cost: { - amount: number; - type: "bits"; - }; - }; - }; -}; diff --git a/ebs/src/util/jwt.ts b/ebs/src/util/jwt.ts index b34c9fd..3d07baf 100644 --- a/ebs/src/util/jwt.ts +++ b/ebs/src/util/jwt.ts @@ -25,6 +25,6 @@ function getJwtSecretBuffer() { return cachedBuffer ??= Buffer.from(process.env.JWT_SECRET!, "base64"); } -export function signJWT(payload: object, buffer: Buffer = getJwtSecretBuffer()) { - return jwt.sign(payload, buffer); +export function signJWT(payload: object, options?: jwt.SignOptions) { + return jwt.sign(payload, getJwtSecretBuffer(), options); } diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index 3e0c906..82767f5 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -1,10 +1,11 @@ -import { Cart, Redeem } from "common/types"; +import { Cart, Redeem, TransactionToken } from "common/types"; import { ebsFetch } from "../../util/ebs"; import { getConfig } from "../../util/config"; import { logToDiscord } from "../../util/logger"; import { setBanned } from "../auth"; import { promptTransaction, transactionCancelled, transactionComplete, updateClientsideBalance } from "../transaction"; import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; +import { getJWTPayload as decodeJWT } from "../../util/jwt"; document.body.addEventListener("dblclick", (e) => { e.stopPropagation(); @@ -44,7 +45,8 @@ const $modalSuccessDescription = document.getElementById("modal-success-descript const $modalSuccessClose = document.getElementById("modal-success-close")!; export let cart: Cart | undefined; -export let transactionToken: string | undefined; +export let transactionToken: TransactionToken | undefined; +export let transactionTokenJwt: string | undefined; let processingTimeout: number | undefined; @@ -179,7 +181,7 @@ async function confirmPurchase() { } logToDiscord({ - transactionToken: transactionToken!, + transactionToken: transactionToken!.id, userIdInsecure: Twitch.ext.viewer.id!, important: false, fields: [{ header: "Transaction started", content: cart }], @@ -222,7 +224,8 @@ async function prePurchase() { } const resp = await response.json() as PrepurchaseResponse; - transactionToken = resp.transactionToken; + transactionTokenJwt = resp.transactionToken; + transactionToken = decodeJWT(resp.transactionToken) as TransactionToken; updateClientsideBalance(resp.credit); return true; diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index 7ad5e1f..d2c7c51 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -1,10 +1,10 @@ import { Transaction } from "common/types"; -import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, transactionToken } from "./modal"; +import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, transactionToken, transactionTokenJwt } from "./modal"; import { logToDiscord } from "../util/logger"; import { ebsFetch } from "../util/ebs"; import { twitchUseBits } from "../util/twitch"; -type TransactionResponse = Twitch.ext.BitsTransaction | "usedCredit" | "cancelled"; +type TransactionResponse = Twitch.ext.BitsTransaction | "useCredit" | "cancelled"; let myCredit = 0; @@ -13,14 +13,13 @@ export async function promptTransaction(sku: string): Promise bitsPrice) { - // TODO: "use credit?" confirmation modal - return "usedCredit"; + return (await confirmCreditTransaction(bitsPrice)) ? "useCredit" : "cancelled"; } else { return await twitchUseBits(sku); } } -export async function transactionComplete(transaction: Twitch.ext.BitsTransaction | "usedCredit") { +export async function transactionComplete(transaction: Twitch.ext.BitsTransaction | "useCredit") { if (!transactionToken) { logToDiscord({ transactionToken: null, @@ -41,10 +40,10 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio ); return; } - const isCredit = transaction === "usedCredit"; + const isCredit = transaction === "useCredit"; logToDiscord({ - transactionToken: transactionToken, + transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, important: false, fields: [ @@ -55,18 +54,15 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio ], }).then(); - const transactionObject: Transaction = { - type: isCredit ? "credit" : "bits", - token: transactionToken, - receipt: isCredit ? undefined : transaction.transactionReceipt, - }; - const result = await ebsFetch("/public/transaction", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(transactionObject), + body: JSON.stringify({ + token: transactionTokenJwt!, + ...(isCredit ? { type: "credit" } : { type: "bits", receipt: transaction.transactionReceipt }), + } satisfies Transaction), }); setTimeout(() => hideProcessingModal(), 250); @@ -77,7 +73,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio } else { if (result.status === 400) { logToDiscord({ - transactionToken: transactionToken, + transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, important: true, fields: [{ header: "Redeem denied", content: text }], @@ -91,7 +87,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio } else if (result.status === 500) { const errorText = `${result.status} ${result.statusText} - ${text}`; logToDiscord({ - transactionToken: transactionToken, + transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, important: true, fields: [{ header: "Redeem failed", content: errorText }], @@ -111,7 +107,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio export async function transactionCancelled() { if (transactionToken) { logToDiscord({ - transactionToken: transactionToken, + transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, important: false, fields: [ @@ -139,3 +135,8 @@ export async function updateClientsideBalance(credit: number) { myCredit = credit; // TODO: update UI (when there is UI) } + +export async function confirmCreditTransaction(price: number) { + // temporary obviously + return confirm(`Use ${price} bits from your credit?`); +} diff --git a/frontend/www/src/util/jwt.ts b/frontend/www/src/util/jwt.ts new file mode 100644 index 0000000..5990ff1 --- /dev/null +++ b/frontend/www/src/util/jwt.ts @@ -0,0 +1,16 @@ +// jsonwebtoken is node-only so we'll do this one manually +export function getJWTPayload(token: string) { + const firstDot = token.indexOf('.'); + if (firstDot < 0) return null; + + const secondDot = token.indexOf('.', firstDot + 1); + if (secondDot < 0) return null; + + const payload = token.substring(firstDot + 1, secondDot); + try { + return JSON.parse(atob(payload)); + } catch (e) { + console.error("failed to parse JWT", e); + return null; + } +} From 485ee424c63c34521904c64751817c0135a1ab7e Mon Sep 17 00:00:00 2001 From: Govorunb Date: Fri, 12 Jul 2024 17:47:34 +1000 Subject: [PATCH 14/22] put some stitches on the gaping wound --- ebs/src/modules/config/index.ts | 28 ++++++------- ebs/src/modules/orders/endpoints/public.ts | 49 ++++++++++++++-------- ebs/src/modules/orders/transaction.ts | 27 ++++++------ ebs/src/modules/user/endpoints.ts | 28 +++++++++++-- ebs/src/util/db.ts | 4 +- frontend/www/src/modules/modal/index.ts | 33 +++++++++------ frontend/www/src/modules/transaction.ts | 26 ++++++------ logger/src/util/db.ts | 4 +- 8 files changed, 121 insertions(+), 78 deletions(-) diff --git a/ebs/src/modules/config/index.ts b/ebs/src/modules/config/index.ts index 85b73fc..de8e365 100644 --- a/ebs/src/modules/config/index.ts +++ b/ebs/src/modules/config/index.ts @@ -24,30 +24,30 @@ async function fetchConfig(): Promise { const response = await fetch(url); const responseData = await response.json(); - const data: Config = JSON.parse(atob(responseData.content)) + const data: Config = JSON.parse(atob(responseData.content)); return data; } catch (e: any) { console.error("Error when fetching config from api URL, falling back to raw URL"); console.error(e); - sendToLogger({ - transactionToken: null, - userIdInsecure: null, - important: true, - fields: [ - { - header: "Error when fetching config from api URL, falling back to raw URL", - content: e.toString(), - }, - ], - }).then(); + sendToLogger({ + transactionToken: null, + userIdInsecure: null, + important: true, + fields: [ + { + header: "Error when fetching config from api URL, falling back to raw URL", + content: e.toString(), + }, + ], + }).then(); try { url = `${rawURL}?${Date.now()}`; const response = await fetch(url); const data: Config = await response.json(); - + return data; } catch (e: any) { console.error("Error when fetching config from raw URL, panic"); @@ -64,7 +64,7 @@ async function fetchConfig(): Promise { }, ], }).then(); - + return { version: -1, message: "Error when fetching config from raw URL, panic", diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index f6a803b..b714569 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -1,4 +1,4 @@ -import { Cart, LogMessage, Transaction, Order } from "common/types"; +import { Cart, LogMessage, Transaction, Order, TransactionToken, TransactionTokenPayload } from "common/types"; import { app } from "../../.."; import { getConfig } from "../../config"; import { createOrder, getOrder, saveOrder, updateUserTwitchInfo } from "../../../util/db"; @@ -8,8 +8,8 @@ import { TwitchUser } from "../../game/messages"; import { asyncCatch } from "../../../util/middleware"; import { validatePrepurchase } from "../prepurchase"; import { setUserBanned } from "../../user"; -import { getAndCheckOrder, jwtExpirySeconds, makeTransactionToken, processRedeemResult } from "../transaction"; -import { signJWT } from "../../../util/jwt"; +import { decodeJWTPayloads, getAndCheckOrder, jwtExpirySeconds, makeTransactionToken, processRedeemResult } from "../transaction"; +import { parseJWT, signJWT, verifyJWT } from "../../../util/jwt"; app.post( "/public/prepurchase", @@ -59,7 +59,7 @@ app.post( await saveOrder(order); const transactionToken = makeTransactionToken(order, req.user); - const transactionTokenJWT = signJWT(transactionToken, { expiresIn: jwtExpirySeconds }); + const transactionTokenJWT = signJWT({ data: transactionToken }, { expiresIn: jwtExpirySeconds }); logMessage.header = "Created prepurchase"; logMessage.content = { orderId: order.id, token: transactionTokenJWT }; @@ -75,16 +75,29 @@ app.post( const transaction = req.body as Transaction; const logContext: LogMessage = { - transactionToken: transaction.token, + transactionToken: null, userIdInsecure: req.user.id, important: true, - fields: [{ header: "", content: "" }], + fields: [{ header: "", content: transaction }], }; const logMessage = logContext.fields[0]; + const decoded = decodeJWTPayloads(transaction); + if ("status" in decoded) { + logMessage.header = decoded.logHeaderOverride ?? decoded.message; + logMessage.content = decoded.logContents ?? { transaction }; + if (decoded.status === 403) { + setUserBanned(req.user, true); + } + sendToLogger(logContext).then(); + res.status(decoded.status).send(decoded.message); + return; + } + logContext.transactionToken = decoded.token.data.id; + let order: Order; try { - const orderMaybe = await getAndCheckOrder(transaction, req.user); + const orderMaybe = await getAndCheckOrder(transaction, decoded, req.user); if ("status" in orderMaybe) { const checkRes = orderMaybe; logMessage.header = checkRes.logHeaderOverride ?? checkRes.message; @@ -92,6 +105,7 @@ app.post( if (checkRes.status === 403) { setUserBanned(req.user, true); } + sendToLogger(logContext).then(); res.status(orderMaybe.status).send(orderMaybe.message); return; } else { @@ -134,6 +148,7 @@ app.post( } console.log(transaction); + console.log(decoded); console.log(order.cart); const redeem = currentConfig.redeems?.[order.cart.id]; @@ -199,22 +214,22 @@ app.post( app.post( "/public/transaction/cancel", asyncCatch(async (req, res) => { - const guid = req.body.token as string; + const jwt = req.body.jwt as string; + if (!verifyJWT(jwt)) { + res.sendStatus(403); + return; + } + const token = parseJWT(jwt) as TransactionTokenPayload; const logContext: LogMessage = { - transactionToken: guid, + transactionToken: token.data.id, userIdInsecure: req.user.id, important: true, - fields: [ - { - header: "", - content: "", - }, - ], + fields: [{ header: "", content: "" }], }; const logMessage = logContext.fields[0]; try { - const order = await getOrder(guid); + const order = await getOrder(token.data.id); if (!order) { res.status(404).send("Transaction not found"); @@ -227,7 +242,7 @@ app.post( order, user: req.user, }; - sendToLogger(logContext); + sendToLogger(logContext).then(); res.status(403).send("This transaction doesn't belong to you"); return; } diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts index 2759140..02ca308 100644 --- a/ebs/src/modules/orders/transaction.ts +++ b/ebs/src/modules/orders/transaction.ts @@ -3,7 +3,6 @@ import { verifyJWT, parseJWT } from "../../util/jwt"; import { getOrAddUser, getOrder, saveOrder, saveUser } from "../../util/db"; import { ResultKind, ResultMessage } from "../game/messages.game"; import { sendToLogger } from "../../util/logger"; -import { JwtPayload } from "jsonwebtoken"; type HttpResult = { status: number; @@ -13,7 +12,7 @@ type HttpResult = { }; export const jwtExpirySeconds = 60; -const jwtExpiryToleranceMillis = 15 * 1000; +const jwtExpiryToleranceSeconds = 15; const usedBitsTransactionIds: Set = new Set(); const defaultResult: HttpResult = { status: 403, message: "Invalid transaction" }; @@ -41,11 +40,7 @@ export function decodeJWTPayloads(transaction: Transaction): HttpResult | Decode }; } -export function verifyTransaction(transaction: Transaction): HttpResult | TransactionToken { - const decoded = decodeJWTPayloads(transaction); - if ("status" in decoded) { - return decoded; - } +export function verifyTransaction(decoded: DecodedTransaction): HttpResult | TransactionToken { const token = decoded.token; if (decoded.type === "bits") { @@ -60,13 +55,13 @@ export function verifyTransaction(transaction: Transaction): HttpResult | Transa return { ...defaultResult, logHeaderOverride: "Transaction replay" }; } usedBitsTransactionIds.add(receipt.data.transactionId); - if (receipt.exp < Date.now() - jwtExpiryToleranceMillis) { + if (receipt.exp < Date.now() / 1000 - jwtExpiryToleranceSeconds) { // status 403 and not 400 because bits JWTs have an expiry of 1 hour // if you're sending a transaction 1 hour after it happened... you're sus return { ...defaultResult, logHeaderOverride: "Bits receipt expired" }; } } else if (decoded.type === "credit") { - if (token.exp < Date.now() - jwtExpiryToleranceMillis) { + if (token.exp < Date.now() / 1000 - jwtExpiryToleranceSeconds) { return { ...defaultResult, status: 400, message: "Transaction expired, try again", logHeaderOverride: "Credit receipt expired" }; } } @@ -74,8 +69,8 @@ export function verifyTransaction(transaction: Transaction): HttpResult | Transa return token.data; } -export async function getAndCheckOrder(transaction: Transaction, user: User): Promise { - const token = verifyTransaction(transaction); +export async function getAndCheckOrder(transaction: Transaction, decoded: DecodedTransaction, user: User): Promise { + const token = verifyTransaction(decoded); if ("status" in token) { return token; } @@ -103,7 +98,7 @@ export async function getAndCheckOrder(transaction: Transaction, user: User): Pr if (transaction.type === "credit") { const sku = order.cart.sku; - const cost = parseInt(sku.substring(4)); // bitsXXX + const cost = getBitsPrice(sku); if (!isFinite(cost) || cost <= 0) { return { status: 500, message: "Internal configuration error", logHeaderOverride: "Bad SKU", logContents: { order: order.id, sku } }; } @@ -111,7 +106,6 @@ export async function getAndCheckOrder(transaction: Transaction, user: User): Pr return { status: 409, message: "Insufficient credit", - logHeaderOverride: "Insufficient credit", logContents: { user: user.id, order: order.id, cost, credit: user.credit }, }; } @@ -119,7 +113,7 @@ export async function getAndCheckOrder(transaction: Transaction, user: User): Pr order.state = "paid"; order.receipt = `credit (${user.credit + cost} - ${cost} = ${user.credit})`; } else { - // for bits transactions, verifying the receipt JWT verifies the fact the person paid + // for bits transactions, we verified the receipt JWT earlier (in verifyTransaction) order.state = "paid"; order.receipt = transaction.receipt; } @@ -201,3 +195,8 @@ export function makeTransactionToken(order: Order, user: User): TransactionToken product: { sku, cost }, }; } + +function getBitsPrice(sku: string) { + // highly advanced pricing technology (all SKUs are in the form bitsXXX where XXX is the price) + return parseInt(sku.substring(4)); +} diff --git a/ebs/src/modules/user/endpoints.ts b/ebs/src/modules/user/endpoints.ts index 686469f..3335d9c 100644 --- a/ebs/src/modules/user/endpoints.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -1,5 +1,5 @@ import { app } from "../.."; -import { updateUserTwitchInfo, lookupUser } from "../../util/db"; +import { updateUserTwitchInfo, lookupUser, saveUser } from "../../util/db"; import { asyncCatch } from "../../util/middleware"; import { setUserBanned } from "."; @@ -30,7 +30,7 @@ app.post( } await setUserBanned(user, true); - res.sendStatus(200); + res.status(200).json(user); }) ); @@ -44,6 +44,28 @@ app.delete( } await setUserBanned(user, false); - res.sendStatus(200); + res.status(200).json(user); + }) +); + +app.post( + "/private/user/:idOrName/addCredit", + asyncCatch(async (req, res) => { + const user = await lookupUser(req.params["idOrName"]); + if (!user) { + res.sendStatus(404); + return; + } + + const amt = parseInt(req.query["amount"] as string); + if (!isFinite(amt)) { + res.sendStatus(400); + return; + } + + user.credit += amt; + await saveUser(user); + res.status(200).json(user); + return; }) ); diff --git a/ebs/src/util/db.ts b/ebs/src/util/db.ts index c98e28b..962d9df 100644 --- a/ebs/src/util/db.ts +++ b/ebs/src/util/db.ts @@ -17,8 +17,8 @@ export async function initDb() { namedPlaceholders: true, }); } catch { - console.log("Failed to connect to database. Retrying in 5 seconds..."); - await new Promise((resolve) => setTimeout(resolve, 5000)); + console.log("Failed to connect to database. Retrying in 1 second..."); + await new Promise((resolve) => setTimeout(resolve, 1000)); } } } diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index 82767f5..b79a916 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -1,4 +1,4 @@ -import { Cart, Redeem, TransactionToken } from "common/types"; +import { Cart, Redeem, TransactionToken, TransactionTokenPayload } from "common/types"; import { ebsFetch } from "../../util/ebs"; import { getConfig } from "../../util/config"; import { logToDiscord } from "../../util/logger"; @@ -176,18 +176,19 @@ async function confirmPurchase() { } showProcessingModal(); - if (!(await prePurchase())) { + if (!(await prePurchase()) || !transactionToken) { return; } logToDiscord({ - transactionToken: transactionToken!.id, + transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, important: false, fields: [{ header: "Transaction started", content: cart }], }).then(); - const res = await promptTransaction(cart!.sku); + const product = transactionToken.product; + const res = await promptTransaction(product.sku, product.cost); if (res === "cancelled") { await transactionCancelled(); } else { @@ -195,11 +196,6 @@ async function confirmPurchase() { } } -type PrepurchaseResponse = { - transactionToken: string; - credit: number; -} - async function prePurchase() { const response = await ebsFetch("/public/prepurchase", { method: "POST", @@ -223,10 +219,21 @@ async function prePurchase() { return false; } - const resp = await response.json() as PrepurchaseResponse; - transactionTokenJwt = resp.transactionToken; - transactionToken = decodeJWT(resp.transactionToken) as TransactionToken; - updateClientsideBalance(resp.credit); + transactionTokenJwt = await response.text(); + const decodedJWT = decodeJWT(transactionTokenJwt) as TransactionTokenPayload; + console.log(decodedJWT); + transactionToken = decodedJWT.data; + if (transactionToken.user.id !== Twitch.ext.viewer.id) { + logToDiscord({ + transactionToken: transactionToken.id, + userIdInsecure: Twitch.ext.viewer.id!, + important: true, + fields: [{ header: "Transaction token was not for me", content: { transactionTokenJwt } }], + }).then(); + showErrorModal("Server Error", "Server returned invalid transaction token. The developers have been notified, please try again later."); + return false; + } + updateClientsideBalance(transactionToken.user.credit); return true; } diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index d2c7c51..c604eeb 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -8,12 +8,10 @@ type TransactionResponse = Twitch.ext.BitsTransaction | "useCredit" | "cancelled let myCredit = 0; -export async function promptTransaction(sku: string): Promise { - // highly advanced technology (sku names are all "bitsXXX") - const bitsPrice = parseInt(sku.substring(4)); - console.log(`Purchasing ${sku} for ${bitsPrice} bits (have ${myCredit})`); - if (myCredit > bitsPrice) { - return (await confirmCreditTransaction(bitsPrice)) ? "useCredit" : "cancelled"; +export async function promptTransaction(sku: string, cost: number): Promise { + console.log(`Purchasing ${sku} for ${cost} bits (have ${myCredit})`); + if (myCredit >= cost) { + return (await confirmCreditTransaction(cost)) ? "useCredit" : "cancelled"; } else { return await twitchUseBits(sku); } @@ -69,20 +67,20 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio const text = await result.text(); if (result.ok) { - showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken}`); + showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken.id}`); } else { if (result.status === 400) { logToDiscord({ transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, - important: true, + important: false, fields: [{ header: "Redeem denied", content: text }], }).then(); showErrorModal( "Redeem not available", `${text} You have been credited the redeem cost, so you may try again later. - Transaction ID: ${transactionToken}` + Transaction ID: ${transactionToken.id}` ); } else if (result.status === 500) { const errorText = `${result.status} ${result.statusText} - ${text}`; @@ -97,7 +95,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio `${errorText} Please contact a moderator (preferably AlexejheroDev) about the error! You have been credited the redeem cost, so you may try again later. - Transaction ID: ${transactionToken} + Transaction ID: ${transactionToken.id} ` ); } @@ -123,12 +121,12 @@ export async function transactionCancelled() { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ token: transactionToken }), + body: JSON.stringify({jwt: transactionTokenJwt}), }); } hideProcessingModal(); - showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken}`); + showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken?.id ?? "none"}`); } export async function updateClientsideBalance(credit: number) { @@ -138,5 +136,7 @@ export async function updateClientsideBalance(credit: number) { export async function confirmCreditTransaction(price: number) { // temporary obviously - return confirm(`Use ${price} bits from your credit?`); + return true; + // no modal APIs in this iframe + //return confirm(`Use ${price} bits from your credit?`); } diff --git a/logger/src/util/db.ts b/logger/src/util/db.ts index 819fc67..70fc1c4 100644 --- a/logger/src/util/db.ts +++ b/logger/src/util/db.ts @@ -17,8 +17,8 @@ export async function initDb() { namedPlaceholders: true, }); } catch { - console.log("Failed to connect to database. Retrying in 5 seconds..."); - await new Promise((resolve) => setTimeout(resolve, 5000)); + console.log("Failed to connect to database. Retrying in 1 second..."); + await new Promise((resolve) => setTimeout(resolve, 1000)); } } } From 1dd250f32c06a05384fff233e13449f309c67e3c Mon Sep 17 00:00:00 2001 From: Govorunb Date: Fri, 12 Jul 2024 18:29:06 +1000 Subject: [PATCH 15/22] disambiguate multiple tabs previously you'd get autobanned if you had two tabs open on the twitch bits transaction screen because twitch broadcasts onTransactionComplete to all of them (which looks like replay to us) --- common/types.ts | 2 ++ ebs/src/modules/orders/endpoints/public.ts | 28 +++++++++++++++++++++- ebs/src/modules/orders/transaction.ts | 5 ---- ebs/src/util/jwt.ts | 2 +- frontend/www/src/modules/modal/index.ts | 9 +++++-- frontend/www/src/modules/transaction.ts | 2 ++ 6 files changed, 39 insertions(+), 9 deletions(-) diff --git a/common/types.ts b/common/types.ts index c943bf5..85dbd09 100644 --- a/common/types.ts +++ b/common/types.ts @@ -73,6 +73,7 @@ export type Config = { export type Cart = { version: number; + clientSession: string; // any string to disambiguate between multiple tabs id: string; sku: string; args: { [name: string]: any }; @@ -84,6 +85,7 @@ export type Transaction = BitsTransaction | CreditTransaction; export type TransactionBase = { token: string; // JWT with TransactionToken (given by EBS on prepurchase) + clientSession: string; // same session as in Cart type: "bits" | "credit"; }; export type BitsTransaction = TransactionBase & { diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index b714569..81f8770 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -1,4 +1,4 @@ -import { Cart, LogMessage, Transaction, Order, TransactionToken, TransactionTokenPayload } from "common/types"; +import { Cart, LogMessage, Transaction, Order, TransactionTokenPayload } from "common/types"; import { app } from "../../.."; import { getConfig } from "../../config"; import { createOrder, getOrder, saveOrder, updateUserTwitchInfo } from "../../../util/db"; @@ -11,6 +11,8 @@ import { setUserBanned } from "../../user"; import { decodeJWTPayloads, getAndCheckOrder, jwtExpirySeconds, makeTransactionToken, processRedeemResult } from "../transaction"; import { parseJWT, signJWT, verifyJWT } from "../../../util/jwt"; +const usedBitsTransactionIds: Set = new Set(); + app.post( "/public/prepurchase", asyncCatch(async (req, res) => { @@ -123,6 +125,30 @@ app.post( return; } + + if (decoded.type === "bits") { + const bitsTransaction = decoded.receipt.data.transactionId; + if (usedBitsTransactionIds.has(bitsTransaction)) { + // happens if there are X extension tabs that are all open on the twitch bits modal + // twitch broadcasts onTransactionComplete to all of them and the client ends up + // sending X requests for each completed transaction (where all but 1 will obviously be duplicates) + // we don't want to auto-ban people just for having multiple tabs open + // but it's still obviously not ideal behaviour + if (order.cart.clientSession == transaction.clientSession) { + logMessage.content = { + order: order.id, + bitsTransaction: decoded.receipt.data, + } + logMessage.header = "Transaction replay"; + sendToLogger(logContext).then(); + } + // unfortunately, in this case the other tab(s) will still lose their purchase + res.status(401).send("Invalid transaction"); + return; + } + usedBitsTransactionIds.add(bitsTransaction); + } + if (order.userId != req.user.id) { // paying for somebody else, how generous logContext.important = true; diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts index 02ca308..0defca9 100644 --- a/ebs/src/modules/orders/transaction.ts +++ b/ebs/src/modules/orders/transaction.ts @@ -13,7 +13,6 @@ type HttpResult = { export const jwtExpirySeconds = 60; const jwtExpiryToleranceSeconds = 15; -const usedBitsTransactionIds: Set = new Set(); const defaultResult: HttpResult = { status: 403, message: "Invalid transaction" }; export function decodeJWTPayloads(transaction: Transaction): HttpResult | DecodedTransaction { @@ -51,10 +50,6 @@ export function verifyTransaction(decoded: DecodedTransaction): HttpResult | Tra // e.g. someone trying to put a token JWT in the receipt field return { ...defaultResult, logHeaderOverride: "Invalid receipt topic" }; } - if (usedBitsTransactionIds.has(receipt.data.transactionId)) { - return { ...defaultResult, logHeaderOverride: "Transaction replay" }; - } - usedBitsTransactionIds.add(receipt.data.transactionId); if (receipt.exp < Date.now() / 1000 - jwtExpiryToleranceSeconds) { // status 403 and not 400 because bits JWTs have an expiry of 1 hour // if you're sending a transaction 1 hour after it happened... you're sus diff --git a/ebs/src/util/jwt.ts b/ebs/src/util/jwt.ts index 3d07baf..a5ef472 100644 --- a/ebs/src/util/jwt.ts +++ b/ebs/src/util/jwt.ts @@ -16,7 +16,7 @@ export function verifyJWT(token: string): boolean { export function parseJWT(token: string) { if (memo[token]) return memo[token]; - const result = jwt.verify(token, getJwtSecretBuffer()); + const result = jwt.verify(token, getJwtSecretBuffer(), { ignoreExpiration: true }); memo[token] = result; return result; } diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index b79a916..c2e5a1f 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -3,7 +3,7 @@ import { ebsFetch } from "../../util/ebs"; import { getConfig } from "../../util/config"; import { logToDiscord } from "../../util/logger"; import { setBanned } from "../auth"; -import { promptTransaction, transactionCancelled, transactionComplete, updateClientsideBalance } from "../transaction"; +import { clientSession, promptTransaction, transactionCancelled, transactionComplete, updateClientsideBalance } from "../transaction"; import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; import { getJWTPayload as decodeJWT } from "../../util/jwt"; @@ -73,7 +73,7 @@ export async function openModal(redeem: Redeem | null) { const config = await getConfig(); - cart = { version: config.version, sku: redeem.sku, id: redeem.id, args: {} }; + cart = { version: config.version, clientSession, sku: redeem.sku, id: redeem.id, args: {} }; $modalWrapper.style.opacity = "1"; $modalWrapper.style.pointerEvents = "unset"; @@ -197,6 +197,11 @@ async function confirmPurchase() { } async function prePurchase() { + if (!cart) { + console.error("Can't send prepurchase without cart"); + return; + } + const response = await ebsFetch("/public/prepurchase", { method: "POST", headers: { diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index c604eeb..ee9389d 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -7,6 +7,7 @@ import { twitchUseBits } from "../util/twitch"; type TransactionResponse = Twitch.ext.BitsTransaction | "useCredit" | "cancelled"; let myCredit = 0; +export const clientSession = Math.random().toString(36).substring(2); export async function promptTransaction(sku: string, cost: number): Promise { console.log(`Purchasing ${sku} for ${cost} bits (have ${myCredit})`); @@ -59,6 +60,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio }, body: JSON.stringify({ token: transactionTokenJwt!, + clientSession, ...(isCredit ? { type: "credit" } : { type: "bits", receipt: transaction.transactionReceipt }), } satisfies Transaction), }); From 38ac8c08ffb52ed5051841cdcd51665e6d7aff66 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Fri, 12 Jul 2024 19:00:48 +1000 Subject: [PATCH 16/22] better disambiguate "rejected" in prepurchase from game-side "denied" --- common/types.ts | 1 + ebs/src/modules/orders/transaction.ts | 2 +- scripts/sql/init_db.sql | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/common/types.ts b/common/types.ts index 85dbd09..10ed9eb 100644 --- a/common/types.ts +++ b/common/types.ts @@ -164,6 +164,7 @@ export type OrderState = | "prepurchase" | "cancelled" | "paid" // waiting for game + | "denied" // routine rejection e.g. precondition failed | "failed" // game failed/timed out | "succeeded"; diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts index 0defca9..f9159f5 100644 --- a/ebs/src/modules/orders/transaction.ts +++ b/ebs/src/modules/orders/transaction.ts @@ -122,7 +122,7 @@ export async function getAndCheckOrder(transaction: Transaction, decoded: Decode const orderStateMap: { [k in ResultKind]: OrderState } = { success: "succeeded", error: "failed", - deny: "rejected", + deny: "denied", }; export async function processRedeemResult(order: Order, result: ResultMessage): Promise { diff --git a/scripts/sql/init_db.sql b/scripts/sql/init_db.sql index f013cea..cfed0ed 100644 --- a/scripts/sql/init_db.sql +++ b/scripts/sql/init_db.sql @@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS orders ( id VARCHAR(36) PRIMARY KEY, userId VARCHAR(255) NOT NULL, - state ENUM('rejected', 'prepurchase', 'cancelled', 'paid', 'failed', 'succeeded'), + state ENUM('rejected', 'prepurchase', 'cancelled', 'paid', 'denied', 'failed', 'succeeded'), cart JSON, receipt VARCHAR(1024), result TEXT, From a0f0bee62eeaaa18d313e393277fe458675778b7 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Fri, 12 Jul 2024 19:34:50 +1000 Subject: [PATCH 17/22] buh --- frontend/www/src/modules/modal/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index c2e5a1f..01d0982 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -196,10 +196,10 @@ async function confirmPurchase() { } } -async function prePurchase() { +async function prePurchase(): Promise { if (!cart) { console.error("Can't send prepurchase without cart"); - return; + return false; } const response = await ebsFetch("/public/prepurchase", { From 3b7f98278c7ed43572d7bfe1c1db69b9ed396f93 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Sat, 13 Jul 2024 15:42:54 +1000 Subject: [PATCH 18/22] prevent opening prepurchases from other tabs it's still possible to lose money but now it's hopefully very difficult to do accidentally --- ebs/src/modules/game/connection.ts | 2 +- ebs/src/modules/game/stresstest.ts | 1 + ebs/src/modules/orders/endpoints/public.ts | 21 ++++--- ebs/src/modules/orders/prepurchase.ts | 69 +++++++++++----------- ebs/src/modules/orders/transaction.ts | 11 +--- ebs/src/modules/pishock/index.ts | 10 ++-- ebs/src/modules/user/endpoints.ts | 8 ++- ebs/src/modules/user/index.ts | 14 +++++ ebs/src/types.ts | 7 +++ ebs/src/util/logger.ts | 4 +- frontend/www/src/modules/auth.ts | 47 +++++++++------ frontend/www/src/modules/modal/index.ts | 6 +- frontend/www/src/modules/transaction.ts | 51 ++++++---------- frontend/www/src/util/logger.ts | 4 +- 14 files changed, 132 insertions(+), 123 deletions(-) diff --git a/ebs/src/modules/game/connection.ts b/ebs/src/modules/game/connection.ts index 99b2dc7..32e6a62 100644 --- a/ebs/src/modules/game/connection.ts +++ b/ebs/src/modules/game/connection.ts @@ -164,7 +164,7 @@ export class GameConnection { command: redeem.id, title: redeem.title, announce: redeem.announce ?? true, - args: order.cart!.args, + args: order.cart.args, user, } as RedeemMessage; if (this.outstandingRedeems.has(msg.guid)) { diff --git a/ebs/src/modules/game/stresstest.ts b/ebs/src/modules/game/stresstest.ts index 11c564b..d29bbe4 100644 --- a/ebs/src/modules/game/stresstest.ts +++ b/ebs/src/modules/game/stresstest.ts @@ -63,6 +63,7 @@ const order: Order = { userId: "stress", cart: { version: 1, + clientSession: "stress", id: redeemId, sku: "bits1", args: { diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index 81f8770..0082668 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -10,6 +10,7 @@ import { validatePrepurchase } from "../prepurchase"; import { setUserBanned } from "../../user"; import { decodeJWTPayloads, getAndCheckOrder, jwtExpirySeconds, makeTransactionToken, processRedeemResult } from "../transaction"; import { parseJWT, signJWT, verifyJWT } from "../../../util/jwt"; +import { HttpResult } from "../../../types"; const usedBitsTransactionIds: Set = new Set(); @@ -33,12 +34,12 @@ app.post( } let order: Order; - let validationError: string | null; + let validationError: HttpResult | null; let fail = "register"; try { order = await createOrder(userId, cart); fail = "validate"; - validationError = await validatePrepurchase(order); + validationError = await validatePrepurchase(order, req.user); } catch (e: any) { logContext.important = true; logMessage.header = `Failed to ${fail} prepurchase`; @@ -50,10 +51,12 @@ app.post( } if (validationError) { - logMessage.header = "Prepurchase failed validation"; - logMessage.content = { orderId: order.id }; + logMessage.header = validationError.logHeaderOverride ?? validationError.message; + logMessage.content = validationError.logContents ?? { order: order.id }; sendToLogger(logContext).then(); - res.status(409).send(validationError); + res.status(validationError.status).send(validationError.message); + order.result = validationError.message; + await saveOrder(order); return; } @@ -125,7 +128,6 @@ app.post( return; } - if (decoded.type === "bits") { const bitsTransaction = decoded.receipt.data.transactionId; if (usedBitsTransactionIds.has(bitsTransaction)) { @@ -134,15 +136,16 @@ app.post( // sending X requests for each completed transaction (where all but 1 will obviously be duplicates) // we don't want to auto-ban people just for having multiple tabs open // but it's still obviously not ideal behaviour - if (order.cart.clientSession == transaction.clientSession) { + if (order.cart.clientSession === transaction.clientSession) { logMessage.content = { order: order.id, bitsTransaction: decoded.receipt.data, - } + }; logMessage.header = "Transaction replay"; sendToLogger(logContext).then(); } - // unfortunately, in this case the other tab(s) will still lose their purchase + // unfortunately, in this case any other tab(s) awaiting twitchUseBits will still lose their purchase + // so we do our best to not allow multiple active prepurchases in the first place res.status(401).send("Invalid transaction"); return; } diff --git a/ebs/src/modules/orders/prepurchase.ts b/ebs/src/modules/orders/prepurchase.ts index 5251069..2705b10 100644 --- a/ebs/src/modules/orders/prepurchase.ts +++ b/ebs/src/modules/orders/prepurchase.ts @@ -1,56 +1,55 @@ -import { Cart, Config, LogMessage, Order } from "common/types"; -import { sendToLogger } from "../../util/logger"; +import { Cart, Config, Order, User } from "common/types"; import { getConfig } from "../config"; +import { HttpResult } from "../../types"; +import { getUserSession } from "../user"; -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 defaultResult: HttpResult = { status: 409, message: "Validation failed" }; + +export async function validatePrepurchase(order: Order, user: User): Promise { + const cart = order.cart; + if (!cart?.clientSession) { + return { ...defaultResult, logHeaderOverride: "Missing client session", logContents: { cart } }; + } - const cart = order.cart!; + const existingSession = await getUserSession(user); + if (existingSession && order.cart.clientSession != existingSession) { + return { + ...defaultResult, + message: "Extension already open in another tab, please try again there or reload this page to make this the main session", + logHeaderOverride: "Non-main session", + logContents: { existingSession: existingSession, order: order.id }, + }; + } 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"; + return { ...defaultResult, message: "Invalid config version", logContents: { received: cart.version, expected: 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"; + return { ...defaultResult, message: "Invalid redeem", logContents: { received: cart, inConfig: redeem } }; } - const valError = validateArgs(config, cart, logContext); + const valError = validateArgs(config, cart); if (valError) { - logMessage.header = "Arg validation failed"; - logMessage.content = { - error: valError, - redeem: cart.id, - expected: redeem.args, - provided: cart.args, + return { + ...defaultResult, + message: "Invalid arguments", + logHeaderOverride: "Arg validation failed", + logContents: { + 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 { +function validateArgs(config: Config, cart: Cart): string | undefined { const redeem = config.redeems![cart.id]; for (const arg of redeem.args) { @@ -114,7 +113,7 @@ function validateArgs(config: Config, cart: Cart, logContext: LogMessage): strin return `Vector3 ${arg.name} components not all floats`; } } - cart!.args[arg.name] = lastThree; + cart.args[arg.name] = lastThree; break; default: const argEnum = config.enums?.[arg.type]; diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts index f9159f5..5ffd32d 100644 --- a/ebs/src/modules/orders/transaction.ts +++ b/ebs/src/modules/orders/transaction.ts @@ -3,13 +3,8 @@ import { verifyJWT, parseJWT } from "../../util/jwt"; import { getOrAddUser, getOrder, saveOrder, saveUser } from "../../util/db"; import { ResultKind, ResultMessage } from "../game/messages.game"; import { sendToLogger } from "../../util/logger"; - -type HttpResult = { - status: number; - message: string; - logHeaderOverride?: string; - logContents?: any; -}; +import { HttpResult } from "../../types"; +import { getUserSession } from "../user"; export const jwtExpirySeconds = 60; const jwtExpiryToleranceSeconds = 15; @@ -44,7 +39,7 @@ export function verifyTransaction(decoded: DecodedTransaction): HttpResult | Tra if (decoded.type === "bits") { // for bits purchases, we don't care if our token JWT expired - // because if the bits t/a is valid, the person already paid the money + // because if the bits t/a is valid, the person paid and we have to honour it const receipt = decoded.receipt; if (receipt.topic != "bits_transaction_receipt") { // e.g. someone trying to put a token JWT in the receipt field diff --git a/ebs/src/modules/pishock/index.ts b/ebs/src/modules/pishock/index.ts index 0c0d7d3..3950670 100644 --- a/ebs/src/modules/pishock/index.ts +++ b/ebs/src/modules/pishock/index.ts @@ -6,9 +6,9 @@ import { sendToLogger } from "../../util/logger"; const pishockRedeemId = "redeem_pishock"; -require('./game'); // init connection just in case import order screwed us over +require("./game"); // init connection just in case import order screwed us over -connection.addRedeemHandler(pishockRedeem) +connection.addRedeemHandler(pishockRedeem); export async function pishockRedeem(redeem: Redeem, order: Order, user: TwitchUser): Promise { if (redeem.id != pishockRedeemId) { @@ -19,7 +19,7 @@ export async function pishockRedeem(redeem: Redeem, order: Order, user: TwitchUs transactionToken: order.id, userIdInsecure: order.userId, important: false, - fields: [{ header: "PiShock Redeem", content: `${user.displayName} redeemed PiShock` }] + fields: [{ header: "PiShock Redeem", content: `${user.displayName} redeemed PiShock` }], }); const success = await sendShock(50, 100); @@ -54,9 +54,7 @@ async function sendOperation(op: number, intensity: number, duration: number) { const response = await fetch(apiUrl, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); diff --git a/ebs/src/modules/user/endpoints.ts b/ebs/src/modules/user/endpoints.ts index 3335d9c..a420e7c 100644 --- a/ebs/src/modules/user/endpoints.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -1,13 +1,17 @@ import { app } from "../.."; import { updateUserTwitchInfo, lookupUser, saveUser } from "../../util/db"; import { asyncCatch } from "../../util/middleware"; -import { setUserBanned } from "."; +import { setUserBanned, setUserSession } from "."; app.post( "/public/authorized", asyncCatch(async (req, res) => { + const {session} = req.body as {session: string}; const user = await updateUserTwitchInfo(req.user); - //console.log(`${user.displayName} has ${user.credit}`); + + // console.log(`${user.displayName} opened extension (session ${session})`); + + setUserSession(user, session); res.status(200).send({ credit: user.credit }); return; }) diff --git a/ebs/src/modules/user/index.ts b/ebs/src/modules/user/index.ts index b5e4db3..d67b2ba 100644 --- a/ebs/src/modules/user/index.ts +++ b/ebs/src/modules/user/index.ts @@ -4,6 +4,8 @@ import { sendPubSubMessage } from "../../util/pubsub"; require("./endpoints"); +const sessions: Map = new Map(); + export async function setUserBanned(user: User, banned: boolean) { user.banned = banned; await saveUser(user); @@ -12,3 +14,15 @@ export async function setUserBanned(user: User, banned: boolean) { data: JSON.stringify({ id: user.id, banned }), }); } + +export async function getUserSession(user: User): Promise { + return sessions.get(user.id) || null; +} + +export async function setUserSession(user: User, session: string) { + const existing = sessions.get(user.id); + if (existing) { + console.log(`Closing existing session ${existing} in favor of ${session}`); + } + sessions.set(user.id, session); +} diff --git a/ebs/src/types.ts b/ebs/src/types.ts index e074f80..6366939 100644 --- a/ebs/src/types.ts +++ b/ebs/src/types.ts @@ -10,3 +10,10 @@ export type AuthorizationPayload = { send: string[]; }; }; + +export type HttpResult = { + status: number; + message: string; + logHeaderOverride?: string; + logContents?: any; +}; \ No newline at end of file diff --git a/ebs/src/util/logger.ts b/ebs/src/util/logger.ts index 075fcd7..c309410 100644 --- a/ebs/src/util/logger.ts +++ b/ebs/src/util/logger.ts @@ -6,9 +6,7 @@ export async function sendToLogger(data: LogMessage) { try { const result = await fetch(logEndpoint, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...data, backendToken: process.env.PRIVATE_LOGGER_TOKEN!, diff --git a/frontend/www/src/modules/auth.ts b/frontend/www/src/modules/auth.ts index 24be8ca..f569a53 100644 --- a/frontend/www/src/modules/auth.ts +++ b/frontend/www/src/modules/auth.ts @@ -1,36 +1,45 @@ import { ebsFetch } from "../util/ebs"; import { renderRedeemButtons } from "./redeems"; import { refreshConfig, setConfig } from "../util/config"; -import { twitchAuth } from "../util/twitch"; -import { updateClientsideBalance } from "./transaction"; +import { onTwitchAuth, twitchAuth } from "../util/twitch"; +import { clientSession, updateClientsideBalance } from "./transaction"; const $loginPopup = document.getElementById("onboarding")!; const $loginButton = document.getElementById("twitch-login")!; +onTwitchAuth(onAuth); + document.addEventListener("DOMContentLoaded", () => { $loginButton.onclick = async () => { - const auth = await twitchAuth(); - $loginPopup.style.display = "none"; - ebsFetch("public/authorized", { - method: "POST", - body: JSON.stringify({ channelId: auth.channelId, userId: Twitch.ext.viewer.id! }), - }).then((res) => { - if (res.status === 403) { - setBanned(true); - } - res.json().then( - (resp: {credit: number}) => { - console.log(`Balance: ${resp.credit}`); - updateClientsideBalance(resp.credit); - } - ); - renderRedeemButtons().then(); - }); + await twitchAuth(); }; }); +function onAuth(auth: Twitch.ext.Authorized) { + if (!Twitch.ext.viewer.id) { + $loginPopup.style.display = ""; + return; + } + $loginPopup.style.display = "none"; + ebsFetch("public/authorized", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session: clientSession }), + }).then((res) => { + if (res.status === 403) { + setBanned(true); + } + res.json().then((resp: { credit: number; }) => { + console.log(`Balance: ${resp.credit}`); + updateClientsideBalance(resp.credit); + }); + renderRedeemButtons().then(); + }); +} + let _banned = false; const callbacks: (() => void)[] = []; + export function getBanned() { return _banned; } diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index 01d0982..26c9206 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -201,12 +201,10 @@ async function prePurchase(): Promise { console.error("Can't send prepurchase without cart"); return false; } - + const response = await ebsFetch("/public/prepurchase", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(cart), }); diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index ee9389d..5558d31 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -11,11 +11,10 @@ export const clientSession = Math.random().toString(36).substring(2); export async function promptTransaction(sku: string, cost: number): Promise { console.log(`Purchasing ${sku} for ${cost} bits (have ${myCredit})`); - if (myCredit >= cost) { - return (await confirmCreditTransaction(cost)) ? "useCredit" : "cancelled"; - } else { - return await twitchUseBits(sku); + if (myCredit >= cost && await confirmUseCredit(cost)) { + return "useCredit"; } + return await twitchUseBits(sku); } export async function transactionComplete(transaction: Twitch.ext.BitsTransaction | "useCredit") { @@ -24,12 +23,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio transactionToken: null, userIdInsecure: Twitch.ext.viewer.id!, important: true, - fields: [ - { - header: "Missing transaction token", - content: transaction, - }, - ], + fields: [{ header: "Missing transaction token", content: transaction }], }).then(); await openModal(null); hideProcessingModal(); @@ -45,19 +39,12 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, important: false, - fields: [ - { - header: "Transaction complete", - content: isCredit ? { creditLeft: myCredit } : transaction, - }, - ], + fields: [{ header: "Transaction complete", content: isCredit ? { creditLeft: myCredit } : transaction }], }).then(); const result = await ebsFetch("/public/transaction", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: transactionTokenJwt!, clientSession, @@ -97,8 +84,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio `${errorText} Please contact a moderator (preferably AlexejheroDev) about the error! You have been credited the redeem cost, so you may try again later. - Transaction ID: ${transactionToken.id} - ` + Transaction ID: ${transactionToken.id}` ); } } @@ -110,20 +96,13 @@ export async function transactionCancelled() { transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, important: false, - fields: [ - { - header: "Transaction cancelled", - content: "User cancelled the transaction.", - }, - ], + fields: [{ header: "Transaction cancelled", content: "User cancelled the transaction." }], }).then(); await ebsFetch("/public/transaction/cancel", { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({jwt: transactionTokenJwt}), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jwt: transactionTokenJwt }), }); } @@ -136,9 +115,15 @@ export async function updateClientsideBalance(credit: number) { // TODO: update UI (when there is UI) } -export async function confirmCreditTransaction(price: number) { +/** + * Opens a modal asking the user if they want to use credit for the redeem + * + * @param price the price in bits + * @returns whether the user wants to use credit; if not, they will be prompted to use bits instead + */ +export async function confirmUseCredit(price: number): Promise { // temporary obviously return true; // no modal APIs in this iframe - //return confirm(`Use ${price} bits from your credit?`); + //return confirm(`Use ${price} bits from your credit? You currently have ${myCredit}`); } diff --git a/frontend/www/src/util/logger.ts b/frontend/www/src/util/logger.ts index 8f8aa7b..5b8c54a 100644 --- a/frontend/www/src/util/logger.ts +++ b/frontend/www/src/util/logger.ts @@ -6,9 +6,7 @@ export async function logToDiscord(data: LogMessage) { try { const result = await fetch(logEndpoint, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...data, } satisfies LogMessage), From cad8541cb20fae444449685146d8e06fff62ede7 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Sat, 13 Jul 2024 17:13:51 +1000 Subject: [PATCH 19/22] make credit/debit more atomic log user API calls to console --- ebs/src/modules/orders/endpoints/public.ts | 16 ++++++++++++--- ebs/src/modules/orders/transaction.ts | 14 ++++++------- ebs/src/modules/user/endpoints.ts | 12 ++++++----- ebs/src/modules/user/index.ts | 4 ++-- ebs/src/util/db.ts | 13 ++++++++++++ ebs/src/util/middleware.ts | 2 ++ scripts/sql/init_db.sql | 24 ++++++++++++++++++++++ 7 files changed, 67 insertions(+), 18 deletions(-) diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index 0082668..1f5a823 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -1,4 +1,4 @@ -import { Cart, LogMessage, Transaction, Order, TransactionTokenPayload } from "common/types"; +import { Cart, LogMessage, Transaction, Order, TransactionTokenPayload, TransactionToken } from "common/types"; import { app } from "../../.."; import { getConfig } from "../../config"; import { createOrder, getOrder, saveOrder, updateUserTwitchInfo } from "../../../util/db"; @@ -63,14 +63,24 @@ app.post( order.state = "prepurchase"; await saveOrder(order); - const transactionToken = makeTransactionToken(order, req.user); + let transactionToken: TransactionToken; + try { + transactionToken = makeTransactionToken(order, req.user); + } catch (e: any) { + logContext.important = true; + logMessage.header = `Failed to create transaction token`; + logMessage.content = { cart, userId, error: e }; + sendToLogger(logContext).then(); + res.status(500).send("Internal configuration error"); + return; + } const transactionTokenJWT = signJWT({ data: transactionToken }, { expiresIn: jwtExpirySeconds }); logMessage.header = "Created prepurchase"; logMessage.content = { orderId: order.id, token: transactionTokenJWT }; sendToLogger(logContext).then(); - res.status(200).send(transactionTokenJWT); + return; }) ); diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts index 5ffd32d..f55f305 100644 --- a/ebs/src/modules/orders/transaction.ts +++ b/ebs/src/modules/orders/transaction.ts @@ -1,10 +1,9 @@ import { Order, Transaction, User, OrderState, TransactionToken, TransactionTokenPayload, DecodedTransaction, BitsTransactionPayload } from "common/types"; import { verifyJWT, parseJWT } from "../../util/jwt"; -import { getOrAddUser, getOrder, saveOrder, saveUser } from "../../util/db"; +import { addCredit, getOrAddUser, getOrder, saveOrder } from "../../util/db"; import { ResultKind, ResultMessage } from "../game/messages.game"; import { sendToLogger } from "../../util/logger"; import { HttpResult } from "../../types"; -import { getUserSession } from "../user"; export const jwtExpirySeconds = 60; const jwtExpiryToleranceSeconds = 15; @@ -99,17 +98,16 @@ export async function getAndCheckOrder(transaction: Transaction, decoded: Decode logContents: { user: user.id, order: order.id, cost, credit: user.credit }, }; } - user.credit -= cost; // good thing node is single threaded :^) - order.state = "paid"; + user = await addCredit(user, -cost); + console.log(`Debited ${user.login ?? user.id} (${user.credit + cost} - ${cost} = ${user.credit})`); order.receipt = `credit (${user.credit + cost} - ${cost} = ${user.credit})`; } else { // for bits transactions, we verified the receipt JWT earlier (in verifyTransaction) - order.state = "paid"; order.receipt = transaction.receipt; } + order.state = "paid"; await saveOrder(order); - await saveUser(user); return order; } @@ -154,8 +152,8 @@ export async function refund(order: Order) { try { let user = await getOrAddUser(order.userId); const cost = parseInt(order.cart.sku.substring(4)); - user.credit += cost; - await saveUser(user); + user = await addCredit(user, cost); + console.log(`Refunded ${user.login ?? user.id} (${user.credit - cost} + ${cost} = ${user.credit})`); } catch (e) { console.error(`Could not refund order ${order.id}`); console.error(e); diff --git a/ebs/src/modules/user/endpoints.ts b/ebs/src/modules/user/endpoints.ts index a420e7c..3ec174c 100644 --- a/ebs/src/modules/user/endpoints.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -1,5 +1,5 @@ import { app } from "../.."; -import { updateUserTwitchInfo, lookupUser, saveUser } from "../../util/db"; +import { updateUserTwitchInfo, lookupUser, addCredit } from "../../util/db"; import { asyncCatch } from "../../util/middleware"; import { setUserBanned, setUserSession } from "."; @@ -9,7 +9,7 @@ app.post( const {session} = req.body as {session: string}; const user = await updateUserTwitchInfo(req.user); - // console.log(`${user.displayName} opened extension (session ${session})`); + // console.log(`${req.auth.opaque_user_id} opened extension (session ${session})`); setUserSession(user, session); res.status(200).send({ credit: user.credit }); @@ -34,6 +34,7 @@ app.post( } await setUserBanned(user, true); + console.log(`[Private API] Banned ${user.login ?? user.id}`); res.status(200).json(user); }) ); @@ -48,6 +49,7 @@ app.delete( } await setUserBanned(user, false); + console.log(`[Private API] Unbanned ${user.login ?? user.id}`); res.status(200).json(user); }) ); @@ -55,7 +57,7 @@ app.delete( app.post( "/private/user/:idOrName/addCredit", asyncCatch(async (req, res) => { - const user = await lookupUser(req.params["idOrName"]); + let user = await lookupUser(req.params["idOrName"]); if (!user) { res.sendStatus(404); return; @@ -67,8 +69,8 @@ app.post( return; } - user.credit += amt; - await saveUser(user); + user = await addCredit(user, amt); + console.log(`[Private API] Granted ${amt} credits to ${user.login ?? user.id} (now ${user.credit})`); res.status(200).json(user); return; }) diff --git a/ebs/src/modules/user/index.ts b/ebs/src/modules/user/index.ts index d67b2ba..cd21a96 100644 --- a/ebs/src/modules/user/index.ts +++ b/ebs/src/modules/user/index.ts @@ -9,10 +9,10 @@ const sessions: Map = new Map(); export async function setUserBanned(user: User, banned: boolean) { user.banned = banned; await saveUser(user); - await sendPubSubMessage({ + sendPubSubMessage({ type: "banned", data: JSON.stringify({ id: user.id, banned }), - }); + }).then(); } export async function getUserSession(user: User): Promise { diff --git a/ebs/src/util/db.ts b/ebs/src/util/db.ts index 962d9df..d542656 100644 --- a/ebs/src/util/db.ts +++ b/ebs/src/util/db.ts @@ -171,3 +171,16 @@ export async function updateUserTwitchInfo(user: User): Promise { } return user; } + +export async function addCredit(user: User, amount: number): Promise { + try { + await db.query(`CALL addCredit(:userId, :amount, @credit);`, { userId: user.id, amount }); + const [rows] = (await db.query("SELECT @credit")) as [RowDataPacket[], any]; + user.credit = rows[0]["@credit"]; + return user; + } catch (e: any) { + console.error("Database query failed (addCredit)"); + console.error(e); + throw e; + } +} diff --git a/ebs/src/util/middleware.ts b/ebs/src/util/middleware.ts index 547c3cb..bd7fcf4 100644 --- a/ebs/src/util/middleware.ts +++ b/ebs/src/util/middleware.ts @@ -38,6 +38,7 @@ export async function publicApiAuth(req: Request, res: Response, next: NextFunct } req.user = await getOrAddUser(twitchAuthorization.user_id); + req.auth = twitchAuthorization; if (req.user.banned) { res.status(403).send("You are banned from using this extension"); @@ -61,6 +62,7 @@ declare global { namespace Express { export interface Request { user: User; + auth: AuthorizationPayload; } } } diff --git a/scripts/sql/init_db.sql b/scripts/sql/init_db.sql index cfed0ed..fcaa59c 100644 --- a/scripts/sql/init_db.sql +++ b/scripts/sql/init_db.sql @@ -27,3 +27,27 @@ CREATE TABLE IF NOT EXISTS logs ( fromBackend BOOLEAN NOT NULL, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + +DELIMITER $$ +DROP PROCEDURE IF EXISTS addCredit +$$ +CREATE PROCEDURE addCredit(IN userId VARCHAR(255), IN delta INT, OUT result INT) +BEGIN + UPDATE users + SET credit = credit + delta + WHERE id = userId; + + SELECT credit INTO result FROM users WHERE id = userId; +END +$$ +DROP PROCEDURE IF EXISTS debug +$$ +CREATE PROCEDURE debug() +BEGIN + SET GLOBAL general_log = 'ON'; + SET GLOBAL log_output = 'TABLE'; + -- Then use: + -- SELECT * FROM mysql.general_log; +END +$$ +DELIMITER ; From c0f634dadbe15d5d3c613094390d574e950fadf0 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Sat, 13 Jul 2024 18:03:08 +1000 Subject: [PATCH 20/22] small refactor for mobile --- ebs/src/modules/user/endpoints.ts | 9 ++++----- frontend/www/css/redeems.css | 4 ++++ frontend/www/html/index.html | 8 -------- frontend/www/src/modules/modal/index.ts | 6 ++++++ 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/ebs/src/modules/user/endpoints.ts b/ebs/src/modules/user/endpoints.ts index 3ec174c..1ffe336 100644 --- a/ebs/src/modules/user/endpoints.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -7,13 +7,12 @@ app.post( "/public/authorized", asyncCatch(async (req, res) => { const {session} = req.body as {session: string}; - const user = await updateUserTwitchInfo(req.user); - // console.log(`${req.auth.opaque_user_id} opened extension (session ${session})`); + setUserSession(req.user, session); - setUserSession(user, session); - res.status(200).send({ credit: user.credit }); - return; + updateUserTwitchInfo(req.user).then(); + + res.status(200).send({ credit: req.user.credit }); }) ); diff --git a/frontend/www/css/redeems.css b/frontend/www/css/redeems.css index c9e1849..0d2105b 100644 --- a/frontend/www/css/redeems.css +++ b/frontend/www/css/redeems.css @@ -111,6 +111,7 @@ text-shadow: 0 0 5px #000; display: -webkit-box; + line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; @@ -140,6 +141,9 @@ .redeemable-item:focus .redeemable-item-price-wrapper { transform: translateY(0%); } +.mobile .redeemable-item .redeemable-item-price-wrapper { + transform: translateY(0%) !important; +} .redeemable-item-price-wrapper>img { width: 20px; diff --git a/frontend/www/html/index.html b/frontend/www/html/index.html index 900664d..9a253e3 100644 --- a/frontend/www/html/index.html +++ b/frontend/www/html/index.html @@ -6,14 +6,6 @@ - - <% if (htmlWebpackPlugin.options.title=="Mobile View" ) { %> - - <% } %> diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index 26c9206..e5ed819 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -54,6 +54,12 @@ document.addEventListener("DOMContentLoaded", () => { $modalConfirm.onclick = confirmPurchase; $modalCancel.onclick = closeModal; + // Twitch sets some parameters in the query string (https://dev.twitch.tv/docs/extensions/reference/#client-query-parameters) + const queryParams = new URLSearchParams(window.location.search); + if (queryParams.get("platform") === "mobile") { + document.body.classList.add("mobile"); + } + $modalWrapper.onclick = (e) => { if (e.target !== $modalWrapper) return; if ($modalProcessing.style.opacity == "1") return; From 0d37bb09df8347b088b6fbb4cf0095cea94889a8 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Sat, 13 Jul 2024 18:19:21 +1000 Subject: [PATCH 21/22] cruddy balance display --- frontend/www/css/redeems.css | 21 ++++++++++++++++++++- frontend/www/html/index.html | 7 +++++++ frontend/www/src/modules/redeems.ts | 2 ++ frontend/www/src/modules/transaction.ts | 9 ++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/frontend/www/css/redeems.css b/frontend/www/css/redeems.css index 0d2105b..9278dab 100644 --- a/frontend/www/css/redeems.css +++ b/frontend/www/css/redeems.css @@ -155,4 +155,23 @@ .redeemable-item-price { color: #333; text-shadow: 0 0 3px #333; -} \ No newline at end of file +} + +.top-status-bar { + position: sticky; + top: 0; + height: 1.5rem; + align-items: center; + background: #333; + z-index: 10; + transition: all 0.2s; +} + +.top-status-bar img { + filter: saturate(10%); +} + +.top-status-bar .top-status-bar-left { + padding-inline: 0.5rem; + display: flex; +} diff --git a/frontend/www/html/index.html b/frontend/www/html/index.html index 9a253e3..f43f880 100644 --- a/frontend/www/html/index.html +++ b/frontend/www/html/index.html @@ -93,6 +93,13 @@ +
diff --git a/frontend/www/src/modules/redeems.ts b/frontend/www/src/modules/redeems.ts index c282b32..b53aa54 100644 --- a/frontend/www/src/modules/redeems.ts +++ b/frontend/www/src/modules/redeems.ts @@ -4,6 +4,8 @@ import { getConfig } from "../util/config"; const $mainContainer = document.getElementsByTagName("main")!; const $redeemContainer = document.getElementById("items")!; const $modalProcessing = document.getElementById("modal-processing")!; +export const $balance = document.getElementById("credit-balance")!; +export const $statusBar = document.getElementById("top-status-bar")!; export async function renderRedeemButtons() { $redeemContainer.innerHTML = `

Loading content...

`; diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index 5558d31..6aef175 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -3,6 +3,7 @@ import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, trans import { logToDiscord } from "../util/logger"; import { ebsFetch } from "../util/ebs"; import { twitchUseBits } from "../util/twitch"; +import { $balance, $statusBar } from "./redeems"; type TransactionResponse = Twitch.ext.BitsTransaction | "useCredit" | "cancelled"; @@ -55,9 +56,14 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio setTimeout(() => hideProcessingModal(), 250); const text = await result.text(); + const cost = transactionToken.product.cost; if (result.ok) { + updateClientsideBalance(myCredit - cost); showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken.id}`); } else { + if (transaction !== "useCredit") { + updateClientsideBalance(myCredit + cost); + } if (result.status === 400) { logToDiscord({ transactionToken: transactionToken.id, @@ -112,7 +118,8 @@ export async function transactionCancelled() { export async function updateClientsideBalance(credit: number) { myCredit = credit; - // TODO: update UI (when there is UI) + $balance.innerText = credit.toString(); + $statusBar.style.display = credit > 0 ? "flex" : "none"; } /** From d5494acce816804956055b0e95d2fa7daf11b35c Mon Sep 17 00:00:00 2001 From: Govorunb Date: Sat, 13 Jul 2024 19:37:55 +1000 Subject: [PATCH 22/22] cruddy modal --- frontend/www/css/base.css | 6 ++++- frontend/www/css/buttons.css | 9 +++++++ frontend/www/css/config.css | 1 + frontend/www/css/modals.css | 11 +++++++- frontend/www/css/redeems.css | 4 --- frontend/www/html/index.html | 23 ++++++++++++++-- frontend/www/src/modules/auth.ts | 4 +-- frontend/www/src/modules/modal/index.ts | 36 +++++++++++++++++++++++-- frontend/www/src/modules/transaction.ts | 26 ++++++++++-------- 9 files changed, 97 insertions(+), 23 deletions(-) diff --git a/frontend/www/css/base.css b/frontend/www/css/base.css index 7661282..53bbb5e 100644 --- a/frontend/www/css/base.css +++ b/frontend/www/css/base.css @@ -84,4 +84,8 @@ select { border: 1px solid #ffffff1A; background-color: #333; color: #eee; -} \ No newline at end of file +} + +.sat-10 { + filter: saturate(10%); +} diff --git a/frontend/www/css/buttons.css b/frontend/www/css/buttons.css index 175e883..ef3e430 100644 --- a/frontend/www/css/buttons.css +++ b/frontend/www/css/buttons.css @@ -46,6 +46,15 @@ .btn-primary:focus { box-shadow: 0 0 0 4px rgba(var(--primary-color), 0.5); } +.btn-secondary { + background-color: rgba(var(--secondary-color), 0.75); + border: 1px solid #ffffff26; + color: #fff; +} + +.btn-secondary:focus { + box-shadow: 0 0 0 4px rgba(var(--secondary-color), 0.5); +} .btn-success { background-color: rgba(var(--success-color), 0.75); diff --git a/frontend/www/css/config.css b/frontend/www/css/config.css index eae3041..4679dd0 100644 --- a/frontend/www/css/config.css +++ b/frontend/www/css/config.css @@ -1,5 +1,6 @@ :root { --primary-color: 203, 4, 165; + --secondary-color: 37, 35, 38; --success-color: 2, 109, 58; --danger-color: 153, 14, 6; --redeem-card-border-radius: 10px; diff --git a/frontend/www/css/modals.css b/frontend/www/css/modals.css index b424035..735d649 100644 --- a/frontend/www/css/modals.css +++ b/frontend/www/css/modals.css @@ -169,6 +169,15 @@ form label:not([aria-required]) { margin-right: 6px; } +.bits-inline { + width: 20px; + height: 20px; + + object-fit: contain; + margin-bottom: -3px; + margin-inline: 2px; +} + .modal-overlay { z-index: 10000; @@ -272,4 +281,4 @@ form label:not([aria-required]) { .modal-descriptors { align-items: center; } -} \ No newline at end of file +} diff --git a/frontend/www/css/redeems.css b/frontend/www/css/redeems.css index 9278dab..def029d 100644 --- a/frontend/www/css/redeems.css +++ b/frontend/www/css/redeems.css @@ -167,10 +167,6 @@ transition: all 0.2s; } -.top-status-bar img { - filter: saturate(10%); -} - .top-status-bar .top-status-bar-left { padding-inline: 0.5rem; display: flex; diff --git a/frontend/www/html/index.html b/frontend/www/html/index.html index f43f880..79067c3 100644 --- a/frontend/www/html/index.html +++ b/frontend/www/html/index.html @@ -90,14 +90,33 @@
+ + +
diff --git a/frontend/www/src/modules/auth.ts b/frontend/www/src/modules/auth.ts index f569a53..167230a 100644 --- a/frontend/www/src/modules/auth.ts +++ b/frontend/www/src/modules/auth.ts @@ -2,7 +2,7 @@ import { ebsFetch } from "../util/ebs"; import { renderRedeemButtons } from "./redeems"; import { refreshConfig, setConfig } from "../util/config"; import { onTwitchAuth, twitchAuth } from "../util/twitch"; -import { clientSession, updateClientsideBalance } from "./transaction"; +import { clientSession, setClientsideBalance } from "./transaction"; const $loginPopup = document.getElementById("onboarding")!; const $loginButton = document.getElementById("twitch-login")!; @@ -31,7 +31,7 @@ function onAuth(auth: Twitch.ext.Authorized) { } res.json().then((resp: { credit: number; }) => { console.log(`Balance: ${resp.credit}`); - updateClientsideBalance(resp.credit); + setClientsideBalance(resp.credit); }); renderRedeemButtons().then(); }); diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index e5ed819..ad49f30 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -3,7 +3,7 @@ import { ebsFetch } from "../../util/ebs"; import { getConfig } from "../../util/config"; import { logToDiscord } from "../../util/logger"; import { setBanned } from "../auth"; -import { clientSession, promptTransaction, transactionCancelled, transactionComplete, updateClientsideBalance } from "../transaction"; +import { clientSession, promptTransaction, transactionCancelled, transactionComplete, setClientsideBalance, getClientsideBalance } from "../transaction"; import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; import { getJWTPayload as decodeJWT } from "../../util/jwt"; @@ -44,6 +44,13 @@ const $modalSuccessTitle = document.getElementById("modal-success-title")!; const $modalSuccessDescription = document.getElementById("modal-success-description")!; const $modalSuccessClose = document.getElementById("modal-success-close")!; +const $modalConfirmCredit = document.getElementById("modal-confirm-credit")!; +const $modalConfirmCreditBalance = document.getElementById("modal-confirm-credit-balance")!; +const $modalConfirmCreditCost = document.getElementById("modal-confirm-credit-cost")!; +const $modalConfirmCreditRemainder = document.getElementById("modal-confirm-credit-remainder")!; +const $modalConfirmCreditUseCredit = document.getElementById("modal-confirm-credit-useCredit")!; +const $modalConfirmCreditUseBits = document.getElementById("modal-confirm-credit-useBits")!; + export let cart: Cart | undefined; export let transactionToken: TransactionToken | undefined; export let transactionTokenJwt: string | undefined; @@ -142,6 +149,26 @@ export function showSuccessModal(title: string, description: string, onClose?: ( }; } +export function showCreditConfirmationModal(useCredit: () => void, useBits: () => void) { + if (!transactionToken) return; + const balance = getClientsideBalance(); + const cost = transactionToken!.product.cost; + + $modalConfirmCredit.style.opacity = "1"; + $modalConfirmCredit.style.pointerEvents = "unset"; + $modalConfirmCreditBalance.textContent = balance.toString(); + $modalConfirmCreditCost.textContent = cost.toString(); + $modalConfirmCreditRemainder.textContent = (balance - cost).toString(); + $modalConfirmCreditUseCredit.onclick = () => { + hideCreditConfirmationModal(); + useCredit(); + } + $modalConfirmCreditUseBits.onclick = () => { + hideCreditConfirmationModal(); + useBits(); + } +} + function closeModal() { cart = undefined; transactionToken = undefined; @@ -175,6 +202,11 @@ function hideSuccessModal(closeMainModal = false) { if (closeMainModal) closeModal(); } +function hideCreditConfirmationModal() { + $modalConfirmCredit.style.opacity = "0"; + $modalConfirmCredit.style.pointerEvents = "none"; +} + async function confirmPurchase() { setCartArgsFromForm($modalOptionsForm); if (!$modalOptionsForm.reportValidity()) { @@ -242,7 +274,7 @@ async function prePurchase(): Promise { showErrorModal("Server Error", "Server returned invalid transaction token. The developers have been notified, please try again later."); return false; } - updateClientsideBalance(transactionToken.user.credit); + setClientsideBalance(transactionToken.user.credit); return true; } diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index 6aef175..67dbd22 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -1,5 +1,5 @@ import { Transaction } from "common/types"; -import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, transactionToken, transactionTokenJwt } from "./modal"; +import { hideProcessingModal, openModal, showCreditConfirmationModal, showErrorModal, showSuccessModal, transactionToken, transactionTokenJwt } from "./modal"; import { logToDiscord } from "../util/logger"; import { ebsFetch } from "../util/ebs"; import { twitchUseBits } from "../util/twitch"; @@ -12,7 +12,7 @@ export const clientSession = Math.random().toString(36).substring(2); export async function promptTransaction(sku: string, cost: number): Promise { console.log(`Purchasing ${sku} for ${cost} bits (have ${myCredit})`); - if (myCredit >= cost && await confirmUseCredit(cost)) { + if (myCredit >= cost && await confirmUseCredit()) { return "useCredit"; } return await twitchUseBits(sku); @@ -58,11 +58,13 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio const text = await result.text(); const cost = transactionToken.product.cost; if (result.ok) { - updateClientsideBalance(myCredit - cost); + if (transaction === "useCredit") { + setClientsideBalance(myCredit - cost); + } showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken.id}`); } else { if (transaction !== "useCredit") { - updateClientsideBalance(myCredit + cost); + setClientsideBalance(myCredit + cost); } if (result.status === 400) { logToDiscord({ @@ -116,7 +118,11 @@ export async function transactionCancelled() { showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken?.id ?? "none"}`); } -export async function updateClientsideBalance(credit: number) { +export function getClientsideBalance() { + return myCredit; +} + +export async function setClientsideBalance(credit: number) { myCredit = credit; $balance.innerText = credit.toString(); $statusBar.style.display = credit > 0 ? "flex" : "none"; @@ -125,12 +131,10 @@ export async function updateClientsideBalance(credit: number) { /** * Opens a modal asking the user if they want to use credit for the redeem * - * @param price the price in bits * @returns whether the user wants to use credit; if not, they will be prompted to use bits instead */ -export async function confirmUseCredit(price: number): Promise { - // temporary obviously - return true; - // no modal APIs in this iframe - //return confirm(`Use ${price} bits from your credit? You currently have ${myCredit}`); +export async function confirmUseCredit(): Promise { + return new Promise((resolve) => { + showCreditConfirmationModal(() => resolve(true), () => resolve(false)); + }); }