client.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. 'use client';
  2. import { clsx, type ClassValue } from 'clsx';
  3. import { twMerge } from 'tailwind-merge';
  4. import { TokenData, ResultDto, ApiError } from '@/types/response/common';
  5. import { CLAIM_NAME_IDENTIFIER, CLAIM_EMAIL, CLAIM_NAME } from '@/constants/common';
  6. // 서버·클라이언트 공용 순수 유틸 재export (기존 import 호환)
  7. export { formatDate, getDateTime, isDateOverdue } from './format';
  8. export function cn(...inputs: ClassValue[]) {
  9. return twMerge(clsx(inputs))
  10. }
  11. // 클라이언트용 fetch 유틸 (Route Handler 호출용)
  12. interface FetchApiOptions extends Omit<RequestInit, 'body'> {
  13. body?: unknown;
  14. /** true로 설정 시 에러 응답을 throw하지 않고 ResultDto를 그대로 반환 */
  15. silent?: boolean;
  16. }
  17. // 토큰 갱신 동기화 — 동일 탭 내 중복 + 다중 탭 동기화 + 네트워크 재시도
  18. let refreshPromise: Promise<boolean>|null = null;
  19. // 다중 탭 동기화 — 한 탭이 refresh 중일 때 다른 탭은 결과를 기다림
  20. // 회전+재사용 감지 정책 하에서 같은 refreshToken으로 동시 호출 시 전체 세션 폐기되는 race 방지
  21. type RefreshBroadcastMsg =
  22. | { type: 'refresh-start'; nonce: string }
  23. | { type: 'refresh-done'; nonce: string; success: boolean };
  24. const refreshChannel: BroadcastChannel|null = typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined'
  25. ? new BroadcastChannel('antooza-auth-refresh')
  26. : null;
  27. let externalRefreshWaiter: { promise: Promise<boolean>; resolve: (v: boolean) => void; nonce: string }|null = null;
  28. if (refreshChannel) {
  29. refreshChannel.addEventListener('message', (e: MessageEvent<RefreshBroadcastMsg>) => {
  30. const msg = e.data;
  31. if (msg.type === 'refresh-start') {
  32. // 다른 탭이 refresh 시작 — 우리도 동시 refresh 하지 않고 대기
  33. if (!externalRefreshWaiter && !refreshPromise) {
  34. let resolveFn!: (v: boolean) => void;
  35. const p = new Promise<boolean>(r => { resolveFn = r; });
  36. externalRefreshWaiter = { promise: p, resolve: resolveFn, nonce: msg.nonce };
  37. console.log('[Auth] Refresh in progress in another tab — waiting', msg.nonce);
  38. // 안전장치: 10초 안에 done 안 오면 자체 refresh
  39. setTimeout(() => {
  40. if (externalRefreshWaiter?.nonce === msg.nonce) {
  41. console.warn('[Auth] External refresh timeout — falling back to self refresh');
  42. externalRefreshWaiter.resolve(false);
  43. externalRefreshWaiter = null;
  44. }
  45. }, 10000);
  46. }
  47. } else if (msg.type === 'refresh-done') {
  48. if (externalRefreshWaiter?.nonce === msg.nonce) {
  49. console.log('[Auth] External refresh done', msg.nonce, 'success=', msg.success);
  50. externalRefreshWaiter.resolve(msg.success);
  51. externalRefreshWaiter = null;
  52. }
  53. }
  54. });
  55. }
  56. async function tryRefreshToken(): Promise<boolean> {
  57. // 1) 같은 탭 내 동시 호출 — 같은 promise 공유
  58. if (refreshPromise) {
  59. return refreshPromise;
  60. }
  61. // 2) 다른 탭이 이미 refresh 중 — 결과 기다림
  62. if (externalRefreshWaiter) {
  63. return externalRefreshWaiter.promise;
  64. }
  65. const nonce = Math.random().toString(36).slice(2);
  66. refreshChannel?.postMessage({ type: 'refresh-start', nonce } satisfies RefreshBroadcastMsg);
  67. refreshPromise = (async () => {
  68. // 네트워크 일시 끊김 대비 재시도 (최대 3회). 단, 명시적 인증 실패(401/403)는 즉시 종료.
  69. for (let attempt = 0; attempt < 3; attempt++) {
  70. try {
  71. const httpRes = await fetch('/api/auth/refresh-token', { method: 'POST' });
  72. const data: ResultDto<unknown> = await httpRes.json();
  73. if (data.success) {
  74. console.log('[Auth] Refresh success', `attempt=${attempt + 1}`);
  75. return true;
  76. }
  77. if (data.status === 401 || data.status === 403) {
  78. console.warn('[Auth] Refresh rejected (auth)', `status=${data.status}`, data.message);
  79. return false;
  80. }
  81. console.warn('[Auth] Refresh non-auth failure — retrying', `status=${data.status}`, `attempt=${attempt + 1}`);
  82. } catch (err) {
  83. console.warn('[Auth] Refresh network error — retrying', `attempt=${attempt + 1}`, err);
  84. }
  85. await new Promise(r => setTimeout(r, 300 * (attempt + 1)));
  86. }
  87. console.error('[Auth] Refresh failed after 3 attempts');
  88. return false;
  89. })();
  90. // 결과 broadcast + promise 해제
  91. const currentPromise = refreshPromise;
  92. currentPromise.then(success => {
  93. refreshChannel?.postMessage({ type: 'refresh-done', nonce, success } satisfies RefreshBroadcastMsg);
  94. }).finally(() => {
  95. if (refreshPromise === currentPromise) {
  96. refreshPromise = null;
  97. }
  98. });
  99. return refreshPromise;
  100. }
  101. export async function fetchApi<T>(url: string, options?: FetchApiOptions): Promise<ResultDto<T>>
  102. {
  103. const isFormData = options?.body instanceof FormData;
  104. const hasBody = options?.body !== undefined;
  105. const fetchOptions: RequestInit = {
  106. ...options,
  107. headers: {
  108. ...(!isFormData && hasBody ? { 'Content-Type': 'application/json' } : {}),
  109. ...options?.headers
  110. },
  111. body: isFormData ? options.body as BodyInit : (options?.body ? JSON.stringify(options.body) : undefined)
  112. };
  113. let res: ResultDto<T> = await (await fetch(url, fetchOptions)).json();
  114. // 401 시 토큰 갱신 후 재시도
  115. if (!res.success && res.status === 401) {
  116. const refreshed = await tryRefreshToken();
  117. if (refreshed) {
  118. res = await (await fetch(url, fetchOptions)).json();
  119. } else if (typeof window !== 'undefined') {
  120. // silent 모드여도 인증 실패 시 강제 로그아웃 이벤트 발행 (UI 와 실제 상태 어긋남 방지)
  121. window.dispatchEvent(new CustomEvent('auth:unauthorized'));
  122. }
  123. }
  124. // 기본: 에러 시 throw (silent 옵션으로 비활성화)
  125. if (!options?.silent) {
  126. throwError(res);
  127. }
  128. return res;
  129. }
  130. // JWT 토큰에서 사용자 정보 추출
  131. export function decodeAccessToken(token: string): TokenData|null
  132. {
  133. try {
  134. const base64URL = token.split('.')[1]; // JWT의 Payload 부분
  135. const base64 = base64URL.replace(/-/g, '+').replace(/_/g, '/'); // Base64 형식 변환
  136. const jsonPayload = decodeURIComponent(
  137. atob(base64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')
  138. );
  139. const payload = JSON.parse(jsonPayload);
  140. return {
  141. id: payload[CLAIM_NAME_IDENTIFIER] || null,
  142. email: payload[CLAIM_EMAIL] || null,
  143. name: payload[CLAIM_NAME] || null
  144. };
  145. } catch {
  146. return null;
  147. }
  148. }
  149. // 파일 이름 생성
  150. export function getDateFilename(prefix: string): string {
  151. if (!prefix) {
  152. return '';
  153. }
  154. const now = new Date();
  155. const yyyy = now.getFullYear();
  156. const MM = String(now.getMonth() + 1).padStart(2, '0');
  157. const dd = String(now.getDate()).padStart(2, '0');
  158. const hh = String(now.getHours()).padStart(2, '0');
  159. const mm = String(now.getMinutes()).padStart(2, '0');
  160. const ss = String(now.getSeconds()).padStart(2, '0');
  161. return `${prefix}-${yyyy}${MM}${dd}${hh}${mm}${ss}.png`;
  162. }
  163. // HTML 태그 제거
  164. export function stripHtmlTags(str?: string|null): string {
  165. if (!str) {
  166. return '';
  167. }
  168. const div = document.createElement('div');
  169. div.innerHTML = str;
  170. return div.textContent || div.innerText || '';
  171. }
  172. // 비밀번호 정책 안내 문구 생성성
  173. export function getPasswordPolicyMessage(policy: {
  174. passwordUppercaseLength: number;
  175. passwordNumbersLength: number;
  176. passwordSpecialcharsLength: number;
  177. }) {
  178. const parts: string[] = [];
  179. if (policy.passwordUppercaseLength > 0) {
  180. parts.push(`영문 대문자 ${policy.passwordUppercaseLength}자 이상`);
  181. }
  182. if (policy.passwordNumbersLength > 0) {
  183. parts.push(`숫자 ${policy.passwordNumbersLength}자 이상`);
  184. }
  185. if (policy.passwordSpecialcharsLength > 0) {
  186. parts.push(`특수문자 ${policy.passwordSpecialcharsLength}자 이상`);
  187. }
  188. if (parts.length > 0) {
  189. return `비밀번호는 ${parts.join(', ')}를 포함해야 합니다.`;
  190. }
  191. return '';
  192. }
  193. // 특수문자 제거
  194. export function filterSpecialCharacters(str: string) {
  195. return str.replace(/[^\p{L}\p{N}\s]/gu, '');
  196. }
  197. // ResultDto에서 첫 번째 오류 조회
  198. function getError(errors: ApiError[]|null|undefined, index = 0): string|undefined {
  199. if (!errors || errors.length === 0) {
  200. return undefined;
  201. }
  202. return errors[index]?.description;
  203. }
  204. // 서버 응답 메시지 분석
  205. function throwError<T>(res: ResultDto<T>): void {
  206. if (res.success) {
  207. return;
  208. }
  209. let message: string|null|undefined = getError(res.errors);
  210. if (!message) {
  211. message = res.message;
  212. }
  213. if (!message) {
  214. switch (res.status) {
  215. case 400: message = '잘못된 요청입니다.'; break;
  216. case 401:
  217. message = '로그인 후 이용해 주세요.';
  218. // 인증 만료 이벤트 발행
  219. window.dispatchEvent(new CustomEvent('auth:unauthorized'));;
  220. break;
  221. case 403: message = '권한이 없습니다.'; break;
  222. case 404: message = '요청하신 페이지를 찾을 수 없습니다.'; break;
  223. case 500: message = '서버 오류가 발생했습니다. 다시 시도해 주세요.'; break;
  224. }
  225. }
  226. if (message) {
  227. throw new Error(message);
  228. }
  229. }
  230. // 1000회 이상 조회된 게시글인지 확인
  231. export function isHotPost(showHotIcon: boolean, post: { views: number; createdAt: string }): boolean {
  232. return (showHotIcon && post.views >= 1000 && Date.now() - new Date(post.createdAt).getTime() < 7 * 24 * 60 * 60 * 1000);
  233. }
  234. // 24시간 이내에 작성된 게시글인지 확인
  235. export function isNewPost(showNewIcon: boolean, post: { createdAt: string }): boolean {
  236. return (showNewIcon && Date.now() - new Date(post.createdAt).getTime() < 24 * 60 * 60 * 1000);
  237. }