server.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. 'use server';
  2. import { cookies, headers } from 'next/headers'
  3. import { ResultDto, TokenData, ApiError } from '@/types/response/common';
  4. import BoardManager from '@/types/forum/boardManager';
  5. import { MemberResponse } from '@/types/response/account/member';
  6. import { CLAIM_NAME_IDENTIFIER, CLAIM_EMAIL, CLAIM_NAME } from '@/constants/common';
  7. const API_URL = process.env.API_URL;
  8. // 클라이언트 → Next.js 서버 → 백엔드 경유 시 원본 요청자 정보(User-Agent, IP)를 forward.
  9. // 미전달 시 Node fetch가 기본 'User-Agent: node'를 전송해 MemberLoginLog 등에 가비지가 쌓임.
  10. async function getForwardHeaders(): Promise<Record<string, string>> {
  11. try {
  12. const h = await headers();
  13. const fwd: Record<string, string> = {};
  14. const ua = h.get('user-agent');
  15. if (ua) {
  16. fwd['User-Agent'] = ua;
  17. }
  18. // 클라이언트 실제 IP 추출: 이미 X-Forwarded-For 체인이 있으면 보존, 없으면 X-Real-IP 사용
  19. const xff = h.get('x-forwarded-for');
  20. const xri = h.get('x-real-ip');
  21. if (xff) {
  22. fwd['X-Forwarded-For'] = xff;
  23. } else if (xri) {
  24. fwd['X-Forwarded-For'] = xri;
  25. }
  26. if (xri) {
  27. fwd['X-Real-IP'] = xri;
  28. }
  29. const referer = h.get('referer');
  30. if (referer) {
  31. fwd['Referer'] = referer;
  32. }
  33. return fwd;
  34. } catch {
  35. // headers() 컨텍스트 밖(예: 빌드 타임)에서 호출되면 무시
  36. return {};
  37. }
  38. }
  39. export async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<ResultDto<T>> {
  40. try {
  41. const actionURL = `${API_URL}${url.startsWith('/') ? url : `/${url}`}`;
  42. const cookie = await cookies();
  43. const cookieData = cookie.getAll().filter(c => c.name !== 'member').map(c => `${c.name}=${c.value}`).join("; ");
  44. const accessToken = cookie.get('accessToken')?.value;
  45. const forwardHeaders = await getForwardHeaders();
  46. const res = await fetch(actionURL, {
  47. ...options,
  48. headers: {
  49. ...(options.body && !(options.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
  50. ...forwardHeaders,
  51. ...(options.headers || {}),
  52. ...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}),
  53. Cookie: cookieData,
  54. 'Accept': 'application/json'
  55. },
  56. cache: 'no-store'
  57. });
  58. // Set-Cookie 헤더 처리 — 백엔드 응답 쿠키를 Next.js 쿠키 저장소에 저장
  59. const setCookieHeader = res.headers.getSetCookie();
  60. if (setCookieHeader && setCookieHeader.length > 0)
  61. {
  62. const cookieStore = await cookies();
  63. for (const cookieString of setCookieHeader) {
  64. const parts = cookieString.split(';').map(p => p.trim());
  65. const [nameValue, ...attributes] = parts;
  66. const eqIndex = nameValue.indexOf('=');
  67. if (eqIndex === -1) {
  68. continue;
  69. }
  70. const name = nameValue.substring(0, eqIndex);
  71. const value = nameValue.substring(eqIndex + 1);
  72. const options: Record<string, unknown> = {};
  73. for (const attr of attributes) {
  74. const [key, val] = attr.split('=');
  75. const lowerKey = key.toLowerCase().trim();
  76. if (lowerKey === 'httponly') options.httpOnly = true;
  77. else if (lowerKey === 'secure') options.secure = true;
  78. else if (lowerKey === 'path') options.path = val;
  79. else if (lowerKey === 'samesite') options.sameSite = val?.toLowerCase() as 'lax' | 'strict' | 'none';
  80. else if (lowerKey === 'max-age') options.maxAge = parseInt(val);
  81. else if (lowerKey === 'expires') options.expires = new Date(val);
  82. }
  83. cookieStore.set(name, value, options);
  84. }
  85. }
  86. if (res.status === 204) {
  87. return { success: true, status: 204, message: null, data: null, errors: null } satisfies ResultDto<T>;
  88. }
  89. const contentType = res.headers.get('content-type');
  90. if (!contentType || !contentType.includes('application/json')) {
  91. return {
  92. success: false,
  93. status: res.status,
  94. message: null,
  95. data: null,
  96. errors: null
  97. } satisfies ResultDto<T>;
  98. }
  99. return await res.json() as ResultDto<T>;
  100. } catch (err) {
  101. let message = '서버와 통신이 불가합니다.';
  102. const status = 500;
  103. if (err instanceof Error) {
  104. message = err.message;
  105. }
  106. console.warn(`[${status}] ${message}`);
  107. return {
  108. success: false,
  109. status: status,
  110. message: message,
  111. data: null,
  112. errors: null
  113. } satisfies ResultDto<T>;
  114. }
  115. }
  116. export async function getAccessToken(): Promise<string|null> {
  117. return (await cookies()).get('accessToken')?.value ?? null;
  118. }
  119. export async function getRefreshToken(): Promise<string|null> {
  120. return (await cookies()).get('refreshToken')?.value ?? null;
  121. }
  122. // SignalR Chat 서버 URL 조회 (/hubs/chat — ChatHub: 채팅, 향후 1:1 실시간 문의)
  123. export async function getSignalRChatUrl(): Promise<string> {
  124. return process.env.SIGNALR_CHAT_URL as string;
  125. }
  126. // SignalR App 서버 URL 조회 (/hubs/app — AppHub: 알림, 쪽지, 크루, 피드)
  127. export async function getSignalRAppUrl(): Promise<string> {
  128. return process.env.SIGNALR_APP_URL as string;
  129. }
  130. // JWT 토큰에서 사용자 정보 추출
  131. export async function getTokenData(): Promise<TokenData|null> {
  132. try {
  133. const token = await getAccessToken();
  134. if (!token) {
  135. throw new Error('Access token not found');
  136. }
  137. const base64URL = token.split('.')[1]; // JWT의 Payload 부분
  138. const base64 = base64URL.replace(/-/g, '+').replace(/_/g, '/'); // Base64 형식 변환
  139. const jsonPayload = decodeURIComponent(
  140. atob(base64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')
  141. );
  142. const payload = JSON.parse(jsonPayload);
  143. return {
  144. id: payload[CLAIM_NAME_IDENTIFIER] || null,
  145. email: payload[CLAIM_EMAIL] || null,
  146. name: payload[CLAIM_NAME] || null
  147. };
  148. } catch {
  149. return null;
  150. }
  151. }
  152. // 첫번째 오류 조회
  153. async function getFirstError(errors: ApiError[] | null): Promise<string|null> {
  154. if (!errors || errors.length === 0) {
  155. return null;
  156. }
  157. return errors[0].description ?? null;
  158. }
  159. // 서버 응답 메시지 분석
  160. export async function throwError<T>(res: ResultDto<T>): Promise<void> {
  161. if (res.success) {
  162. return;
  163. }
  164. let message:string|null = await getFirstError(res.errors);
  165. if (!message && res.message) {
  166. message = res.message;
  167. }
  168. switch (res.status) {
  169. case 400:
  170. throw Error(message || '잘못된 요청입니다.');
  171. case 401:
  172. throw Error('로그인 후 이용해 주세요.');
  173. case 403:
  174. throw Error('권한이 없습니다.');
  175. case 404:
  176. throw Error('요청하신 페이지를 찾을 수 없습니다.');
  177. }
  178. if (message) {
  179. throw Error(message);
  180. }
  181. }
  182. // 게시판, 게시글, 댓글 등 권한 확인
  183. export async function checkPermission(permission: number, boardManager: BoardManager[]): Promise<boolean> {
  184. if (permission <= -1) {
  185. return true;
  186. }
  187. const raw = (await cookies()).get('member')?.value;
  188. const member = raw ? JSON.parse(decodeURIComponent(raw)) as MemberResponse : null;
  189. if (!member) {
  190. return false;
  191. }
  192. // 최고관리자는 항상 허용
  193. if (member.isAdmin) {
  194. return true;
  195. }
  196. // 유효 권한 레벨 계산 (매니저=99, 일반=memberGrade.order)
  197. const isBoardManager = boardManager.some(manager => member.id && manager.user.email === member.email);
  198. const level = isBoardManager ? 99 : (member.memberGrade?.order ?? 0);
  199. return permission <= level;
  200. }