From bf33f8735d583b8cce2b55bc304360831e1599ea Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 13 Jul 2026 04:35:24 +0800 Subject: [PATCH] fix(api): raise FPS caps to 1000 and validate FPS before game lookup --- .../performance-import-validation.test.ts | 50 +++++++++++ apps/web/lib/api/performance-import.ts | 84 ++++++++++++------- apps/web/lib/api/performance-submit.ts | 16 ++-- 3 files changed, 113 insertions(+), 37 deletions(-) create mode 100644 apps/web/lib/api/__tests__/performance-import-validation.test.ts diff --git a/apps/web/lib/api/__tests__/performance-import-validation.test.ts b/apps/web/lib/api/__tests__/performance-import-validation.test.ts new file mode 100644 index 0000000..c8cf433 --- /dev/null +++ b/apps/web/lib/api/__tests__/performance-import-validation.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest" +import { validateFps } from "@/lib/api/performance-import" + +describe("validateFps", () => { + it("rejects missing/null fpsAvg", () => { + const r = validateFps({ fpsAvg: null as unknown as number }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatch(/fpsAvg/i) + }) + + it("rejects NaN fpsAvg", () => { + const r = validateFps({ fpsAvg: NaN }) + expect(r.ok).toBe(false) + }) + + it("rejects fpsAvg below 1", () => { + const r = validateFps({ fpsAvg: 0 }) + expect(r.ok).toBe(false) + }) + + it("accepts fpsAvg up to 1000", () => { + const r = validateFps({ fpsAvg: 1000, fpsHigh: 999 }) + expect(r.ok).toBe(true) + }) + + it("rejects fpsAvg above 1000", () => { + const r = validateFps({ fpsAvg: 1001 }) + expect(r.ok).toBe(false) + }) + + it("accepts fpsHigh of 750 (legit >500)", () => { + const r = validateFps({ fpsAvg: 120, fpsHigh: 750 }) + expect(r.ok).toBe(true) + }) + + it("accepts optional nulls for fpsLow/onePct/high", () => { + const r = validateFps({ fpsAvg: 60, fpsLow: null, fpsOnePercentLow: null, fpsHigh: null }) + expect(r.ok).toBe(true) + }) + + it("rejects negative fpsLow", () => { + const r = validateFps({ fpsAvg: 60, fpsLow: -1 }) + expect(r.ok).toBe(false) + }) + + it("rejects fpsHigh above 1000", () => { + const r = validateFps({ fpsAvg: 60, fpsHigh: 1200 }) + expect(r.ok).toBe(false) + }) +}) \ No newline at end of file diff --git a/apps/web/lib/api/performance-import.ts b/apps/web/lib/api/performance-import.ts index 10a71c9..107f2d1 100644 --- a/apps/web/lib/api/performance-import.ts +++ b/apps/web/lib/api/performance-import.ts @@ -19,6 +19,48 @@ type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number] type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number] type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[number] +const FPS_MIN_AVG = 1 +const FPS_MIN_OTHER = 0 +const FPS_MAX = 1000 + +export type FpsInput = { + fpsAvg: number + fpsLow?: number | null + fpsOnePercentLow?: number | null + fpsHigh?: number | null +} + +export type FpsValues = { + fpsAvg: number + fpsLow: number | null + fpsOnePercentLow: number | null + fpsHigh: number | null +} + +export type FpsValidationResult = + | { ok: true; values: FpsValues } + | { ok: false; error: string } + +export function validateFps(input: FpsInput): FpsValidationResult { + const fpsAvg = Number(input.fpsAvg) + if (input.fpsAvg == null || isNaN(fpsAvg) || fpsAvg < FPS_MIN_AVG || fpsAvg > FPS_MAX) { + return { ok: false, error: `fpsAvg must be between ${FPS_MIN_AVG} and ${FPS_MAX}` } + } + const fpsLow = input.fpsLow != null ? Number(input.fpsLow) : null + if (fpsLow !== null && (isNaN(fpsLow) || fpsLow < FPS_MIN_OTHER || fpsLow > FPS_MAX)) { + return { ok: false, error: `fpsLow must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` } + } + const fpsOnePercentLow = input.fpsOnePercentLow != null ? Number(input.fpsOnePercentLow) : null + if (fpsOnePercentLow !== null && (isNaN(fpsOnePercentLow) || fpsOnePercentLow < FPS_MIN_OTHER || fpsOnePercentLow > FPS_MAX)) { + return { ok: false, error: `fpsOnePercentLow must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` } + } + const fpsHigh = input.fpsHigh != null ? Number(input.fpsHigh) : null + if (fpsHigh !== null && (isNaN(fpsHigh) || fpsHigh < FPS_MIN_OTHER || fpsHigh > FPS_MAX)) { + return { ok: false, error: `fpsHigh must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` } + } + return { ok: true, values: { fpsAvg, fpsLow, fpsOnePercentLow, fpsHigh } } +} + export const performanceImportRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] }, @@ -38,6 +80,19 @@ export const performanceImportRoutes = new Elysia({ return { error: "Unsupported import format version" } } + // ── Validate FPS fields (before game lookup so a 400 is never masked by a 404) ── + const fpsResult = validateFps({ + fpsAvg: body.fpsAvg, + fpsLow: body.fpsLow, + fpsOnePercentLow: body.fpsOnePercentLow, + fpsHigh: body.fpsHigh, + }) + if (!fpsResult.ok) { + set.status = 400 + return { error: fpsResult.error } + } + const { fpsAvg, fpsLow, fpsOnePercentLow, fpsHigh } = fpsResult.values + // ── Resolve game version from steamAppId ────────────────────── const steamAppId = body.steamAppId if (!steamAppId) { @@ -111,35 +166,6 @@ export const performanceImportRoutes = new Elysia({ } } - // ── Validate FPS fields ─────────────────────────────────────── - const fpsAvg = Number(body.fpsAvg) - if (isNaN(fpsAvg) || fpsAvg < 1 || fpsAvg > 500) { - set.status = 400 - return { error: "fpsAvg must be between 1 and 500" } - } - - const fpsLow = body.fpsLow != null ? Number(body.fpsLow) : null - if (fpsLow !== null && (isNaN(fpsLow) || fpsLow < 0 || fpsLow > 500)) { - set.status = 400 - return { error: "fpsLow must be between 0 and 500" } - } - - const fpsOnePercentLow = - body.fpsOnePercentLow != null ? Number(body.fpsOnePercentLow) : null - if ( - fpsOnePercentLow !== null && - (isNaN(fpsOnePercentLow) || fpsOnePercentLow < 0 || fpsOnePercentLow > 500) - ) { - set.status = 400 - return { error: "fpsOnePercentLow must be between 0 and 500" } - } - - const fpsHigh = body.fpsHigh != null ? Number(body.fpsHigh) : null - if (fpsHigh !== null && (isNaN(fpsHigh) || fpsHigh < 0 || fpsHigh > 500)) { - set.status = 400 - return { error: "fpsHigh must be between 0 and 500" } - } - // ── Validate enums ──────────────────────────────────────────── const rawUpscalerType = body.upscalerType ?? "none" const upscalerType: UpscalerType = VALID_UPSCALER_TYPES.includes( diff --git a/apps/web/lib/api/performance-submit.ts b/apps/web/lib/api/performance-submit.ts index 57176c2..a6370f6 100644 --- a/apps/web/lib/api/performance-submit.ts +++ b/apps/web/lib/api/performance-submit.ts @@ -146,23 +146,23 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", deta } // ── Validation: fpsAvg bounds ─────────────────────────────── - if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) { + if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 1000) { set.status = 400 - return { error: "fpsAvg must be between 1 and 500" } + return { error: "fpsAvg must be between 1 and 1000" } } // ── Validation: optional FPS bounds ───────────────────────── - if (fpsLow !== null && (fpsLow < 0 || fpsLow > 500)) { + if (fpsLow !== null && (fpsLow < 0 || fpsLow > 1000)) { set.status = 400 - return { error: "fpsLow must be between 0 and 500" } + return { error: "fpsLow must be between 0 and 1000" } } - if (fpsHigh !== null && (fpsHigh < 0 || fpsHigh > 500)) { + if (fpsHigh !== null && (fpsHigh < 0 || fpsHigh > 1000)) { set.status = 400 - return { error: "fpsHigh must be between 0 and 500" } + return { error: "fpsHigh must be between 0 and 1000" } } - if (fpsOnePercentLow !== null && (fpsOnePercentLow < 0 || fpsOnePercentLow > 500)) { + if (fpsOnePercentLow !== null && (fpsOnePercentLow < 0 || fpsOnePercentLow > 1000)) { set.status = 400 - return { error: "fpsOnePercentLow must be between 0 and 500" } + return { error: "fpsOnePercentLow must be between 0 and 1000" } } // ── Validation: settingsJson size limits ────────────────────