게시물 가져오기 중복 복원 방지
This commit is contained in:
@@ -101,6 +101,7 @@
|
||||
| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 10건 |
|
||||
| tests/playwright/e2e/admin-guard-and-settings.spec.js | 설정 카드 독립 저장과 미저장 이탈 취소 브라우저 E2E 2건 |
|
||||
| tests/playwright/e2e/admin-publish.spec.js | 관리자 로그인·게시물 발행·공개 화면 확인 브라우저 E2E 1건 |
|
||||
| tests/playwright/e2e/admin-regressions.spec.js | 태그 추천 연속 선택·미디어 파생 탭 배지 숨김·단일 이미지 `{size=}` 너비 브라우저 회귀 테스트 |
|
||||
|
||||
## DB 마이그레이션
|
||||
|
||||
|
||||
@@ -684,7 +684,7 @@ components/content/
|
||||
- `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제
|
||||
- `POST /admin/api/posts/export-jobs/:jobId/retry` - 실패한 게시물 Export 작업을 준비 완료 파일은 유지한 채 나머지 분할 파일부터 재시도
|
||||
- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드
|
||||
- `POST /admin/api/posts/import` - Export ZIP 파일 Import. multipart `file` 필드의 ZIP을 최대 300MB까지 임시 파일로 스트리밍하고 Markdown frontmatter를 게시물로 생성하며, ZIP 내부 `images/`·`files/` 자산을 엔트리별 스트림으로 새 업로드 URL에 저장한다. 엔트리는 최대 5,000개, 개별 300MB, 전체 비압축 2GB, 압축률 250배, Markdown 개별 5MB·합계 50MB로 제한한다. 응답은 `importedCount`, `assetCount`, `warningCount`, `warnings`, `posts`를 반환한다.
|
||||
- `POST /admin/api/posts/import` - Export ZIP 파일 Import. multipart `file` 필드의 ZIP을 최대 300MB까지 임시 파일로 스트리밍하고 Markdown frontmatter를 게시물로 생성하며, ZIP 내부 `images/`·`files/` 자산을 엔트리별 스트림으로 업로드 URL에 저장한다. 같은 `slug`의 기존 게시물 또는 ZIP 내부 중복 게시물은 새 slug로 복제하지 않고 건너뛴다. 건너뛴 게시물의 자산은 저장하지 않으며, 같은 ZIP 자산 또는 같은 해시의 기존 저장 파일은 재사용한다. 엔트리는 최대 5,000개, 개별 300MB, 전체 비압축 2GB, 압축률 250배, Markdown 개별 5MB·합계 50MB로 제한한다. 응답은 `importedCount`, `skippedCount`, `assetCount`, `reusedAssetCount`, `warningCount`, `warnings`, `posts`, `skippedPosts`를 반환한다.
|
||||
- `GET /admin/api/pages` - 고정 페이지 목록
|
||||
- `POST /admin/api/pages` - 고정 페이지 작성
|
||||
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
||||
@@ -882,7 +882,7 @@ components/content/
|
||||
### 사이트 설정
|
||||
|
||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 콘텐츠·안전 영역의 **사이트 용량**은 그래프 없이 텍스트 카드로 표시하고, 기준 시각·콘텐츠 수량·파일 용량을 새로고침할 수 있다. 게시물 백업 도구는 `게시물 내보내기`와 `게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **사이트 코드** 카드는 ads.txt, 공통 헤더 코드, 공통 푸터 코드의 등록 여부를 라벨/값 세로 행으로 보여 주고, `편집`을 눌렀을 때만 textarea와 저장/취소가 나타난다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 라벨/값 세로 행으로 읽기 전용 표시하고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리하며, 읽기 화면의 로고는 등록 상태 문구 없이 이미지 또는 점선 미등록 박스로 표시한다. **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/posts/YYYY/MM/`에 새 파일로 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 기존 ZIP처럼 `show_featured_image`가 없으면 `false`로 가져온다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 `slug-2`, `slug-3`처럼 새 슬러그로 가져온다. ZIP 안에서 자산을 찾지 못한 경우 Import는 계속 진행하되 응답 경고로 누락 경로를 알려 준다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다.
|
||||
- 게시물 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로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 기존 ZIP처럼 `show_featured_image`가 없으면 `false`로 가져온다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰거나 `slug-2`처럼 복제하지 않고 건너뛰며, 해당 게시물의 자산도 다시 저장하지 않는다. 같은 Import 실행 안에서 여러 게시물이 같은 ZIP 자산을 참조하면 한 번 저장한 URL을 재사용하고, 재시도 중 같은 파일명 계열과 같은 해시의 자산이 이미 있으면 기존 파일 URL을 재사용한다. ZIP 안에서 자산을 찾지 못한 경우 Import는 계속 진행하되 응답 경고로 누락 경로를 알려 준다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다.
|
||||
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, worker가 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. worker는 서버 시작과 30초 주기로 `queued` 및 재시작으로 중단된 `processing` 작업을 찾고, 작업 ID별 PostgreSQL advisory lock을 전용 연결에 유지해 여러 앱 인스턴스가 같은 작업을 동시에 생성하지 못하게 한다. 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` 테이블의 단일 레코드로 관리한다.
|
||||
- 설정 카드 저장은 카드가 담당하는 필드만 `PATCH /admin/api/settings`로 전달하며, 서버는 전달된 컬럼만 단일 `UPDATE`로 수정한다. 저장 성공 응답도 해당 필드만 현재 폼에 반영한다.
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
## v1.11 내부 개선
|
||||
|
||||
- 게시물 Import 장기 작업화 검토.
|
||||
- 현재 Import는 단일 요청 안에서 진행 중 안내와 중복 건너뛰기를 제공함.
|
||||
- 수백~수천 개 ZIP의 정확한 퍼센트 진행률·일시 중단 복구·작업 이력 관리는 Export처럼 job 테이블 기반 비동기 처리로 전환 검토.
|
||||
- 거대 컴포넌트 책임 분리 진행.
|
||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||
- `pages/admin/settings/index.vue`: 설정 카드별 저장·상태·검증 로직을 카드 컴포넌트 또는 composable 단위로 정리.
|
||||
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
||||
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||
- 최근 브라우저 회귀 테스트 추가.
|
||||
- 태그 추천 선택 후 다음 태그 후보가 포커스 해제 없이 유지되는지 확인.
|
||||
- 관리자 미디어 대표 이미지/본문 이미지 탭 전환 중 역할 배지가 노출되지 않는지 확인.
|
||||
- 단일 이미지 `{size=}` 너비가 사용자 뷰어와 라이브 모드에 동일하게 반영되는지 확인.
|
||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.17
|
||||
|
||||
- 게시물 Import에서 이미 같은 slug가 있는 게시물은 새 slug로 복제하지 않고 건너뛰도록 수정.
|
||||
- Import 재시도 시 건너뛴 게시물의 자산은 다시 저장하지 않고, 같은 ZIP 자산 또는 같은 해시의 기존 저장 파일은 재사용하도록 보완.
|
||||
- 관리자 게시물 가져오기 UI에 진행 중 안내와 가져옴·건너뜀·신규 자산·재사용 자산 결과 요약 추가.
|
||||
|
||||
## v1.11.16
|
||||
|
||||
- 태그 추천 연속 선택, 관리자 미디어 파생 탭 역할 배지, 단일 이미지 `{size=}` 표시 너비 회귀를 확인하는 Playwright E2E 테스트 추가.
|
||||
- 단일 이미지 너비 테스트는 공개 게시물 화면과 관리자 글쓰기 미리보기 화면을 함께 검증하도록 구성.
|
||||
|
||||
## v1.11.15
|
||||
|
||||
- 관리자 설정 화면의 업로드 제한·SNS 아이콘·공개 표시개수·게시물 내보내기 범위 select를 공용 `AdminSelectInput`으로 정리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.15",
|
||||
"version": "1.11.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.15",
|
||||
"version": "1.11.17",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.15",
|
||||
"version": "1.11.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -121,6 +121,29 @@ const postExportDateRangeModeOptions = [
|
||||
{ value: 'month', label: '특정월' },
|
||||
{ value: 'custom', label: '직접 지정' }
|
||||
]
|
||||
|
||||
const postImportStatusSummary = computed(() => {
|
||||
if (!postImportResult.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const result = postImportResult.value
|
||||
const segments = [
|
||||
`게시물 ${Number(result.importedCount || 0)}개 가져옴`
|
||||
]
|
||||
|
||||
if (Number(result.skippedCount || 0) > 0) {
|
||||
segments.push(`${Number(result.skippedCount || 0)}개 건너뜀`)
|
||||
}
|
||||
|
||||
segments.push(`신규 자산 ${Number(result.assetCount || 0)}개`)
|
||||
|
||||
if (Number(result.reusedAssetCount || 0) > 0) {
|
||||
segments.push(`재사용 자산 ${Number(result.reusedAssetCount || 0)}개`)
|
||||
}
|
||||
|
||||
return segments.join(', ')
|
||||
})
|
||||
/** 블로그 제목·설명 카드 편집 모드 여부 */
|
||||
const editTitleDesc = ref(false)
|
||||
/** 사이트 정보 카드 편집 모드 여부 */
|
||||
@@ -975,6 +998,10 @@ const requestPostExport = async () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const openPostImportFilePicker = () => {
|
||||
if (importingPosts.value) {
|
||||
return
|
||||
}
|
||||
|
||||
postImportInputRef.value?.click()
|
||||
}
|
||||
|
||||
@@ -984,6 +1011,10 @@ const openPostImportFilePicker = () => {
|
||||
* @returns {boolean} 선택 성공 여부
|
||||
*/
|
||||
const selectPostImportFile = (file) => {
|
||||
if (importingPosts.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return false
|
||||
}
|
||||
@@ -1028,6 +1059,10 @@ const dropPostImportFile = (event) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const importSelectedPostFile = async () => {
|
||||
if (importingPosts.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!postImportFile.value) {
|
||||
showToast('error', '가져올 ZIP 파일을 먼저 선택해 주세요.')
|
||||
return
|
||||
@@ -1049,7 +1084,7 @@ const importSelectedPostFile = async () => {
|
||||
postImportResult.value = result
|
||||
postImportFile.value = null
|
||||
postImportFileName.value = ''
|
||||
showToast('success', `게시물 ${result.importedCount}개를 가져왔습니다.`)
|
||||
showToast('success', postImportStatusSummary.value || '게시물 가져오기가 완료되었습니다.')
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || '게시물 가져오기를 완료하지 못했습니다.'
|
||||
showToast('error', message)
|
||||
@@ -3914,7 +3949,7 @@ onBeforeUnmount(() => {
|
||||
게시물 가져오기
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
내보내기로 만든 ZIP 백업을 게시물과 미디어 파일로 복원합니다.
|
||||
내보내기로 만든 ZIP 백업을 게시물과 미디어 파일로 복원합니다. 이미 같은 slug가 있는 게시물은 건너뜁니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -3922,6 +3957,7 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
:aria-expanded="postImportExportPanel === 'import'"
|
||||
aria-controls="admin-settings-import-panel"
|
||||
:disabled="importingPosts"
|
||||
@click="togglePostImportExportPanel('import')"
|
||||
>
|
||||
{{ postImportExportPanel === 'import' ? '접기' : '가져오기' }}
|
||||
@@ -3938,6 +3974,7 @@ onBeforeUnmount(() => {
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
:disabled="importingPosts"
|
||||
@change="handlePostImportFileChange"
|
||||
>
|
||||
<button
|
||||
@@ -3956,10 +3993,18 @@ onBeforeUnmount(() => {
|
||||
</svg>
|
||||
<span>{{ postImportFileName || 'ZIP 파일을 드롭하거나 선택하세요.' }}</span>
|
||||
<span class="text-xs font-medium text-[#8a94a3]">
|
||||
같은 슬러그가 있으면 덮어쓰지 않고 새 슬러그로 가져옵니다.
|
||||
같은 slug의 게시물은 덮어쓰지 않고 건너뛰며, 건너뛴 게시물의 자산도 다시 저장하지 않습니다.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="importingPosts"
|
||||
class="admin-settings-screen__import-progress rounded-md bg-[#eef5ff] px-3 py-2 text-sm text-[#1f5f99] ring-1 ring-inset ring-[#bdd8f3]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
게시물을 가져오는 중입니다. 대량 ZIP은 시간이 걸릴 수 있으니 이 화면을 닫지 마세요.
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<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]"
|
||||
@@ -3974,7 +4019,15 @@ onBeforeUnmount(() => {
|
||||
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 }}개를 가져왔습니다.
|
||||
{{ postImportStatusSummary }}
|
||||
<ul v-if="postImportResult.skippedPosts?.length" class="mt-2 grid gap-1 text-xs leading-relaxed text-[#24633f]">
|
||||
<li
|
||||
v-for="post in postImportResult.skippedPosts"
|
||||
:key="`${post.slug}-${post.reason}`"
|
||||
>
|
||||
건너뜀: {{ post.title }} ({{ post.slug }})
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="postImportResult?.warningCount"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
@@ -31,6 +32,21 @@ const sanitizeFilenameSegment = (value) => String(value || '')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 80) || 'asset'
|
||||
|
||||
/**
|
||||
* 읽기 스트림의 SHA-256 해시를 만든다.
|
||||
* @param {NodeJS.ReadableStream} stream - 읽기 스트림
|
||||
* @returns {Promise<string>} 해시 문자열
|
||||
*/
|
||||
const createStreamHash = async (stream) => {
|
||||
const hash = createHash('sha256')
|
||||
|
||||
for await (const chunk of stream) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬러그에 안전한 문자열로 정리한다.
|
||||
* @param {string} value - 원본 슬러그
|
||||
@@ -330,21 +346,65 @@ const pickUniqueDiskFileName = async (directoryPath, originalName) => {
|
||||
throw new Error('IMPORT_ASSET_FILENAME_FAILED')
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 저장된 같은 자산 파일을 찾는다.
|
||||
* @param {string} directoryPath - 저장 디렉터리
|
||||
* @param {string} originalName - 원본 파일명
|
||||
* @param {{ uncompressedSize: number, createReadStream: () => NodeJS.ReadableStream }} entry - ZIP 엔트리
|
||||
* @returns {Promise<{ fileName: string, filePath: string }|null>} 재사용 파일
|
||||
*/
|
||||
const findReusableDiskAsset = async (directoryPath, originalName, entry) => {
|
||||
const extension = extname(originalName || '') || '.bin'
|
||||
const stem = sanitizeFilenameSegment(String(originalName || '').replace(/\.[^.]+$/g, '')) || 'asset'
|
||||
const entrySize = Number(entry.uncompressedSize || 0)
|
||||
let entryHash = ''
|
||||
|
||||
for (let suffix = 1; suffix < 10000; suffix += 1) {
|
||||
const fileName = suffix === 1 ? `${stem}${extension}` : `${stem}-${suffix}${extension}`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
let fileStat = null
|
||||
|
||||
try {
|
||||
fileStat = await stat(filePath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (entrySize > 0 && Number(fileStat.size || 0) !== entrySize) {
|
||||
continue
|
||||
}
|
||||
|
||||
entryHash ||= await createStreamHash(entry.createReadStream())
|
||||
|
||||
if (await createStreamHash(createReadStream(filePath)) === entryHash) {
|
||||
return {
|
||||
fileName,
|
||||
filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Import 자산을 업로드 폴더에 저장한다.
|
||||
* @param {Object} input - 저장 입력
|
||||
* @param {Map<string, Object>} input.entryMap - ZIP 엔트리 맵
|
||||
* @param {string} input.postFolder - 게시물 폴더
|
||||
* @param {Set<string>} input.assetPaths - 자산 경로 목록
|
||||
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string> }>} 저장 결과
|
||||
* @param {Map<string, string>} input.savedAssetUrls - ZIP 엔트리별 저장 URL 캐시
|
||||
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string>, savedCount: number, reusedCount: number }>} 저장 결과
|
||||
*/
|
||||
const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
const saveImportAssets = async ({ entryMap, postFolder, assetPaths, savedAssetUrls }) => {
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const directoryPath = join(getUploadRoot(), 'posts', year, month)
|
||||
const replacements = new Map()
|
||||
const missingAssets = []
|
||||
let savedCount = 0
|
||||
let reusedCount = 0
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
@@ -357,6 +417,28 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
continue
|
||||
}
|
||||
|
||||
const cachedUrl = savedAssetUrls.get(entryPath)
|
||||
|
||||
if (cachedUrl) {
|
||||
replacements.set(assetPath, cachedUrl)
|
||||
replacements.set(assetPath.replace(/^\.\//, ''), cachedUrl)
|
||||
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, cachedUrl)
|
||||
reusedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const reusableAsset = await findReusableDiskAsset(directoryPath, basename(entryPath), entry)
|
||||
|
||||
if (reusableAsset) {
|
||||
const publicUrl = `${UPLOAD_BASE_URL}/posts/${year}/${month}/${reusableAsset.fileName}`
|
||||
savedAssetUrls.set(entryPath, publicUrl)
|
||||
replacements.set(assetPath, publicUrl)
|
||||
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
|
||||
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
|
||||
reusedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, basename(entryPath))
|
||||
try {
|
||||
await pipeline(
|
||||
@@ -377,14 +459,18 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
origin: 'import'
|
||||
})
|
||||
|
||||
savedAssetUrls.set(entryPath, publicUrl)
|
||||
replacements.set(assetPath, publicUrl)
|
||||
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
|
||||
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
|
||||
savedCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
replacements,
|
||||
missingAssets
|
||||
missingAssets,
|
||||
savedCount,
|
||||
reusedCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,45 +518,42 @@ const collectImportAssetPaths = ({ frontmatter, content }) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 슬러그 중복을 피한다.
|
||||
* @param {string} baseSlug - 기준 슬러그
|
||||
* @param {Set<string>} reservedSlugs - 이번 Import에서 예약된 슬러그
|
||||
* @returns {Promise<string>} 고유 슬러그
|
||||
* 게시물 슬러그가 이미 존재하는지 확인한다.
|
||||
* @param {string} slug - 게시물 슬러그
|
||||
* @returns {Promise<boolean>} 존재 여부
|
||||
*/
|
||||
const createUniquePostSlug = async (baseSlug, reservedSlugs) => {
|
||||
const postSlugExists = async (slug) => {
|
||||
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
|
||||
return false
|
||||
}
|
||||
|
||||
while (suffix < 10000) {
|
||||
const rows = await sql`
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE slug = ${next}
|
||||
LIMIT 1
|
||||
`
|
||||
const rows = await sql`
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE slug = ${slug}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
if (!rows.length && !reservedSlugs.has(next)) {
|
||||
reservedSlugs.add(next)
|
||||
return next
|
||||
}
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
next = `${base}-${suffix}`
|
||||
suffix += 1
|
||||
/**
|
||||
* Import 게시물 슬러그를 정리한다.
|
||||
* @param {string} baseSlug - 기준 슬러그
|
||||
* @param {Set<string>} reservedSlugs - 이번 Import에서 예약된 슬러그
|
||||
* @returns {{ slug: string, duplicateInZip: boolean }} 정리된 슬러그
|
||||
*/
|
||||
const reserveImportPostSlug = (baseSlug, reservedSlugs) => {
|
||||
const slug = sanitizeSlug(baseSlug)
|
||||
const duplicateInZip = reservedSlugs.has(slug)
|
||||
reservedSlugs.add(slug)
|
||||
|
||||
return {
|
||||
slug,
|
||||
duplicateInZip
|
||||
}
|
||||
|
||||
throw new Error('IMPORT_SLUG_FAILED')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -567,7 +650,7 @@ const collectMarkdownPosts = async (entries) => {
|
||||
/**
|
||||
* Export ZIP을 게시물로 가져온다.
|
||||
* @param {{ zipFilePath: string, authorId: string }} input - Import 입력
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
|
||||
* @returns {Promise<{ importedCount: number, skippedCount: number, assetCount: number, reusedAssetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object>, skippedPosts: Array<Object> }>} Import 결과
|
||||
*/
|
||||
export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
|
||||
const entries = await openZipFileEntries(zipFilePath, {
|
||||
@@ -584,25 +667,55 @@ export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
|
||||
}
|
||||
|
||||
const reservedSlugs = new Set()
|
||||
const savedAssetUrls = new Map()
|
||||
const importedPosts = []
|
||||
const skippedPosts = []
|
||||
const warnings = []
|
||||
let importedAssetCount = 0
|
||||
let reusedAssetCount = 0
|
||||
|
||||
for (const markdownPost of markdownPosts) {
|
||||
const { frontmatter, content, postFolder } = markdownPost
|
||||
const title = String(frontmatter.title || basename(markdownPost.path).replace(MARKDOWN_EXTENSION_PATTERN, '') || 'Imported Post').trim()
|
||||
const { slug, duplicateInZip } = reserveImportPostSlug(frontmatter.slug || title, reservedSlugs)
|
||||
|
||||
if (duplicateInZip) {
|
||||
skippedPosts.push({
|
||||
title,
|
||||
slug,
|
||||
reason: 'duplicate-in-zip'
|
||||
})
|
||||
warnings.push(`${markdownPost.path}: ZIP 안에 같은 slug가 있어 건너뛰었습니다. (${slug})`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (await postSlugExists(slug)) {
|
||||
skippedPosts.push({
|
||||
title,
|
||||
slug,
|
||||
reason: 'existing-slug'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const assetPaths = collectImportAssetPaths({ frontmatter, content })
|
||||
const { replacements, missingAssets } = await saveImportAssets({
|
||||
const {
|
||||
replacements,
|
||||
missingAssets,
|
||||
savedCount,
|
||||
reusedCount
|
||||
} = await saveImportAssets({
|
||||
entryMap,
|
||||
postFolder,
|
||||
assetPaths
|
||||
assetPaths,
|
||||
savedAssetUrls
|
||||
})
|
||||
importedAssetCount += replacements.size ? new Set(replacements.values()).size : 0
|
||||
importedAssetCount += savedCount
|
||||
reusedAssetCount += reusedCount
|
||||
missingAssets.forEach((assetPath) => {
|
||||
warnings.push(`${markdownPost.path}: ZIP 내부 자산을 찾지 못했습니다. (${assetPath})`)
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -610,36 +723,54 @@ export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
|
||||
? 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,
|
||||
showFeaturedImage: Boolean(frontmatter.show_featured_image),
|
||||
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)
|
||||
let post
|
||||
|
||||
try {
|
||||
post = await createAdminPost({
|
||||
title,
|
||||
slug,
|
||||
content: replaceAssetPaths(content, replacements),
|
||||
excerpt: String(frontmatter.excerpt || ''),
|
||||
featuredImage,
|
||||
showFeaturedImage: Boolean(frontmatter.show_featured_image),
|
||||
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)
|
||||
} catch (error) {
|
||||
if (error?.code === '23505') {
|
||||
skippedPosts.push({
|
||||
title,
|
||||
slug,
|
||||
reason: 'existing-slug'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
importedPosts.push(post)
|
||||
}
|
||||
|
||||
return {
|
||||
importedCount: importedPosts.length,
|
||||
skippedCount: skippedPosts.length,
|
||||
assetCount: importedAssetCount,
|
||||
reusedAssetCount,
|
||||
warningCount: warnings.length,
|
||||
warnings: warnings.slice(0, 20),
|
||||
posts: importedPosts.map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug
|
||||
}))
|
||||
})),
|
||||
skippedPosts: skippedPosts.slice(0, 20)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
|
||||
/**
|
||||
* 관리자 게시물 Import API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
|
||||
* @returns {Promise<{ importedCount: number, skippedCount: number, assetCount: number, reusedAssetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object>, skippedPosts: Array<Object> }>} Import 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminSession = requireAdminSession(event)
|
||||
|
||||
129
tests/playwright/e2e/admin-regressions.spec.js
Normal file
129
tests/playwright/e2e/admin-regressions.spec.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const adminEmail = 'playwright-admin@example.com'
|
||||
const adminPassword = 'playwright-admin-password'
|
||||
|
||||
/**
|
||||
* 관리자 화면에 로그인한다.
|
||||
* @param {import('@playwright/test').Page} page - 브라우저 페이지
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const loginAdmin = async (page) => {
|
||||
await page.goto('/admin/login')
|
||||
await page.getByLabel('이메일').fill(adminEmail)
|
||||
await page.locator('input[type="password"]').fill(adminPassword)
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) =>
|
||||
response.url().endsWith('/admin/api/auth/login') && response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: '로그인' }).click()
|
||||
])
|
||||
await page.waitForURL(/\/admin(?:\/)?$/)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 API로 태그를 만든다.
|
||||
* @param {import('@playwright/test').APIRequestContext} request - API 요청 컨텍스트
|
||||
* @param {{ name: string, slug: string }} tag - 태그 입력값
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const createTag = async (request, tag) => {
|
||||
const response = await request.post('/admin/api/tags', {
|
||||
data: {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: '',
|
||||
color: '#15171a',
|
||||
tagType: 'managed'
|
||||
}
|
||||
})
|
||||
expect([200, 409]).toContain(response.status())
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 API로 공개 게시물을 만든다.
|
||||
* @param {import('@playwright/test').APIRequestContext} request - API 요청 컨텍스트
|
||||
* @param {Object} post - 게시물 입력값
|
||||
* @returns {Promise<Object>} 생성된 게시물
|
||||
*/
|
||||
const createPublishedPost = async (request, post) => {
|
||||
const response = await request.post('/admin/api/posts', {
|
||||
data: {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
content: post.content,
|
||||
excerpt: '',
|
||||
featuredImage: null,
|
||||
showFeaturedImage: false,
|
||||
status: 'published',
|
||||
publishedAt: new Date().toISOString(),
|
||||
tags: post.tags || [],
|
||||
tagNames: post.tagNames || {}
|
||||
}
|
||||
})
|
||||
expect(response.status()).toBe(200)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
test('태그 추천을 선택한 뒤에도 다음 후보를 바로 선택할 수 있다', async ({ page }) => {
|
||||
await loginAdmin(page)
|
||||
await createTag(page.request, {
|
||||
name: 'Regression Recent',
|
||||
slug: 'regression-recent'
|
||||
})
|
||||
await createTag(page.request, {
|
||||
name: 'Regression Next',
|
||||
slug: 'regression-next'
|
||||
})
|
||||
await createPublishedPost(page.request, {
|
||||
title: 'Regression Tag Seed',
|
||||
slug: 'regression-tag-seed',
|
||||
content: '태그 추천 회귀 테스트용 게시물',
|
||||
tags: ['regression-recent'],
|
||||
tagNames: {
|
||||
'regression-recent': 'Regression Recent'
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto('/admin/posts/new')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.getByLabel('태그 추천 목록 열기').click()
|
||||
await page.getByRole('option', { name: /Regression Recent/ }).click()
|
||||
|
||||
await expect(page.getByRole('option', { name: /Regression Next/ })).toBeVisible()
|
||||
await page.getByRole('option', { name: /Regression Next/ }).click()
|
||||
await expect(page.getByLabel('Regression Recent 태그 삭제')).toBeVisible()
|
||||
await expect(page.getByLabel('Regression Next 태그 삭제')).toBeVisible()
|
||||
})
|
||||
|
||||
test('미디어 파생 탭에는 역할 배지가 표시되지 않는다', async ({ page }) => {
|
||||
await loginAdmin(page)
|
||||
await page.goto('/admin/media')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.getByRole('button', { name: '대표 이미지' }).click()
|
||||
await expect(page.locator('.admin-media__usage-badge')).toHaveCount(0)
|
||||
|
||||
await page.getByRole('button', { name: '본문 이미지' }).click()
|
||||
await expect(page.locator('.admin-media__usage-badge')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('단일 이미지 size 옵션이 공개 화면과 관리자 미리보기에 반영된다', async ({ page }) => {
|
||||
await loginAdmin(page)
|
||||
const post = await createPublishedPost(page.request, {
|
||||
title: 'Regression Image Width',
|
||||
slug: 'regression-image-width',
|
||||
content: '{size=180}'
|
||||
})
|
||||
|
||||
await page.goto('/post/regression-image-width')
|
||||
const publicImage = page.locator('.prose-image').first()
|
||||
await expect(publicImage).toHaveCSS('max-width', '180px')
|
||||
|
||||
await page.goto(`/admin/posts/${post.id}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.getByLabel('미리보기로 전환').click()
|
||||
const previewImage = page.locator('.admin-markdown-editor__preview .prose-image').first()
|
||||
await expect(previewImage).toHaveCSS('max-width', '180px')
|
||||
})
|
||||
Reference in New Issue
Block a user