feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬
- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결) - frame-src에 'self' 추가 - 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto) - HR 사원관리, 결재, 품목, 생산 등 다수 개선 - API 에러 핸들링 및 JSON 파싱 안정화
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', '')}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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="전개도"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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="새 탭에서 열기"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user