운영 업로드 파일 제공 경로 보강

This commit is contained in:
2026-05-15 10:27:25 +09:00
parent 20b901d4a1
commit 9e544d97fa
9 changed files with 110 additions and 4 deletions

View File

@@ -1,5 +1,9 @@
# 업데이트 요약
## v1.1.5
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
## v1.1.4
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.

View File

@@ -323,7 +323,8 @@ docker compose --env-file .env.production restart sori-studio-db
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
- `public/uploads/`는 Git에 포함하지 않는다.
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
- NAS 운영에서는 `docker-compose.yml``./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-15 v1.1.5
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
Nuxt 운영 빌드는 `public/`을 빌드 시점에 `.output/public`으로 복사해 정적 파일로 제공한다. 반면 Docker 운영 업로드는 컨테이너 실행 중 `/app/public/uploads` 볼륨에 기록되므로, 새 파일이 `.output/public` 스냅샷에 없으면 업로드 직후 이미지가 깨져 보일 수 있다. 업로드 파일은 사용자 콘텐츠이자 런타임 데이터이므로 빌드 산출물에 의존하지 않고 `/uploads/**` 요청을 `public/uploads`에서 직접 스트리밍하도록 결정했다.
## 2026-05-15 v1.1.4
### 관리자 멤버 썸네일 업로드 경로 분리

View File

@@ -43,6 +43,7 @@
| 파일 | 용도 |
|------|------|
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) |
## 사이트 컴포넌트

View File

@@ -395,6 +395,10 @@ components/content/
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
### 업로드 파일 제공
- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다.
### 관리자 API (`/admin/api/`)
- `POST /admin/api/auth/login` - 로그인

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.1.5
- 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가.
- Docker 운영 이미지의 `.output/public` 빌드 시점 스냅샷에 의존하지 않고 새로 업로드한 로고·게시물 이미지·회원 썸네일이 즉시 표시되도록 수정.
- 패키지 버전 `1.1.5`로 갱신.
## v1.1.4
- 관리자 멤버 썸네일 업로드가 게시물용 `/uploads/posts`가 아니라 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.

4
package-lock.json generated
View File

@@ -5920,7 +5920,7 @@
}
},
"node_modules/dlv": {
"version": "1.1.4",
"version": "1.1.5",
"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.4",
"version": "1.1.5",
"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.4",
"version": "1.1.5",
"private": true,
"type": "module",
"imports": {

View File

@@ -0,0 +1,84 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { extname, join, relative } from 'node:path'
import { createError, getRequestURL, sendStream, setResponseHeader } from 'h3'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const contentTypes = new Map([
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.png', 'image/png'],
['.webp', 'image/webp'],
['.gif', 'image/gif'],
['.svg', 'image/svg+xml'],
['.ico', 'image/x-icon']
])
/**
* 업로드 요청 URL을 디스크 파일 경로로 변환한다.
* @param {string} pathname - 요청 경로
* @returns {string} 디스크 파일 경로
*/
const resolveUploadFilePath = (pathname) => {
let decodedPath = ''
try {
decodedPath = decodeURIComponent(pathname)
} catch {
throw createError({
statusCode: 400,
message: '업로드 파일 경로가 올바르지 않습니다.'
})
}
const relativeUrlPath = decodedPath.replace(/^\/uploads\/?/g, '')
if (!relativeUrlPath || relativeUrlPath.includes('\0')) {
throw createError({
statusCode: 404,
message: '업로드 파일을 찾을 수 없습니다.'
})
}
const filePath = join(uploadRoot, relativeUrlPath)
const relativeDiskPath = relative(uploadRoot, filePath)
if (relativeDiskPath.startsWith('..') || relativeDiskPath === '') {
throw createError({
statusCode: 400,
message: '업로드 파일 경로가 올바르지 않습니다.'
})
}
return filePath
}
/**
* 런타임 업로드 파일 제공 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<void>} 업로드 파일 스트림
*/
export default defineEventHandler(async (event) => {
const filePath = resolveUploadFilePath(getRequestURL(event).pathname)
const fileStat = await stat(filePath).catch(() => null)
if (!fileStat?.isFile()) {
throw createError({
statusCode: 404,
message: '업로드 파일을 찾을 수 없습니다.'
})
}
const extension = extname(filePath).toLowerCase()
const contentType = contentTypes.get(extension)
if (contentType) {
setResponseHeader(event, 'content-type', contentType)
}
setResponseHeader(event, 'content-length', String(fileStat.size))
setResponseHeader(event, 'cache-control', 'no-cache')
setResponseHeader(event, 'last-modified', fileStat.mtime.toUTCString())
return sendStream(event, createReadStream(filePath))
})