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 @@
- +