| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- '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<RequestInit, 'body'> {
- body?: unknown;
- /** true로 설정 시 에러 응답을 throw하지 않고 ResultDto를 그대로 반환 */
- silent?: boolean;
- }
- // 토큰 갱신 동기화 — 동일 탭 내 중복 + 다중 탭 동기화 + 네트워크 재시도
- let refreshPromise: Promise<boolean>|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('dpot-auth-refresh')
- : null;
- let externalRefreshWaiter: { promise: Promise<boolean>; resolve: (v: boolean) => void; nonce: string }|null = null;
- if (refreshChannel) {
- refreshChannel.addEventListener('message', (e: MessageEvent<RefreshBroadcastMsg>) => {
- const msg = e.data;
- if (msg.type === 'refresh-start') {
- // 다른 탭이 refresh 시작 — 우리도 동시 refresh 하지 않고 대기
- if (!externalRefreshWaiter && !refreshPromise) {
- let resolveFn!: (v: boolean) => void;
- const p = new Promise<boolean>(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<boolean> {
- // 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<unknown> = 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<T>(url: string, options?: FetchApiOptions): Promise<ResultDto<T>>
- {
- 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<T> = 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<T>(res: ResultDto<T>): 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);
- }
|