관리자 멤버 썸네일 업로드 경로 수정

This commit is contained in:
2026-05-15 10:11:02 +09:00
parent 0ed848a2eb
commit 20b901d4a1
15 changed files with 309 additions and 163 deletions

View File

@@ -191,12 +191,23 @@ const uploadAvatar = async (event) => {
try {
const formData = new FormData()
formData.append('files', file)
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
form.avatarUrl = result.files?.[0]?.url || ''
formData.append('file', file)
const result = isNewMember.value
? await $fetch('/admin/api/member-avatar', {
method: 'POST',
body: formData
})
: 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) {
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
} finally {

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.1.4
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
- 태그 목록 카드 그리드 여백 수정 반영.
## v1.0.19
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.

View File

@@ -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
### 사이드바 행 호버 배경 분리

View File

@@ -190,7 +190,8 @@
| 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(원본명 기반 파일명·충돌 시 넘버링, `/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.post.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.post.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/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
| 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-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |

View File

@@ -416,7 +416,8 @@ components/content/
- `GET /admin/api/media-folders` - 미디어 폴더 목록
- `POST /admin/api/media-folders` - 미디어 폴더 생성
- `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`)
- `POST /admin/api/tags` - 태그 생성
- `GET /admin/api/tags/:id` - 태그 상세
@@ -430,7 +431,8 @@ components/content/
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
- `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/posts/:id`의 `status` 값으로 처리한다.

View File

@@ -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
- 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 행 호버를 `site-sidebar-nav-row`로 분리하고, 라이트 테마에서 배경 `#F7F4EF`로 완화. 다크 테마는 기존 `color-mix` 패널 호버 유지.

4
package-lock.json generated
View File

@@ -5920,7 +5920,7 @@
}
},
"node_modules/dlv": {
"version": "1.1.3",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT"
@@ -9929,7 +9929,7 @@
}
},
"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",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"license": "Apache-2.0",

View File

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

View File

@@ -31,7 +31,7 @@ const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(s
</section>
<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
v-for="tag in tags"
:key="tag.id"

View File

@@ -1,82 +1,10 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { createError } from 'h3'
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
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
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -92,80 +20,7 @@ export default defineEventHandler(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) => 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)
const { avatarUrl } = await uploadMemberAvatarImage(event)
await updateMemberProfile({
userId: session.userId,
@@ -183,4 +38,3 @@ export default defineEventHandler(async (event) => {
avatarUrl
}
})

View File

@@ -678,6 +678,25 @@ export const updateMemberByAdmin = async (input) => {
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 - 이메일

View 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
}
})

View File

@@ -2,6 +2,7 @@ import { createError, getRouterParam, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
const memberInputSchema = z.object({
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
})

View 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
})

View 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
}
}