From 9c4f0e8dbc33c350a5c32d3a9f756555e279846d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 10 May 2026 20:06:32 +0900 Subject: [PATCH] Switch login to password-only and split pack zip paths - Login form/route accepts password only; matched account row provides session userId - PackDefinition: replace packPath with mapPath (.mc_custom/saves) and serverPath (server install dir); editor exposes two .zip fields - Installer resolves relative platform/map/server URLs against manifest origin under /file/{platforms,maps,servers}/; downloads and extracts the zips Co-Authored-By: Claude Opus 4.7 --- manifest/music-quiz.json | 5 +-- src/installer/main.ts | 68 ++++++++++++++++++++++++++-------------- src/server/routes/op.ts | 8 ++--- src/shared/store.ts | 16 ++++++++-- src/shared/types.ts | 5 ++- views/op/editor.ejs | 15 ++++++--- views/op/login.ejs | 6 +--- 7 files changed, 82 insertions(+), 41 deletions(-) diff --git a/manifest/music-quiz.json b/manifest/music-quiz.json index 4669c8a..34fcd97 100644 --- a/manifest/music-quiz.json +++ b/manifest/music-quiz.json @@ -3,7 +3,7 @@ "mcVersion": "1.20.1", "platform": { "type": "forge", - "downloadUrl": "https://example.com/forge-installer.jar" + "downloadUrl": "/forge-installer.jar" }, "mods": [ { @@ -21,5 +21,6 @@ "serverMaxRam": 8192, "clientMinRam": 4096, "clientRecommendedRam": 8192, - "packPath": "music-quiz/files" + "mapPath": "music-quiz-map.zip", + "serverPath": "music-quiz-server.zip" } diff --git a/src/installer/main.ts b/src/installer/main.ts index aa3c84d..fd089c0 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -8,6 +8,8 @@ import fsp from 'node:fs/promises' import { spawn } from 'node:child_process' import { URL } from 'node:url' import natUpnp from 'nat-upnp' +// extract-zip은 CommonJS 기본 export. +const extractZip: (source: string, options: { dir: string }) => Promise = require('extract-zip') import type { ClientInstallPayload, FetchedPack, @@ -201,30 +203,47 @@ ipcMain.handle('jdk:detect', async () => { return { found: false, path: '' } }) -async function downloadServerFiles(pack: PackDefinition, targetDir: string): Promise { - const indexUrl = `${state.baseUrl}/file/${pack.packPath.replace(/^\/+|\/+$/g, '')}` - sendLog(`서버 파일 인덱스: ${indexUrl}`) - let listing: string[] = [] - try { - const directoryHtml = (await fetchBuffer(indexUrl)).toString('utf8') - listing = Array.from(directoryHtml.matchAll(/href=\"([^\"]+)\"/g)) - .map((match) => match[1]) - .filter((href) => !href.startsWith('?') && !href.endsWith('/')) - } catch (error) { - sendLog(`서버 파일 인덱스 로드 실패: ${(error as Error).message}`) - } +/** + * 입력값이 절대 URL이면 그대로, 상대값이면 manifest 도메인의 /file// 로 해석. + */ +function resolveManifestRelative(input: string, subDir: string): string { + if (!input) return '' + if (/^https?:\/\//i.test(input)) return input + const fileName = input.replace(/^\/+/, '') + return `${state.baseUrl}/file/${subDir}/${fileName}` +} - if (listing.length === 0) { - sendLog('서버 파일 인덱스를 가져올 수 없습니다. packPath 또는 사이트 디렉토리 인덱스 설정을 확인해 주세요.') +async function downloadAndExtractZip(url: string, label: string, extractDir: string): Promise { + await fsp.mkdir(extractDir, { recursive: true }) + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mq-zip-')) + const tempZip = path.join(tempDir, 'package.zip') + try { + sendLog(`${label} 다운로드: ${url}`) + await downloadFile(url, tempZip) + sendLog(`${label} 압축 해제: ${extractDir}`) + await extractZip(tempZip, { dir: extractDir }) + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }) + } +} + +async function downloadServerZip(pack: PackDefinition, targetDir: string): Promise { + if (!pack.serverPath) { + sendLog('서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.') return } + const url = resolveManifestRelative(pack.serverPath, 'servers') + await downloadAndExtractZip(url, '서버 파일', targetDir) +} - for (const fileName of listing) { - const targetUrl = `${indexUrl.replace(/\/$/, '')}/${fileName}` - const target = path.join(targetDir, decodeURIComponent(fileName)) - sendLog(`다운로드: ${fileName}`) - await downloadFile(targetUrl, target) +async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise { + if (!pack.mapPath) { + sendLog('맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.') + return } + const url = resolveManifestRelative(pack.mapPath, 'maps') + const savesDir = path.join(customRoot, 'saves') + await downloadAndExtractZip(url, '맵', savesDir) } ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => { @@ -238,7 +257,7 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) = await fsp.mkdir(installPath, { recursive: true }) sendLog(`서버 설치 경로: ${installPath}`) - await downloadServerFiles(pack.pack, installPath) + await downloadServerZip(pack.pack, installPath) const eulaPath = path.join(installPath, 'eula.txt') if (fs.existsSync(eulaPath)) { @@ -466,11 +485,12 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) = await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true }) if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) { + const platformUrl = resolveManifestRelative(pack.pack.platform.downloadUrl, 'platforms') const cacheDir = path.join(customRoot, 'platform-cache') await fsp.mkdir(cacheDir, { recursive: true }) - const installerPath = path.join(cacheDir, deriveFileName(pack.pack.platform.downloadUrl) || 'platform-installer.jar') - sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${pack.pack.platform.downloadUrl}`) - await downloadFile(pack.pack.platform.downloadUrl, installerPath) + const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar') + sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${platformUrl}`) + await downloadFile(platformUrl, installerPath) sendLog(`플랫폼 설치파일 저장: ${installerPath} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)`) } else if (!payload.installPlatform) { sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.') @@ -489,6 +509,8 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) = await downloadFile(resourcePack.downloadUrl, target) } + await downloadMapZip(pack.pack, customRoot) + await updateLauncherProfile(pack.pack, customRoot) }) diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index 5766100..6c31290 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -38,12 +38,11 @@ opRouter.get('/op', (req, res) => { opRouter.post('/op', async (req, res, next) => { try { - const id = pickFirstValue(req.body.id).trim() const password = pickFirstValue(req.body.password) const accounts = await readAccounts() - const matched = accounts.find((entry) => entry.id === id && entry.password === password) + const matched = accounts.find((entry) => entry.password === password) if (!matched) { - res.status(401).render('op/login', { error: '아이디 또는 비밀번호가 올바르지 않습니다.' }) + res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' }) return } req.session.userId = matched.id @@ -147,7 +146,8 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)), clientMinRam: Number(pickFirstValue(req.body.clientMinRam)), clientRecommendedRam: Number(pickFirstValue(req.body.clientRecommendedRam)), - packPath: pickFirstValue(req.body.packPath) + mapPath: pickFirstValue(req.body.mapPath), + serverPath: pickFirstValue(req.body.serverPath) } const normalized = normalizePackDefinition(partial) diff --git a/src/shared/store.ts b/src/shared/store.ts index 67aaae3..beabda7 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -38,10 +38,21 @@ export function defaultPackDefinition(name: string): PackDefinition { serverMaxRam: 4096, clientMinRam: 2048, clientRecommendedRam: 4096, - packPath: '' + mapPath: '', + serverPath: '' } } +function sanitizeZipFileName(input: unknown): string { + if (typeof input !== 'string') return '' + const trimmed = input.trim().replace(/^\/+/, '') + if (trimmed.length === 0) return '' + // 빈 값 허용, .zip 으로 끝나야 함, 경로 탈출 방지 + if (trimmed.includes('..') || trimmed.includes('\\')) return '' + if (!/\.zip$/i.test(trimmed)) return '' + return trimmed +} + const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge'] export function normalizePackDefinition(input: Partial & Record): PackDefinition { @@ -90,7 +101,8 @@ export function normalizePackDefinition(input: Partial & Record< serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam), clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam), clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam), - packPath: typeof input.packPath === 'string' ? input.packPath.trim() : '' + mapPath: sanitizeZipFileName(input.mapPath), + serverPath: sanitizeZipFileName(input.serverPath) } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 92b91f9..7d7cf8e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -20,7 +20,10 @@ export interface PackDefinition { serverMaxRam: number clientMinRam: number clientRecommendedRam: number - packPath: string + /** /file/maps/ 에서 받아 .mc_custom/saves 로 풀 zip 파일 이름. */ + mapPath: string + /** /file/servers/ 에서 받아 서버 설치 경로로 풀 zip 파일 이름. */ + serverPath: string } export interface ManifestEntry { diff --git a/views/op/editor.ejs b/views/op/editor.ejs index 98c4b75..99a4477 100644 --- a/views/op/editor.ejs +++ b/views/op/editor.ejs @@ -49,7 +49,8 @@ -