feat(member): 회원 썸네일 최소 해상도와 설정 보정 추가

아바타 업로드 시 최소 해상도 조건을 검증하고 리사이즈/품질 설정값을 안전 범위로 보정해 운영 설정 오입력에도 안정적으로 동작하도록 개선한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 17:27:47 +09:00
parent 65af30724c
commit ede272e7b1
8 changed files with 59 additions and 8 deletions

View File

@@ -14,6 +14,8 @@ MEMBER_SESSION_SECRET=replace-with-random-password
# Upload
UPLOAD_DIR=/uploads
MAX_FILE_SIZE=10485760
AVATAR_MIN_WIDTH=96
AVATAR_MIN_HEIGHT=96
AVATAR_MAX_WIDTH=512
AVATAR_MAX_HEIGHT=512
AVATAR_WEBP_QUALITY=82

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-11 v0.0.75
### 회원 썸네일 최소 해상도/설정 방어 강화
회원 썸네일은 너무 작은 원본이 올라오면 헤더/설정 화면에서 품질 저하가 크게 보이므로 최소 해상도 제한을 추가했다. 또한 운영 환경 변수 오입력으로 리사이즈/품질 값이 비정상이어도 업로드가 깨지지 않도록 서버에서 값 범위를 보정(clamp)해 안정성을 높였다.
## 2026-05-11 v0.0.74
### 회원 썸네일 업로드 표준화(WebP + 리사이즈)

View File

@@ -123,7 +123,7 @@
| server/api/auth/logout.post.js | 회원 로그아웃 API |
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최대 해상도 리사이즈) |
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최소 해상도 검증, 최대 해상도 리사이즈, 품질 보정) |
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
@@ -198,7 +198,7 @@
| 파일 | 기능 |
|------|------|
| package.json | Nuxt 실행 스크립트와 의존성 |
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath``main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 리사이즈/품질 런타임 설정 |
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath``main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
| tailwind.config.js | Tailwind 테마 설정 |
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |

View File

@@ -345,7 +345,8 @@ components/content/
- `PUT /api/auth/password` - 회원 비밀번호 변경
- `DELETE /api/auth/account` - 회원 탈퇴
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MAX_WIDTH`/`AVATAR_MAX_HEIGHT` 기준으로 자동 리사이즈한다.
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 `AVATAR_MAX_WIDTH`/`AVATAR_MAX_HEIGHT` 기준으로 자동 리사이즈한다.
> `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다.
> 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다.
### 관리자 API (`/admin/api/`)
@@ -546,6 +547,8 @@ MEMBER_SESSION_SECRET=replace-with-random-password
# Upload
UPLOAD_DIR=/uploads
MAX_FILE_SIZE=10485760
AVATAR_MIN_WIDTH=96
AVATAR_MIN_HEIGHT=96
AVATAR_MAX_WIDTH=512
AVATAR_MAX_HEIGHT=512
AVATAR_WEBP_QUALITY=82
@@ -585,6 +588,6 @@ APP_PORT=43118
## 버전 관리
- 현재 버전: v0.0.74
- 현재 버전: v0.0.75
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v0.0.75
- 회원 썸네일 업로드 시 최소 해상도 제한(`AVATAR_MIN_WIDTH`, `AVATAR_MIN_HEIGHT`)을 추가해 너무 작은 이미지를 차단.
- 썸네일 품질과 리사이즈 설정값이 비정상일 때 서버에서 안전 범위로 보정(clamp)하도록 방어 로직을 추가.
- 최대 해상도 설정이 최소 해상도보다 작게 들어와도 자동 보정되도록 처리.
## v0.0.74
- 회원 썸네일 업로드 시 원본 포맷과 관계없이 WebP로 변환 저장하도록 수정.

View File

@@ -53,6 +53,8 @@ export default defineNuxtConfig({
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
uploadDir: process.env.UPLOAD_DIR || '/uploads',
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
avatarMinWidth: Number(process.env.AVATAR_MIN_WIDTH || 96),
avatarMinHeight: Number(process.env.AVATAR_MIN_HEIGHT || 96),
avatarMaxWidth: Number(process.env.AVATAR_MAX_WIDTH || 512),
avatarMaxHeight: Number(process.env.AVATAR_MAX_HEIGHT || 512),
avatarWebpQuality: Number(process.env.AVATAR_WEBP_QUALITY || 82),

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.74",
"version": "0.0.75",
"private": true,
"type": "module",
"imports": {

View File

@@ -25,6 +25,29 @@ const sanitizePathPart = (value) => value
.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)
}
/**
* 회원 썸네일 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -42,9 +65,11 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const avatarMaxWidth = Number(config.avatarMaxWidth || 512)
const avatarMaxHeight = Number(config.avatarMaxHeight || 512)
const avatarWebpQuality = Number(config.avatarWebpQuality || 82)
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 avatarWebpQuality = clampNumber(Number(config.avatarWebpQuality || 82), 1, 100)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
@@ -91,6 +116,13 @@ export default defineEventHandler(async (event) => {
})
}
if (metadata.width < avatarMinWidth || metadata.height < avatarMinHeight) {
throw createError({
statusCode: 400,
message: `최소 ${avatarMinWidth}x${avatarMinHeight} 이상 이미지만 업로드할 수 있습니다.`
})
}
const resizedBuffer = await sharp(file.data)
.rotate()
.resize({