Add external server stats API
This commit is contained in:
@@ -8,6 +8,7 @@ DB_USER=tier_cursor
|
|||||||
DB_PASSWORD=tier_cursor1234
|
DB_PASSWORD=tier_cursor1234
|
||||||
DB_NAME=tier_cursor
|
DB_NAME=tier_cursor
|
||||||
SESSION_SECRET=tmaker-local-session-secret
|
SESSION_SECRET=tmaker-local-session-secret
|
||||||
|
SERVER_STATS_TOKEN=tmaker-local-dashboard-token
|
||||||
APP_ORIGIN=http://localhost:5173
|
APP_ORIGIN=http://localhost:5173
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ MARIADB_DATABASE=tier_db
|
|||||||
MARIADB_USER=zenn
|
MARIADB_USER=zenn
|
||||||
MARIADB_PASSWORD=wps!xldj180204
|
MARIADB_PASSWORD=wps!xldj180204
|
||||||
SESSION_SECRET=291fbf7d5d112786a84d8ff62dd2839ce343a820c47d88da746ec78e11d640b3
|
SESSION_SECRET=291fbf7d5d112786a84d8ff62dd2839ce343a820c47d88da746ec78e11d640b3
|
||||||
|
SERVER_STATS_TOKEN=d36841db050c4050bfb875045516868c0097db5c6a176989c6d9eafc93b990be
|
||||||
APP_ORIGIN=https://tmaker.sori.studio
|
APP_ORIGIN=https://tmaker.sori.studio
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
@@ -11,3 +12,5 @@ SMTP_USER=zenn.sori.studio@gmail.com
|
|||||||
SMTP_PASS=kcasoehxcspqdoxz
|
SMTP_PASS=kcasoehxcspqdoxz
|
||||||
SMTP_FROM="Tier Maker <zenn.sori.studio@gmail.com>"
|
SMTP_FROM="Tier Maker <zenn.sori.studio@gmail.com>"
|
||||||
NICKNAME_CHANGE_INTERVAL_DAYS=14
|
NICKNAME_CHANGE_INTERVAL_DAYS=14
|
||||||
|
# SKIP_EMAIL_VERIFICATION=false
|
||||||
|
SERVER_STATS_TOKEN=d36841db050c4050bfb875045516868c0097db5c6a176989c6d9eafc93b990be
|
||||||
@@ -3,6 +3,7 @@ MARIADB_DATABASE=tier_cursor
|
|||||||
MARIADB_USER=tier_cursor
|
MARIADB_USER=tier_cursor
|
||||||
MARIADB_PASSWORD=tier_cursor_production_password
|
MARIADB_PASSWORD=tier_cursor_production_password
|
||||||
SESSION_SECRET=tmaker-production-session-secret
|
SESSION_SECRET=tmaker-production-session-secret
|
||||||
|
SERVER_STATS_TOKEN=change-this-long-random-dashboard-token
|
||||||
APP_ORIGIN=https://tmaker.sori.studio
|
APP_ORIGIN=https://tmaker.sori.studio
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const express = require('express')
|
|||||||
const cors = require('cors')
|
const cors = require('cors')
|
||||||
const session = require('express-session')
|
const session = require('express-session')
|
||||||
const FileStoreFactory = require('session-file-store')
|
const FileStoreFactory = require('session-file-store')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
function loadEnvFileIfExists(fileName) {
|
function loadEnvFileIfExists(fileName) {
|
||||||
const envPath = path.join(__dirname, '..', fileName)
|
const envPath = path.join(__dirname, '..', fileName)
|
||||||
@@ -23,7 +24,7 @@ if (nodeEnv === 'production') {
|
|||||||
if (!loadedLocal) loadEnvFileIfExists('.env.production')
|
if (!loadedLocal) loadEnvFileIfExists('.env.production')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ensureData } = require('./src/db')
|
const { ensureData, getServerStats } = require('./src/db')
|
||||||
const authRoutes = require('./src/routes/auth')
|
const authRoutes = require('./src/routes/auth')
|
||||||
const topicsRoutes = require('./src/routes/topics')
|
const topicsRoutes = require('./src/routes/topics')
|
||||||
const tierListsRoutes = require('./src/routes/tierlists')
|
const tierListsRoutes = require('./src/routes/tierlists')
|
||||||
@@ -42,9 +43,30 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '')
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map((origin) => origin.trim())
|
.map((origin) => origin.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
const SERVER_STATS_TOKEN = String(process.env.SERVER_STATS_TOKEN || '').trim()
|
||||||
|
|
||||||
const FileStore = FileStoreFactory(session)
|
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) => {
|
;['uploads/avatars', 'uploads/topics', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
|
||||||
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
|
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
|
||||||
})
|
})
|
||||||
@@ -101,6 +123,10 @@ app.use(async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes)
|
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/topics', topicsRoutes)
|
||||||
app.use('/api/tierlists', tierListsRoutes)
|
app.use('/api/tierlists', tierListsRoutes)
|
||||||
app.use('/api/comments', commentsRoutes)
|
app.use('/api/comments', commentsRoutes)
|
||||||
|
|||||||
@@ -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 = '') {
|
async function findTierListById(id, currentUserId = '') {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
@@ -3589,6 +3700,7 @@ module.exports = {
|
|||||||
listUserTierLists,
|
listUserTierLists,
|
||||||
listAdminTierLists,
|
listAdminTierLists,
|
||||||
summarizeAdminTierLists,
|
summarizeAdminTierLists,
|
||||||
|
getServerStats,
|
||||||
findTierListById,
|
findTierListById,
|
||||||
updateAdminTierListMeta,
|
updateAdminTierListMeta,
|
||||||
updateTierListFeaturedStatus,
|
updateTierListFeaturedStatus,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ services:
|
|||||||
DB_PASSWORD: ${MARIADB_PASSWORD}
|
DB_PASSWORD: ${MARIADB_PASSWORD}
|
||||||
DB_NAME: ${MARIADB_DATABASE}
|
DB_NAME: ${MARIADB_DATABASE}
|
||||||
SESSION_SECRET: ${SESSION_SECRET}
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
SERVER_STATS_TOKEN: ${SERVER_STATS_TOKEN}
|
||||||
SESSION_COOKIE_SECURE: "true"
|
SESSION_COOKIE_SECURE: "true"
|
||||||
SESSION_COOKIE_SAME_SITE: "lax"
|
SESSION_COOKIE_SAME_SITE: "lax"
|
||||||
CORS_ORIGINS: https://tmaker.sori.studio
|
CORS_ORIGINS: https://tmaker.sori.studio
|
||||||
|
|||||||
@@ -244,6 +244,10 @@
|
|||||||
- `GET /api/auth/me`
|
- `GET /api/auth/me`
|
||||||
- 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
|
- 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
|
||||||
- `GET /api/auth/meta`
|
- `GET /api/auth/meta`
|
||||||
|
- `GET /api/server-stats`
|
||||||
|
- 외부 대시보드/모니터링 앱에서 주기적으로 서비스 현황을 읽는 API다.
|
||||||
|
- `Authorization: Bearer <SERVER_STATS_TOKEN>` 헤더 또는 `?token=` 쿼리로 인증한다.
|
||||||
|
- 회원 수, 티어표 수, 템플릿 수, 템플릿 요청 상태, 커스텀 아이템 수, 즐겨찾기/댓글/팔로우 수를 반환한다.
|
||||||
- `POST /api/auth/profile`
|
- `POST /api/auth/profile`
|
||||||
- `POST /api/auth/password`
|
- `POST /api/auth/password`
|
||||||
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
|
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
|
||||||
@@ -429,6 +433,7 @@
|
|||||||
- `DB_NAME`: 데이터베이스명
|
- `DB_NAME`: 데이터베이스명
|
||||||
- `PORT`: 서버 포트
|
- `PORT`: 서버 포트
|
||||||
- `SESSION_SECRET`: 세션 서명 키
|
- `SESSION_SECRET`: 세션 서명 키
|
||||||
|
- `SERVER_STATS_TOKEN`: 외부 대시보드용 `GET /api/server-stats` 호출 토큰. 미설정 시 해당 API는 503을 반환한다.
|
||||||
- `CORS_ORIGINS`: 허용할 프런트 도메인 목록(쉼표 구분)
|
- `CORS_ORIGINS`: 허용할 프런트 도메인 목록(쉼표 구분)
|
||||||
- `TRUST_PROXY`: 프록시 홉 수
|
- `TRUST_PROXY`: 프록시 홉 수
|
||||||
- `SESSION_COOKIE_SECURE`: `true`면 HTTPS 전용 쿠키
|
- `SESSION_COOKIE_SECURE`: `true`면 HTTPS 전용 쿠키
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ cp .env.production.example .env.production
|
|||||||
- `MARIADB_ROOT_PASSWORD`
|
- `MARIADB_ROOT_PASSWORD`
|
||||||
- `MARIADB_PASSWORD`
|
- `MARIADB_PASSWORD`
|
||||||
- `SESSION_SECRET`
|
- `SESSION_SECRET`
|
||||||
|
- `SERVER_STATS_TOKEN`
|
||||||
|
|
||||||
예시:
|
예시:
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ MARIADB_DATABASE=tier_cursor
|
|||||||
MARIADB_USER=tier_cursor
|
MARIADB_USER=tier_cursor
|
||||||
MARIADB_PASSWORD=very-strong-app-password
|
MARIADB_PASSWORD=very-strong-app-password
|
||||||
SESSION_SECRET=very-strong-random-session-secret
|
SESSION_SECRET=very-strong-random-session-secret
|
||||||
|
SERVER_STATS_TOKEN=very-strong-dashboard-token
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 컨테이너 실행
|
## 3. 컨테이너 실행
|
||||||
@@ -87,7 +89,7 @@ docker compose --env-file .env.production -f docker-compose.prod.yml --profile a
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull origin main
|
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`를 유지한다.
|
- Dockerfile, Nginx 설정, 프런트 소스, 백엔드 소스가 바뀐 경우에는 `--build`를 유지한다.
|
||||||
@@ -170,6 +172,8 @@ docker compose --env-file .env.production -f docker-compose.prod.yml up -d --bui
|
|||||||
## 7. 점검 포인트
|
## 7. 점검 포인트
|
||||||
- 메인 접속: `https://tmaker.sori.studio`
|
- 메인 접속: `https://tmaker.sori.studio`
|
||||||
- 헬스체크: `https://tmaker.sori.studio/health`
|
- 헬스체크: `https://tmaker.sori.studio/health`
|
||||||
|
- 외부 대시보드 통계:
|
||||||
|
`curl -H "Authorization: Bearer $SERVER_STATS_TOKEN" https://tmaker.sori.studio/api/server-stats`
|
||||||
- 로그인 후 새로고침 또는 `내 티어표` 진입 시 세션이 유지되는지 확인
|
- 로그인 후 새로고침 또는 `내 티어표` 진입 시 세션이 유지되는지 확인
|
||||||
- 관리자 로그인 후:
|
- 관리자 로그인 후:
|
||||||
- 게임 생성
|
- 게임 생성
|
||||||
|
|||||||
Reference in New Issue
Block a user