From ee57cc221ff2edafd3c0f12a181214c602ed257e Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 22 Jul 2025 02:57:00 +0000 Subject: (대표님, 최겸) 이메일 템플릿, 벤더데이터 변경사항 대응, 기술영업 변경요구사항 구현 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evcp/(evcp)/budgetary-tech-sales-hull/page.tsx | 2 +- .../evcp/(evcp)/budgetary-tech-sales-ship/page.tsx | 2 +- .../evcp/(evcp)/budgetary-tech-sales-top/page.tsx | 2 +- .../evcp/(evcp)/contact-possible-items/page.tsx | 8 +- .../evcp/(evcp)/email-template/[slug]/page.tsx | 12 +- .../(procurement)/email-template/[name]/page.tsx | 26 - .../(procurement)/email-template/page.tsx | 19 - app/api/revision-upload-ship/route.ts | 90 +- app/api/revision-upload/route.ts | 175 +- app/api/sync/import/status/route.ts | 208 +- components/additional-info/join-form.tsx | 311 +- .../additional-info/tech-vendor-info-form.tsx | 188 +- components/layout/Header.tsx | 4 +- components/settings/account-form.tsx | 283 +- .../ship-vendor-document/add-attachment-dialog.tsx | 40 +- .../ship-vendor-document/new-revision-dialog.tsx | 62 +- .../user-vendor-document-table-container.tsx | 76 +- components/signup/tech-vendor-join-form.tsx | 6 +- .../tech-vendor-possible-items-container.tsx | 4 +- components/tech-vendors/tech-vendor-container.tsx | 2 +- config/menuConfig.ts | 24 +- db/migrations/0220_colossal_domino.sql | 71 + db/migrations/0221_neat_grey_gargoyle.sql | 46 + db/migrations/0222_stiff_nehzno.sql | 46 + db/migrations/meta/0220_snapshot.json | 40120 ++++++++++++++++++ db/migrations/meta/0221_snapshot.json | 40425 ++++++++++++++++++ db/migrations/meta/0222_snapshot.json | 40719 +++++++++++++++++++ db/migrations/meta/_journal.json | 21 + hooks/use-sync-status.ts | 437 +- lib/dashboard/partners-service.ts | 28 +- lib/dashboard/service.ts | 9 - .../editor/template-content-editor.tsx | 9 +- lib/email-template/service.ts | 9 +- .../table/template-table-columns.tsx | 14 +- lib/email-template/table/update-template-sheet.tsx | 26 +- lib/file-stroage.ts | 2 +- lib/form-list.zip | Bin 12417 -> 0 bytes lib/sedp/get-form-tags.ts | 124 +- lib/sedp/sync-form.ts | 18 +- lib/soap/mdg/send/vendor-master/action.ts | 5 +- lib/tech-vendors/repository.ts | 1 - .../table/detail-table/rfq-detail-table.tsx | 4 +- lib/users/auth/verifyCredentails.ts | 1 + lib/users/service.ts | 176 +- lib/vendor-document-list/dolce-upload-service.ts | 107 +- .../enhanced-document-service.ts | 11 +- lib/vendor-document-list/import-service.ts | 287 +- .../ship/enhanced-doc-table-columns.tsx | 42 +- .../ship/enhanced-documents-table.tsx | 76 +- .../ship/import-from-dolce-button.tsx | 86 +- .../ship/send-to-shi-button.tsx | 286 +- middleware.ts | 1 + pages/api/pdftron/createVendorDataReports.ts | 364 +- public/0107.sql | 213 - public/SHi_logo.svg | 224 + public/globals.css | 168 - public/images/SHI_logo.svg | 224 + public/images/headerImg.png | Bin 0 -> 77256 bytes public/images/samsung_logo.png | Bin 0 -> 30904 bytes 59 files changed, 124417 insertions(+), 1527 deletions(-) delete mode 100644 app/[lng]/procurement/(procurement)/email-template/[name]/page.tsx delete mode 100644 app/[lng]/procurement/(procurement)/email-template/page.tsx create mode 100644 db/migrations/0220_colossal_domino.sql create mode 100644 db/migrations/0221_neat_grey_gargoyle.sql create mode 100644 db/migrations/0222_stiff_nehzno.sql create mode 100644 db/migrations/meta/0220_snapshot.json create mode 100644 db/migrations/meta/0221_snapshot.json create mode 100644 db/migrations/meta/0222_snapshot.json delete mode 100644 lib/form-list.zip delete mode 100644 public/0107.sql create mode 100644 public/SHi_logo.svg delete mode 100644 public/globals.css create mode 100644 public/images/SHI_logo.svg create mode 100644 public/images/headerImg.png create mode 100644 public/images/samsung_logo.png diff --git a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-hull/page.tsx index ce7bac9a..97e53567 100644 --- a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-hull/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-hull/page.tsx @@ -35,7 +35,7 @@ export default async function HullRfqPage(props: HullRfqPageProps) {

- 기술영업-해양 Hull RFQ + 기술영업-해양 Hull Budgetary RFQ

diff --git a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx index b2132cac..779b9ac9 100644 --- a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx @@ -35,7 +35,7 @@ export default async function RfqPage(props: RfqPageProps) {

- 기술영업-조선 RFQ + 기술영업-조선 Budgetary RFQ

diff --git a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-top/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-top/page.tsx index 37b75d22..5c96c85d 100644 --- a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-top/page.tsx +++ b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-top/page.tsx @@ -35,7 +35,7 @@ export default async function HullRfqPage(props: HullRfqPageProps) {

- 기술영업-해양 TOP RFQ + 기술영업-해양 TOP Budgetary RFQ

diff --git a/app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx b/app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx index 9fda681e..5bc36790 100644 --- a/app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx +++ b/app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx @@ -25,11 +25,11 @@ export default async function ContactPossibleItemsPage({

- 담당자별 아이템 관리 + 담당자별 자재 관리

-

- 기술영업 담당자별 가능 아이템을 관리합니다. -

+ {/*

+ 기술영업 담당자별 자재를 관리합니다. +

*/}
diff --git a/app/[lng]/evcp/(evcp)/email-template/[slug]/page.tsx b/app/[lng]/evcp/(evcp)/email-template/[slug]/page.tsx index 713c2b6d..2654489f 100644 --- a/app/[lng]/evcp/(evcp)/email-template/[slug]/page.tsx +++ b/app/[lng]/evcp/(evcp)/email-template/[slug]/page.tsx @@ -6,13 +6,14 @@ import { getTemplateAction } from "@/lib/email-template/service" import { TemplateEditor } from "@/lib/email-template/editor/template-editor" interface TemplateDetailPageProps { - params: { + params: Promise<{ slug: string - } + }> } export async function generateMetadata({ params }: TemplateDetailPageProps): Promise { - const result = await getTemplateAction(params.slug) + const { slug } = await params + const result = await getTemplateAction(slug) if (!result.success || !result.data) { return { @@ -27,7 +28,8 @@ export async function generateMetadata({ params }: TemplateDetailPageProps): Pro } export default async function TemplateDetailPage({ params }: TemplateDetailPageProps) { - const result = await getTemplateAction(params.slug) + const { slug } = await params + const result = await getTemplateAction(slug) if (!result.success || !result.data) { notFound() @@ -36,7 +38,7 @@ export default async function TemplateDetailPage({ params }: TemplateDetailPageP return (
diff --git a/app/[lng]/procurement/(procurement)/email-template/[name]/page.tsx b/app/[lng]/procurement/(procurement)/email-template/[name]/page.tsx deleted file mode 100644 index cccc10fc..00000000 --- a/app/[lng]/procurement/(procurement)/email-template/[name]/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getTemplateAction } from '@/lib/mail/service'; -import MailTemplateEditorClient from '@/components/mail/mail-template-editor-client'; - -interface EditMailTemplatePageProps { - params: { - name: string; - lng: string; - }; -} - -export default async function EditMailTemplatePage({ params }: EditMailTemplatePageProps) { - const { name: templateName } = await params; - - // 서버에서 초기 템플릿 데이터 가져오기 - const result = await getTemplateAction(templateName); - const initialTemplate = result.success ? result.data : null; - - return ( -
- -
- ); -} diff --git a/app/[lng]/procurement/(procurement)/email-template/page.tsx b/app/[lng]/procurement/(procurement)/email-template/page.tsx deleted file mode 100644 index 7c1156ee..00000000 --- a/app/[lng]/procurement/(procurement)/email-template/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { getTemplatesAction } from '@/lib/mail/service'; -import MailTemplatesClient from '@/components/mail/mail-templates-client'; - -export default async function MailTemplatesPage() { - // 서버에서 초기 데이터 가져오기 - const result = await getTemplatesAction(); - const initialData = result.success ? result.data : []; - - return ( -
-
-

메일 템플릿 관리

- {/*

이메일 템플릿을 관리할 수 있습니다.

*/} -
- - -
- ); -} diff --git a/app/api/revision-upload-ship/route.ts b/app/api/revision-upload-ship/route.ts index 549d15bd..38762e5d 100644 --- a/app/api/revision-upload-ship/route.ts +++ b/app/api/revision-upload-ship/route.ts @@ -1,8 +1,4 @@ import { NextRequest, NextResponse } from "next/server" -import { writeFile } from "fs/promises" -import { join } from "path" -import { v4 as uuidv4 } from "uuid" -import path from "path" import { revalidateTag } from "next/cache" import db from "@/db/db" @@ -14,6 +10,9 @@ import { } from "@/db/schema/vendorDocu" import { and, eq } from "drizzle-orm" +/* 보안 강화된 파일 저장 유틸리티 */ +import { saveFile, SaveFileResult } from "@/lib/file-stroage" + /* change log 유틸 */ import { logRevisionChange, @@ -42,13 +41,16 @@ export async function POST(request: NextRequest) { if (!attachmentFiles.length) return NextResponse.json({ error: "No files provided" }, { status: 400 }) + // 기본 파일 크기 검증 (보안 함수에서도 검증하지만 조기 체크) const MAX = 50 * 1024 * 1024 // 50MB (다이얼로그 제한과 맞춤) - for (const f of attachmentFiles) - if (f.size > MAX) + for (const f of attachmentFiles) { + if (f.size > MAX) { return NextResponse.json( { error: `${f.name} exceeds 50MB limit` }, { status: 400 } ) + } + } /* ------- 계약 ID 확보 ------- */ const [docInfo] = await db @@ -93,7 +95,7 @@ export async function POST(request: NextRequest) { /* ------- 트랜잭션 ------- */ const result = await db.transaction(async (tx) => { - /* Revision 생성 */ + /* Revision 생성/업데이트 */ const today = new Date().toISOString().slice(0, 10) // 동일한 revision이 이미 있는지 확인 (usage, usageType도 포함) @@ -187,34 +189,49 @@ export async function POST(request: NextRequest) { ) } - /* 첨부파일 처리 */ + /* ------- 보안 강화된 첨부파일 처리 ------- */ const uploadedFiles: any[] = [] - const baseDir = join(process.cwd(), "public", "documents") + const securityFailures: string[] = [] for (const file of attachmentFiles) { - const ext = path.extname(file.name) - const fname = uuidv4() + ext - const dest = join(baseDir, fname) + console.log(`🔐 보안 검증 시작: ${file.name}`) + + // 보안 강화된 파일 저장 + const saveResult: SaveFileResult = await saveFile({ + file, + directory: "documents", // 문서 전용 디렉토리 + originalName: file.name, + userId: uploaderName || "anonymous", // 업로더 정보 로깅용 + }) + + if (!saveResult.success) { + console.error(`❌ 파일 보안 검증 실패: ${file.name} - ${saveResult.error}`) + securityFailures.push(`${file.name}: ${saveResult.error}`) + continue // 실패한 파일은 건너뛰고 계속 진행 + } - await writeFile(dest, Buffer.from(await file.arrayBuffer())) + console.log(`✅ 파일 보안 검증 통과: ${file.name}`) + console.log(`📁 저장된 경로: ${saveResult.publicPath}`) + // DB에 첨부파일 정보 저장 const [att] = await tx.insert(documentAttachments) .values({ revisionId, - fileName: file.name, - filePath: "/documents/" + fname, - fileSize: file.size, - fileType: ext.slice(1).toLowerCase() || undefined, + fileName: saveResult.originalName!, // 원본 파일명 + filePath: saveResult.publicPath!, // 웹 접근 경로 + fileSize: saveResult.fileSize!, + fileType: saveResult.fileName!.split('.').pop()?.toLowerCase() || undefined, updatedAt: new Date(), }) .returning() uploadedFiles.push({ id: att.id, - fileName: file.name, - fileSize: file.size, - filePath: att.filePath, - fileType: ext.slice(1).toLowerCase() || null, // ✅ 추가 + fileName: saveResult.originalName, + fileSize: saveResult.fileSize, + filePath: saveResult.publicPath, + fileType: saveResult.fileName!.split('.').pop()?.toLowerCase() || null, + securityChecks: saveResult.securityChecks, // 보안 검증 결과 }) // change_logs: attachment CREATE @@ -230,6 +247,16 @@ export async function POST(request: NextRequest) { ) } + // 보안 검증 실패한 파일이 있으면 경고 반환 + if (securityFailures.length > 0) { + console.warn(`⚠️ 일부 파일의 보안 검증 실패:`, securityFailures) + + // 모든 파일이 실패한 경우 에러 반환 + if (uploadedFiles.length === 0) { + throw new Error(`모든 파일의 보안 검증이 실패했습니다: ${securityFailures.join(', ')}`) + } + } + /* documents.updatedAt 업데이트 */ await tx.update(documents) .set({ updatedAt: new Date() }) @@ -237,38 +264,45 @@ export async function POST(request: NextRequest) { return { revisionId, - issueStageId, // ✅ 추가 + issueStageId, stage, revision, uploadedFiles, contractId: docInfo.contractId, usage, - usageType + usageType, + securityFailures // 보안 실패 정보 포함 } }) // 캐시 무효화 try { revalidateTag(`sync-status-${result.contractId}`) - console.log(`✅ Cache invalidated for contract ${result.contractId}`) } catch (cacheError) { console.warn('⚠️ Cache invalidation failed:', cacheError) } + // 응답 메시지 구성 + let message = `리비전 ${result.revision}이 성공적으로 업로드되었습니다` + if (result.securityFailures.length > 0) { + message += ` (일부 파일 보안 검증 실패: ${result.securityFailures.length}개)` + } + return NextResponse.json({ success: true, - message: `리비전 ${result.revision}이 성공적으로 업로드되었습니다`, + message, data: { revisionId: result.revisionId, - issueStageId: issueStageId, // ✅ 추가 + issueStageId: result.issueStageId, stage: result.stage, revision: result.revision, usage: result.usage, usageType: result.usageType, - uploaderName: uploaderName, // ✅ 추가 + uploaderName: uploaderName, uploadedFiles: result.uploadedFiles, - filesCount: result.uploadedFiles.length + filesCount: result.uploadedFiles.length, + securityFailures: result.securityFailures, // 클라이언트에 보안 실패 정보 전달 }, }) } catch (e) { diff --git a/app/api/revision-upload/route.ts b/app/api/revision-upload/route.ts index 1a9666a7..b171b89a 100644 --- a/app/api/revision-upload/route.ts +++ b/app/api/revision-upload/route.ts @@ -1,9 +1,5 @@ import { NextRequest, NextResponse } from "next/server" -import { writeFile } from "fs/promises" -import { join } from "path" -import { v4 as uuidv4 } from "uuid" -import path from "path" -import { revalidateTag } from "next/cache" // ✅ 추가 +import { revalidateTag } from "next/cache" import db from "@/db/db" import { @@ -14,7 +10,10 @@ import { } from "@/db/schema/vendorDocu" import { and, eq } from "drizzle-orm" -/* ① change log 유틸 */ +/* 보안 강화된 파일 저장 유틸리티 */ +import { saveFile, SaveFileResult } from "@/lib/file-stroage" + +/* change log 유틸 */ import { logRevisionChange, logAttachmentChange, @@ -29,8 +28,8 @@ export async function POST(request: NextRequest) { const revision = formData.get("revision") as string | null const docId = Number(formData.get("documentId")) const uploaderName = formData.get("uploaderName") as string | null - const usage = formData.get("usage") as string | null - const usageType = formData.get("usageType") as string | null + const usage = formData.get("usage") as string | null + const usageType = formData.get("usageType") as string | null const comment = formData.get("comment") as string | null const mode = (formData.get("mode") || "new") as string // 'new'|'append' const targetSystem = (formData.get("targetSystem") as string | null) ?? "DOLCE" @@ -44,24 +43,31 @@ export async function POST(request: NextRequest) { if (!attachmentFiles.length) return NextResponse.json({ error: "No files provided" }, { status: 400 }) + // 기본 파일 크기 검증 (보안 함수에서도 검증하지만 조기 체크) const MAX = 3 * 1024 * 1024 * 1024 // 3 GB - for (const f of attachmentFiles) - if (f.size > MAX) + for (const f of attachmentFiles) { + if (f.size > MAX) { return NextResponse.json( { error: `${f.name} > 3 GB` }, { status: 400 } ) + } + } /* ------- 계약 ID 확보 ------- */ - const [{ contractId }] = await db + const [docInfo] = await db .select({ contractId: documents.contractId }) .from(documents) .where(eq(documents.id, docId)) .limit(1) + if (!docInfo) { + return NextResponse.json({ error: "Document not found" }, { status: 404 }) + } + /* ------- 트랜잭션 ------- */ const result = await db.transaction(async (tx) => { - /* 1) Stage */ + /* 1) Stage 생성/조회 */ let issueStageId: number const [stageRow] = await tx .select({ id: issueStages.id }) @@ -77,9 +83,11 @@ export async function POST(request: NextRequest) { .values({ documentId: docId, stageName: stage, updatedAt: new Date() }) .returning({ id: issueStages.id }) issueStageId = s.id - } else issueStageId = stageRow.id + } else { + issueStageId = stageRow.id + } - /* 2) Revision */ + /* 2) Revision 생성/업데이트 */ const today = new Date().toISOString().slice(0, 10) let revisionId: number const [revRow] = await tx @@ -93,23 +101,30 @@ export async function POST(request: NextRequest) { if (!revRow || mode === "new") { /* --- CREATE --- */ + const revisionData: any = { + issueStageId, + revision, + uploaderType: "vendor", + uploaderName: uploaderName ?? undefined, + revisionStatus: "UPLOADED", + uploadedAt: today, + comment: comment ?? undefined, + updatedAt: new Date(), + } + + // usage와 usageType이 있으면 추가 + if (usage) revisionData.usage = usage + if (usageType) revisionData.usageType = usageType + const [newRev] = await tx.insert(revisions) - .values({ - issueStageId, - revision, - uploaderType: "vendor", - uploaderName: uploaderName ?? undefined, - revisionStatus: "UPLOADED", - uploadedAt: today, - comment: comment ?? undefined, - updatedAt: new Date(), - }) + .values(revisionData) .returning() + revisionId = newRev.id // change_logs: CREATE await logRevisionChange( - contractId, + docInfo.contractId, revisionId, "CREATE", newRev, @@ -120,12 +135,18 @@ export async function POST(request: NextRequest) { ) } else { /* --- UPDATE --- */ + const updateData: any = { + uploaderName: uploaderName ?? revRow.uploaderName, + comment: comment ?? revRow.comment, + updatedAt: new Date(), + } + + // usage와 usageType이 있으면 업데이트 + if (usage) updateData.usage = usage + if (usageType) updateData.usageType = usageType + await tx.update(revisions) - .set({ - uploaderName: uploaderName ?? revRow.uploaderName, - comment: comment ?? revRow.comment, - updatedAt: new Date(), - }) + .set(updateData) .where(eq(revisions.id, revRow.id)) const [updated] = await tx @@ -136,7 +157,7 @@ export async function POST(request: NextRequest) { revisionId = revRow.id await logRevisionChange( - contractId, + docInfo.contractId, revisionId, "UPDATE", updated, @@ -147,38 +168,54 @@ export async function POST(request: NextRequest) { ) } - /* 3) Attachments */ + /* ------- 보안 강화된 첨부파일 처리 ------- */ const uploadedFiles: any[] = [] - const baseDir = join(process.cwd(), "public", "documents") + const securityFailures: string[] = [] for (const file of attachmentFiles) { - const ext = path.extname(file.name) - const fname = uuidv4() + ext - const dest = join(baseDir, fname) + console.log(`🔐 보안 검증 시작: ${file.name}`) + + // 보안 강화된 파일 저장 + const saveResult: SaveFileResult = await saveFile({ + file, + directory: "documents", // 문서 전용 디렉토리 + originalName: file.name, + userId: uploaderName || "anonymous", // 업로더 정보 로깅용 + }) + + if (!saveResult.success) { + console.error(`❌ 파일 보안 검증 실패: ${file.name} - ${saveResult.error}`) + securityFailures.push(`${file.name}: ${saveResult.error}`) + continue // 실패한 파일은 건너뛰고 계속 진행 + } - await writeFile(dest, Buffer.from(await file.arrayBuffer())) + console.log(`✅ 파일 보안 검증 통과: ${file.name}`) + console.log(`📁 저장된 경로: ${saveResult.publicPath}`) + // DB에 첨부파일 정보 저장 const [att] = await tx.insert(documentAttachments) .values({ revisionId, - fileName: file.name, - filePath: "/documents/" + fname, - fileSize: file.size, - fileType: ext.slice(1).toLowerCase() || undefined, + fileName: saveResult.originalName!, // 원본 파일명 + filePath: saveResult.publicPath!, // 웹 접근 경로 + fileSize: saveResult.fileSize!, + fileType: saveResult.fileName!.split('.').pop()?.toLowerCase() || undefined, updatedAt: new Date(), }) .returning() uploadedFiles.push({ id: att.id, - fileName: file.name, - fileSize: file.size, - filePath: att.filePath, + fileName: saveResult.originalName, + fileSize: saveResult.fileSize, + filePath: saveResult.publicPath, + fileType: saveResult.fileName!.split('.').pop()?.toLowerCase() || null, + securityChecks: saveResult.securityChecks, // 보안 검증 결과 }) // change_logs: attachment CREATE await logAttachmentChange( - contractId, + docInfo.contractId, att.id, "CREATE", att, @@ -189,15 +226,35 @@ export async function POST(request: NextRequest) { ) } - /* 4) documents.updatedAt */ + // 보안 검증 실패한 파일이 있으면 경고 반환 + if (securityFailures.length > 0) { + console.warn(`⚠️ 일부 파일의 보안 검증 실패:`, securityFailures) + + // 모든 파일이 실패한 경우 에러 반환 + if (uploadedFiles.length === 0) { + throw new Error(`모든 파일의 보안 검증이 실패했습니다: ${securityFailures.join(', ')}`) + } + } + + /* 4) documents.updatedAt 업데이트 */ await tx.update(documents) .set({ updatedAt: new Date() }) .where(eq(documents.id, docId)) - return { revisionId, stage, revision, uploadedFiles, mode, contractId } + return { + revisionId, + stage, + revision, + uploadedFiles, + mode, + contractId: docInfo.contractId, + usage, + usageType, + securityFailures // 보안 실패 정보 포함 + } }) - // ✅ 캐시 무효화 - 트랜잭션 완료 후에 실행 + // 캐시 무효화 - 트랜잭션 완료 후에 실행 try { // enhanced documents 캐시 무효화 revalidateTag(`enhanced-documents-${result.contractId}`) @@ -211,10 +268,28 @@ export async function POST(request: NextRequest) { // 캐시 무효화 실패해도 업로드는 성공으로 처리 } + // 응답 메시지 구성 + let message = `${result.uploadedFiles.length}개 파일 업로드 완료` + if (result.securityFailures.length > 0) { + message += ` (일부 파일 보안 검증 실패: ${result.securityFailures.length}개)` + } + return NextResponse.json({ success: true, - message: `${result.uploadedFiles.length}개 파일 업로드 완료`, - data: result, + message, + data: { + revisionId: result.revisionId, + stage: result.stage, + revision: result.revision, + mode: result.mode, + usage: result.usage, + usageType: result.usageType, + uploaderName: uploaderName, + uploadedFiles: result.uploadedFiles, + filesCount: result.uploadedFiles.length, + securityFailures: result.securityFailures, // 클라이언트에 보안 실패 정보 전달 + contractId: result.contractId, + }, }) } catch (e) { console.error("revision-upload error:", e) diff --git a/app/api/sync/import/status/route.ts b/app/api/sync/import/status/route.ts index c5b4b0bd..8b6144d6 100644 --- a/app/api/sync/import/status/route.ts +++ b/app/api/sync/import/status/route.ts @@ -1,8 +1,19 @@ -// app/api/sync/import/status/route.ts +// app/api/sync/status/route.ts import { NextRequest, NextResponse } from "next/server" -import { importService } from "@/lib/vendor-document-list/import-service" import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { documents, revisions, documentAttachments, contracts, projects, vendors } from "@/db/schema" +import { eq, and, sql, desc } from "drizzle-orm" + +interface SyncStatus { + syncEnabled: boolean + pendingChanges: number + syncedChanges: number + failedChanges: number + lastSyncAt?: string + error?: string +} export async function GET(request: NextRequest) { try { @@ -13,7 +24,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const contractId = searchParams.get('contractId') - const sourceSystem = searchParams.get('sourceSystem') || 'DOLCE' + const targetSystem = searchParams.get('targetSystem') || 'SHI' if (!contractId) { return NextResponse.json( @@ -22,20 +33,185 @@ export async function GET(request: NextRequest) { ) } - const status = await importService.getImportStatus( - Number(contractId), - sourceSystem - ) + // 🔥 안전하게 동기화 상태 조회 + const syncStatus = await getSyncStatusSafely(Number(contractId), targetSystem) + + return NextResponse.json(syncStatus) + + } catch (error) { + console.error('Unexpected error in sync status API:', error) + + // 🔥 에러 시에도 200으로 응답하고 error 필드 포함 + return NextResponse.json({ + syncEnabled: false, + pendingChanges: 0, + syncedChanges: 0, + failedChanges: 0, + lastSyncAt: undefined, + error: '시스템 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + }, { status: 200 }) + } +} + +async function getSyncStatusSafely(contractId: number, targetSystem: string): Promise { + try { + // 1. 계약 정보 확인 + const contractInfo = await db + .select({ + projectCode: projects.code, + vendorCode: vendors.vendorCode, + contractStatus: contracts.status + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) + .where(eq(contracts.id, contractId)) + .limit(1) + + // 계약 정보가 없는 경우 + if (!contractInfo || contractInfo.length === 0) { + return { + syncEnabled: false, + pendingChanges: 0, + syncedChanges: 0, + failedChanges: 0, + error: `계약 ${contractId}를 찾을 수 없습니다.` + } + } + + const contract = contractInfo[0] + + // 프로젝트 코드나 벤더 코드가 없는 경우 + if (!contract.projectCode || !contract.vendorCode) { + return { + syncEnabled: false, + pendingChanges: 0, + syncedChanges: 0, + failedChanges: 0, + error: `계약 ${contractId}에 프로젝트 코드 또는 벤더 코드가 설정되지 않았습니다.` + } + } + + // 2. 마지막 동기화 시간 조회 + const [lastSync] = await db + .select({ + lastSyncAt: sql`MAX(${documents.externalSyncedAt})` + }) + .from(documents) + .where(and( + eq(documents.contractId, contractId), + eq(documents.externalSystemType, targetSystem) + )) + + // 3. 문서별 변경사항 분석 + const documentStats = await db + .select({ + id: documents.id, + docNumber: documents.docNumber, + updatedAt: documents.updatedAt, + externalSyncedAt: documents.externalSyncedAt, + syncStatus: documents.syncStatus + }) + .from(documents) + .where(eq(documents.contractId, contractId)) + + let pendingChanges = 0 + let syncedChanges = 0 + let failedChanges = 0 + + // 각 문서의 동기화 상태 분석 + for (const doc of documentStats) { + // 문서 자체가 변경되었는지 확인 + const docNeedsSync = !doc.externalSyncedAt || + (doc.updatedAt && doc.externalSyncedAt && doc.updatedAt > doc.externalSyncedAt) + + if (docNeedsSync) { + if (doc.syncStatus === 'FAILED') { + failedChanges++ + } else if (doc.syncStatus === 'SYNCED') { + syncedChanges++ + } else { + pendingChanges++ + } + } + + // 해당 문서의 리비전 변경사항 확인 + const revisionStats = await db + .select({ + updatedAt: revisions.updatedAt, + externalSyncedAt: revisions.externalSyncedAt, + syncStatus: revisions.syncStatus + }) + .from(revisions) + .innerJoin(documents, eq(revisions.documentId, documents.id)) + .where(eq(documents.id, doc.id)) + + for (const revision of revisionStats) { + const revisionNeedsSync = !revision.externalSyncedAt || + (revision.updatedAt && revision.externalSyncedAt && revision.updatedAt > revision.externalSyncedAt) + + if (revisionNeedsSync) { + if (revision.syncStatus === 'FAILED') { + failedChanges++ + } else if (revision.syncStatus === 'SYNCED') { + syncedChanges++ + } else { + pendingChanges++ + } + } + } + + // 첨부파일 변경사항 확인 + const attachmentStats = await db + .select({ + updatedAt: documentAttachments.updatedAt, + externalSyncedAt: documentAttachments.externalSyncedAt, + syncStatus: documentAttachments.syncStatus + }) + .from(documentAttachments) + .innerJoin(revisions, eq(documentAttachments.revisionId, revisions.id)) + .innerJoin(documents, eq(revisions.documentId, documents.id)) + .where(eq(documents.id, doc.id)) + + for (const attachment of attachmentStats) { + const attachmentNeedsSync = !attachment.externalSyncedAt || + (attachment.updatedAt && attachment.externalSyncedAt && attachment.updatedAt > attachment.externalSyncedAt) + + if (attachmentNeedsSync) { + if (attachment.syncStatus === 'FAILED') { + failedChanges++ + } else if (attachment.syncStatus === 'SYNCED') { + syncedChanges++ + } else { + pendingChanges++ + } + } + } + } + + // 4. 동기화 활성화 여부 확인 + const syncEnabled = contract.contractStatus === 'ACTIVE' && + Boolean(contract.projectCode) && + Boolean(contract.vendorCode) && + process.env[`SYNC_${targetSystem.toUpperCase()}_ENABLED`] === 'true' + + return { + syncEnabled, + pendingChanges, + syncedChanges, + failedChanges, + lastSyncAt: lastSync?.lastSyncAt ? new Date(lastSync.lastSyncAt).toISOString() : undefined + } - return NextResponse.json(status) } catch (error) { - console.error('Failed to get import status:', error) - return NextResponse.json( - { - error: 'Failed to get import status', - message: error instanceof Error ? error.message : 'Unknown error' - }, - { status: 500 } - ) + console.error(`Failed to get sync status for contract ${contractId}:`, error) + + return { + syncEnabled: false, + pendingChanges: 0, + syncedChanges: 0, + failedChanges: 0, + error: error instanceof Error ? error.message : '동기화 상태를 확인할 수 없습니다.' + } } } \ No newline at end of file diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index b6cb0d9c..da2ddac7 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -80,6 +80,17 @@ import { CardTitle, } from "@/components/ui/card" import { InformationButton } from "@/components/information/information-button" + +// 보안 파일 다운로드 유틸리티 import +import { + downloadFile, + quickDownload, + smartFileAction, + getFileInfo, + formatFileSize, + getSecurityInfo +} from "@/lib/file-download" + i18nIsoCountries.registerLocale(enLocale) i18nIsoCountries.registerLocale(koLocale) @@ -111,7 +122,6 @@ const cashFlowRatingScaleMap: Record = { SCI: ["Level 1", "Level 2", "Level 3", "Level 4"], } - const MAX_FILE_SIZE = 3e9 // 파일 타입 정의 @@ -277,6 +287,7 @@ export function InfoForm() { fetchVendorData() }, [companyId, form, replaceContacts]) + // 보안 다운로드 유틸리티를 사용한 개별 파일 다운로드 const handleDownloadFile = async (file: AttachmentFile) => { try { setIsDownloading(true); @@ -285,74 +296,94 @@ export function InfoForm() { const fileId = typeof file === 'object' ? file.id : file; const fileName = typeof file === 'object' ? file.fileName : `file-${fileId}`; - // 다운로드 링크 생성 (URL 인코딩 적용) + // API 엔드포인트 URL 구성 const downloadUrl = `/api/vendors/attachments/download?id=${fileId}&vendorId=${Number(companyId)}`; - // a 태그를 사용한 다운로드 - const downloadLink = document.createElement('a'); - downloadLink.href = downloadUrl; - downloadLink.download = fileName; - downloadLink.target = '_blank'; // 추가: 새 탭에서 열도록 설정 (일부 브라우저에서 더 안정적) - document.body.appendChild(downloadLink); - downloadLink.click(); - - // 정리 (메모리 누수 방지) - setTimeout(() => { - document.body.removeChild(downloadLink); - }, 100); - - toast({ - title: "다운로드 시작", - description: "파일 다운로드가 시작되었습니다.", + // 보안 다운로드 유틸리티 사용 + const result = await downloadFile(downloadUrl, fileName, { + action: 'download', + showToast: false, // 우리가 직접 토스트 관리 + onSuccess: (fileName, fileSize) => { + const sizeText = fileSize ? ` (${formatFileSize(fileSize)})` : ''; + toast({ + title: "다운로드 완료", + description: `파일 다운로드가 완료되었습니다: ${fileName}${sizeText}`, + }); + }, + onError: (error) => { + console.error("Download error:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: error || "파일 다운로드 중 오류가 발생했습니다.", + }); + } }); + + if (!result.success && result.error) { + // 오류 처리는 onError 콜백에서 이미 처리됨 + console.error("Download failed:", result.error); + } + } catch (error) { console.error("Error downloading file:", error); toast({ variant: "destructive", title: "다운로드 오류", - description: "파일 다운로드 중 오류가 발생했습니다.", + description: "파일 다운로드 중 예상치 못한 오류가 발생했습니다.", }); } finally { setIsDownloading(false); } }; - // 전체 파일 다운로드 함수 -const handleDownloadAllFiles = async () => { - try { - setIsDownloading(true); - - // 다운로드 URL 생성 - const downloadUrl = `/api/vendors/attachments/download-all?vendorId=${Number(companyId)}`; - - // a 태그를 사용한 다운로드 - const downloadLink = document.createElement('a'); - downloadLink.href = downloadUrl; - downloadLink.download = `vendor-${companyId}-files.zip`; - downloadLink.target = '_blank'; - document.body.appendChild(downloadLink); - downloadLink.click(); - - // 정리 - setTimeout(() => { - document.body.removeChild(downloadLink); - }, 100); - - toast({ - title: "다운로드 시작", - description: "전체 파일 다운로드가 시작되었습니다.", - }); - } catch (error) { - console.error("Error downloading files:", error); - toast({ - variant: "destructive", - title: "다운로드 오류", - description: "파일 다운로드 중 오류가 발생했습니다.", - }); - } finally { - setIsDownloading(false); - } -}; + // 보안 다운로드 유틸리티를 사용한 전체 파일 다운로드 + const handleDownloadAllFiles = async () => { + try { + setIsDownloading(true); + + // 전체 파일 다운로드 API 엔드포인트 + const downloadUrl = `/api/vendors/attachments/download-all?vendorId=${Number(companyId)}`; + const fileName = `vendor-${companyId}-files.zip`; + + // 보안 다운로드 유틸리티 사용 + const result = await downloadFile(downloadUrl, fileName, { + action: 'download', + showToast: false, // 우리가 직접 토스트 관리 + onSuccess: (fileName, fileSize) => { + const sizeText = fileSize ? ` (${formatFileSize(fileSize)})` : ''; + toast({ + title: "전체 다운로드 완료", + description: `전체 파일 다운로드가 완료되었습니다: ${fileName}${sizeText}`, + }); + }, + onError: (error) => { + console.error("Download all error:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: error || "전체 파일 다운로드 중 오류가 발생했습니다.", + }); + } + }); + + if (!result.success && result.error) { + // 오류 처리는 onError 콜백에서 이미 처리됨 + console.error("Download all failed:", result.error); + } + + } catch (error) { + console.error("Error downloading files:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: "전체 파일 다운로드 중 예상치 못한 오류가 발생했습니다.", + }); + } finally { + setIsDownloading(false); + } + }; + // Dropzone handlers const handleDropAccepted = (acceptedFiles: File[]) => { const newFiles = [...selectedFiles, ...acceptedFiles] @@ -529,6 +560,9 @@ const handleDownloadAllFiles = async () => { ) } + // 보안 정보 가져오기 (선택적으로 사용자에게 표시) + const securityInfo = getSecurityInfo(); + // Render return (
@@ -563,6 +597,11 @@ const handleDownloadAllFiles = async () => {
)} + + {/* 보안 정보 표시 (선택적) */} +
+

📁 허용 파일 크기: {securityInfo.maxFileSizeFormatted} | 남은 다운로드: {securityInfo.remainingDownloads}/분

+
@@ -583,27 +622,35 @@ const handleDownloadAllFiles = async () => {

일반 첨부파일

- {existingFiles.map((file) => ( - - - - - {file.fileName} - - {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} - - -
- handleDownloadFile(file)}> - - - handleDeleteExistingFile(file.id)}> - - -
-
-
- ))} + {existingFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + + + + + + {fileInfo.icon} {file.fileName} + + + {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + + +
+ handleDownloadFile(file)} + disabled={isDownloading} + > + {isDownloading ? : } + + handleDeleteExistingFile(file.id)}> + + +
+
+
+ ); + })}
@@ -614,27 +661,35 @@ const handleDownloadAllFiles = async () => {

신용평가 첨부파일

- {existingCreditFiles.map((file) => ( - - - - - {file.fileName} - - {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} - - -
- handleDownloadFile(file)}> - - - handleDeleteExistingFile(file.id)}> - - -
-
-
- ))} + {existingCreditFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + + + + + + {fileInfo.icon} {file.fileName} + + + {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + + +
+ handleDownloadFile(file)} + disabled={isDownloading} + > + {isDownloading ? : } + + handleDeleteExistingFile(file.id)}> + + +
+
+
+ ); + })}
@@ -645,27 +700,35 @@ const handleDownloadAllFiles = async () => {

현금흐름 첨부파일

- {existingCashFlowFiles.map((file) => ( - - - - - {file.fileName} - - {file.fileSize ? prettyBytes(file.fileSize) : '크기 정보 없음'} - - -
- handleDownloadFile(file)}> - - - handleDeleteExistingFile(file.id)}> - - -
-
-
- ))} + {existingCashFlowFiles.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( + + + + + + {fileInfo.icon} {file.fileName} + + + {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + + +
+ handleDownloadFile(file)} + disabled={isDownloading} + > + {isDownloading ? : } + + handleDeleteExistingFile(file.id)}> + + +
+
+
+ ); + })}
@@ -674,8 +737,16 @@ const handleDownloadAllFiles = async () => { {(existingFiles.length + existingCreditFiles.length + existingCashFlowFiles.length) > 1 && ( - )} diff --git a/components/additional-info/tech-vendor-info-form.tsx b/components/additional-info/tech-vendor-info-form.tsx index 55d01d21..02358a45 100644 --- a/components/additional-info/tech-vendor-info-form.tsx +++ b/components/additional-info/tech-vendor-info-form.tsx @@ -31,6 +31,17 @@ import { CardTitle, } from "@/components/ui/card" import { InformationButton } from "@/components/information/information-button" + +// 보안 파일 다운로드 유틸리티 import +import { + downloadFile, + quickDownload, + smartFileAction, + getFileInfo, + formatFileSize, + getSecurityInfo +} from "@/lib/file-download" + // 타입 정의 interface TechVendorContact { id: number @@ -46,6 +57,7 @@ interface TechVendorAttachment { fileName: string filePath: string attachmentType: string + fileSize?: number createdAt: Date updatedAt: Date } @@ -133,69 +145,96 @@ export function TechVendorInfoForm() { fetchTechVendorData() }, [techCompanyId, form]) + // 보안 다운로드 유틸리티를 사용한 개별 파일 다운로드 const handleDownloadFile = async (file: TechVendorAttachment) => { try { setIsDownloading(true) + // API 엔드포인트 URL 구성 const downloadUrl = `/api/tech-vendors/attachments/download?id=${file.id}&vendorId=${Number(techCompanyId)}` - const downloadLink = document.createElement('a') - downloadLink.href = downloadUrl - downloadLink.download = file.fileName - downloadLink.target = '_blank' - document.body.appendChild(downloadLink) - downloadLink.click() - - setTimeout(() => { - document.body.removeChild(downloadLink) - }, 100) + // 보안 다운로드 유틸리티 사용 + const result = await downloadFile(downloadUrl, file.fileName, { + action: 'download', + showToast: false, // 우리가 직접 토스트 관리 + onSuccess: (fileName, fileSize) => { + const sizeText = fileSize ? ` (${formatFileSize(fileSize)})` : ''; + toast({ + title: "다운로드 완료", + description: `파일 다운로드가 완료되었습니다: ${fileName}${sizeText}`, + }); + }, + onError: (error) => { + console.error("Download error:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: error || "파일 다운로드 중 오류가 발생했습니다.", + }); + } + }); + + if (!result.success && result.error) { + // 오류 처리는 onError 콜백에서 이미 처리됨 + console.error("Download failed:", result.error); + } - toast({ - title: "다운로드 시작", - description: "파일 다운로드가 시작되었습니다.", - }) } catch (error) { - console.error("Error downloading file:", error) + console.error("Error downloading file:", error); toast({ variant: "destructive", title: "다운로드 오류", - description: "파일 다운로드 중 오류가 발생했습니다.", - }) + description: "파일 다운로드 중 예상치 못한 오류가 발생했습니다.", + }); } finally { - setIsDownloading(false) + setIsDownloading(false); } } + // 보안 다운로드 유틸리티를 사용한 전체 파일 다운로드 const handleDownloadAllFiles = async () => { try { setIsDownloading(true) + // 전체 파일 다운로드 API 엔드포인트 const downloadUrl = `/api/tech-vendors/attachments/download-all?vendorId=${Number(techCompanyId)}` + const fileName = `tech-vendor-${techCompanyId}-files.zip` - const downloadLink = document.createElement('a') - downloadLink.href = downloadUrl - downloadLink.download = `tech-vendor-${techCompanyId}-files.zip` - downloadLink.target = '_blank' - document.body.appendChild(downloadLink) - downloadLink.click() - - setTimeout(() => { - document.body.removeChild(downloadLink) - }, 100) + // 보안 다운로드 유틸리티 사용 + const result = await downloadFile(downloadUrl, fileName, { + action: 'download', + showToast: false, // 우리가 직접 토스트 관리 + onSuccess: (fileName, fileSize) => { + const sizeText = fileSize ? ` (${formatFileSize(fileSize)})` : ''; + toast({ + title: "전체 다운로드 완료", + description: `전체 파일 다운로드가 완료되었습니다: ${fileName}${sizeText}`, + }); + }, + onError: (error) => { + console.error("Download all error:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: error || "전체 파일 다운로드 중 오류가 발생했습니다.", + }); + } + }); + + if (!result.success && result.error) { + // 오류 처리는 onError 콜백에서 이미 처리됨 + console.error("Download all failed:", result.error); + } - toast({ - title: "다운로드 시작", - description: "전체 파일 다운로드가 시작되었습니다.", - }) } catch (error) { - console.error("Error downloading files:", error) + console.error("Error downloading files:", error); toast({ variant: "destructive", title: "다운로드 오류", - description: "파일 다운로드 중 오류가 발생했습니다.", - }) + description: "전체 파일 다운로드 중 예상치 못한 오류가 발생했습니다.", + }); } finally { - setIsDownloading(false) + setIsDownloading(false); } } @@ -247,6 +286,9 @@ export function TechVendorInfoForm() { ) } + // 보안 정보 가져오기 + const securityInfo = getSecurityInfo(); + return (
@@ -256,6 +298,11 @@ export function TechVendorInfoForm() {

기술영업 벤더 정보를 확인하고 업데이트할 수 있습니다.

+ + {/* 보안 정보 표시 */} +
+

📁 허용 파일 크기: {securityInfo.maxFileSizeFormatted} | 남은 다운로드: {securityInfo.remainingDownloads}/분

+
{attachments.length > 0 && ( )} @@ -395,7 +446,6 @@ export function TechVendorInfoForm() { - {/* 연락처 정보 */} {contacts.length > 0 && ( @@ -455,7 +505,7 @@ export function TechVendorInfoForm() { )} - {/* 첨부파일 정보 */} + {/* 첨부파일 정보 - 보안 강화된 버전 */} {attachments.length > 0 && ( @@ -466,29 +516,45 @@ export function TechVendorInfoForm() {
- {attachments.map((file) => ( -
-
-

{file.fileName}

-
- {file.attachmentType} - - {new Date(file.createdAt).toLocaleDateString()} + {attachments.map((file) => { + const fileInfo = getFileInfo(file.fileName); + return ( +
+
+
+ {fileInfo.icon} +

{file.fileName}

+
+
+ {file.attachmentType} + + {new Date(file.createdAt).toLocaleDateString()} + {file.fileSize && ( + <> + + {formatFileSize(file.fileSize)} + + )} +
+
- -
- ))} + ); + })}
@@ -513,4 +579,4 @@ export function TechVendorInfoForm() {
) -} \ No newline at end of file +} \ No newline at end of file diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index a686da7a..0b04c0c3 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -52,6 +52,8 @@ export function Header() { const { data: session } = useSession(); const { activeMenus, isLoading } = useActiveMenus(); + console.log(session) + const userName = session?.user?.name || ""; const domain = session?.user?.domain || ""; const initials = userName @@ -242,7 +244,7 @@ export function Header() { - + {initials || "?"} diff --git a/components/settings/account-form.tsx b/components/settings/account-form.tsx index 97cad9e5..e2435a2b 100644 --- a/components/settings/account-form.tsx +++ b/components/settings/account-form.tsx @@ -24,8 +24,6 @@ import { useSession } from "next-auth/react"; import { updateUserProfileImage } from "@/lib/users/service" - - const accountFormSchema = z.object({ name: z .string() @@ -36,56 +34,95 @@ const accountFormSchema = z.object({ message: "Name must not be longer than 30 characters.", }), email: z.string().email(), - company: z - .string() - .min(2, { - message: "Name must be at least 2 characters.", - }) - .max(30, { - message: "Name must not be longer than 30 characters.", - }), - imageFile: z.any().optional(), - }) type AccountFormValues = z.infer - - export function AccountForm() { - const { data: session } = useSession(); const userId = session?.user.id || "" - + const [currentImageUrl, setCurrentImageUrl] = React.useState(null) const [previewUrl, setPreviewUrl] = React.useState(null) + const [imageError, setImageError] = React.useState(false) const form = useForm({ resolver: zodResolver(accountFormSchema), defaultValues: { name: "", - company: "", email: "", imageFile: null, }, }) - // Fetch data in useEffect - React.useEffect(() => { - console.log("Form state changed: ", form.getValues()); + // 안전한 이미지 URL 검증 함수 + const isValidImageUrl = (url: string): boolean => { + try { + // 1. 빈 문자열 체크 + if (!url || typeof url !== 'string') return false + + // 2. 위험한 프로토콜 차단 + const dangerousProtocols = ['javascript:', 'data:', 'vbscript:', 'file:', 'ftp:'] + const lowerUrl = url.toLowerCase() + if (dangerousProtocols.some(protocol => lowerUrl.startsWith(protocol))) { + return false + } + + // 3. 상대 경로 공격 방지 + if (url.includes('../') || url.includes('..\\')) { + return false + } + + // 4. 허용된 경로만 통과 (프로젝트 구조에 맞게 조정) + const allowedPaths = ['/profiles/', '/uploads/', '/images/'] + const hasAllowedPath = allowedPaths.some(path => url.startsWith(path)) + + // 5. 또는 허용된 도메인만 통과 (필요한 경우) + // const allowedDomains = ['yourdomain.com', 'cdn.yourdomain.com'] + // if (url.startsWith('http')) { + // const urlObj = new URL(url) + // return allowedDomains.includes(urlObj.hostname) + // } + + return hasAllowedPath + + } catch (error) { + console.error('URL validation error:', error) + return false + } + } + + // 안전한 이미지 URL 생성 함수 + const getSafeImageUrl = (imagePath: string | null): string | null => { + if (!imagePath) return null + + // 이미 전체 경로인 경우 + if (imagePath.startsWith('/profiles/') || imagePath.startsWith('/uploads/')) { + return isValidImageUrl(imagePath) ? imagePath : null + } + + // 파일명만 있는 경우 안전한 경로로 조합 + const safePath = `/profiles/${encodeURIComponent(imagePath)}` + return isValidImageUrl(safePath) ? safePath : null + } + React.useEffect(() => { async function fetchUser() { try { const data = await findUserById(Number(userId)) if (data) { - // Also reset the form's default values form.reset({ name: data.user_name || "", - company: data.company_name || "", email: data.user_email || "", - imageFile: data.user_image, // no file to begin with + imageFile: null, }) + + // 안전한 이미지 URL 설정 + const safeImageUrl = getSafeImageUrl(data.user_image) + setCurrentImageUrl(safeImageUrl) + setImageError(false) + setPreviewUrl(null) } } catch (error) { console.error("Failed to fetch user data:", error) @@ -97,12 +134,9 @@ export function AccountForm() { } }, [userId, form]) - async function onSubmit(data: AccountFormValues) { - // RHF가 추적한 dirtyFields를 가져옵니다. const { dirtyFields } = form.formState - // 변경된 필드가 전혀 없다면 => 업데이트 스킵 if (Object.keys(dirtyFields).length === 0) { toast({ title: "No changes", @@ -111,18 +145,26 @@ export function AccountForm() { return } - // 바뀐 파일만 업로드 let imageFile: File | null = null if (dirtyFields.imageFile && data.imageFile && data.imageFile.length > 0) { - // 새로 업로드한 파일 - imageFile = data.imageFile[0] + const file = data.imageFile[0] + + // 클라이언트 측 파일 검증 + if (!isValidImageFile(file)) { + toast({ + title: "Invalid file", + description: "Please select a valid image file (PNG, JPG, JPEG, WebP, max 5MB)", + variant: "destructive", + }) + return + } + + imageFile = file } - // FormData 생성 const formData = new FormData() formData.append("userId", userId) formData.append("name", data.name) - formData.append("company", data.company) formData.append("email", data.email) if (imageFile) { @@ -130,7 +172,6 @@ export function AccountForm() { } try { - // 서버 액션(또는 API) 호출 await updateUserProfileImage(formData) toast({ @@ -147,6 +188,72 @@ export function AccountForm() { } } + // 파일 유효성 검증 함수 + const isValidImageFile = (file: File): boolean => { + // 1. 파일 크기 검증 (5MB 제한) + const maxSize = 5 * 1024 * 1024 // 5MB + if (file.size > maxSize) { + return false + } + + // 2. MIME 타입 검증 + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] + if (!allowedTypes.includes(file.type)) { + return false + } + + // 3. 파일 확장자 검증 (추가 보안) + const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'] + const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) + if (!allowedExtensions.includes(fileExtension)) { + return false + } + + return true + } + + // 이미지 로드 에러 처리 + const handleImageError = () => { + setImageError(true) + setCurrentImageUrl(null) + } + + // 안전한 이미지 표시 함수 + const getDisplayImage = () => { + if (previewUrl) { + return ( + Preview setPreviewUrl(null)} + /> + ) + } + + if (currentImageUrl && !imageError) { + return ( + Current profile + ) + } + + return ( +
+ + {imageError ? "Image load failed" : "No image"} + +
+ ) + } return (
@@ -186,32 +293,6 @@ export function AccountForm() { )} /> - ( - - Company - - - - - This is the name that will be displayed on your profile and in - emails. - - - - )} - /> - - - - {/* 이미지 업로드 */} Profile Image -
- { - field.onChange(e.target.files) - if (e.target.files && e.target.files.length > 0) { - // 로컬 미리보기 URL - const file = e.target.files[0] - const url = URL.createObjectURL(file) - setPreviewUrl(url) - } - }} - /> - - {previewUrl ? ( - Local Preview - ) : ( - typeof field.value === "string" && - field.value && ( - Server Image - ) - )} -
+
+ { + const files = e.target.files + field.onChange(files) + + if (files && files.length > 0) { + const file = files[0] + + // 파일 유효성 검증 + if (!isValidImageFile(file)) { + toast({ + title: "Invalid file", + description: "Please select a valid image file (PNG, JPG, JPEG, WebP, max 5MB)", + variant: "destructive", + }) + // 파일 입력 초기화 + e.target.value = '' + field.onChange(null) + return + } + + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + + const url = URL.createObjectURL(file) + setPreviewUrl(url) + setImageError(false) + } else { + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + setPreviewUrl(null) + } + }} + /> + +
+ {getDisplayImage()} +
+ + {previewUrl && ( +

새 이미지 미리보기

+ )} + {!previewUrl && currentImageUrl && !imageError && ( +

현재 프로필 이미지

+ )} + {imageError && ( +

이미지를 불러올 수 없습니다

+ )} +
- Upload your profile image. + Upload your profile image (PNG, JPG, JPEG, WebP, max 5MB). @@ -260,4 +367,4 @@ export function AccountForm() { ) -} +} \ No newline at end of file diff --git a/components/ship-vendor-document/add-attachment-dialog.tsx b/components/ship-vendor-document/add-attachment-dialog.tsx index 2f2467a3..a285b4de 100644 --- a/components/ship-vendor-document/add-attachment-dialog.tsx +++ b/components/ship-vendor-document/add-attachment-dialog.tsx @@ -55,15 +55,15 @@ const ACCEPTED_FILE_TYPES = [ const attachmentUploadSchema = z.object({ attachments: z .array(z.instanceof(File)) - .min(1, "최소 1개의 파일을 업로드해주세요") - .max(10, "최대 10개의 파일까지 업로드 가능합니다") + .min(1, "Please upload at least 1 file") + .max(10, "Maximum 10 files can be uploaded") .refine( (files) => files.every((file) => file.size <= MAX_FILE_SIZE), - "파일 크기는 50MB 이하여야 합니다" + "File size must be 50MB or less" ) .refine( (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), - "지원하지 않는 파일 형식입니다" + "Unsupported file format" ), }) @@ -128,10 +128,10 @@ function FileUploadArea({ >

- 추가할 파일을 드래그하여 놓거나 클릭하여 선택하세요 + Drag files to add here or click to select

- PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) + Supports PDF, Word, Excel, Image, Text, ZIP files (max 50MB)

0 && (
-

선택된 파일 ({files.length}개)

+

Selected Files ({files.length})

{files.map((file, index) => (
{ handleDialogClose() @@ -270,8 +270,8 @@ export function AddAttachmentDialog({ }, 1000) } catch (error) { - console.error('❌ 첨부파일 업로드 오류:', error) - toast.error(error instanceof Error ? error.message : "첨부파일 업로드 중 오류가 발생했습니다") + console.error('❌ Attachment upload error:', error) + toast.error(error instanceof Error ? error.message : "An error occurred while uploading attachments") } finally { setIsUploading(false) setTimeout(() => setUploadProgress(0), 2000) @@ -285,10 +285,10 @@ export function AddAttachmentDialog({ - 첨부파일 추가 + Add Attachments - 리비전 {revisionName}에 추가 첨부파일을 업로드합니다 + Upload additional attachments to revision {revisionName} @@ -302,7 +302,7 @@ export function AddAttachmentDialog({ name="attachments" render={({ field }) => ( - 첨부파일 + Attachments
- 업로드 진행률 + Upload Progress {uploadProgress.toFixed(0)}%
{uploadProgress === 100 && (
- 업로드 완료 + Upload Complete
)}
@@ -340,7 +340,7 @@ export function AddAttachmentDialog({ onClick={handleDialogClose} disabled={isUploading} > - 취소 + Cancel diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx index 83c9c658..7adc0b3a 100644 --- a/components/ship-vendor-document/new-revision-dialog.tsx +++ b/components/ship-vendor-document/new-revision-dialog.tsx @@ -67,20 +67,20 @@ const ACCEPTED_FILE_TYPES = [ // drawingKind에 따른 동적 스키마 생성 const createRevisionUploadSchema = (drawingKind: string) => { const baseSchema = { - usage: z.string().min(1, "용도를 선택해주세요"), - revision: z.string().min(1, "리비전을 입력해주세요").max(50, "리비전은 50자 이내로 입력해주세요"), + usage: z.string().min(1, "Please select a usage"), + revision: z.string().min(1, "Please enter a revision").max(50, "Revision must be 50 characters or less"), comment: z.string().optional(), attachments: z .array(z.instanceof(File)) - .min(1, "최소 1개의 파일을 업로드해주세요") - .max(10, "최대 10개의 파일까지 업로드 가능합니다") + .min(1, "Please upload at least 1 file") + .max(10, "Maximum 10 files can be uploaded") .refine( (files) => files.every((file) => file.size <= MAX_FILE_SIZE), - "파일 크기는 50MB 이하여야 합니다" + "File size must be 50MB or less" ) .refine( (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), - "지원하지 않는 파일 형식입니다" + "Unsupported file format" ), } @@ -88,7 +88,7 @@ const createRevisionUploadSchema = (drawingKind: string) => { if (drawingKind === 'B3') { return z.object({ ...baseSchema, - usageType: z.string().min(1, "용도 타입을 선택해주세요"), + usageType: z.string().min(1, "Please select a usage type"), }) } else { return z.object({ @@ -151,7 +151,7 @@ const getUsageTypeOptions = (usage: string) => { // 리비전 형식 가이드 생성 const getRevisionGuide = () => { - return "R01, R02, R03... 형식으로 입력하세요" + return "Enter in R01, R02, R03... format" } interface NewRevisionDialogProps { @@ -216,10 +216,10 @@ function FileUploadArea({ >

- 파일을 드래그하여 놓거나 클릭하여 선택하세요 + Drag files here or click to select

- PDF, Word, Excel, 이미지, 텍스트, ZIP 파일 지원 (최대 50MB) + Supports PDF, Word, Excel, Image, Text, ZIP files (max 50MB)

0 && (
-

선택된 파일 ({files.length}개)

+

Selected Files ({files.length})

{files.map((file, index) => (
{ handleDialogClose() @@ -405,8 +405,8 @@ export function NewRevisionDialog({ }, 1000) } catch (error) { - console.error('❌ 업로드 오류:', error) - toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + console.error('❌ Upload error:', error) + toast.error(error instanceof Error ? error.message : "An error occurred during upload") } finally { setIsUploading(false) setTimeout(() => setUploadProgress(0), 2000) @@ -420,11 +420,11 @@ export function NewRevisionDialog({ - 새 리비전 업로드 + Upload New Revision {documentTitle && ( -
문서: {documentTitle}
+
Document: {documentTitle}
)}
@@ -439,11 +439,11 @@ export function NewRevisionDialog({ name="usage" render={({ field }) => ( - 용도 + Usage - + @@ -493,7 +493,7 @@ export function NewRevisionDialog({ name="revision" render={({ field }) => ( - 리비전 + Revision ( - 코멘트 + Comment