From b4e4e37f5ad835c94cf113866860d85fe66e499a Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 15 May 2026 11:00:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=20=EC=BA=90=EC=8B=9C=20=EA=B0=B1=EC=8B=A0=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 5 ++ docs/history.md | 6 ++ docs/map.md | 5 +- docs/spec.md | 9 +-- docs/todo.md | 1 - docs/update.md | 8 +++ package-lock.json | 8 +-- package.json | 2 +- server/routes/admin/api/settings/logo.post.js | 19 +++-- server/utils/media-library.js | 72 ++++++++++++++++--- 10 files changed, 109 insertions(+), 26 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index d0b8627..36396fb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.1.7 + +- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임. +- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강. + ## v1.1.6 - 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정. diff --git a/docs/history.md b/docs/history.md index 7f53bcc..c6d946e 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-15 v1.1.7 + +### 사이트 로고 파일명을 교체마다 고유하게 저장 + +사이트 로고 업로드는 미디어 라이브러리에 `시스템` 폴더 메타로 남지만, 기존 구현은 항상 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 덮어썼다. 운영 브라우저와 파비콘 캐시는 같은 URL의 이미지를 오래 보관할 수 있어 파일이 바뀌어도 이전 이미지처럼 보일 수 있다. 따라서 로고와 파비콘은 업로드마다 고유 파일명으로 저장하고 사이트 설정 URL 자체를 갱신한다. 현재 사이트 설정에서 참조 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시해 실수로 이름을 바꾸거나 삭제하지 못하게 했다. + ## 2026-05-15 v1.1.6 ### 일반 태그도 검색 없이 보이는 관리 화면 diff --git a/docs/map.md b/docs/map.md index 881f25e..7d9d1b2 100644 --- a/docs/map.md +++ b/docs/map.md @@ -114,12 +114,12 @@ | pages/admin/pages/index.vue | 페이지 목록 | | pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 | | pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | -| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) | +| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 | | pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/[id].vue | 태그 수정 | -| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) | +| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) | | pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) | | pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) | | pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) | @@ -201,6 +201,7 @@ | server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API | | server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API | | server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API | +| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-*.webp`, `/uploads/system/favicon-*.png` 생성, `시스템` 미디어 메타 저장) | | server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API | | server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API | | server/routes/admin/api/members.get.js | 관리자 멤버 목록 API | diff --git a/docs/spec.md b/docs/spec.md index 63f24e8..2d8f2e4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -550,7 +550,7 @@ components/content/ - 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다. - 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다. -- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 함께 생성한다. +- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-*.webp`와 `/uploads/system/favicon-*.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다. - 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다. - 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다. - DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다. @@ -612,13 +612,14 @@ components/content/ /uploads/posts/YYYY/MM/filename.webp /uploads/pages/YYYY/MM/filename.webp /uploads/members/avatars/YYYY/MM/filename.webp -/uploads/system/logo.png -/uploads/system/favicon.png +/uploads/system/logo-YYYYMMDDTHHMMSS-random.webp +/uploads/system/favicon-YYYYMMDDTHHMMSS-random.png ``` - 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다. - `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다. - 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다. +- 사이트 로고와 파비콘은 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url` 또는 `favicon_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다. - 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다. - 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다. @@ -635,7 +636,7 @@ components/content/ - 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통). - 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. -- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다. +- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘 URL을 기준으로 표시한다. - 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다. - 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다. diff --git a/docs/todo.md b/docs/todo.md index 63bfe5a..fda8069 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -6,7 +6,6 @@ ## 2차 관리자 개발 -- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 - [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결 ## 프론트엔드 개발 diff --git a/docs/update.md b/docs/update.md index b2f1a25..78a1bf5 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 이력 +## v1.1.7 + +- 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정. +- 사이트 파비콘도 로고와 같은 고유 접미사 파일명으로 생성해 브라우저 캐시로 이전 이미지가 남는 문제를 완화. +- 미디어 라이브러리 사용 현황에 사이트 설정 로고·파비콘 참조를 포함하고, 현재 사용 중인 시스템 이미지는 파일명 변경·삭제가 잠기도록 수정. +- 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 todo 정리. +- 패키지 버전 `1.1.7`로 갱신. + ## v1.1.6 - 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정. diff --git a/package-lock.json b/package-lock.json index 5f3d08a..3f006fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.0.19", + "version": "1.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.0.19", + "version": "1.1.7", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", @@ -5920,7 +5920,7 @@ } }, "node_modules/dlv": { - "version": "1.1.6", + "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" @@ -6920,7 +6920,7 @@ "license": "MIT" }, "node_modules/impound": { - "version": "1.1.6", + "version": "1.1.5", "resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz", "integrity": "sha512-5AUn+QE0UofqNHu5f2Skf6Svukdg4ehOIq8O0EtqIx4jta0CDZYBPqpIHt0zrlUTiFVYlLpeH39DoikXBjPKpA==", "license": "MIT", diff --git a/package.json b/package.json index 7c1082e..da321d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.1.6", + "version": "1.1.7", "private": true, "type": "module", "imports": { diff --git a/server/routes/admin/api/settings/logo.post.js b/server/routes/admin/api/settings/logo.post.js index 39eb0fe..6757381 100644 --- a/server/routes/admin/api/settings/logo.post.js +++ b/server/routes/admin/api/settings/logo.post.js @@ -31,6 +31,14 @@ const clampNumber = (value, minimum, maximum) => { return Math.round(value) } +/** + * 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다. + * @returns {string} 파일명 접미사 + */ +const createSystemAssetSuffix = () => `${new Date().toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}Z$/g, '')}-${Math.random().toString(36).slice(2, 8)}` + /** * 사이트 로고 업로드 API * @param {import('h3').H3Event} event - 요청 이벤트 @@ -77,10 +85,13 @@ export default defineEventHandler(async (event) => { const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads' const publicBasePath = uploadBaseUrl.replace(/^\/+/, '') const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system') - const logoPath = join(directoryPath, 'logo.webp') - const faviconPath = join(directoryPath, 'favicon.png') - const logoUrl = `${uploadBaseUrl}/system/logo.webp` - const faviconUrl = `${uploadBaseUrl}/system/favicon.png` + const assetSuffix = createSystemAssetSuffix() + const logoFileName = `logo-${assetSuffix}.webp` + const faviconFileName = `favicon-${assetSuffix}.png` + const logoPath = join(directoryPath, logoFileName) + const faviconPath = join(directoryPath, faviconFileName) + const logoUrl = `${uploadBaseUrl}/system/${logoFileName}` + const faviconUrl = `${uploadBaseUrl}/system/${faviconFileName}` await mkdir(directoryPath, { recursive: true }) diff --git a/server/utils/media-library.js b/server/utils/media-library.js index efae453..5799dc7 100644 --- a/server/utils/media-library.js +++ b/server/utils/media-library.js @@ -1,7 +1,7 @@ import { readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, extname, join, relative } from 'node:path' import { createError } from 'h3' -import { listAdminPosts, listPages } from '../repositories/content-repository' +import { getSiteSettings, listAdminPosts, listPages } from '../repositories/content-repository' import { getPostgresClient } from '../repositories/postgres-client' const uploadRoot = join(process.cwd(), 'public', 'uploads') @@ -478,6 +478,46 @@ const getMediaUsage = (url, posts, pages) => { return [...postUsages, ...pageUsages] } +/** + * 사이트 설정에서 미디어 URL 사용처 조회 + * @param {string} url - 미디어 URL + * @param {Object} siteSettings - 사이트 설정 + * @returns {Array} 사용처 목록 + */ +const getSiteSettingsMediaUsage = (url, siteSettings) => { + const usages = [] + + if (siteSettings.logoUrl === url) { + usages.push({ + type: 'settings', + typeLabel: '사이트 설정', + id: 'site-logo', + title: '사이트 로고', + adminUrl: '/admin/settings', + publicUrl: '/', + status: 'system', + location: 'logoUrl', + label: '사이트 로고' + }) + } + + if (siteSettings.faviconUrl === url) { + usages.push({ + type: 'settings', + typeLabel: '사이트 설정', + id: 'site-favicon', + title: '파비콘', + adminUrl: '/admin/settings', + publicUrl: '/', + status: 'system', + location: 'faviconUrl', + label: '파비콘' + }) + } + + return usages +} + /** * 미디어 목록 조회 * @returns {Promise>} 미디어 항목 목록 @@ -485,9 +525,10 @@ const getMediaUsage = (url, posts, pages) => { export const listMediaItems = async () => { const items = await readMediaDirectory(uploadRoot) const metadataMap = await getMediaMetadataMap() - const [posts, pages] = await Promise.all([ + const [posts, pages, siteSettings] = await Promise.all([ listAdminPosts(), - listPages() + listPages(), + getSiteSettings() ]) const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url)) const itemsWithUsage = items.map((item) => { @@ -498,7 +539,10 @@ export const listMediaItems = async () => { return { ...item, category, - usage: getMediaUsage(item.url, posts, pages), + usage: [ + ...getMediaUsage(item.url, posts, pages), + ...getSiteSettingsMediaUsage(item.url, siteSettings) + ], avatarOwner } }) @@ -615,11 +659,15 @@ export const updateMediaCategories = async (urls, category) => { * @returns {Promise} */ export const deleteMediaItem = async (url) => { - const [posts, pages] = await Promise.all([ + const [posts, pages, siteSettings] = await Promise.all([ listAdminPosts(), - listPages() + listPages(), + getSiteSettings() ]) - const usage = getMediaUsage(url, posts, pages) + const usage = [ + ...getMediaUsage(url, posts, pages), + ...getSiteSettingsMediaUsage(url, siteSettings) + ] if (usage.length) { throw createError({ @@ -646,11 +694,15 @@ export const deleteMediaItem = async (url) => { * @returns {Promise} 변경된 미디어 항목 */ export const renameMediaItem = async (url, name) => { - const [posts, pages] = await Promise.all([ + const [posts, pages, siteSettings] = await Promise.all([ listAdminPosts(), - listPages() + listPages(), + getSiteSettings() ]) - const usage = getMediaUsage(url, posts, pages) + const usage = [ + ...getMediaUsage(url, posts, pages), + ...getSiteSettingsMediaUsage(url, siteSettings) + ] if (usage.length) { throw createError({