import { NextRequest, NextResponse } from 'next/server'; /** * ๐Ÿ”ต 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 * * โš ๏ธ ์ฃผ์˜: * - ๋กœ๊ทธ์•„์›ƒ API(/api/auth/logout)์™€ ๋™์ผํ•œ ํŒจํ„ด * - ๋ชจ๋“  HTTP ๋ฉ”์„œ๋“œ ์ง€์› (GET, POST, PUT, DELETE) * - ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ์™€ ์š”์ฒญ ๋ฐ”๋”” ๋ชจ๋‘ ์ „๋‹ฌ */ /** * ํ† ํฐ ๊ฐฑ์‹  ํ•จ์ˆ˜ (access_token ๋งŒ๋ฃŒ ์‹œ refresh_token์œผ๋กœ ๊ฐฑ์‹ ) */ async function refreshAccessToken(refreshToken: string): Promise<{ success: boolean; accessToken?: string; refreshToken?: string; expiresIn?: number; }> { try { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', }, body: JSON.stringify({ refresh_token: refreshToken, }), }); if (!response.ok) { console.warn('๐Ÿ”ด [PROXY] Token refresh failed'); return { success: false }; } const data = await response.json(); console.log('โœ… [PROXY] Token refreshed successfully'); return { success: true, accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in, }; } catch (error) { console.error('๐Ÿ”ด [PROXY] Token refresh error:', error); return { success: false }; } } /** * ๋ฐฑ์—”๋“œ API ์š”์ฒญ ์‹คํ–‰ ํ•จ์ˆ˜ * * @param isFormData - true์ธ ๊ฒฝ์šฐ Content-Type ํ—ค๋”๋ฅผ ์ƒ๋žต (๋ธŒ๋ผ์šฐ์ €๊ฐ€ boundary ์ž๋™ ์„ค์ •) */ async function executeBackendRequest( url: URL, method: string, token: string | undefined, body: string | FormData | undefined, contentType: string, isFormData: boolean = false ): Promise { // FormData์ธ ๊ฒฝ์šฐ Content-Type์„ ์ƒ๋žตํ•ด์•ผ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ boundary๋ฅผ ์ž๋™ ์„ค์ • const headers: Record = { 'Accept': 'application/json', 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', 'Authorization': token ? `Bearer ${token}` : '', }; // FormData๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋งŒ Content-Type ์„ค์ • if (!isFormData) { headers['Content-Type'] = contentType; } return fetch(url.toString(), { method, headers, body, }); } /** * ์ฟ ํ‚ค ์ƒ์„ฑ ํ—ฌํผ ํ•จ์ˆ˜ */ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: string; expiresIn?: number }) { const cookies: string[] = []; if (tokens.accessToken) { cookies.push([ `access_token=${tokens.accessToken}`, 'HttpOnly', 'Secure', 'SameSite=Strict', 'Path=/', `Max-Age=${tokens.expiresIn || 7200}`, ].join('; ')); } if (tokens.refreshToken) { cookies.push([ `refresh_token=${tokens.refreshToken}`, 'HttpOnly', 'Secure', 'SameSite=Strict', 'Path=/', 'Max-Age=604800', // 7 days ].join('; ')); } return cookies; } /** * ์ฟ ํ‚ค ์‚ญ์ œ ํ—ฌํผ ํ•จ์ˆ˜ (ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ) */ function createClearTokenCookies(): string[] { return [ 'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0', 'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0', ]; } /** * Catch-all proxy handler for all HTTP methods * * ๐Ÿ”„ ํ† ํฐ ๊ฐฑ์‹  ๋กœ์ง: * 1. ํ˜„์žฌ access_token์œผ๋กœ ๋ฐฑ์—”๋“œ ์š”์ฒญ * 2. 401 ์‘๋‹ต ์‹œ โ†’ refresh_token์œผ๋กœ ์ƒˆ ํ† ํฐ ๋ฐœ๊ธ‰ * 3. ์ƒˆ ํ† ํฐ์œผ๋กœ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„ * 4. ์žฌ์‹œ๋„๋„ ์‹คํŒจํ•˜๋ฉด โ†’ ์ฟ ํ‚ค ์‚ญ์ œ ํ›„ 401 ๋ฐ˜ํ™˜ */ async function proxyRequest( request: NextRequest, params: { path: string[] }, method: string ) { try { // 1. HttpOnly ์ฟ ํ‚ค์—์„œ ํ† ํฐ ์ฝ๊ธฐ (์„œ๋ฒ„์—์„œ๋งŒ ๊ฐ€๋Šฅ!) let 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(); console.log('๐Ÿ”ต [PROXY] Request:', method, url.toString()); console.log('๐Ÿ”ต [PROXY] Request Body:', body); // ๋””๋ฒ„๊น…์šฉ } else if (contentType.includes('multipart/form-data')) { // multipart/form-data ์ฒ˜๋ฆฌ: FormData๋ฅผ ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ console.log('๐Ÿ“Ž [PROXY] Processing multipart/form-data request'); isFormData = true; // ์›๋ณธ ์š”์ฒญ์˜ FormData ์ฝ๊ธฐ const originalFormData = await request.formData(); // ์ƒˆ FormData ์ƒ์„ฑ (๋ฐฑ์—”๋“œ ์ „์†ก์šฉ) const newFormData = new FormData(); // ๋ชจ๋“  ํ•„๋“œ ๋ณต์‚ฌ for (const [key, value] of originalFormData.entries()) { if (value instanceof File) { // File ๊ฐ์ฒด๋Š” ๊ทธ๋Œ€๋กœ ์ถ”๊ฐ€ newFormData.append(key, value, value.name); console.log(`๐Ÿ“Ž [PROXY] File field: ${key} = ${value.name} (${value.size} bytes)`); } else { // ์ผ๋ฐ˜ ํ•„๋“œ newFormData.append(key, value); console.log(`๐Ÿ“Ž [PROXY] Form field: ${key} = ${value}`); } } body = newFormData; console.log('๐Ÿ”ต [PROXY] Request:', method, url.toString()); } } else { console.log('๐Ÿ”ต [PROXY] Request:', method, url.toString()); } // 4. ๋ฐฑ์—”๋“œ๋กœ ํ”„๋ก์‹œ ์š”์ฒญ let backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData); let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null; // 5. ๐Ÿ”„ 401 ์‘๋‹ต ์‹œ ํ† ํฐ ๊ฐฑ์‹  ํ›„ ์žฌ์‹œ๋„ if (backendResponse.status === 401 && refreshToken) { console.log('๐Ÿ”„ [PROXY] Got 401, attempting token refresh...'); const refreshResult = await refreshAccessToken(refreshToken); if (refreshResult.success && refreshResult.accessToken) { console.log('โœ… [PROXY] Token refreshed, retrying original request...'); // ์ƒˆ ํ† ํฐ์œผ๋กœ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„ token = refreshResult.accessToken; newTokens = refreshResult; backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData); console.log('๐Ÿ”ต [PROXY] Retry response status:', backendResponse.status); } else { // ๋ฆฌํ”„๋ ˆ์‹œ ์‹คํŒจ โ†’ ์ฟ ํ‚ค ์‚ญ์ œํ•˜๊ณ  401 ๋ฐ˜ํ™˜ console.warn('๐Ÿ”ด [PROXY] Token refresh 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; } } // 6. ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ฝ๊ธฐ console.log('๐Ÿ”ต [PROXY] Response status:', backendResponse.status); const responseContentType = backendResponse.headers.get('content-type') || 'application/json'; // 7. ๋ฐ”์ด๋„ˆ๋ฆฌ ํŒŒ์ผ vs ํ…์ŠคํŠธ/JSON ๊ตฌ๋ถ„ // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ (PDF, ์ด๋ฏธ์ง€, ๋“ฑ)๋Š” ๋ฐ”์ด๋„ˆ๋ฆฌ๋กœ ์ฒ˜๋ฆฌํ•ด์•ผ ์†์ƒ๋˜์ง€ ์•Š์Œ 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) { // ๋ฐ”์ด๋„ˆ๋ฆฌ ํŒŒ์ผ: arrayBuffer๋กœ ์ฝ์–ด์„œ ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ console.log('๐Ÿ“„ [PROXY] Binary response detected:', responseContentType); 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 { // JSON/ํ…์ŠคํŠธ: text๋กœ ์ฝ์–ด์„œ ์ „๋‹ฌ const responseData = await backendResponse.text(); clientResponse = new NextResponse(responseData, { status: backendResponse.status, headers: { 'Content-Type': responseContentType, }, }); } // 8. ํ† ํฐ์ด ๊ฐฑ์‹ ๋˜์—ˆ์œผ๋ฉด ์ƒˆ ์ฟ ํ‚ค ์„ค์ • if (newTokens && newTokens.accessToken) { createTokenCookies(newTokens).forEach(cookie => { clientResponse.headers.append('Set-Cookie', cookie); }); console.log('๐Ÿช [PROXY] New tokens set in cookies'); } 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'); }