운영 업로드 파일 제공 경로 보강
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
||||
|
||||
## v1.1.4
|
||||
|
||||
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||
|
||||
@@ -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` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
### 관리자 멤버 썸네일 업로드 경로 분리
|
||||
|
||||
@@ -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/**`로 스트리밍) |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
|
||||
|
||||
@@ -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` - 로그인
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
84
server/routes/uploads/[...path].get.js
Normal file
84
server/routes/uploads/[...path].get.js
Normal 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))
|
||||
})
|
||||
Reference in New Issue
Block a user