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}/<name>; downloads and extracts the zips
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> = 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<void> {
|
||||
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/<subDir>/<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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PackDefinition> & Record<string, unknown>): PackDefinition {
|
||||
@@ -90,7 +101,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ export interface PackDefinition {
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
clientRecommendedRam: number
|
||||
packPath: string
|
||||
/** /file/maps/<mapPath> 에서 받아 .mc_custom/saves 로 풀 zip 파일 이름. */
|
||||
mapPath: string
|
||||
/** /file/servers/<serverPath> 에서 받아 서버 설치 경로로 풀 zip 파일 이름. */
|
||||
serverPath: string
|
||||
}
|
||||
|
||||
export interface ManifestEntry {
|
||||
|
||||
Reference in New Issue
Block a user