From f8621d49d884c26623423610a0b9924093b1d10d Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 1 Jun 2026 13:33:41 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EB=AC=BC=20Export=20ZIP=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=97=B0=EA=B2=B0=20v1.5.22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 5 + docs/deploy.md | 3 +- docs/history.md | 6 +- docs/map.md | 6 +- docs/spec.md | 7 +- docs/todo.md | 4 +- docs/update.md | 8 + package.json | 2 +- pages/admin/settings/index.vue | 44 +- server/repositories/post-export-repository.js | 623 +++++++++++++++++- .../routes/admin/api/posts/export-jobs.get.js | 8 +- .../admin/api/posts/export-jobs.post.js | 13 +- .../export-jobs/[fileId]/download.get.js | 85 +++ server/utils/zip-writer.js | 167 +++++ 14 files changed, 962 insertions(+), 19 deletions(-) create mode 100644 server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js create mode 100644 server/utils/zip-writer.js diff --git a/docs/changelog.md b/docs/changelog.md index 9d8481f..a79ba8b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.5.22 + +- 게시물 Export가 실제 분할 ZIP 파일을 생성하고, 준비 완료된 파일을 관리자 화면에서 내려받을 수 있도록 개선했다. +- Export 생성 중에는 새 요청 버튼이 비활성화되어 중복 작업을 만들지 않도록 정리했다. + ## v1.5.21 - 게시물 Export 작업 카드에 진행 숫자와 진행률 바를 추가하고, 진행 중 작업이 있으면 자동으로 상태를 새로고침하도록 개선했다. diff --git a/docs/deploy.md b/docs/deploy.md index c6741fc..170101a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.5.21에서 `npm run lint`, `npm run build`, `npm run db:migrate:dev` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.5.22에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -380,6 +380,7 @@ docker compose --env-file .env.production restart sori-studio-db - 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다. - 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다. +- 게시물 Export ZIP 산출물은 `public/uploads/exports/YYYY/MM/{jobId}/` 아래 생성되며, 관리자 다운로드 API를 통해 내려받는다. - `public/uploads/`는 Git에 포함하지 않는다. - NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다. - 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다. diff --git a/docs/history.md b/docs/history.md index 9dbde03..67e809a 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,8 +1,12 @@ # 의사결정 이력 +## 2026-06-01 v1.5.22 — Export는 실제 파일 생성 단계로 연결 + +Export 작업이 대기열과 진행도 표시까지만 존재하면 관리자는 같은 버튼을 반복해서 누르기 쉽고, 실제 백업 파일도 받을 수 없다. 이번 단계에서는 요청 직후 서버 프로세스가 대기 작업을 백그라운드로 실행해 분할 ZIP을 만들고, 준비 완료된 파일만 다운로드할 수 있게 했다. 내부 `/uploads` 자산은 ZIP 내부의 `images/` 또는 `files/` 폴더로 복사하고 Markdown 참조는 상대 경로로 바꿔, 원본 서버 URL이 사라져도 Obsidian에서 백업을 열 수 있는 형태를 우선했다. 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼은 잠가 중복 작업 생성을 막는다. + ## 2026-06-01 v1.5.21 — Export 작업은 숫자로 진행 여부를 확인 -Export 작업이 대기열에만 보이면 관리자는 실제로 서버가 일하고 있는지 알 수 없다. 다운로드가 아직 연결되지 않았더라도 작업 레코드에는 전체 게시물 수 대비 처리된 게시물 수, 현재 분할 파일 순번, 진행 메시지, 시작 시각을 저장할 수 있어야 한다. 설정 화면은 진행 중 작업이 있을 때 주기적으로 목록을 새로고침하고 `1201 / 30002` 같은 숫자와 진행률 바를 보여 주도록 했다. 실제 값 갱신은 zip 생성 워커가 담당하는 후속 작업으로 둔다. +Export 작업이 대기열에만 보이면 관리자는 실제로 서버가 일하고 있는지 알 수 없다. 다운로드가 아직 연결되지 않았더라도 작업 레코드에는 전체 게시물 수 대비 처리된 게시물 수, 현재 분할 파일 순번, 진행 메시지, 시작 시각을 저장할 수 있어야 한다. 설정 화면은 진행 중 작업이 있을 때 주기적으로 목록을 새로고침하고 `1201 / 30002` 같은 숫자와 진행률 바를 보여 주도록 했다. 이 기반은 v1.5.22의 zip 생성 워커에서 실제 값 갱신에 사용한다. ## 2026-06-01 v1.5.20 — Export 구현은 작업 등록부터 단계적으로 연결 diff --git a/docs/map.md b/docs/map.md index 791e7b7..1e2d3d5 100644 --- a/docs/map.md +++ b/docs/map.md @@ -140,7 +140,7 @@ | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 | | pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/[id].vue | 태그 수정 | -| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), 타임존, 게시물 Import/Export 작업 요청·최근 작업·분할 파일 계획·진행도 표시 | +| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), 타임존, 게시물 Import/Export 작업 요청·최근 작업·진행도·준비 완료 분할 파일 다운로드, 진행 중 요청 버튼 잠금 | | lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 | | pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) | | pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) | @@ -205,6 +205,7 @@ | server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API | | server/routes/admin/api/posts/export-jobs.get.js | 관리자 게시물 Export 작업 목록 API | | server/routes/admin/api/posts/export-jobs.post.js | 관리자 게시물 Export 작업 요청 API | +| server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js | 관리자 게시물 Export 분할 ZIP 다운로드 API | | server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API | | server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API | | server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API | @@ -253,11 +254,12 @@ | server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단 | | server/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 | -| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획 저장소 | +| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·ZIP 생성 워커 저장소 | | server/repositories/member-repository.js | 회원 조회/생성 저장소 | | server/repositories/comment-repository.js | 댓글 조회/생성 저장소 | | server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 | | server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 | +| server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 | | server/api/analytics/pageview.post.js | 공개 통계 수집 API | | server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API | | server/utils/analytics-heartbeat-input.js | heartbeat 검증·기록 | diff --git a/docs/spec.md b/docs/spec.md index e5bf594..e79573e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -513,7 +513,8 @@ components/content/ - `PUT /admin/api/posts/:id` - 글 수정 - `DELETE /admin/api/posts/:id` - 글 삭제 - `GET /admin/api/posts/export-jobs` - 게시물 Export 작업 목록 -- `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. 현재 1차 구현은 작업 레코드와 100개 단위 분할 파일 계획을 만든다. +- `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. 작업 레코드와 100개 단위 분할 파일 계획을 만들고 백그라운드 ZIP 생성을 시작한다. +- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드 - `GET /admin/api/pages` - 고정 페이지 목록 - `POST /admin/api/pages` - 고정 페이지 작성 - `GET /admin/api/pages/:id` - 고정 페이지 상세 @@ -688,9 +689,9 @@ components/content/ ### 사이트 설정 -- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존은 메뉴·안내 카드만 제공한다. 게시물 Import/Export는 Export 작업 요청, 최근 작업 목록, 분할 파일 계획을 표시한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다. +- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존은 메뉴·안내 카드만 제공한다. 게시물 Import/Export는 Export 작업 요청, 최근 작업 목록, 진행도, 준비 완료 분할 파일 다운로드를 표시하며 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼을 비활성화한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다. - 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 폴더/zip을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 미디어 업로드 저장소로 가져온 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. -- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 일정 개수 또는 산출 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 1차 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성한다. 작업 레코드는 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 포함해 실제 생성 워커가 붙으면 관리자 화면에서 진행 숫자와 진행률을 표시할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어간다. 모든 분할 파일 생성이 끝나면 요청 관리자에게 이메일로 완료 알림을 보내고, 관리자 화면에는 export 작업별 다운로드 목록을 표시한다. 사용자는 분할 파일을 각각 다운로드하거나, 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 일괄 다운로드가 중간에 멈추면 완료된 항목과 실패/미완료 항목을 구분해 해당 분할 파일부터 다시 받을 수 있어야 한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하고, 만료된 파일과 작업 레코드는 정리 대상이 된다. +- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 일정 개수 또는 산출 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 모든 분할 파일 생성 완료 이메일 알림, 브라우저 일괄 순차 다운로드, 실패 지점 재다운로드, 만료 파일 정리는 후속 구현 대상이다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하고, 만료된 파일과 작업 레코드는 정리 대상이 된다. - 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다. - 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다. - **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 커버 파일 업로드·제목·본문을 편집한 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 읽기·편집 미리보기는 실제 `HomeHero` 컴포넌트를 사용해 긴 본문도 공개 화면과 같은 오버레이 폭(`max-w-[32rem]`)과 줄바꿈으로 확인한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`). diff --git a/docs/todo.md b/docs/todo.md index 466b054..83f2fef 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,8 +2,8 @@ ## 1차 관리자 개발 -- [ ] 게시물 Import/Export 1차 구현: 게시물별 폴더(`제목/제목.md`)와 `images/`, `files/` 로컬 자산 폴더를 포함한 Obsidian 호환 zip 백업 번들, 태그/상태/발행일/대표 이미지/SEO frontmatter 매핑, export 시 내부 `/uploads` URL을 상대 경로로 재작성, import 시 로컬 자산을 미디어 저장소로 복원하고 본문 경로를 새 `/uploads` URL로 재매핑 -- [ ] 게시물 Export 대용량 작업 후속 구현: `post_export_jobs`/`post_export_files` 기준 zip 생성 워커, `processed_count`/`current_part_index`/`progress_message` 진행도 갱신, 내부 `/uploads` 자산 복사와 상대 경로 재작성, 완료 이메일 알림, 준비 완료 파일 다운로드 API, 일괄 순차 다운로드 UI, 실패 지점 재다운로드, 100일 만료 파일 정리 +- [ ] 게시물 Import 1차 구현: Export ZIP의 frontmatter를 게시물 메타데이터로 복원하고, 로컬 `images/`·`files/` 자산을 미디어 저장소로 가져온 뒤 본문 경로를 새 `/uploads/...` URL로 재매핑 +- [ ] 게시물 Export 대용량 작업 후속 구현: 완료 이메일 알림, 일괄 순차 다운로드 UI, 실패 지점 재다운로드, 100일 만료 파일 정리, 산출 ZIP 용량 기준 분할 고도화 - [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토 ## 2차 관리자 개발 diff --git a/docs/update.md b/docs/update.md index f1c7b36..0845434 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 이력 +## v1.5.22 + +- 게시물 Export: 대기 작업을 백그라운드에서 실제 ZIP 파일로 생성하는 서버 실행부 추가. +- 게시물 Export: 게시물별 폴더와 `제목.md`, 내부 `images/`·`files/` 자산 폴더를 ZIP에 포함하도록 추가. +- 게시물 Export: 내부 `/uploads` 이미지·파일 URL을 ZIP 내부 상대 경로로 재작성하도록 추가. +- 게시물 Export: 준비 완료된 분할 ZIP 파일을 관리자 다운로드 API로 내려받을 수 있도록 추가. +- 관리자 사이트 설정: 대기 중·생성 중 Export 작업이 있으면 Export 요청 버튼을 비활성화하도록 수정. + ## v1.5.21 - 게시물 Export: 작업 진행 확인용 `processed_count`, `current_part_index`, `progress_message`, `started_at` 컬럼 추가. diff --git a/package.json b/package.json index e425140..cb80fd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.21", + "version": "1.5.22", "private": true, "type": "module", "imports": { diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index af36d70..c99d8cd 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -180,6 +180,24 @@ const hasActivePostExportJobs = computed(() => normalizedPostExportJobs.value.so job.status === 'queued' || job.status === 'processing' ))) +/** + * 게시물 export 요청 버튼 활성 가능 여부 + * @returns {boolean} 활성 가능 여부 + */ +const canRequestPostExport = computed(() => !requestingPostExport.value && !hasActivePostExportJobs.value) + +/** + * 게시물 export 요청 버튼 안내 문구 + * @returns {string} 안내 문구 + */ +const postExportRequestTitle = computed(() => { + if (hasActivePostExportJobs.value) { + return '진행 중인 Export 작업이 끝난 뒤 새 요청을 만들 수 있습니다.' + } + + return '게시물 Export 작업을 요청합니다.' +}) + /** * export 상태 라벨 조회 * @param {string} status - export 상태 @@ -273,7 +291,7 @@ const getPostExportProgressDescription = (job) => { } if (job.status === 'queued') { - return '작업 대기열에 등록되었습니다. 생성 워커가 시작되면 진행 숫자가 갱신됩니다.' + return '작업 대기열에 등록되었습니다. 곧 파일 생성이 시작됩니다.' } if (job.status === 'processing') { @@ -461,7 +479,7 @@ const showToast = (type, message) => { * @returns {Promise} */ const requestPostExport = async () => { - if (requestingPostExport.value) { + if (!canRequestPostExport.value) { return } @@ -487,6 +505,13 @@ const requestPostExport = async () => { } } +/** + * Export 파일 다운로드 URL을 만든다. + * @param {Object} file - Export 파일 + * @returns {string} 다운로드 URL + */ +const getPostExportDownloadUrl = (file) => `/admin/api/posts/export-jobs/${file.id}/download` + /** * 로고 파일 선택창을 연다. * @returns {void} @@ -1813,10 +1838,11 @@ onBeforeUnmount(() => { @@ -1909,12 +1935,20 @@ onBeforeUnmount(() => { {{ file.postStart }}-{{ file.postEnd }} · {{ formatExportFileSize(file.fileSizeBytes) }}

