diff --git a/backend/lib/db.js b/backend/lib/db.js index eb0eead..9dbaa95 100644 --- a/backend/lib/db.js +++ b/backend/lib/db.js @@ -93,6 +93,7 @@ async function initModpacksTable() { changelog TEXT, file_key VARCHAR(500) NOT NULL, file_size BIGINT DEFAULT 0, + original_filename VARCHAR(255), contents_json LONGTEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/backend/lib/s3.js b/backend/lib/s3.js index bb5f3f4..08e1ba9 100644 --- a/backend/lib/s3.js +++ b/backend/lib/s3.js @@ -1,5 +1,9 @@ -import crypto from "crypto"; -import http from "http"; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + HeadObjectCommand, +} from "@aws-sdk/client-s3"; // S3 설정 (RustFS) - 환경변수에서 로드 const s3Config = { @@ -10,144 +14,77 @@ const s3Config = { publicUrl: "https://s3.caadiq.co.kr", }; -/** - * AWS Signature V4 서명 - */ -function signV4(method, path, headers, payload, accessKey, secretKey) { - const service = "s3"; - const region = "us-east-1"; - const now = new Date(); - const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); - const dateStamp = amzDate.slice(0, 8); - - headers["x-amz-date"] = amzDate; - headers["x-amz-content-sha256"] = crypto - .createHash("sha256") - .update(payload) - .digest("hex"); - - const signedHeaders = Object.keys(headers) - .map((k) => k.toLowerCase()) - .sort() - .join(";"); - const canonicalHeaders = Object.keys(headers) - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) - .map((k) => `${k.toLowerCase()}:${headers[k].trim()}`) - .join("\n"); - - const canonicalRequest = [ - method, - path, - "", - canonicalHeaders + "\n", - signedHeaders, - headers["x-amz-content-sha256"], - ].join("\n"); - const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; - const stringToSign = [ - "AWS4-HMAC-SHA256", - amzDate, - credentialScope, - crypto.createHash("sha256").update(canonicalRequest).digest("hex"), - ].join("\n"); - - const getSignatureKey = (key, ds, rg, sv) => { - const kDate = crypto.createHmac("sha256", `AWS4${key}`).update(ds).digest(); - const kRegion = crypto.createHmac("sha256", kDate).update(rg).digest(); - const kService = crypto.createHmac("sha256", kRegion).update(sv).digest(); - return crypto - .createHmac("sha256", kService) - .update("aws4_request") - .digest(); - }; - - const signingKey = getSignatureKey(secretKey, dateStamp, region, service); - const signature = crypto - .createHmac("sha256", signingKey) - .update(stringToSign) - .digest("hex"); - headers[ - "Authorization" - ] = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; - return headers; -} +// S3 클라이언트 생성 +const s3Client = new S3Client({ + endpoint: s3Config.endpoint, + region: "us-east-1", + credentials: { + accessKeyId: s3Config.accessKey, + secretAccessKey: s3Config.secretKey, + }, + forcePathStyle: true, // RustFS/MinIO용 +}); /** * S3(RustFS)에 파일 업로드 */ -function uploadToS3( +async function uploadToS3( bucket, key, data, contentType = "application/octet-stream" ) { - return new Promise((resolve, reject) => { - const url = new URL(s3Config.endpoint); - const path = `/${bucket}/${key}`; - const headers = { - Host: url.host, - "Content-Type": contentType, - "Content-Length": data.length.toString(), - }; - signV4("PUT", path, headers, data, s3Config.accessKey, s3Config.secretKey); - - const options = { - hostname: url.hostname, - port: url.port || 80, - path, - method: "PUT", - headers, - }; - const req = http.request(options, (res) => { - let body = ""; - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(`${s3Config.publicUrl}/${bucket}/${key}`); - } else { - reject(new Error(`S3 Upload failed: ${res.statusCode}`)); - } - }); - }); - req.on("error", reject); - req.write(data); - req.end(); + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: data, + ContentType: contentType, }); + + await s3Client.send(command); + // 공개 URL 반환 (key는 인코딩) + const encodedKey = key + .split("/") + .map((s) => encodeURIComponent(s)) + .join("/"); + return `${s3Config.publicUrl}/${bucket}/${encodedKey}`; } /** * S3(RustFS)에서 파일 다운로드 */ -function downloadFromS3(bucket, key) { - return new Promise((resolve, reject) => { - const url = new URL(s3Config.endpoint); - const path = `/${bucket}/${key}`; - const headers = { - Host: url.host, - }; - signV4("GET", path, headers, "", s3Config.accessKey, s3Config.secretKey); - - const options = { - hostname: url.hostname, - port: url.port || 80, - path, - method: "GET", - headers, - }; - const req = http.request(options, (res) => { - const chunks = []; - res.on("data", (chunk) => chunks.push(chunk)); - res.on("end", () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(Buffer.concat(chunks)); - } else { - reject(new Error(`S3 Download failed: ${res.statusCode}`)); - } - }); - }); - req.on("error", reject); - req.end(); +async function downloadFromS3(bucket, key) { + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key, }); + + const response = await s3Client.send(command); + // ReadableStream을 Buffer로 변환 + const chunks = []; + for await (const chunk of response.Body) { + chunks.push(chunk); + } + return Buffer.concat(chunks); } -export { s3Config, uploadToS3, downloadFromS3 }; +/** + * S3(RustFS)에 파일이 존재하는지 확인 + */ +async function checkS3Exists(bucket, key) { + try { + const command = new HeadObjectCommand({ + Bucket: bucket, + Key: key, + }); + await s3Client.send(command); + return true; + } catch (err) { + if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) { + return false; + } + throw err; + } +} + +export { s3Config, uploadToS3, downloadFromS3, checkS3Exists }; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index f618c08..779d877 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -367,15 +367,19 @@ router.post( // multipart 파싱 for (const part of parts) { if (part.includes('name="file"')) { - const match = part.match(/filename="([^"]+)"/); + // 헤더 부분을 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 = part.indexOf("\r\n\r\n") + 4; + + 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"); - // UTF-8로 디코딩 changelog = Buffer.from( part.slice(dataStart, dataEnd), "binary" @@ -408,37 +412,147 @@ router.post( (k) => k !== "minecraft" ) || null; - // contents 파싱 (mods, resourcepacks, shaderpacks) + // contents 파싱 - Modrinth API로 모드 정보 가져오기 const contents = { mods: [], resourcepacks: [], shaderpacks: [] }; + + // 각 카테고리별 파일 정보 수집 (sha1 해시 포함) + const filesByCategory = { mods: [], resourcepacks: [], shaderpacks: [] }; for (const file of indexJson.files || []) { const path = file.path; - // 파일명에서 이름과 버전 추출 시도 (예: Create-0.5.1.jar) + const sha1 = file.hashes?.sha1; const fileName = path .split("/") .pop() .replace(/\.(jar|zip)$/, ""); - const match = fileName.match(/^(.+?)[-_](\d+[\d.]+\d*)$/); - const name = match ? match[1].replace(/[-_]/g, " ") : fileName; - const version = match ? match[2] : ""; if (path.startsWith("mods/")) { - contents.mods.push({ name, version }); + filesByCategory.mods.push({ fileName, sha1 }); } else if (path.startsWith("resourcepacks/")) { - contents.resourcepacks.push({ name, version }); + filesByCategory.resourcepacks.push({ fileName, sha1 }); } else if (path.startsWith("shaderpacks/")) { - contents.shaderpacks.push({ name, version }); + filesByCategory.shaderpacks.push({ fileName, sha1 }); } } - // S3에 업로드 (경로는 ASCII만 사용) - const safeName = modpackName - .replace(/[^a-zA-Z0-9-]/g, "_") // 영문, 숫자, 하이픈만 허용 - .replace(/_+/g, "_") // 연속 언더스코어 정리 - .replace(/^_|_$/g, ""); // 앞뒤 언더스코어 제거 - const s3Key = `modpacks/${ - safeName || "modpack" - }/${modpackVersion}.mrpack`; - const { uploadToS3 } = await import("../lib/s3.js"); + // 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"); // 중복 체크 diff --git a/backend/routes/api.js b/backend/routes/api.js index 9d2a8be..8c06d0c 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -147,10 +147,33 @@ router.get("/modpacks/:id/download", async (req, res) => { } const modpack = rows[0]; - const downloadUrl = `https://s3.caadiq.co.kr/minecraft/${modpack.file_key}`; + // file_key의 각 세그먼트를 인코딩 + const encodedKey = modpack.file_key + .split("/") + .map((s) => encodeURIComponent(s)) + .join("/"); + const downloadUrl = `https://s3.caadiq.co.kr/minecraft/${encodedKey}`; - // Content-Disposition 헤더로 파일명 지정하여 리디렉션 - res.redirect(downloadUrl); + // file_key에서 파일명 추출 (인코딩 안 된 원본) + const filename = modpack.file_key.split("/").pop(); + + // S3에서 파일 가져오기 + const s3Response = await fetch(downloadUrl); + if (!s3Response.ok) { + return res.status(404).json({ error: "파일을 찾을 수 없습니다" }); + } + + // 헤더 설정 + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Length", modpack.file_size); + res.setHeader( + "Content-Disposition", + `attachment; filename*=UTF-8''${encodeURIComponent(filename)}` + ); + + // 스트리밍 전달 + const { Readable } = await import("stream"); + Readable.fromWeb(s3Response.body).pipe(res); } catch (error) { console.error("[API] 모드팩 다운로드 실패:", error.message); res.status(500).json({ error: "다운로드 실패" }); diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 1846d66..81edd9a 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -133,6 +133,7 @@ export default function Admin({ isMobile = false }) { const [modpacks, setModpacks] = useState([]); const [modpackDeleteTarget, setModpackDeleteTarget] = useState(null); // 삭제 확인 다이얼로그용 const [modpackLoading, setModpackLoading] = useState(false); // 업로드/삭제 로딩 + const [isDragging, setIsDragging] = useState(false); // 드래그 상태 // 권한 확인 useEffect(() => { @@ -2071,7 +2072,27 @@ export default function Admin({ isMobile = false }) { {modpackDialogMode === 'upload' && (
-
@@ -108,9 +108,9 @@ const ModpackCard = ({ modpack, isLatest }) => { {/* 변경 로그 토글 */} @@ -125,13 +125,13 @@ const ModpackCard = ({ modpack, isLatest }) => { className="overflow-hidden" >
-
+
{modpack.changelog.split('\n').map((line, i) => { if (line.startsWith('###')) { - return

{line.replace('### ', '')}

; + return

{line.replace('### ', '')}

; } if (line.startsWith('- ')) { - return

• {line.replace('- ', '')}

; + return

• {line.replace('- ', '')}

; } return null; })} @@ -144,7 +144,7 @@ const ModpackCard = ({ modpack, isLatest }) => { {/* 포함 콘텐츠 토글 */}