/** * 관리자 전용 API 라우트 * - 서버 명령어 실행 * - 인증 + 관리자 권한 필요 */ import express from "express"; import jwt from "jsonwebtoken"; import { pool, loadTranslations } 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"')) { // 헤더 부분을 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="([^"]+)"/); if (match) fileName = match[1]; const dataStart = headerEnd + 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 = Buffer.from( part.slice(dataStart, dataEnd), "binary" ).toString("utf8"); } } 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 파싱 - Modrinth API로 모드 정보 가져오기 const contents = { mods: [], resourcepacks: [], shaderpacks: [] }; // 각 카테고리별 파일 정보 수집 (sha1 해시 포함) const filesByCategory = { mods: [], resourcepacks: [], shaderpacks: [] }; for (const file of indexJson.files || []) { const path = file.path; const sha1 = file.hashes?.sha1; const fileName = path .split("/") .pop() .replace(/\.(jar|zip)$/, ""); if (path.startsWith("mods/")) { filesByCategory.mods.push({ fileName, sha1 }); } else if (path.startsWith("resourcepacks/")) { filesByCategory.resourcepacks.push({ fileName, sha1 }); } else if (path.startsWith("shaderpacks/")) { filesByCategory.shaderpacks.push({ fileName, sha1 }); } } // 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}`; await uploadToS3("minecraft", s3Key, fileData, "application/zip"); // 중복 체크 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}은(는) 이미 존재합니다.`, }); } // DB에 저장 const [result] = await pool.query( `INSERT INTO modpacks (name, version, minecraft_version, mod_loader, changelog, file_key, file_size, contents_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ 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: "삭제 실패" }); } }); // ======================== // 모드 번역 API // ======================== /** * GET /api/admin/modtranslations - 업로드된 모드 번역 목록 */ router.get("/modtranslations", requireAdmin, async (req, res) => { try { // 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 `); 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//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; // 다른 모드 키는 무시 // 이름에 .이 포함된 경우 스킵 (예: copper_backtank.tooltip.behaviour) if (name.includes(".")) continue; if (type === "block") { await pool.query( `INSERT INTO items (name, name_ko, mod_id, type) VALUES (?, ?, ?, 'block') ON DUPLICATE KEY UPDATE name_ko = VALUES(name_ko)`, [name, value, modId] ); blockCount++; } else if (type === "item") { await pool.query( `INSERT INTO items (name, name_ko, mod_id, type) VALUES (?, ?, ?, 'item') 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 - 모드 번역 삭제 */ router.delete("/modtranslations/:modId", requireAdmin, async (req, res) => { const { modId } = req.params; try { await pool.query(`DELETE FROM items WHERE mod_id = ?`, [modId]); console.log(`[Admin] 모드 번역 삭제: ${modId}`); // 번역 캐시 새로고침 await loadTranslations(); res.json({ success: true }); } catch (error) { console.error("[Admin] 모드 번역 삭제 오류:", error); res.status(500).json({ error: "삭제 실패" }); } }); // ======================== // 아이콘 관리 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: "조회 실패" }); } }); /** * POST /api/admin/icons/upload - 아이콘 ZIP 업로드 * IconExporter로 생성된 ZIP 파일을 업로드하여 RustFS에 저장 */ router.post( "/icons/upload", express.raw({ type: "multipart/form-data", limit: "100mb" }), 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 = ""; // multipart 파싱 for (const part of parts) { if (part.includes('name="file"')) { const headerEnd = part.indexOf("\r\n\r\n"); const header = Buffer.from( part.slice(0, headerEnd), "binary" ).toString("utf8"); const match = header.match(/filename="([^"]+)"/); if (match) fileName = match[1]; const dataStart = headerEnd + 4; const dataEnd = part.lastIndexOf("\r\n"); fileData = Buffer.from(part.slice(dataStart, dataEnd), "binary"); } } if (!fileData || !fileName.endsWith(".zip")) { return res.status(400).json({ error: "ZIP 파일이 필요합니다" }); } // ZIP 파일 처리 const AdmZip = (await import("adm-zip")).default; const zip = new AdmZip(fileData); const entries = zip.getEntries(); const { uploadToS3 } = await import("../lib/s3.js"); // 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; console.log( `[Admin] ZIP metadata: mod_id=${modId}, icon_size=${metadata.icon_size}` ); } catch (e) { console.warn("[Admin] metadata.json 파싱 실패:", e.message); } } if (!modId) { return res .status(400) .json({ error: "metadata.json에 mod_id가 없습니다" }); } let uploadedCount = 0; let skippedCount = 0; let updatedCount = 0; for (const entry of entries) { // 디렉토리 스킵 if (entry.isDirectory) continue; // PNG 파일만 처리 if (!entry.entryName.endsWith(".png")) continue; // 새 ZIP 구조: items/.png const pathParts = entry.entryName.split("/"); // items/brass_casing.png -> brass_casing const itemId = pathParts[pathParts.length - 1].replace(".png", ""); // icons/items에 저장: modid_name.png const s3Key = `icons/items/${modId}_${itemId}.png`; // 파일 업로드 const imageData = entry.getData(); try { await uploadToS3("minecraft", s3Key, imageData, "image/png"); } catch (s3Error) { console.error( `[Admin] S3 업로드 실패: ${s3Key} - ${s3Error.message}` ); skippedCount++; continue; } // items 테이블의 icon 컬럼 업데이트 (block과 item 모두) 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] ); if (result.affectedRows > 0) { updatedCount++; } uploadedCount++; } console.log( `[Admin] 아이콘 업로드 완료 [${modId}]: ${uploadedCount}개 업로드, ${updatedCount}개 DB 업데이트, ${skippedCount}개 스킵` ); res.json({ success: true, uploaded: uploadedCount, updated: updatedCount, skipped: skippedCount, modId: modId, }); } catch (error) { console.error("[Admin] 아이콘 업로드 오류:", error); res.status(500).json({ error: "업로드 실패: " + error.message }); } } ); /** * DELETE /api/admin/icons/:modId - 특정 모드 아이콘 초기화 * blocks/items 테이블의 icon 컬럼을 NULL로 설정 */ router.delete("/icons/:modId", requireAdmin, async (req, res) => { const { modId } = req.params; try { // items 테이블의 icon 컬럼 NULL로 업데이트 const [result] = await pool.query( `UPDATE items SET icon = NULL WHERE mod_id = ? AND icon IS NOT NULL`, [modId] ); console.log(`[Admin] 아이콘 초기화: ${modId} (${result.affectedRows}개)`); res.json({ success: true, deleted: result.affectedRows }); } catch (error) { console.error("[Admin] 아이콘 초기화 오류:", error); res.status(500).json({ error: "삭제 실패" }); } }); export default router;