From fbb3b7f05737f9571b04b0a8f4f15c0928de8545 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 01:43:36 +0000 Subject: (대표님) 변경사항 20250707 10시 43분 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware.ts | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 167 insertions(+), 8 deletions(-) (limited to 'middleware.ts') diff --git a/middleware.ts b/middleware.ts index 6424a02f..e32415dd 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,8 +5,11 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import acceptLanguage from 'accept-language'; import { getToken } from 'next-auth/jwt'; +// UAParser 임포트 수정 +import { UAParser } from 'ua-parser-js'; import { fallbackLng, languages, cookieName } from '@/i18n/settings'; +import { SessionRepository } from './lib/users/session/repository'; acceptLanguage.languages(languages); @@ -25,6 +28,27 @@ const publicPaths = [ '/auth/reset-password', ]; +// 페이지 추적에서 제외할 경로들 +const trackingExcludePaths = [ + '/api', + '/_next', + '/favicon.ico', + '/robots.txt', + '/sitemap.xml', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.svg', + '.ico', + '.css', + '.js', + '.woff', + '.woff2', + '.ttf', + '.eot' +]; + // 경로가 공개 경로인지 확인하는 함수 function isPublicPath(path: string, lng: string) { // 1. 정확한 로그인 페이지 매칭 (/ko/evcp, /en/partners 등) @@ -40,6 +64,13 @@ function isPublicPath(path: string, lng: string) { return false; } +// 페이지 추적 제외 경로인지 확인 +function shouldExcludeFromTracking(pathname: string): boolean { + return trackingExcludePaths.some(excludePath => + pathname.startsWith(excludePath) || pathname.includes(excludePath) + ); +} + // 도메인별 기본 대시보드 경로 정의 function getDashboardPath(domain: string, lng: string): string { switch (domain) { @@ -171,6 +202,125 @@ function createLoginUrl(pathname: string, detectedLng: string, origin: string, r return redirectUrl; } +// 클라이언트 IP 추출 함수 (수정됨) +function getClientIP(request: NextRequest): string { + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + const cfConnectingIP = request.headers.get('cf-connecting-ip'); // Cloudflare + + if (cfConnectingIP) { + return cfConnectingIP; + } + + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + + if (realIP) { + return realIP; + } + + // NextRequest에는 ip 프로퍼티가 없으므로 기본값 반환 + return '127.0.0.1'; +} + +// 디바이스 타입 판단 함수 +function getDeviceType(deviceType?: string): string { + if (!deviceType) return 'desktop'; + if (deviceType === 'mobile') return 'mobile'; + if (deviceType === 'tablet') return 'tablet'; + return 'desktop'; +} + +// 페이지 제목 추출 함수 +function extractPageTitle(pathname: string, lng: string): string { + // 언어 코드 제거 + const cleanPath = pathname.replace(`/${lng}`, '') || '/'; + + // 라우트 기반 페이지 제목 매핑 + const titleMap: Record = { + '/': 'Home', + '/evcp': 'EVCP Login', + '/evcp/report': 'EVCP Report', + '/evcp/dashboard': 'EVCP Dashboard', + '/procurement': 'Procurement Login', + '/procurement/dashboard': 'Procurement Dashboard', + '/sales': 'Sales Login', + '/sales/dashboard': 'Sales Dashboard', + '/engineering': 'Engineering Login', + '/engineering/dashboard': 'Engineering Dashboard', + '/partners': 'Partners Login', + '/partners/dashboard': 'Partners Dashboard', + '/pending': 'Pending', + '/profile': 'Profile', + '/settings': 'Settings', + }; + + // 정확한 매칭 우선 + if (titleMap[cleanPath]) { + return titleMap[cleanPath]; + } + + // 부분 매칭으로 fallback + for (const [route, title] of Object.entries(titleMap)) { + if (cleanPath.startsWith(route) && route !== '/') { + return title; + } + } + + return cleanPath || 'Unknown Page'; +} + +// 페이지 방문 추적 함수 (비동기, 논블로킹) - UAParser 수정됨 +async function trackPageVisit(request: NextRequest, token: any, detectedLng: string) { + // 백그라운드에서 실행하여 메인 요청을 블로킹하지 않음 + setImmediate(async () => { + try { + const { pathname, searchParams } = request.nextUrl; + + // 추적 제외 경로 체크 + if (shouldExcludeFromTracking(pathname)) { + return; + } + + const userAgent = request.headers.get('user-agent') || ''; + // UAParser 사용 방법 수정 + const parser = new UAParser(userAgent); + const result = parser.getResult(); + + // 활성 세션 조회 및 업데이트 + let sessionId = null; + if (token?.id && token?.dbSessionId) { + sessionId = token.dbSessionId; + + // 세션 활동 시간 업데이트 (await 없이 비동기 실행) + SessionRepository.updateSessionActivity(sessionId).catch(error => { + console.error('Failed to update session activity:', error); + }); + } + + // 페이지 방문 기록 + await SessionRepository.recordPageVisit({ + userId: token?.id || undefined, + sessionId, + route: pathname, + pageTitle: extractPageTitle(pathname, detectedLng), + referrer: request.headers.get('referer') || undefined, + ipAddress: getClientIP(request), + userAgent, + queryParams: searchParams.toString() || undefined, + deviceType: getDeviceType(result.device.type), + browserName: result.browser.name || undefined, + osName: result.os.name || undefined, + }); + + } catch (error) { + // 추적 실패는 로그만 남기고 메인 플로우에 영향 주지 않음 + console.error('Failed to track page visit:', error); + } + }); +} + export async function middleware(request: NextRequest) { /** * 1. 쿠키에서 언어 가져오기 @@ -220,7 +370,16 @@ export async function middleware(request: NextRequest) { const token = await getToken({ req: request }); /** - * 6. 세션 타임아웃 체크 (인증된 사용자에 대해서만) + * 6. 페이지 방문 추적 (비동기, 논블로킹) + * - 리다이렉트가 발생하기 전에 실행 + * - API나 정적 파일은 제외 + */ + if (!shouldExcludeFromTracking(pathname)) { + trackPageVisit(request, token, detectedLng); // await 하지 않음 (논블로킹) + } + + /** + * 7. 세션 타임아웃 체크 (인증된 사용자에 대해서만) */ if (token && !isPublicPath(pathname, detectedLng)) { const { isExpired, isExpiringSoon } = checkSessionTimeout(token); @@ -233,7 +392,7 @@ export async function middleware(request: NextRequest) { } /** - * 7. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션 + * 8. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션 */ if (token && token.domain && !isPublicPath(pathname, detectedLng)) { // 사용자의 domain과 URL 경로가 일치하는지 확인 @@ -250,7 +409,7 @@ export async function middleware(request: NextRequest) { } /** - * 8. 이미 로그인한 사용자가 로그인 페이지에 접근할 경우 대시보드로 리다이렉트 + * 9. 이미 로그인한 사용자가 로그인 페이지에 접근할 경우 대시보드로 리다이렉트 */ if (token) { // 세션이 만료되지 않은 경우에만 대시보드로 리다이렉트 @@ -278,7 +437,7 @@ export async function middleware(request: NextRequest) { } /** - * 9. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트 + * 10. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트 */ if (!isPublicPath(pathname, detectedLng)) { if (!token) { @@ -295,12 +454,12 @@ export async function middleware(request: NextRequest) { } /** - * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. + * 11. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. */ const response = NextResponse.next(); /** - * 11. 세션 만료 경고를 위한 헤더 추가 + * 12. 세션 만료 경고를 위한 헤더 추가 */ if (token && !isPublicPath(pathname, detectedLng)) { const { isExpiringSoon } = checkSessionTimeout(token); @@ -313,7 +472,7 @@ export async function middleware(request: NextRequest) { } /** - * 12. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트 + * 13. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트 */ const currentCookie = request.cookies.get(cookieName)?.value; if (detectedLng && detectedLng !== currentCookie) { @@ -324,7 +483,7 @@ export async function middleware(request: NextRequest) { } /** - * 13. 매칭할 경로 설정 + * 14. 매칭할 경로 설정 */ export const config = { matcher: [ -- cgit v1.2.3