diff --git a/lib/api/__tests__/rate-limit-removal.test.ts b/lib/api/__tests__/rate-limit-removal.test.ts new file mode 100644 index 0000000..1894d43 --- /dev/null +++ b/lib/api/__tests__/rate-limit-removal.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest" +import fs from "node:fs" +import path from "node:path" + +describe("rate limiting removal", () => { + const cwd = process.cwd() + + it("lib/auth/rate-limit.ts no longer exports the rateLimit function", () => { + const filePath = path.join(cwd, "lib", "auth", "rate-limit.ts") + expect(fs.existsSync(filePath)).toBe(false) + }) + + it("lib/api/app.ts no longer imports or uses rateLimit", () => { + const content = fs.readFileSync( + path.join(cwd, "lib", "api", "app.ts"), + "utf8", + ) + expect(content).not.toContain('import { rateLimit }') + expect(content).not.toContain('.use(rateLimit(') + }) + + it("lib/auth.ts has Better Auth rateLimit disabled", () => { + const content = fs.readFileSync( + path.join(cwd, "lib", "auth.ts"), + "utf8", + ) + const hasRateLimitBlock = content.includes("rateLimit:") + if (hasRateLimitBlock) { + expect(content).toContain("enabled: false") + } + }) + + it("lib/api/contact.ts no longer calls checkContactRateLimit", () => { + const content = fs.readFileSync( + path.join(cwd, "lib", "api", "contact.ts"), + "utf8", + ) + expect(content).not.toContain("checkContactRateLimit") + }) + + it("lib/api/comments.ts no longer enforces hourly cap", () => { + const content = fs.readFileSync( + path.join(cwd, "lib", "api", "comments.ts"), + "utf8", + ) + expect(content).not.toContain("hourly limit") + expect(content).not.toContain('"Duplicate comment detected') + }) + + it("lib/api/performance-submit.ts no longer enforces submission cooldown", () => { + const content = fs.readFileSync( + path.join(cwd, "lib", "api", "performance-submit.ts"), + "utf8", + ) + expect(content).not.toContain("Submission cooldown") + expect(content).not.toContain("sixtySecondsAgo") + expect(content).not.toContain("Please wait before submitting") + }) +}) \ No newline at end of file diff --git a/lib/api/app.ts b/lib/api/app.ts index ead20c5..ccc9938 100644 --- a/lib/api/app.ts +++ b/lib/api/app.ts @@ -2,7 +2,6 @@ import { Elysia, t } from "elysia" import { openapi } from "@elysia/openapi" import { cron, Patterns } from "@elysia/cron" import { auth } from "@/lib/auth" -import { rateLimit } from "@/lib/auth/rate-limit" import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "@/lib/auth/domain-block" import { taskRegistry } from "./cron" import { @@ -154,10 +153,9 @@ export const app = new Elysia({ prefix: "/api" }) error: code === "NOT_FOUND" ? "Not found" : "Internal server error", } }) - // ── Auth routes (auth rate limit + betterAuth) ────────────── + // ── Auth routes ───────────────────────────────────────────── .group("", (app) => app - .use(rateLimit("auth")) .onBeforeHandle(async ({ request, set }) => { const url = new URL(request.url) const isSignUp = @@ -214,10 +212,9 @@ export const app = new Elysia({ prefix: "/api" }) }, ) ) - // ── Read-heavy public routes (read rate limit) ─────────────── + // ── Read-heavy public routes ────────────────────────────── .group("", (app) => app - .use(rateLimit("read")) .use(healthRoutes) .use(gamesRoutes) .use(gameVersionsRoutes) @@ -246,10 +243,9 @@ export const app = new Elysia({ prefix: "/api" }) .use(screenshotRoutes) .use(mobileRoutes) ) - // ── Write routes (write rate limit + betterAuth) ───────────── + // ── Write routes ─────────────────────────────────────────── .group("", (app) => app - .use(rateLimit("write")) .use(betterAuth) .use(clientVersionRoutes) .use(performanceVerifyRoutes) @@ -262,17 +258,15 @@ export const app = new Elysia({ prefix: "/api" }) .use(adminStorageRoutes) .use(adminAnalyticsRoutes) ) - // ── Strict rate limit (public forms, no auth) ─────────────── + // ── Public forms (no auth) ───────────────────────────────── .group("", (app) => app - .use(rateLimit("strict")) .use(contactRoutes) .use(communitySuggestionRoutes) ) - // ── Cron (no rate limit) ───────────────────────────────────── + // ── Cron ───────────────────────────────────────────────────── .use(cronRoutes) - // ── Root (default rate limit) ──────────────────────────────── - .use(rateLimit("default")) + // ── Root ──────────────────────────────────────────────────── .get("/", () => ({ name: "DeckyVault API", version: "2026.2.2", diff --git a/lib/api/comments.ts b/lib/api/comments.ts index ef3a81a..ffac22f 100644 --- a/lib/api/comments.ts +++ b/lib/api/comments.ts @@ -119,52 +119,6 @@ export const commentsRoutes = new Elysia({ return { error: "Comment content exceeds maximum size (50KB)" } } - // ── Anti-spam: duplicate detection ───────────────────────── - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000) - const [duplicate] = await db - .select({ id: gameComments.id }) - .from(gameComments) - .where( - and( - eq(gameComments.gameId, params.gameId), - eq(gameComments.userId, guard.user.id), - eq(gameComments.isRemoved, false), - sql`${gameComments.createdAt} >= ${fiveMinutesAgo}`, - ), - ) - .limit(1) - - if (duplicate) { - const [recent] = await db - .select({ content: gameComments.content }) - .from(gameComments) - .where(eq(gameComments.id, duplicate.id)) - .limit(1) - - if (recent && JSON.stringify(recent.content) === contentStr) { - set.status = 409 - return { error: "Duplicate comment detected — you posted identical content in the last 5 minutes" } - } - } - - // ── Anti-spam: per-user hourly cap ───────────────────────── - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) - const [{ count: recentCount }] = await db - .select({ count: sql`count(*)::int` }) - .from(gameComments) - .where( - and( - eq(gameComments.userId, guard.user.id), - eq(gameComments.isRemoved, false), - sql`${gameComments.createdAt} >= ${oneHourAgo}`, - ), - ) - - if (recentCount >= 30) { - set.status = 429 - return { error: "Too many comments — you've reached the hourly limit of 30" } - } - // If parentId is provided, verify it exists and belongs to the same game if (body.parentId) { const [parent] = await db diff --git a/lib/api/contact.ts b/lib/api/contact.ts index 9ddf494..8fe4ad8 100644 --- a/lib/api/contact.ts +++ b/lib/api/contact.ts @@ -1,42 +1,5 @@ import { Elysia, t } from "elysia" -// ── In-memory rate limiter for contact form ────────────────────── -const contactLimiter = new Map() -const RATE_LIMIT_MAX = 3 -const RATE_LIMIT_WINDOW = 60 * 60 * 1000 // 1 hour in ms - -// Clean up expired entries every 5 minutes -setInterval(() => { - const now = Date.now() - for (const [key, entry] of contactLimiter) { - if (now > entry.resetAt) contactLimiter.delete(key) - } -}, 5 * 60 * 1000) - -function getClientIP(request: Request): string { - const forwarded = request.headers.get("x-forwarded-for") - if (forwarded) return forwarded.split(",")[0].trim() - return "unknown" -} - -function checkContactRateLimit(ip: string): { allowed: boolean; retryAfter: number } { - const now = Date.now() - const entry = contactLimiter.get(ip) - - if (!entry || now > entry.resetAt) { - contactLimiter.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }) - return { allowed: true, retryAfter: 0 } - } - - if (entry.count >= RATE_LIMIT_MAX) { - const retryAfter = Math.ceil((entry.resetAt - now) / 1000) - return { allowed: false, retryAfter } - } - - entry.count++ - return { allowed: true, retryAfter: 0 } -} - // ── Discord embed colors by category ───────────────────────────── const CATEGORY_COLORS: Record = { bug: 0xe74c3c, // red @@ -87,14 +50,6 @@ export const contactRoutes = new Elysia({ prefix: "/contact", detail: { tags: [" } } - // ── Rate limit ────────────────────────────────────────────── - const ip = getClientIP(request) - const rateCheck = checkContactRateLimit(ip) - if (!rateCheck.allowed) { - set.status = 429 - return { error: "Too many submissions. Please try again later.", retryAfter: rateCheck.retryAfter } - } - // ── Validate category ────────────────────────────────────── if (!VALID_CATEGORIES.includes(payload.category)) { set.status = 400 @@ -149,7 +104,7 @@ export const contactRoutes = new Elysia({ prefix: "/contact", detail: { tags: [" ...(payload.name ? [{ name: "Name", value: payload.name, inline: true }] : []), ...(payload.email ? [{ name: "Email", value: payload.email, inline: true }] : []), ...(payload.gameUrl ? [{ name: "Game URL", value: payload.gameUrl, inline: false }] : []), - { name: "IP Hash", value: `\`${ip.slice(0, 8)}...\``, inline: true }, + { name: "IP Hash", value: `\`${(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown").slice(0, 8)}...\``, inline: true }, ], timestamp: new Date().toISOString(), } diff --git a/lib/api/performance-submit.ts b/lib/api/performance-submit.ts index 8895697..57176c2 100644 --- a/lib/api/performance-submit.ts +++ b/lib/api/performance-submit.ts @@ -145,31 +145,6 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", deta return { error: "Missing required fields: versionId, hardwareSlug, fpsAvg" } } - // ── Submission cooldown: 60 seconds between entries per user ── - const sixtySecondsAgo = new Date(Date.now() - 60 * 1000) - const [lastEntry] = await db - .select({ createdAt: performanceEntries.createdAt }) - .from(performanceEntries) - .where( - and( - eq(performanceEntries.userId, guard.user.id), - sql`${performanceEntries.createdAt} >= ${sixtySecondsAgo}`, - ), - ) - .orderBy(sql`${performanceEntries.createdAt} DESC`) - .limit(1) - - if (lastEntry) { - const retryAfter = Math.ceil( - (lastEntry.createdAt.getTime() + 60_000 - Date.now()) / 1000 - ) - set.status = 429 - return { - error: "Please wait before submitting another benchmark", - retryAfter: Math.max(1, retryAfter), - } - } - // ── Validation: fpsAvg bounds ─────────────────────────────── if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) { set.status = 400 diff --git a/lib/auth.ts b/lib/auth.ts index 4e2635c..0a35e4a 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -82,9 +82,7 @@ export const auth = betterAuth({ }, trustedOrigins, rateLimit: { - enabled: true, - window: 60, - max: 100 + enabled: false, }, session: { expiresIn: 60 * 60 * 24 * 7, diff --git a/lib/auth/__tests__/rate-limit.test.ts b/lib/auth/__tests__/rate-limit.test.ts deleted file mode 100644 index 9b0f7ca..0000000 --- a/lib/auth/__tests__/rate-limit.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from "vitest" -import { rateLimit } from "../rate-limit" -import { Elysia } from "elysia" - -describe("rateLimit plugin", () => { - it("creates an Elysia plugin instance", () => { - const plugin = rateLimit("default") - expect(plugin).toBeInstanceOf(Elysia) - }) - - it("sets rate limit headers on allowed requests", async () => { - const app = new Elysia() - .use(rateLimit("default")) - .get("/test", () => "ok") - - const response = await app.handle( - new Request("http://localhost/test") - ) - - expect(response.status).toBe(200) - expect(response.headers.get("X-RateLimit-Limit")).toBeTruthy() - expect(response.headers.get("X-RateLimit-Remaining")).toBeTruthy() - expect(response.headers.get("X-RateLimit-Reset")).toBeTruthy() - }) - - it("allows requests within limit", async () => { - const app = new Elysia() - .use(rateLimit("default")) - .get("/test", () => "ok") - - // Make a reasonable number of requests that should all succeed - for (let i = 0; i < 5; i++) { - const response = await app.handle( - new Request("http://localhost/test") - ) - expect(response.status).toBe(200) - } - }) - - it("different categories have independent limits", async () => { - const app = new Elysia() - .use(rateLimit("default")) - .get("/test", () => "ok") - - // Just verify it works at all — actual limit enforcement depends on the config - const response = await app.handle(new Request("http://localhost/test")) - expect(response.status).toBe(200) - }) - - it("returns Retry-After header on rate limit", async () => { - const app = new Elysia() - .use(rateLimit("strict")) - .get("/test", () => "ok") - - // Make requests until the remaining goes to 0, then check Retry-After - for (let i = 0; i < 10; i++) { - const response = await app.handle(new Request("http://localhost/test")) - if (response.status === 429) { - const retryAfter = response.headers.get("Retry-After") - expect(retryAfter).toBeTruthy() - expect(Number(retryAfter)).toBeGreaterThan(0) - break - } - } - }) -}) \ No newline at end of file diff --git a/lib/auth/rate-limit.ts b/lib/auth/rate-limit.ts deleted file mode 100644 index 301086f..0000000 --- a/lib/auth/rate-limit.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Elysia } from "elysia" - -type RateLimitEntry = { - count: number - resetAt: number -} - -// NOTE: This is an in-memory rate limiter for development/single-instance -// deployments. For production with multiple instances or serverless, use a -// shared store like Redis or Upstash. -const store = new Map() - -// Clean up expired entries every 60 seconds -setInterval(() => { - const now = Date.now() - for (const [key, entry] of store) { - if (now > entry.resetAt) { - store.delete(key) - } - } -}, 60_000) - -function getClientIP(request: Request): string { - const forwarded = request.headers.get("x-forwarded-for") - if (forwarded) { - return forwarded.split(",")[0].trim() - } - return "unknown" -} - -function checkRateLimit( - key: string, - window: number, - max: number, -): { allowed: boolean; remaining: number; resetAt: number } { - const now = Date.now() - const entry = store.get(key) - - if (!entry || now > entry.resetAt) { - const resetAt = now + window * 1000 - store.set(key, { count: 1, resetAt }) - return { allowed: true, remaining: max - 1, resetAt } - } - - if (entry.count >= max) { - return { allowed: false, remaining: 0, resetAt: entry.resetAt } - } - - entry.count++ - return { allowed: true, remaining: max - entry.count, resetAt: entry.resetAt } -} - -const CATEGORY_LIMITS: Record = { - default: { window: 60, max: 100 }, - auth: { window: 60, max: 20 }, - read: { window: 60, max: 300 }, - write: { window: 60, max: 10 }, - strict: { window: 60, max: 5 }, -} - -export const rateLimit = ( - category: string = "default", -) => { - const { window, max } = CATEGORY_LIMITS[category] ?? CATEGORY_LIMITS.default - - return new Elysia({ name: `rate-limit-${category}` }).onRequest(({ request, set }) => { - const ip = getClientIP(request) - const path = new URL(request.url).pathname - const key = `${category}:${ip}:${path}` - - const result = checkRateLimit(key, window, max) - - if (!result.allowed) { - const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000) - set.status = 429 - set.headers["Retry-After"] = String(retryAfter) - return { - error: "Too many requests", - retryAfter, - } - } - - // These headers are informational — clients can use them to throttle - set.headers["X-RateLimit-Limit"] = String(max) - set.headers["X-RateLimit-Remaining"] = String(result.remaining) - set.headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000)) - }) -}