From 7899c128f56b4344a37366e8966cf1d3d06578d3 Mon Sep 17 00:00:00 2001 From: Govorunb Date: Sun, 14 Jul 2024 10:55:28 +1000 Subject: [PATCH] port over refactors and fixes from #57 refactors (lint, split endpoints from functions, etc) make purchasing from multiple tabs harder make the transaction token a signed JWT retarget to es2018 to allow named regex capture groups add small grace period for extension logs prevent replaying bits transactions detect mobile slightly differently --- common/types.ts | 55 ++- ebs/src/index.ts | 4 +- ebs/src/modules/config/endpoints.ts | 48 +++ .../modules/{config.ts => config/index.ts} | 105 ++--- ebs/src/modules/game/connection.ts | 83 ++-- ebs/src/modules/game/endpoints.ts | 80 ++++ ebs/src/modules/game/index.ts | 78 +--- 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 | 95 ++-- ebs/src/modules/orders/endpoints/index.ts | 2 + ebs/src/modules/orders/endpoints/private.ts | 10 + ebs/src/modules/orders/endpoints/public.ts | 304 +++++++++++++ ebs/src/modules/orders/index.ts | 318 +------------- ebs/src/modules/orders/prepurchase.ts | 73 ++-- ebs/src/modules/orders/transaction.ts | 120 ++++++ ebs/src/modules/pishock.ts | 38 ++ .../modules/{twitch.ts => twitch/index.ts} | 4 +- .../{orders/user.ts => user/endpoints.ts} | 36 +- ebs/src/modules/user/index.ts | 28 ++ ebs/src/types.ts | 26 +- ebs/src/util/db.ts | 36 +- ebs/src/util/jwt.ts | 6 +- ebs/src/util/logger.ts | 4 +- ebs/src/util/middleware.ts | 2 + ebs/src/util/pishock.ts | 4 +- ebs/src/util/pubsub.ts | 6 +- frontend/tsconfig.json | 2 +- frontend/www/css/redeems.css | 4 + frontend/www/html/index.html | 8 - frontend/www/src/index.ts | 2 + frontend/www/src/modules/auth.ts | 149 ++----- frontend/www/src/modules/modal.ts | 408 ------------------ frontend/www/src/modules/modal/form.ts | 196 +++++++++ frontend/www/src/modules/modal/index.ts | 247 +++++++++++ frontend/www/src/modules/pubsub.ts | 8 +- frontend/www/src/modules/transaction.ts | 91 ++++ frontend/www/src/util/jwt.ts | 16 + frontend/www/src/util/logger.ts | 4 +- frontend/www/src/util/twitch.ts | 59 +++ logger/src/modules/endpoints.ts | 5 +- logger/src/util/db.ts | 4 +- scripts/sql/init_db.sql | 13 + 44 files changed, 1630 insertions(+), 1236 deletions(-) create mode 100644 ebs/src/modules/config/endpoints.ts rename ebs/src/modules/{config.ts => config/index.ts} (69%) 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 create mode 100644 ebs/src/modules/orders/transaction.ts create mode 100644 ebs/src/modules/pishock.ts rename ebs/src/modules/{twitch.ts => twitch/index.ts} (77%) rename ebs/src/modules/{orders/user.ts => user/endpoints.ts} (50%) create mode 100644 ebs/src/modules/user/index.ts 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 create mode 100644 frontend/www/src/modules/transaction.ts create mode 100644 frontend/www/src/util/jwt.ts create mode 100644 frontend/www/src/util/twitch.ts diff --git a/common/types.ts b/common/types.ts index 13bba18..fc42fad 100644 --- a/common/types.ts +++ b/common/types.ts @@ -73,18 +73,57 @@ 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 }; }; -export type IdentifiableCart = Cart & { - userId: string; -}; +export type IdentifiableCart = Cart & { userId: string }; export type Transaction = { - receipt: string; - token: string; + token: string; // JWT with TransactionToken (given by EBS on prepurchase) + clientSession: string; // same session as in Cart + receipt: string; // JWT with BitsTransactionPayload (coming from Twitch) +}; +export type DecodedTransaction = { + token: TransactionTokenPayload; + receipt: BitsTransactionPayload; +}; + +export type TransactionToken = { + id: string; + time: number; // Unix millis + user: { + id: string; // user channel id + }; + 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 = { @@ -123,9 +162,11 @@ export type Order = { id: string; userId: string; state: OrderState; - cart?: Cart; + cart: Cart; receipt?: string; result?: string; createdAt: number; updatedAt: number; -}; \ No newline at end of file +}; + +export type Callback = (data: T) => void; 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..c5b1132 --- /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()); + }) +); diff --git a/ebs/src/modules/config.ts b/ebs/src/modules/config/index.ts similarity index 69% rename from ebs/src/modules/config.ts rename to ebs/src/modules/config/index.ts index c04f486..de8e365 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()}`; @@ -19,7 +24,7 @@ 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) { @@ -42,12 +47,12 @@ async function fetchConfig(): Promise { 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"); console.error(e); - + sendToLogger({ transactionToken: null, userIdInsecure: null, @@ -59,14 +64,23 @@ async function fetchConfig(): Promise { }, ], }).then(); - + return { version: -1, message: "Error when fetching config from raw URL, panic", }; } } +} +export function isIngame() { + return ingameState; +} + +export async function setIngame(newIngame: boolean) { + if (ingameState == newIngame) return; + ingameState = newIngame; + await setActiveConfig(await getRawConfigData()); } 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/connection.ts b/ebs/src/modules/game/connection.ts index 97ae706..32e6a62 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[] = [GameConnection.prototype.sendRedeemToGame.bind(this)]; public isConnected() { return this.socket?.readyState == ServerWS.OPEN; @@ -49,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); } @@ -90,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}`); @@ -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/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/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 370d03f..d29bbe4 100644 --- a/ebs/src/modules/game/stresstest.ts +++ b/ebs/src/modules/game/stresstest.ts @@ -1,9 +1,8 @@ -import { IdentifiableCart } from "common/types"; +import { BitsTransactionPayload, 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"; +import { AuthorizationPayload } from "../../types"; export enum StressTestType { GameSpawnQueue, @@ -26,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); @@ -58,21 +57,28 @@ 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, + clientSession: "stress", + 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 +101,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 +129,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 +140,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 +167,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 +179,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)`); } } 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..a22c09b --- /dev/null +++ b/ebs/src/modules/orders/endpoints/private.ts @@ -0,0 +1,10 @@ +import { app } from "../../.."; +import { getOrder } from "../../../util/db"; +import { asyncCatch } from "../../../util/middleware"; + +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 new file mode 100644 index 0000000..76a4df0 --- /dev/null +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -0,0 +1,304 @@ +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"; +import { sendToLogger } from "../../../util/logger"; +import { connection } from "../../game"; +import { TwitchUser } from "../../game/messages"; +import { asyncCatch } from "../../../util/middleware"; +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(); + +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: HttpResult | null; + let fail = "register"; + try { + order = await createOrder(userId, cart); + fail = "validate"; + validationError = await validatePrepurchase(order, req.user); + } 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 = validationError.logHeaderOverride ?? validationError.message; + logMessage.content = validationError.logContents ?? { order: order.id }; + sendToLogger(logContext).then(); + res.status(validationError.status).send(validationError.message); + order.result = validationError.message; + await saveOrder(order); + return; + } + + order.state = "prepurchase"; + await saveOrder(order); + + 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; + }) +); + +app.post( + "/public/transaction", + asyncCatch(async (req, res) => { + const transaction = req.body as Transaction; + + const logContext: LogMessage = { + transactionToken: null, + userIdInsecure: req.user.id, + important: true, + 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, decoded, req.user); + if ("status" in orderMaybe) { + const checkRes = orderMaybe; + logMessage.header = checkRes.logHeaderOverride ?? checkRes.message; + logMessage.content = checkRes.logContents ?? { transaction }; + if (checkRes.status === 403) { + setUserBanned(req.user, true); + } + sendToLogger(logContext).then(); + res.status(orderMaybe.status).send(orderMaybe.message); + return; + } else { + order = orderMaybe; + } + } 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; + } + + 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) { + // if it's not coming from a different tab, you're obviously trying to replay + logMessage.content = { + order: order.id, + bitsTransaction: decoded.receipt.data, + }; + logMessage.header = "Transaction replay"; + sendToLogger(logContext).then(); + } + // 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; + } + usedBitsTransactionIds.add(bitsTransaction); + + 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: order.id, + 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(decoded); + 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}`); + } + } + + try { + const result = await connection.redeem(redeem, order, userInfo); + const processedResult = await processRedeemResult(order, result); + logContext.important = processedResult.status === 500; + 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 }; + sendToLogger(logContext).then(); + connection.onResult(order.id, (res) => { + console.log(`Got late result (from re-send?) for ${order.id}`); + processRedeemResult(order, res).then(); + }); + res.status(500).send(`Failed to process redeem - ${error}`); + return; + } + }) +); + +app.post( + "/public/transaction/cancel", + asyncCatch(async (req, res) => { + const jwt = req.body.jwt as string; + if (!verifyJWT(jwt)) { + res.sendStatus(403); + return; + } + const token = parseJWT(jwt) as TransactionTokenPayload; + const logContext: LogMessage = { + transactionToken: token.data.id, + userIdInsecure: req.user.id, + important: true, + fields: [{ header: "", content: "" }], + }; + const logMessage = logContext.fields[0]; + + try { + const order = await getOrder(token.data.id); + + 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).then(); + res.status(403).send("This transaction doesn't belong to you"); + return; + } + + if (order.state !== "prepurchase") { + res.status(409).send("Cannot cancel this transaction"); + return; + } + + order.state = "cancelled"; + await saveOrder(order); + res.sendStatus(200); + } catch (error) { + logMessage.header = "Failed to cancel order"; + logMessage.content = error; + sendToLogger(logContext).then(); + + res.sendStatus(500); + } + }) +); diff --git a/ebs/src/modules/orders/index.ts b/ebs/src/modules/orders/index.ts index ce9ebd4..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/orders/prepurchase.ts b/ebs/src/modules/orders/prepurchase.ts index a64f1a1..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]; @@ -134,4 +133,4 @@ function validateArgs(config: Config, cart: Cart, logContext: LogMessage): strin break; } } -} \ No newline at end of file +} diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts new file mode 100644 index 0000000..aa0a668 --- /dev/null +++ b/ebs/src/modules/orders/transaction.ts @@ -0,0 +1,120 @@ +import { Order, Transaction, User, OrderState, TransactionToken, TransactionTokenPayload, DecodedTransaction, BitsTransactionPayload } from "common/types"; +import { verifyJWT, parseJWT } from "../../util/jwt"; +import { getOrder, saveOrder } from "../../util/db"; +import { ResultMessage } from "../game/messages.game"; +import { HttpResult } from "../../types"; + +export const jwtExpirySeconds = 60; +const jwtExpiryToleranceSeconds = 15; +const defaultResult: HttpResult = { status: 403, message: "Invalid transaction" }; + +export function decodeJWTPayloads(transaction: Transaction): HttpResult | DecodedTransaction { + if (!transaction.token || !verifyJWT(transaction.token)) { + return { ...defaultResult, logHeaderOverride: "Invalid token" }; + } + const token = parseJWT(transaction.token) as TransactionTokenPayload; + if (!transaction.receipt || !verifyJWT(transaction.receipt)) { + return { ...defaultResult, logHeaderOverride: "Invalid receipt" }; + } + return { + token, + receipt: parseJWT(transaction.receipt) as BitsTransactionPayload, + }; +} + +export function verifyTransaction(decoded: DecodedTransaction): HttpResult | TransactionToken { + const token = decoded.token; + + // we don't care if our token JWT expired + // 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 + return { ...defaultResult, logHeaderOverride: "Invalid receipt topic" }; + } + 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" }; + } + + return token.data; +} + +export async function getAndCheckOrder(transaction: Transaction, decoded: DecodedTransaction, user: User): Promise { + const token = verifyTransaction(decoded); + if ("status" in token) { + return token; + } + + const orderMaybe = await getOrder(token.id); + 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: "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 }, + }; + } + + // we verified the receipt JWT earlier (in verifyTransaction) + order.receipt = transaction.receipt; + + order.state = "paid"; + await saveOrder(order); + + return order; +} + +export async function processRedeemResult(order: Order, result: ResultMessage): Promise { + order.state = result.success ? "succeeded" : "failed"; + order.result = result.message; + await saveOrder(order); + let msg = result.message; + const res = { logContents: { order: order.id, cart: order.cart } }; + if (result.success) { + console.log(`[${result.guid}] Redeem succeeded: ${JSON.stringify(result)}`); + 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", ...res }; + } else { + console.error(`[${result.guid}] Redeem failed: ${JSON.stringify(result)}`); + msg ??= "Redeem failed."; + return { status: 500, message: msg, logHeaderOverride: "Redeem failed", ...res }; + } +} + +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, + }, + 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/pishock.ts b/ebs/src/modules/pishock.ts new file mode 100644 index 0000000..c2975bb --- /dev/null +++ b/ebs/src/modules/pishock.ts @@ -0,0 +1,38 @@ +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"; +import { sendShock } from "../util/pishock"; + +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(), + success, + }; + if (!success) { + result.message = "Failed to send PiShock operation"; + } + return result; +} + 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 50% rename from ebs/src/modules/orders/user.ts rename to ebs/src/modules/user/endpoints.ts index 89c1ebf..46ecc1b 100644 --- a/ebs/src/modules/orders/user.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -1,29 +1,27 @@ -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, setUserSession } from "."; app.post( "/public/authorized", asyncCatch(async (req, res) => { + const {session} = req.body as {session: string}; + // console.log(`${req.auth.opaque_user_id} opened extension (session ${session})`); + setUserSession(req.user, session); + + updateUserTwitchInfo(req.user).then(); + res.sendStatus(200); - updateUserTwitchInfo(req.user).then().catch(console.error); }) ); -app.get("/private/user/:idOrName", asyncCatch(async (req, res) => { - res.json(await lookupUser(req.params["idOrName"])); -})); +app.get( + "/private/user/:idOrName", + asyncCatch(async (req, res) => { + res.json(await lookupUser(req.params["idOrName"])); + }) +); app.post( "/private/user/:idOrName/ban", @@ -35,7 +33,8 @@ app.post( } await setUserBanned(user, true); - res.sendStatus(200); + console.log(`[Private API] Banned ${user.login ?? user.id}`); + res.status(200).json(user); }) ); @@ -49,6 +48,7 @@ app.delete( } await setUserBanned(user, false); - res.sendStatus(200); + console.log(`[Private API] Unbanned ${user.login ?? user.id}`); + res.status(200).json(user); }) ); diff --git a/ebs/src/modules/user/index.ts b/ebs/src/modules/user/index.ts new file mode 100644 index 0000000..cd21a96 --- /dev/null +++ b/ebs/src/modules/user/index.ts @@ -0,0 +1,28 @@ +import { User } from "common/types"; +import { saveUser } from "../../util/db"; +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); + sendPubSubMessage({ + type: "banned", + data: JSON.stringify({ id: user.id, banned }), + }).then(); +} + +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 d74add1..6366939 100644 --- a/ebs/src/types.ts +++ b/ebs/src/types.ts @@ -9,23 +9,11 @@ export type AuthorizationPayload = { listen: string[]; 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"; - } - }; - } }; + +export type HttpResult = { + status: number; + message: string; + logHeaderOverride?: string; + logContents?: any; +}; \ No newline at end of file diff --git a/ebs/src/util/db.ts b/ebs/src/util/db.ts index 3a215ac..7cfdd2c 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; @@ -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)); } } } @@ -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; } @@ -139,7 +145,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/ebs/src/util/jwt.ts b/ebs/src/util/jwt.ts index b34c9fd..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; } @@ -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/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/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/ebs/src/util/pishock.ts b/ebs/src/util/pishock.ts index 0cf00b8..21cc62f 100644 --- a/ebs/src/util/pishock.ts +++ b/ebs/src/util/pishock.ts @@ -17,9 +17,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/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)); 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, 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/index.ts b/frontend/www/src/index.ts index 1fa7b1a..5ffa535 100644 --- a/frontend/www/src/index.ts +++ b/frontend/www/src/index.ts @@ -11,3 +11,5 @@ import "./modules/auth"; 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 386aa73..eb76597 100644 --- a/frontend/www/src/modules/auth.ts +++ b/frontend/www/src/modules/auth.ts @@ -1,16 +1,41 @@ -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 { onTwitchAuth, twitchAuth } from "../util/twitch"; +import { clientSession } from "./transaction"; const $loginPopup = document.getElementById("onboarding")!; const $loginButton = document.getElementById("twitch-login")!; -document.addEventListener("DOMContentLoaded", () => ($loginButton.onclick = Twitch.ext.actions.requestIdShare)); +onTwitchAuth(onAuth); + +document.addEventListener("DOMContentLoaded", () => { + $loginButton.onclick = async () => { + 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); + } + renderRedeemButtons().then(); + }); +} let _banned = false; +const callbacks: (() => void)[] = []; + export function getBanned() { return _banned; } @@ -20,6 +45,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 { @@ -27,118 +53,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 deleted file mode 100644 index 2b2fa92..0000000 --- a/frontend/www/src/modules/modal.ts +++ /dev/null @@ -1,408 +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"; - -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(); - - Twitch.ext.bits.useBits(cart!.sku); -} - -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..6c37420 --- /dev/null +++ b/frontend/www/src/modules/modal/index.ts @@ -0,0 +1,247 @@ +import { Cart, Redeem, TransactionToken, TransactionTokenPayload } from "common/types"; +import { ebsFetch } from "../../util/ebs"; +import { getConfig } from "../../util/config"; +import { logToDiscord } from "../../util/logger"; +import { setBanned } from "../auth"; +import { clientSession, promptTransaction, transactionCancelled, transactionComplete, } from "../transaction"; +import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; +import { getJWTPayload as decodeJWT } from "../../util/jwt"; + +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: TransactionToken | undefined; +export let transactionTokenJwt: string | undefined; + +let processingTimeout: number | undefined; + +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; + + 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, clientSession, 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()) || !transactionToken) { + return; + } + + logToDiscord({ + transactionToken: transactionToken.id, + userIdInsecure: Twitch.ext.viewer.id!, + important: false, + fields: [{ header: "Transaction started", content: cart }], + }).then(); + + const product = transactionToken.product; + const res = await promptTransaction(product.sku, product.cost); + if (res === "cancelled") { + await transactionCancelled(); + } else { + await transactionComplete(res); + } +} + +async function prePurchase(): Promise { + if (!cart) { + console.error("Can't send prepurchase without cart"); + return false; + } + + 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; + } + + 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; + } + + return true; +} diff --git a/frontend/www/src/modules/pubsub.ts b/frontend/www/src/modules/pubsub.ts index 06530b4..00e9575 100644 --- a/frontend/www/src/modules/pubsub.ts +++ b/frontend/www/src/modules/pubsub.ts @@ -4,7 +4,9 @@ import { renderRedeemButtons } from "./redeems"; import { strToU8, decompressSync, strFromU8 } from "fflate"; import { getBanned, setBanned } from "./auth"; -Twitch.ext.listen("global", async (_t, _c, message) => { +Twitch.ext.listen("global", onPubsubMessage); + +async function onPubsubMessage(target: string, contentType: string, message: string) { const fullMessage = JSON.parse(message) as PubSubMessage; console.log(fullMessage); @@ -20,9 +22,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/modules/transaction.ts b/frontend/www/src/modules/transaction.ts new file mode 100644 index 0000000..1d5b0cb --- /dev/null +++ b/frontend/www/src/modules/transaction.ts @@ -0,0 +1,91 @@ +import { Transaction } from "common/types"; +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 | "cancelled"; + +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`); + return await twitchUseBits(sku); +} + +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.id, + userIdInsecure: Twitch.ext.viewer.id!, + important: false, + fields: [{ header: "Transaction complete", content: transaction }], + }).then(); + + const result = await ebsFetch("/public/transaction", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token: transactionTokenJwt!, + clientSession, + ...{ receipt: transaction.transactionReceipt }, + } satisfies Transaction), + }); + + setTimeout(() => hideProcessingModal(), 250); + + const text = await result.text(); + const cost = transactionToken.product.cost; + if (result.ok) { + showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken.id}`); + } else { + const errorText = `${result.status} ${result.statusText} - ${text}`; + logToDiscord({ + transactionToken: transactionToken.id, + 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! + Transaction ID: ${transactionToken.id}` + ); + } +} + +export async function transactionCancelled() { + if (transactionToken) { + logToDiscord({ + transactionToken: transactionToken.id, + 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({ jwt: transactionTokenJwt }), + }); + } + + hideProcessingModal(); + showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken?.id ?? "none"}`); +} 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; + } +} 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), diff --git a/frontend/www/src/util/twitch.ts b/frontend/www/src/util/twitch.ts new file mode 100644 index 0000000..735934b --- /dev/null +++ b/frontend/www/src/util/twitch.ts @@ -0,0 +1,59 @@ +import { Callback } from "common/types"; + +type AuthResponse = Twitch.ext.Authorized; +type TransactionResponse = Twitch.ext.BitsTransaction | "cancelled"; + +class Callbacks { + private persistent: Callback[] = []; + private transient: Callback[] = []; + + public addPersistent(callback: Callback) { + this.persistent.push(callback); + } + + public addTransient(callback: Callback) { + this.transient.push(callback); + } + + public 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) => { + authCallbacks.call(auth); +}); + +Twitch.ext.bits.onTransactionComplete((transaction) => { + transactionCallbacks.call(transaction); +}); + +Twitch.ext.bits.onTransactionCancelled(() => { + 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(Callbacks.prototype.addTransient.bind(authCallbacks)); +} + +export async function twitchUseBits(sku: string): Promise { + Twitch.ext.bits.useBits(sku); + return new Promise(Callbacks.prototype.addTransient.bind(transactionCallbacks)); +} + +export function onTwitchAuth(callback: Callback) { + authCallbacks.addPersistent(callback); +} + +export function onTwitchBits(callback: Callback) { + transactionCallbacks.addPersistent(callback); +} 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." }; } 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)); } } } diff --git a/scripts/sql/init_db.sql b/scripts/sql/init_db.sql index a037ba4..640f5dc 100644 --- a/scripts/sql/init_db.sql +++ b/scripts/sql/init_db.sql @@ -26,3 +26,16 @@ CREATE TABLE IF NOT EXISTS logs ( fromBackend BOOLEAN NOT NULL, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + +DELIMITER $$ +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 ;