diff --git a/apps/explorer/src/index.server.ts b/apps/explorer/src/index.server.ts index b362a49b2..182182f3a 100644 --- a/apps/explorer/src/index.server.ts +++ b/apps/explorer/src/index.server.ts @@ -1,8 +1,5 @@ import * as Sentry from '@sentry/cloudflare' import handler, { createServerEntry } from '@tanstack/react-start/server-entry' -import { ExplorerExportRateLimit } from '#lib/server/export-rate-limit' - -export { ExplorerExportRateLimit } export const redirects: Array<{ from: RegExp diff --git a/apps/explorer/src/lib/server/export-rate-limit.ts b/apps/explorer/src/lib/server/export-rate-limit.ts index 2e0fda861..b40e3334c 100644 --- a/apps/explorer/src/lib/server/export-rate-limit.ts +++ b/apps/explorer/src/lib/server/export-rate-limit.ts @@ -1,28 +1,13 @@ -import { DurableObject, env } from 'cloudflare:workers' -import type { Address } from 'ox' +import { env } from 'cloudflare:workers' import { getRequestHeader } from '@tanstack/react-start/server' -const RATE_LIMIT_WINDOW_MS = 10 * 60_000 -const RATE_LIMITS = { - ip: 20, - wallet: 5, -} as const - -type RateLimitBucket = { - count: number - resetAt: number -} +const RATE_LIMIT_PERIOD_SECONDS = 60 type RateLimitResult = { allowed: boolean - remaining: number retryAfterSeconds: number } -type ExportRateLimitEnv = Cloudflare.Env & { - EXPLORER_EXPORT_RATE_LIMIT?: DurableObjectNamespace -} - export class RateLimitExceededError extends Error { retryAfterSeconds: number @@ -33,70 +18,6 @@ export class RateLimitExceededError extends Error { } } -export class ExplorerExportRateLimit extends DurableObject { - async fetch(request: Request): Promise { - const body = (await request.json()) as { - limit?: number - windowMs?: number - } - - const limit = - typeof body.limit === 'number' && Number.isFinite(body.limit) - ? Math.floor(body.limit) - : 0 - const windowMs = - typeof body.windowMs === 'number' && Number.isFinite(body.windowMs) - ? Math.floor(body.windowMs) - : 0 - - if (limit < 1 || windowMs < 1) { - return Response.json( - { error: 'Invalid export rate limit request' }, - { status: 400 }, - ) - } - - const now = Date.now() - const existing = await this.ctx.storage.get('bucket') - const bucket = - existing && existing.resetAt > now - ? existing - : { count: 0, resetAt: now + windowMs } - - if (bucket.count >= limit) { - return Response.json({ - allowed: false, - remaining: 0, - retryAfterSeconds: Math.max( - 1, - Math.ceil((bucket.resetAt - now) / 1000), - ), - } satisfies RateLimitResult) - } - - bucket.count += 1 - await this.ctx.storage.put('bucket', bucket) - await this.ctx.storage.setAlarm(bucket.resetAt) - - return Response.json({ - allowed: true, - remaining: Math.max(0, limit - bucket.count), - retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), - } satisfies RateLimitResult) - } - - async alarm(): Promise { - const bucket = await this.ctx.storage.get('bucket') - if (!bucket || bucket.resetAt > Date.now()) return - - await this.ctx.storage.deleteAll() - } -} - -function getRateLimitNamespace(): DurableObjectNamespace | null { - return (env as ExportRateLimitEnv).EXPLORER_EXPORT_RATE_LIMIT ?? null -} - function getClientIp(): string | null { const cfConnectingIp = getRequestHeader('cf-connecting-ip')?.trim() if (cfConnectingIp) return cfConnectingIp @@ -114,65 +35,36 @@ function getClientIp(): string | null { } async function consumeRateLimit(params: { + limiter: RateLimit key: string - limit: number - windowMs: number }): Promise { - const namespace = getRateLimitNamespace() - if (!namespace) { + if (!params.limiter) return { allowed: true, - remaining: params.limit, retryAfterSeconds: 0, } - } - const id = namespace.idFromName(params.key) - const stub = namespace.get(id) - const response = await stub.fetch('https://rate-limit/consume', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ limit: params.limit, windowMs: params.windowMs }), - }) + const { success } = await params.limiter.limit({ key: params.key }) - if (!response.ok) { - throw new Error('Failed to check export rate limit') + return { + allowed: success, + retryAfterSeconds: success ? 0 : RATE_LIMIT_PERIOD_SECONDS, } - - return (await response.json()) as RateLimitResult } -export async function enforceCsvExportRateLimit( - address: Address.Address, -): Promise { +export async function enforceCsvExportRateLimit(): Promise { const ip = getClientIp() if (!ip) return - const normalizedAddress = address.toLowerCase() - const [ipResult, walletResult] = await Promise.all([ - consumeRateLimit({ - key: `csv-export:ip:${ip}`, - limit: RATE_LIMITS.ip, - windowMs: RATE_LIMIT_WINDOW_MS, - }), - consumeRateLimit({ - key: `csv-export:ip:${ip}:wallet:${normalizedAddress}`, - limit: RATE_LIMITS.wallet, - windowMs: RATE_LIMIT_WINDOW_MS, - }), - ]) + const result = await consumeRateLimit({ + limiter: env.EXPLORER_EXPORT_RATE_LIMIT, + key: `csv-export:ip:${ip}`, + }) - if (!ipResult.allowed) { + if (!result.allowed) { throw new RateLimitExceededError( 'Too many CSV exports from this IP. Please wait a few minutes before trying again.', - ipResult.retryAfterSeconds, - ) - } - - if (!walletResult.allowed) { - throw new RateLimitExceededError( - 'Too many CSV exports for this wallet from your IP. Please wait a few minutes before trying again.', - walletResult.retryAfterSeconds, + result.retryAfterSeconds, ) } } diff --git a/apps/explorer/src/routes/api/address/balances/$address.ts b/apps/explorer/src/routes/api/address/balances/$address.ts index 130043dd9..efb9b2d28 100644 --- a/apps/explorer/src/routes/api/address/balances/$address.ts +++ b/apps/explorer/src/routes/api/address/balances/$address.ts @@ -30,7 +30,7 @@ export const Route = createFileRoute('/api/address/balances/$address')({ const config = getWagmiConfig() const chainId = getChainId(config) if (isCsvExport) { - await enforceCsvExportRateLimit(address) + await enforceCsvExportRateLimit() } const response = await fetchAddressBalancesData({ address, diff --git a/apps/explorer/src/routes/api/address/history/$address.ts b/apps/explorer/src/routes/api/address/history/$address.ts index 7ccf4d0a6..f2349781e 100644 --- a/apps/explorer/src/routes/api/address/history/$address.ts +++ b/apps/explorer/src/routes/api/address/history/$address.ts @@ -61,7 +61,7 @@ export const Route = createFileRoute('/api/address/history/$address')({ const config = getWagmiConfig() const chainId = getChainId(config) if (isCsvExport) { - await enforceCsvExportRateLimit(address) + await enforceCsvExportRateLimit() const transactions = await fetchAddressHistoryExportRows({ address, chainId, diff --git a/apps/explorer/wrangler.json b/apps/explorer/wrangler.json index 21f67bfbc..e57f3a36c 100644 --- a/apps/explorer/wrangler.json +++ b/apps/explorer/wrangler.json @@ -16,18 +16,21 @@ "preview_id": "0211859b856e40d095ab4e2a5329a22d" } ], - "durable_objects": { - "bindings": [ - { - "name": "EXPLORER_EXPORT_RATE_LIMIT", - "class_name": "ExplorerExportRateLimit" - } - ] - }, + "ratelimits": [ + { + "name": "EXPLORER_EXPORT_RATE_LIMIT", + "namespace_id": "421701", + "simple": { "limit": 20, "period": 60 } + } + ], "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ExplorerExportRateLimit"] + }, + { + "tag": "v2", + "deleted_classes": ["ExplorerExportRateLimit"] } ], "browser": { "binding": "BROWSER" }, @@ -41,14 +44,13 @@ "preview_id": "0211859b856e40d095ab4e2a5329a22d" } ], - "durable_objects": { - "bindings": [ - { - "name": "EXPLORER_EXPORT_RATE_LIMIT", - "class_name": "ExplorerExportRateLimit" - } - ] - }, + "ratelimits": [ + { + "name": "EXPLORER_EXPORT_RATE_LIMIT", + "namespace_id": "421701", + "simple": { "limit": 20, "period": 60 } + } + ], "routes": [ { "custom_domain": true, @@ -97,14 +99,13 @@ "preview_id": "0211859b856e40d095ab4e2a5329a22d" } ], - "durable_objects": { - "bindings": [ - { - "name": "EXPLORER_EXPORT_RATE_LIMIT", - "class_name": "ExplorerExportRateLimit" - } - ] - }, + "ratelimits": [ + { + "name": "EXPLORER_EXPORT_RATE_LIMIT", + "namespace_id": "421701", + "simple": { "limit": 20, "period": 60 } + } + ], "routes": [ { "custom_domain": true, @@ -148,14 +149,13 @@ "preview_id": "0211859b856e40d095ab4e2a5329a22d" } ], - "durable_objects": { - "bindings": [ - { - "name": "EXPLORER_EXPORT_RATE_LIMIT", - "class_name": "ExplorerExportRateLimit" - } - ] - }, + "ratelimits": [ + { + "name": "EXPLORER_EXPORT_RATE_LIMIT", + "namespace_id": "421701", + "simple": { "limit": 20, "period": 60 } + } + ], "routes": [ { "custom_domain": true,