minecraft-web/backend/routes/admin.js
caadiq dd17cb5c5e feat(admin): 화이트리스트 API 연동 및 UI 개선
- 화이트리스트 조회/추가/삭제/토글 API 연동 (Mod WhitelistHandler)
- 화이트리스트 아바타 S3 캐싱 (CachedSkin 컴포넌트)
- 플레이어 아바타 S3 캐싱 연동
- 플레이어 추가 시 즉시 목록 반영
- 토스트 중앙 정렬 (모바일 대응)
- URL 해시로 탭 상태 유지
- 화이트리스트 활성화 상태 정확히 조회 (white-list 값만 체크)
2025-12-23 12:17:58 +09:00

348 lines
9.7 KiB
JavaScript

/**
* 관리자 전용 API 라우트
* - 서버 명령어 실행
* - 인증 + 관리자 권한 필요
*/
import express from "express";
import jwt from "jsonwebtoken";
import { pool } from "../lib/db.js";
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret";
const MOD_API_URL = process.env.MOD_API_URL || "http://minecraft-server:8080";
/**
* JWT 토큰에서 사용자 정보 추출
*/
function getUserFromToken(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return null;
}
try {
const token = authHeader.split(" ")[1];
return jwt.verify(token, JWT_SECRET);
} catch {
return null;
}
}
/**
* 관리자 권한 확인 미들웨어
*/
async function requireAdmin(req, res, next) {
const user = getUserFromToken(req);
if (!user) {
return res.status(401).json({ error: "인증이 필요합니다" });
}
try {
// DB에서 관리자 권한 확인 (MySQL 문법)
const [rows] = await pool.query("SELECT is_admin FROM users WHERE id = ?", [
user.id,
]);
if (rows.length === 0 || !rows[0].is_admin) {
return res.status(403).json({ error: "관리자 권한이 필요합니다" });
}
req.user = user;
next();
} catch (error) {
console.error("[Admin] 권한 확인 오류:", error);
res.status(500).json({ error: "서버 오류" });
}
}
/**
* POST /api/admin/logs/upload - 모드에서 로그 파일 업로드
* (인증 없음 - 내부 네트워크에서만 접근 가능)
*/
router.post(
"/logs/upload",
express.raw({ type: "multipart/form-data", limit: "50mb" }),
async (req, res) => {
try {
const boundary = req.headers["content-type"].split("boundary=")[1];
const body = req.body.toString("binary");
const parts = body.split(`--${boundary}`);
let serverId = "";
let fileType = "dated";
let fileName = "";
let fileData = null;
for (const part of parts) {
if (part.includes('name="serverId"')) {
serverId = part.split("\r\n\r\n")[1]?.trim().split("\r\n")[0] || "";
} else if (part.includes('name="fileType"')) {
fileType =
part.split("\r\n\r\n")[1]?.trim().split("\r\n")[0] || "dated";
} else if (part.includes('name="file"')) {
const match = part.match(/filename="([^"]+)"/);
if (match) fileName = match[1];
const contentStart = part.indexOf("\r\n\r\n") + 4;
const contentEnd = part.lastIndexOf("\r\n");
fileData = Buffer.from(
part.substring(contentStart, contentEnd),
"binary"
);
}
}
if (!serverId || !fileName || !fileData) {
return res.status(400).json({ error: "Missing required fields" });
}
const s3Key = `logs/${serverId}/${fileType}/${fileName}`;
const { uploadToS3 } = await import("../lib/s3.js");
await uploadToS3("minecraft", s3Key, fileData, "application/gzip");
await pool.query(
"INSERT INTO log_files (server_id, file_name, file_type, file_size, s3_key) VALUES (?, ?, ?, ?, ?)",
[serverId, fileName, fileType, fileData.length, s3Key]
);
console.log(`[Admin] 로그 업로드 완료: ${s3Key}`);
res.json({ success: true, s3Key });
} catch (error) {
console.error("[Admin] 로그 업로드 오류:", error);
res.status(500).json({ error: "업로드 실패" });
}
}
);
// 모든 라우트에 관리자 권한 필요
router.use(requireAdmin);
/**
* POST /admin/command - 서버 명령어 실행
*/
router.post("/command", async (req, res) => {
const { command } = req.body;
if (!command || typeof command !== "string" || !command.trim()) {
return res
.status(400)
.json({ success: false, message: "명령어를 입력해주세요" });
}
try {
console.log(`[Admin] ${req.user.email}님이 명령어 실행: ${command}`);
const response = await fetch(`${MOD_API_URL}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: command.trim() }),
});
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 명령어 전송 오류:", error);
res
.status(500)
.json({ success: false, message: "서버에 연결할 수 없습니다" });
}
});
/**
* GET /api/admin/logs - 서버 로그 조회
*/
router.get("/logs", async (req, res) => {
try {
const response = await fetch(`${MOD_API_URL}/logs`);
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 로그 조회 오류:", error);
res.status(500).json({ logs: [], error: "서버에 연결할 수 없습니다" });
}
});
/**
* GET /api/admin/players - 플레이어 목록 조회
*/
router.get("/players", async (req, res) => {
try {
const response = await fetch(`${MOD_API_URL}/players`);
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 플레이어 목록 조회 오류:", error);
res.status(500).json({ players: [], error: "서버에 연결할 수 없습니다" });
}
});
/**
* GET /api/admin/banlist - 밴 목록 조회 (모드 API 프록시)
*/
router.get("/banlist", async (req, res) => {
try {
const response = await fetch(`${MOD_API_URL}/banlist`);
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 밴 목록 조회 오류:", error);
res.status(500).json({ banList: [], error: "서버에 연결할 수 없습니다" });
}
});
/**
* GET /api/admin/whitelist - 화이트리스트 조회 (모드 API 프록시)
*/
router.get("/whitelist", async (req, res) => {
try {
const response = await fetch(`${MOD_API_URL}/whitelist`);
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 화이트리스트 조회 오류:", error);
res
.status(500)
.json({
enabled: false,
players: [],
error: "서버에 연결할 수 없습니다",
});
}
});
/**
* GET /api/admin/logfiles - DB에서 로그 파일 목록 조회
*/
router.get("/logfiles", async (req, res) => {
try {
const { serverId, fileType } = req.query;
let query = "SELECT * FROM log_files";
const params = [];
const conditions = [];
if (serverId) {
conditions.push("server_id = ?");
params.push(serverId);
}
if (fileType) {
conditions.push("file_type = ?");
params.push(fileType);
}
if (conditions.length > 0) {
query += " WHERE " + conditions.join(" AND ");
}
query += " ORDER BY file_name DESC";
const [files] = await pool.query(query, params);
// 서버 ID 목록도 함께 반환
const [servers] = await pool.query(
"SELECT DISTINCT server_id FROM log_files ORDER BY server_id"
);
res.json({
files: files.map((f) => ({
id: f.id,
serverId: f.server_id,
fileName: f.file_name,
fileType: f.file_type,
fileSize: formatFileSize(f.file_size),
s3Key: f.s3_key,
uploadedAt: f.uploaded_at,
})),
servers: servers.map((s) => s.server_id),
});
} catch (error) {
console.error("[Admin] 로그 파일 목록 오류:", error);
res.status(500).json({ files: [], error: "DB 조회 실패" });
}
});
/**
* GET /api/admin/logfile - RustFS에서 로그 파일 다운로드
*/
router.get("/logfile", async (req, res) => {
try {
const fileId = req.query.id;
if (!fileId) {
return res.status(400).json({ error: "File ID required" });
}
// DB에서 파일 정보 조회
const [rows] = await pool.query("SELECT * FROM log_files WHERE id = ?", [
fileId,
]);
if (rows.length === 0) {
return res.status(404).json({ error: "File not found" });
}
const file = rows[0];
// RustFS에서 다운로드
const { downloadFromS3 } = await import("../lib/s3.js");
const buffer = await downloadFromS3("minecraft", file.s3_key);
res.setHeader("Content-Type", "application/gzip");
res.setHeader(
"Content-Disposition",
`attachment; filename="${file.file_name}"`
);
res.send(buffer);
} catch (error) {
console.error("[Admin] 로그 파일 다운로드 오류:", error);
res.status(500).json({ error: "다운로드 실패" });
}
});
/**
* DELETE /api/admin/logfile - 로그 파일 삭제
*/
router.delete("/logfile", async (req, res) => {
try {
const fileId = req.query.id;
if (!fileId) {
return res.status(400).json({ error: "File ID required" });
}
// DB에서 파일 정보 조회
const [rows] = await pool.query("SELECT * FROM log_files WHERE id = ?", [
fileId,
]);
if (rows.length === 0) {
return res.status(404).json({ error: "File not found" });
}
const file = rows[0];
// S3에서 파일 삭제 (선택적 - 실패해도 DB 레코드는 삭제)
try {
// S3 삭제는 복잡하므로 일단 DB만 삭제
} catch (s3Error) {
console.error("[Admin] S3 삭제 실패:", s3Error);
}
// DB에서 레코드 삭제
await pool.query("DELETE FROM log_files WHERE id = ?", [fileId]);
console.log(`[Admin] 로그 파일 삭제: ${file.file_name}`);
res.json({ success: true });
} catch (error) {
console.error("[Admin] 로그 파일 삭제 오류:", error);
res.status(500).json({ error: "삭제 실패" });
}
});
/**
* 파일 크기 포맷
*/
function formatFileSize(bytes) {
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
return bytes + " B";
}
export default router;