'use client'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { TokenData, ResultDto, ApiError } from '@/types/response/common'; import { CLAIM_NAME_IDENTIFIER, CLAIM_EMAIL, CLAIM_NAME } from '@/constants/common'; // 서버·클라이언트 공용 순수 유틸 재export (기존 import 호환) export { formatDate, getDateTime, isDateOverdue } from './format'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } // 클라이언트용 fetch 유틸 (Route Handler 호출용) interface FetchApiOptions extends Omit { body?: unknown; /** true로 설정 시 에러 응답을 throw하지 않고 ResultDto를 그대로 반환 */ silent?: boolean; } // 토큰 갱신 동기화 — 동일 탭 내 중복 + 다중 탭 동기화 + 네트워크 재시도 let refreshPromise: Promise|null = null; // 다중 탭 동기화 — 한 탭이 refresh 중일 때 다른 탭은 결과를 기다림 // 회전+재사용 감지 정책 하에서 같은 refreshToken으로 동시 호출 시 전체 세션 폐기되는 race 방지 type RefreshBroadcastMsg = | { type: 'refresh-start'; nonce: string } | { type: 'refresh-done'; nonce: string; success: boolean }; const refreshChannel: BroadcastChannel|null = typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('antooza-auth-refresh') : null; let externalRefreshWaiter: { promise: Promise; resolve: (v: boolean) => void; nonce: string }|null = null; if (refreshChannel) { refreshChannel.addEventListener('message', (e: MessageEvent) => { const msg = e.data; if (msg.type === 'refresh-start') { // 다른 탭이 refresh 시작 — 우리도 동시 refresh 하지 않고 대기 if (!externalRefreshWaiter && !refreshPromise) { let resolveFn!: (v: boolean) => void; const p = new Promise(r => { resolveFn = r; }); externalRefreshWaiter = { promise: p, resolve: resolveFn, nonce: msg.nonce }; console.log('[Auth] Refresh in progress in another tab — waiting', msg.nonce); // 안전장치: 10초 안에 done 안 오면 자체 refresh setTimeout(() => { if (externalRefreshWaiter?.nonce === msg.nonce) { console.warn('[Auth] External refresh timeout — falling back to self refresh'); externalRefreshWaiter.resolve(false); externalRefreshWaiter = null; } }, 10000); } } else if (msg.type === 'refresh-done') { if (externalRefreshWaiter?.nonce === msg.nonce) { console.log('[Auth] External refresh done', msg.nonce, 'success=', msg.success); externalRefreshWaiter.resolve(msg.success); externalRefreshWaiter = null; } } }); } async function tryRefreshToken(): Promise { // 1) 같은 탭 내 동시 호출 — 같은 promise 공유 if (refreshPromise) { return refreshPromise; } // 2) 다른 탭이 이미 refresh 중 — 결과 기다림 if (externalRefreshWaiter) { return externalRefreshWaiter.promise; } const nonce = Math.random().toString(36).slice(2); refreshChannel?.postMessage({ type: 'refresh-start', nonce } satisfies RefreshBroadcastMsg); refreshPromise = (async () => { // 네트워크 일시 끊김 대비 재시도 (최대 3회). 단, 명시적 인증 실패(401/403)는 즉시 종료. for (let attempt = 0; attempt < 3; attempt++) { try { const httpRes = await fetch('/api/auth/refresh-token', { method: 'POST' }); const data: ResultDto = await httpRes.json(); if (data.success) { console.log('[Auth] Refresh success', `attempt=${attempt + 1}`); return true; } if (data.status === 401 || data.status === 403) { console.warn('[Auth] Refresh rejected (auth)', `status=${data.status}`, data.message); return false; } console.warn('[Auth] Refresh non-auth failure — retrying', `status=${data.status}`, `attempt=${attempt + 1}`); } catch (err) { console.warn('[Auth] Refresh network error — retrying', `attempt=${attempt + 1}`, err); } await new Promise(r => setTimeout(r, 300 * (attempt + 1))); } console.error('[Auth] Refresh failed after 3 attempts'); return false; })(); // 결과 broadcast + promise 해제 const currentPromise = refreshPromise; currentPromise.then(success => { refreshChannel?.postMessage({ type: 'refresh-done', nonce, success } satisfies RefreshBroadcastMsg); }).finally(() => { if (refreshPromise === currentPromise) { refreshPromise = null; } }); return refreshPromise; } export async function fetchApi(url: string, options?: FetchApiOptions): Promise> { const isFormData = options?.body instanceof FormData; const hasBody = options?.body !== undefined; const fetchOptions: RequestInit = { ...options, headers: { ...(!isFormData && hasBody ? { 'Content-Type': 'application/json' } : {}), ...options?.headers }, body: isFormData ? options.body as BodyInit : (options?.body ? JSON.stringify(options.body) : undefined) }; let res: ResultDto = await (await fetch(url, fetchOptions)).json(); // 401 시 토큰 갱신 후 재시도 if (!res.success && res.status === 401) { const refreshed = await tryRefreshToken(); if (refreshed) { res = await (await fetch(url, fetchOptions)).json(); } else if (typeof window !== 'undefined') { // silent 모드여도 인증 실패 시 강제 로그아웃 이벤트 발행 (UI 와 실제 상태 어긋남 방지) window.dispatchEvent(new CustomEvent('auth:unauthorized')); } } // 기본: 에러 시 throw (silent 옵션으로 비활성화) if (!options?.silent) { throwError(res); } return res; } // JWT 토큰에서 사용자 정보 추출 export function decodeAccessToken(token: string): TokenData|null { try { const base64URL = token.split('.')[1]; // JWT의 Payload 부분 const base64 = base64URL.replace(/-/g, '+').replace(/_/g, '/'); // Base64 형식 변환 const jsonPayload = decodeURIComponent( atob(base64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('') ); const payload = JSON.parse(jsonPayload); return { id: payload[CLAIM_NAME_IDENTIFIER] || null, email: payload[CLAIM_EMAIL] || null, name: payload[CLAIM_NAME] || null }; } catch { return null; } } // 파일 이름 생성 export function getDateFilename(prefix: string): string { if (!prefix) { return ''; } const now = new Date(); const yyyy = now.getFullYear(); const MM = String(now.getMonth() + 1).padStart(2, '0'); const dd = String(now.getDate()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); const ss = String(now.getSeconds()).padStart(2, '0'); return `${prefix}-${yyyy}${MM}${dd}${hh}${mm}${ss}.png`; } // HTML 태그 제거 export function stripHtmlTags(str?: string|null): string { if (!str) { return ''; } const div = document.createElement('div'); div.innerHTML = str; return div.textContent || div.innerText || ''; } // 비밀번호 정책 안내 문구 생성성 export function getPasswordPolicyMessage(policy: { passwordUppercaseLength: number; passwordNumbersLength: number; passwordSpecialcharsLength: number; }) { const parts: string[] = []; if (policy.passwordUppercaseLength > 0) { parts.push(`영문 대문자 ${policy.passwordUppercaseLength}자 이상`); } if (policy.passwordNumbersLength > 0) { parts.push(`숫자 ${policy.passwordNumbersLength}자 이상`); } if (policy.passwordSpecialcharsLength > 0) { parts.push(`특수문자 ${policy.passwordSpecialcharsLength}자 이상`); } if (parts.length > 0) { return `비밀번호는 ${parts.join(', ')}를 포함해야 합니다.`; } return ''; } // 특수문자 제거 export function filterSpecialCharacters(str: string) { return str.replace(/[^\p{L}\p{N}\s]/gu, ''); } // ResultDto에서 첫 번째 오류 조회 function getError(errors: ApiError[]|null|undefined, index = 0): string|undefined { if (!errors || errors.length === 0) { return undefined; } return errors[index]?.description; } // 서버 응답 메시지 분석 function throwError(res: ResultDto): void { if (res.success) { return; } let message: string|null|undefined = getError(res.errors); if (!message) { message = res.message; } if (!message) { switch (res.status) { case 400: message = '잘못된 요청입니다.'; break; case 401: message = '로그인 후 이용해 주세요.'; // 인증 만료 이벤트 발행 window.dispatchEvent(new CustomEvent('auth:unauthorized'));; break; case 403: message = '권한이 없습니다.'; break; case 404: message = '요청하신 페이지를 찾을 수 없습니다.'; break; case 500: message = '서버 오류가 발생했습니다. 다시 시도해 주세요.'; break; } } if (message) { throw new Error(message); } } // 1000회 이상 조회된 게시글인지 확인 export function isHotPost(showHotIcon: boolean, post: { views: number; createdAt: string }): boolean { return (showHotIcon && post.views >= 1000 && Date.now() - new Date(post.createdAt).getTime() < 7 * 24 * 60 * 60 * 1000); } // 24시간 이내에 작성된 게시글인지 확인 export function isNewPost(showNewIcon: boolean, post: { createdAt: string }): boolean { return (showNewIcon && Date.now() - new Date(post.createdAt).getTime() < 24 * 60 * 60 * 1000); }