From 200632f580ef79204c1845cc1f093f6ca5d21110 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Mon, 13 Apr 2026 18:58:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(upload):=20decodeURIComponent(escape)=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=ED=95=9C=EA=B8=80=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EB=B3=B5=EC=9B=90,=20defParamCharset=20utf8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/decode-upload-filename.js: 전형적 Latin-1 래핑 UTF-8 복원 - multer 경영성과 업로드는 기본 latin1 + 서버에서 decodeUploadFilename - utf8 defParamCharset는 이중 디코딩으로 á 패턴 등이 날 수 있어 제거 Made-with: Cursor --- README.md | 2 +- lib/decode-upload-filename.js | 42 +++++++++++++++++++++++++++++++++++ server.js | 24 +++----------------- 3 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 lib/decode-upload-filename.js diff --git a/README.md b/README.md index 2ab2e91..dcbe5ed 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃) - **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동 - **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결 -- **경영성과 대시보드** (`/dashboard/business-performance`): 상단 **엑셀 업로드**(`.xlsx`, 매출일보 시트) → DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 하단 **대시보드 조회**는 동일 페이지에 Chart.js 템플릿을 인라인으로 렌더(iframe 제거, 별도 `/dashboard/business-performance/embed`는 직접 열람용으로 유지). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 Multer **`defParamCharset: 'utf8'`**(Busboy가 `Content-Disposition`의 `filename`을 UTF-8로 해석)과, 예외 시 **`decodeMultipartFilename`**(Latin-1로 잘못 들어온 바이트를 UTF-8로 재해석)로 보정 후 저장합니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작. +- **경영성과 대시보드** (`/dashboard/business-performance`): 상단 **엑셀 업로드**(`.xlsx`, 매출일보 시트) → DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 하단 **대시보드 조회**는 동일 페이지에 Chart.js 템플릿을 인라인으로 렌더(iframe 제거, 별도 `/dashboard/business-performance/embed`는 직접 열람용으로 유지). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작. - **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`data/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다. - **대시보드 메뉴 접근**: `.env`의 `DASHBOARD_MENU_ALLOWED_EMAILS`에 **쉼표로 구분한 OPS 로그인 이메일**만 좌측 **대시보드** 메뉴·`/dashboard`·경영성과 API가 보입니다. 목록이 비어 있으면 누구에게도 표시되지 않습니다. 로컬(DEV)에서 관리자 토큰만 쓰는 경우 `DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1`과 `MEETING_DEV_EMAIL`을 허용 목록과 맞추면 대조됩니다. - **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만) diff --git a/lib/decode-upload-filename.js b/lib/decode-upload-filename.js new file mode 100644 index 0000000..178bd39 --- /dev/null +++ b/lib/decode-upload-filename.js @@ -0,0 +1,42 @@ +"use strict"; + +/** + * multipart `filename` 복원. + * + * 브라우저는 UTF-8 파일명을 `Content-Disposition`에 넣을 때, 바이트를 ISO-8859-1(Latin-1) 코드 포인트 + * (U+00xx) 한 글자씩으로 보내는 경우가 많습니다. 이 경우 `decodeURIComponent(escape(s))` 또는 + * `Buffer.from(s, "latin1").toString("utf8")`로 UTF-8 문자열로 되돌릴 수 있습니다. + * + * 일부 환경에서는 이미 잘못 합쳐진 유니코드(예: U+00E1 등)만 남아 복구가 불가능할 수 있습니다. + * + * @param {string} name multer `originalname` + * @returns {string} + */ +function decodeUploadFilename(name) { + if (name == null || typeof name !== "string") return ""; + const raw = name.trim(); + if (!raw) return ""; + const nfc = raw.normalize("NFC"); + if (/[\uAC00-\uD7A3]/.test(nfc)) return nfc; + + try { + const viaEscape = decodeURIComponent(escape(nfc)); + if (!viaEscape.includes("\uFFFD") && viaEscape !== nfc) { + return viaEscape.normalize("NFC"); + } + } catch (_) { + /* escape가 만들어 낸 % 시퀀스가 URI로 부적합한 경우 */ + } + + try { + const buf = Buffer.from(nfc, "latin1"); + const asUtf8 = buf.toString("utf8"); + if (!asUtf8.includes("\uFFFD")) { + return asUtf8.normalize("NFC"); + } + } catch (_) {} + + return nfc; +} + +module.exports = { decodeUploadFilename }; diff --git a/server.js b/server.js index 4634684..69cdc5c 100644 --- a/server.js +++ b/server.js @@ -27,24 +27,7 @@ const { } = require("./lib/ops-state"); const { fetchOpenGraphImageUrl } = require("./lib/link-preview"); const mgmtPerf = require("./lib/mgmt-perf"); - -/** - * multipart `filename`이 Latin-1로 잘못 해석된 UTF-8 바이트일 때 복원합니다. - * 이미 한글이 올바른 유니코드로 들어온 경우는 그대로 둡니다. - */ -function decodeMultipartFilename(name) { - if (name == null || typeof name !== "string") return ""; - // 이미 한글 등 BMP가 올바르게 들어온 경우(멀티바이트 그대로) 덮어쓰지 않음 - if (/[\uAC00-\uD7A3]/.test(name)) return name; - try { - const dec = Buffer.from(name, "latin1").toString("utf8"); - // UTF-8이 Latin-1로 잘못 해석된 경우 복원. ASCII-only 파일명은 dec ≈ name. - if (dec && !dec.includes("\uFFFD")) return dec; - } catch (_) { - /* ignore */ - } - return name; -} +const { decodeUploadFilename } = require("./lib/decode-upload-filename"); const app = express(); const PORT = process.env.PORT || 8030; @@ -569,8 +552,7 @@ const mgmtPerfStorage = multer.diskStorage({ const uploadMgmtPerfExcel = multer({ storage: mgmtPerfStorage, limits: { fileSize: 55 * 1024 * 1024 }, - /** Busboy 기본은 latin1; `filename="..."` 안의 UTF-8 바이트를 올바르게 UTF-8 문자열로 씁니다. */ - defParamCharset: "utf8", + /** 기본(latin1): `filename`을 바이트→U+00xx 문자열로 두고 `decodeUploadFilename`에서 UTF-8 복원. `utf8`이면 이중 디코딩·깨짐이 날 수 있음. */ fileFilter: (_, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (ext !== ".xlsx") { @@ -1263,7 +1245,7 @@ app.post( const payload = mgmtPerf.buildPayloadFromWorkbook(buf, defaultPayload); await mgmtPerf.saveUploadAndSnapshot(pgPool, { userEmail: email, - originalFilename: decodeMultipartFilename(req.file.originalname), + originalFilename: decodeUploadFilename(req.file.originalname), filePath: req.file.path, fiscalYear, quarter,