/** * 마인크래프트 계정 연동 API * * POST /link/request - 연동 토큰 생성 * GET /link/status - 연동 상태 확인 * POST /link/verify - 마인크래프트에서 연동 완료 (모드에서 호출) * POST /link/unlink - 연동 해제 */ import express from "express"; import jwt from "jsonwebtoken"; import { pool } from "../lib/db.js"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; const router = express.Router(); const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret"; const S3_ENDPOINT = process.env.S3_ENDPOINT || "http://rustfs:9000"; const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY; const S3_SECRET_KEY = process.env.S3_SECRET_KEY; // S3 클라이언트 설정 const s3Client = new S3Client({ endpoint: S3_ENDPOINT, region: "us-east-1", credentials: { accessKeyId: S3_ACCESS_KEY || "", secretAccessKey: S3_SECRET_KEY || "", }, forcePathStyle: true, }); /** * 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; } } /** * 랜덤 6자리 토큰 생성 */ function generateLinkToken() { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 혼동되는 문자 제외 let token = ""; for (let i = 0; i < 6; i++) { token += chars.charAt(Math.floor(Math.random() * chars.length)); } return token; } /** * 랜덤 닉네임 생성 (연동 해제용) */ async function generateRandomNickname() { try { const [adjectives] = await pool.query( "SELECT word FROM nickname_words WHERE type = 'adjective' ORDER BY RAND() LIMIT 1" ); const [nouns] = await pool.query( "SELECT word FROM nickname_words WHERE type = 'noun' ORDER BY RAND() LIMIT 1" ); if (adjectives.length > 0 && nouns.length > 0) { return `${adjectives[0].word} ${nouns[0].word}`; } return `사용자${Math.floor(Math.random() * 10000)}`; } catch { return `사용자${Math.floor(Math.random() * 10000)}`; } } /** * 랜덤 프로필 URL 생성 (연동 해제용) */ function getRandomProfileUrl() { const num = Math.floor(Math.random() * 16) + 1; return `https://s3.caadiq.co.kr/minecraft/profile/default_profile_${num}.png`; } /** * 플레이어 스킨 다운로드 후 S3 업로드 */ async function downloadAndUploadSkin(uuid, minecraftName) { try { console.log(`[Link] 스킨 다운로드 시작: ${minecraftName} (${uuid})`); // mc-heads.net에서 스킨 헤드 다운로드 const skinUrl = `https://mc-heads.net/avatar/${uuid}/128`; console.log(`[Link] 스킨 URL: ${skinUrl}`); const response = await fetch(skinUrl); if (!response.ok) { console.error( `[Link] 스킨 다운로드 실패: ${response.status} ${response.statusText}` ); return null; } const imageBuffer = await response.arrayBuffer(); console.log(`[Link] 스킨 다운로드 완료: ${imageBuffer.byteLength} bytes`); // S3에 업로드 const key = `profile/${uuid}.png`; console.log(`[Link] S3 업로드 시작: bucket=minecraft, key=${key}`); await s3Client.send( new PutObjectCommand({ Bucket: "minecraft", Key: key, Body: Buffer.from(imageBuffer), ContentType: "image/png", }) ); const profileUrl = `https://s3.caadiq.co.kr/minecraft/${key}`; console.log(`[Link] 스킨 업로드 완료: ${profileUrl}`); return profileUrl; } catch (error) { console.error("[Link] 스킨 처리 오류:", error.message, error.stack); return null; } } /** * POST /link/request - 연동 토큰 생성 */ router.post("/request", async (req, res) => { const user = getUserFromToken(req); if (!user) { return res.status(401).json({ error: "로그인이 필요합니다." }); } try { // 기존 토큰 확인/갱신 const [existing] = await pool.query( "SELECT * FROM minecraft_links WHERE user_id = ?", [user.id] ); // 이미 연동된 경우 if (existing.length > 0 && existing[0].minecraft_uuid) { return res.json({ linked: true, minecraftName: existing[0].minecraft_name, minecraftUuid: existing[0].minecraft_uuid, }); } // 새 토큰 생성 const linkToken = generateLinkToken(); const tokenExpires = new Date(Date.now() + 10 * 60 * 1000); // 10분 if (existing.length > 0) { // 기존 레코드 업데이트 await pool.query( "UPDATE minecraft_links SET link_token = ?, token_expires = ? WHERE user_id = ?", [linkToken, tokenExpires, user.id] ); } else { // 새 레코드 생성 await pool.query( "INSERT INTO minecraft_links (user_id, link_token, token_expires) VALUES (?, ?, ?)", [user.id, linkToken, tokenExpires] ); } console.log(`[Link] 토큰 생성: ${user.email} -> ${linkToken}`); res.json({ linked: false, token: linkToken, command: `/인증 ${linkToken}`, expiresIn: 600, // 초 }); } catch (error) { console.error("[Link] 토큰 생성 오류:", error); res.status(500).json({ error: "서버 오류가 발생했습니다." }); } }); /** * GET /link/status - 연동 상태 확인 */ router.get("/status", async (req, res) => { const user = getUserFromToken(req); if (!user) { return res.status(401).json({ error: "로그인이 필요합니다." }); } try { const [links] = await pool.query( "SELECT minecraft_uuid, minecraft_name, linked_at FROM minecraft_links WHERE user_id = ?", [user.id] ); if (links.length === 0 || !links[0].minecraft_uuid) { return res.json({ linked: false }); } const uuid = links[0].minecraft_uuid; let currentName = links[0].minecraft_name; // 모드 API에서 최신 닉네임 조회 및 동기화 try { const MOD_API_URL = process.env.MOD_API_URL || "http://minecraft-server:8080"; const modRes = await fetch(`${MOD_API_URL}/player/${uuid}`); if (modRes.ok) { const playerData = await modRes.json(); if (playerData.name && playerData.name !== currentName) { // 닉네임이 변경되었으면 minecraft_links 및 users 테이블 모두 업데이트 await pool.query( "UPDATE minecraft_links SET minecraft_name = ? WHERE user_id = ?", [playerData.name, user.id] ); await pool.query("UPDATE users SET name = ? WHERE id = ?", [ playerData.name, user.id, ]); currentName = playerData.name; console.log( `[Link] 닉네임 동기화: ${links[0].minecraft_name} → ${currentName}` ); } } } catch (modErr) { // 모드 API 호출 실패해도 기존 데이터로 응답 console.log("[Link] 닉네임 동기화 실패 (모드 API 오류):", modErr.message); } res.json({ linked: true, minecraftName: currentName, minecraftUuid: uuid, linkedAt: links[0].linked_at, }); } catch (error) { console.error("[Link] 상태 확인 오류:", error); res.status(500).json({ error: "서버 오류가 발생했습니다." }); } }); /** * POST /link/verify - 마인크래프트에서 연동 완료 (ServerStatus 모드에서 호출) */ router.post("/verify", async (req, res) => { const { token, uuid, name } = req.body; if (!token || !uuid || !name) { return res.status(400).json({ error: "필수 파라미터가 누락되었습니다." }); } try { // 이미 연동된 UUID인지 확인 (중복 연동 방지) const [existingUuid] = await pool.query( "SELECT user_id FROM minecraft_links WHERE minecraft_uuid = ?", [uuid] ); if (existingUuid.length > 0) { return res.status(400).json({ error: "이미 다른 계정에 연동된 마인크래프트 계정입니다.", }); } // 토큰 검증 const [links] = await pool.query( "SELECT * FROM minecraft_links WHERE link_token = ? AND token_expires > NOW()", [token.toUpperCase()] ); if (links.length === 0) { return res .status(400) .json({ error: "유효하지 않거나 만료된 토큰입니다." }); } const link = links[0]; // 스킨 다운로드 및 업로드 const profileUrl = await downloadAndUploadSkin(uuid, name); console.log(`[Link] 프로필 URL 결과: ${profileUrl}`); // 연동 정보 업데이트 await pool.query( `UPDATE minecraft_links SET minecraft_uuid = ?, minecraft_name = ?, linked_at = NOW(), link_token = NULL, token_expires = NULL WHERE id = ?`, [uuid, name, link.id] ); // users 테이블 업데이트 (닉네임, 프로필 사진) if (profileUrl) { await pool.query( "UPDATE users SET name = ?, profile_url = ? WHERE id = ?", [name, profileUrl, link.user_id] ); console.log( `[Link] 사용자 업데이트 완료: name=${name}, profile_url=${profileUrl}` ); } else { await pool.query("UPDATE users SET name = ? WHERE id = ?", [ name, link.user_id, ]); console.log( `[Link] 사용자 업데이트 완료: name=${name} (프로필 사진 없음)` ); } console.log( `[Link] 연동 완료: user_id=${link.user_id}, minecraft=${name} (${uuid})` ); res.json({ success: true, message: `${name}님, 계정 연동이 완료되었습니다!`, }); } catch (error) { console.error("[Link] 연동 오류:", error); res.status(500).json({ error: "서버 오류가 발생했습니다." }); } }); /** * POST /link/unlink - 연동 해제 (닉네임, 프로필 초기화) */ router.post("/unlink", async (req, res) => { const user = getUserFromToken(req); if (!user) { return res.status(401).json({ error: "로그인이 필요합니다." }); } try { // 연동 해제 await pool.query("DELETE FROM minecraft_links WHERE user_id = ?", [ user.id, ]); // 닉네임, 프로필 초기화 const newNickname = await generateRandomNickname(); const newProfileUrl = getRandomProfileUrl(); await pool.query( "UPDATE users SET name = ?, profile_url = ? WHERE id = ?", [newNickname, newProfileUrl, user.id] ); console.log( `[Link] 연동 해제: user_id=${user.id}, 새 닉네임=${newNickname}` ); res.json({ success: true, newName: newNickname, newProfileUrl: newProfileUrl, }); } catch (error) { console.error("[Link] 연동 해제 오류:", error); res.status(500).json({ error: "서버 오류가 발생했습니다." }); } }); /** * POST /link/cancel - 대기 중인 토큰 취소 (새로고침/페이지 이탈 시) */ router.post("/cancel", express.text({ type: "*/*" }), async (req, res) => { try { // sendBeacon은 JSON을 text로 보내므로 파싱 let authToken; try { const body = typeof req.body === "string" ? JSON.parse(req.body) : req.body; authToken = body.authToken; } catch { authToken = null; } if (!authToken) { return res.status(200).json({ success: false }); } // JWT에서 사용자 ID 추출 let userId; try { const decoded = jwt.verify(authToken, JWT_SECRET); userId = decoded.id; } catch { return res.status(200).json({ success: false }); } // 대기 중인 토큰 삭제 (연동 완료되지 않은 것만) await pool.query( "UPDATE minecraft_links SET link_token = NULL, token_expires = NULL WHERE user_id = ? AND minecraft_uuid IS NULL", [userId] ); console.log(`[Link] 토큰 취소: user_id=${userId}`); res.json({ success: true }); } catch (error) { console.error("[Link] 토큰 취소 오류:", error); res.status(200).json({ success: false }); } }); /** * GET /link/skin/:type/:uuid/:size - 스킨 URL 조회 (캐싱) * type: avatar 또는 body * RustFS에 있으면 S3 URL 반환, 없으면 mc-heads에서 다운로드하여 저장 후 반환 */ router.get("/skin/:type/:uuid/:size", async (req, res) => { const { type, uuid, size } = req.params; if (!uuid || uuid.length < 32) { return res.status(400).json({ error: "유효하지 않은 UUID입니다." }); } const validTypes = ["avatar", "body"]; const skinType = validTypes.includes(type) ? type : "avatar"; const skinSize = parseInt(size) || 128; // S3 URL (타입별로 폴더 분리) const s3Key = `skins/${skinType}/${uuid}_${skinSize}.png`; const s3Url = `https://s3.caadiq.co.kr/minecraft/${s3Key}`; try { // S3에 파일 있는지 HEAD 요청으로 확인 const headRes = await fetch(s3Url, { method: "HEAD" }); if (headRes.ok) { // 이미 캐시됨 return res.json({ url: s3Url, cached: true }); } // 없으면 mc-heads에서 다운로드 console.log(`[Link] 스킨 캐싱: ${skinType}/${uuid}/${skinSize}`); const skinUrl = `https://mc-heads.net/${skinType}/${uuid}/${skinSize}`; const skinRes = await fetch(skinUrl); if (!skinRes.ok) { return res .status(404) .json({ error: "스킨을 찾을 수 없습니다.", url: skinUrl }); } const imageBuffer = await skinRes.arrayBuffer(); // S3에 업로드 await s3Client.send( new PutObjectCommand({ Bucket: "minecraft", Key: s3Key, Body: Buffer.from(imageBuffer), ContentType: "image/png", }) ); console.log(`[Link] 스킨 캐시 완료: ${skinType}/${uuid}/${skinSize}`); res.json({ url: s3Url, cached: false }); } catch (error) { console.error("[Link] 스킨 캐싱 오류:", error); // 폴백: mc-heads URL 직접 반환 res.json({ url: `https://mc-heads.net/${skinType}/${uuid}/${skinSize}`, cached: false, fallback: true, }); } }); export default router;