diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js index f01c278..5fb1195 100644 --- a/apps/dashboard/next.config.js +++ b/apps/dashboard/next.config.js @@ -96,10 +96,6 @@ const nextConfig = { async rewrites() { const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') return [ - { - source: '/api/:path*', - destination: `${apiOrigin}/api/:path*`, - }, { source: '/storage/:path*', destination: `${apiOrigin}/storage/:path*`, diff --git a/apps/dashboard/src/app/api/v1/[...path]/route.test.ts b/apps/dashboard/src/app/api/v1/[...path]/route.test.ts new file mode 100644 index 0000000..1402c30 --- /dev/null +++ b/apps/dashboard/src/app/api/v1/[...path]/route.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +afterEach(() => { + vi.restoreAllMocks() + delete process.env.API_INTERNAL_URL + delete process.env.API_URL + delete process.env.NEXT_PUBLIC_API_URL +}) + +describe('dashboard API proxy route', () => { + it('forwards dashboard API requests to the configured API origin', async () => { + process.env.API_INTERNAL_URL = 'http://api:4000/api/v1' + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: { ok: true } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + + const { GET } = await import('./route') + const response = await GET( + new Request('https://rentaldrivego.ma/dashboard/api/v1/auth/employee/me?fresh=1', { + headers: { cookie: 'employee_session=token-123' }, + }), + { params: { path: ['auth', 'employee', 'me'] } }, + ) + + expect(fetchMock).toHaveBeenCalledWith(new URL('http://api:4000/api/v1/auth/employee/me?fresh=1'), expect.objectContaining({ + method: 'GET', + cache: 'no-store', + headers: expect.any(Headers), + })) + const forwardedInit = (fetchMock as any).mock.calls[0][1] as RequestInit + expect((forwardedInit.headers as Headers).get('cookie')).toBe('employee_session=token-123') + await expect(response.json()).resolves.toEqual({ data: { ok: true } }) + }) + + it('rewrites upstream session cookies for the dashboard host', async () => { + process.env.API_URL = 'https://api.rentaldrivego.ma' + const headers = new Headers({ 'content-type': 'application/json' }) + headers.append('set-cookie', 'employee_session=token-123; Path=/; Domain=api.rentaldrivego.ma; HttpOnly; Secure; SameSite=Lax') + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: { employee: { id: 'emp_1' } } }), { + status: 200, + headers, + })) + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + + const { POST } = await import('./route') + const response = await POST( + new Request('https://rentaldrivego.ma/dashboard/api/v1/auth/employee/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'owner@example.com', password: 'secret' }), + }), + { params: Promise.resolve({ path: ['auth', 'employee', 'login'] }) }, + ) + + expect(fetchMock).toHaveBeenCalledWith(new URL('https://api.rentaldrivego.ma/api/v1/auth/employee/login'), expect.objectContaining({ + method: 'POST', + })) + expect(response.headers.get('set-cookie')).toBe('employee_session=token-123; Path=/; HttpOnly; Secure; SameSite=Lax') + }) + + it('falls back to the public API origin when the internal API origin is unavailable', async () => { + process.env.API_INTERNAL_URL = 'http://api:4000/api/v1' + process.env.API_URL = 'https://api.rentaldrivego.ma' + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND api')) + .mockResolvedValueOnce(new Response(JSON.stringify({ error: 'unauthenticated' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + + const { GET } = await import('./route') + const response = await GET( + new Request('https://rentaldrivego.ma/dashboard/api/v1/auth/employee/me'), + { params: { path: ['auth', 'employee', 'me'] } }, + ) + + expect(fetchMock).toHaveBeenNthCalledWith(1, new URL('http://api:4000/api/v1/auth/employee/me'), expect.any(Object)) + expect(fetchMock).toHaveBeenNthCalledWith(2, new URL('https://api.rentaldrivego.ma/api/v1/auth/employee/me'), expect.any(Object)) + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'unauthenticated' }) + }) +}) diff --git a/apps/dashboard/src/app/api/v1/[...path]/route.ts b/apps/dashboard/src/app/api/v1/[...path]/route.ts new file mode 100644 index 0000000..19f2aa1 --- /dev/null +++ b/apps/dashboard/src/app/api/v1/[...path]/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from 'next/server' + +type RouteContext = { + params: Promise<{ path?: string[] }> | { path?: string[] } +} + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'content-encoding', + 'content-length', + 'host', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'set-cookie', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]) + +function normalizeApiOrigin(value: string): string { + return value.replace(/\/+$/, '').replace(/\/api(?:\/v1)?$/, '') +} + +function resolveApiOrigins(): string[] { + return Array.from(new Set([ + process.env.API_INTERNAL_URL, + process.env.API_URL, + process.env.NEXT_PUBLIC_API_URL, + 'http://localhost:4000', + ] + .filter((value): value is string => Boolean(value)) + .map(normalizeApiOrigin))) +} + +function rewriteCookieForDashboard(cookie: string): string { + return cookie + .replace(/;\s*Domain=[^;]*/gi, '') + .replace(/;\s*SameSite=None/gi, '; SameSite=Lax') +} + +function getSetCookieHeaders(headers: Headers): string[] { + const withGetter = headers as Headers & { getSetCookie?: () => string[] } + if (typeof withGetter.getSetCookie === 'function') return withGetter.getSetCookie() + + const cookie = headers.get('set-cookie') + return cookie ? [cookie] : [] +} + +function copyRequestHeaders(request: Request): Headers { + const headers = new Headers() + request.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) headers.set(key, value) + }) + return headers +} + +function copyResponseHeaders(upstream: Response): Headers { + const headers = new Headers() + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) headers.set(key, value) + }) + return headers +} + +async function proxyDashboardApi(request: Request, context: RouteContext) { + const params = await Promise.resolve(context.params) + const path = (params.path ?? []).map(encodeURIComponent).join('/') + const requestUrl = new URL(request.url) + const method = request.method.toUpperCase() + const hasBody = method !== 'GET' && method !== 'HEAD' + const body = hasBody ? await request.arrayBuffer() : undefined + const headers = copyRequestHeaders(request) + let lastError: unknown = null + let upstream: Response | null = null + + for (const origin of resolveApiOrigins()) { + const upstreamUrl = new URL(`/api/v1/${path}${requestUrl.search}`, origin) + try { + upstream = await fetch(upstreamUrl, { + method, + headers, + body, + cache: 'no-store', + }) + break + } catch (error) { + lastError = error + } + } + + if (!upstream) { + const message = lastError instanceof Error ? lastError.message : 'API proxy request failed' + return NextResponse.json({ error: 'api_proxy_unavailable', message }, { status: 502 }) + } + + const response = new NextResponse(await upstream.arrayBuffer(), { + status: upstream.status, + statusText: upstream.statusText, + headers: copyResponseHeaders(upstream), + }) + + for (const cookie of getSetCookieHeaders(upstream.headers)) { + response.headers.append('set-cookie', rewriteCookieForDashboard(cookie)) + } + + return response +} + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(request: Request, context: RouteContext) { + return proxyDashboardApi(request, context) +} + +export async function POST(request: Request, context: RouteContext) { + return proxyDashboardApi(request, context) +} + +export async function PUT(request: Request, context: RouteContext) { + return proxyDashboardApi(request, context) +} + +export async function PATCH(request: Request, context: RouteContext) { + return proxyDashboardApi(request, context) +} + +export async function DELETE(request: Request, context: RouteContext) { + return proxyDashboardApi(request, context) +}