From ede272e7b192bde29961fa52607e37ccc31359e1 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 11 May 2026 17:27:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(member):=20=ED=9A=8C=EC=9B=90=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=20=EC=B5=9C=EC=86=8C=20=ED=95=B4=EC=83=81?= =?UTF-8?q?=EB=8F=84=EC=99=80=20=EC=84=A4=EC=A0=95=20=EB=B3=B4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아바타 업로드 시 최소 해상도 조건을 검증하고 리사이즈/품질 설정값을 안전 범위로 보정해 운영 설정 오입력에도 안정적으로 동작하도록 개선한다. Co-authored-by: Cursor --- .env.example | 2 ++ docs/history.md | 6 ++++++ docs/map.md | 4 ++-- docs/spec.md | 7 +++++-- docs/update.md | 6 ++++++ nuxt.config.js | 2 ++ package.json | 2 +- server/api/auth/avatar.post.js | 38 +++++++++++++++++++++++++++++++--- 8 files changed, 59 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index d39f2a0..e7456eb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/history.md b/docs/history.md index 6f0fe26..6cbd9cd 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-11 v0.0.75 + +### 회원 썸네일 최소 해상도/설정 방어 강화 + +회원 썸네일은 너무 작은 원본이 올라오면 헤더/설정 화면에서 품질 저하가 크게 보이므로 최소 해상도 제한을 추가했다. 또한 운영 환경 변수 오입력으로 리사이즈/품질 값이 비정상이어도 업로드가 깨지지 않도록 서버에서 값 범위를 보정(clamp)해 안정성을 높였다. + ## 2026-05-11 v0.0.74 ### 회원 썸네일 업로드 표준화(WebP + 리사이즈) diff --git a/docs/map.md b/docs/map.md index c189401..5a4744b 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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`(모바일 백드롭 등) | diff --git a/docs/spec.md b/docs/spec.md index c80b9b8..7edc04b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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 - 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가 - 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정 diff --git a/docs/update.md b/docs/update.md index 1ffb529..ff3fa18 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v0.0.75 + +- 회원 썸네일 업로드 시 최소 해상도 제한(`AVATAR_MIN_WIDTH`, `AVATAR_MIN_HEIGHT`)을 추가해 너무 작은 이미지를 차단. +- 썸네일 품질과 리사이즈 설정값이 비정상일 때 서버에서 안전 범위로 보정(clamp)하도록 방어 로직을 추가. +- 최대 해상도 설정이 최소 해상도보다 작게 들어와도 자동 보정되도록 처리. + ## v0.0.74 - 회원 썸네일 업로드 시 원본 포맷과 관계없이 WebP로 변환 저장하도록 수정. diff --git a/nuxt.config.js b/nuxt.config.js index af18d04..bd7ec1c 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -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), diff --git a/package.json b/package.json index c85dd2e..5c312cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.74", + "version": "0.0.75", "private": true, "type": "module", "imports": { diff --git a/server/api/auth/avatar.post.js b/server/api/auth/avatar.post.js index 8cef937..08406cc 100644 --- a/server/api/auth/avatar.post.js +++ b/server/api/auth/avatar.post.js @@ -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({