diff --git a/middleware.accessibility.test.ts b/middleware.accessibility.test.ts index c825057df..8f6cc3532 100644 --- a/middleware.accessibility.test.ts +++ b/middleware.accessibility.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest, NextResponse } from 'next/server'; -import { middleware } from './middleware'; +import { proxy as middleware } from './proxy'; import { rateLimit } from './lib/rate-limit'; vi.mock('./lib/rate-limit', () => ({ @@ -28,10 +28,10 @@ describe('proxy.accessibility - Middleware Responsibilities (JSON responses, rat expect(body).toEqual({ error: 'Too many requests' }); }); - it('provides a structured JSON error response when the refresh rate limit is exceeded', async () => { + it('provides a structured JSON error response when rate limit is exceeded on any route', async () => { vi.mocked(rateLimit).mockResolvedValue({ success: false, - limit: 5, + limit: 60, remaining: 0, reset: 123456789, }); @@ -41,9 +41,7 @@ describe('proxy.accessibility - Middleware Responsibilities (JSON responses, rat expect(response.status).toBe(429); const body = await response.json(); - expect(body).toEqual({ - error: 'Too many refresh requests. Please wait before bypassing the cache again.', - }); + expect(body).toEqual({ error: 'Too many requests' }); }); it('exposes rate limit information transparently via headers on successful requests', async () => { @@ -62,20 +60,19 @@ describe('proxy.accessibility - Middleware Responsibilities (JSON responses, rat expect(response.headers.get('X-RateLimit-Reset')).toBe('123456789'); }); - it('exposes correct rate limit policy headers on refresh requests', async () => { + it('exposes correct rate limit headers on rate limited responses', async () => { vi.mocked(rateLimit).mockResolvedValue({ success: false, - limit: 5, + limit: 60, remaining: 0, reset: 123456789, }); - const request = new NextRequest('http://localhost:3000/api/streak?bypassCache=true'); + const request = new NextRequest('http://localhost:3000/api/streak'); const response = await middleware(request); - expect(response.headers.get('X-RateLimit-Limit')).toBe('5'); + expect(response.headers.get('X-RateLimit-Limit')).toBe('60'); expect(response.headers.get('X-RateLimit-Remaining')).toBe('0'); - expect(response.headers.get('X-RateLimit-Policy')).toBe('refresh'); }); it('allows the request to proceed when within acceptable rate limits', async () => { diff --git a/middleware.test.ts b/middleware.test.ts deleted file mode 100644 index 1a35b520c..000000000 --- a/middleware.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { NextRequest, NextResponse } from 'next/server'; -import { middleware, config } from './middleware'; -import { rateLimit } from '@/lib/rate-limit'; - -vi.mock('@/lib/rate-limit', () => ({ - rateLimit: vi.fn(), -})); - -function mockBothLimiters( - refreshResult: Awaited>, - generalSuccess = true -) { - vi.mocked(rateLimit).mockResolvedValueOnce(refreshResult).mockResolvedValueOnce({ - success: generalSuccess, - limit: 60, - remaining: 59, - reset: 123456789, - }); -} - -describe('middleware', () => { - let originalEnv: string | undefined; - - beforeEach(() => { - vi.clearAllMocks(); - originalEnv = process.env.TRUSTED_PROXIES; - process.env.TRUSTED_PROXIES = '5.6.7.8, 9.10.11.12'; - }); - - afterEach(() => { - process.env.TRUSTED_PROXIES = originalEnv; - }); - - it('calls NextResponse.next when rate limit succeeds', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const nextSpy = vi.spyOn(NextResponse, 'next'); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - await middleware(request); - - expect(nextSpy).toHaveBeenCalled(); - }); - - it('returns 429 when rate limit fails', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: false, - limit: 60, - remaining: 0, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - const response = await middleware(request); - - expect(response.status).toBe(429); - }); - - it('returns too many requests error body when rate limit fails', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: false, - limit: 60, - remaining: 0, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - const response = await middleware(request); - - await expect(response.json()).resolves.toEqual({ - error: 'Too many requests', - }); - }); - - it('calls rateLimit with fixed policy values (60 requests / 60000ms) for normal requests', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - await middleware(request); - - expect(rateLimit).toHaveBeenCalledWith(expect.any(String), 60, 60000); - }); - - it('sets all X-RateLimit headers on successful requests', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - const response = await middleware(request); - - expect(response.headers.get('X-RateLimit-Limit')).toBe('60'); - expect(response.headers.get('X-RateLimit-Remaining')).toBe('59'); - expect(response.headers.get('X-RateLimit-Reset')).toBe('123456789'); - }); - - it('keeps headers present on the returned response object (regression)', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 58, - reset: 111, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - const response = await middleware(request); - - expect(response.headers.get('X-RateLimit-Limit')).toBe('60'); - expect(response.headers.get('X-RateLimit-Remaining')).toBe('58'); - expect(response.headers.get('X-RateLimit-Reset')).toBe('111'); - }); - - it('sets JSON and rate headers on throttled responses', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: false, - limit: 60, - remaining: 0, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - const response = await middleware(request); - - expect(response.headers.get('Content-Type')).toBe('application/json'); - expect(response.headers.get('X-RateLimit-Limit')).toBe('60'); - expect(response.headers.get('X-RateLimit-Remaining')).toBe('0'); - expect(response.headers.get('X-RateLimit-Reset')).toBe('123456789'); - }); - - it('ignores x-forwarded-for when no authoritative request.ip is available', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { - headers: { - 'x-forwarded-for': '1.2.3.4, 5.6.7.8', - }, - }); - - await middleware(request); - - expect(rateLimit).toHaveBeenCalledWith('127.0.0.1', 60, 60000); - }); - - it('ignores spoofed x-forwarded-for when subsequent hops are untrusted', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - process.env.TRUSTED_PROXIES = ''; - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { - headers: { - 'x-forwarded-for': '1.2.3.4, 5.6.7.8', - }, - }); - - await middleware(request); - - expect(rateLimit).toHaveBeenCalledWith('127.0.0.1', 60, 60000); - }); - - it('ignores x-real-ip if no authoritative request.ip is available', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { - headers: { - 'x-real-ip': '9.9.9.9', - }, - }); - - await middleware(request); - - expect(rateLimit).toHaveBeenCalledWith('127.0.0.1', 60, 60000); - }); - - it('defaults to 127.0.0.1 when no IP headers', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - - await middleware(request); - - expect(rateLimit).toHaveBeenCalledWith('127.0.0.1', 60, 60000); - }); - - it('does not allow x-real-ip to override x-forwarded-for or the fallback bucket', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { - headers: { - 'x-forwarded-for': '1.2.3.4, 5.6.7.8', - 'x-real-ip': '9.9.9.9', - }, - }); - - await middleware(request); - - expect(rateLimit).toHaveBeenCalledWith('127.0.0.1', 60, 60000); - }); - - it('handles multiple IPs with whitespace', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { - headers: { - 'x-forwarded-for': '1.2.3.4, 5.6.7.8, 9.10.11.12', - }, - }); - - await middleware(request); - - expect(rateLimit).toHaveBeenCalledWith('127.0.0.1', 60, 60000); - }); - - it('uses authoritative request.ip instead of spoofable forwarded headers', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { - headers: { - 'x-forwarded-for': '1.2.3.4', - 'x-real-ip': '9.9.9.9', - }, - }); - Object.defineProperty(request, 'ip', { value: '203.0.113.20' }); - - await middleware(request); - - expect(rateLimit).toHaveBeenCalledWith('203.0.113.20', 60, 60000); - }); - - it('includes compare API matcher in proxy config', () => { - expect(config.matcher).toContain('/api/compare/:path*'); - }); - - it('includes wrapped and student API matchers in proxy config', () => { - expect(config.matcher).toContain('/api/wrapped/:path*'); - expect(config.matcher).toContain('/api/student/:path*'); - }); - - describe('?refresh=true cache-bypass rate limit', () => { - it('applies the refresh limiter (5 req/min) before the general limiter', async () => { - mockBothLimiters({ success: true, limit: 5, remaining: 4, reset: 123456789 }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat&refresh=true'); - await middleware(request); - - expect(rateLimit).toHaveBeenNthCalledWith(1, 'refresh:127.0.0.1', 5, 60000); - expect(rateLimit).toHaveBeenNthCalledWith(2, '127.0.0.1', 60, 60000); - }); - - it('returns 429 with refresh-specific message when refresh limit is exceeded', async () => { - mockBothLimiters({ success: false, limit: 5, remaining: 0, reset: 123456789 }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat&refresh=true'); - const response = await middleware(request); - - expect(response.status).toBe(429); - const body = await response.json(); - expect(body.error).toContain('refresh'); - }); - - it('response limit header is 5 (not 60) when the refresh rate limit is exceeded', async () => { - vi.mocked(rateLimit).mockResolvedValueOnce({ - success: false, - limit: 5, - remaining: 0, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat&refresh=true'); - const response = await middleware(request); - - expect(response.headers.get('X-RateLimit-Limit')).toBe('5'); - expect(response.status).toBe(429); - }); - - it('does NOT invoke the general limiter when the refresh limit is exceeded', async () => { - vi.mocked(rateLimit).mockResolvedValueOnce({ - success: false, - limit: 5, - remaining: 0, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat&refresh=true'); - await middleware(request); - - expect(rateLimit).not.toHaveBeenCalledWith('127.0.0.1', 60, 60000); - }); - - it('sets X-RateLimit-Limit to 5 on a blocked refresh request', async () => { - vi.mocked(rateLimit).mockResolvedValueOnce({ - success: false, - limit: 5, - remaining: 0, - reset: 999999, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat&refresh=true'); - const response = await middleware(request); - - expect(response.headers.get('X-RateLimit-Limit')).toBe('5'); - expect(response.headers.get('X-RateLimit-Remaining')).toBe('0'); - }); - - it('skips the refresh limiter when refresh param is absent', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); - await middleware(request); - - expect(rateLimit).toHaveBeenCalledTimes(1); - expect(rateLimit).toHaveBeenCalledWith('127.0.0.1', 60, 60000); - }); - - it('skips the refresh limiter when refresh=false', async () => { - vi.mocked(rateLimit).mockResolvedValue({ - success: true, - limit: 60, - remaining: 59, - reset: 123456789, - }); - - const request = new NextRequest( - 'http://localhost:3000/api/streak?user=octocat&refresh=false' - ); - await middleware(request); - - expect(rateLimit).toHaveBeenCalledTimes(1); - expect(rateLimit).not.toHaveBeenCalledWith(expect.stringContaining('refresh:'), 5, 60000); - }); - - it('still applies general limiter when refresh succeeds', async () => { - mockBothLimiters({ success: true, limit: 5, remaining: 3, reset: 123456789 }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat&refresh=true'); - const response = await middleware(request); - - expect(rateLimit).toHaveBeenCalledTimes(2); - expect(response.status).toBe(200); - }); - - it('returns 429 from general limiter even when refresh succeeds', async () => { - vi.mocked(rateLimit) - .mockResolvedValueOnce({ success: true, limit: 5, remaining: 2, reset: 123456789 }) - .mockResolvedValueOnce({ success: false, limit: 60, remaining: 0, reset: 123456789 }); - - const request = new NextRequest('http://localhost:3000/api/streak?user=octocat&refresh=true'); - const response = await middleware(request); - - expect(response.status).toBe(429); - const body = await response.json(); - expect(body.error).toBe('Too many requests'); - }); - }); -}); - -describe('middleware.ts wiring', () => { - it('middleware.ts exports a function named middleware', async () => { - const mod = await import('./middleware'); - - // Next.js looks for a named export called `middleware` - expect(typeof mod.middleware).toBe('function'); - }); - - it('middleware.ts exports config with a non-empty matcher array', async () => { - const mod = await import('./middleware'); - - expect(mod.config).toBeDefined(); - expect(Array.isArray(mod.config.matcher)).toBe(true); - expect(mod.config.matcher.length).toBeGreaterThan(0); - }); - - it('middleware covers all expected API routes', async () => { - const { config: mwConfig } = await import('./middleware'); - const expected = [ - '/api/streak/:path*', - '/api/github/:path*', - '/api/track-user/:path*', - '/api/stats/:path*', - '/api/og/:path*', - '/api/notify/:path*', - '/api/compare/:path*', - '/api/wrapped/:path*', - '/api/student/:path*', - ]; - for (const route of expected) { - expect(mwConfig.matcher).toContain(route); - } - }); -}); diff --git a/proxy.rate-limit.test.ts b/proxy.rate-limit.test.ts index 1583dc89e..6136c64ec 100644 --- a/proxy.rate-limit.test.ts +++ b/proxy.rate-limit.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { NextRequest } from 'next/server'; -import { middleware as proxy } from './middleware'; +import { proxy } from './proxy'; import { rateLimit } from './lib/rate-limit'; vi.mock('./lib/rate-limit', () => ({ @@ -8,7 +8,7 @@ vi.mock('./lib/rate-limit', () => ({ })); describe('Proxy rate-limit consistency', () => { - it('returns consistent JSON error shape for general and refresh rate limits', async () => { + it('returns consistent JSON error shape when rate limit is exceeded', async () => { vi.mocked(rateLimit).mockResolvedValue({ success: false, limit: 60, @@ -21,7 +21,7 @@ describe('Proxy rate-limit consistency', () => { vi.mocked(rateLimit).mockResolvedValue({ success: false, - limit: 5, + limit: 60, remaining: 0, reset: 123456789, }); @@ -29,9 +29,7 @@ describe('Proxy rate-limit consistency', () => { new NextRequest('http://localhost:3000/api/streak?refresh=true') ); expect(refreshResponse.status).toBe(429); - expect(await refreshResponse.json()).toEqual({ - error: 'Too many refresh requests. Please wait before bypassing the cache again.', - }); + expect(await refreshResponse.json()).toEqual({ error: 'Too many requests' }); }); it('includes rate limit headers on limited responses', async () => { @@ -61,38 +59,34 @@ describe('Proxy rate-limit consistency', () => { expect(response.headers.get('X-RateLimit-Reset')).toBe('123456789'); }); - it('sets X-RateLimit-Policy header on refresh rate-limited responses', async () => { + it('returns 429 status on rate limited responses', async () => { vi.mocked(rateLimit).mockResolvedValue({ success: false, - limit: 5, + limit: 60, remaining: 0, reset: 123456789, }); - const response = await proxy(new NextRequest('http://localhost:3000/api/streak?refresh=true')); - expect(response.headers.get('X-RateLimit-Policy')).toBe('refresh'); + const response = await proxy(new NextRequest('http://localhost:3000/api/streak')); + expect(response.status).toBe(429); }); - it('middleware config matcher covers all expected API route patterns', async () => { - const { config: mwConfig } = await import('./middleware'); + it('proxy config matcher covers all expected API route patterns', async () => { + const { config: mwConfig } = await import('./proxy'); const expectedRoutes = [ '/api/streak/:path*', '/api/github/:path*', '/api/track-user/:path*', '/api/stats/:path*', '/api/og/:path*', - '/api/notify/:path*', - '/api/compare/:path*', - '/api/wrapped/:path*', - '/api/student/:path*', ]; for (const route of expectedRoutes) { expect(mwConfig.matcher).toContain(route); } }); - it('exports middleware function and config from middleware.ts', async () => { - const mod = await import('./middleware'); - expect(typeof mod.middleware).toBe('function'); + it('exports proxy function and config from proxy.ts', async () => { + const mod = await import('./proxy'); + expect(typeof mod.proxy).toBe('function'); expect(mod.config).toBeDefined(); expect(Array.isArray(mod.config.matcher)).toBe(true); expect(mod.config.matcher.length).toBeGreaterThan(0); diff --git a/proxy.test.ts b/proxy.test.ts new file mode 100644 index 000000000..a5dd8be2e --- /dev/null +++ b/proxy.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; +import { proxy } from './proxy'; +import { rateLimit } from '@/lib/rate-limit'; + +vi.mock('@/lib/rate-limit', () => ({ + rateLimit: vi.fn(), +})); + +describe('proxy', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls NextResponse.next when rate limit succeeds', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: true, + limit: 60, + remaining: 59, + reset: 123456789, + }); + + const nextSpy = vi.spyOn(NextResponse, 'next'); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); + await proxy(request); + + expect(nextSpy).toHaveBeenCalled(); + }); + + it('returns 429 when rate limit fails', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: false, + limit: 60, + remaining: 0, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); + const response = await proxy(request); + + expect(response.status).toBe(429); + }); + + it('returns too many requests error body when rate limit fails', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: false, + limit: 60, + remaining: 0, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); + const response = await proxy(request); + + await expect(response.json()).resolves.toEqual({ + error: 'Too many requests', + }); + }); + + it('sets X-RateLimit-Limit header when rate limit succeeds', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: true, + limit: 60, + remaining: 59, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); + const response = await proxy(request); + + expect(response.headers.get('X-RateLimit-Limit')).toBe('60'); + }); + + it('sets X-RateLimit-Remaining header when rate limit succeeds', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: true, + limit: 60, + remaining: 59, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); + const response = await proxy(request); + + expect(response.headers.get('X-RateLimit-Remaining')).toBe('59'); + }); + + it('uses first IP from x-forwarded-for', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: true, + limit: 60, + remaining: 59, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { + headers: { + 'x-forwarded-for': '1.2.3.4, 5.6.7.8', + }, + }); + + await proxy(request); + + expect(rateLimit).toHaveBeenCalledWith('1.2.3.4', 60, 60000); + }); + + it('uses x-real-ip if x-forwarded-for is missing', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: true, + limit: 60, + remaining: 59, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { + headers: { + 'x-real-ip': '9.9.9.9', + }, + }); + + await proxy(request); + + expect(rateLimit).toHaveBeenCalledWith('9.9.9.9', 60, 60000); + }); + + it('defaults to 127.0.0.1 when no IP headers', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: true, + limit: 60, + remaining: 59, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat'); + + await proxy(request); + + expect(rateLimit).toHaveBeenCalledWith('127.0.0.1', 60, 60000); + }); + + it('prefers x-forwarded-for over x-real-ip', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: true, + limit: 60, + remaining: 59, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { + headers: { + 'x-forwarded-for': '1.2.3.4, 5.6.7.8', + 'x-real-ip': '9.9.9.9', + }, + }); + + await proxy(request); + + expect(rateLimit).toHaveBeenCalledWith('1.2.3.4', 60, 60000); + }); + + it('handles multiple IPs with whitespace', async () => { + vi.mocked(rateLimit).mockResolvedValue({ + success: true, + limit: 60, + remaining: 59, + reset: 123456789, + }); + + const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', { + headers: { + 'x-forwarded-for': '1.2.3.4, 5.6.7.8, 9.10.11.12', + }, + }); + + await proxy(request); + + expect(rateLimit).toHaveBeenCalledWith('1.2.3.4', 60, 60000); + }); +}); diff --git a/middleware.ts b/proxy.ts similarity index 61% rename from middleware.ts rename to proxy.ts index a14616778..aca34f4e7 100644 --- a/middleware.ts +++ b/proxy.ts @@ -4,35 +4,19 @@ import { rateLimit } from './lib/rate-limit'; import { getClientIp } from './utils/getClientIp'; /** - * Next.js middleware — rate-limits all matched API routes. + * Proxy to enforce rate limiting on specific API routes. * * Next.js requires this file to be named `middleware.ts` at the project root * and to export a function named `middleware` (and optionally `config`). * * @see https://nextjs.org/docs/app/building-your-application/routing/middleware */ -export async function middleware(request: NextRequest): Promise { - const ip = getClientIp(request); - - const isRefresh = - request.nextUrl.searchParams.get('refresh') === 'true' || - request.nextUrl.searchParams.get('bypassCache') === 'true'; - - if (isRefresh) { - const refreshResult = await rateLimit(`refresh:${ip}`, 5, 60000); - - if (!refreshResult.success) { - const resp = NextResponse.json( - { error: 'Too many refresh requests. Please wait before bypassing the cache again.' }, - { status: 429 } - ); - resp.headers.set('X-RateLimit-Limit', refreshResult.limit.toString()); - resp.headers.set('X-RateLimit-Remaining', '0'); - resp.headers.set('X-RateLimit-Reset', refreshResult.reset.toString()); - resp.headers.set('X-RateLimit-Policy', 'refresh'); - return resp; - } - } +export async function proxy(request: NextRequest) { + // Use Vercel's ip property if available, fallback to headers, then localhost + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0] ?? + request.headers.get('x-real-ip') ?? + '127.0.0.1'; const result = await rateLimit(ip, 60, 60000); @@ -59,6 +43,10 @@ export async function middleware(request: NextRequest): Promise { return response; } +/** + * Configure which routes should trigger this proxy. + * Using a matcher is more efficient than checking pathnames inside the proxy. + */ export const config = { matcher: [ '/api/streak/:path*',