From 1363913352722a03e051b15297f72bf16d80106f Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 7 Nov 2025 17:39:36 +0900
Subject: (김준회) 돌체 업로드 MIME 타입 검증 문제 확장자로 처리
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ship-vendor-document/add-attachment-dialog.tsx | 55 ++++++++-
.../ship-vendor-document/new-revision-dialog.tsx | 51 ++++++++-
lib/file-stroage.ts | 12 +-
.../enhanced-document-service.ts | 33 +++++-
lib/vendor-document-list/service.ts | 36 ++++++
.../ship/bulk-b4-upload-dialog.tsx | 51 ++++++++-
.../ship/import-from-dolce-button.tsx | 126 ++++++++++++---------
7 files changed, 297 insertions(+), 67 deletions(-)
diff --git a/components/ship-vendor-document/add-attachment-dialog.tsx b/components/ship-vendor-document/add-attachment-dialog.tsx
index 6765bcf5..4a51c3b5 100644
--- a/components/ship-vendor-document/add-attachment-dialog.tsx
+++ b/components/ship-vendor-document/add-attachment-dialog.tsx
@@ -38,7 +38,7 @@ import { useSession } from "next-auth/react"
* -----------------------------------------------------------------------------------------------*/
// 파일 검증 스키마
-const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 50MB
+const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
const ACCEPTED_FILE_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@@ -73,7 +73,7 @@ const attachmentUploadSchema = z.object({
// .max(10, "Maximum 10 files can be uploaded")
.refine(
(files) => files.every((file) => file.size <= MAX_FILE_SIZE),
- "File size must be 50MB or less"
+ "File size must be 1GB or less"
)
// .refine(
// (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)),
@@ -101,10 +101,46 @@ function FileUploadArea({
}) {
const fileInputRef = React.useRef(null)
+ // 파일 검증 함수
+ const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']
+
+ const valid: File[] = []
+ const invalid: string[] = []
+
+ filesToValidate.forEach(file => {
+ // 파일 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`)
+ return
+ }
+
+ // 파일 확장자 검증
+ const extension = file.name.split('.').pop()?.toLowerCase()
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`)
+ return
+ }
+
+ valid.push(file)
+ })
+
+ return { valid, invalid }
+ }
+
const handleFileSelect = (event: React.ChangeEvent) => {
const selectedFiles = Array.from(event.target.files || [])
if (selectedFiles.length > 0) {
- onFilesChange([...files, ...selectedFiles])
+ const { valid, invalid } = validateFiles(selectedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -112,7 +148,15 @@ function FileUploadArea({
event.preventDefault()
const droppedFiles = Array.from(event.dataTransfer.files)
if (droppedFiles.length > 0) {
- onFilesChange([...files, ...droppedFiles])
+ const { valid, invalid } = validateFiles(droppedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -147,6 +191,9 @@ function FileUploadArea({
Supports PDF, Word, Excel, Image, Text, ZIP, CAD files (DWG, DXF, STEP, STL, IGES) (max 1GB)
+
+ Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd
+
Note: File names cannot contain these characters: < > : " ' | ? *
diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx
index 91694827..bdbb1bc6 100644
--- a/components/ship-vendor-document/new-revision-dialog.tsx
+++ b/components/ship-vendor-document/new-revision-dialog.tsx
@@ -83,10 +83,46 @@ function FileUploadArea({
}) {
const fileInputRef = React.useRef(null)
+ // 파일 검증 함수
+ const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']
+
+ const valid: File[] = []
+ const invalid: string[] = []
+
+ filesToValidate.forEach(file => {
+ // 파일 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`)
+ return
+ }
+
+ // 파일 확장자 검증
+ const extension = file.name.split('.').pop()?.toLowerCase()
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`)
+ return
+ }
+
+ valid.push(file)
+ })
+
+ return { valid, invalid }
+ }
+
const handleFileSelect = (event: React.ChangeEvent) => {
const selectedFiles = Array.from(event.target.files || [])
if (selectedFiles.length > 0) {
- onFilesChange([...files, ...selectedFiles])
+ const { valid, invalid } = validateFiles(selectedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -94,7 +130,15 @@ function FileUploadArea({
event.preventDefault()
const droppedFiles = Array.from(event.dataTransfer.files)
if (droppedFiles.length > 0) {
- onFilesChange([...files, ...droppedFiles])
+ const { valid, invalid } = validateFiles(droppedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -132,6 +176,9 @@ function FileUploadArea({
Note: File names cannot contain these characters: < > : " ' | ? *
+
+ Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd
+
console.error('Error fetching contract IDs by vendor:', error)
return []
}
+}
+
+/**
+ * 프로젝트 ID 배열로 프로젝트 정보를 조회하는 서버 액션
+ * @param projectIds - 프로젝트 ID 배열
+ * @returns 프로젝트 정보 배열 [{ id, code, name }]
+ */
+export async function getProjectsByIds(projectIds: number[]): Promise> {
+ try {
+ if (projectIds.length === 0) {
+ return []
+ }
+
+ // null 값 제거
+ const validProjectIds = projectIds.filter((id): id is number => id !== null && !isNaN(id))
+
+ if (validProjectIds.length === 0) {
+ return []
+ }
+
+ const projectsData = await db
+ .select({
+ id: projects.id,
+ code: projects.code,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(inArray(projects.id, validProjectIds))
+ .orderBy(projects.code)
+
+ return projectsData
+ } catch (error) {
+ console.error('프로젝트 정보 조회 중 오류:', error)
+ return []
+ }
}
\ No newline at end of file
diff --git a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx
index 3ff2f467..be656a48 100644
--- a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx
+++ b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx
@@ -163,9 +163,53 @@ export function BulkB4UploadDialog({
setPendingProjectId("")
}
+ // 파일 검증 함수
+ const validateFile = (file: File): { valid: boolean; error?: string } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']
+
+ // 파일 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ return {
+ valid: false,
+ error: `파일 크기가 1GB를 초과합니다 (${(file.size / (1024 * 1024 * 1024)).toFixed(2)}GB)`
+ }
+ }
+
+ // 파일 확장자 검증
+ const extension = file.name.split('.').pop()?.toLowerCase()
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ return {
+ valid: false,
+ error: `금지된 파일 형식입니다 (.${extension})`
+ }
+ }
+
+ return { valid: true }
+ }
+
// 파일 선택 시 파싱
const handleFilesChange = (files: File[]) => {
- const parsed = files.map(file => {
+ const validFiles: File[] = []
+ const invalidFiles: string[] = []
+
+ // 파일 검증
+ files.forEach(file => {
+ const validation = validateFile(file)
+ if (validation.valid) {
+ validFiles.push(file)
+ } else {
+ invalidFiles.push(`${file.name}: ${validation.error}`)
+ }
+ })
+
+ // 유효하지 않은 파일이 있으면 토스트 표시
+ if (invalidFiles.length > 0) {
+ invalidFiles.forEach(msg => toast.error(msg))
+ }
+
+ // 유효한 파일만 파싱
+ const parsed = validFiles.map(file => {
const { docNumber, revision } = parseFileName(file.name)
return {
file,
@@ -429,7 +473,10 @@ export function BulkB4UploadDialog({
}
- PDF, DOC, DOCX, XLS, XLSX, DWG, DXF
+ PDF, DOC, DOCX, XLS, XLSX, DWG, DXF (max 1GB per file)
+
+
+ Forbidden: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd
diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
index 76d66960..dfbd0600 100644
--- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx
+++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
@@ -24,21 +24,28 @@ import { Separator } from "@/components/ui/separator"
import { SimplifiedDocumentsView } from "@/db/schema"
import { ImportStatus } from "../import-service"
import { useSession } from "next-auth/react"
-import { getProjectIdsByVendor } from "../service"
+import { getProjectIdsByVendor, getProjectsByIds } from "../service"
import { useParams } from "next/navigation"
import { useTranslation } from "@/i18n/client"
-// 🔥 API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유)
+// API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유)
const statusCache = new Map()
const CACHE_TTL = 2 * 60 * 1000 // 2분 캐시
interface ImportFromDOLCEButtonProps {
allDocuments: SimplifiedDocumentsView[]
- projectIds?: number[] // 🔥 미리 계산된 projectIds를 props로 받음
+ projectIds?: number[] // 미리 계산된 projectIds를 props로 받음
onImportComplete?: () => void
}
-// 🔥 디바운스 훅
+// 프로젝트 정보 타입
+interface ProjectInfo {
+ id: number
+ code: string
+ name: string
+}
+
+// 디바운스 훅
function useDebounce(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = React.useState(value)
@@ -67,6 +74,7 @@ export function ImportFromDOLCEButton({
const [statusLoading, setStatusLoading] = React.useState(false)
const [vendorProjectIds, setVendorProjectIds] = React.useState([])
const [loadingVendorProjects, setLoadingVendorProjects] = React.useState(false)
+ const [projectsMap, setProjectsMap] = React.useState