Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,29 @@ export type Cart = {

export type IdentifiableCart = Cart & { userId: string };

export type Transaction = {
export type Transaction = BitsTransaction | CreditTransaction;

export type TransactionBase = {
token: string; // JWT with TransactionToken (given by EBS on prepurchase)
clientSession: string; // same session as in Cart
type: "bits" | "credit";
};
export type BitsTransaction = TransactionBase & {
type: "bits";
receipt: string; // JWT with BitsTransactionPayload (coming from Twitch)
};
export type CreditTransaction = TransactionBase & { type: "credit" };

export type DecodedTransaction = {
token: TransactionTokenPayload;
receipt: BitsTransactionPayload;
};
} & ({ type: "credit" } | { type: "bits"; receipt: BitsTransactionPayload });

export type TransactionToken = {
id: string;
time: number; // Unix millis
user: {
id: string; // user channel id
credit: number;
};
product: {
sku: string;
Expand Down Expand Up @@ -148,13 +156,15 @@ export type User = {
login?: string;
displayName?: string;
banned: boolean;
credit: number;
};

export type OrderState =
| "rejected"
| "prepurchase"
| "cancelled"
| "paid" // waiting for game
| "denied" // routine rejection e.g. precondition failed
| "failed" // game failed/timed out
| "succeeded";

Expand Down
4 changes: 3 additions & 1 deletion ebs/src/modules/game/messages.game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ export type LogMessage = GameMessageBase & {
message: string;
};

export type ResultKind = "success" | "error" | "deny";

export type ResultMessage = GameMessageBase & {
messageType: MessageType.Result;
success: boolean;
status: ResultKind;
message?: string;
};

Expand Down
42 changes: 22 additions & 20 deletions ebs/src/modules/orders/endpoints/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,28 +138,30 @@ app.post(
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();
if (decoded.type === "bits") {
const bitsTransaction = decoded.receipt.data.transactionId;
if (usedBitsTransactionIds.has(bitsTransaction)) {
// happens if there are X extension tabs that are all open on the twitch bits modal
// twitch broadcasts onTransactionComplete to all of them and the client ends up
// sending X requests for each completed transaction (where all but 1 will obviously be duplicates)
// we don't want to auto-ban people just for having multiple tabs open
// but it's still obviously not ideal behaviour
if (order.cart.clientSession === transaction.clientSession) {
// 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;
}
// 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);
}
usedBitsTransactionIds.add(bitsTransaction);

if (order.userId != req.user.id) {
// paying for somebody else, how generous
Expand Down
117 changes: 94 additions & 23 deletions ebs/src/modules/orders/transaction.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,58 @@
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 { addCredit, getOrAddUser, getOrder, saveOrder } from "../../util/db";
import { ResultKind, ResultMessage } from "../game/messages.game";
import { sendToLogger } from "../../util/logger";
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.type !== "bits" && transaction.type !== "credit") {
return { ...defaultResult, logHeaderOverride: "Invalid type" };
}
if (!transaction.token || !verifyJWT(transaction.token)) {
return { ...defaultResult, logHeaderOverride: "Invalid token" };
}
const token = parseJWT(transaction.token) as TransactionTokenPayload;
if (!transaction.receipt || !verifyJWT(transaction.receipt)) {
return { ...defaultResult, logHeaderOverride: "Invalid receipt" };
if (transaction.type === "bits") {
if (!transaction.receipt || !verifyJWT(transaction.receipt)) {
return { ...defaultResult, logHeaderOverride: "Invalid receipt" };
}
return {
type: "bits",
token,
receipt: parseJWT(transaction.receipt) as BitsTransactionPayload,
};
}
return {
type: "credit",
token,
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" };
if (decoded.type === "bits") {
// for bits purchases, we don't care if our token JWT expired
// because if the bits t/a is valid, the person 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" };
}
} else if (decoded.type === "credit") {
if (token.exp < Date.now() / 1000 - jwtExpiryToleranceSeconds) {
return { ...defaultResult, status: 400, message: "Transaction expired, try again", logHeaderOverride: "Credit receipt expired" };
}
}

return token.data;
Expand Down Expand Up @@ -68,32 +85,85 @@ export async function getAndCheckOrder(transaction: Transaction, decoded: Decode
};
}

// we verified the receipt JWT earlier (in verifyTransaction)
order.receipt = transaction.receipt;
if (transaction.type === "credit") {
const sku = order.cart.sku;
const cost = getBitsPrice(sku);
if (!isFinite(cost) || cost <= 0) {
return { status: 500, message: "Internal configuration error", logHeaderOverride: "Bad SKU", logContents: { order: order.id, sku } };
}
if (user.credit < cost) {
return {
status: 409,
message: "Insufficient credit",
logContents: { user: user.id, order: order.id, cost, credit: user.credit },
};
}
user = await addCredit(user, -cost);
console.log(`Debited ${user.login ?? user.id} (${user.credit + cost} - ${cost} = ${user.credit})`);
order.receipt = `credit (${user.credit + cost} - ${cost} = ${user.credit})`;
} else {
// for bits transactions, we verified the receipt JWT earlier (in verifyTransaction)
order.receipt = transaction.receipt;
}

order.state = "paid";
await saveOrder(order);

return order;
}

const orderStateMap: { [k in ResultKind]: OrderState } = {
success: "succeeded",
error: "failed",
deny: "denied",
};

export async function processRedeemResult(order: Order, result: ResultMessage): Promise<HttpResult> {
order.state = result.success ? "succeeded" : "failed";
order.state = orderStateMap[result.status];
order.result = result.message;
await saveOrder(order);
let msg = result.message;
const res = { logContents: { order: order.id, cart: order.cart } };
if (result.success) {
if (result.status === "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 };
await refund(order);
let status: number;
let header: string;
if (result.status === "deny") {
status = 400;
msg ??= "The game is not ready to process this redeem.";
header = "Redeem denied";
} else {
status = 500;
msg ??= "Redeem failed.";
header = "Redeem failed";
}
console.error(`[${result.guid}] ${header}: ${JSON.stringify(result)}`);
return { status: status, message: msg, logHeaderOverride: header, ...res };
}
}

export async function refund(order: Order) {
try {
let user = await getOrAddUser(order.userId);
const cost = parseInt(order.cart.sku.substring(4));
user = await addCredit(user, cost);
console.log(`Refunded ${user.login ?? user.id} (${user.credit - cost} + ${cost} = ${user.credit})`);
} catch (e) {
console.error(`Could not refund order ${order.id}`);
console.error(e);
sendToLogger({
transactionToken: order.id,
userIdInsecure: order.userId,
important: true,
fields: [{ header: "Failed to refund", content: { order: order.id, error: e } }],
});
}
}

Expand All @@ -109,6 +179,7 @@ export function makeTransactionToken(order: Order, user: User): TransactionToken
time: Date.now(),
user: {
id: user.id,
credit: user.credit,
},
product: { sku, cost },
};
Expand Down
26 changes: 24 additions & 2 deletions ebs/src/modules/user/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { app } from "../..";
import { updateUserTwitchInfo, lookupUser } from "../../util/db";
import { updateUserTwitchInfo, lookupUser, addCredit } from "../../util/db";
import { asyncCatch } from "../../util/middleware";
import { setUserBanned, setUserSession } from ".";

Expand All @@ -12,7 +12,7 @@ app.post(

updateUserTwitchInfo(req.user).then();

res.sendStatus(200);
res.status(200).send({ credit: req.user.credit });
})
);

Expand Down Expand Up @@ -52,3 +52,25 @@ app.delete(
res.status(200).json(user);
})
);

