/** * 관리자 전용 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"; } /** * POST /api/admin/modpacks - 모드팩 업로드 * multipart/form-data: file (.mrpack), changelog (옵션) */ router.post( "/modpacks", express.raw({ type: "multipart/form-data", limit: "500mb" }), async (req, res) => { try { const boundary = req.headers["content-type"]?.split("boundary=")[1]; if (!boundary) { return res.status(400).json({ error: "Invalid content-type" }); } const body = req.body.toString("binary"); const parts = body.split(`--${boundary}`); let fileData = null; let fileName = ""; let changelog = ""; // multipart 파싱 for (const part of parts) { if (part.includes('name="file"')) { const match = part.match(/filename="([^"]+)"/); if (match) fileName = match[1]; const dataStart = part.indexOf("\r\n\r\n") + 4; const dataEnd = part.lastIndexOf("\r\n"); fileData = Buffer.from(part.slice(dataStart, dataEnd), "binary"); } else if (part.includes('name="changelog"')) { const dataStart = part.indexOf("\r\n\r\n") + 4; const dataEnd = part.lastIndexOf("\r\n"); changelog = part.slice(dataStart, dataEnd); } } if (!fileData || !fileName.endsWith(".mrpack")) { return res.status(400).json({ error: ".mrpack 파일이 필요합니다" }); } // .mrpack은 ZIP 파일. modrinth.index.json 파싱 const { unzipSync } = await import("zlib"); const AdmZip = (await import("adm-zip")).default; const zip = new AdmZip(fileData); const indexEntry = zip.getEntry("modrinth.index.json"); if (!indexEntry) { return res .status(400) .json({ error: "modrinth.index.json을 찾을 수 없습니다" }); } const indexJson = JSON.parse(indexEntry.getData().toString("utf8")); const modpackName = indexJson.name || "Unknown Modpack"; const modpackVersion = indexJson.versionId || "1.0.0"; const minecraftVersion = indexJson.dependencies?.minecraft || null; const modLoader = Object.keys(indexJson.dependencies || {}).find( (k) => k !== "minecraft" ) || null; // contents 파싱 (mods, resourcepacks, shaderpacks) const contents = { mods: [], resourcepacks: [], shaderpacks: [] }; for (const file of indexJson.files || []) { const path = file.path; // 파일명에서 이름과 버전 추출 시도 (예: Create-0.5.1.jar) const fileName = path .split("/") .pop() .replace(/\.(jar|zip)$/, ""); const match = fileName.match(/^(.+?)[-_](\d+[\d.]+\d*)$/); const name = match ? match[1].replace(/[-_]/g, " ") : fileName; const version = match ? match[2] : ""; if (path.startsWith("mods/")) { contents.mods.push({ name, version }); } else if (path.startsWith("resourcepacks/")) { contents.resourcepacks.push({ name, version }); } else if (path.startsWith("shaderpacks/")) { contents.shaderpacks.push({ name, version }); } } // S3에 업로드 const s3Key = `modpacks/${modpackName.replace( /[^a-zA-Z0-9가-힣]/g, "_" )}/${modpackVersion}.mrpack`; const { uploadToS3 } = await import("../lib/s3.js"); await uploadToS3("minecraft", s3Key, fileData, "application/zip"); // DB에 저장 const [result] = await pool.query( `INSERT INTO modpacks (name, version, minecraft_version, mod_loader, changelog, file_key, file_size, contents_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE changelog = VALUES(changelog), file_key = VALUES(file_key), file_size = VALUES(file_size), contents_json = VALUES(contents_json), updated_at = CURRENT_TIMESTAMP`, [ modpackName, modpackVersion, minecraftVersion, modLoader, changelog, s3Key, fileData.length, JSON.stringify(contents), ] ); console.log( `[Admin] 모드팩 업로드 완료: ${modpackName} v${modpackVersion}` ); res.json({ success: true, id: result.insertId, name: modpackName, version: modpackVersion, size: formatFileSize(fileData.length), }); } catch (error) { console.error("[Admin] 모드팩 업로드 오류:", error); res.status(500).json({ error: "업로드 실패: " + error.message }); } } ); /** * PUT /api/admin/modpacks/:id - 모드팩 수정 (변경 로그만) */ router.put("/modpacks/:id", async (req, res) => { try { const { id } = req.params; const { changelog } = req.body; const [result] = await pool.query( `UPDATE modpacks SET changelog = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [changelog, id] ); if (result.affectedRows === 0) { return res.status(404).json({ error: "모드팩을 찾을 수 없습니다" }); } console.log(`[Admin] 모드팩 수정 완료: ID ${id}`); res.json({ success: true }); } catch (error) { console.error("[Admin] 모드팩 수정 오류:", error); res.status(500).json({ error: "수정 실패" }); } }); /** * DELETE /api/admin/modpacks/:id - 모드팩 삭제 */ router.delete("/modpacks/:id", async (req, res) => { try { const { id } = req.params; // DB에서 파일 정보 조회 const [rows] = await pool.query(`SELECT * FROM modpacks WHERE id = ?`, [ id, ]); if (rows.length === 0) { return res.status(404).json({ error: "모드팩을 찾을 수 없습니다" }); } const modpack = rows[0]; // S3에서 삭제 (deleteFromS3 함수 필요 - 일단 생략, 나중에 추가) // TODO: S3 파일 삭제 구현 // DB에서 삭제 await pool.query(`DELETE FROM modpacks WHERE id = ?`, [id]); console.log( `[Admin] 모드팩 삭제 완료: ${modpack.name} v${modpack.version}` ); res.json({ success: true }); } catch (error) { console.error("[Admin] 모드팩 삭제 오류:", error); res.status(500).json({ error: "삭제 실패" }); } }); export default router;