게시물 Export ZIP Import 추가 v1.5.27
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.5.27
|
||||||
|
|
||||||
|
- 게시물 Export ZIP을 관리자 설정에서 다시 Import할 수 있게 했다.
|
||||||
|
- Import 시 Markdown frontmatter를 게시물 메타데이터로 복원하고, ZIP 내부 이미지·파일은 새 업로드 URL로 재매핑한다.
|
||||||
|
- 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 새 슬러그로 가져오도록 정리했다.
|
||||||
|
|
||||||
## v1.5.26
|
## v1.5.26
|
||||||
|
|
||||||
- 관리자 사이트 설정의 게시물 Import/Export 영역을 기본적으로 접힌 액션 중심 UI로 정리했다.
|
- 관리자 사이트 설정의 게시물 Import/Export 영역을 기본적으로 접힌 액션 중심 UI로 정리했다.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 로컬 기준 v1.5.26에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
> 로컬 기준 v1.5.27에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
@@ -240,6 +240,8 @@ docker compose --env-file .env.production up -d --build
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `POST_EXPORT_MAX_FILE_SIZE_BYTES` | 게시물 Export 분할 ZIP 목표 최대 용량. 기본값은 500MB이며 관리자 설정 화면 요청값이 있으면 그 값을 우선 사용한다. |
|
| `POST_EXPORT_MAX_FILE_SIZE_BYTES` | 게시물 Export 분할 ZIP 목표 최대 용량. 기본값은 500MB이며 관리자 설정 화면 요청값이 있으면 그 값을 우선 사용한다. |
|
||||||
|
|
||||||
|
- 게시물 Import는 관리자 설정의 Import 패널에서 Export ZIP 파일을 업로드해 실행한다. 1회 업로드 파일은 300MB 이하, Markdown 게시물은 최대 1000개까지 처리한다.
|
||||||
|
|
||||||
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
||||||
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
||||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-06-02 v1.5.27 — Import 1차는 덮어쓰기보다 안전한 추가 가져오기
|
||||||
|
|
||||||
|
Export ZIP은 백업 복구와 다른 환경으로의 이관에 모두 쓰일 수 있다. 같은 슬러그가 이미 있는 운영 DB에서 Import가 기존 글을 바로 덮어쓰면 복구보다 손상 위험이 커지므로, 1차 Import는 모든 게시물을 새 글로 추가하고 슬러그 충돌 시 `-2`, `-3` 접미사를 붙이는 정책으로 둔다. ZIP 내부 자산은 원래 URL을 믿지 않고 새 업로드 저장소로 다시 복사해 본문 경로를 새 `/uploads/...` URL로 재매핑한다.
|
||||||
|
|
||||||
## 2026-06-02 v1.5.26 — Import/Export 설정은 필요할 때만 펼친다
|
## 2026-06-02 v1.5.26 — Import/Export 설정은 필요할 때만 펼친다
|
||||||
|
|
||||||
게시물 Import/Export는 운영자가 매일 조작하는 기본 설정이 아니라 필요할 때 요청하거나 다운로드하는 백업 도구다. 상세 범위와 분할 설정이 항상 카드 상단을 크게 차지하면 다른 설정을 훑는 흐름이 무거워지므로, 기본 화면은 `Export 요청`과 `Import 하기` 진입 버튼, 그리고 요청된 작업의 다운로드 상태 중심으로 줄였다. Export 조건 입력은 버튼을 눌렀을 때만 펼치고, 아직 실제 가져오기 API가 연결되지 않은 Import는 접힌 안내 패널로만 둔다.
|
게시물 Import/Export는 운영자가 매일 조작하는 기본 설정이 아니라 필요할 때 요청하거나 다운로드하는 백업 도구다. 상세 범위와 분할 설정이 항상 카드 상단을 크게 차지하면 다른 설정을 훑는 흐름이 무거워지므로, 기본 화면은 `Export 요청`과 `Import 하기` 진입 버튼, 그리고 요청된 작업의 다운로드 상태 중심으로 줄였다. Export 조건 입력은 버튼을 눌렀을 때만 펼치고, 아직 실제 가져오기 API가 연결되지 않은 Import는 접힌 안내 패널로만 둔다.
|
||||||
|
|||||||
@@ -208,6 +208,7 @@
|
|||||||
| server/routes/admin/api/posts/export-jobs/[jobId].delete.js | 관리자 게시물 Export 작업·생성 ZIP 삭제 API |
|
| server/routes/admin/api/posts/export-jobs/[jobId].delete.js | 관리자 게시물 Export 작업·생성 ZIP 삭제 API |
|
||||||
| server/routes/admin/api/posts/export-jobs/[jobId]/retry.post.js | 관리자 게시물 Export 실패 작업 재시도 API |
|
| server/routes/admin/api/posts/export-jobs/[jobId]/retry.post.js | 관리자 게시물 Export 실패 작업 재시도 API |
|
||||||
| server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js | 관리자 게시물 Export 분할 ZIP 다운로드 API |
|
| server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js | 관리자 게시물 Export 분할 ZIP 다운로드 API |
|
||||||
|
| server/routes/admin/api/posts/import.post.js | 관리자 게시물 Export ZIP Import API |
|
||||||
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API |
|
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API |
|
||||||
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
|
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
|
||||||
| server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API |
|
| server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API |
|
||||||
@@ -257,11 +258,13 @@
|
|||||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||||
| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·ZIP 생성 워커 저장소 |
|
| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·ZIP 생성 워커 저장소 |
|
||||||
|
| server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter·자산 재매핑·게시물 생성) |
|
||||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||||
| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 |
|
| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 |
|
||||||
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
|
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
|
||||||
| server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 |
|
| server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 |
|
||||||
|
| server/utils/zip-reader.js | 게시물 Import ZIP 읽기 유틸리티 |
|
||||||
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
|
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
|
||||||
| server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API |
|
| server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API |
|
||||||
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·기록 |
|
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·기록 |
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ components/content/
|
|||||||
- `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제
|
- `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제
|
||||||
- `POST /admin/api/posts/export-jobs/:jobId/retry` - 실패한 게시물 Export 작업을 준비 완료 파일은 유지한 채 나머지 분할 파일부터 재시도
|
- `POST /admin/api/posts/export-jobs/:jobId/retry` - 실패한 게시물 Export 작업을 준비 완료 파일은 유지한 채 나머지 분할 파일부터 재시도
|
||||||
- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드
|
- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드
|
||||||
|
- `POST /admin/api/posts/import` - Export ZIP 파일 Import. multipart `file` 필드의 ZIP을 읽어 Markdown frontmatter를 게시물로 생성하고, ZIP 내부 `images/`·`files/` 자산을 새 업로드 URL로 재매핑한다.
|
||||||
- `GET /admin/api/pages` - 고정 페이지 목록
|
- `GET /admin/api/pages` - 고정 페이지 목록
|
||||||
- `POST /admin/api/pages` - 고정 페이지 작성
|
- `POST /admin/api/pages` - 고정 페이지 작성
|
||||||
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
||||||
@@ -697,7 +698,7 @@ components/content/
|
|||||||
### 사이트 설정
|
### 사이트 설정
|
||||||
|
|
||||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존은 메뉴·안내 카드만 제공한다. 게시물 Import/Export 기본 화면은 `Export 요청`·`Import 하기` 액션 버튼과 최근 작업 목록을 중심으로 표시한다. Export 상세 설정은 `Export 요청` 버튼을 눌렀을 때만 펼쳐지며, 전체·특정년·특정월·직접 지정 범위 Export 요청, 목표 ZIP 용량과 ZIP당 최대 게시물 수 지정을 제공한다. Import 상세는 `Import 하기` 버튼을 눌렀을 때 안내 패널로 펼쳐진다. 최근 작업 목록은 진행도, 준비 완료 분할 파일 다운로드, 브라우저 순차 일괄 다운로드, 실패 작업 재시도, 실패 상세 오류, 완료·실패 작업 삭제를 표시하며 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼을 비활성화한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존은 메뉴·안내 카드만 제공한다. 게시물 Import/Export 기본 화면은 `Export 요청`·`Import 하기` 액션 버튼과 최근 작업 목록을 중심으로 표시한다. Export 상세 설정은 `Export 요청` 버튼을 눌렀을 때만 펼쳐지며, 전체·특정년·특정월·직접 지정 범위 Export 요청, 목표 ZIP 용량과 ZIP당 최대 게시물 수 지정을 제공한다. Import 상세는 `Import 하기` 버튼을 눌렀을 때 안내 패널로 펼쳐진다. 최근 작업 목록은 진행도, 준비 완료 분할 파일 다운로드, 브라우저 순차 일괄 다운로드, 실패 작업 재시도, 실패 상세 오류, 완료·실패 작업 삭제를 표시하며 대기 중·생성 중 작업이 있으면 새 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로 다시 매핑한다.
|
- 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 ZIP을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 `/uploads/posts/YYYY/MM/`에 새 파일로 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 `slug-2`, `slug-3`처럼 새 슬러그로 가져온다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다.
|
||||||
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
|
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
|
||||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
## 1차 관리자 개발
|
## 1차 관리자 개발
|
||||||
|
|
||||||
- [ ] 게시물 Import 1차 구현: Export ZIP의 frontmatter를 게시물 메타데이터로 복원하고, 로컬 `images/`·`files/` 자산을 미디어 저장소로 가져온 뒤 본문 경로를 새 `/uploads/...` URL로 재매핑
|
- [ ] 게시물 Import 후속 검증: Export ZIP 샘플을 실제 Obsidian vault에서 열어 경로·frontmatter 호환성 확인
|
||||||
- [ ] 게시물 Import 1차 구현 전에 Export ZIP 샘플을 실제 Obsidian vault에서 열어 경로·frontmatter 호환성 확인
|
|
||||||
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
|
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
|
||||||
|
|
||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.5.27
|
||||||
|
|
||||||
|
- 게시물 Import: Export ZIP을 업로드해 Markdown frontmatter 기반 게시물로 복원하는 관리자 API 추가.
|
||||||
|
- 게시물 Import: ZIP 내부 `images/`·`files/` 자산을 `/uploads/posts/YYYY/MM/`로 저장하고 본문 경로를 새 URL로 재매핑하도록 추가.
|
||||||
|
- 게시물 Import: 기존 슬러그와 충돌하면 덮어쓰지 않고 `-2`, `-3` 식 새 슬러그로 가져오도록 정리.
|
||||||
|
- 관리자 사이트 설정: Import 패널에서 ZIP 선택, Import 진행 상태, 완료 요약을 표시하도록 수정.
|
||||||
|
|
||||||
## v1.5.26
|
## v1.5.26
|
||||||
|
|
||||||
- 관리자 사이트 설정: 게시물 Import/Export 섹션 기본 화면을 요청 버튼과 최근 작업 중심으로 축소.
|
- 관리자 사이트 설정: 게시물 Import/Export 섹션 기본 화면을 요청 버튼과 최근 작업 중심으로 축소.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.26",
|
"version": "1.5.27",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.26",
|
"version": "1.5.27",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.26",
|
"version": "1.5.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const requestingPostExport = ref(false)
|
|||||||
const deletingPostExportJobIds = ref([])
|
const deletingPostExportJobIds = ref([])
|
||||||
const downloadingPostExportJobIds = ref([])
|
const downloadingPostExportJobIds = ref([])
|
||||||
const retryingPostExportJobIds = ref([])
|
const retryingPostExportJobIds = ref([])
|
||||||
|
const importingPosts = ref(false)
|
||||||
|
const postImportFileName = ref('')
|
||||||
|
const postImportResult = ref(null)
|
||||||
const postExportDateRangeMode = ref('all')
|
const postExportDateRangeMode = ref('all')
|
||||||
const postExportYear = ref(new Date().getFullYear())
|
const postExportYear = ref(new Date().getFullYear())
|
||||||
const postExportMonth = ref(new Date().getMonth() + 1)
|
const postExportMonth = ref(new Date().getMonth() + 1)
|
||||||
@@ -37,6 +40,7 @@ const toast = ref(null)
|
|||||||
const logoInputRef = ref(null)
|
const logoInputRef = ref(null)
|
||||||
const homeCoverInputRef = ref(null)
|
const homeCoverInputRef = ref(null)
|
||||||
const homeCoverDarkInputRef = ref(null)
|
const homeCoverDarkInputRef = ref(null)
|
||||||
|
const postImportInputRef = ref(null)
|
||||||
const mainScrollRef = ref(null)
|
const mainScrollRef = ref(null)
|
||||||
const navSearchQuery = ref('')
|
const navSearchQuery = ref('')
|
||||||
const activeSectionId = ref('admin-settings-section-title')
|
const activeSectionId = ref('admin-settings-section-title')
|
||||||
@@ -606,6 +610,58 @@ const requestPostExport = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 Import 파일 선택창을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openPostImportFilePicker = () => {
|
||||||
|
postImportInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 Import 파일을 업로드한다.
|
||||||
|
* @param {Event} event - 파일 선택 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const importPostsFromFile = async (event) => {
|
||||||
|
const target = event.target
|
||||||
|
const file = target instanceof HTMLInputElement ? target.files?.[0] : null
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
showToast('error', 'ZIP 파일만 Import할 수 있습니다.')
|
||||||
|
target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
importingPosts.value = true
|
||||||
|
postImportFileName.value = file.name
|
||||||
|
postImportResult.value = null
|
||||||
|
showToast('info', '게시물 Import를 시작합니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const result = await $fetch('/admin/api/posts/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
postImportResult.value = result
|
||||||
|
showToast('success', `게시물 ${result.importedCount}개를 Import했습니다.`)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.data?.message || '게시물 Import를 완료하지 못했습니다.'
|
||||||
|
showToast('error', message)
|
||||||
|
} finally {
|
||||||
|
importingPosts.value = false
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export 파일 다운로드 URL을 만든다.
|
* Export 파일 다운로드 URL을 만든다.
|
||||||
* @param {Object} file - Export 파일
|
* @param {Object} file - Export 파일
|
||||||
@@ -2235,11 +2291,41 @@ onBeforeUnmount(() => {
|
|||||||
백업 ZIP 가져오기
|
백업 ZIP 가져오기
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||||
Export ZIP 구조를 다시 게시물로 가져오는 기능은 다음 단계에서 연결합니다.
|
Export로 만든 Obsidian 호환 ZIP을 게시물과 미디어 파일로 다시 가져옵니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md border border-[#e6e8eb] bg-white px-4 py-5 text-sm text-[#657080]">
|
<div class="grid gap-3 rounded-md border border-[#e6e8eb] bg-white p-4">
|
||||||
Import 구현 전까지는 이 영역만 접힌 상태로 보관합니다.
|
<input
|
||||||
|
ref="postImportInputRef"
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
@change="importPostsFromFile"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-[#15171a]">
|
||||||
|
{{ postImportFileName || 'ZIP 파일을 선택해 주세요.' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs leading-relaxed text-[#657080]">
|
||||||
|
같은 슬러그가 있으면 덮어쓰지 않고 새 슬러그로 Import합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
|
||||||
|
type="button"
|
||||||
|
:disabled="importingPosts"
|
||||||
|
@click="openPostImportFilePicker"
|
||||||
|
>
|
||||||
|
{{ importingPosts ? 'Import 중...' : 'ZIP 선택' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="postImportResult"
|
||||||
|
class="rounded-md bg-[#f4fbf7] px-3 py-2 text-sm text-[#147a45] ring-1 ring-inset ring-[#b9e7cd]"
|
||||||
|
>
|
||||||
|
게시물 {{ postImportResult.importedCount }}개, 자산 {{ postImportResult.assetCount }}개를 가져왔습니다.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
534
server/repositories/post-import-repository.js
Normal file
534
server/repositories/post-import-repository.js
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||||
|
import { basename, extname, join } from 'node:path'
|
||||||
|
import { readZipBufferEntries } from '../utils/zip-reader'
|
||||||
|
import { upsertMediaMetadataCategory } from '../utils/media-library'
|
||||||
|
import { getPostgresClient } from './postgres-client'
|
||||||
|
import { createAdminPost } from './content-repository'
|
||||||
|
|
||||||
|
const UPLOAD_BASE_URL = '/uploads'
|
||||||
|
const MAX_IMPORT_POSTS = 1000
|
||||||
|
const MARKDOWN_EXTENSION_PATTERN = /\.md$/i
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일명에 안전한 문자열로 정리한다.
|
||||||
|
* @param {string} value - 원본 문자열
|
||||||
|
* @returns {string} 정리된 문자열
|
||||||
|
*/
|
||||||
|
const sanitizeFilenameSegment = (value) => String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 80) || 'asset'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬러그에 안전한 문자열로 정리한다.
|
||||||
|
* @param {string} value - 원본 슬러그
|
||||||
|
* @returns {string} 정리된 슬러그
|
||||||
|
*/
|
||||||
|
const sanitizeSlug = (value) => String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFC')
|
||||||
|
.replace(/[^a-z0-9가-힣]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|| 'imported-post'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YAML 문자열 따옴표를 해제한다.
|
||||||
|
* @param {string} value - YAML 값
|
||||||
|
* @returns {string} 문자열 값
|
||||||
|
*/
|
||||||
|
const unquoteYamlString = (value) => {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed
|
||||||
|
.slice(1, -1)
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
|
.replace(/\\\\/g, '\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YAML 배열 값을 파싱한다.
|
||||||
|
* @param {string} value - YAML 배열 문자열
|
||||||
|
* @returns {Array<string>} 문자열 배열
|
||||||
|
*/
|
||||||
|
const parseYamlArray = (value) => {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
|
||||||
|
if (!trimmed || trimmed === '[]') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = trimmed.slice(1, -1).trim()
|
||||||
|
if (!inner) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = []
|
||||||
|
let current = ''
|
||||||
|
let inQuote = false
|
||||||
|
let escaped = false
|
||||||
|
|
||||||
|
for (const char of inner) {
|
||||||
|
if (escaped) {
|
||||||
|
current += char
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
current += char
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
current += char
|
||||||
|
inQuote = !inQuote
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ',' && !inQuote) {
|
||||||
|
values.push(unquoteYamlString(current))
|
||||||
|
current = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.trim()) {
|
||||||
|
values.push(unquoteYamlString(current))
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.map((item) => item.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YAML 단일 값을 파싱한다.
|
||||||
|
* @param {string} value - YAML 값
|
||||||
|
* @returns {unknown} 파싱된 값
|
||||||
|
*/
|
||||||
|
const parseYamlValue = (value) => {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
|
||||||
|
if (trimmed === 'null') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[')) {
|
||||||
|
return parseYamlArray(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return unquoteYamlString(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown frontmatter와 본문을 분리한다.
|
||||||
|
* @param {string} markdown - Markdown 문서
|
||||||
|
* @returns {{ frontmatter: Object, content: string }} 분리 결과
|
||||||
|
*/
|
||||||
|
const parseMarkdownDocument = (markdown) => {
|
||||||
|
const normalized = String(markdown || '').replace(/^\uFEFF/, '')
|
||||||
|
|
||||||
|
if (!normalized.startsWith('---\n')) {
|
||||||
|
return {
|
||||||
|
frontmatter: {},
|
||||||
|
content: normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endIndex = normalized.indexOf('\n---', 4)
|
||||||
|
|
||||||
|
if (endIndex < 0) {
|
||||||
|
return {
|
||||||
|
frontmatter: {},
|
||||||
|
content: normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatterText = normalized.slice(4, endIndex)
|
||||||
|
const content = normalized.slice(endIndex + 4).replace(/^\n/, '')
|
||||||
|
const frontmatter = {}
|
||||||
|
|
||||||
|
for (const line of frontmatterText.split('\n')) {
|
||||||
|
const separatorIndex = line.indexOf(':')
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = line.slice(0, separatorIndex).trim()
|
||||||
|
const value = line.slice(separatorIndex + 1)
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
frontmatter[key] = parseYamlValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
frontmatter,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP 엔트리를 경로 기준 Map으로 만든다.
|
||||||
|
* @param {Array<{ path: string, data: Buffer }>} entries - ZIP 엔트리
|
||||||
|
* @returns {Map<string, Buffer>} 엔트리 맵
|
||||||
|
*/
|
||||||
|
const createZipEntryMap = (entries) => new Map(entries.map((entry) => [entry.path, entry.data]))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 파일의 상위 폴더를 조회한다.
|
||||||
|
* @param {string} path - ZIP 내부 경로
|
||||||
|
* @returns {string} 상위 폴더
|
||||||
|
*/
|
||||||
|
const getPostFolder = (path) => {
|
||||||
|
const parts = String(path || '').split('/').filter(Boolean)
|
||||||
|
parts.pop()
|
||||||
|
return parts.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자산 경로를 Markdown 파일 기준 ZIP 엔트리 경로로 해석한다.
|
||||||
|
* @param {string} postFolder - 게시물 폴더
|
||||||
|
* @param {string} assetPath - Markdown 안의 자산 경로
|
||||||
|
* @returns {string} ZIP 엔트리 경로
|
||||||
|
*/
|
||||||
|
const resolveAssetEntryPath = (postFolder, assetPath) => {
|
||||||
|
const cleaned = String(assetPath || '')
|
||||||
|
.split(/[?#]/)[0]
|
||||||
|
.replace(/^\.\/+/, '')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
|
||||||
|
if (!cleaned) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = postFolder ? `${postFolder}/${cleaned}` : cleaned
|
||||||
|
|
||||||
|
return base
|
||||||
|
.split('/')
|
||||||
|
.filter((part) => part && part !== '.' && part !== '..')
|
||||||
|
.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장할 고유 파일명을 고른다.
|
||||||
|
* @param {string} directoryPath - 저장 디렉터리
|
||||||
|
* @param {string} originalName - 원본 파일명
|
||||||
|
* @returns {Promise<{ fileName: string, filePath: string }>} 저장 파일명과 경로
|
||||||
|
*/
|
||||||
|
const pickUniqueDiskFileName = async (directoryPath, originalName) => {
|
||||||
|
const extension = extname(originalName || '') || '.bin'
|
||||||
|
const stem = sanitizeFilenameSegment(String(originalName || '').replace(/\.[^.]+$/g, '')) || 'asset'
|
||||||
|
let suffix = 1
|
||||||
|
|
||||||
|
while (suffix < 10000) {
|
||||||
|
const fileName = suffix === 1 ? `${stem}${extension}` : `${stem}-${suffix}${extension}`
|
||||||
|
const filePath = join(directoryPath, fileName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(filePath)
|
||||||
|
suffix += 1
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('IMPORT_ASSET_FILENAME_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import 자산을 업로드 폴더에 저장한다.
|
||||||
|
* @param {Object} input - 저장 입력
|
||||||
|
* @param {Map<string, Buffer>} input.entryMap - ZIP 엔트리 맵
|
||||||
|
* @param {string} input.postFolder - 게시물 폴더
|
||||||
|
* @param {Set<string>} input.assetPaths - 자산 경로 목록
|
||||||
|
* @returns {Promise<Map<string, string>>} 원본 경로별 새 URL
|
||||||
|
*/
|
||||||
|
const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||||
|
const now = new Date()
|
||||||
|
const year = String(now.getFullYear())
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const directoryPath = join(process.cwd(), 'public', 'uploads', 'posts', year, month)
|
||||||
|
const replacements = new Map()
|
||||||
|
|
||||||
|
await mkdir(directoryPath, { recursive: true })
|
||||||
|
|
||||||
|
for (const assetPath of assetPaths) {
|
||||||
|
const entryPath = resolveAssetEntryPath(postFolder, assetPath)
|
||||||
|
const data = entryMap.get(entryPath)
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, basename(entryPath))
|
||||||
|
await writeFile(filePath, data)
|
||||||
|
|
||||||
|
const publicUrl = `${UPLOAD_BASE_URL}/posts/${year}/${month}/${fileName}`
|
||||||
|
await upsertMediaMetadataCategory(publicUrl, '미분류')
|
||||||
|
|
||||||
|
replacements.set(assetPath, publicUrl)
|
||||||
|
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
|
||||||
|
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacements
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 안의 로컬 자산 경로를 새 업로드 URL로 교체한다.
|
||||||
|
* @param {string} content - 원본 본문
|
||||||
|
* @param {Map<string, string>} replacements - 경로 교체 맵
|
||||||
|
* @returns {string} 교체된 본문
|
||||||
|
*/
|
||||||
|
const replaceAssetPaths = (content, replacements) => {
|
||||||
|
let next = String(content || '')
|
||||||
|
const entries = [...replacements.entries()]
|
||||||
|
.sort((a, b) => b[0].length - a[0].length)
|
||||||
|
|
||||||
|
for (const [source, target] of entries) {
|
||||||
|
next = next.split(source).join(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import 대상 자산 경로를 수집한다.
|
||||||
|
* @param {Object} input - 수집 입력
|
||||||
|
* @param {Object} input.frontmatter - frontmatter
|
||||||
|
* @param {string} input.content - 본문
|
||||||
|
* @returns {Set<string>} 자산 경로 목록
|
||||||
|
*/
|
||||||
|
const collectImportAssetPaths = ({ frontmatter, content }) => {
|
||||||
|
const paths = new Set()
|
||||||
|
const localAssetPattern = /(?:\.\/)?(?:images|files)\/[^\s"'<>)]*/g
|
||||||
|
|
||||||
|
for (const match of String(content || '').match(localAssetPattern) || []) {
|
||||||
|
paths.add(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ['featured_image', 'og_image']) {
|
||||||
|
const value = frontmatter[key]
|
||||||
|
if (typeof value === 'string' && /^(?:\.\/)?(?:images|files)\//.test(value)) {
|
||||||
|
paths.add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 슬러그 중복을 피한다.
|
||||||
|
* @param {string} baseSlug - 기준 슬러그
|
||||||
|
* @param {Set<string>} reservedSlugs - 이번 Import에서 예약된 슬러그
|
||||||
|
* @returns {Promise<string>} 고유 슬러그
|
||||||
|
*/
|
||||||
|
const createUniquePostSlug = async (baseSlug, reservedSlugs) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
const base = sanitizeSlug(baseSlug)
|
||||||
|
let next = base
|
||||||
|
let suffix = 2
|
||||||
|
|
||||||
|
while (reservedSlugs.has(next)) {
|
||||||
|
next = `${base}-${suffix}`
|
||||||
|
suffix += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
reservedSlugs.add(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
while (suffix < 10000) {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT 1
|
||||||
|
FROM posts
|
||||||
|
WHERE slug = ${next}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (!rows.length && !reservedSlugs.has(next)) {
|
||||||
|
reservedSlugs.add(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
next = `${base}-${suffix}`
|
||||||
|
suffix += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('IMPORT_SLUG_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 상태를 Import 가능한 값으로 정리한다.
|
||||||
|
* @param {unknown} value - 상태 값
|
||||||
|
* @returns {'published'|'draft'|'members'|'private'} 게시물 상태
|
||||||
|
*/
|
||||||
|
const normalizePostStatus = (value) => {
|
||||||
|
const status = String(value || '').trim()
|
||||||
|
|
||||||
|
if (['published', 'draft', 'members', 'private'].includes(status)) {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'draft'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* frontmatter 이미지 값을 Import 후 URL로 정리한다.
|
||||||
|
* @param {unknown} value - frontmatter 이미지 값
|
||||||
|
* @param {Map<string, string>} replacements - 자산 교체 맵
|
||||||
|
* @returns {string|null} 저장할 이미지 URL
|
||||||
|
*/
|
||||||
|
const resolveImportedImageUrl = (value, replacements) => {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
const replaced = replacements.get(trimmed)
|
||||||
|
|
||||||
|
if (replaced) {
|
||||||
|
return replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(?:\.\/)?(?:images|files)\//.test(trimmed)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 날짜 문자열을 정리한다.
|
||||||
|
* @param {unknown} value - 날짜 값
|
||||||
|
* @returns {string|null} ISO 문자열
|
||||||
|
*/
|
||||||
|
const normalizeIsoDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(String(value))
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP 엔트리에서 Markdown 게시물 목록을 만든다.
|
||||||
|
* @param {Array<{ path: string, data: Buffer }>} entries - ZIP 엔트리
|
||||||
|
* @returns {Array<Object>} Markdown 문서 목록
|
||||||
|
*/
|
||||||
|
const collectMarkdownPosts = (entries) => entries
|
||||||
|
.filter((entry) => MARKDOWN_EXTENSION_PATTERN.test(entry.path))
|
||||||
|
.map((entry) => ({
|
||||||
|
path: entry.path,
|
||||||
|
postFolder: getPostFolder(entry.path),
|
||||||
|
...parseMarkdownDocument(entry.data.toString('utf8'))
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export ZIP을 게시물로 가져온다.
|
||||||
|
* @param {{ zipBuffer: Buffer, authorId: string }} input - Import 입력
|
||||||
|
* @returns {Promise<{ importedCount: number, assetCount: number, posts: Array<Object> }>} Import 결과
|
||||||
|
*/
|
||||||
|
export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
|
||||||
|
const entries = readZipBufferEntries(zipBuffer)
|
||||||
|
const entryMap = createZipEntryMap(entries)
|
||||||
|
const markdownPosts = collectMarkdownPosts(entries)
|
||||||
|
|
||||||
|
if (!markdownPosts.length) {
|
||||||
|
throw new Error('IMPORT_MARKDOWN_NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markdownPosts.length > MAX_IMPORT_POSTS) {
|
||||||
|
throw new Error('IMPORT_POST_LIMIT_EXCEEDED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservedSlugs = new Set()
|
||||||
|
const importedPosts = []
|
||||||
|
let importedAssetCount = 0
|
||||||
|
|
||||||
|
for (const markdownPost of markdownPosts) {
|
||||||
|
const { frontmatter, content, postFolder } = markdownPost
|
||||||
|
const assetPaths = collectImportAssetPaths({ frontmatter, content })
|
||||||
|
const replacements = await saveImportAssets({
|
||||||
|
entryMap,
|
||||||
|
postFolder,
|
||||||
|
assetPaths
|
||||||
|
})
|
||||||
|
importedAssetCount += replacements.size ? new Set(replacements.values()).size : 0
|
||||||
|
|
||||||
|
const title = String(frontmatter.title || basename(markdownPost.path).replace(MARKDOWN_EXTENSION_PATTERN, '') || 'Imported Post').trim()
|
||||||
|
const slug = await createUniquePostSlug(frontmatter.slug || title, reservedSlugs)
|
||||||
|
const featuredImage = resolveImportedImageUrl(frontmatter.featured_image, replacements)
|
||||||
|
const ogImage = resolveImportedImageUrl(frontmatter.og_image, replacements)
|
||||||
|
const status = normalizePostStatus(frontmatter.status)
|
||||||
|
const publishedAt = status === 'published' || status === 'members'
|
||||||
|
? normalizeIsoDate(frontmatter.published_at) || new Date().toISOString()
|
||||||
|
: normalizeIsoDate(frontmatter.published_at)
|
||||||
|
|
||||||
|
const post = await createAdminPost({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content: replaceAssetPaths(content, replacements),
|
||||||
|
excerpt: String(frontmatter.excerpt || ''),
|
||||||
|
featuredImage,
|
||||||
|
isFeatured: false,
|
||||||
|
seoTitle: String(frontmatter.seo_title || ''),
|
||||||
|
seoDescription: String(frontmatter.seo_description || ''),
|
||||||
|
canonicalUrl: String(frontmatter.canonical_url || ''),
|
||||||
|
noindex: Boolean(frontmatter.noindex),
|
||||||
|
ogImage,
|
||||||
|
status,
|
||||||
|
publishedAt,
|
||||||
|
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : []
|
||||||
|
}, authorId)
|
||||||
|
|
||||||
|
importedPosts.push(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
importedCount: importedPosts.length,
|
||||||
|
assetCount: importedAssetCount,
|
||||||
|
posts: importedPosts.map((post) => ({
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
77
server/routes/admin/api/posts/import.post.js
Normal file
77
server/routes/admin/api/posts/import.post.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createError, readMultipartFormData } from 'h3'
|
||||||
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
|
import { importPostsFromExportZip } from '../../../../repositories/post-import-repository'
|
||||||
|
|
||||||
|
const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 게시물 Import API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ importedCount: number, assetCount: number, posts: Array<Object> }>} Import 결과
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const adminSession = requireAdminSession(event)
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Import할 ZIP 파일을 선택해 주세요.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String(file.filename || '').toLowerCase().endsWith('.zip')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'ZIP 파일만 Import할 수 있습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.data?.length) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.data.length > MAX_IMPORT_ZIP_BYTES) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 413,
|
||||||
|
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await importPostsFromExportZip({
|
||||||
|
zipBuffer: file.data,
|
||||||
|
authorId: adminSession.userId
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.message === 'IMPORT_MARKDOWN_NOT_FOUND') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'ZIP 안에서 Import할 Markdown 게시물을 찾지 못했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error?.message === 'IMPORT_POST_LIMIT_EXCEEDED') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '한 번에 Import할 수 있는 게시물 수를 초과했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error?.code === '23505') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: '게시물 Import 중 중복 데이터가 감지되었습니다. 다시 시도해 주세요.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: '게시물 Import를 완료하지 못했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
123
server/utils/zip-reader.js
Normal file
123
server/utils/zip-reader.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { inflateRawSync } from 'node:zlib'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP 엔트리 경로를 안전하게 정리한다.
|
||||||
|
* @param {string} value - ZIP 내부 경로
|
||||||
|
* @returns {string} 정리된 경로
|
||||||
|
*/
|
||||||
|
const normalizeZipEntryPath = (value) => String(value || '')
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\/+/g, '')
|
||||||
|
.split('/')
|
||||||
|
.filter((part) => part && part !== '.' && part !== '..')
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP 종료 레코드 위치를 찾는다.
|
||||||
|
* @param {Buffer} buffer - ZIP 파일 버퍼
|
||||||
|
* @returns {number} 종료 레코드 위치
|
||||||
|
*/
|
||||||
|
const findEndOfCentralDirectoryOffset = (buffer) => {
|
||||||
|
const signature = 0x06054b50
|
||||||
|
const minOffset = Math.max(0, buffer.length - 65557)
|
||||||
|
|
||||||
|
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
|
||||||
|
if (buffer.readUInt32LE(offset) === signature) {
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP 엔트리 데이터를 압축 해제한다.
|
||||||
|
* @param {Buffer} buffer - 전체 ZIP 버퍼
|
||||||
|
* @param {Object} entry - 중앙 디렉터리 엔트리
|
||||||
|
* @returns {Buffer} 압축 해제된 데이터
|
||||||
|
*/
|
||||||
|
const readZipEntryData = (buffer, entry) => {
|
||||||
|
const localHeaderOffset = entry.localHeaderOffset
|
||||||
|
|
||||||
|
if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) {
|
||||||
|
throw new Error('INVALID_ZIP_LOCAL_HEADER')
|
||||||
|
}
|
||||||
|
|
||||||
|
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26)
|
||||||
|
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28)
|
||||||
|
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength
|
||||||
|
const compressedData = buffer.subarray(dataOffset, dataOffset + entry.compressedSize)
|
||||||
|
|
||||||
|
if (entry.compressionMethod === 0) {
|
||||||
|
return Buffer.from(compressedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.compressionMethod === 8) {
|
||||||
|
return inflateRawSync(compressedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('UNSUPPORTED_ZIP_COMPRESSION')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP 파일 버퍼를 파일 엔트리 목록으로 읽는다.
|
||||||
|
* @param {Buffer} buffer - ZIP 파일 버퍼
|
||||||
|
* @returns {Array<{ path: string, data: Buffer }>} 파일 엔트리 목록
|
||||||
|
*/
|
||||||
|
export const readZipBufferEntries = (buffer) => {
|
||||||
|
if (!Buffer.isBuffer(buffer) || buffer.length < 22) {
|
||||||
|
throw new Error('INVALID_ZIP_FILE')
|
||||||
|
}
|
||||||
|
|
||||||
|
const endOffset = findEndOfCentralDirectoryOffset(buffer)
|
||||||
|
|
||||||
|
if (endOffset < 0) {
|
||||||
|
throw new Error('INVALID_ZIP_FILE')
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryCount = buffer.readUInt16LE(endOffset + 10)
|
||||||
|
const centralDirectorySize = buffer.readUInt32LE(endOffset + 12)
|
||||||
|
const centralDirectoryOffset = buffer.readUInt32LE(endOffset + 16)
|
||||||
|
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize
|
||||||
|
|
||||||
|
if (centralDirectoryOffset < 0 || centralDirectoryEnd > buffer.length) {
|
||||||
|
throw new Error('INVALID_ZIP_FILE')
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
let offset = centralDirectoryOffset
|
||||||
|
|
||||||
|
for (let index = 0; index < entryCount; index += 1) {
|
||||||
|
if (buffer.readUInt32LE(offset) !== 0x02014b50) {
|
||||||
|
throw new Error('INVALID_ZIP_CENTRAL_DIRECTORY')
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressionMethod = buffer.readUInt16LE(offset + 10)
|
||||||
|
const compressedSize = buffer.readUInt32LE(offset + 20)
|
||||||
|
const uncompressedSize = buffer.readUInt32LE(offset + 24)
|
||||||
|
const nameLength = buffer.readUInt16LE(offset + 28)
|
||||||
|
const extraLength = buffer.readUInt16LE(offset + 30)
|
||||||
|
const commentLength = buffer.readUInt16LE(offset + 32)
|
||||||
|
const localHeaderOffset = buffer.readUInt32LE(offset + 42)
|
||||||
|
const rawPath = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
||||||
|
const normalizedPath = normalizeZipEntryPath(rawPath)
|
||||||
|
const isDirectory = rawPath.replace(/\\/g, '/').endsWith('/')
|
||||||
|
|
||||||
|
if (normalizedPath && !isDirectory && !normalizedPath.startsWith('__MACOSX/')) {
|
||||||
|
entries.push({
|
||||||
|
path: normalizedPath,
|
||||||
|
compressionMethod,
|
||||||
|
compressedSize,
|
||||||
|
uncompressedSize,
|
||||||
|
localHeaderOffset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += 46 + nameLength + extraLength + commentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
path: entry.path,
|
||||||
|
data: readZipEntryData(buffer, entry)
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user