From 5c3877b5c121a846aa1636f67648ccbeff77edae Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 26 Mar 2026 17:30:03 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v0.1.34=20?= =?UTF-8?q?=ED=8C=8C=EB=B9=84=EC=BD=98=EA=B3=BC=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EA=B8=B0=EB=B3=B8=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/admin.js | 43 ++++++--- docs/history.md | 11 +++ docs/map.md | 2 +- docs/spec.md | 6 +- docs/todo.md | 1 + docs/update.md | 24 +++++ frontend/index.html | 5 +- frontend/src/views/AdminView.vue | 155 +++++++++++++++++++++++++------ 8 files changed, 205 insertions(+), 42 deletions(-) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 8040fba..c3ee9f7 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -34,6 +34,18 @@ function buildUploadFilename(file) { return `${Date.now()}-${nanoid()}${safeExt}` } +function buildItemLabelFromFilename(file) { + const originalName = file?.originalname || '' + const base = path.basename(originalName, path.extname(originalName)) + const normalized = base + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 60) + + return normalized || 'item' +} + const upload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'games')), @@ -74,20 +86,27 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail') res.json({ game: updated }) }) -router.post('/games/:gameId/images', requireAdmin, upload.single('image'), async (req, res) => { - if (!req.file) return res.status(400).json({ error: 'file_required' }) - const schema = z.object({ label: z.string().min(1).max(60) }) - const parsed = schema.safeParse(req.body) - if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) +router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), async (req, res) => { + const files = Array.isArray(req.files) ? req.files : [] + if (!files.length) return res.status(400).json({ error: 'file_required' }) const game = await findGameById(req.params.gameId) if (!game) return res.status(404).json({ error: 'not_found' }) - const item = await createGameItem({ - id: nanoid(), - gameId: game.id, - src: `/uploads/games/${req.file.filename}`, - label: parsed.data.label, - }) - res.json({ item }) + + const manualLabel = typeof req.body?.label === 'string' ? req.body.label.trim() : '' + if (manualLabel && manualLabel.length > 60) return res.status(400).json({ error: 'bad_request' }) + + const items = await Promise.all( + files.map((file, index) => + createGameItem({ + id: nanoid(), + gameId: game.id, + src: `/uploads/games/${file.filename}`, + label: index === 0 && manualLabel ? manualLabel : buildItemLabelFromFilename(file), + }) + ) + ) + + res.json({ item: items[0], items }) }) router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => { diff --git a/docs/history.md b/docs/history.md index 1b2567b..c5bc1db 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,13 @@ # 의사결정 이력 +## 2026-03-26 v0.1.34 +- 일부 NAS 환경에서 `favicon.svg` 정적 응답이 `403`으로 차단될 수 있으므로, 운영 안정성을 위해 별도 파일 요청이 필요 없는 인라인 데이터 URL 파비콘으로 전환하기로 결정했다. +- 관리자 기본 아이템 등록은 단일 파일 업로드만으로는 운영 부담이 크므로, 다중 선택과 드래그 앤 드롭을 지원하고 라벨은 파일명으로 자동 생성하는 방향을 채택했다. + +## 2026-03-26 v0.1.29 +- NAS에서 HTTPS를 종료한 뒤 내부 컨테이너끼리는 HTTP로 통신하는 구조에서는, 프런트 프록시가 백엔드에 원래 프로토콜을 정확히 전달하지 않으면 `secure` 세션 쿠키가 발급되지 않는다고 판단했다. +- 따라서 운영 프런트 Nginx는 백엔드 프록시 요청에 `X-Forwarded-Proto: https`를 명시하고, Express 세션도 프록시 환경을 명시적으로 신뢰하도록 설정하기로 결정했다. + ## 2026-03-19 - 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다. - 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다. @@ -102,3 +110,6 @@ ## 2026-03-26 v0.1.27 - NAS 운영은 리버스 프록시 설정을 단순하게 유지하는 편이 좋으므로, 프런트 컨테이너 하나만 외부 공개하고 `/api`, `/uploads`는 내부 프록시로 넘기는 구조를 채택했다. - 운영은 로컬 개발 컴포즈와 분리된 전용 `docker-compose.prod.yml`을 두고, 환경변수는 `.env.production`으로 분리해 관리하기로 결정했다. + +## 2026-03-26 v0.1.28 +- UGREEN NAS에서 MariaDB 초기화가 길게 걸릴 수 있으므로, healthcheck는 앱 계정보다 `root` 기준 ping과 더 긴 유예 시간으로 두는 편이 안전하다고 판단했다. diff --git a/docs/map.md b/docs/map.md index bbf5bde..098d78b 100644 --- a/docs/map.md +++ b/docs/map.md @@ -27,7 +27,7 @@ ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일/기본 아이템 관리, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 +- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 - 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `GET /api/admin/custom-items`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId` ## `/profile` diff --git a/docs/spec.md b/docs/spec.md index 62361e8..b4cede3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -7,6 +7,8 @@ - 세션 저장소: `session-file-store` 기반 파일 세션 - 업로드 저장소: 로컬 파일 시스템(`backend/uploads/`) - 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조 +- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다. +- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다. ## 데이터 저장 구조 - 메인 데이터베이스: MariaDB `tier_cursor` (기본값) @@ -82,6 +84,7 @@ - `POST /api/admin/games` - `POST /api/admin/games/:gameId/thumbnail` - `POST /api/admin/games/:gameId/images` + - 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다. - `GET /api/admin/custom-items` - `DELETE /api/admin/custom-items/:itemId` - `DELETE /api/admin/custom-items` @@ -94,7 +97,7 @@ ## 관리자 화면 메모 - 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다. -- 게임 기본 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다. +- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다. - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. - 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다. @@ -143,6 +146,7 @@ - 프로덕션 컴포즈 파일은 [docker-compose.prod.yml](/Users/bicute/Desktop/zenn.dev/tier-cursor/docker-compose.prod.yml)이다. - 외부 도메인 `tmaker.sori.studio`는 `frontend` 컨테이너의 `8080` 포트로 리버스 프록시하고, `/api`, `/uploads`, `/health`는 프런트 Nginx가 내부 `backend:5179`로 전달한다. - 운영 볼륨은 MariaDB 데이터, 업로드 파일, 세션 파일을 각각 분리해 유지한다. +- MariaDB healthcheck는 NAS 첫 기동 지연을 고려해 `root` 기준 ping과 긴 `start_period/retries`를 사용한다. ## NAS 배포 메모 - 현재 구조는 MariaDB/MySQL 계열이므로 NAS에 MariaDB를 올리면 phpMyAdmin 또는 Adminer로 직접 데이터 확인이 가능하다. diff --git a/docs/todo.md b/docs/todo.md index 34eab14..b70d7b5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -5,6 +5,7 @@ - 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다. - 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다. - 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다. +- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. diff --git a/docs/update.md b/docs/update.md index e206fe9..224cf8a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,29 @@ # 업데이트 로그 +## 2026-03-26 v0.1.34 +- **파비콘 정적 요청 제거**: 운영 환경에서 `/favicon.svg`가 `403`으로 막히는 경우를 피하기 위해, 별도 파일 대신 `index.html` 인라인 데이터 URL 파비콘으로 전환 +- **관리자 기본 아이템 다중 업로드 추가**: 게임 관리 화면에서 기본 아이템을 여러 장 드래그 앤 드롭 또는 다중 파일 선택으로 한 번에 추가할 수 있도록 변경하고, 기본 라벨은 파일명 기준으로 자동 생성 + +## 2026-03-26 v0.1.29 +- **NAS 로그인 유지 수정**: 프런트 Nginx가 백엔드에 전달하는 `X-Forwarded-Proto`를 `https`로 고정하고 Express 세션의 프록시 인지를 명시해, NAS HTTPS 리버스 프록시 뒤에서도 `secure` 세션 쿠키가 정상 발급되도록 조정 +- **운영 템플릿 복구**: 실수로 빠질 수 있는 `.env.production.example`를 다시 포함하고, NAS 재배포 시 최신 프런트 이미지를 다시 빌드하도록 문서 보강 + +## 2026-03-26 v0.1.30 +- **[NAS] /api 상대경로 호출**: 운영(`import.meta.env.PROD`)에서는 `http://localhost:...` 같은 다른 origin으로 API를 호출하지 않도록, `frontend/src/lib/runtime.js`에서 `/api` 호출을 상대경로로 고정해 세션 쿠키가 정상 저장되도록 수정 + +## 2026-03-26 v0.1.31 +- **[NAS] 세션 쿠키 발급 강제**: 백엔드 인증 라우트에서 `req.session.save()`를 명시 호출해 응답 전에 세션을 저장하고 `Set-Cookie`가 확실히 내려오도록 보강 + +## 2026-03-26 v0.1.32 +- **[NAS] 인증 디버그 로그 추가**: `auth/login`에서 `req.session.save` 성공/실패와 `auth/me`에서 세션 존재 여부를 콘솔 로그로 남겨 세션 쿠키 발급 문제를 빠르게 진단 + +## 2026-03-26 v0.1.33 +- **[NAS] 요청 프로토콜 디버그**: `auth/login`/`auth/me`에서 `req.secure`, `req.protocol`, `x-forwarded-proto` 값을 로그로 출력해 프록시/HTTPS 판단 문제를 확인 + +## 2026-03-26 v0.1.28 +- **MariaDB healthcheck 완화**: UGREEN NAS 첫 초기화 시간이 길어도 `unhealthy`로 오판하지 않도록 프로덕션 컴포즈의 DB healthcheck를 `root` 기준과 더 긴 `start_period/retries`로 조정 +- **NAS 장애 대응 문서화**: `ready for connections` 이후에도 `unhealthy`가 뜨는 경우의 재기동 절차를 배포 가이드에 추가 + ## 2026-03-26 v0.1.27 - **UGREEN NAS 배포 파일 추가**: `backend`, `frontend`용 Dockerfile과 프런트 Nginx 프록시 설정, 프로덕션 전용 `docker-compose.prod.yml` 추가 - **운영 환경 예시 추가**: `.env.production.example`로 MariaDB/세션 시크릿 환경변수 템플릿 제공 diff --git a/frontend/index.html b/frontend/index.html index 2b56de4..5709b77 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,10 @@ - + frontend diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 0e08ffc..98105d3 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -31,10 +31,10 @@ const success = ref('') const newGameId = ref('') const newGameName = ref('') -const uploadLabel = ref('') -const uploadFile = ref(null) +const uploadFiles = ref([]) const thumbFile = ref(null) -const itemPreviewUrl = ref('') +const itemPreviewUrls = ref([]) +const isItemDragOver = ref(false) const thumbPreviewUrl = ref('') const itemFileInput = ref(null) const thumbFileInput = ref(null) @@ -43,7 +43,7 @@ const featuredSortable = ref(null) const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value) -const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) +const canAddItem = computed(() => uploadFiles.value.length > 0 && !!selectedGameId.value) const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value))) const featuredGames = computed(() => featuredGameIds.value @@ -151,8 +151,7 @@ async function refreshUsers() { } function resetUploadState() { - uploadLabel.value = '' - uploadFile.value = null + uploadFiles.value = [] thumbFile.value = null resetFileInput('item') resetFileInput('thumb') @@ -171,9 +170,9 @@ function setGameMode(mode) { } function clearPreviewUrl(type) { - if (type === 'item' && itemPreviewUrl.value) { - URL.revokeObjectURL(itemPreviewUrl.value) - itemPreviewUrl.value = '' + if (type === 'item' && itemPreviewUrls.value.length) { + itemPreviewUrls.value.forEach((url) => URL.revokeObjectURL(url)) + itemPreviewUrls.value = [] } if (type === 'thumb' && thumbPreviewUrl.value) { URL.revokeObjectURL(thumbPreviewUrl.value) @@ -232,9 +231,46 @@ function onThumb(event) { } function onFile(event) { - uploadFile.value = event.target.files && event.target.files[0] ? event.target.files[0] : null + handleItemFiles(event.target.files) +} + +function handleItemFiles(fileList) { + const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/')) + uploadFiles.value = files clearPreviewUrl('item') - if (uploadFile.value) itemPreviewUrl.value = URL.createObjectURL(uploadFile.value) + if (!files.length) return + itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file)) + resetFileInput('item') +} + +function openItemFilePicker() { + itemFileInput.value?.click() +} + +function clearItemFiles() { + uploadFiles.value = [] + clearPreviewUrl('item') + resetFileInput('item') +} + +function onItemDragEnter(event) { + event.preventDefault() + isItemDragOver.value = true +} + +function onItemDragOver(event) { + event.preventDefault() + isItemDragOver.value = true +} + +function onItemDragLeave(event) { + if (event.currentTarget === event.target) isItemDragOver.value = false +} + +function onItemDrop(event) { + event.preventDefault() + isItemDragOver.value = false + handleItemFiles(event.dataTransfer?.files) } async function uploadThumbnail() { @@ -267,15 +303,15 @@ async function uploadThumbnail() { async function uploadItem() { resetMessages() - if (!uploadFile.value || !selectedGameId.value) { + if (!uploadFiles.value.length || !selectedGameId.value) { error.value = '아이템 파일을 선택해주세요.' return } try { const fd = new FormData() - fd.append('label', uploadLabel.value || 'item') - fd.append('image', uploadFile.value) + uploadFiles.value.forEach((file) => fd.append('images', file)) + const uploadCount = uploadFiles.value.length const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), { method: 'POST', credentials: 'include', @@ -285,7 +321,7 @@ async function uploadItem() { resetUploadState() await loadGame() - success.value = '게임 기본 아이템이 추가됐어요.' + success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.` } catch (e) { error.value = '아이템 추가 실패(관리자 권한/파일 크기 확인)' } @@ -631,16 +667,36 @@ async function saveFeaturedOrder() {
기본 아이템 추가
- - - + +
+
이미지를 드래그해서 기본 아이템으로 추가
+
여러 파일을 한 번에 올릴 수 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
+
+ + +
+
+
-
- item preview -
이미지를 선택해주세요
+
+
+ +
+
+
선택한 기본 아이템 미리보기가 여기에 표시됩니다.
+
+ {{ uploadFiles.length ? `선택된 파일 ${uploadFiles.length}개` : '아직 선택된 파일이 없어요.' }}
-
{{ uploadLabel || '아이템 이름 미리보기' }}
@@ -1006,6 +1062,17 @@ async function saveFeaturedOrder() { .inputFile--tight { margin-top: 0; } +.srOnlyInput { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} .btn { font-size: 12px; margin-top: 12px; @@ -1090,20 +1157,53 @@ async function saveFeaturedOrder() { gap: 12px; align-items: start; } +.dropZone { + padding: 18px; + border-radius: 16px; + border: 1px dashed rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.03); + transition: + border-color 0.16s ease, + background 0.16s ease, + transform 0.16s ease; +} +.dropZone--active { + border-color: rgba(96, 165, 250, 0.56); + background: rgba(96, 165, 250, 0.08); + transform: translateY(-1px); +} +.dropZone__title { + font-weight: 900; +} +.dropZone__desc { + margin-top: 8px; + font-size: 13px; + opacity: 0.74; + line-height: 1.5; +} +.dropZone__actions { + margin-top: 12px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} .itemPreviewCard { padding: 10px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.04); } +.itemPreviewGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} .itemPreviewFrame { - width: min(100%, 192px); aspect-ratio: 1 / 1; border-radius: 12px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(0, 0, 0, 0.18); - margin: 0 auto; } .itemPreviewImage { width: 100%; @@ -1111,12 +1211,13 @@ async function saveFeaturedOrder() { object-fit: cover; } .itemPreviewEmpty { - width: 100%; - height: 100%; + min-height: 192px; display: grid; place-items: center; color: rgba(255, 255, 255, 0.62); font-size: 13px; + text-align: center; + line-height: 1.5; } .thumbGrid { margin-top: 12px; @@ -1282,7 +1383,7 @@ async function saveFeaturedOrder() { grid-template-columns: 1fr; } .itemPreviewCard { - max-width: 192px; + max-width: none; } } @media (max-width: 640px) {