2025-12-22 09:36:23 +09:00
|
|
|
/**
|
|
|
|
|
* 마인크래프트 계정 연동 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 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 11:42:37 +09:00
|
|
|
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();
|
2025-12-23 10:07:34 +09:00
|
|
|
// displayName이 있으면 displayName 사용 (Essentials 닉네임), 없으면 name 사용
|
|
|
|
|
const newName = playerData.displayName || playerData.name;
|
|
|
|
|
if (newName && newName !== currentName) {
|
2025-12-22 11:42:37 +09:00
|
|
|
// 닉네임이 변경되었으면 minecraft_links 및 users 테이블 모두 업데이트
|
|
|
|
|
await pool.query(
|
|
|
|
|
"UPDATE minecraft_links SET minecraft_name = ? WHERE user_id = ?",
|
2025-12-23 10:07:34 +09:00
|
|
|
[newName, user.id]
|
2025-12-22 11:42:37 +09:00
|
|
|
);
|
|
|
|
|
await pool.query("UPDATE users SET name = ? WHERE id = ?", [
|
2025-12-23 10:07:34 +09:00
|
|
|
newName,
|
2025-12-22 11:42:37 +09:00
|
|
|
user.id,
|
|
|
|
|
]);
|
2025-12-23 10:07:34 +09:00
|
|
|
currentName = newName;
|
2025-12-22 11:42:37 +09:00
|
|
|
console.log(
|
|
|
|
|
`[Link] 닉네임 동기화: ${links[0].minecraft_name} → ${currentName}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (modErr) {
|
|
|
|
|
// 모드 API 호출 실패해도 기존 데이터로 응답
|
|
|
|
|
console.log("[Link] 닉네임 동기화 실패 (모드 API 오류):", modErr.message);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 09:36:23 +09:00
|
|
|
res.json({
|
|
|
|
|
linked: true,
|
2025-12-22 11:42:37 +09:00
|
|
|
minecraftName: currentName,
|
|
|
|
|
minecraftUuid: uuid,
|
2025-12-22 09:36:23 +09:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-22 11:42:37 +09:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-22 09:36:23 +09:00
|
|
|
export default router;
|