diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index fcc4967..4721d29 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -26,6 +26,20 @@ const profileSchema = z.object({ removeAvatar: z.union([z.string(), z.undefined()]).optional(), }) +function establishSession(req, user) { + return new Promise((resolve, reject) => { + req.session.regenerate((regenerateError) => { + if (regenerateError) return reject(regenerateError) + req.session.userId = user.id + req.session.isAdmin = !!user.isAdmin + req.session.save((saveError) => { + if (saveError) return reject(saveError) + resolve() + }) + }) + }) +} + async function serializeUser(user) { if (!user) return null const primaryAdmin = await findPrimaryAdminUser() @@ -56,12 +70,12 @@ router.post('/signup', async (req, res) => { const isAdmin = (await countUsers()) === 0 const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin }) - req.session.userId = user.id - req.session.isAdmin = !!user.isAdmin - req.session.save(async (err) => { - if (err) return res.status(500).json({ error: 'session_save_failed' }) + try { + await establishSession(req, user) res.json(await serializeUser(user)) - }) + } catch (err) { + return res.status(500).json({ error: 'session_save_failed' }) + } }) router.post('/login', async (req, res) => { @@ -75,12 +89,12 @@ router.post('/login', async (req, res) => { const ok = await bcrypt.compare(password, user.passwordHash) if (!ok) return res.status(401).json({ error: 'invalid_credentials' }) - req.session.userId = user.id - req.session.isAdmin = !!user.isAdmin - req.session.save(async (err) => { - if (err) return res.status(500).json({ error: 'session_save_failed' }) + try { + await establishSession(req, user) res.json(await serializeUser(user)) - }) + } catch (err) { + return res.status(500).json({ error: 'session_save_failed' }) + } }) router.post('/logout', async (req, res) => { diff --git a/docs/todo.md b/docs/todo.md index a410b93..21a0c80 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,20 +1,10 @@ # 할 일 및 이슈 -## 즉시 확인 필요 -- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다. -- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. -- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. -- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다. -- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브 기준을 정한다. - -## 배포 전 작업 -- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. -- MariaDB 접속 정보 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`를 설정한다. -- HTTPS를 사용할 경우 `SESSION_COOKIE_SECURE=true`로 설정하고 리버스 프록시 헤더 전달을 확인한다. -- `backend/uploads/`, `backend/.sessions/`, MariaDB 백업 정책을 정한다. -- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다. - ## 중기 개선 - 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다. - 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다. - 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다. +- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다. +- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다. +- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다. +- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다. diff --git a/docs/update.md b/docs/update.md index 9115cec..850ba61 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-01 v1.3.25 +- todo 문서에서는 운영 정책/배포 체크 성격 항목을 우선 제거하고, 제품/보안 후속 작업 중심으로 다시 정리함. +- 관리자 게임 관리는 우측 셀렉트 박스 대신 검색 가능한 리스트와 최신순/오래된순 정렬로 바꿔, 게임 수가 많아져도 실제로 선택 가능한 구조로 개선함. +- 로그인과 회원가입은 기존 세션을 그대로 덮어쓰지 않고 세션을 재생성한 뒤 사용자 정보를 저장하도록 바꿔, 세션 고정 공격 방어를 보강함. + ## 2026-04-01 v1.3.24 - 게임 선택 후 보이는 공개 티어표 목록 그리드도 auto-fit 최대폭 방식 대신 4/3/2/1열 고정 반응형 규칙으로 바꿔, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 넘어가며 공백이 크게 남던 문제를 줄임. diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index d9d42a4..d7f1c85 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -22,6 +22,8 @@ const games = ref([]) const selectedGameId = ref('') const selectedGame = ref(null) const featuredGameIds = ref([]) +const gameAdminQuery = ref('') +const gameAdminSort = ref('recent') const customItems = ref([]) const customItemQuery = ref('') @@ -104,6 +106,19 @@ const featuredGames = computed(() => .filter(Boolean) ) const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id))) +const filteredAdminGames = computed(() => { + const query = gameAdminQuery.value.trim().toLowerCase() + const list = games.value.filter((game) => { + if (!query) return true + const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase() + return haystack.includes(query) + }) + + return list.slice().sort((a, b) => { + if (gameAdminSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0) + return Number(b.createdAt || 0) - Number(a.createdAt || 0) + }) +}) const importModalItemCount = computed(() => importModalItems.value.length) const activeTabTitle = computed(() => { if (activeTab.value === 'featured') return '목록 관리' @@ -378,6 +393,12 @@ async function handleSelectedGameChange(event) { await loadGame() } +async function selectAdminGame(gameId) { + if (!gameId || selectedGameId.value === gameId) return + selectedGameId.value = gameId + await loadGame() +} + async function refreshGames() { try { const data = await api.listGames() @@ -2030,10 +2051,25 @@ async function saveFeaturedOrder() {
Game
- + +
+ +
검색 결과가 없어요.
+
선택된 게임 ID: {{ selectedGameId }}
@@ -2370,6 +2406,39 @@ async function saveFeaturedOrder() { font-weight: 800; color: rgba(255, 255, 255, 0.84); } +.adminGamePicker { + display: grid; + gap: 8px; + max-height: 320px; + overflow: auto; + padding-right: 4px; +} +.adminGamePicker__item { + display: grid; + gap: 2px; + padding: 11px 12px; + text-align: left; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.9); + cursor: pointer; +} +.adminGamePicker__item--active { + border-color: rgba(77, 127, 233, 0.58); + background: rgba(77, 127, 233, 0.12); +} +.adminGamePicker__name { + font-size: 13px; + font-weight: 800; +} +.adminGamePicker__meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.56); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} .sidebarStat { display: grid; gap: 4px;