+ + 다운로드 + diff --git a/server/repositories/post-export-repository.js b/server/repositories/post-export-repository.js index e55b6e1..e6b0f55 100644 --- a/server/repositories/post-export-repository.js +++ b/server/repositories/post-export-repository.js @@ -1,18 +1,30 @@ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, extname, join, relative } from 'node:path' +import { createZipBuffer } from '../utils/zip-writer' import { getDefaultSiteSettings } from '../utils/site-settings' import { getPostgresClient } from './postgres-client' const EXPORT_STATUS = { QUEUED: 'queued', + PROCESSING: 'processing', + FAILED: 'failed', READY: 'ready' } const EXPORT_FILE_STATUS = { - PENDING: 'pending' + PENDING: 'pending', + PROCESSING: 'processing', + READY: 'ready', + FAILED: 'failed' } const DEFAULT_CHUNK_SIZE = 100 const MAX_CHUNK_SIZE = 500 const DEFAULT_RETENTION_DAYS = 100 +const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads') +const EXPORT_ROOT_NAME = 'exports' +const runningPostExportJobIds = new Set() +const localUploadUrlPattern = /\/uploads\/[^\s"'<>)]*/g /** * 파일명에 쓸 수 없는 문자를 정리한다. @@ -27,6 +39,234 @@ const sanitizeFilenameSegment = (value) => String(value || '') .replace(/^-|-$/g, '') .slice(0, 80) || 'sori.studio' +/** + * 중복되지 않는 ZIP 내부 경로 세그먼트를 만든다. + * @param {string} value - 원본 세그먼트 + * @param {Set} usedSegments - 이미 사용한 세그먼트 + * @returns {string} 고유 세그먼트 + */ +const createUniqueSegment = (value, usedSegments) => { + const base = sanitizeFilenameSegment(value) + let next = base + let index = 2 + + while (usedSegments.has(next)) { + next = `${base}-${index}` + index += 1 + } + + usedSegments.add(next) + return next +} + +/** + * ZIP 내부 파일명 중복을 방지한다. + * @param {string} value - 원본 파일명 + * @param {Set} usedNames - 이미 사용한 파일명 + * @returns {string} 고유 파일명 + */ +const createUniqueAssetName = (value, usedNames) => { + const rawName = sanitizeFilenameSegment(value || 'asset') + const extension = extname(rawName) + const stem = extension ? rawName.slice(0, -extension.length) : rawName + let next = rawName + let index = 2 + + while (usedNames.has(next)) { + next = extension ? `${stem}-${index}${extension}` : `${stem}-${index}` + index += 1 + } + + usedNames.add(next) + return next +} + +/** + * YAML 값에 안전한 따옴표를 적용한다. + * @param {unknown} value - 원본 값 + * @returns {string} YAML 문자열 값 + */ +const quoteYamlValue = (value) => `"${String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + +/** + * YAML 배열을 만든다. + * @param {Array} values - 배열 값 + * @returns {string} YAML 배열 + */ +const formatYamlArray = (values) => { + if (!Array.isArray(values) || values.length === 0) { + return '[]' + } + + return `[${values.map((value) => quoteYamlValue(value)).join(', ')}]` +} + +/** + * 내부 업로드 URL인지 확인한다. + * @param {string} value - URL + * @returns {boolean} 내부 업로드 URL 여부 + */ +const isLocalUploadUrl = (value) => String(value || '').startsWith('/uploads/') + +/** + * 이미지 확장자인지 확인한다. + * @param {string} value - 파일명 또는 URL + * @returns {boolean} 이미지 여부 + */ +const isImageAsset = (value) => /\.(avif|gif|jpe?g|png|svg|webp)$/i.test(String(value || '').split(/[?#]/)[0]) + +/** + * 업로드 URL을 안전한 디스크 경로로 변환한다. + * @param {string} url - 내부 업로드 URL + * @returns {string|null} 디스크 경로 + */ +const resolveUploadUrlToDiskPath = (url) => { + const pathOnly = String(url || '').split(/[?#]/)[0] + + if (!isLocalUploadUrl(pathOnly)) { + return null + } + + const relativeUploadPath = decodeURIComponent(pathOnly.replace(/^\/uploads\/?/, '')) + const diskPath = join(UPLOAD_ROOT, relativeUploadPath) + const safeRelativePath = relative(UPLOAD_ROOT, diskPath) + + if (!safeRelativePath || safeRelativePath.startsWith('..')) { + return null + } + + return diskPath +} + +/** + * 게시물 본문에서 내부 업로드 URL을 수집한다. + * @param {string} content - 게시물 본문 + * @returns {Array} 내부 업로드 URL 목록 + */ +const collectLocalUploadUrls = (content) => { + const matches = String(content || '').match(localUploadUrlPattern) || [] + return [...new Set(matches.filter(isLocalUploadUrl))] +} + +/** + * 게시물 frontmatter를 만든다. + * @param {Object} post - 게시물 + * @param {Object} assets - 변환된 자산 경로 + * @returns {string} frontmatter 문자열 + */ +const createPostFrontmatter = (post, assets) => { + const lines = [ + '---', + `id: ${quoteYamlValue(post.id)}`, + `title: ${quoteYamlValue(post.title)}`, + `slug: ${quoteYamlValue(post.slug)}`, + `status: ${quoteYamlValue(post.status)}`, + `published_at: ${post.published_at ? quoteYamlValue(post.published_at.toISOString()) : 'null'}`, + `created_at: ${quoteYamlValue(post.created_at.toISOString())}`, + `updated_at: ${quoteYamlValue(post.updated_at.toISOString())}`, + `excerpt: ${quoteYamlValue(post.excerpt || '')}`, + `featured_image: ${assets.featuredImage ? quoteYamlValue(assets.featuredImage) : 'null'}`, + `seo_title: ${quoteYamlValue(post.seo_title || '')}`, + `seo_description: ${quoteYamlValue(post.seo_description || '')}`, + `canonical_url: ${quoteYamlValue(post.canonical_url || '')}`, + `noindex: ${post.noindex ? 'true' : 'false'}`, + `og_image: ${assets.ogImage ? quoteYamlValue(assets.ogImage) : 'null'}`, + `tags: ${formatYamlArray(post.tags || [])}`, + '---', + '' + ] + + return lines.join('\n') +} + +/** + * 게시물의 내부 업로드 자산을 ZIP 파일 목록으로 변환한다. + * @param {Object} post - 게시물 + * @param {string} postFolder - 게시물 폴더명 + * @returns {Promise} 변환 결과 + */ +const buildPostAssetFiles = async (post, postFolder) => { + const urls = new Set(collectLocalUploadUrls(post.content)) + + if (isLocalUploadUrl(post.featured_image)) { + urls.add(post.featured_image) + } + + if (isLocalUploadUrl(post.og_image)) { + urls.add(post.og_image) + } + + const replacements = {} + const zipFiles = [] + const usedAssetNames = new Set() + + for (const url of urls) { + const diskPath = resolveUploadUrlToDiskPath(url) + + if (!diskPath) { + continue + } + + try { + const fileStat = await stat(diskPath) + + if (!fileStat.isFile()) { + continue + } + + const assetFolder = isImageAsset(url) ? 'images' : 'files' + const fileName = createUniqueAssetName(basename(diskPath), usedAssetNames) + const localPath = `./${assetFolder}/${fileName}` + + replacements[url] = localPath + zipFiles.push({ + path: `${postFolder}/${assetFolder}/${fileName}`, + data: await readFile(diskPath) + }) + } catch { + replacements[url] = url + } + } + + return { + replacements, + zipFiles + } +} + +/** + * 게시물을 Obsidian 친화적인 Markdown 파일 묶음으로 변환한다. + * @param {Object} post - 게시물 + * @param {Set} usedFolderNames - 사용한 폴더명 + * @returns {Promise>} ZIP 내부 파일 목록 + */ +const buildPostZipFiles = async (post, usedFolderNames) => { + const postFolder = createUniqueSegment(post.title || post.slug || post.id, usedFolderNames) + const { replacements, zipFiles } = await buildPostAssetFiles(post, postFolder) + const replaceUrl = (value) => { + let next = String(value || '') + Object.entries(replacements).forEach(([source, target]) => { + next = next.split(source).join(target) + }) + return next + } + const markdown = [ + createPostFrontmatter(post, { + featuredImage: replacements[post.featured_image] || post.featured_image || '', + ogImage: replacements[post.og_image] || post.og_image || '' + }), + replaceUrl(post.content || '') + ].join('\n') + + return [ + { + path: `${postFolder}/${sanitizeFilenameSegment(post.title || post.slug || 'post')}.md`, + data: markdown + }, + ...zipFiles + ] +} + /** * export 작업 행을 응답 구조로 변환한다. * @param {Object} row - DB 행 @@ -161,6 +401,28 @@ export const listPostExportJobs = async () => { })) } +/** + * 실행 가능한 대기 작업 ID 목록 조회 + * @returns {Promise>} 대기 작업 ID 목록 + */ +export const listQueuedPostExportJobIds = async () => { + const sql = getPostgresClient() + + if (!sql) { + return [] + } + + const rows = await sql` + SELECT id + FROM post_export_jobs + WHERE status = ${EXPORT_STATUS.QUEUED} + ORDER BY created_at ASC + LIMIT 3 + ` + + return rows.map((row) => row.id) +} + /** * 게시물 export 작업 요청 생성 * @param {Object} input - 작업 요청 입력 @@ -275,3 +537,362 @@ export const createPostExportJob = async (input) => { files: [] }) } + +/** + * Export 작업 상세를 조회한다. + * @param {string} jobId - 작업 ID + * @returns {Promise} Export 작업 + */ +export const getPostExportJobById = async (jobId) => { + const sql = getPostgresClient() + + if (!sql) { + return null + } + + const jobRows = await sql` + SELECT * + FROM post_export_jobs + WHERE id = ${jobId} + LIMIT 1 + ` + + if (!jobRows[0]) { + return null + } + + const fileRows = await sql` + SELECT * + FROM post_export_files + WHERE job_id = ${jobId} + ORDER BY part_index ASC + ` + + return mapPostExportJobRow({ + ...jobRows[0], + files: fileRows.map(mapPostExportFileRow) + }) +} + +/** + * Export 작업을 처리 중 상태로 변경한다. + * @param {ReturnType} sql - PostgreSQL 클라이언트 + * @param {string} jobId - 작업 ID + * @returns {Promise} + */ +const markPostExportJobProcessing = async (sql, jobId) => { + await sql` + UPDATE post_export_jobs + SET status = ${EXPORT_STATUS.PROCESSING}, + started_at = COALESCE(started_at, now()), + progress_message = 'Export 파일 생성을 시작했습니다.', + updated_at = now() + WHERE id = ${jobId} + AND status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING}) + ` +} + +/** + * Export 작업 진행도를 저장한다. + * @param {ReturnType} sql - PostgreSQL 클라이언트 + * @param {Object} input - 진행도 입력 + * @returns {Promise} + */ +const updatePostExportJobProgress = async (sql, input) => { + await sql` + UPDATE post_export_jobs + SET processed_count = ${input.processedCount}, + current_part_index = ${input.currentPartIndex}, + progress_message = ${input.progressMessage}, + updated_at = now() + WHERE id = ${input.jobId} + ` +} + +/** + * Export 작업을 완료 상태로 변경한다. + * @param {ReturnType} sql - PostgreSQL 클라이언트 + * @param {string} jobId - 작업 ID + * @param {number} postCount - 전체 게시물 수 + * @returns {Promise} + */ +const markPostExportJobReady = async (sql, jobId, postCount) => { + await sql` + UPDATE post_export_jobs + SET status = ${EXPORT_STATUS.READY}, + processed_count = ${postCount}, + current_part_index = null, + message = '게시물 Export 파일 생성이 완료되었습니다.', + progress_message = '생성이 완료되었습니다.', + completed_at = now(), + updated_at = now() + WHERE id = ${jobId} + ` +} + +/** + * Export 작업을 실패 상태로 변경한다. + * @param {ReturnType} sql - PostgreSQL 클라이언트 + * @param {string} jobId - 작업 ID + * @param {unknown} error - 오류 + * @returns {Promise} + */ +const markPostExportJobFailed = async (sql, jobId, error) => { + const message = error instanceof Error ? error.message : '알 수 없는 오류' + + await sql` + UPDATE post_export_jobs + SET status = ${EXPORT_STATUS.FAILED}, + message = ${`게시물 Export 생성 실패: ${message}`}, + progress_message = ${message}, + current_part_index = null, + completed_at = now(), + updated_at = now() + WHERE id = ${jobId} + ` +} + +/** + * Export 분할 파일을 처리 중 상태로 변경한다. + * @param {ReturnType} sql - PostgreSQL 클라이언트 + * @param {string} fileId - 파일 ID + * @returns {Promise} + */ +const markPostExportFileProcessing = async (sql, fileId) => { + await sql` + UPDATE post_export_files + SET status = ${EXPORT_FILE_STATUS.PROCESSING}, + updated_at = now() + WHERE id = ${fileId} + ` +} + +/** + * Export 분할 파일을 완료 상태로 변경한다. + * @param {ReturnType} sql - PostgreSQL 클라이언트 + * @param {Object} input - 파일 완료 입력 + * @returns {Promise} + */ +const markPostExportFileReady = async (sql, input) => { + await sql` + UPDATE post_export_files + SET status = ${EXPORT_FILE_STATUS.READY}, + file_path = ${input.filePath}, + file_size_bytes = ${input.fileSizeBytes}, + completed_at = now(), + updated_at = now() + WHERE id = ${input.fileId} + ` +} + +/** + * Export 분할 파일을 실패 상태로 변경한다. + * @param {ReturnType} sql - PostgreSQL 클라이언트 + * @param {string} fileId - 파일 ID + * @returns {Promise} + */ +const markPostExportFileFailed = async (sql, fileId) => { + await sql` + UPDATE post_export_files + SET status = ${EXPORT_FILE_STATUS.FAILED}, + updated_at = now() + WHERE id = ${fileId} + ` +} + +/** + * Export 분할 범위에 해당하는 게시물을 조회한다. + * @param {ReturnType} sql - PostgreSQL 클라이언트 + * @param {Object} job - Export 작업 + * @param {Object} file - Export 분할 파일 + * @returns {Promise} 게시물 목록 + */ +const listPostsForExportFile = async (sql, job, file) => { + const limit = Number(file.postEnd || 0) - Number(file.postStart || 0) + 1 + const offset = Number(file.postStart || 1) - 1 + + if (job.scope === 'author') { + return sql` + SELECT + posts.*, + COALESCE(array_agg(tags.slug ORDER BY tags.sort_order ASC, tags.name ASC) FILTER (WHERE tags.id IS NOT NULL), '{}') AS tags + FROM posts + LEFT JOIN post_tags ON post_tags.post_id = posts.id + LEFT JOIN tags ON tags.id = post_tags.tag_id + WHERE posts.author_id = ${job.requestedBy} + GROUP BY posts.id + ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC + LIMIT ${limit} + OFFSET ${offset} + ` + } + + return sql` + SELECT + posts.*, + COALESCE(array_agg(tags.slug ORDER BY tags.sort_order ASC, tags.name ASC) FILTER (WHERE tags.id IS NOT NULL), '{}') AS tags + FROM posts + LEFT JOIN post_tags ON post_tags.post_id = posts.id + LEFT JOIN tags ON tags.id = post_tags.tag_id + GROUP BY posts.id + ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC + LIMIT ${limit} + OFFSET ${offset} + ` +} + +/** + * Export ZIP 파일을 생성한다. + * @param {Object} job - Export 작업 + * @param {Object} file - Export 분할 파일 + * @returns {Promise} 생성 결과 + */ +const createPostExportZipFile = async (job, file) => { + const sql = getPostgresClient() + const posts = await listPostsForExportFile(sql, job, file) + const usedFolderNames = new Set() + const zipEntries = [] + + for (const post of posts) { + zipEntries.push(...await buildPostZipFiles(post, usedFolderNames)) + } + + const zipBuffer = createZipBuffer(zipEntries) + const now = new Date() + const year = String(now.getFullYear()) + const month = String(now.getMonth() + 1).padStart(2, '0') + const relativePath = join(EXPORT_ROOT_NAME, year, month, job.id, file.fileName) + const absolutePath = join(UPLOAD_ROOT, relativePath) + + await mkdir(dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, zipBuffer) + + return { + relativePath, + fileSizeBytes: zipBuffer.length, + postCount: posts.length + } +} + +/** + * 게시물 Export 작업을 실제로 실행한다. + * @param {string} jobId - Export 작업 ID + * @returns {Promise} + */ +export const runPostExportJob = async (jobId) => { + const sql = getPostgresClient() + + if (!sql || runningPostExportJobIds.has(jobId)) { + return + } + + runningPostExportJobIds.add(jobId) + + try { + const job = await getPostExportJobById(jobId) + + if (!job || ![EXPORT_STATUS.QUEUED, EXPORT_STATUS.PROCESSING].includes(job.status)) { + return + } + + await markPostExportJobProcessing(sql, jobId) + + const readyPostCount = job.files + .filter((file) => file.status === EXPORT_FILE_STATUS.READY) + .reduce((total, file) => total + Math.max(file.postEnd - file.postStart + 1, 0), 0) + let processedCount = Math.max(Number(job.processedCount || 0), readyPostCount) + + for (const file of job.files) { + if (file.status === EXPORT_FILE_STATUS.READY) { + continue + } + + try { + await markPostExportFileProcessing(sql, file.id) + await updatePostExportJobProgress(sql, { + jobId, + processedCount, + currentPartIndex: file.partIndex, + progressMessage: `${file.partIndex}번째 분할 파일을 생성하는 중입니다.` + }) + + const result = await createPostExportZipFile(job, file) + processedCount += result.postCount + await markPostExportFileReady(sql, { + fileId: file.id, + filePath: result.relativePath, + fileSizeBytes: result.fileSizeBytes + }) + await updatePostExportJobProgress(sql, { + jobId, + processedCount, + currentPartIndex: file.partIndex, + progressMessage: `${file.fileName} 생성 완료 (${processedCount.toLocaleString()} / ${job.postCount.toLocaleString()})` + }) + } catch (error) { + await markPostExportFileFailed(sql, file.id) + throw error + } + } + + await markPostExportJobReady(sql, jobId, job.postCount) + } catch (error) { + await markPostExportJobFailed(sql, jobId, error) + } finally { + runningPostExportJobIds.delete(jobId) + } +} + +/** + * Export 작업을 백그라운드 실행 대기열에 올린다. + * @param {string} jobId - Export 작업 ID + * @returns {void} + */ +export const queuePostExportJobRun = (jobId) => { + if (!jobId || runningPostExportJobIds.has(jobId)) { + return + } + + setTimeout(() => { + runPostExportJob(jobId).catch((error) => { + console.error('게시물 Export 작업 실행 실패', error) + }) + }, 0) +} + +/** + * 대기 중인 Export 작업을 백그라운드 실행 대기열에 올린다. + * @returns {Promise} + */ +export const queuePendingPostExportJobs = async () => { + const jobIds = await listQueuedPostExportJobIds() + jobIds.forEach(queuePostExportJobRun) +} + +/** + * 다운로드 가능한 Export 파일을 조회한다. + * @param {string} fileId - Export 파일 ID + * @returns {Promise} Export 파일 + */ +export const getReadyPostExportFile = async (fileId) => { + const sql = getPostgresClient() + + if (!sql) { + return null + } + + const rows = await sql` + SELECT * + FROM post_export_files + WHERE id = ${fileId} + AND status = ${EXPORT_FILE_STATUS.READY} + AND file_path <> '' + LIMIT 1 + ` + + if (!rows[0]) { + return null + } + + return mapPostExportFileRow(rows[0]) +} diff --git a/server/routes/admin/api/posts/export-jobs.get.js b/server/routes/admin/api/posts/export-jobs.get.js index 19da6da..652d10b 100644 --- a/server/routes/admin/api/posts/export-jobs.get.js +++ b/server/routes/admin/api/posts/export-jobs.get.js @@ -1,5 +1,8 @@ import { requireAdminSession } from '../../../../utils/admin-auth' -import { listPostExportJobs } from '../../../../repositories/post-export-repository' +import { + listPostExportJobs, + queuePendingPostExportJobs +} from '../../../../repositories/post-export-repository' /** * 관리자 게시물 Export 작업 목록 API @@ -8,6 +11,9 @@ import { listPostExportJobs } from '../../../../repositories/post-export-reposit */ export default defineEventHandler((event) => { requireAdminSession(event) + queuePendingPostExportJobs().catch((error) => { + console.error('대기 중인 게시물 Export 작업 실행 실패', error) + }) return listPostExportJobs() }) diff --git a/server/routes/admin/api/posts/export-jobs.post.js b/server/routes/admin/api/posts/export-jobs.post.js index 6f9deef..8887d50 100644 --- a/server/routes/admin/api/posts/export-jobs.post.js +++ b/server/routes/admin/api/posts/export-jobs.post.js @@ -1,7 +1,10 @@ import { readBody } from 'h3' import { z } from 'zod' import { requireAdminSession } from '../../../../utils/admin-auth' -import { createPostExportJob } from '../../../../repositories/post-export-repository' +import { + createPostExportJob, + queuePostExportJobRun +} from '../../../../repositories/post-export-repository' const postExportJobInputSchema = z.object({ chunkSize: z.number().int().min(1).max(500).optional(), @@ -17,11 +20,17 @@ export default defineEventHandler(async (event) => { const adminSession = requireAdminSession(event) const input = postExportJobInputSchema.parse(await readBody(event)) - return createPostExportJob({ + const job = await createPostExportJob({ requestedBy: adminSession.userId, requestedEmail: adminSession.email, scope: 'all', chunkSize: input.chunkSize, retentionDays: input.retentionDays }) + + if (job.status === 'queued') { + queuePostExportJobRun(job.id) + } + + return job }) diff --git a/server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js b/server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js new file mode 100644 index 0000000..5a8bca6 --- /dev/null +++ b/server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js @@ -0,0 +1,85 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { join, relative } from 'node:path' +import { + createError, + getRouterParam, + sendStream, + setResponseHeader +} from 'h3' +import { requireAdminSession } from '../../../../../../utils/admin-auth' +import { getReadyPostExportFile } from '../../../../../../repositories/post-export-repository' + +const uploadRoot = join(process.cwd(), 'public', 'uploads') + +/** + * Export 파일 경로를 안전하게 해석한다. + * @param {string} filePath - DB에 저장된 상대 경로 + * @returns {string|null} 디스크 경로 + */ +const resolveExportFilePath = (filePath) => { + const absolutePath = join(uploadRoot, filePath || '') + const relativePath = relative(uploadRoot, absolutePath) + + if (!relativePath || relativePath.startsWith('..')) { + return null + } + + return absolutePath +} + +/** + * 관리자 게시물 Export 파일 다운로드 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} ZIP 파일 스트림 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const fileId = getRouterParam(event, 'fileId') + + if (!fileId) { + throw createError({ + statusCode: 400, + statusMessage: 'Export 파일 ID가 필요합니다.' + }) + } + + const exportFile = await getReadyPostExportFile(fileId) + + if (!exportFile) { + throw createError({ + statusCode: 404, + statusMessage: 'Export 파일을 찾을 수 없습니다.' + }) + } + + const absolutePath = resolveExportFilePath(exportFile.filePath) + + if (!absolutePath) { + throw createError({ + statusCode: 404, + statusMessage: 'Export 파일 경로가 올바르지 않습니다.' + }) + } + + const fileStat = await stat(absolutePath).catch(() => null) + + if (!fileStat?.isFile()) { + throw createError({ + statusCode: 404, + statusMessage: 'Export 파일이 서버에 없습니다.' + }) + } + + setResponseHeader(event, 'content-type', 'application/zip') + setResponseHeader(event, 'content-length', String(fileStat.size)) + setResponseHeader(event, 'cache-control', 'private, no-store') + setResponseHeader( + event, + 'content-disposition', + `attachment; filename*=UTF-8''${encodeURIComponent(exportFile.fileName)}` + ) + + return sendStream(event, createReadStream(absolutePath)) +}) diff --git a/server/utils/zip-writer.js b/server/utils/zip-writer.js new file mode 100644 index 0000000..9e9f6fa --- /dev/null +++ b/server/utils/zip-writer.js @@ -0,0 +1,167 @@ +import { deflateRawSync } from 'node:zlib' + +const crcTable = Array.from({ length: 256 }, (_, index) => { + let value = index + + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 + } + + return value >>> 0 +}) + +/** + * CRC32 값을 계산한다. + * @param {Buffer} input - 원본 데이터 + * @returns {number} CRC32 + */ +const calculateCrc32 = (input) => { + let crc = 0xffffffff + + for (const byte of input) { + crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8) + } + + return (crc ^ 0xffffffff) >>> 0 +} + +/** + * ZIP DOS 날짜/시간 값을 만든다. + * @param {Date} date - 기준 날짜 + * @returns {{ dosDate: number, dosTime: number }} DOS 날짜/시간 + */ +const createDosDateTime = (date) => { + const year = Math.max(date.getFullYear(), 1980) + + return { + dosTime: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), + dosDate: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate() + } +} + +/** + * ZIP 엔트리 경로를 정리한다. + * @param {string} pathValue - 엔트리 경로 + * @returns {string} 정리된 경로 + */ +const normalizeZipPath = (pathValue) => String(pathValue || '') + .replace(/\\/g, '/') + .replace(/^\/+/g, '') + .replace(/\.\.+/g, '.') + +/** + * ZIP 로컬 파일 헤더를 만든다. + * @param {Object} entry - 엔트리 정보 + * @returns {Buffer} 로컬 파일 헤더 + */ +const createLocalFileHeader = (entry) => { + const header = Buffer.alloc(30) + header.writeUInt32LE(0x04034b50, 0) + header.writeUInt16LE(20, 4) + header.writeUInt16LE(0x0800, 6) + header.writeUInt16LE(8, 8) + header.writeUInt16LE(entry.dosTime, 10) + header.writeUInt16LE(entry.dosDate, 12) + header.writeUInt32LE(entry.crc32, 14) + header.writeUInt32LE(entry.compressedSize, 18) + header.writeUInt32LE(entry.uncompressedSize, 22) + header.writeUInt16LE(entry.name.length, 26) + header.writeUInt16LE(0, 28) + + return Buffer.concat([header, entry.name]) +} + +/** + * ZIP 중앙 디렉터리 헤더를 만든다. + * @param {Object} entry - 엔트리 정보 + * @returns {Buffer} 중앙 디렉터리 헤더 + */ +const createCentralDirectoryHeader = (entry) => { + const header = Buffer.alloc(46) + header.writeUInt32LE(0x02014b50, 0) + header.writeUInt16LE(20, 4) + header.writeUInt16LE(20, 6) + header.writeUInt16LE(0x0800, 8) + header.writeUInt16LE(8, 10) + header.writeUInt16LE(entry.dosTime, 12) + header.writeUInt16LE(entry.dosDate, 14) + header.writeUInt32LE(entry.crc32, 16) + header.writeUInt32LE(entry.compressedSize, 20) + header.writeUInt32LE(entry.uncompressedSize, 24) + header.writeUInt16LE(entry.name.length, 28) + header.writeUInt16LE(0, 30) + header.writeUInt16LE(0, 32) + header.writeUInt16LE(0, 34) + header.writeUInt16LE(0, 36) + header.writeUInt32LE(0, 38) + header.writeUInt32LE(entry.offset, 42) + + return Buffer.concat([header, entry.name]) +} + +/** + * ZIP 종료 레코드를 만든다. + * @param {number} entryCount - 엔트리 개수 + * @param {number} centralDirectorySize - 중앙 디렉터리 크기 + * @param {number} centralDirectoryOffset - 중앙 디렉터리 시작 위치 + * @returns {Buffer} 종료 레코드 + */ +const createEndOfCentralDirectory = (entryCount, centralDirectorySize, centralDirectoryOffset) => { + const endRecord = Buffer.alloc(22) + endRecord.writeUInt32LE(0x06054b50, 0) + endRecord.writeUInt16LE(0, 4) + endRecord.writeUInt16LE(0, 6) + endRecord.writeUInt16LE(entryCount, 8) + endRecord.writeUInt16LE(entryCount, 10) + endRecord.writeUInt32LE(centralDirectorySize, 12) + endRecord.writeUInt32LE(centralDirectoryOffset, 16) + endRecord.writeUInt16LE(0, 20) + + return endRecord +} + +/** + * 메모리에서 ZIP 파일 버퍼를 만든다. + * @param {Array<{ path: string, data: Buffer | string }>} files - ZIP 파일 목록 + * @returns {Buffer} ZIP 버퍼 + */ +export const createZipBuffer = (files) => { + const localFileParts = [] + const centralDirectoryParts = [] + const entries = [] + let offset = 0 + + for (const file of files) { + const normalizedPath = normalizeZipPath(file.path) + if (!normalizedPath) { + continue + } + + const rawData = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data), 'utf8') + const compressedData = deflateRawSync(rawData) + const { dosDate, dosTime } = createDosDateTime(new Date()) + const entry = { + name: Buffer.from(normalizedPath, 'utf8'), + crc32: calculateCrc32(rawData), + compressedSize: compressedData.length, + uncompressedSize: rawData.length, + dosDate, + dosTime, + offset + } + const localHeader = createLocalFileHeader(entry) + + localFileParts.push(localHeader, compressedData) + offset += localHeader.length + compressedData.length + entries.push(entry) + } + + for (const entry of entries) { + centralDirectoryParts.push(createCentralDirectoryHeader(entry)) + } + + const centralDirectory = Buffer.concat(centralDirectoryParts) + const endRecord = createEndOfCentralDirectory(entries.length, centralDirectory.length, offset) + + return Buffer.concat([...localFileParts, centralDirectory, endRecord]) +}