From ea833a3c2a17804aca6c7698d0aa022ad775cc9f Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 23 Dec 2025 10:40:32 -0800 Subject: [PATCH] rate limit --- .github/workflows/deploy.yml | 1 + .github/workflows/release.yml | 4 +++ deploy.sh | 1 + example.env | 1 + src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 3 ++ src/server/{Archive.ts => Api.ts} | 48 ++++++++++++++++++++++--- src/server/{jwt.ts => Auth.ts} | 45 ++--------------------- src/server/GameServer.ts | 2 +- src/server/Worker.ts | 4 +-- 10 files changed, 59 insertions(+), 51 deletions(-) rename src/server/{Archive.ts => Api.ts} (61%) rename src/server/{jwt.ts => Auth.ts} (59%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ddd5fdc78..9a4a30135 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -108,6 +108,7 @@ jobs: ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }} CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_RATE_LIMIT_BYPASS_TOKEN: ${{ secrets.CF_RATE_LIMIT_BYPASS_TOKEN }} DOCKER_REPO: ${{ vars.DOCKERHUB_REPO }} DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} ENV: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a65e7b8f..7591acfd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,7 @@ jobs: ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }} CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_RATE_LIMIT_BYPASS_TOKEN: ${{ secrets.CF_RATE_LIMIT_BYPASS_TOKEN }} DOCKER_REPO: openfront-prod DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} @@ -124,6 +125,7 @@ jobs: ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }} CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_RATE_LIMIT_BYPASS_TOKEN: ${{ secrets.CF_RATE_LIMIT_BYPASS_TOKEN }} DOCKER_REPO: ${{ vars.DOCKERHUB_REPO }} DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} @@ -182,6 +184,7 @@ jobs: ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }} CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_RATE_LIMIT_BYPASS_TOKEN: ${{ secrets.CF_RATE_LIMIT_BYPASS_TOKEN }} DOCKER_REPO: ${{ vars.DOCKERHUB_REPO }} DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} @@ -240,6 +243,7 @@ jobs: ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }} CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_RATE_LIMIT_BYPASS_TOKEN: ${{ secrets.CF_RATE_LIMIT_BYPASS_TOKEN }} DOCKER_REPO: ${{ vars.DOCKERHUB_REPO }} DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} diff --git a/deploy.sh b/deploy.sh index cc5b0ac35..4be5bae6f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -176,6 +176,7 @@ R2_ACCESS_KEY=$R2_ACCESS_KEY R2_SECRET_KEY=$R2_SECRET_KEY R2_BUCKET=$R2_BUCKET CF_API_TOKEN=$CF_API_TOKEN +CF_RATE_LIMIT_BYPASS_TOKEN=$CF_RATE_LIMIT_BYPASS_TOKEN TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY API_KEY=$API_KEY DOMAIN=$DOMAIN diff --git a/example.env b/example.env index 4135026cf..8cd211cc2 100644 --- a/example.env +++ b/example.env @@ -12,6 +12,7 @@ ADMIN_TOKEN=your_admin_token_here # Cloudflare Configuration CF_ACCOUNT_ID=your_cloudflare_account_id CF_API_TOKEN=your_cloudflare_api_token +CF_RATE_LIMIT_BYPASS_TOKEN=your_rate_limit_bypass_token DOMAIN=your-domain.com # R2 Configuration diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index a03d85e5a..1102d7880 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -61,6 +61,7 @@ export interface ServerConfig { subdomain(): string; cloudflareAccountId(): string; cloudflareApiToken(): string; + cloudflareRateLimitBypassToken(): string; cloudflareConfigPath(): string; cloudflareCredsPath(): string; stripePublishableKey(): string; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 04589128d..61582e7c7 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -108,6 +108,9 @@ export abstract class DefaultServerConfig implements ServerConfig { cloudflareApiToken(): string { return process.env.CF_API_TOKEN ?? ""; } + cloudflareRateLimitBypassToken(): string { + return process.env.CF_RATE_LIMIT_BYPASS_TOKEN ?? ""; + } cloudflareConfigPath(): string { return process.env.CF_CONFIG_PATH ?? ""; } diff --git a/src/server/Archive.ts b/src/server/Api.ts similarity index 61% rename from src/server/Archive.ts rename to src/server/Api.ts index b540f9c8d..78bed0661 100644 --- a/src/server/Archive.ts +++ b/src/server/Api.ts @@ -1,4 +1,6 @@ -import z from "zod"; +import { z } from "zod/v4/classic/external.cjs"; +import { UserMeResponse, UserMeResponseSchema } from "../core/ApiSchemas"; +import { ServerConfig } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameID, @@ -11,9 +13,45 @@ import { replacer } from "../core/Util"; import { logger } from "./Logger"; const config = getServerConfigFromServer(); +const log = logger.child({ component: "Api" }); -const log = logger.child({ component: "Archive" }); - +export async function getUserMe( + token: string, + config: ServerConfig, +): Promise< + | { type: "success"; response: UserMeResponse } + | { type: "error"; message: string } +> { + try { + // Get the user object + const response = await fetch(config.jwtIssuer() + "/users/@me", { + headers: { + authorization: `Bearer ${token}`, + "x-service-bypass": config.cloudflareRateLimitBypassToken(), + }, + }); + if (response.status !== 200) { + return { + type: "error", + message: `Failed to fetch user me: ${response.statusText}`, + }; + } + const body = await response.json(); + const result = UserMeResponseSchema.safeParse(body); + if (!result.success) { + return { + type: "error", + message: `Invalid response: ${z.prettifyError(result.error)}`, + }; + } + return { type: "success", response: result.data }; + } catch (e) { + return { + type: "error", + message: `Failed to fetch user me: ${e}`, + }; + } +} export async function archive(gameRecord: GameRecord) { try { const parsed = GameRecordSchema.safeParse(gameRecord); @@ -30,6 +68,7 @@ export async function archive(gameRecord: GameRecord) { headers: { "Content-Type": "application/json", "x-api-key": config.apiKey(), + "x-service-bypass": config.cloudflareRateLimitBypassToken(), }, }); if (!response.ok) { @@ -45,7 +84,6 @@ export async function archive(gameRecord: GameRecord) { return; } } - export async function readGameRecord( gameId: GameID, ): Promise { @@ -59,6 +97,7 @@ export async function readGameRecord( method: "GET", headers: { "Content-Type": "application/json", + "x-service-bypass": config.cloudflareRateLimitBypassToken(), }, }); const record = await response.json(); @@ -76,7 +115,6 @@ export async function readGameRecord( return null; } } - export function finalizeGameRecord( clientRecord: PartialGameRecord, ): GameRecord { diff --git a/src/server/jwt.ts b/src/server/Auth.ts similarity index 59% rename from src/server/jwt.ts rename to src/server/Auth.ts index e4a09b012..ee3007b30 100644 --- a/src/server/jwt.ts +++ b/src/server/Auth.ts @@ -1,12 +1,8 @@ import { jwtVerify } from "jose"; import { z } from "zod"; -import { - TokenPayload, - TokenPayloadSchema, - UserMeResponse, - UserMeResponseSchema, -} from "../core/ApiSchemas"; +import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; import { GameEnv, ServerConfig } from "../core/configuration/Config"; +import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { PersistentIdSchema } from "../core/Schemas"; type TokenVerificationResult = @@ -61,40 +57,3 @@ export async function verifyClientToken( return { type: "error", message }; } } - -export async function getUserMe( - token: string, - config: ServerConfig, -): Promise< - | { type: "success"; response: UserMeResponse } - | { type: "error"; message: string } -> { - try { - // Get the user object - const response = await fetch(config.jwtIssuer() + "/users/@me", { - headers: { - authorization: `Bearer ${token}`, - }, - }); - if (response.status !== 200) { - return { - type: "error", - message: `Failed to fetch user me: ${response.statusText}`, - }; - } - const body = await response.json(); - const result = UserMeResponseSchema.safeParse(body); - if (!result.success) { - return { - type: "error", - message: `Invalid response: ${z.prettifyError(result.error)}`, - }; - } - return { type: "success", response: result.data }; - } catch (e) { - return { - type: "error", - message: `Failed to fetch user me: ${e}`, - }; - } -} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index fa3ddf50f..df5d54907 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -23,7 +23,7 @@ import { Turn, } from "../core/Schemas"; import { createPartialGameRecord, getClanTag } from "../core/Util"; -import { archive, finalizeGameRecord } from "./Archive"; +import { archive, finalizeGameRecord } from "./Api"; import { Client } from "./Client"; export enum GamePhase { Lobby = "LOBBY", diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 50ba20b90..4ed577550 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -18,10 +18,10 @@ import { } from "../core/Schemas"; import { generateID, replacer } from "../core/Util"; import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; -import { archive, finalizeGameRecord } from "./Archive"; +import { archive, finalizeGameRecord, getUserMe } from "./Api"; +import { verifyClientToken } from "./Auth"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; -import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; import { GameEnv } from "../core/configuration/Config";