2025-12-22 15:37:54 +09:00
|
|
|
/**
|
|
|
|
|
* 관리자 전용 API 라우트
|
|
|
|
|
* - 서버 명령어 실행
|
|
|
|
|
* - 인증 + 관리자 권한 필요
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import express from "express";
|
|
|
|
|
import jwt from "jsonwebtoken";
|
2025-12-24 16:20:36 +09:00
|
|
|
import { pool, loadTranslations } from "../lib/db.js";
|
2025-12-22 15:37:54 +09:00
|
|
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret";
|
2025-12-23 10:07:34 +09:00
|
|
|
const MOD_API_URL = process.env.MOD_API_URL || "http://minecraft-server:8080";
|
2025-12-22 15:37:54 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 {
|
2025-12-23 10:07:34 +09:00
|
|
|
// DB에서 관리자 권한 확인 (MySQL 문법)
|
|
|
|
|
const [rows] = await pool.query("SELECT is_admin FROM users WHERE id = ?", [
|
|
|
|
|
user.id,
|
|
|
|
|
]);
|
2025-12-22 15:37:54 +09:00
|
|
|
|
2025-12-23 10:07:34 +09:00
|
|
|
if (rows.length === 0 || !rows[0].is_admin) {
|
2025-12-22 15:37:54 +09:00
|
|
|
return res.status(403).json({ error: "관리자 권한이 필요합니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.user = user;
|
|
|
|
|
next();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 권한 확인 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "서버 오류" });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 10:07:34 +09:00
|
|
|
/**
|
|
|
|
|
* 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: "업로드 실패" });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-22 15:37:54 +09:00
|
|
|
// 모든 라우트에 관리자 권한 필요
|
|
|
|
|
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: "서버에 연결할 수 없습니다" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-23 10:07:34 +09:00
|
|
|
/**
|
|
|
|
|
* 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: "서버에 연결할 수 없습니다" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-23 12:17:58 +09:00
|
|
|
/**
|
|
|
|
|
* 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);
|
2025-12-23 16:32:06 +09:00
|
|
|
res.status(500).json({
|
|
|
|
|
enabled: false,
|
|
|
|
|
players: [],
|
|
|
|
|
error: "서버에 연결할 수 없습니다",
|
|
|
|
|
});
|
2025-12-23 12:17:58 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-23 10:07:34 +09:00
|
|
|
/**
|
|
|
|
|
* 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";
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 16:32:06 +09:00
|
|
|
/**
|
|
|
|
|
* 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"')) {
|
2025-12-23 21:58:45 +09:00
|
|
|
// 헤더 부분을 UTF-8로 디코딩하여 파일명 추출
|
|
|
|
|
const headerEnd = part.indexOf("\r\n\r\n");
|
|
|
|
|
const headerBinary = part.slice(0, headerEnd);
|
|
|
|
|
const header = Buffer.from(headerBinary, "binary").toString("utf8");
|
|
|
|
|
const match = header.match(/filename="([^"]+)"/);
|
2025-12-23 16:32:06 +09:00
|
|
|
if (match) fileName = match[1];
|
2025-12-23 21:58:45 +09:00
|
|
|
|
|
|
|
|
const dataStart = headerEnd + 4;
|
2025-12-23 16:32:06 +09:00
|
|
|
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");
|
2025-12-23 17:15:32 +09:00
|
|
|
changelog = Buffer.from(
|
|
|
|
|
part.slice(dataStart, dataEnd),
|
|
|
|
|
"binary"
|
|
|
|
|
).toString("utf8");
|
2025-12-23 16:32:06 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2025-12-23 21:58:45 +09:00
|
|
|
// contents 파싱 - Modrinth API로 모드 정보 가져오기
|
2025-12-23 16:32:06 +09:00
|
|
|
const contents = { mods: [], resourcepacks: [], shaderpacks: [] };
|
2025-12-23 21:58:45 +09:00
|
|
|
|
|
|
|
|
// 각 카테고리별 파일 정보 수집 (sha1 해시 포함)
|
|
|
|
|
const filesByCategory = { mods: [], resourcepacks: [], shaderpacks: [] };
|
2025-12-23 16:32:06 +09:00
|
|
|
for (const file of indexJson.files || []) {
|
|
|
|
|
const path = file.path;
|
2025-12-23 21:58:45 +09:00
|
|
|
const sha1 = file.hashes?.sha1;
|
2025-12-23 16:32:06 +09:00
|
|
|
const fileName = path
|
|
|
|
|
.split("/")
|
|
|
|
|
.pop()
|
|
|
|
|
.replace(/\.(jar|zip)$/, "");
|
|
|
|
|
|
|
|
|
|
if (path.startsWith("mods/")) {
|
2025-12-23 21:58:45 +09:00
|
|
|
filesByCategory.mods.push({ fileName, sha1 });
|
2025-12-23 16:32:06 +09:00
|
|
|
} else if (path.startsWith("resourcepacks/")) {
|
2025-12-23 21:58:45 +09:00
|
|
|
filesByCategory.resourcepacks.push({ fileName, sha1 });
|
2025-12-23 16:32:06 +09:00
|
|
|
} else if (path.startsWith("shaderpacks/")) {
|
2025-12-23 21:58:45 +09:00
|
|
|
filesByCategory.shaderpacks.push({ fileName, sha1 });
|
2025-12-23 16:32:06 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 21:58:45 +09:00
|
|
|
// Modrinth API로 모드 정보 가져오기
|
|
|
|
|
const allHashes = [
|
|
|
|
|
...filesByCategory.mods,
|
|
|
|
|
...filesByCategory.resourcepacks,
|
|
|
|
|
...filesByCategory.shaderpacks,
|
|
|
|
|
]
|
|
|
|
|
.filter((f) => f.sha1)
|
|
|
|
|
.map((f) => f.sha1);
|
|
|
|
|
|
|
|
|
|
let modrinthData = {};
|
|
|
|
|
let projectsMap = {};
|
|
|
|
|
|
|
|
|
|
if (allHashes.length > 0) {
|
|
|
|
|
try {
|
|
|
|
|
// 1. version_files API로 버전 정보 가져오기
|
|
|
|
|
const versionsRes = await fetch(
|
|
|
|
|
"https://api.modrinth.com/v2/version_files",
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ hashes: allHashes, algorithm: "sha1" }),
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (versionsRes.ok) {
|
|
|
|
|
modrinthData = await versionsRes.json();
|
|
|
|
|
|
|
|
|
|
// 2. projects API로 프로젝트 정보 가져오기
|
|
|
|
|
const projectIds = [
|
|
|
|
|
...new Set(Object.values(modrinthData).map((v) => v.project_id)),
|
|
|
|
|
];
|
|
|
|
|
if (projectIds.length > 0) {
|
|
|
|
|
const projectsRes = await fetch(
|
|
|
|
|
"https://api.modrinth.com/v2/projects?ids=" +
|
|
|
|
|
encodeURIComponent(JSON.stringify(projectIds))
|
|
|
|
|
);
|
|
|
|
|
if (projectsRes.ok) {
|
|
|
|
|
const projectsData = await projectsRes.json();
|
|
|
|
|
projectsMap = Object.fromEntries(
|
|
|
|
|
projectsData.map((p) => [p.id, p])
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Modrinth API 호출 실패:", err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// API 실패해도 계속 진행 (fallback으로 파일명 사용)
|
|
|
|
|
|
|
|
|
|
// S3 아이콘 캐싱 함수
|
|
|
|
|
const { uploadToS3, checkS3Exists } = await import("../lib/s3.js");
|
|
|
|
|
const cacheIcon = async (iconUrl, slug) => {
|
|
|
|
|
if (!iconUrl || !slug) return null;
|
|
|
|
|
|
|
|
|
|
const s3Key = `mod_icons/${slug}.webp`;
|
|
|
|
|
const cachedUrl = `https://s3.caadiq.co.kr/minecraft/${s3Key}`;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// S3에 이미 캐시되어 있는지 확인
|
|
|
|
|
const exists = await checkS3Exists("minecraft", s3Key);
|
|
|
|
|
if (exists) {
|
|
|
|
|
return cachedUrl;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Modrinth CDN에서 다운로드
|
|
|
|
|
const iconRes = await fetch(iconUrl);
|
|
|
|
|
if (!iconRes.ok) return iconUrl; // 실패시 원본 URL 반환
|
|
|
|
|
|
|
|
|
|
const iconBuffer = Buffer.from(await iconRes.arrayBuffer());
|
|
|
|
|
|
|
|
|
|
// S3에 업로드
|
|
|
|
|
await uploadToS3("minecraft", s3Key, iconBuffer, "image/webp");
|
|
|
|
|
return cachedUrl;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`아이콘 캐싱 실패 (${slug}):`, err.message);
|
|
|
|
|
return iconUrl; // 실패시 원본 URL 반환
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 각 카테고리별로 모드 정보 구성 (아이콘 캐싱 포함)
|
|
|
|
|
const buildModInfo = async (file) => {
|
|
|
|
|
const versionInfo = modrinthData[file.sha1];
|
|
|
|
|
if (versionInfo) {
|
|
|
|
|
const project = projectsMap[versionInfo.project_id];
|
|
|
|
|
if (project) {
|
|
|
|
|
// 아이콘 캐싱
|
|
|
|
|
const cachedIconUrl = await cacheIcon(
|
|
|
|
|
project.icon_url,
|
|
|
|
|
project.slug
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
title: project.title,
|
|
|
|
|
version: versionInfo.version_number,
|
|
|
|
|
icon_url: cachedIconUrl,
|
|
|
|
|
slug: project.slug,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// fallback: 파일명만 사용
|
|
|
|
|
return {
|
|
|
|
|
title: file.fileName,
|
|
|
|
|
version: null,
|
|
|
|
|
icon_url: null,
|
|
|
|
|
slug: null,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 병렬로 모드 정보 구성
|
|
|
|
|
contents.mods = await Promise.all(filesByCategory.mods.map(buildModInfo));
|
|
|
|
|
contents.resourcepacks = await Promise.all(
|
|
|
|
|
filesByCategory.resourcepacks.map(buildModInfo)
|
|
|
|
|
);
|
|
|
|
|
contents.shaderpacks = await Promise.all(
|
|
|
|
|
filesByCategory.shaderpacks.map(buildModInfo)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// S3에 업로드 (원본 파일명 사용)
|
|
|
|
|
const s3Key = `modpacks/${fileName}`;
|
2025-12-23 16:32:06 +09:00
|
|
|
await uploadToS3("minecraft", s3Key, fileData, "application/zip");
|
|
|
|
|
|
2025-12-23 17:15:32 +09:00
|
|
|
// 중복 체크
|
|
|
|
|
const [existing] = await pool.query(
|
|
|
|
|
`SELECT id FROM modpacks WHERE name = ? AND version = ?`,
|
|
|
|
|
[modpackName, modpackVersion]
|
|
|
|
|
);
|
|
|
|
|
if (existing.length > 0) {
|
|
|
|
|
return res.status(409).json({
|
|
|
|
|
error: `${modpackName} v${modpackVersion}은(는) 이미 존재합니다.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 16:32:06 +09:00
|
|
|
// DB에 저장
|
|
|
|
|
const [result] = await pool.query(
|
|
|
|
|
`INSERT INTO modpacks (name, version, minecraft_version, mod_loader, changelog, file_key, file_size, contents_json)
|
2025-12-23 17:15:32 +09:00
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
2025-12-23 16:32:06 +09:00
|
|
|
[
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-23 16:33:51 +09:00
|
|
|
/**
|
|
|
|
|
* 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: "삭제 실패" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-24 16:20:36 +09:00
|
|
|
// ========================
|
|
|
|
|
// 모드 번역 API
|
|
|
|
|
// ========================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/modtranslations - 업로드된 모드 번역 목록
|
|
|
|
|
*/
|
|
|
|
|
router.get("/modtranslations", requireAdmin, async (req, res) => {
|
|
|
|
|
try {
|
2025-12-26 19:52:35 +09:00
|
|
|
// items 테이블에서 minecraft가 아닌 mod_id별 type 집계
|
|
|
|
|
const [mods] = await pool.query(`
|
|
|
|
|
SELECT mod_id,
|
|
|
|
|
SUM(CASE WHEN type = 'block' THEN 1 ELSE 0 END) as block_count,
|
|
|
|
|
SUM(CASE WHEN type = 'item' THEN 1 ELSE 0 END) as item_count
|
|
|
|
|
FROM items
|
|
|
|
|
WHERE mod_id != 'minecraft'
|
|
|
|
|
GROUP BY mod_id
|
|
|
|
|
ORDER BY mod_id
|
|
|
|
|
`);
|
2025-12-24 16:20:36 +09:00
|
|
|
res.json({ mods });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 모드 번역 목록 조회 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "조회 실패" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/modtranslations - JAR 파일에서 번역 추출
|
|
|
|
|
*/
|
|
|
|
|
router.post("/modtranslations", requireAdmin, async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
// multipart 파싱 (기존 모드팩 업로드와 동일한 방식)
|
|
|
|
|
const contentType = req.headers["content-type"] || "";
|
|
|
|
|
if (!contentType.includes("multipart/form-data")) {
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: "multipart/form-data 형식으로 전송해주세요" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const boundary = contentType.split("boundary=")[1];
|
|
|
|
|
if (!boundary) {
|
|
|
|
|
return res.status(400).json({ error: "boundary가 없습니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chunks = [];
|
|
|
|
|
for await (const chunk of req) {
|
|
|
|
|
chunks.push(chunk);
|
|
|
|
|
}
|
|
|
|
|
const body = Buffer.concat(chunks);
|
|
|
|
|
|
|
|
|
|
// 파일 데이터 추출
|
|
|
|
|
const parts = body.toString("binary").split("--" + boundary);
|
|
|
|
|
let fileData = null;
|
|
|
|
|
let fileName = "";
|
|
|
|
|
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (part.includes('name="file"')) {
|
|
|
|
|
const match = part.match(/filename="([^"]+)"/);
|
|
|
|
|
if (match) {
|
|
|
|
|
fileName = match[1];
|
|
|
|
|
}
|
|
|
|
|
const headerEnd = part.indexOf("\r\n\r\n");
|
|
|
|
|
if (headerEnd !== -1) {
|
|
|
|
|
const dataStart = headerEnd + 4;
|
|
|
|
|
const dataEnd = part.lastIndexOf("\r\n");
|
|
|
|
|
fileData = Buffer.from(part.substring(dataStart, dataEnd), "binary");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!fileData || !fileName.endsWith(".jar")) {
|
|
|
|
|
return res.status(400).json({ error: "JAR 파일이 필요합니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 임시 파일로 저장
|
|
|
|
|
const fs = await import("fs/promises");
|
|
|
|
|
const path = await import("path");
|
|
|
|
|
const { execSync } = await import("child_process");
|
|
|
|
|
const os = await import("os");
|
|
|
|
|
|
|
|
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mod-"));
|
|
|
|
|
const jarPath = path.join(tempDir, fileName);
|
|
|
|
|
await fs.writeFile(jarPath, fileData);
|
|
|
|
|
|
|
|
|
|
// ko_kr.json 추출
|
|
|
|
|
let koKrJson = null;
|
|
|
|
|
let modId = null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// JAR 내 ko_kr.json 찾기
|
|
|
|
|
const listOutput = execSync(
|
|
|
|
|
`unzip -l "${jarPath}" 2>/dev/null | grep "ko_kr.json"`,
|
|
|
|
|
{ encoding: "utf-8" }
|
|
|
|
|
);
|
|
|
|
|
const koKrPath = listOutput.trim().split(/\s+/).pop();
|
|
|
|
|
|
|
|
|
|
if (koKrPath) {
|
|
|
|
|
// mod_id 추출 (assets/<mod_id>/lang/ko_kr.json)
|
|
|
|
|
const pathParts = koKrPath.split("/");
|
|
|
|
|
if (pathParts.length >= 3 && pathParts[0] === "assets") {
|
|
|
|
|
modId = pathParts[1];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// JSON 추출
|
|
|
|
|
const jsonContent = execSync(`unzip -p "${jarPath}" "${koKrPath}"`, {
|
|
|
|
|
encoding: "utf-8",
|
|
|
|
|
});
|
|
|
|
|
koKrJson = JSON.parse(jsonContent);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("[Admin] ko_kr.json 추출 실패:", e.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 임시 파일 정리
|
|
|
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
|
|
|
|
|
|
|
if (!koKrJson || !modId) {
|
|
|
|
|
return res.status(400).json({ error: "ko_kr.json을 찾을 수 없습니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// block.*, item.* 키 추출 및 DB 저장
|
|
|
|
|
let blockCount = 0;
|
|
|
|
|
let itemCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(koKrJson)) {
|
|
|
|
|
const parts = key.split(".");
|
|
|
|
|
if (parts.length >= 3) {
|
|
|
|
|
const type = parts[0]; // block 또는 item
|
|
|
|
|
const keyModId = parts[1]; // 모드 ID
|
|
|
|
|
const name = parts.slice(2).join("."); // 나머지는 이름
|
|
|
|
|
|
|
|
|
|
if (keyModId !== modId) continue; // 다른 모드 키는 무시
|
|
|
|
|
|
2025-12-26 17:50:10 +09:00
|
|
|
// 이름에 .이 포함된 경우 스킵 (예: copper_backtank.tooltip.behaviour)
|
|
|
|
|
if (name.includes(".")) continue;
|
|
|
|
|
|
2025-12-24 16:20:36 +09:00
|
|
|
if (type === "block") {
|
|
|
|
|
await pool.query(
|
2025-12-26 19:52:35 +09:00
|
|
|
`INSERT INTO items (name, name_ko, mod_id, type) VALUES (?, ?, ?, 'block')
|
2025-12-24 16:20:36 +09:00
|
|
|
ON DUPLICATE KEY UPDATE name_ko = VALUES(name_ko)`,
|
|
|
|
|
[name, value, modId]
|
|
|
|
|
);
|
|
|
|
|
blockCount++;
|
|
|
|
|
} else if (type === "item") {
|
|
|
|
|
await pool.query(
|
2025-12-26 19:52:35 +09:00
|
|
|
`INSERT INTO items (name, name_ko, mod_id, type) VALUES (?, ?, ?, 'item')
|
2025-12-24 16:20:36 +09:00
|
|
|
ON DUPLICATE KEY UPDATE name_ko = VALUES(name_ko)`,
|
|
|
|
|
[name, value, modId]
|
|
|
|
|
);
|
|
|
|
|
itemCount++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`[Admin] 모드 번역 추출 완료: ${modId} (blocks: ${blockCount}, items: ${itemCount})`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 번역 캐시 새로고침
|
|
|
|
|
await loadTranslations();
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
mod_id: modId,
|
|
|
|
|
count: blockCount + itemCount,
|
|
|
|
|
block_count: blockCount,
|
|
|
|
|
item_count: itemCount,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 모드 번역 업로드 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "업로드 실패: " + error.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* DELETE /api/admin/modtranslations/:modId - 모드 번역 삭제
|
2025-12-26 20:14:43 +09:00
|
|
|
* 번역 데이터와 함께 아이콘(S3 파일)도 삭제
|
2025-12-24 16:20:36 +09:00
|
|
|
*/
|
|
|
|
|
router.delete("/modtranslations/:modId", requireAdmin, async (req, res) => {
|
|
|
|
|
const { modId } = req.params;
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-26 20:14:43 +09:00
|
|
|
const { deleteByPrefix } = await import("../lib/s3.js");
|
2025-12-24 16:20:36 +09:00
|
|
|
|
2025-12-26 20:14:43 +09:00
|
|
|
// S3에서 해당 모드의 아이콘 파일 삭제
|
|
|
|
|
const s3DeletedCount = await deleteByPrefix(
|
|
|
|
|
"minecraft",
|
|
|
|
|
`icons/items/${modId}_`
|
|
|
|
|
);
|
|
|
|
|
console.log(`[Admin] S3 아이콘 파일 삭제: ${modId} (${s3DeletedCount}개)`);
|
|
|
|
|
|
|
|
|
|
// items 테이블에서 해당 모드 삭제
|
|
|
|
|
const [result] = await pool.query(`DELETE FROM items WHERE mod_id = ?`, [
|
|
|
|
|
modId,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`[Admin] 모드 삭제: ${modId} (DB ${result.affectedRows}개, S3 ${s3DeletedCount}개)`
|
|
|
|
|
);
|
2025-12-24 16:20:36 +09:00
|
|
|
|
|
|
|
|
// 번역 캐시 새로고침
|
|
|
|
|
await loadTranslations();
|
|
|
|
|
|
2025-12-26 20:14:43 +09:00
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
deleted: result.affectedRows,
|
|
|
|
|
s3Deleted: s3DeletedCount,
|
|
|
|
|
});
|
2025-12-24 16:20:36 +09:00
|
|
|
} catch (error) {
|
2025-12-26 20:14:43 +09:00
|
|
|
console.error("[Admin] 모드 삭제 오류:", error);
|
2025-12-24 16:20:36 +09:00
|
|
|
res.status(500).json({ error: "삭제 실패" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-26 19:52:35 +09:00
|
|
|
// ========================
|
|
|
|
|
// 아이콘 관리 API
|
|
|
|
|
// ========================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/icons - 등록된 아이콘 목록 (모드별 집계)
|
|
|
|
|
* blocks/items 테이블에서 icon이 있는 항목을 모드별로 집계
|
|
|
|
|
*/
|
|
|
|
|
router.get("/icons", requireAdmin, async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
// items 테이블에서 icon이 있는 모드를 type별로 집계
|
|
|
|
|
const [mods] = await pool.query(`
|
|
|
|
|
SELECT mod_id,
|
|
|
|
|
SUM(CASE WHEN type = 'block' THEN 1 ELSE 0 END) as block_count,
|
|
|
|
|
SUM(CASE WHEN type = 'item' THEN 1 ELSE 0 END) as item_count
|
|
|
|
|
FROM items
|
|
|
|
|
WHERE icon IS NOT NULL AND mod_id != 'minecraft'
|
|
|
|
|
GROUP BY mod_id
|
|
|
|
|
ORDER BY mod_id
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
res.json({ mods });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 아이콘 목록 조회 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "조회 실패" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-26 19:59:08 +09:00
|
|
|
* POST /api/admin/icons/upload - 아이콘 ZIP 업로드 (여러 파일 지원)
|
2025-12-26 19:52:35 +09:00
|
|
|
* IconExporter로 생성된 ZIP 파일을 업로드하여 RustFS에 저장
|
2025-12-26 19:59:08 +09:00
|
|
|
* 번역본이 먼저 업로드되어 있어야 함
|
2025-12-26 19:52:35 +09:00
|
|
|
*/
|
|
|
|
|
router.post(
|
|
|
|
|
"/icons/upload",
|
2025-12-26 19:59:08 +09:00
|
|
|
express.raw({ type: "multipart/form-data", limit: "200mb" }),
|
2025-12-26 19:52:35 +09:00
|
|
|
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}`);
|
|
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
// 여러 ZIP 파일 수집
|
|
|
|
|
const zipFiles = [];
|
2025-12-26 19:52:35 +09:00
|
|
|
for (const part of parts) {
|
2025-12-26 19:59:08 +09:00
|
|
|
if (part.includes('name="file"') || part.includes('name="files"')) {
|
2025-12-26 19:52:35 +09:00
|
|
|
const headerEnd = part.indexOf("\r\n\r\n");
|
|
|
|
|
const header = Buffer.from(
|
|
|
|
|
part.slice(0, headerEnd),
|
|
|
|
|
"binary"
|
|
|
|
|
).toString("utf8");
|
|
|
|
|
const match = header.match(/filename="([^"]+)"/);
|
2025-12-26 19:59:08 +09:00
|
|
|
if (match && match[1].endsWith(".zip")) {
|
|
|
|
|
const dataStart = headerEnd + 4;
|
|
|
|
|
const dataEnd = part.lastIndexOf("\r\n");
|
|
|
|
|
const fileData = Buffer.from(
|
|
|
|
|
part.slice(dataStart, dataEnd),
|
|
|
|
|
"binary"
|
|
|
|
|
);
|
|
|
|
|
zipFiles.push({ name: match[1], data: fileData });
|
|
|
|
|
}
|
2025-12-26 19:52:35 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
if (zipFiles.length === 0) {
|
2025-12-26 19:52:35 +09:00
|
|
|
return res.status(400).json({ error: "ZIP 파일이 필요합니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AdmZip = (await import("adm-zip")).default;
|
|
|
|
|
const { uploadToS3 } = await import("../lib/s3.js");
|
|
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
const results = [];
|
|
|
|
|
const errors = [];
|
|
|
|
|
|
|
|
|
|
// 각 ZIP 파일 처리
|
|
|
|
|
for (const zipFile of zipFiles) {
|
|
|
|
|
const zip = new AdmZip(zipFile.data);
|
|
|
|
|
const entries = zip.getEntries();
|
|
|
|
|
|
|
|
|
|
// metadata.json에서 mod_id 읽기
|
|
|
|
|
let modId = null;
|
|
|
|
|
const metadataEntry = entries.find(
|
|
|
|
|
(e) => e.entryName === "metadata.json"
|
|
|
|
|
);
|
|
|
|
|
if (metadataEntry) {
|
|
|
|
|
try {
|
|
|
|
|
const metadata = JSON.parse(
|
|
|
|
|
metadataEntry.getData().toString("utf8")
|
|
|
|
|
);
|
|
|
|
|
modId = metadata.mod_id;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
errors.push({
|
|
|
|
|
file: zipFile.name,
|
|
|
|
|
error: "metadata.json 파싱 실패",
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2025-12-26 19:52:35 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
if (!modId) {
|
|
|
|
|
errors.push({
|
|
|
|
|
file: zipFile.name,
|
|
|
|
|
error: "metadata.json에 mod_id가 없습니다",
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2025-12-26 19:52:35 +09:00
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
// 해당 모드가 DB에 있는지 확인
|
|
|
|
|
const [modCheck] = await pool.query(
|
|
|
|
|
`SELECT COUNT(*) as count FROM items WHERE mod_id = ?`,
|
|
|
|
|
[modId]
|
|
|
|
|
);
|
|
|
|
|
if (modCheck[0].count === 0) {
|
|
|
|
|
errors.push({
|
|
|
|
|
file: zipFile.name,
|
|
|
|
|
error: `모드 '${modId}'의 번역본이 먼저 업로드되어야 합니다`,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2025-12-26 19:52:35 +09:00
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
let uploadedCount = 0;
|
|
|
|
|
let updatedCount = 0;
|
|
|
|
|
let skippedCount = 0;
|
2025-12-26 19:52:35 +09:00
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (entry.isDirectory) continue;
|
|
|
|
|
if (!entry.entryName.endsWith(".png")) continue;
|
2025-12-26 19:52:35 +09:00
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
const pathParts = entry.entryName.split("/");
|
|
|
|
|
const itemId = pathParts[pathParts.length - 1].replace(".png", "");
|
|
|
|
|
const s3Key = `icons/items/${modId}_${itemId}.png`;
|
2025-12-26 19:52:35 +09:00
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
const imageData = entry.getData();
|
|
|
|
|
try {
|
|
|
|
|
await uploadToS3("minecraft", s3Key, imageData, "image/png");
|
|
|
|
|
} catch (s3Error) {
|
|
|
|
|
console.error(
|
|
|
|
|
`[Admin] S3 업로드 실패: ${s3Key} - ${s3Error.message}`
|
|
|
|
|
);
|
|
|
|
|
skippedCount++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2025-12-26 19:52:35 +09:00
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
const iconUrl = `https://s3.caadiq.co.kr/minecraft/${s3Key}`;
|
|
|
|
|
const [result] = await pool.query(
|
|
|
|
|
`UPDATE items SET icon = ? WHERE mod_id = ? AND name = ?`,
|
|
|
|
|
[iconUrl, modId, itemId]
|
2025-12-26 19:52:35 +09:00
|
|
|
);
|
|
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
if (result.affectedRows > 0) {
|
|
|
|
|
updatedCount++;
|
|
|
|
|
}
|
|
|
|
|
uploadedCount++;
|
|
|
|
|
}
|
2025-12-26 19:52:35 +09:00
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
console.log(
|
|
|
|
|
`[Admin] 아이콘 업로드 완료 [${modId}]: ${uploadedCount}개 업로드, ${updatedCount}개 DB 업데이트`
|
2025-12-26 19:52:35 +09:00
|
|
|
);
|
|
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
results.push({
|
|
|
|
|
modId,
|
|
|
|
|
uploaded: uploadedCount,
|
|
|
|
|
updated: updatedCount,
|
|
|
|
|
skipped: skippedCount,
|
|
|
|
|
});
|
2025-12-26 19:52:35 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-26 19:59:08 +09:00
|
|
|
// 번역 캐시 새로고침
|
|
|
|
|
await loadTranslations();
|
|
|
|
|
|
|
|
|
|
if (errors.length > 0 && results.length === 0) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
success: false,
|
|
|
|
|
errors,
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-12-26 19:52:35 +09:00
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
2025-12-26 19:59:08 +09:00
|
|
|
results,
|
|
|
|
|
errors: errors.length > 0 ? errors : undefined,
|
2025-12-26 19:52:35 +09:00
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 아이콘 업로드 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "업로드 실패: " + error.message });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-26 20:14:43 +09:00
|
|
|
* DELETE /api/admin/icons/:modId - 특정 모드 아이콘 삭제
|
|
|
|
|
* items 테이블의 icon 컬럼을 NULL로 설정하고 S3에서 파일 삭제
|
2025-12-26 19:52:35 +09:00
|
|
|
*/
|
|
|
|
|
router.delete("/icons/:modId", requireAdmin, async (req, res) => {
|
|
|
|
|
const { modId } = req.params;
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-26 20:14:43 +09:00
|
|
|
const { deleteByPrefix } = await import("../lib/s3.js");
|
|
|
|
|
|
|
|
|
|
// S3에서 해당 모드의 아이콘 파일 삭제
|
|
|
|
|
const s3DeletedCount = await deleteByPrefix(
|
|
|
|
|
"minecraft",
|
|
|
|
|
`icons/items/${modId}_`
|
|
|
|
|
);
|
|
|
|
|
console.log(`[Admin] S3 아이콘 파일 삭제: ${modId} (${s3DeletedCount}개)`);
|
|
|
|
|
|
2025-12-26 19:52:35 +09:00
|
|
|
// items 테이블의 icon 컬럼 NULL로 업데이트
|
|
|
|
|
const [result] = await pool.query(
|
|
|
|
|
`UPDATE items SET icon = NULL WHERE mod_id = ? AND icon IS NOT NULL`,
|
|
|
|
|
[modId]
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-26 20:14:43 +09:00
|
|
|
// 번역 캐시 새로고침
|
|
|
|
|
await loadTranslations();
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`[Admin] 아이콘 삭제: ${modId} (DB ${result.affectedRows}개, S3 ${s3DeletedCount}개)`
|
|
|
|
|
);
|
2025-12-26 19:52:35 +09:00
|
|
|
|
2025-12-26 20:14:43 +09:00
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
deleted: result.affectedRows,
|
|
|
|
|
s3Deleted: s3DeletedCount,
|
|
|
|
|
});
|
2025-12-26 19:52:35 +09:00
|
|
|
} catch (error) {
|
2025-12-26 20:14:43 +09:00
|
|
|
console.error("[Admin] 아이콘 삭제 오류:", error);
|
2025-12-26 19:52:35 +09:00
|
|
|
res.status(500).json({ error: "삭제 실패" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-29 13:43:43 +09:00
|
|
|
/**
|
|
|
|
|
* 서버 관리 API
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { exec } from "child_process";
|
|
|
|
|
import { promisify } from "util";
|
|
|
|
|
import fs from "fs";
|
|
|
|
|
import path from "path";
|
|
|
|
|
|
|
|
|
|
const execAsync = promisify(exec);
|
2025-12-29 13:53:12 +09:00
|
|
|
const SERVER_BASE_PATH = "/minecraft/server"; // 컨테이너 내부 경로 (파일 확인용)
|
|
|
|
|
const HOST_SERVER_PATH = "/docker/minecraft/server"; // 호스트 경로 (docker-compose 실행용)
|
2025-12-29 13:43:43 +09:00
|
|
|
|
|
|
|
|
// 서버 목록 조회 헬퍼
|
|
|
|
|
async function getServerList() {
|
|
|
|
|
const servers = [];
|
|
|
|
|
|
|
|
|
|
// 로더별 폴더 탐색 (neoforge, fabric 등)
|
|
|
|
|
const loaders = fs
|
|
|
|
|
.readdirSync(SERVER_BASE_PATH)
|
|
|
|
|
.filter((f) => fs.statSync(path.join(SERVER_BASE_PATH, f)).isDirectory());
|
|
|
|
|
|
|
|
|
|
for (const loader of loaders) {
|
|
|
|
|
const loaderPath = path.join(SERVER_BASE_PATH, loader);
|
|
|
|
|
|
|
|
|
|
// 버전별, 서버별 폴더 재귀 탐색
|
|
|
|
|
const findServers = (dir, relativePath = "") => {
|
|
|
|
|
const items = fs.readdirSync(dir);
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const itemPath = path.join(dir, item);
|
|
|
|
|
const itemRelative = relativePath ? `${relativePath}/${item}` : item;
|
|
|
|
|
|
|
|
|
|
if (fs.statSync(itemPath).isDirectory()) {
|
|
|
|
|
// docker-compose.yml이 있으면 서버로 인식
|
|
|
|
|
const composePath = path.join(itemPath, "docker-compose.yml");
|
|
|
|
|
if (fs.existsSync(composePath)) {
|
|
|
|
|
servers.push({
|
|
|
|
|
id: item,
|
|
|
|
|
loader: loader.charAt(0).toUpperCase() + loader.slice(1), // neoforge -> Neoforge
|
|
|
|
|
path: `${loader}/${itemRelative}`,
|
|
|
|
|
fullPath: itemPath,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// 하위 폴더 탐색
|
|
|
|
|
findServers(itemPath, itemRelative);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
findServers(loaderPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return servers;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 실행 중인 서버 확인
|
|
|
|
|
async function getRunningServer() {
|
|
|
|
|
try {
|
|
|
|
|
const { stdout } = await execAsync(
|
|
|
|
|
'docker ps --format "{{.Names}}" | grep -E "^minecraft-server"'
|
|
|
|
|
);
|
|
|
|
|
const containers = stdout.trim().split("\n").filter(Boolean);
|
|
|
|
|
return containers.length > 0 ? containers[0] : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 컨테이너 상태 확인
|
|
|
|
|
async function getContainerStatus(serverPath) {
|
|
|
|
|
try {
|
2025-12-29 13:53:12 +09:00
|
|
|
const hostPath = path.join(HOST_SERVER_PATH, serverPath);
|
2025-12-29 13:43:43 +09:00
|
|
|
const { stdout } = await execAsync(
|
2025-12-29 13:53:12 +09:00
|
|
|
`docker compose ps --format json 2>/dev/null`,
|
|
|
|
|
{ cwd: hostPath }
|
2025-12-29 13:43:43 +09:00
|
|
|
);
|
|
|
|
|
if (stdout.trim()) {
|
|
|
|
|
const containers = stdout
|
|
|
|
|
.trim()
|
|
|
|
|
.split("\n")
|
|
|
|
|
.map((line) => JSON.parse(line));
|
|
|
|
|
const running = containers.some((c) => c.State === "running");
|
|
|
|
|
return running ? "running" : "stopped";
|
|
|
|
|
}
|
|
|
|
|
return "not_created";
|
|
|
|
|
} catch {
|
|
|
|
|
return "not_created";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/servers - 서버 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
router.get("/servers", async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const servers = await getServerList();
|
|
|
|
|
const runningContainer = await getRunningServer();
|
|
|
|
|
|
|
|
|
|
// 각 서버의 상태 확인
|
|
|
|
|
const serversWithStatus = await Promise.all(
|
|
|
|
|
servers.map(async (server) => {
|
|
|
|
|
const status = await getContainerStatus(server.path);
|
|
|
|
|
return {
|
|
|
|
|
...server,
|
|
|
|
|
running: status === "running",
|
|
|
|
|
status,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
servers: serversWithStatus,
|
|
|
|
|
runningContainer,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 서버 목록 조회 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "서버 목록 조회 실패" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/servers/start - 서버 시작
|
|
|
|
|
*/
|
|
|
|
|
router.post("/servers/start", async (req, res) => {
|
|
|
|
|
const { serverPath } = req.body;
|
|
|
|
|
|
|
|
|
|
if (!serverPath) {
|
|
|
|
|
return res.status(400).json({ error: "서버 경로가 필요합니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 이미 실행 중인 서버가 있는지 확인
|
|
|
|
|
const runningContainer = await getRunningServer();
|
|
|
|
|
if (runningContainer) {
|
2025-12-29 13:53:12 +09:00
|
|
|
return res.status(400).json({
|
|
|
|
|
error: "이미 실행 중인 서버가 있습니다",
|
|
|
|
|
running: runningContainer,
|
|
|
|
|
});
|
2025-12-29 13:43:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fullPath = path.join(SERVER_BASE_PATH, serverPath);
|
2025-12-29 13:53:12 +09:00
|
|
|
const hostPath = path.join(HOST_SERVER_PATH, serverPath); // 호스트 경로
|
2025-12-29 13:43:43 +09:00
|
|
|
|
|
|
|
|
// docker-compose.yml 존재 확인
|
|
|
|
|
if (!fs.existsSync(path.join(fullPath, "docker-compose.yml"))) {
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: "docker-compose.yml을 찾을 수 없습니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[Admin] 서버 시작 중: ${serverPath}`);
|
|
|
|
|
|
2025-12-29 13:53:12 +09:00
|
|
|
// docker-compose up -d 실행 (호스트 경로 사용)
|
|
|
|
|
const { stdout, stderr } = await execAsync("docker compose up -d", {
|
|
|
|
|
cwd: hostPath,
|
2025-12-29 13:43:43 +09:00
|
|
|
timeout: 60000, // 60초 타임아웃
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.log(`[Admin] 서버 시작 완료: ${serverPath}`);
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
message: "서버 시작됨",
|
|
|
|
|
stdout,
|
|
|
|
|
stderr,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 서버 시작 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "서버 시작 실패", details: error.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/servers/stop - 서버 종료
|
|
|
|
|
*/
|
|
|
|
|
router.post("/servers/stop", async (req, res) => {
|
|
|
|
|
const { serverPath } = req.body;
|
|
|
|
|
|
|
|
|
|
if (!serverPath) {
|
|
|
|
|
return res.status(400).json({ error: "서버 경로가 필요합니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const fullPath = path.join(SERVER_BASE_PATH, serverPath);
|
2025-12-29 13:53:12 +09:00
|
|
|
const hostPath = path.join(HOST_SERVER_PATH, serverPath); // 호스트 경로
|
2025-12-29 13:43:43 +09:00
|
|
|
|
|
|
|
|
// docker-compose.yml 존재 확인
|
|
|
|
|
if (!fs.existsSync(path.join(fullPath, "docker-compose.yml"))) {
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: "docker-compose.yml을 찾을 수 없습니다" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[Admin] 서버 종료 중: ${serverPath}`);
|
|
|
|
|
|
2025-12-29 13:53:12 +09:00
|
|
|
// docker-compose down 실행 (호스트 경로 사용)
|
|
|
|
|
const { stdout, stderr } = await execAsync("docker compose down", {
|
|
|
|
|
cwd: hostPath,
|
2025-12-29 13:43:43 +09:00
|
|
|
timeout: 60000, // 60초 타임아웃
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.log(`[Admin] 서버 종료 완료: ${serverPath}`);
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
message: "서버 종료됨",
|
|
|
|
|
stdout,
|
|
|
|
|
stderr,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 서버 종료 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "서버 종료 실패", details: error.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/servers/status/:path - 특정 서버 상태 조회
|
|
|
|
|
*/
|
|
|
|
|
router.get("/servers/status/:serverPath(*)", async (req, res) => {
|
|
|
|
|
const { serverPath } = req.params;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const status = await getContainerStatus(serverPath);
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
serverPath,
|
|
|
|
|
status,
|
|
|
|
|
running: status === "running",
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[Admin] 서버 상태 조회 오류:", error);
|
|
|
|
|
res.status(500).json({ error: "상태 조회 실패" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-22 15:37:54 +09:00
|
|
|
export default router;
|