From 9e544d97fa8af3b1ecf7b5b2802dcd5e05e49bd9 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 15 May 2026 10:27:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B3=B5=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 4 ++ docs/deploy.md | 3 +- docs/history.md | 6 ++ docs/map.md | 1 + docs/spec.md | 4 ++ docs/update.md | 6 ++ package-lock.json | 4 +- package.json | 2 +- server/routes/uploads/[...path].get.js | 84 ++++++++++++++++++++++++++ 9 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 server/routes/uploads/[...path].get.js diff --git a/docs/changelog.md b/docs/changelog.md index 27836d6..addb690 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.1.5 + +- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강. + ## v1.1.4 - 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정. diff --git a/docs/deploy.md b/docs/deploy.md index ad7b943..c78f8e0 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다. - 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다. diff --git a/docs/history.md b/docs/history.md index 412cb0a..f753fe2 100644 --- a/docs/history.md +++ b/docs/history.md @@ -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 ### 관리자 멤버 썸네일 업로드 경로 분리 diff --git a/docs/map.md b/docs/map.md index 3981496..881f25e 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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/**`로 스트리밍) | ## 사이트 컴포넌트 diff --git a/docs/spec.md b/docs/spec.md index 461c10d..03e3915 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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` - 로그인 diff --git a/docs/update.md b/docs/update.md index c5c1466..308b66f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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` 경로를 사용하도록 수정. diff --git a/package-lock.json b/package-lock.json index 359c2a7..9c922bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dfb9a27..cec48e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.1.4", + "version": "1.1.5", "private": true, "type": "module", "imports": { diff --git a/server/routes/uploads/[...path].get.js b/server/routes/uploads/[...path].get.js new file mode 100644 index 0000000..9d49fa8 --- /dev/null +++ b/server/routes/uploads/[...path].get.js @@ -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} 업로드 파일 스트림 + */ +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)) +})