minecraft-web/backend/routes/link.js

387 lines
11 KiB
JavaScript

/**
* 마인크래프트 계정 연동 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 });
}
res.json({
linked: true,
minecraftName: links[0].minecraft_name,
minecraftUuid: links[0].minecraft_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 });
}
});
export default router;