diff --git a/.env.local.example b/.env.local.example index 9385743..da2b9e0 100644 --- a/.env.local.example +++ b/.env.local.example @@ -8,6 +8,7 @@ DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor SESSION_SECRET=tmaker-local-session-secret +SERVER_STATS_TOKEN=tmaker-local-dashboard-token APP_ORIGIN=http://localhost:5173 SMTP_HOST=smtp.gmail.com SMTP_PORT=465 diff --git a/.env.production b/.env.production index 6f3826b..1de0f82 100644 --- a/.env.production +++ b/.env.production @@ -3,6 +3,7 @@ MARIADB_DATABASE=tier_db MARIADB_USER=zenn MARIADB_PASSWORD=wps!xldj180204 SESSION_SECRET=291fbf7d5d112786a84d8ff62dd2839ce343a820c47d88da746ec78e11d640b3 +SERVER_STATS_TOKEN=d36841db050c4050bfb875045516868c0097db5c6a176989c6d9eafc93b990be APP_ORIGIN=https://tmaker.sori.studio SMTP_HOST=smtp.gmail.com SMTP_PORT=465 @@ -11,3 +12,5 @@ SMTP_USER=zenn.sori.studio@gmail.com SMTP_PASS=kcasoehxcspqdoxz SMTP_FROM="Tier Maker " NICKNAME_CHANGE_INTERVAL_DAYS=14 +# SKIP_EMAIL_VERIFICATION=false +SERVER_STATS_TOKEN=d36841db050c4050bfb875045516868c0097db5c6a176989c6d9eafc93b990be \ No newline at end of file diff --git a/.env.production.example b/.env.production.example index fb3e958..a4d39ca 100644 --- a/.env.production.example +++ b/.env.production.example @@ -3,6 +3,7 @@ MARIADB_DATABASE=tier_cursor MARIADB_USER=tier_cursor MARIADB_PASSWORD=tier_cursor_production_password SESSION_SECRET=tmaker-production-session-secret +SERVER_STATS_TOKEN=change-this-long-random-dashboard-token APP_ORIGIN=https://tmaker.sori.studio SMTP_HOST=smtp.gmail.com SMTP_PORT=465 diff --git a/backend/index.js b/backend/index.js index 1802354..dc96773 100644 --- a/backend/index.js +++ b/backend/index.js @@ -5,6 +5,7 @@ const express = require('express') const cors = require('cors') const session = require('express-session') const FileStoreFactory = require('session-file-store') +const crypto = require('crypto') function loadEnvFileIfExists(fileName) { const envPath = path.join(__dirname, '..', fileName) @@ -23,7 +24,7 @@ if (nodeEnv === 'production') { if (!loadedLocal) loadEnvFileIfExists('.env.production') } -const { ensureData } = require('./src/db') +const { ensureData, getServerStats } = require('./src/db') const authRoutes = require('./src/routes/auth') const topicsRoutes = require('./src/routes/topics') const tierListsRoutes = require('./src/routes/tierlists') @@ -42,9 +43,30 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '') .split(',') .map((origin) => origin.trim()) .filter(Boolean) +const SERVER_STATS_TOKEN = String(process.env.SERVER_STATS_TOKEN || '').trim() const FileStore = FileStoreFactory(session) +function getBearerToken(req) { + const authorization = String(req.headers.authorization || '') + const match = authorization.match(/^Bearer\s+(.+)$/i) + if (match) return match[1].trim() + return String(req.query.token || '').trim() +} + +function isEqualSecret(a, b) { + const left = Buffer.from(String(a || '')) + const right = Buffer.from(String(b || '')) + return left.length === right.length && crypto.timingSafeEqual(left, right) +} + +function requireServerStatsToken(req, res, next) { + if (!SERVER_STATS_TOKEN) return res.status(503).json({ error: 'server_stats_token_not_configured' }) + const token = getBearerToken(req) + if (!token || !isEqualSecret(token, SERVER_STATS_TOKEN)) return res.status(401).json({ error: 'unauthorized' }) + next() +} + ;['uploads/avatars', 'uploads/topics', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => { fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true }) }) @@ -101,6 +123,10 @@ app.use(async (req, res, next) => { }) app.use('/api/auth', authRoutes) +app.get('/api/server-stats', requireServerStatsToken, async (req, res) => { + const stats = await getServerStats() + res.json(stats) +}) app.use('/api/topics', topicsRoutes) app.use('/api/tierlists', tierListsRoutes) app.use('/api/comments', commentsRoutes) diff --git a/backend/src/db.js b/backend/src/db.js index e5ac7fe..831b761 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -3140,6 +3140,117 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavori } } +async function getServerStats() { + const [ + userRows, + tierListRows, + topicRows, + templateRequestRows, + customItemRows, + topicItemRows, + commentRows, + favoriteRows, + followRows, + ] = await Promise.all([ + query( + `SELECT + COUNT(*) AS total_count, + COALESCE(SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END), 0) AS admin_count, + COALESCE(SUM(CASE WHEN email_verified = 1 THEN 1 ELSE 0 END), 0) AS verified_count, + COALESCE(SUM(CASE WHEN last_login_at > 0 THEN 1 ELSE 0 END), 0) AS logged_in_count + FROM users` + ), + query( + `SELECT + COUNT(*) AS total_count, + COALESCE(SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END), 0) AS public_count, + COALESCE(SUM(CASE WHEN is_featured = 1 THEN 1 ELSE 0 END), 0) AS featured_count + FROM tierlists` + ), + query( + `SELECT + COUNT(*) AS total_count, + COALESCE(SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END), 0) AS public_count + FROM topics + WHERE id <> ?`, + [FREEFORM_TOPIC_ID] + ), + query( + `SELECT + COUNT(*) AS total_count, + COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_count, + COALESCE(SUM(CASE WHEN status = 'reviewing' THEN 1 ELSE 0 END), 0) AS reviewing_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count, + COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count, + COALESCE(SUM(CASE WHEN request_type = 'create' THEN 1 ELSE 0 END), 0) AS create_count, + COALESCE(SUM(CASE WHEN request_type = 'update' THEN 1 ELSE 0 END), 0) AS update_count + FROM template_requests` + ), + query('SELECT COUNT(*) AS total_count FROM custom_items'), + query('SELECT COUNT(*) AS total_count FROM topic_items'), + query('SELECT COUNT(*) AS total_count FROM tierlist_comments'), + query('SELECT COUNT(*) AS total_count FROM favorite_tierlists'), + query('SELECT COUNT(*) AS total_count FROM user_follows'), + ]) + + const users = userRows[0] || {} + const tierLists = tierListRows[0] || {} + const topics = topicRows[0] || {} + const templateRequests = templateRequestRows[0] || {} + const customItems = customItemRows[0] || {} + const topicItems = topicItemRows[0] || {} + const comments = commentRows[0] || {} + const favorites = favoriteRows[0] || {} + const follows = followRows[0] || {} + const tierListTotal = Number(tierLists.total_count || 0) + const tierListPublicCount = Number(tierLists.public_count || 0) + const topicTotal = Number(topics.total_count || 0) + const topicPublicCount = Number(topics.public_count || 0) + const userCustomItemCount = Number(customItems.total_count || 0) + const templateItemCount = Number(topicItems.total_count || 0) + + return { + ok: true, + generatedAt: now(), + users: { + total: Number(users.total_count || 0), + admins: Number(users.admin_count || 0), + verified: Number(users.verified_count || 0), + loggedInAtLeastOnce: Number(users.logged_in_count || 0), + }, + tierLists: { + total: tierListTotal, + publicCount: tierListPublicCount, + privateCount: Math.max(0, tierListTotal - tierListPublicCount), + featuredCount: Number(tierLists.featured_count || 0), + favoriteCount: Number(favorites.total_count || 0), + commentCount: Number(comments.total_count || 0), + }, + topics: { + total: topicTotal, + publicCount: topicPublicCount, + privateCount: Math.max(0, topicTotal - topicPublicCount), + }, + templateRequests: { + total: Number(templateRequests.total_count || 0), + pending: Number(templateRequests.pending_count || 0), + reviewing: Number(templateRequests.reviewing_count || 0), + completed: Number(templateRequests.completed_count || 0), + rejected: Number(templateRequests.rejected_count || 0), + create: Number(templateRequests.create_count || 0), + update: Number(templateRequests.update_count || 0), + }, + customItems: { + total: userCustomItemCount + templateItemCount, + userUploaded: userCustomItemCount, + template: templateItemCount, + }, + social: { + follows: Number(follows.total_count || 0), + }, + } +} + async function findTierListById(id, currentUserId = '') { const rows = await query( ` @@ -3589,6 +3700,7 @@ module.exports = { listUserTierLists, listAdminTierLists, summarizeAdminTierLists, + getServerStats, findTierListById, updateAdminTierListMeta, updateTierListFeaturedStatus, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e1bbe8a..bd87bd1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -37,6 +37,7 @@ services: DB_PASSWORD: ${MARIADB_PASSWORD} DB_NAME: ${MARIADB_DATABASE} SESSION_SECRET: ${SESSION_SECRET} + SERVER_STATS_TOKEN: ${SERVER_STATS_TOKEN} SESSION_COOKIE_SECURE: "true" SESSION_COOKIE_SAME_SITE: "lax" CORS_ORIGINS: https://tmaker.sori.studio diff --git a/docs/spec.md b/docs/spec.md index 42350fc..f029aaa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -244,6 +244,10 @@ - `GET /api/auth/me` - 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다. - `GET /api/auth/meta` + - `GET /api/server-stats` + - 외부 대시보드/모니터링 앱에서 주기적으로 서비스 현황을 읽는 API다. + - `Authorization: Bearer ` 헤더 또는 `?token=` 쿼리로 인증한다. + - 회원 수, 티어표 수, 템플릿 수, 템플릿 요청 상태, 커스텀 아이템 수, 즐겨찾기/댓글/팔로우 수를 반환한다. - `POST /api/auth/profile` - `POST /api/auth/password` - 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다. @@ -429,6 +433,7 @@ - `DB_NAME`: 데이터베이스명 - `PORT`: 서버 포트 - `SESSION_SECRET`: 세션 서명 키 + - `SERVER_STATS_TOKEN`: 외부 대시보드용 `GET /api/server-stats` 호출 토큰. 미설정 시 해당 API는 503을 반환한다. - `CORS_ORIGINS`: 허용할 프런트 도메인 목록(쉼표 구분) - `TRUST_PROXY`: 프록시 홉 수 - `SESSION_COOKIE_SECURE`: `true`면 HTTPS 전용 쿠키 diff --git a/docs/ugreen-nas-deploy.md b/docs/ugreen-nas-deploy.md index 84f15dd..5ec73a7 100644 --- a/docs/ugreen-nas-deploy.md +++ b/docs/ugreen-nas-deploy.md @@ -54,6 +54,7 @@ cp .env.production.example .env.production - `MARIADB_ROOT_PASSWORD` - `MARIADB_PASSWORD` - `SESSION_SECRET` + - `SERVER_STATS_TOKEN` 예시: @@ -63,6 +64,7 @@ MARIADB_DATABASE=tier_cursor MARIADB_USER=tier_cursor MARIADB_PASSWORD=very-strong-app-password SESSION_SECRET=very-strong-random-session-secret +SERVER_STATS_TOKEN=very-strong-dashboard-token ``` ## 3. 컨테이너 실행 @@ -87,7 +89,7 @@ docker compose --env-file .env.production -f docker-compose.prod.yml --profile a ```bash git pull origin main -docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build +sudo docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build ``` - Dockerfile, Nginx 설정, 프런트 소스, 백엔드 소스가 바뀐 경우에는 `--build`를 유지한다. @@ -170,6 +172,8 @@ docker compose --env-file .env.production -f docker-compose.prod.yml up -d --bui ## 7. 점검 포인트 - 메인 접속: `https://tmaker.sori.studio` - 헬스체크: `https://tmaker.sori.studio/health` +- 외부 대시보드 통계: + `curl -H "Authorization: Bearer $SERVER_STATS_TOKEN" https://tmaker.sori.studio/api/server-stats` - 로그인 후 새로고침 또는 `내 티어표` 진입 시 세션이 유지되는지 확인 - 관리자 로그인 후: - 게임 생성