diff --git a/app/src/app/api/og/route.ts b/app/src/app/api/og/route.ts index 06b5601..dc503ff 100644 --- a/app/src/app/api/og/route.ts +++ b/app/src/app/api/og/route.ts @@ -2,11 +2,11 @@ import { type NextRequest } from "next/server"; import { isValidId } from "@pixabots/core"; import { generateOgImage } from "@/lib/og-image"; import { CORS_HEADERS, optionsResponse, imageResponse } from "@/lib/api"; -import { checkRate, clientKey } from "@/lib/rate-limit"; +import { checkRate, clientKey, isSameOrigin } from "@/lib/rate-limit"; import { parseIdsCsv } from "@/lib/ids"; import { normalizeHex } from "@/lib/palette"; -const OG_LIMIT = 20; +const OG_LIMIT = 60; const OG_WINDOW_MS = 60_000; const MAX_TITLE_LEN = 60; @@ -40,7 +40,9 @@ function parsePalette( } export async function GET(request: NextRequest) { - const rate = checkRate(`og:${clientKey(request)}`, OG_LIMIT, OG_WINDOW_MS); + const rate = isSameOrigin(request) + ? { allowed: true, remaining: OG_LIMIT, resetSeconds: 60, limit: OG_LIMIT } + : checkRate(`og:${clientKey(request)}`, OG_LIMIT, OG_WINDOW_MS); if (!rate.allowed) { return Response.json( { error: `Rate limit exceeded. Try again in ${rate.resetSeconds}s.` }, diff --git a/app/src/app/api/pixabot/[id]/route.ts b/app/src/app/api/pixabot/[id]/route.ts index 4d5cdaa..506aeb2 100644 --- a/app/src/app/api/pixabot/[id]/route.ts +++ b/app/src/app/api/pixabot/[id]/route.ts @@ -17,12 +17,13 @@ import { MAX_SPEED, DETERMINISTIC_CACHE, } from "@/lib/api"; -import { checkRate, clientKey } from "@/lib/rate-limit"; +import { checkRate, clientKey, isSameOrigin } from "@/lib/rate-limit"; import { normalizeHex } from "@/lib/palette"; // Per-IP rate limit for animated renders (GIF / WebP are 5–50× costlier -// than PNG). Limits per minute per lambda instance. -const ANIMATED_LIMIT = 30; +// than PNG). Limits per minute per lambda instance. Same-origin traffic +// (our own /browse grid, etc.) bypasses this — see isSameOrigin below. +const ANIMATED_LIMIT = 120; const ANIMATED_WINDOW_MS = 60_000; export const OPTIONS = optionsResponse; @@ -133,7 +134,9 @@ export async function GET( }); } if (animated) { - const rate = checkRate(`animated:${clientKey(request)}`, ANIMATED_LIMIT, ANIMATED_WINDOW_MS); + const rate = isSameOrigin(request) + ? { allowed: true, remaining: ANIMATED_LIMIT, resetSeconds: 60, limit: ANIMATED_LIMIT } + : checkRate(`animated:${clientKey(request)}`, ANIMATED_LIMIT, ANIMATED_WINDOW_MS); if (!rate.allowed) { return Response.json( { error: `Rate limit exceeded. Try again in ${rate.resetSeconds}s.` }, diff --git a/app/src/lib/rate-limit.ts b/app/src/lib/rate-limit.ts index 65a3535..201cd4d 100644 --- a/app/src/lib/rate-limit.ts +++ b/app/src/lib/rate-limit.ts @@ -72,3 +72,17 @@ export function clientKey(request: Request): string { if (xff) return xff.split(",")[0].trim(); return request.headers.get("x-real-ip") ?? "unknown"; } + +/** + * True when the request came from our own pages (the browse grid, the bot + * detail page, etc.). Used to exempt UI traffic from the animated-render + * rate limit — /browse alone renders ~60 GIFs on first paint, which would + * blow past any sane per-IP cap. Sec-Fetch-Site is modern-browser-native + * (Chrome / Firefox / Safari) and cannot be forged by script from another + * origin, so it's a reasonable guard for "this is our UI, not abuse." + * External consumers (curl, servers, other sites hotlinking) get + * `cross-site` or no header → still rate-limited. + */ +export function isSameOrigin(request: Request): boolean { + return request.headers.get("sec-fetch-site") === "same-origin"; +}