summaryrefslogtreecommitdiff
path: root/middleware.ts
diff options
context:
space:
mode:
Diffstat (limited to 'middleware.ts')
-rw-r--r--middleware.ts175
1 files changed, 167 insertions, 8 deletions
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<string, string> = {
+ '/': '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: [