refactor(WEB): Server Action 공통화 및 보안 강화
- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일) - sanitize 유틸 추가 (XSS 방지) - middleware CSP 헤더 추가 및 Open Redirect 방지 - 프록시 라우트 로깅 개발환경 한정으로 변경 - 프로덕션 불필요 console.log 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { CompanyFormData } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// API 응답 타입
|
||||
interface TenantApiData {
|
||||
id: number;
|
||||
@@ -35,138 +36,61 @@ interface TenantApiData {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 정보 조회
|
||||
*/
|
||||
export async function getCompanyInfo(): Promise<{
|
||||
success: boolean;
|
||||
data?: CompanyFormData & { tenantId: number };
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
|
||||
{
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '회사 정보 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '회사 정보 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const apiData: TenantApiData = result.data;
|
||||
const formData = transformApiToFrontend(apiData);
|
||||
|
||||
return { success: true, data: formData };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[getCompanyInfo] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
// ===== 테넌트 정보 조회 =====
|
||||
export async function getCompanyInfo(): Promise<ActionResult<CompanyFormData & { tenantId: number }>> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/tenants`,
|
||||
transform: (data: TenantApiData) => transformApiToFrontend(data),
|
||||
errorMessage: '회사 정보 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 정보 수정
|
||||
*/
|
||||
// ===== 테넌트 정보 수정 =====
|
||||
export async function updateCompanyInfo(
|
||||
tenantId: number,
|
||||
data: Partial<CompanyFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: CompanyFormData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformFrontendToApi(tenantId, data);
|
||||
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '회사 정보 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '회사 정보 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const updatedData = transformApiToFrontend(result.data);
|
||||
return { success: true, data: updatedData };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[updateCompanyInfo] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
): Promise<ActionResult<CompanyFormData & { tenantId: number }>> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/tenants`,
|
||||
method: 'PUT',
|
||||
body: transformFrontendToApi(tenantId, data),
|
||||
transform: (d: TenantApiData) => transformApiToFrontend(d),
|
||||
errorMessage: '회사 정보 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상대 경로를 절대 URL로 변환
|
||||
* /storage/... → https://api.example.com/storage/...
|
||||
*/
|
||||
// ===== 회사 로고 업로드 =====
|
||||
export async function uploadCompanyLogo(formData: FormData): Promise<ActionResult<{ logoUrl: string }>> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/tenants/logo`,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
transform: (data: { logo_url?: string }) => ({
|
||||
logoUrl: toAbsoluteUrl(data?.logo_url) || '',
|
||||
}),
|
||||
errorMessage: '로고 업로드에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 유틸리티 =====
|
||||
|
||||
function toAbsoluteUrl(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
// 이미 절대 URL이면 그대로 반환
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
// 상대 경로면 API URL 붙이기
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
return `${apiUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → Frontend 변환
|
||||
*
|
||||
* 기본 필드: company_name, ceo_name, email, phone, business_num, address
|
||||
* 확장 필드: options JSON에서 읽어옴
|
||||
*/
|
||||
function transformApiToFrontend(apiData: TenantApiData): CompanyFormData & { tenantId: number } {
|
||||
const opts = apiData.options || {};
|
||||
|
||||
return {
|
||||
// tenantId (API 응답의 id 필드)
|
||||
tenantId: apiData.id,
|
||||
// 기본 필드
|
||||
companyName: apiData.company_name || '',
|
||||
representativeName: apiData.ceo_name || '',
|
||||
email: apiData.email || '',
|
||||
managerPhone: apiData.phone || '',
|
||||
businessNumber: apiData.business_num || '',
|
||||
address: apiData.address || '',
|
||||
// 로고 URL (상대 경로 → 절대 URL 변환)
|
||||
companyLogo: toAbsoluteUrl(apiData.logo),
|
||||
businessType: opts.business_type || '',
|
||||
businessCategory: opts.business_category || '',
|
||||
@@ -182,25 +106,12 @@ function transformApiToFrontend(apiData: TenantApiData): CompanyFormData & { ten
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend → API 변환
|
||||
*
|
||||
* 기본 필드: tenant_id, company_name, ceo_name, email, phone, business_num, address
|
||||
* 확장 필드: options JSON에 저장 (businessType, businessCategory, taxInvoiceEmail 등)
|
||||
*/
|
||||
function transformFrontendToApi(
|
||||
tenantId: number,
|
||||
data: Partial<CompanyFormData>
|
||||
): Record<string, unknown> {
|
||||
// 로고 URL에서 상대 경로 추출 (API는 상대 경로 기대)
|
||||
function transformFrontendToApi(tenantId: number, data: Partial<CompanyFormData>): Record<string, unknown> {
|
||||
let logoPath: string | null = null;
|
||||
if (data.companyLogo && typeof data.companyLogo === 'string') {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
logoPath = data.companyLogo.startsWith(apiUrl)
|
||||
? data.companyLogo.replace(apiUrl, '')
|
||||
: data.companyLogo;
|
||||
logoPath = data.companyLogo.startsWith(apiUrl) ? data.companyLogo.replace(apiUrl, '') : data.companyLogo;
|
||||
}
|
||||
|
||||
return {
|
||||
tenant_id: tenantId,
|
||||
company_name: data.companyName,
|
||||
@@ -208,75 +119,14 @@ function transformFrontendToApi(
|
||||
email: data.email,
|
||||
phone: data.managerPhone,
|
||||
business_num: data.businessNumber,
|
||||
// 로고 (삭제 시 null)
|
||||
logo: logoPath,
|
||||
// address: 우편번호 + 주소 + 상세주소 결합
|
||||
address: [data.zipCode, data.address, data.addressDetail]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
// 확장 필드 (options JSON)
|
||||
address: [data.zipCode, data.address, data.addressDetail].filter(Boolean).join(' '),
|
||||
options: {
|
||||
business_type: data.businessType,
|
||||
business_category: data.businessCategory,
|
||||
zip_code: data.zipCode,
|
||||
address_detail: data.addressDetail,
|
||||
tax_invoice_email: data.taxInvoiceEmail,
|
||||
manager_name: data.managerName,
|
||||
payment_bank: data.paymentBank,
|
||||
payment_account: data.paymentAccount,
|
||||
payment_account_holder: data.paymentAccountHolder,
|
||||
payment_day: data.paymentDay,
|
||||
business_type: data.businessType, business_category: data.businessCategory,
|
||||
zip_code: data.zipCode, address_detail: data.addressDetail,
|
||||
tax_invoice_email: data.taxInvoiceEmail, manager_name: data.managerName,
|
||||
payment_bank: data.paymentBank, payment_account: data.paymentAccount,
|
||||
payment_account_holder: data.paymentAccountHolder, payment_day: data.paymentDay,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 로고 업로드
|
||||
* @param formData - FormData (클라이언트에서 생성, 'logo' 키로 파일 포함)
|
||||
*/
|
||||
export async function uploadCompanyLogo(formData: FormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { logoUrl: string };
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants/logo`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// FormData는 Content-Type을 자동으로 설정하므로 headers 제거
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '로고 업로드에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '로고 업로드에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
logoUrl: toAbsoluteUrl(result.data?.logo_url) || '',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[uploadCompanyLogo] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user