From 56bfa86b778864ff3314891c75ccbd7d6dbbba0f Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 12 May 2026 12:45:34 -0600 Subject: [PATCH 1/4] Add target calculation commands - Add `/target` and `/calculate` subcommands for game stat goals - Support stat and ratio targets with autocomplete and setback estimates - Add localized command and argument strings --- .../src/commands/target/target.command.ts | 522 ++++++++++++++++++ locales/en-US/default.json | 32 ++ 2 files changed, 554 insertions(+) create mode 100644 apps/discord-bot/src/commands/target/target.command.ts diff --git a/apps/discord-bot/src/commands/target/target.command.ts b/apps/discord-bot/src/commands/target/target.command.ts new file mode 100644 index 000000000..638ef2436 --- /dev/null +++ b/apps/discord-bot/src/commands/target/target.command.ts @@ -0,0 +1,522 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { + type APIApplicationCommandOptionChoice, + ApplicationCommandOptionType, +} from "discord-api-types/v10"; +import { + AbstractArgument, + ApiService, + Command, + CommandContext, + EmbedBuilder, + ErrorMessage, + type LocalizationString, + PlayerArgument, + SubCommand, +} from "@statsify/discord"; +import { + type Constructor, + prettify, + removeFormatting, +} from "@statsify/util"; +import { Container } from "typedi"; +import { + LEADERBOARD_RATIOS, + MetadataScanner, + type Player, + PlayerStats, + type Ratio, +} from "@statsify/schemas"; +import { STATUS_COLORS } from "@statsify/logger"; + +type GameKey = keyof PlayerStats; + +interface TargetStat { + denominator?: string; + denominatorName?: string; + key: string; + name: string; + numerator?: string; + numeratorName?: string; + ratio?: Ratio; + type: "ratio" | "stat"; +} + +const apiService = Container.get(ApiService); +const DEFAULT_SETBACK = 15; + +const GAMES: [GameKey, name: string, group?: string][] = [ + ["arcade", "Arcade"], + ["arenabrawl", "Arena Brawl", "classic"], + ["bedwars", "BedWars"], + ["blitzsg", "BlitzSG"], + ["buildbattle", "Build Battle"], + ["challenges", "Challenges"], + ["copsandcrims", "Cops and Crims"], + ["duels", "Duels"], + ["general", "General"], + ["megawalls", "MegaWalls"], + ["murdermystery", "Murder Mystery"], + ["paintball", "Paintball", "classic"], + ["parkour", "Parkour"], + ["pit", "Pit"], + ["quake", "Quake", "classic"], + ["quests", "Quests"], + ["skywars", "SkyWars"], + ["smashheroes", "Smash Heroes"], + ["speeduhc", "Speed UHC"], + ["tntgames", "TNT Games"], + ["turbokartracers", "Turbo Kart Racers", "classic"], + ["uhc", "UHC"], + ["vampirez", "VampireZ", "classic"], + ["walls", "Walls", "classic"], + ["warlords", "Warlords"], + ["woolgames", "WoolGames"], +]; + +const GAME_NAMES = new Map(GAMES.map(([key, name]) => [key, name])); + +const getGameClass = (game: GameKey) => + Reflect.getMetadata("design:type", PlayerStats.prototype, game) as Constructor; + +const statCache = new Map(); + +const targetArgs = (game: GameKey) => [ + new TargetStatArgument(game), + new TargetArgument(), + new PlayerArgument(), + new SetbackArgument(), +]; + +class TargetStatArgument extends AbstractArgument { + public autocomplete = true; + public description: LocalizationString; + public name = "stat"; + public required = true; + public type = ApplicationCommandOptionType.String; + private readonly game: GameKey; + + public constructor(game: GameKey) { + super(); + this.description = (t) => t("arguments.target-stat"); + this.game = game; + } + + public autocompleteHandler( + context: CommandContext + ): APIApplicationCommandOptionChoice[] { + const currentValue = context.option(this.name, "").toLowerCase(); + const stats = getTargetStats(this.game); + + const filtered = currentValue ? + stats.filter((stat) => + [stat.key, stat.name] + .some((value) => value.toLowerCase().includes(currentValue)) + ) : + stats; + + return filtered + .slice(0, 25) + .map((stat) => ({ name: stat.name.slice(0, 100), value: stat.key })); + } +} + +class TargetArgument extends AbstractArgument { + public description: LocalizationString; + public min_value = 0; + public name = "target"; + public required = true; + public type = ApplicationCommandOptionType.Number; + + public constructor() { + super(); + this.description = (t) => t("arguments.target"); + } +} + +class SetbackArgument extends AbstractArgument { + public description: LocalizationString; + public min_value = 0; + public name = "setback"; + public required = false; + public type = ApplicationCommandOptionType.Integer; + + public constructor() { + super(); + this.description = (t) => t("arguments.target-setback"); + } +} + +@Command({ description: (t) => t("commands.target") }) +export class TargetCommand { + @SubCommand({ description: (t) => t("commands.target-arcade"), args: targetArgs("arcade") }) + public arcade(context: CommandContext) { + return runTarget(context, "arcade"); + } + + @SubCommand({ description: (t) => t("commands.target-arenabrawl"), args: targetArgs("arenabrawl"), group: "classic" }) + public arenabrawl(context: CommandContext) { + return runTarget(context, "arenabrawl"); + } + + @SubCommand({ description: (t) => t("commands.target-bedwars"), args: targetArgs("bedwars") }) + public bedwars(context: CommandContext) { + return runTarget(context, "bedwars"); + } + + @SubCommand({ description: (t) => t("commands.target-blitzsg"), args: targetArgs("blitzsg") }) + public blitzsg(context: CommandContext) { + return runTarget(context, "blitzsg"); + } + + @SubCommand({ description: (t) => t("commands.target-buildbattle"), args: targetArgs("buildbattle") }) + public buildbattle(context: CommandContext) { + return runTarget(context, "buildbattle"); + } + + @SubCommand({ description: (t) => t("commands.target-challenges"), args: targetArgs("challenges") }) + public challenges(context: CommandContext) { + return runTarget(context, "challenges"); + } + + @SubCommand({ description: (t) => t("commands.target-copsandcrims"), args: targetArgs("copsandcrims") }) + public copsandcrims(context: CommandContext) { + return runTarget(context, "copsandcrims"); + } + + @SubCommand({ description: (t) => t("commands.target-duels"), args: targetArgs("duels") }) + public duels(context: CommandContext) { + return runTarget(context, "duels"); + } + + @SubCommand({ description: (t) => t("commands.target-general"), args: targetArgs("general") }) + public general(context: CommandContext) { + return runTarget(context, "general"); + } + + @SubCommand({ description: (t) => t("commands.target-megawalls"), args: targetArgs("megawalls") }) + public megawalls(context: CommandContext) { + return runTarget(context, "megawalls"); + } + + @SubCommand({ description: (t) => t("commands.target-murdermystery"), args: targetArgs("murdermystery") }) + public murdermystery(context: CommandContext) { + return runTarget(context, "murdermystery"); + } + + @SubCommand({ description: (t) => t("commands.target-paintball"), args: targetArgs("paintball"), group: "classic" }) + public paintball(context: CommandContext) { + return runTarget(context, "paintball"); + } + + @SubCommand({ description: (t) => t("commands.target-parkour"), args: targetArgs("parkour") }) + public parkour(context: CommandContext) { + return runTarget(context, "parkour"); + } + + @SubCommand({ description: (t) => t("commands.target-pit"), args: targetArgs("pit") }) + public pit(context: CommandContext) { + return runTarget(context, "pit"); + } + + @SubCommand({ description: (t) => t("commands.target-quake"), args: targetArgs("quake"), group: "classic" }) + public quake(context: CommandContext) { + return runTarget(context, "quake"); + } + + @SubCommand({ description: (t) => t("commands.target-quests"), args: targetArgs("quests") }) + public quests(context: CommandContext) { + return runTarget(context, "quests"); + } + + @SubCommand({ description: (t) => t("commands.target-skywars"), args: targetArgs("skywars") }) + public skywars(context: CommandContext) { + return runTarget(context, "skywars"); + } + + @SubCommand({ description: (t) => t("commands.target-smashheroes"), args: targetArgs("smashheroes") }) + public smashheroes(context: CommandContext) { + return runTarget(context, "smashheroes"); + } + + @SubCommand({ description: (t) => t("commands.target-speeduhc"), args: targetArgs("speeduhc") }) + public speeduhc(context: CommandContext) { + return runTarget(context, "speeduhc"); + } + + @SubCommand({ description: (t) => t("commands.target-tntgames"), args: targetArgs("tntgames") }) + public tntgames(context: CommandContext) { + return runTarget(context, "tntgames"); + } + + @SubCommand({ description: (t) => t("commands.target-turbokartracers"), args: targetArgs("turbokartracers"), group: "classic" }) + public turbokartracers(context: CommandContext) { + return runTarget(context, "turbokartracers"); + } + + @SubCommand({ description: (t) => t("commands.target-uhc"), args: targetArgs("uhc") }) + public uhc(context: CommandContext) { + return runTarget(context, "uhc"); + } + + @SubCommand({ description: (t) => t("commands.target-vampirez"), args: targetArgs("vampirez"), group: "classic" }) + public vampirez(context: CommandContext) { + return runTarget(context, "vampirez"); + } + + @SubCommand({ description: (t) => t("commands.target-walls"), args: targetArgs("walls"), group: "classic" }) + public walls(context: CommandContext) { + return runTarget(context, "walls"); + } + + @SubCommand({ description: (t) => t("commands.target-warlords"), args: targetArgs("warlords") }) + public warlords(context: CommandContext) { + return runTarget(context, "warlords"); + } + + @SubCommand({ description: (t) => t("commands.target-woolgames"), args: targetArgs("woolgames") }) + public woolgames(context: CommandContext) { + return runTarget(context, "woolgames"); + } +} + +@Command({ name: "calculate", description: (t) => t("commands.calculate") }) +export class CalculateCommand extends TargetCommand {} + +async function runTarget(context: CommandContext, game: GameKey) { + const user = context.getUser(); + const player = await apiService.getPlayer(context.option("player"), user); + const target = context.option("target"); + const setback = context.option("setback", DEFAULT_SETBACK); + const stat = resolveTargetStat(game, context.option("stat")); + const gameStats = player.stats[game] as unknown as Record; + const level = getLevel(gameStats); + + if (stat.type === "ratio") { + return buildRatioResponse(player, game, gameStats, stat, target, setback, level); + } + + return buildStatResponse(player, game, gameStats, stat, target, level); +} + +function buildRatioResponse( + player: Player, + game: GameKey, + gameStats: Record, + stat: TargetStat, + target: number, + setback: number, + level?: string +) { + const numerator = getNumber(gameStats, stat.numerator!); + const denominator = getNumber(gameStats, stat.denominator!); + const current = denominator === 0 ? numerator : numerator / denominator; + const needed = Math.max(0, Math.ceil(target * denominator - numerator)); + const neededWithSetback = Math.max( + 0, + Math.ceil(target * (denominator + setback) - numerator) + ); + const numeratorName = stat.numeratorName!; + const denominatorName = singularize(stat.denominatorName!); + + const lines = [ + `Current: **${formatDecimal(current)} ${stat.name}**`, + `Needed: **${formatInteger(needed)} ${numeratorName}** without another ${denominatorName}`, + ]; + + if (setback > 0) { + lines.push( + `Or: **${formatInteger(neededWithSetback)} ${numeratorName}** if you take **${formatInteger(setback)} ${stat.denominatorName}**` + ); + } + + return { + embeds: [ + baseEmbed(player, game, level) + .title(`To reach ${formatDecimal(target)} ${stat.name}:`) + .description(lines.join("\n")), + ], + }; +} + +function buildStatResponse( + player: Player, + game: GameKey, + gameStats: Record, + stat: TargetStat, + target: number, + level?: string +) { + const current = getNumber(gameStats, stat.key); + const needed = Math.max(0, Math.ceil(target - current)); + const statName = statNameLower(stat.name); + + return { + embeds: [ + baseEmbed(player, game, level) + .title(`To reach ${formatTarget(target)} ${stat.name}:`) + .description( + [ + `Current: **${formatTarget(current)} ${stat.name}**`, + `Needed: **${formatInteger(needed)} ${statName}**${needed === 0 ? " (target reached)" : ""}`, + ].join("\n") + ), + ], + }; +} + +function baseEmbed(player: Player, game: GameKey, level?: string) { + const titleParts = [player.displayName]; + if (level) titleParts.push(level); + + return new EmbedBuilder() + .author(titleParts.join(" ")) + .footer(GAME_NAMES.get(game)!) + .color(STATUS_COLORS.info); +} + +function getTargetStats(game: GameKey) { + if (statCache.has(game)) return statCache.get(game)!; + + const metadata = MetadataScanner.scan(getGameClass(game)); + const numberFields = metadata + .filter(([, { type }]) => type.type === Number) + .map(([key, { leaderboard }]) => ({ + key, + name: cleanName(leaderboard.fieldName || leaderboard.name || prettify(key)), + })); + + const byKey = new Map(numberFields.map((field) => [field.key, field])); + const ratioKeys = new Set(LEADERBOARD_RATIOS.map((ratio) => ratio[2])); + const ratios: TargetStat[] = []; + + for (const [numerator, denominator, ratioKey, prettyName] of LEADERBOARD_RATIOS) { + for (const field of numberFields) { + if (lastPathPart(field.key) !== ratioKey) continue; + + const parent = parentPath(field.key); + const numeratorKey = pathWithParent(parent, numerator); + const denominatorKey = pathWithParent(parent, denominator); + const numeratorField = byKey.get(numeratorKey); + const denominatorField = byKey.get(denominatorKey); + + if (!numeratorField || !denominatorField) continue; + + ratios.push({ + denominator: denominatorKey, + denominatorName: statNameLower(denominatorField.name), + key: field.key, + name: parent === "overall" || !parent ? prettyName : `${cleanName(parent)} ${prettyName}`, + numerator: numeratorKey, + numeratorName: statNameLower(numeratorField.name), + ratio: [numerator, denominator, ratioKey, prettyName], + type: "ratio", + }); + } + } + + const stats: TargetStat[] = [ + ...ratios, + ...numberFields + .filter((field) => !ratioKeys.has(lastPathPart(field.key))) + .map((field) => ({ ...field, type: "stat" as const })), + ]; + + statCache.set(game, stats); + return stats; +} + +function resolveTargetStat(game: GameKey, input: string) { + const stats = getTargetStats(game); + const normalized = input.toLowerCase(); + const exact = stats.find((stat) => stat.key === input); + if (exact) return exact; + + const overall = stats.find( + (stat) => + stat.key.toLowerCase() === `overall.${normalized}` || + stat.name.toLowerCase() === normalized + ); + if (overall) return overall; + + const fallback = stats.find( + (stat) => + lastPathPart(stat.key).toLowerCase() === normalized || + stat.name.toLowerCase().includes(normalized) + ); + + if (!fallback) { + throw new ErrorMessage( + "Target stat not found", + `I couldn't find \`${input}\` for ${GAME_NAMES.get(game)}. Use the stat autocomplete to pick a supported target.` + ); + } + + return fallback; +} + +function getLevel(gameStats: Record) { + const formatted = gameStats.levelFormatted || gameStats.naturalLevelFormatted; + if (typeof formatted === "string") return removeFormatting(formatted); + + const level = gameStats.level; + if (typeof level === "number") return `Level ${formatDecimal(level)}`; + + return undefined; +} + +function getNumber(data: Record, path: string) { + const value = path + .split(".") + .reduce((acc, key) => (acc as Record | undefined)?.[key], data); + + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function cleanName(value: string) { + return removeFormatting(value) + .replace(/\s+/g, " ") + .trim(); +} + +function statNameLower(value: string) { + return cleanName(value).toLowerCase(); +} + +function singularize(value: string) { + return value.endsWith("s") ? value.slice(0, -1) : value; +} + +function formatDecimal(value: number) { + return value.toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); +} + +function formatInteger(value: number) { + return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function formatTarget(value: number) { + return Number.isInteger(value) ? formatInteger(value) : formatDecimal(value); +} + +function lastPathPart(path: string) { + return path.split(".").at(-1)!; +} + +function parentPath(path: string) { + return path.split(".").slice(0, -1).join("."); +} + +function pathWithParent(parent: string, key: string) { + return parent ? `${parent}.${key}` : key; +} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..5e94dcc89 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -14,6 +14,9 @@ "server": "A Minecraft server name or a server IP", "tags-content": "The content of the tag", "tags-name": "The name of the tag", + "target": "The goal you want to reach", + "target-setback": "How many denominator stats to include in the alternate calculation", + "target-stat": "The stat or ratio you want to target", "text": "A message", "user": "Choose a Discord user" }, @@ -31,6 +34,7 @@ "buildbattle": "$t(commands.hypixel-command, { \"name\": \"Build Battle\" })", "cape": "View someone's Minecraft and Optifine capes", "challenges": "$t(commands.hypixel-command, { \"name\": \"Challenge\" })", + "calculate": "$t(commands.target)", "quests-command": "View your {{name}} questing stats", "quests": "$t(commands.hypixel-command, { \"name\": \"Questing\" })", "quests-overall": "$t(commands.quests-command, { \"name\": \"Overall\" })", @@ -212,6 +216,34 @@ "tags-delete": "Delete a support tag", "tags-rename": "Rename a support tag", "text": "Generate Minecraft text", + "target": "Calculate what you need to reach a stat target", + "target-arcade": "$t(commands.target-command, { \"name\": \"Arcade\" })", + "target-arenabrawl": "$t(commands.target-command, { \"name\": \"Arena Brawl\" })", + "target-bedwars": "$t(commands.target-command, { \"name\": \"BedWars\" })", + "target-blitzsg": "$t(commands.target-command, { \"name\": \"BlitzSG\" })", + "target-buildbattle": "$t(commands.target-command, { \"name\": \"Build Battle\" })", + "target-command": "Calculate a {{name}} stat target", + "target-challenges": "$t(commands.target-command, { \"name\": \"Challenges\" })", + "target-copsandcrims": "$t(commands.target-command, { \"name\": \"Cops and Crims\" })", + "target-duels": "$t(commands.target-command, { \"name\": \"Duels\" })", + "target-general": "$t(commands.target-command, { \"name\": \"General\" })", + "target-megawalls": "$t(commands.target-command, { \"name\": \"MegaWalls\" })", + "target-murdermystery": "$t(commands.target-command, { \"name\": \"Murder Mystery\" })", + "target-paintball": "$t(commands.target-command, { \"name\": \"Paintball\" })", + "target-parkour": "$t(commands.target-command, { \"name\": \"Parkour\" })", + "target-pit": "$t(commands.target-command, { \"name\": \"Pit\" })", + "target-quake": "$t(commands.target-command, { \"name\": \"Quake\" })", + "target-quests": "$t(commands.target-command, { \"name\": \"Quests\" })", + "target-skywars": "$t(commands.target-command, { \"name\": \"SkyWars\" })", + "target-smashheroes": "$t(commands.target-command, { \"name\": \"Smash Heroes\" })", + "target-speeduhc": "$t(commands.target-command, { \"name\": \"Speed UHC\" })", + "target-tntgames": "$t(commands.target-command, { \"name\": \"TNT Games\" })", + "target-turbokartracers": "$t(commands.target-command, { \"name\": \"Turbo Kart Racers\" })", + "target-uhc": "$t(commands.target-command, { \"name\": \"UHC\" })", + "target-vampirez": "$t(commands.target-command, { \"name\": \"VampireZ\" })", + "target-walls": "$t(commands.target-command, { \"name\": \"Walls\" })", + "target-warlords": "$t(commands.target-command, { \"name\": \"Warlords\" })", + "target-woolgames": "$t(commands.target-command, { \"name\": \"WoolGames\" })", "theme": "Change your theme for every profile", "theme-boxes": "Change the appearance of the profile boxes", "theme-font": "Change the font of the profiles", From 9b509d9cb534e209e0cdf853504aa7981a3cc5f3 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 12 May 2026 13:00:50 -0600 Subject: [PATCH 2/4] Revert "Add target calculation commands" (#849) This reverts commit 56bfa86b778864ff3314891c75ccbd7d6dbbba0f. --- .../src/commands/target/target.command.ts | 522 ------------------ locales/en-US/default.json | 32 -- 2 files changed, 554 deletions(-) delete mode 100644 apps/discord-bot/src/commands/target/target.command.ts diff --git a/apps/discord-bot/src/commands/target/target.command.ts b/apps/discord-bot/src/commands/target/target.command.ts deleted file mode 100644 index 638ef2436..000000000 --- a/apps/discord-bot/src/commands/target/target.command.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Copyright (c) Statsify - * - * This source code is licensed under the GNU GPL v3 license found in the - * LICENSE file in the root directory of this source tree. - * https://github.com/Statsify/statsify/blob/main/LICENSE - */ - -import { - type APIApplicationCommandOptionChoice, - ApplicationCommandOptionType, -} from "discord-api-types/v10"; -import { - AbstractArgument, - ApiService, - Command, - CommandContext, - EmbedBuilder, - ErrorMessage, - type LocalizationString, - PlayerArgument, - SubCommand, -} from "@statsify/discord"; -import { - type Constructor, - prettify, - removeFormatting, -} from "@statsify/util"; -import { Container } from "typedi"; -import { - LEADERBOARD_RATIOS, - MetadataScanner, - type Player, - PlayerStats, - type Ratio, -} from "@statsify/schemas"; -import { STATUS_COLORS } from "@statsify/logger"; - -type GameKey = keyof PlayerStats; - -interface TargetStat { - denominator?: string; - denominatorName?: string; - key: string; - name: string; - numerator?: string; - numeratorName?: string; - ratio?: Ratio; - type: "ratio" | "stat"; -} - -const apiService = Container.get(ApiService); -const DEFAULT_SETBACK = 15; - -const GAMES: [GameKey, name: string, group?: string][] = [ - ["arcade", "Arcade"], - ["arenabrawl", "Arena Brawl", "classic"], - ["bedwars", "BedWars"], - ["blitzsg", "BlitzSG"], - ["buildbattle", "Build Battle"], - ["challenges", "Challenges"], - ["copsandcrims", "Cops and Crims"], - ["duels", "Duels"], - ["general", "General"], - ["megawalls", "MegaWalls"], - ["murdermystery", "Murder Mystery"], - ["paintball", "Paintball", "classic"], - ["parkour", "Parkour"], - ["pit", "Pit"], - ["quake", "Quake", "classic"], - ["quests", "Quests"], - ["skywars", "SkyWars"], - ["smashheroes", "Smash Heroes"], - ["speeduhc", "Speed UHC"], - ["tntgames", "TNT Games"], - ["turbokartracers", "Turbo Kart Racers", "classic"], - ["uhc", "UHC"], - ["vampirez", "VampireZ", "classic"], - ["walls", "Walls", "classic"], - ["warlords", "Warlords"], - ["woolgames", "WoolGames"], -]; - -const GAME_NAMES = new Map(GAMES.map(([key, name]) => [key, name])); - -const getGameClass = (game: GameKey) => - Reflect.getMetadata("design:type", PlayerStats.prototype, game) as Constructor; - -const statCache = new Map(); - -const targetArgs = (game: GameKey) => [ - new TargetStatArgument(game), - new TargetArgument(), - new PlayerArgument(), - new SetbackArgument(), -]; - -class TargetStatArgument extends AbstractArgument { - public autocomplete = true; - public description: LocalizationString; - public name = "stat"; - public required = true; - public type = ApplicationCommandOptionType.String; - private readonly game: GameKey; - - public constructor(game: GameKey) { - super(); - this.description = (t) => t("arguments.target-stat"); - this.game = game; - } - - public autocompleteHandler( - context: CommandContext - ): APIApplicationCommandOptionChoice[] { - const currentValue = context.option(this.name, "").toLowerCase(); - const stats = getTargetStats(this.game); - - const filtered = currentValue ? - stats.filter((stat) => - [stat.key, stat.name] - .some((value) => value.toLowerCase().includes(currentValue)) - ) : - stats; - - return filtered - .slice(0, 25) - .map((stat) => ({ name: stat.name.slice(0, 100), value: stat.key })); - } -} - -class TargetArgument extends AbstractArgument { - public description: LocalizationString; - public min_value = 0; - public name = "target"; - public required = true; - public type = ApplicationCommandOptionType.Number; - - public constructor() { - super(); - this.description = (t) => t("arguments.target"); - } -} - -class SetbackArgument extends AbstractArgument { - public description: LocalizationString; - public min_value = 0; - public name = "setback"; - public required = false; - public type = ApplicationCommandOptionType.Integer; - - public constructor() { - super(); - this.description = (t) => t("arguments.target-setback"); - } -} - -@Command({ description: (t) => t("commands.target") }) -export class TargetCommand { - @SubCommand({ description: (t) => t("commands.target-arcade"), args: targetArgs("arcade") }) - public arcade(context: CommandContext) { - return runTarget(context, "arcade"); - } - - @SubCommand({ description: (t) => t("commands.target-arenabrawl"), args: targetArgs("arenabrawl"), group: "classic" }) - public arenabrawl(context: CommandContext) { - return runTarget(context, "arenabrawl"); - } - - @SubCommand({ description: (t) => t("commands.target-bedwars"), args: targetArgs("bedwars") }) - public bedwars(context: CommandContext) { - return runTarget(context, "bedwars"); - } - - @SubCommand({ description: (t) => t("commands.target-blitzsg"), args: targetArgs("blitzsg") }) - public blitzsg(context: CommandContext) { - return runTarget(context, "blitzsg"); - } - - @SubCommand({ description: (t) => t("commands.target-buildbattle"), args: targetArgs("buildbattle") }) - public buildbattle(context: CommandContext) { - return runTarget(context, "buildbattle"); - } - - @SubCommand({ description: (t) => t("commands.target-challenges"), args: targetArgs("challenges") }) - public challenges(context: CommandContext) { - return runTarget(context, "challenges"); - } - - @SubCommand({ description: (t) => t("commands.target-copsandcrims"), args: targetArgs("copsandcrims") }) - public copsandcrims(context: CommandContext) { - return runTarget(context, "copsandcrims"); - } - - @SubCommand({ description: (t) => t("commands.target-duels"), args: targetArgs("duels") }) - public duels(context: CommandContext) { - return runTarget(context, "duels"); - } - - @SubCommand({ description: (t) => t("commands.target-general"), args: targetArgs("general") }) - public general(context: CommandContext) { - return runTarget(context, "general"); - } - - @SubCommand({ description: (t) => t("commands.target-megawalls"), args: targetArgs("megawalls") }) - public megawalls(context: CommandContext) { - return runTarget(context, "megawalls"); - } - - @SubCommand({ description: (t) => t("commands.target-murdermystery"), args: targetArgs("murdermystery") }) - public murdermystery(context: CommandContext) { - return runTarget(context, "murdermystery"); - } - - @SubCommand({ description: (t) => t("commands.target-paintball"), args: targetArgs("paintball"), group: "classic" }) - public paintball(context: CommandContext) { - return runTarget(context, "paintball"); - } - - @SubCommand({ description: (t) => t("commands.target-parkour"), args: targetArgs("parkour") }) - public parkour(context: CommandContext) { - return runTarget(context, "parkour"); - } - - @SubCommand({ description: (t) => t("commands.target-pit"), args: targetArgs("pit") }) - public pit(context: CommandContext) { - return runTarget(context, "pit"); - } - - @SubCommand({ description: (t) => t("commands.target-quake"), args: targetArgs("quake"), group: "classic" }) - public quake(context: CommandContext) { - return runTarget(context, "quake"); - } - - @SubCommand({ description: (t) => t("commands.target-quests"), args: targetArgs("quests") }) - public quests(context: CommandContext) { - return runTarget(context, "quests"); - } - - @SubCommand({ description: (t) => t("commands.target-skywars"), args: targetArgs("skywars") }) - public skywars(context: CommandContext) { - return runTarget(context, "skywars"); - } - - @SubCommand({ description: (t) => t("commands.target-smashheroes"), args: targetArgs("smashheroes") }) - public smashheroes(context: CommandContext) { - return runTarget(context, "smashheroes"); - } - - @SubCommand({ description: (t) => t("commands.target-speeduhc"), args: targetArgs("speeduhc") }) - public speeduhc(context: CommandContext) { - return runTarget(context, "speeduhc"); - } - - @SubCommand({ description: (t) => t("commands.target-tntgames"), args: targetArgs("tntgames") }) - public tntgames(context: CommandContext) { - return runTarget(context, "tntgames"); - } - - @SubCommand({ description: (t) => t("commands.target-turbokartracers"), args: targetArgs("turbokartracers"), group: "classic" }) - public turbokartracers(context: CommandContext) { - return runTarget(context, "turbokartracers"); - } - - @SubCommand({ description: (t) => t("commands.target-uhc"), args: targetArgs("uhc") }) - public uhc(context: CommandContext) { - return runTarget(context, "uhc"); - } - - @SubCommand({ description: (t) => t("commands.target-vampirez"), args: targetArgs("vampirez"), group: "classic" }) - public vampirez(context: CommandContext) { - return runTarget(context, "vampirez"); - } - - @SubCommand({ description: (t) => t("commands.target-walls"), args: targetArgs("walls"), group: "classic" }) - public walls(context: CommandContext) { - return runTarget(context, "walls"); - } - - @SubCommand({ description: (t) => t("commands.target-warlords"), args: targetArgs("warlords") }) - public warlords(context: CommandContext) { - return runTarget(context, "warlords"); - } - - @SubCommand({ description: (t) => t("commands.target-woolgames"), args: targetArgs("woolgames") }) - public woolgames(context: CommandContext) { - return runTarget(context, "woolgames"); - } -} - -@Command({ name: "calculate", description: (t) => t("commands.calculate") }) -export class CalculateCommand extends TargetCommand {} - -async function runTarget(context: CommandContext, game: GameKey) { - const user = context.getUser(); - const player = await apiService.getPlayer(context.option("player"), user); - const target = context.option("target"); - const setback = context.option("setback", DEFAULT_SETBACK); - const stat = resolveTargetStat(game, context.option("stat")); - const gameStats = player.stats[game] as unknown as Record; - const level = getLevel(gameStats); - - if (stat.type === "ratio") { - return buildRatioResponse(player, game, gameStats, stat, target, setback, level); - } - - return buildStatResponse(player, game, gameStats, stat, target, level); -} - -function buildRatioResponse( - player: Player, - game: GameKey, - gameStats: Record, - stat: TargetStat, - target: number, - setback: number, - level?: string -) { - const numerator = getNumber(gameStats, stat.numerator!); - const denominator = getNumber(gameStats, stat.denominator!); - const current = denominator === 0 ? numerator : numerator / denominator; - const needed = Math.max(0, Math.ceil(target * denominator - numerator)); - const neededWithSetback = Math.max( - 0, - Math.ceil(target * (denominator + setback) - numerator) - ); - const numeratorName = stat.numeratorName!; - const denominatorName = singularize(stat.denominatorName!); - - const lines = [ - `Current: **${formatDecimal(current)} ${stat.name}**`, - `Needed: **${formatInteger(needed)} ${numeratorName}** without another ${denominatorName}`, - ]; - - if (setback > 0) { - lines.push( - `Or: **${formatInteger(neededWithSetback)} ${numeratorName}** if you take **${formatInteger(setback)} ${stat.denominatorName}**` - ); - } - - return { - embeds: [ - baseEmbed(player, game, level) - .title(`To reach ${formatDecimal(target)} ${stat.name}:`) - .description(lines.join("\n")), - ], - }; -} - -function buildStatResponse( - player: Player, - game: GameKey, - gameStats: Record, - stat: TargetStat, - target: number, - level?: string -) { - const current = getNumber(gameStats, stat.key); - const needed = Math.max(0, Math.ceil(target - current)); - const statName = statNameLower(stat.name); - - return { - embeds: [ - baseEmbed(player, game, level) - .title(`To reach ${formatTarget(target)} ${stat.name}:`) - .description( - [ - `Current: **${formatTarget(current)} ${stat.name}**`, - `Needed: **${formatInteger(needed)} ${statName}**${needed === 0 ? " (target reached)" : ""}`, - ].join("\n") - ), - ], - }; -} - -function baseEmbed(player: Player, game: GameKey, level?: string) { - const titleParts = [player.displayName]; - if (level) titleParts.push(level); - - return new EmbedBuilder() - .author(titleParts.join(" ")) - .footer(GAME_NAMES.get(game)!) - .color(STATUS_COLORS.info); -} - -function getTargetStats(game: GameKey) { - if (statCache.has(game)) return statCache.get(game)!; - - const metadata = MetadataScanner.scan(getGameClass(game)); - const numberFields = metadata - .filter(([, { type }]) => type.type === Number) - .map(([key, { leaderboard }]) => ({ - key, - name: cleanName(leaderboard.fieldName || leaderboard.name || prettify(key)), - })); - - const byKey = new Map(numberFields.map((field) => [field.key, field])); - const ratioKeys = new Set(LEADERBOARD_RATIOS.map((ratio) => ratio[2])); - const ratios: TargetStat[] = []; - - for (const [numerator, denominator, ratioKey, prettyName] of LEADERBOARD_RATIOS) { - for (const field of numberFields) { - if (lastPathPart(field.key) !== ratioKey) continue; - - const parent = parentPath(field.key); - const numeratorKey = pathWithParent(parent, numerator); - const denominatorKey = pathWithParent(parent, denominator); - const numeratorField = byKey.get(numeratorKey); - const denominatorField = byKey.get(denominatorKey); - - if (!numeratorField || !denominatorField) continue; - - ratios.push({ - denominator: denominatorKey, - denominatorName: statNameLower(denominatorField.name), - key: field.key, - name: parent === "overall" || !parent ? prettyName : `${cleanName(parent)} ${prettyName}`, - numerator: numeratorKey, - numeratorName: statNameLower(numeratorField.name), - ratio: [numerator, denominator, ratioKey, prettyName], - type: "ratio", - }); - } - } - - const stats: TargetStat[] = [ - ...ratios, - ...numberFields - .filter((field) => !ratioKeys.has(lastPathPart(field.key))) - .map((field) => ({ ...field, type: "stat" as const })), - ]; - - statCache.set(game, stats); - return stats; -} - -function resolveTargetStat(game: GameKey, input: string) { - const stats = getTargetStats(game); - const normalized = input.toLowerCase(); - const exact = stats.find((stat) => stat.key === input); - if (exact) return exact; - - const overall = stats.find( - (stat) => - stat.key.toLowerCase() === `overall.${normalized}` || - stat.name.toLowerCase() === normalized - ); - if (overall) return overall; - - const fallback = stats.find( - (stat) => - lastPathPart(stat.key).toLowerCase() === normalized || - stat.name.toLowerCase().includes(normalized) - ); - - if (!fallback) { - throw new ErrorMessage( - "Target stat not found", - `I couldn't find \`${input}\` for ${GAME_NAMES.get(game)}. Use the stat autocomplete to pick a supported target.` - ); - } - - return fallback; -} - -function getLevel(gameStats: Record) { - const formatted = gameStats.levelFormatted || gameStats.naturalLevelFormatted; - if (typeof formatted === "string") return removeFormatting(formatted); - - const level = gameStats.level; - if (typeof level === "number") return `Level ${formatDecimal(level)}`; - - return undefined; -} - -function getNumber(data: Record, path: string) { - const value = path - .split(".") - .reduce((acc, key) => (acc as Record | undefined)?.[key], data); - - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -function cleanName(value: string) { - return removeFormatting(value) - .replace(/\s+/g, " ") - .trim(); -} - -function statNameLower(value: string) { - return cleanName(value).toLowerCase(); -} - -function singularize(value: string) { - return value.endsWith("s") ? value.slice(0, -1) : value; -} - -function formatDecimal(value: number) { - return value.toLocaleString("en-US", { - maximumFractionDigits: 2, - minimumFractionDigits: 2, - }); -} - -function formatInteger(value: number) { - return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); -} - -function formatTarget(value: number) { - return Number.isInteger(value) ? formatInteger(value) : formatDecimal(value); -} - -function lastPathPart(path: string) { - return path.split(".").at(-1)!; -} - -function parentPath(path: string) { - return path.split(".").slice(0, -1).join("."); -} - -function pathWithParent(parent: string, key: string) { - return parent ? `${parent}.${key}` : key; -} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 5e94dcc89..b66f3e884 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -14,9 +14,6 @@ "server": "A Minecraft server name or a server IP", "tags-content": "The content of the tag", "tags-name": "The name of the tag", - "target": "The goal you want to reach", - "target-setback": "How many denominator stats to include in the alternate calculation", - "target-stat": "The stat or ratio you want to target", "text": "A message", "user": "Choose a Discord user" }, @@ -34,7 +31,6 @@ "buildbattle": "$t(commands.hypixel-command, { \"name\": \"Build Battle\" })", "cape": "View someone's Minecraft and Optifine capes", "challenges": "$t(commands.hypixel-command, { \"name\": \"Challenge\" })", - "calculate": "$t(commands.target)", "quests-command": "View your {{name}} questing stats", "quests": "$t(commands.hypixel-command, { \"name\": \"Questing\" })", "quests-overall": "$t(commands.quests-command, { \"name\": \"Overall\" })", @@ -216,34 +212,6 @@ "tags-delete": "Delete a support tag", "tags-rename": "Rename a support tag", "text": "Generate Minecraft text", - "target": "Calculate what you need to reach a stat target", - "target-arcade": "$t(commands.target-command, { \"name\": \"Arcade\" })", - "target-arenabrawl": "$t(commands.target-command, { \"name\": \"Arena Brawl\" })", - "target-bedwars": "$t(commands.target-command, { \"name\": \"BedWars\" })", - "target-blitzsg": "$t(commands.target-command, { \"name\": \"BlitzSG\" })", - "target-buildbattle": "$t(commands.target-command, { \"name\": \"Build Battle\" })", - "target-command": "Calculate a {{name}} stat target", - "target-challenges": "$t(commands.target-command, { \"name\": \"Challenges\" })", - "target-copsandcrims": "$t(commands.target-command, { \"name\": \"Cops and Crims\" })", - "target-duels": "$t(commands.target-command, { \"name\": \"Duels\" })", - "target-general": "$t(commands.target-command, { \"name\": \"General\" })", - "target-megawalls": "$t(commands.target-command, { \"name\": \"MegaWalls\" })", - "target-murdermystery": "$t(commands.target-command, { \"name\": \"Murder Mystery\" })", - "target-paintball": "$t(commands.target-command, { \"name\": \"Paintball\" })", - "target-parkour": "$t(commands.target-command, { \"name\": \"Parkour\" })", - "target-pit": "$t(commands.target-command, { \"name\": \"Pit\" })", - "target-quake": "$t(commands.target-command, { \"name\": \"Quake\" })", - "target-quests": "$t(commands.target-command, { \"name\": \"Quests\" })", - "target-skywars": "$t(commands.target-command, { \"name\": \"SkyWars\" })", - "target-smashheroes": "$t(commands.target-command, { \"name\": \"Smash Heroes\" })", - "target-speeduhc": "$t(commands.target-command, { \"name\": \"Speed UHC\" })", - "target-tntgames": "$t(commands.target-command, { \"name\": \"TNT Games\" })", - "target-turbokartracers": "$t(commands.target-command, { \"name\": \"Turbo Kart Racers\" })", - "target-uhc": "$t(commands.target-command, { \"name\": \"UHC\" })", - "target-vampirez": "$t(commands.target-command, { \"name\": \"VampireZ\" })", - "target-walls": "$t(commands.target-command, { \"name\": \"Walls\" })", - "target-warlords": "$t(commands.target-command, { \"name\": \"Warlords\" })", - "target-woolgames": "$t(commands.target-command, { \"name\": \"WoolGames\" })", "theme": "Change your theme for every profile", "theme-boxes": "Change the appearance of the profile boxes", "theme-font": "Change the font of the profiles", From 427ad706c9adb14d7afef48c31384aca06b749f6 Mon Sep 17 00:00:00 2001 From: Cody Date: Sat, 30 May 2026 05:15:38 -0600 Subject: [PATCH 3/4] perf(api): bucket rate limit counters --- apps/api/src/auth/auth.service.test.ts | 37 ++++++ apps/api/src/auth/auth.service.ts | 154 +++++++++++++++++-------- apps/api/tsconfig.json | 5 +- apps/api/vitest.config.ts | 11 ++ 4 files changed, 158 insertions(+), 49 deletions(-) create mode 100644 apps/api/src/auth/auth.service.test.ts create mode 100644 apps/api/vitest.config.ts diff --git a/apps/api/src/auth/auth.service.test.ts b/apps/api/src/auth/auth.service.test.ts new file mode 100644 index 000000000..bdd000581 --- /dev/null +++ b/apps/api/src/auth/auth.service.test.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { describe, expect, it } from "vitest"; +import { getRateLimitBucketStats } from "./auth.service.js"; + +describe("AuthService", () => { + it("sums only rate limit buckets within the current window", () => { + const now = 120_500; + + expect( + getRateLimitBucketStats( + { + 60: "2", + 61: "3", + 120: "5", + }, + now + ) + ).toEqual({ + recentRequests: 8, + resetTime: 500, + }); + }); + + it("returns an empty rate limit state when no buckets are in the window", () => { + expect(getRateLimitBucketStats({ 1: "2" }, 120_500)).toEqual({ + recentRequests: 0, + resetTime: 0, + }); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 9ac0be742..db86e9745 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -20,56 +20,93 @@ import { Key } from "@statsify/schemas"; import { Redis } from "ioredis"; import { createHash, randomUUID } from "node:crypto"; -@Injectable() -export class AuthService { - private readonly logger = new Logger("AuthGuard"); +const RATE_LIMIT_WINDOW_SECONDS = 60; - public constructor(@InjectRedis() private readonly redis: Redis) {} +const RATE_LIMIT_SCRIPT = ` +local key = KEYS[1] +local rateLimitKey = KEYS[2] - public async limited(apiKey: string, weight: number, role: AuthRole) { - const hash = this.hash(apiKey); +local requiredRole = tonumber(ARGV[1]) +local weight = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local windowSeconds = tonumber(ARGV[4]) +local currentSecond = math.floor(now / 1000) +local windowStart = currentSecond - windowSeconds + 1 - const [name, ...keyInfo] = await this.redis.hmget( - `key:${hash}`, - "name", - "role", - "limit" - ); +local keyData = redis.call("HMGET", key, "name", "role", "limit") +local name = keyData[1] - if (name === null) throw new UnauthorizedException(); +if not name then + return { "unauthorized" } +end - const [apiKeyRole, apiKeyLimit] = keyInfo.map(Number); +local apiKeyRole = tonumber(keyData[2]) or 0 +local apiKeyLimit = tonumber(keyData[3]) or 0 - if (apiKeyRole < role) throw new ForbiddenException(); +if apiKeyRole < requiredRole then + return { "forbidden" } +end - const pipeline = this.redis.pipeline(); +local buckets = redis.call("HGETALL", rateLimitKey) +local weightedTotal = 0 +local oldestSecond = currentSecond - const time = Date.now(); - const expirey = 60_000; +for i = 1, #buckets, 2 do + local bucketSecond = tonumber(buckets[i]) + local bucketWeight = tonumber(buckets[i + 1]) - const key = `ratelimit:${hash}`; + if bucketSecond < windowStart then + redis.call("HDEL", rateLimitKey, buckets[i]) + else + weightedTotal = weightedTotal + bucketWeight - pipeline.zremrangebyscore(key, 0, time - expirey); - pipeline.zadd(key, time, `${randomUUID()}:${weight}`); - pipeline.zrange(key, 0, -1, "WITHSCORES"); - pipeline.hincrby(`key:${hash}`, "requests", weight); - pipeline.expire(key, expirey / 1000); + if bucketSecond < oldestSecond then + oldestSecond = bucketSecond + end + end +end - const pipelineResult = await pipeline.exec(); +weightedTotal = weightedTotal + weight - if (!pipelineResult) throw new InternalServerErrorException(); +redis.call("HINCRBY", rateLimitKey, currentSecond, weight) +redis.call("HINCRBY", key, "requests", weight) +redis.call("EXPIRE", rateLimitKey, windowSeconds) - const requests = pipelineResult[2]; +local resetTime = ((oldestSecond + windowSeconds) * 1000) - now - if (requests[0]) throw new InternalServerErrorException(); +return { "ok", name, apiKeyLimit, weightedTotal, resetTime } +`; - const weightedTotal = (requests[1] as string[]) - .filter((_, i) => i % 2 === 0) - .reduce((acc, key) => acc + Number(key.split(":")[1]), 0); +type RateLimitResult = + ["unauthorized"] | + ["forbidden"] | + ["ok", string, number, number, number]; + +@Injectable() +export class AuthService { + private readonly logger = new Logger("AuthGuard"); - const resetTime = 60_000 - (time - (requests[1] as [Error | null, number])[1]); + public constructor(@InjectRedis() private readonly redis: Redis) {} - if (weightedTotal > apiKeyLimit) { + public async limited(apiKey: string, weight: number, role: AuthRole) { + const hash = this.hash(apiKey); + const result = await this.redis.eval( + RATE_LIMIT_SCRIPT, + 2, + `key:${hash}`, + `ratelimit:v2:${hash}`, + role, + weight, + Date.now(), + RATE_LIMIT_WINDOW_SECONDS + ) as RateLimitResult; + + const [status, name, apiKeyLimit, weightedTotal, resetTime] = result; + + if (status === "unauthorized") throw new UnauthorizedException(); + if (status === "forbidden") throw new ForbiddenException(); + + if (Number(weightedTotal) > Number(apiKeyLimit)) { this.logger.warn( `${name} has exceeded their request limit of ${apiKeyLimit} and has requested ${weightedTotal} times` ); @@ -79,9 +116,9 @@ export class AuthService { return { canActivate: true, - used: weightedTotal, - limit: apiKeyLimit, - resetTime, + used: Number(weightedTotal), + limit: Number(apiKeyLimit), + resetTime: Number(resetTime), }; } @@ -106,11 +143,11 @@ export class AuthService { public async getKey(apiKey: string): Promise { const hash = this.hash(apiKey); - const key = `ratelimit:${hash}`; + const key = `ratelimit:v2:${hash}`; const pipeline = this.redis.pipeline(); pipeline.hmget(`key:${hash}`, "name", "requests", "limit"); - pipeline.zrange(key, 0, -1, "WITHSCORES"); + pipeline.hgetall(key); const pipelineResult = await pipeline.exec(); @@ -119,15 +156,11 @@ export class AuthService { const [keydata, requests] = pipelineResult; const [name, lifetimeRequests, limit] = keydata[1] as [string, number, number]; - - const recentRequests = (requests[1] as string[]) - .filter((_, i) => i % 2 === 0) - .reduce((acc, key) => acc + Number(key.split(":")[1]), 0); - - const time = Date.now(); - - const resetTime = - 60_000 - (time - Number((requests[1] as [Error | null, number])[1])); + const requestBuckets = requests[1] as Record; + const { recentRequests, resetTime } = getRateLimitBucketStats( + requestBuckets, + Date.now() + ); return { name, @@ -142,3 +175,30 @@ export class AuthService { return createHash("sha256").update(apiKey).digest("hex"); } } + +export function getRateLimitBucketStats(buckets: Record, now: number) { + const currentSecond = Math.floor(now / 1000); + const windowStart = currentSecond - RATE_LIMIT_WINDOW_SECONDS + 1; + + let oldestSecond = currentSecond; + let recentRequests = 0; + + for (const [bucket, weight] of Object.entries(buckets)) { + const bucketSecond = Number(bucket); + + if (bucketSecond < windowStart) continue; + + recentRequests += Number(weight); + + if (bucketSecond < oldestSecond) { + oldestSecond = bucketSecond; + } + } + + return { + recentRequests, + resetTime: recentRequests > 0 ? + (oldestSecond + RATE_LIMIT_WINDOW_SECONDS) * 1000 - now : + 0, + }; +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index f1e6b476d..06810fb21 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.json", "include": [ "src", - "eslint.config.js" + "eslint.config.js", + "vitest.config.ts" ] -} \ No newline at end of file +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 000000000..2f7603e59 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { config } from "../../vitest.shared.js"; + +export default await config(); From 1b567b61b40c47482cfe7a3c6dd25186a5d2a734 Mon Sep 17 00:00:00 2001 From: Cody Date: Mon, 1 Jun 2026 03:14:36 -0600 Subject: [PATCH 4/4] feat(auth): implement rate limit exception handling * add RateLimitException class to handle rate limit errors * update AuthGuard to set response headers on rate limit exceeded * modify auth service to throw RateLimitException when limit is exceeded --- apps/api/src/auth/auth.guard.ts | 15 +++++++++++++-- apps/api/src/auth/auth.service.ts | 21 ++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts index c85dbddb2..1c17a984e 100644 --- a/apps/api/src/auth/auth.guard.ts +++ b/apps/api/src/auth/auth.guard.ts @@ -7,7 +7,7 @@ */ import { AuthRole } from "./auth.role.js"; -import { AuthService } from "./auth.service.js"; +import { AuthService, RateLimitException } from "./auth.service.js"; import { CanActivate, ExecutionContext, @@ -45,7 +45,18 @@ export class AuthGuard implements CanActivate { if (!apiKey) throw new UnauthorizedException(); - const keyInfo = await this.authService.limited(apiKey, weight, role); + let keyInfo; + try { + keyInfo = await this.authService.limited(apiKey, weight, role); + } catch (err) { + if (err instanceof RateLimitException) { + context.switchToHttp().getResponse().headers({ + "retry-after": err.resetTime, + "x-ratelimit-timeout": err.resetTime, + }); + } + throw err; + } context.switchToHttp().getResponse().headers({ "x-ratelimit-used": keyInfo.used, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index db86e9745..326f4e8cc 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -15,6 +15,14 @@ import { Logger, UnauthorizedException, } from "@nestjs/common"; + +export class RateLimitException extends HttpException { + public readonly resetTime: number; + public constructor(resetTime: number) { + super("Too Many Requests", 429); + this.resetTime = resetTime; + } +} import { InjectRedis } from "#redis"; import { Key } from "@statsify/schemas"; import { Redis } from "ioredis"; @@ -66,15 +74,18 @@ for i = 1, #buckets, 2 do end end -weightedTotal = weightedTotal + weight +local projectedTotal = weightedTotal + weight +local resetTime = ((oldestSecond + windowSeconds) * 1000) - now + +if projectedTotal > apiKeyLimit then + return { "ok", name, apiKeyLimit, projectedTotal, resetTime } +end redis.call("HINCRBY", rateLimitKey, currentSecond, weight) redis.call("HINCRBY", key, "requests", weight) redis.call("EXPIRE", rateLimitKey, windowSeconds) -local resetTime = ((oldestSecond + windowSeconds) * 1000) - now - -return { "ok", name, apiKeyLimit, weightedTotal, resetTime } +return { "ok", name, apiKeyLimit, projectedTotal, resetTime } `; type RateLimitResult = @@ -111,7 +122,7 @@ export class AuthService { `${name} has exceeded their request limit of ${apiKeyLimit} and has requested ${weightedTotal} times` ); - throw new HttpException("Too Many Requests", 429); + throw new RateLimitException(Number(resetTime)); } return {