app.post(
"/private/user/:idOrName/addCredit",
asyncCatch(async (req, res) => {
let user = await lookupUser(req.params["idOrName"]);
if (!user) {
res.sendStatus(404);
return;
}

const amt = parseInt(req.query["amount"] as string);
if (!isFinite(amt)) {
res.sendStatus(400);
return;
}

user = await addCredit(user, amt);
console.log(`[Private API] Granted ${amt} credits to ${user.login ?? user.id} (now ${user.credit})`);
res.status(200).json(user);
return;
})
);
23 changes: 20 additions & 3 deletions ebs/src/util/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,13 @@ async function createUser(id: string): Promise<User> {
const user: User = {
id,
banned: false,
credit: 0,
};
try {
await db.query(
`
INSERT INTO users (id, login, displayName, banned)
VALUES (:id, :login, :displayName, :banned)`,
INSERT INTO users (id, login, displayName, banned, credit)
VALUES (:id, :login, :displayName, :banned, :credit)`,
user
);
} catch (e: any) {
Expand All @@ -130,7 +131,10 @@ export async function saveUser(user: User) {
await db.query(
`
UPDATE users
SET login = :login, displayName = :displayName, banned = :banned
SET login = :login,
displayName = :displayName,
banned = :banned,
credit = :credit
WHERE id = :id`,
{ ...user }
);
Expand Down Expand Up @@ -167,3 +171,16 @@ export async function updateUserTwitchInfo(user: User): Promise<User> {
}
return user;
}

export async function addCredit(user: User, amount: number): Promise<User> {
try {
await db.query(`CALL addCredit(:userId, :amount, @credit);`, { userId: user.id, amount });
const [rows] = (await db.query("SELECT @credit")) as [RowDataPacket[], any];
user.credit = rows[0]["@credit"];
return user;
} catch (e: any) {
console.error("Database query failed (addCredit)");
console.error(e);
throw e;
}
}
6 changes: 5 additions & 1 deletion frontend/www/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,8 @@ select {
border: 1px solid #ffffff1A;
background-color: #333;
color: #eee;
}
}

.sat-10 {
filter: saturate(10%);
}
Loading