썸네일 미참조 삭제 허용·원본명 업로드·미디어 검색 정리(v0.0.91)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-12 v0.0.91
|
||||
|
||||
### 썸네일 미사용 자산과 업로드 파일명
|
||||
|
||||
프로필에서 바뀐 옛 썸네일을 바로 디스크에서 지우면 관리자가 누가 올린 자산인지 추적하기 어렵다. 메타만 끊고 파일은 남겨 썸네일 탭에서 정리하도록 바꿨다. 삭제·이름 변경 차단은 `avatar_url`이 가리키는 경우로 한정했다. 게시물 업로드는 UUID 접미 대신 원본명과 넘버링으로 검색 가능성을 높였다.
|
||||
|
||||
## 2026-05-12 v0.0.90
|
||||
|
||||
### 관리자 미디어 라이브러리와 썸네일 탭 분리
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 상단 탭, 라이브러리: 폴더 트리·폴더 추가 모달·폴더 삭제·드래그 이동·썸네일 탭: 회원 아바타만·검색(닉네임 등), 썸네일 클릭 미리보기 모달(연결 회원 블록), 좌상단 선택 토글·Shift 범위 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집) |
|
||||
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
@@ -150,7 +150,7 @@
|
||||
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||
@@ -167,7 +167,7 @@
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·파일/메타데이터 정리 유틸리티 |
|
||||
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||
|
||||
15
docs/spec.md
15
docs/spec.md
@@ -365,7 +365,7 @@ components/content/
|
||||
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다.
|
||||
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
|
||||
> `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다.
|
||||
> 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다.
|
||||
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
||||
|
||||
### 관리자 API (`/admin/api/`)
|
||||
@@ -384,12 +384,12 @@ components/content/
|
||||
- `PUT /admin/api/pages/:id` - 고정 페이지 수정
|
||||
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
|
||||
- `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음)
|
||||
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부)
|
||||
- `DELETE /admin/api/media` - 업로드 미디어 삭제(회원 아바타 디스크 경로 파일은 거부)
|
||||
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부; 썸네일 파일명 변경은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
|
||||
- `DELETE /admin/api/media` - 업로드 미디어 삭제(게시물·페이지에서 사용 중이면 거부; `/members/avatars/` URL은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
|
||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링)
|
||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
@@ -559,9 +559,9 @@ components/content/
|
||||
/uploads/system/favicon.png
|
||||
```
|
||||
|
||||
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음).
|
||||
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
|
||||
- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다.
|
||||
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
|
||||
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
|
||||
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
||||
|
||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||
@@ -572,12 +572,13 @@ components/content/
|
||||
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
|
||||
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다.
|
||||
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
|
||||
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 회원 프로필 썸네일 파일은 관리자 화면에서 파일명 변경·삭제를 차단한다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.91
|
||||
|
||||
- 회원 썸네일 교체·삭제·탈퇴 시 이전 파일은 디스크에 남기고 `media_metadata`만 제거해, 관리자 썸네일 탭에서 미사용 자산을 구분·삭제할 수 있게 함.
|
||||
- 관리자 미디어: 프로필이 참조 중인 썸네일만 삭제·이름 변경 차단(미참조 파일은 허용).
|
||||
- `POST /admin/api/uploads`·`POST /api/auth/avatar`: 저장 파일명은 원본명 기반, 동일 폴더 충돌 시 `-2` 넘버링.
|
||||
- 관리자 미디어 검색: 파일명·게시물 사용처 제목만; 모달에서 폴더 요약 중복 행 제거.
|
||||
- `renameMediaItem`: 대상 폴더에 동일 파일명이 있으면 409.
|
||||
|
||||
## v0.0.90
|
||||
|
||||
- 관리자 미디어: 상단 탭으로 **미디어 라이브러리**와 **썸네일**(회원 `/members/avatars/`만) 분리, 썸네일 검색에 닉네임·이메일·IP 반영.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.90",
|
||||
"version": "0.0.91",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -133,15 +133,9 @@ const filteredMediaItems = computed(() => {
|
||||
? true
|
||||
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
|
||||
const usageTitles = item.usage?.map((usage) => usage.title) || []
|
||||
const ownerFields = item.avatarOwner
|
||||
? [item.avatarOwner.username, item.avatarOwner.email, item.avatarOwner.lastSeenIp]
|
||||
: []
|
||||
const matchesQuery = !query || [
|
||||
item.name,
|
||||
item.url,
|
||||
item.category,
|
||||
...usageTitles,
|
||||
...ownerFields
|
||||
...usageTitles
|
||||
].some((value) => String(value || '').toLowerCase().includes(query))
|
||||
|
||||
return matchesFolder && matchesQuery
|
||||
@@ -467,7 +461,7 @@ const renameMedia = async () => {
|
||||
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
|
||||
|
||||
if (editingItem && isMediaItemLocked(editingItem)) {
|
||||
errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 파일명을 변경할 수 없습니다.'
|
||||
errorMessage.value = '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 파일명을 바꿀 수 없습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -496,7 +490,7 @@ const renameMedia = async () => {
|
||||
*/
|
||||
const deleteMedia = async (item) => {
|
||||
if (isMediaItemLocked(item)) {
|
||||
errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 삭제할 수 없습니다.'
|
||||
errorMessage.value = '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 삭제할 수 없습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -558,7 +552,7 @@ const deleteMedia = async (item) => {
|
||||
v-model="searchText"
|
||||
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
||||
type="search"
|
||||
:placeholder="activeTab === 'thumbnails' ? '닉네임, 이메일, IP, 파일명 검색' : '파일명, 경로, 폴더, 사용처 검색'"
|
||||
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -641,7 +635,7 @@ const deleteMedia = async (item) => {
|
||||
<span>{{ thumbnailMediaItems.length }}</span>
|
||||
</button>
|
||||
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
|
||||
회원 프로필에서 저장된 이미지만 표시됩니다. 파일 삭제·이름 변경은 이 화면에서 할 수 없으며, 회원이 프로필에서 바꾸면 갱신됩니다.
|
||||
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제된 파일은 이 목록에 남으며, 관리자가 직접 정리할 수 있습니다.
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
@@ -804,10 +798,6 @@ const deleteMedia = async (item) => {
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
|
||||
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">폴더</dt>
|
||||
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.category }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="admin-media__category grid gap-2">
|
||||
@@ -903,7 +893,7 @@ const deleteMedia = async (item) => {
|
||||
@keydown.enter.prevent="renameMedia"
|
||||
>
|
||||
<p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
|
||||
사용 중이거나 회원 썸네일인 미디어는 파일명 변경과 삭제가 잠겨 있습니다.
|
||||
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
@@ -48,6 +47,36 @@ const clampNumber = (value, minimum, maximum) => {
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 저장 디렉터리에서 비어 있는 `.webp` 파일명을 고른다. 동일 stem이 있으면 `-2`, `-3` 넘버링한다.
|
||||
* @param {string} directoryPath - 저장 디렉터리 절대 경로
|
||||
* @param {string} stem - 확장자 제외 파일명
|
||||
* @returns {Promise<{ fileName: string, filePath: string }>} 파일명과 절대 경로
|
||||
*/
|
||||
const pickUniqueWebpFileName = async (directoryPath, stem) => {
|
||||
let suffix = 1
|
||||
|
||||
while (suffix < 10000) {
|
||||
const fileName = suffix === 1 ? `${stem}.webp` : `${stem}-${suffix}.webp`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
|
||||
try {
|
||||
await stat(filePath)
|
||||
suffix += 1
|
||||
} catch {
|
||||
return {
|
||||
fileName,
|
||||
filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '저장할 고유 파일명을 만들 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 썸네일 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -104,9 +133,8 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
const originalName = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
|
||||
const fileName = `${originalName}-${randomUUID()}.webp`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
|
||||
const { fileName, filePath } = await pickUniqueWebpFileName(directoryPath, originalStem)
|
||||
const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}`
|
||||
const metadata = await sharp(file.data).metadata()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { extname, join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
@@ -37,6 +36,37 @@ const getUploadExtension = (file) => {
|
||||
return extension
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉터리 안에서 비어 있는 저장 파일명을 고른다. 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
|
||||
* @param {string} directoryPath - 저장 디렉터리 절대 경로
|
||||
* @param {string} stem - 확장자 제외 파일명
|
||||
* @param {string} extension - 확장자(점 포함, 예: `.png`)
|
||||
* @returns {Promise<{ fileName: string, filePath: string }>} 선택된 파일명과 절대 경로
|
||||
*/
|
||||
const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
|
||||
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 createError({
|
||||
statusCode: 500,
|
||||
message: '저장할 고유 파일명을 만들 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 이미지 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -83,10 +113,9 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const originalName = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image'
|
||||
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image'
|
||||
const extension = getUploadExtension(file)
|
||||
const fileName = `${originalName}-${randomUUID()}${extension}`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, originalStem, extension)
|
||||
|
||||
await writeFile(filePath, file.data)
|
||||
|
||||
@@ -96,7 +125,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
uploadedFiles.push({
|
||||
url: publicUrl,
|
||||
name: file.filename,
|
||||
name: fileName,
|
||||
size: file.data.length
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,17 +96,28 @@ const assertCategoryMoveAllowed = (urls, normalizedCategory) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 아바타 파일은 라이브러리에서 직접 삭제·이름 변경하지 않는다.
|
||||
* 해당 URL이 회원 프로필 `avatar_url`로 참조 중인지 확인한다.
|
||||
* @param {string} url - 미디어 URL
|
||||
* @returns {void}
|
||||
* @returns {Promise<boolean>} 참조 중이면 true
|
||||
*/
|
||||
const assertNotMemberAvatarFile = (url) => {
|
||||
if (isMemberAvatarPublicUrl(url)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 프로필 썸네일은 이 화면에서 삭제·이름 변경할 수 없습니다.'
|
||||
})
|
||||
const isAvatarUrlReferencedByProfile = async (url) => {
|
||||
if (!isMemberAvatarPublicUrl(url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE avatar_url = ${url}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,8 +615,6 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteMediaItem = async (url) => {
|
||||
assertNotMemberAvatarFile(url)
|
||||
|
||||
const [posts, pages] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
@@ -619,6 +628,13 @@ export const deleteMediaItem = async (url) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (await isAvatarUrlReferencedByProfile(url)) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '회원 프로필에서 사용 중인 썸네일은 삭제할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
await rm(resolveMediaPath(url))
|
||||
await deleteMediaMetadata(url)
|
||||
}
|
||||
@@ -630,8 +646,6 @@ export const deleteMediaItem = async (url) => {
|
||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
||||
*/
|
||||
export const renameMediaItem = async (url, name) => {
|
||||
assertNotMemberAvatarFile(url)
|
||||
|
||||
const [posts, pages] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
@@ -645,6 +659,13 @@ export const renameMediaItem = async (url, name) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (await isAvatarUrlReferencedByProfile(url)) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '회원 프로필에서 사용 중인 썸네일은 파일명을 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const currentPath = resolveMediaPath(url)
|
||||
const currentExtension = extname(currentPath)
|
||||
const cleanName = sanitizeMediaName(name.replace(/\.[^.]+$/g, ''))
|
||||
@@ -658,6 +679,22 @@ export const renameMediaItem = async (url, name) => {
|
||||
|
||||
const nextPath = join(dirname(currentPath), `${cleanName}${currentExtension}`)
|
||||
|
||||
if (currentPath === nextPath) {
|
||||
return createMediaItem(currentPath)
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(nextPath)
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '같은 폴더에 동일한 파일명이 이미 있습니다.'
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.statusCode === 409) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await rename(currentPath, nextPath)
|
||||
|
||||
const renamedItem = await createMediaItem(nextPath)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
import { createError } from 'h3'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
@@ -41,7 +40,7 @@ export const resolveMemberAvatarPath = (url) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 썸네일 파일과 메타데이터를 정리한다.
|
||||
* 프로필에서 썸네일 URL을 끊을 때 `media_metadata` 행만 제거한다. 디스크 파일은 유지해 관리자 미디어(썸네일 탭)에서 미사용 자산으로 확인·삭제할 수 있게 한다.
|
||||
* @param {string} url - 정리 대상 URL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
@@ -50,9 +49,6 @@ export const removeManagedAvatarAsset = async (url) => {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = resolveMemberAvatarPath(url)
|
||||
await rm(filePath, { force: true })
|
||||
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user