import { NextRequest, NextResponse } from 'next/server'; import { authenticatedFetch } from '@/lib/api/authenticated-fetch'; /** * ๐Ÿ”ต Catch-All API Proxy (HttpOnly Cookie Pattern) * * โšก ์„ค๊ณ„ ๋ชฉ์ : * - HttpOnly ์ฟ ํ‚ค ๋ณด์•ˆ ์œ ์ง€: JavaScript ์ ‘๊ทผ ์ฐจ๋‹จ * - ๋ชจ๋“  ๋ฐฑ์—”๋“œ API๋ฅผ ๋‹จ์ผ ํ”„๋ก์‹œ๋กœ ์ฒ˜๋ฆฌ * - ์„œ๋ฒ„์—์„œ ์ฟ ํ‚ค ์ฝ์–ด Authorization ํ—ค๋” ์ž๋™ ์ถ”๊ฐ€ * * ๐Ÿ”„ ๋™์ž‘ ํ๋ฆ„: * 1. ํด๋ผ์ด์–ธํŠธ โ†’ Next.js /api/proxy/* (ํ† ํฐ ์—†์ด) * 2. Next.js: HttpOnly ์ฟ ํ‚ค์—์„œ access_token ์ฝ๊ธฐ (์„œ๋ฒ„์—์„œ๋งŒ ๊ฐ€๋Šฅ) * 3. Next.js โ†’ PHP Backend /api/v1/* (Authorization ํ—ค๋” ํฌํ•จ) * 4. PHP Backend โ†’ Next.js (์‘๋‹ต) * 5. Next.js โ†’ ํด๋ผ์ด์–ธํŠธ (์‘๋‹ต ์ „๋‹ฌ) * * ๐Ÿ” ๋ณด์•ˆ ํŠน์ง•: * - HttpOnly ์ฟ ํ‚ค: JavaScript ์ ‘๊ทผ ๋ถˆ๊ฐ€ (XSS ๋ฐฉ์ง€) * - ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ํ† ํฐ ์ฒ˜๋ฆฌ: ๋ธŒ๋ผ์šฐ์ €์— ํ† ํฐ ๋…ธ์ถœ ์•ˆ๋จ * - ์ž๋™ ์ธ์ฆ ํ—ค๋” ์ถ”๊ฐ€: ํด๋ผ์ด์–ธํŠธ๋Š” ์‹ ๊ฒฝ ์“ธ ํ•„์š” ์—†์Œ * * ๐Ÿ“ ์‚ฌ์šฉ ์˜ˆ์‹œ: * - Frontend: fetch('/api/proxy/item-master/init') * - Backend: GET https://api.codebridge-x.com/api/v1/item-master/init * * ๐Ÿ”„ ์ธ์ฆ ์ฒ˜๋ฆฌ: * - 401 ๊ฐ์ง€ โ†’ refresh โ†’ retry ๋Š” authenticatedFetch ๊ฒŒ์ดํŠธ์›จ์ด์— ์œ„์ž„ * - PROXY๋Š” ์ฟ ํ‚ค ์ฝ๊ธฐ/์„ค์ •/์‚ญ์ œ๋งŒ ๋‹ด๋‹น */ /** * ์ฟ ํ‚ค ์ƒ์„ฑ ํ—ฌํผ ํ•จ์ˆ˜ */ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: string; expiresIn?: number }) { const cookies: string[] = []; const isProduction = process.env.NODE_ENV === 'production'; if (tokens.accessToken) { cookies.push([ `access_token=${tokens.accessToken}`, 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', `Max-Age=${tokens.expiresIn || 7200}`, ].join('; ')); // FCM ๋“ฑ์—์„œ ์ธ์ฆ ์ƒํƒœ ํ™•์ธ์šฉ (non-HttpOnly) cookies.push([ 'is_authenticated=true', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', `Max-Age=${tokens.expiresIn || 7200}`, ].join('; ')); } if (tokens.refreshToken) { cookies.push([ `refresh_token=${tokens.refreshToken}`, 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=604800', // 7 days ].join('; ')); } return cookies; } /** * ์ฟ ํ‚ค ์‚ญ์ œ ํ—ฌํผ ํ•จ์ˆ˜ (ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ) */ function createClearTokenCookies(): string[] { const isProduction = process.env.NODE_ENV === 'production'; const secureFlag = isProduction ? '; Secure' : ''; return [ `access_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`, `refresh_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`, `is_authenticated=${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`, ]; } /** * Catch-all proxy handler for all HTTP methods */ async function proxyRequest( request: NextRequest, params: { path: string[] }, method: string ) { try { // 1. HttpOnly ์ฟ ํ‚ค์—์„œ ํ† ํฐ ์ฝ๊ธฐ const token = request.cookies.get('access_token')?.value; const refreshToken = request.cookies.get('refresh_token')?.value; // 2. ๋ฐฑ์—”๋“œ URL ๊ตฌ์„ฑ const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`; const url = new URL(backendUrl); request.nextUrl.searchParams.forEach((value, key) => { url.searchParams.append(key, value); }); // 3. ์š”์ฒญ ๋ฐ”๋”” ์ฝ๊ธฐ (POST, PUT, DELETE, PATCH) let body: string | FormData | undefined; const contentType = request.headers.get('content-type') || 'application/json'; let isFormData = false; if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { if (contentType.includes('application/json')) { body = await request.text(); } else if (contentType.includes('multipart/form-data')) { isFormData = true; const originalFormData = await request.formData(); const newFormData = new FormData(); for (const [key, value] of originalFormData.entries()) { if (value instanceof File) { newFormData.append(key, value, value.name); } else { newFormData.append(key, value); } } body = newFormData; } } // 4. ํ—ค๋” ๊ตฌ์„ฑ const headers: Record = { 'Accept': 'application/json', 'X-API-KEY': process.env.API_KEY || '', 'Authorization': token ? `Bearer ${token}` : '', }; if (!isFormData) { headers['Content-Type'] = contentType; } // 5. authenticatedFetch ๊ฒŒ์ดํŠธ์›จ์ด๋กœ ์š”์ฒญ ์‹คํ–‰ // 401 ๊ฐ์ง€ โ†’ refresh โ†’ retry ๋ชจ๋‘ ๊ฒŒ์ดํŠธ์›จ์ด๊ฐ€ ์ฒ˜๋ฆฌ const { response: backendResponse, newTokens, authFailed } = await authenticatedFetch( url.toString(), { method, headers, body }, refreshToken, 'PROXY' ); // 6. ์ธ์ฆ ์‹คํŒจ โ†’ ์ฟ ํ‚ค ์‚ญ์ œ + 401 ๋ฐ˜ํ™˜ if (authFailed) { if (process.env.NODE_ENV === 'development') { console.warn('๐Ÿ”ด [PROXY] Auth failed, clearing cookies...'); } const clearResponse = NextResponse.json( { error: 'Authentication failed', needsReauth: true }, { status: 401 } ); createClearTokenCookies().forEach(cookie => { clearResponse.headers.append('Set-Cookie', cookie); }); return clearResponse; } // 7. ์‘๋‹ต ์ฒ˜๋ฆฌ (๋ฐ”์ด๋„ˆ๋ฆฌ vs ํ…์ŠคํŠธ/JSON) const responseContentType = backendResponse.headers.get('content-type') || 'application/json'; const isBinaryResponse = responseContentType.includes('application/pdf') || responseContentType.includes('application/octet-stream') || responseContentType.includes('image/') || responseContentType.includes('application/zip') || responseContentType.includes('application/vnd') || responseContentType.includes('application/msword') || responseContentType.includes('application/x-'); let clientResponse: NextResponse; if (isBinaryResponse) { const binaryData = await backendResponse.arrayBuffer(); clientResponse = new NextResponse(binaryData, { status: backendResponse.status, headers: { 'Content-Type': responseContentType, 'Content-Disposition': backendResponse.headers.get('content-disposition') || '', 'Content-Length': backendResponse.headers.get('content-length') || '', }, }); } else { const responseData = await backendResponse.text(); clientResponse = new NextResponse(responseData, { status: backendResponse.status, headers: { 'Content-Type': responseContentType, }, }); } // 8. ํ† ํฐ์ด ๊ฐฑ์‹ ๋˜์—ˆ์œผ๋ฉด ์ƒˆ ์ฟ ํ‚ค ์„ค์ • if (newTokens?.accessToken) { createTokenCookies(newTokens).forEach(cookie => { clientResponse.headers.append('Set-Cookie', cookie); }); } return clientResponse; } catch (error) { console.error('Proxy request error:', error); return NextResponse.json( { error: 'Proxy server error' }, { status: 500 } ); } } /** * GET ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” */ export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'GET'); } /** * POST ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” */ export async function POST( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'POST'); } /** * PUT ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” */ export async function PUT( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'PUT'); } /** * DELETE ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” */ export async function DELETE( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'DELETE'); } /** * PATCH ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” * ์šฉ๋„: toggle ์—”๋“œํฌ์ธํŠธ (/clients/{id}/toggle, /client-groups/{id}/toggle) */ export async function PATCH( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'PATCH'); }