관리자 멤버 썸네일 업로드 경로 수정
This commit is contained in:
@@ -191,12 +191,23 @@ const uploadAvatar = async (event) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('files', file)
|
formData.append('file', file)
|
||||||
const result = await $fetch('/admin/api/uploads', {
|
const result = isNewMember.value
|
||||||
method: 'POST',
|
? await $fetch('/admin/api/member-avatar', {
|
||||||
body: formData
|
method: 'POST',
|
||||||
})
|
body: formData
|
||||||
form.avatarUrl = result.files?.[0]?.url || ''
|
})
|
||||||
|
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
form.avatarUrl = result.avatarUrl || ''
|
||||||
|
|
||||||
|
if (!isNewMember.value) {
|
||||||
|
emit('saved', result)
|
||||||
|
savedMemberSnapshot.value = serializeMemberPayload()
|
||||||
|
saveMessage.value = '썸네일이 변경되었습니다.'
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.1.4
|
||||||
|
|
||||||
|
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||||
|
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
|
||||||
|
- 태그 목록 카드 그리드 여백 수정 반영.
|
||||||
|
|
||||||
## v1.0.19
|
## v1.0.19
|
||||||
|
|
||||||
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
|
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-15 v1.1.4
|
||||||
|
|
||||||
|
### 관리자 멤버 썸네일 업로드 경로 분리
|
||||||
|
|
||||||
|
회원 프로필 썸네일은 관리자 계정인지 일반 회원인지와 무관하게 회원 자산이므로 `/uploads/members/avatars`에 저장해야 한다. 관리자 멤버 편집 화면이 공용 게시물 이미지 업로드 API를 사용하면 `/uploads/posts`에 저장되어 미디어 분류와 썸네일 생명주기 규칙이 어긋난다. 회원 설정 업로드와 관리자 멤버 업로드가 같은 검증·WebP 변환·1:1 크롭 로직을 쓰도록 공통 유틸로 분리하고, 관리자 멤버 화면은 회원 전용 업로드 API를 사용하도록 정리했다.
|
||||||
|
|
||||||
## 2026-05-13 v1.1.3
|
## 2026-05-13 v1.1.3
|
||||||
|
|
||||||
### 사이드바 행 호버 배경 분리
|
### 사이드바 행 호버 배경 분리
|
||||||
|
|||||||
@@ -190,7 +190,8 @@
|
|||||||
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
||||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||||
| server/routes/admin/api/media-folders.post.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(원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||||
|
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
|
||||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||||
@@ -204,13 +205,15 @@
|
|||||||
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||||
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
|
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
|
||||||
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
|
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
|
||||||
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API |
|
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) |
|
||||||
|
| server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
|
||||||
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||||
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
||||||
|
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·WebP 변환·중앙 1:1 크롭·저장 유틸 |
|
||||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||||
|
|||||||
@@ -416,7 +416,8 @@ components/content/
|
|||||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링)
|
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
|
||||||
|
- `POST /admin/api/member-avatar` - 관리자 새 회원 생성 전 썸네일 사전 업로드(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||||
- `POST /admin/api/tags` - 태그 생성
|
- `POST /admin/api/tags` - 태그 생성
|
||||||
- `GET /admin/api/tags/:id` - 태그 상세
|
- `GET /admin/api/tags/:id` - 태그 상세
|
||||||
@@ -430,7 +431,8 @@ components/content/
|
|||||||
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||||
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||||
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
||||||
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`
|
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
|
||||||
|
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||||
|
|
||||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.1.4
|
||||||
|
|
||||||
|
- 관리자 멤버 썸네일 업로드가 게시물용 `/uploads/posts`가 아니라 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||||
|
- 회원 썸네일 업로드 검증·WebP 변환·1:1 크롭 로직을 공통 유틸로 분리.
|
||||||
|
- 관리자 멤버 편집 전용 썸네일 업로드 API와 새 멤버 생성 전 썸네일 사전 업로드 API 추가.
|
||||||
|
- 관리자 회원 기본 정보 저장에서 기존 회원 전용 썸네일 URL이 교체·제거되면 `media_metadata` 연결을 분리하도록 정리.
|
||||||
|
- 태그 목록 카드 그리드에 사용자 수정 `px-6` 반영.
|
||||||
|
- 패키지 버전 `1.1.4`로 갱신.
|
||||||
|
|
||||||
## v1.1.3
|
## v1.1.3
|
||||||
|
|
||||||
- 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 행 호버를 `site-sidebar-nav-row`로 분리하고, 라이트 테마에서 배경 `#F7F4EF`로 완화. 다크 테마는 기존 `color-mix` 패널 호버 유지.
|
- 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 행 호버를 `site-sidebar-nav-row`로 분리하고, 라이트 테마에서 배경 `#F7F4EF`로 완화. 다크 테마는 기존 `color-mix` 패널 호버 유지.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -5920,7 +5920,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -9929,7 +9929,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdir-glob": {
|
"node_modules/readdir-glob": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(s
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="tags-page-list mb-8">
|
<section class="tags-page-list mb-8">
|
||||||
<ul class="mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
|
<ul class="px-6 mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
|
||||||
<li
|
<li
|
||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
|
|||||||
@@ -1,82 +1,10 @@
|
|||||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
import { createError } from 'h3'
|
||||||
import { join } from 'node:path'
|
|
||||||
import { createError, readMultipartFormData } from 'h3'
|
|
||||||
import sharp from 'sharp'
|
|
||||||
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
|
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
|
||||||
import { requireMemberSession } from '../../utils/member-auth'
|
import { requireMemberSession } from '../../utils/member-auth'
|
||||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||||
|
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
|
||||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
|
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
|
||||||
|
|
||||||
const allowedImageTypes = new Map([
|
|
||||||
['image/jpeg', '.jpg'],
|
|
||||||
['image/png', '.png'],
|
|
||||||
['image/webp', '.webp'],
|
|
||||||
['image/gif', '.gif']
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 업로드 경로 조각을 URL 안전 문자열로 정리
|
|
||||||
* @param {string} value - 원본 경로 조각
|
|
||||||
* @returns {string} 정리된 경로 조각
|
|
||||||
*/
|
|
||||||
const sanitizePathPart = (value) => value
|
|
||||||
.replace(/[^a-zA-Z0-9가-힣._-]/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 숫자 설정값을 최소/최대 범위로 보정한다.
|
|
||||||
* @param {number} value - 원본 값
|
|
||||||
* @param {number} minimum - 최소값
|
|
||||||
* @param {number} maximum - 최대값
|
|
||||||
* @returns {number} 보정된 값
|
|
||||||
*/
|
|
||||||
const clampNumber = (value, minimum, maximum) => {
|
|
||||||
if (!Number.isFinite(value)) {
|
|
||||||
return minimum
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value < minimum) {
|
|
||||||
return minimum
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value > maximum) {
|
|
||||||
return 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
|
* 회원 썸네일 업로드 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
@@ -92,80 +20,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||||
const maxFileSize = Number(config.maxFileSize || 10485760)
|
|
||||||
const avatarMinWidth = clampNumber(Number(config.avatarMinWidth || 96), 1, 4096)
|
|
||||||
const avatarMinHeight = clampNumber(Number(config.avatarMinHeight || 96), 1, 4096)
|
|
||||||
const avatarMaxWidth = clampNumber(Number(config.avatarMaxWidth || 512), avatarMinWidth, 4096)
|
|
||||||
const avatarMaxHeight = clampNumber(Number(config.avatarMaxHeight || 512), avatarMinHeight, 4096)
|
|
||||||
const avatarSquareSize = Math.min(avatarMaxWidth, avatarMaxHeight)
|
|
||||||
const avatarWebpQuality = clampNumber(Number(config.avatarWebpQuality || 82), 1, 100)
|
|
||||||
const formData = await readMultipartFormData(event)
|
|
||||||
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: '업로드할 이미지가 없습니다.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowedImageTypes.has(file.type)) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: '이미지 파일만 업로드할 수 있습니다.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.data.length > maxFileSize) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 413,
|
|
||||||
message: '업로드 가능한 파일 크기를 초과했습니다.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const year = String(now.getFullYear())
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
||||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
|
||||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
|
||||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
|
|
||||||
|
|
||||||
await mkdir(directoryPath, { recursive: true })
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
if (!metadata.width || !metadata.height) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: '이미지 메타데이터를 읽을 수 없습니다.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.width < avatarMinWidth || metadata.height < avatarMinHeight) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: `최소 ${avatarMinWidth}x${avatarMinHeight} 이상 이미지만 업로드할 수 있습니다.`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const resizedBuffer = await sharp(file.data)
|
|
||||||
.rotate()
|
|
||||||
.resize({
|
|
||||||
width: avatarSquareSize,
|
|
||||||
height: avatarSquareSize,
|
|
||||||
fit: 'cover',
|
|
||||||
position: 'centre'
|
|
||||||
})
|
|
||||||
.webp({
|
|
||||||
quality: avatarWebpQuality
|
|
||||||
})
|
|
||||||
.toBuffer()
|
|
||||||
|
|
||||||
await writeFile(filePath, resizedBuffer)
|
|
||||||
|
|
||||||
await updateMemberProfile({
|
await updateMemberProfile({
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
@@ -183,4 +38,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
avatarUrl
|
avatarUrl
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -678,6 +678,25 @@ export const updateMemberByAdmin = async (input) => {
|
|||||||
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
|
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 화면에서 회원 썸네일만 수정한다.
|
||||||
|
* @param {{ memberId: string, avatarUrl: string }} input - 수정 값
|
||||||
|
* @returns {Promise<Object | null>} 수정된 회원
|
||||||
|
*/
|
||||||
|
export const updateMemberAvatarByAdmin = async (input) => {
|
||||||
|
const sql = requireSql()
|
||||||
|
const rows = await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
avatar_url = ${input.avatarUrl},
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = ${input.memberId}
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이메일 기준 관리자 회원 조회
|
* 이메일 기준 관리자 회원 조회
|
||||||
* @param {string} email - 이메일
|
* @param {string} email - 이메일
|
||||||
|
|||||||
19
server/routes/admin/api/member-avatar.post.js
Normal file
19
server/routes/admin/api/member-avatar.post.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||||
|
import { uploadMemberAvatarImage } from '../../../utils/member-avatar-upload'
|
||||||
|
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../utils/media-library'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 새 회원용 썸네일 사전 업로드 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
|
||||||
|
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||||
|
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { createError, getRouterParam, readBody } from 'h3'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
|
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
|
||||||
|
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||||
|
|
||||||
const memberInputSchema = z.object({
|
const memberInputSchema = z.object({
|
||||||
username: z.string().trim().min(1).max(60),
|
username: z.string().trim().min(1).max(60),
|
||||||
@@ -76,5 +77,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing.avatarUrl && existing.avatarUrl !== updated.avatarUrl) {
|
||||||
|
await removeManagedAvatarAsset(existing.avatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
|
|||||||
52
server/routes/admin/api/members/[id]/avatar.post.js
Normal file
52
server/routes/admin/api/members/[id]/avatar.post.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { createError, getRouterParam } from 'h3'
|
||||||
|
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||||
|
import { getMemberForAdmin, updateMemberAvatarByAdmin } from '../../../../../repositories/member-repository'
|
||||||
|
import { removeManagedAvatarAsset } from '../../../../../utils/member-avatar'
|
||||||
|
import { uploadMemberAvatarImage } from '../../../../../utils/member-avatar-upload'
|
||||||
|
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../../../utils/media-library'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 썸네일 업로드 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<Object>} 수정된 회원
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
const memberId = String(getRouterParam(event, 'id') || '')
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await getMemberForAdmin(memberId)
|
||||||
|
if (!member) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: '회원을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||||
|
const updated = await updateMemberAvatarByAdmin({
|
||||||
|
memberId,
|
||||||
|
avatarUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: '회원 썸네일 수정에 실패했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
|
||||||
|
|
||||||
|
if (member.avatarUrl && member.avatarUrl !== avatarUrl) {
|
||||||
|
await removeManagedAvatarAsset(member.avatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
160
server/utils/member-avatar-upload.js
Normal file
160
server/utils/member-avatar-upload.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { createError, readMultipartFormData } from 'h3'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
|
||||||
|
const allowedImageTypes = new Map([
|
||||||
|
['image/jpeg', '.jpg'],
|
||||||
|
['image/png', '.png'],
|
||||||
|
['image/webp', '.webp'],
|
||||||
|
['image/gif', '.gif']
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 경로 조각을 URL 안전 문자열로 정리한다.
|
||||||
|
* @param {string} value - 원본 경로 조각
|
||||||
|
* @returns {string} 정리된 경로 조각
|
||||||
|
*/
|
||||||
|
const sanitizePathPart = (value) => value
|
||||||
|
.replace(/[^a-zA-Z0-9가-힣._-]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 설정값을 최소/최대 범위로 보정한다.
|
||||||
|
* @param {number} value - 원본 값
|
||||||
|
* @param {number} minimum - 최소값
|
||||||
|
* @param {number} maximum - 최대값
|
||||||
|
* @returns {number} 보정된 값
|
||||||
|
*/
|
||||||
|
const clampNumber = (value, minimum, maximum) => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return minimum
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < minimum) {
|
||||||
|
return minimum
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value > maximum) {
|
||||||
|
return maximum
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아바타 저장 디렉터리에서 비어 있는 `.webp` 파일명을 고른다.
|
||||||
|
* @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: '저장할 고유 파일명을 만들 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 썸네일 파일을 검증하고 회원 전용 경로에 저장한다.
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ avatarUrl: string }>} 저장된 썸네일 URL
|
||||||
|
*/
|
||||||
|
export const uploadMemberAvatarImage = async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const maxFileSize = Number(config.maxFileSize || 10485760)
|
||||||
|
const avatarMinWidth = clampNumber(Number(config.avatarMinWidth || 96), 1, 4096)
|
||||||
|
const avatarMinHeight = clampNumber(Number(config.avatarMinHeight || 96), 1, 4096)
|
||||||
|
const avatarMaxWidth = clampNumber(Number(config.avatarMaxWidth || 512), avatarMinWidth, 4096)
|
||||||
|
const avatarMaxHeight = clampNumber(Number(config.avatarMaxHeight || 512), avatarMinHeight, 4096)
|
||||||
|
const avatarSquareSize = Math.min(avatarMaxWidth, avatarMaxHeight)
|
||||||
|
const avatarWebpQuality = clampNumber(Number(config.avatarWebpQuality || 82), 1, 100)
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
const file = (formData || []).find((part) => ['file', 'files'].includes(String(part.name || '')) && part.filename)
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '업로드할 이미지가 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedImageTypes.has(file.type)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '이미지 파일만 업로드할 수 있습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.data.length > maxFileSize) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 413,
|
||||||
|
message: '업로드 가능한 파일 크기를 초과했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await sharp(file.data).metadata()
|
||||||
|
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '이미지 메타데이터를 읽을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.width < avatarMinWidth || metadata.height < avatarMinHeight) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `최소 ${avatarMinWidth}x${avatarMinHeight} 이상 이미지만 업로드할 수 있습니다.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const year = String(now.getFullYear())
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||||
|
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||||
|
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
|
||||||
|
|
||||||
|
await mkdir(directoryPath, { recursive: true })
|
||||||
|
|
||||||
|
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
|
||||||
|
const { fileName, filePath } = await pickUniqueWebpFileName(directoryPath, originalStem)
|
||||||
|
const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}`
|
||||||
|
const resizedBuffer = await sharp(file.data)
|
||||||
|
.rotate()
|
||||||
|
.resize({
|
||||||
|
width: avatarSquareSize,
|
||||||
|
height: avatarSquareSize,
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.webp({
|
||||||
|
quality: avatarWebpQuality
|
||||||
|
})
|
||||||
|
.toBuffer()
|
||||||
|
|
||||||
|
await writeFile(filePath, resizedBuffer)
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user