feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬

- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결)
- frame-src에 'self' 추가
- 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto)
- HR 사원관리, 결재, 품목, 생산 등 다수 개선
- API 에러 핸들링 및 JSON 파싱 안정화
This commit is contained in:
유병철
2026-03-11 22:32:58 +09:00
parent e9ac2470e1
commit ea6ca335f1
24 changed files with 625 additions and 139 deletions

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticatedFetch } from '@/lib/api/authenticated-fetch';
import { stripJsonTrailingData } from '@/lib/api/safe-json-parse';
/**
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
@@ -190,7 +191,7 @@ async function proxyRequest(
},
});
} else {
const responseData = await backendResponse.text();
let responseData = await backendResponse.text();
// 백엔드가 HTML 에러 페이지를 반환한 경우 (404/500 등)
// HTML을 그대로 전달하면 클라이언트 response.json()에서 SyntaxError 발생
@@ -208,6 +209,10 @@ async function proxyRequest(
{ status }
);
} else {
// PHP trailing output 제거 (JSON 뒤에 warning/error 텍스트가 붙는 경우)
if (responseContentType.includes('application/json') || responseData.trimStart().startsWith('{') || responseData.trimStart().startsWith('[')) {
responseData = stripJsonTrailingData(responseData);
}
clientResponse = new NextResponse(responseData, {
status: backendResponse.status,
headers: {

View File

@@ -171,7 +171,7 @@ export async function uploadFiles(files: File[]): Promise<{
uploadedFiles.push({
id: result.data.id,
name: result.data.display_name || file.name,
url: `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${result.data.id}/download`,
url: `/api/proxy/files/${result.data.id}/download`,
size: result.data.file_size,
mime_type: result.data.mime_type,
});
@@ -589,7 +589,7 @@ function transformApiToFormData(apiData: {
// URL이 없거나 상대 경로인 경우 다운로드 URL 생성
url: f.url?.startsWith('http')
? f.url
: `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${f.id}/download`,
: `/api/proxy/files/${f.id}/download`,
size: f.size,
mime_type: f.mime_type,
}));

View File

@@ -112,8 +112,8 @@ export function NoticePopupModal({ popup, open, onOpenChange }: NoticePopupModal
{/* 제목 */}
<h3 className="text-base font-medium mb-4">{popup.title}</h3>
{/* 이미지 영역 */}
{popup.imageUrl ? (
{/* 이미지 영역 - imageUrl이 있을 때만 표시 */}
{popup.imageUrl && (
<div className="relative w-full aspect-[4/3] mb-4 rounded-md overflow-hidden border bg-muted">
<img
src={popup.imageUrl}
@@ -121,17 +121,13 @@ export function NoticePopupModal({ popup, open, onOpenChange }: NoticePopupModal
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-full aspect-[4/3] mb-4 rounded-md border bg-muted flex items-center justify-center">
<span className="text-muted-foreground text-sm">IMG</span>
</div>
)}
{/* 내용 */}
<div className="text-sm text-foreground mb-6">
<p className="text-muted-foreground mb-2"></p>
<div
className="prose prose-sm max-w-none"
className="prose prose-sm max-w-none [&_img]:mx-auto [&_img]:block"
dangerouslySetInnerHTML={{ __html: sanitizeHTML(popup.content) }}
/>
</div>

View File

@@ -43,7 +43,7 @@ import {
EMPLOYEE_STATUS_LABELS,
DEFAULT_FIELD_SETTINGS,
} from './types';
import { getPositions, getDepartments, uploadProfileImage, type PositionItem, type DepartmentItem } from './actions';
import { getPositions, getDepartments, type PositionItem, type DepartmentItem } from './actions';
import { extractDigits } from '@/lib/formatters';
// 부서 트리 구조 타입
@@ -578,19 +578,33 @@ export function EmployeeForm({
// 미리보기 즉시 표시
const previewUrl = URL.createObjectURL(file);
handleChange('profileImage', previewUrl);
// 서버에 업로드 (FormData로 감싸서 전송)
const uploadFormData = new FormData();
uploadFormData.append('file', file);
const result = await uploadProfileImage(uploadFormData);
if (result.success && result.data?.url) {
// 업로드 성공 시 서버 URL로 업데이트
URL.revokeObjectURL(previewUrl);
handleChange('profileImage', result.data.url);
} else {
// 업로드 실패 시 미리보기 제거 및 에러 표시
try {
// 프록시를 통해 직접 업로드 (서버 액션 경유 시 FormData File 손실 방지)
const uploadFormData = new FormData();
uploadFormData.append('file', file);
uploadFormData.append('directory', 'employees/profiles');
const response = await fetch('/api/proxy/files/upload', {
method: 'POST',
body: uploadFormData,
});
if (!response.ok) {
throw new Error(`업로드 실패: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
URL.revokeObjectURL(previewUrl);
const fileId = result.data.id;
const viewUrl = fileId
? `/api/proxy/files/${fileId}/view`
: result.data.file_path || '';
handleChange('profileImage', viewUrl);
} else {
throw new Error(result.message || '업로드 실패');
}
} catch (err) {
URL.revokeObjectURL(previewUrl);
handleChange('profileImage', '');
toast.error(result.error || '이미지 업로드에 실패했습니다.');
toast.error(err instanceof Error ? err.message : '이미지 업로드에 실패했습니다.');
}
}}
onRemove={() => handleChange('profileImage', '')}

View File

@@ -213,7 +213,7 @@ export async function getDepartments(): Promise<DepartmentItem[]> {
export async function uploadProfileImage(inputFormData: FormData): Promise<{
success: boolean;
data?: { url: string; path: string };
data?: { url: string; path: string; fileId?: number };
error?: string;
__authError?: boolean;
}> {
@@ -243,18 +243,21 @@ export async function uploadProfileImage(inputFormData: FormData): Promise<{
const result = await response.json();
if (!result.success) return { success: false, error: result.message || '파일 업로드에 실패했습니다.' };
const uploadedPath = result.data?.file_path || result.data?.path || result.data?.url;
if (!uploadedPath) return { success: false, error: '업로드된 파일 경로를 가져올 수 없습니다.' };
// R2 전환: /storage/ 직접 접근 불가 → /api/proxy/files/{id}/view 사용
const fileId = result.data?.id;
const uploadedPath = result.data?.file_path || result.data?.path || '';
const storagePath = uploadedPath.startsWith('/storage/')
? uploadedPath
: `/storage/tenants/${uploadedPath}`;
// file_id가 있으면 프록시 경로 사용, 없으면 fallback
const viewUrl = fileId
? `/api/proxy/files/${fileId}/view`
: uploadedPath;
return {
success: true,
data: {
url: `${API_URL}${storagePath}`,
url: viewUrl,
path: uploadedPath,
fileId,
},
};
} catch (error) {

View File

@@ -27,7 +27,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
* 프로필 이미지 경로를 전체 URL로 변환
* - 이미 전체 URL이면 그대로 반환
* - base64 data URL이면 그대로 반환
* - 상대 경로면 API URL + /storage/tenants/ 붙여서 반환
* - /api/proxy/files/ 경로면 그대로 반환
* - 상대 경로면 그대로 반환 (R2 전환 후 직접 접근 불가)
*/
export function getProfileImageUrl(path: string | null | undefined): string | undefined {
if (!path) return undefined;
@@ -42,8 +43,14 @@ export function getProfileImageUrl(path: string | null | undefined): string | un
return path;
}
// 상대 경로인 경우 API URL과 결합 (tenants 디렉토리 사용)
return `${API_URL}/storage/tenants/${path}`;
// 프록시 경로인 경우 (/api/proxy/files/{id}/view)
if (path.startsWith('/api/proxy/')) {
return path;
}
// 상대 경로인 경우 — R2 전환 후 /storage/tenants/ 직접 접근 불가
// 경로만 보존하고 표시는 프록시 통해서 처리
return path;
}
/**
@@ -65,13 +72,23 @@ export function extractRelativePath(path: string | null | undefined): string | n
return null;
}
// 프록시 경로인 경우 (/api/proxy/files/{id}/view) - 그대로 반환
if (path.startsWith('/api/proxy/')) {
return path;
}
// 전체 URL인 경우 상대 경로 추출
if (path.startsWith('http://') || path.startsWith('https://')) {
// /storage/tenants/ 이후의 경로 추출
// /storage/tenants/ 이후의 경로 추출 (레거시)
const match = path.match(/\/storage\/tenants\/(.+)$/);
if (match) {
return match[1];
}
// /api/proxy/files/ 경로 추출
const proxyMatch = path.match(/(\/api\/proxy\/files\/.+)$/);
if (proxyMatch) {
return proxyMatch[1];
}
// 매칭 실패 시 null 반환
return null;
}

View File

@@ -82,9 +82,9 @@ function getStorageUrl(path: string | undefined): string | null {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 상대 경로인 경우
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${apiUrl}/storage/${path}`;
// R2 전환 후 /storage/ 직접 접근 불가 → 레거시 경로는 빈 값 반환
// bendingDiagramFileId가 있으면 /view 엔드포인트를 사용하므로 여기는 폴백 전용
return null;
}
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
@@ -372,7 +372,7 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.bendingDiagramFileId
? `/api/proxy/files/${item.bendingDiagramFileId}/download`
? `/api/proxy/files/${item.bendingDiagramFileId}/view`
: getStorageUrl(item.bendingDiagram) || ''
}
alt="전개도"

View File

@@ -191,7 +191,7 @@ export default function BendingDiagramSection({
)}
<div className="border rounded bg-white p-2">
<img
src={`/api/proxy/files/${existingBendingDiagramFileId}/download`}
src={`/api/proxy/files/${existingBendingDiagramFileId}/view`}
alt="기존 전개도"
className="max-w-full h-auto max-h-96 mx-auto"
onError={(e) => {

View File

@@ -1851,7 +1851,7 @@ export async function uploadInspectionFiles(files: File[]): Promise<{
uploadedFiles.push({
id: result.data.id,
name: result.data.display_name || file.name,
url: buildApiUrl(`/api/v1/files/${result.data.id}/download`),
url: `/api/proxy/files/${result.data.id}/download`,
size: result.data.file_size,
});
}

View File

@@ -68,15 +68,16 @@ interface TemplateInspectionContentProps {
// ===== 유틸 =====
/** API 저장소 이미지 URL 생성 (사원관리와 동일 패턴) */
function getImageUrl(path: string | null | undefined): string {
/** API 저장소 이미지 URL 생성 — R2 전환 후 프록시 사용 */
function getImageUrl(path: string | null | undefined, fileId?: number | null): string {
if (!path && !fileId) return '';
// file_id가 있으면 프록시 경로 사용
if (fileId) return `/api/proxy/files/${fileId}/view`;
if (!path) return '';
if (path.startsWith('http://') || path.startsWith('https://')) return path;
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
// tenant storage 경로 (숫자/로 시작: {tenant_id}/temp/...)
if (/^\d+\//.test(path)) return `${apiUrl}/storage/tenants/${path}`;
// 레거시 경로 (document-templates/xxx.jpg 등)
return `${apiUrl}/storage/${path}`;
if (path.startsWith('/api/proxy/')) return path;
// R2 전환 후 /storage/ 직접 접근 불가 — 경로만 반환 (fallback)
return path;
}
/** field_values.reference_attribute에서 작업 아이템의 실제 치수를 resolve */

View File

@@ -388,7 +388,7 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
<div className="p-4 flex items-center justify-center min-h-[100px]">
{section.imagePath ? (
<img
src={`${process.env.NEXT_PUBLIC_API_URL}/storage/${section.imagePath}`}
src={section.imagePath || ''}
alt={section.name}
className="max-h-[300px] max-w-full object-contain"
/>

View File

@@ -7,8 +7,9 @@ import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
/**
* 상대 경로를 절대 URL로 변환
* /storage/... 또는 1/temp/... → https://api.example.com/storage/tenants/...
* 상대 경로를 표시 가능한 URL로 변환
* R2 전환 후: /api/proxy/files/{id}/view 사용
* 레거시 경로는 그대로 반환 (표시 불가할 수 있음)
*/
function toAbsoluteUrl(path: string | undefined): string | undefined {
if (!path) return undefined;
@@ -16,12 +17,12 @@ function toAbsoluteUrl(path: string | undefined): string | undefined {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
// /storage/로 시작하면 그대로, 아니면 /storage/tenants/ 붙이기
if (path.startsWith('/storage/')) {
return `${apiUrl}${path}`;
// 프록시 경로면 그대로 반환
if (path.startsWith('/api/proxy/')) {
return path;
}
return `${apiUrl}/storage/tenants/${path}`;
// R2 전환 후 /storage/ 직접 접근 불가 — 경로만 보존
return path;
}
// ===== 계정 정보 조회 =====
@@ -142,12 +143,14 @@ export async function uploadProfileImage(formData: FormData): Promise<{
if (updateResult.__authError) return { success: false, __authError: true };
if (!updateResult.success) return { success: false, error: updateResult.error };
const storagePath = uploadedPath.startsWith('/storage/')
? uploadedPath
: `/storage/tenants/${uploadedPath}`;
// R2 전환: file_id 기반 프록시 경로 사용
const fileId = uploadResult.data.id;
const viewUrl = fileId
? `/api/proxy/files/${fileId}/view`
: uploadedPath;
return {
success: true,
data: { imageUrl: toAbsoluteUrl(storagePath) || '' },
data: { imageUrl: viewUrl },
};
}

View File

@@ -168,13 +168,13 @@ export function FileList({
</Button>
)}
{/* 새 탭에서 열기 */}
{file.url && (
{/* 새 탭에서 열기 (인라인 뷰) */}
{file.id && (
<Button
type="button"
variant="ghost"
size={compact ? 'sm' : 'default'}
onClick={() => window.open(file.url, '_blank')}
onClick={() => window.open(`/api/proxy/files/${file.id}/view`, '_blank')}
className={cn('text-gray-600 hover:text-gray-700', compact && 'h-7 w-7 p-0')}
title="새 탭에서 열기"
>

View File

@@ -114,11 +114,12 @@ export async function executeServerAction<TApi = unknown, TResult = TApi>(
return { success: true };
}
// JSON 파싱 (백엔드가 HTML 에러 페이지 반환 시 안전 처리)
// JSON 파싱 (PHP trailing output + HTML 에러 페이지 안전 처리)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: any;
try {
result = await response.json();
const { safeResponseJson } = await import('./safe-json-parse');
result = await safeResponseJson(response);
} catch {
const status = response.status;
console.error(`[executeServerAction] JSON 파싱 실패 (${method} ${url}, status: ${status})`);

View File

@@ -183,7 +183,19 @@ class ServerApiClient {
return undefined as T;
}
return await response.json();
// 방어적 JSON 파싱: PHP 백엔드가 JSON 뒤에 경고/에러 텍스트를 붙여 보내는 경우 대응
const text = await response.text();
try {
return JSON.parse(text);
} catch (parseError) {
// JSON 뒤에 trailing garbage가 있는 경우 복구 시도
const match = text.match(/^(\{[\s\S]*\}|\[[\s\S]*\])/);
if (match) {
console.warn('[ServerApiClient] Response contained trailing data, recovering:', text.slice(match[1].length, match[1].length + 100));
return JSON.parse(match[1]);
}
throw parseError;
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
throw error;

View File

@@ -74,7 +74,9 @@ async function doRefreshToken(refreshToken: string): Promise<RefreshResult> {
return { success: false };
}
const data = await response.json();
// 방어적 JSON 파싱: PHP가 JSON 뒤에 warning을 붙여 보내는 경우 대응
const { safeResponseJson } = await import('./safe-json-parse');
const data = await safeResponseJson<{ access_token: string; refresh_token: string; expires_in: number }>(response);
return {
success: true,

View File

@@ -0,0 +1,98 @@
/**
* 방어적 JSON 파싱 유틸리티
*
* PHP 백엔드가 JSON 출력 후 warning/error 텍스트를 붙여 보내는 경우 대응.
* 예: {"success":true,"data":[...]}<br />Warning: Undefined variable...
*
* JSON.parse는 유효한 JSON 이후 trailing data가 있으면
* "Unexpected non-whitespace character after JSON at position N" 에러를 발생시킴.
* 이때 position N까지가 유효한 JSON이므로, 해당 지점까지 잘라서 재파싱.
*/
/**
* PHP trailing output을 제거하고 JSON 파싱
*
* @param text - JSON 문자열 (trailing garbage 포함 가능)
* @returns 파싱된 JSON 객체
* @throws SyntaxError - 유효한 JSON이 아닌 경우 (복구 불가)
*/
export function safeJsonParse<T = unknown>(text: string): T {
try {
return JSON.parse(text);
} catch (e) {
if (!(e instanceof SyntaxError)) throw e;
// "Unexpected ... after JSON at position N" 패턴에서 position 추출
const posMatch = e.message.match(/position\s+(\d+)/i);
if (posMatch) {
const pos = parseInt(posMatch[1], 10);
if (pos > 0) {
try {
const result = JSON.parse(text.substring(0, pos)) as T;
console.warn(
`[safeJsonParse] PHP trailing output detected (${text.length - pos} bytes). ` +
`Trailing: ${text.substring(pos, pos + 200).replace(/\n/g, '\\n')}`
);
return result;
} catch {
// truncation으로도 실패하면 원래 에러 throw
}
}
}
throw e;
}
}
/**
* Response 객체에서 방어적으로 JSON 파싱
*
* response.json() 대신 response.text() + safeJsonParse() 사용.
* PHP trailing output이 있어도 유효한 JSON을 복구.
*/
export async function safeResponseJson<T = unknown>(response: Response): Promise<T> {
const text = await response.text();
return safeJsonParse<T>(text);
}
/**
* JSON 텍스트에서 trailing garbage 제거
*
* 프록시에서 사용: 파싱하지 않고 텍스트 레벨에서 trailing data만 제거.
* JSON이 아니거나 trailing data가 없으면 원본 그대로 반환.
*/
export function stripJsonTrailingData(text: string): string {
// JSON이 아닌 경우 원본 반환
const trimmed = text.trimStart();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return text;
}
try {
// 정상 파싱 되면 trailing data 없음
JSON.parse(text);
return text;
} catch (e) {
if (!(e instanceof SyntaxError)) return text;
const posMatch = e.message.match(/position\s+(\d+)/i);
if (posMatch) {
const pos = parseInt(posMatch[1], 10);
if (pos > 0) {
const truncated = text.substring(0, pos);
try {
// truncated가 유효한 JSON인지 확인
JSON.parse(truncated);
console.warn(
`[stripJsonTrailingData] Removed ${text.length - pos} bytes of trailing data`
);
return truncated;
} catch {
// 복구 불가 → 원본 반환
}
}
}
return text;
}
}

View File

@@ -51,12 +51,10 @@ export async function downloadFileById(fileId: number, fileName?: string): Promi
}
/**
* 파일 경로로 새 탭에서 열기 (미리보기)
* @param filePath 파일 경로
* 파일 ID로 새 탭에서 열기 (인라인 미리보기)
* R2 스토리지 전환으로 /storage/ 직접 접근 불가 → /view 엔드포인트 사용
* @param fileId 파일 ID
*/
export function openFileInNewTab(filePath: string): void {
// 백엔드 파일 서빙 URL 구성
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const fileUrl = `${baseUrl}/storage/${filePath}`;
window.open(fileUrl, '_blank');
export function openFileInNewTab(fileId: number): void {
window.open(`/api/proxy/files/${fileId}/view`, '_blank');
}

View File

@@ -199,6 +199,23 @@ export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const userAgent = request.headers.get('user-agent') || '';
// 🚨 -2⃣ Server Action 요청 처리
// Server Action POST 요청은 Next-Action 헤더를 가짐
// intlMiddleware가 RSC 응답을 간섭하면 직렬화가 깨지므로
// locale만 수동 rewrite하고 intlMiddleware는 건너뜀
if (request.headers.get('next-action')) {
// 기본 locale(ko)은 URL에 없으므로 locale prefix 추가하여 rewrite
const hasLocale = locales.some(
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
);
if (!hasLocale) {
const url = request.nextUrl.clone();
url.pathname = `/${defaultLocale}${pathname}`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
// 🚨 -1⃣ Next.js 내부 요청 필터링
// 동적 라우트 세그먼트가 리터럴로 포함된 요청은 Next.js 내부 컴파일/prefetch
// 예: /[locale]/settings/... 형태의 요청은 실제 사용자 요청이 아님
@@ -307,12 +324,12 @@ export async function middleware(request: NextRequest) {
intlResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
intlResponse.headers.set('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com *.daumcdn.net",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com *.daumcdn.net *.kakaocdn.net *.kakao.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self' https://maps.googleapis.com *.daum.net *.daumcdn.net",
"frame-src *.daum.net *.daumcdn.net",
"connect-src 'self' https://maps.googleapis.com *.daum.net *.daumcdn.net *.kakao.com *.kakaocdn.net",
"frame-src 'self' *.daum.net *.daumcdn.net *.kakao.com *.kakaocdn.net",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",