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:
2026-05-10 20:06:32 +09:00
parent 7a963d0409
commit 9c4f0e8dbc
7 changed files with 82 additions and 41 deletions

View File

@@ -3,7 +3,7 @@
"mcVersion": "1.20.1", "mcVersion": "1.20.1",
"platform": { "platform": {
"type": "forge", "type": "forge",
"downloadUrl": "https://example.com/forge-installer.jar" "downloadUrl": "/forge-installer.jar"
}, },
"mods": [ "mods": [
{ {
@@ -21,5 +21,6 @@
"serverMaxRam": 8192, "serverMaxRam": 8192,
"clientMinRam": 4096, "clientMinRam": 4096,
"clientRecommendedRam": 8192, "clientRecommendedRam": 8192,
"packPath": "music-quiz/files" "mapPath": "music-quiz-map.zip",
"serverPath": "music-quiz-server.zip"
} }

View File

@@ -8,6 +8,8 @@ import fsp from 'node:fs/promises'
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { URL } from 'node:url' import { URL } from 'node:url'
import natUpnp from 'nat-upnp' import natUpnp from 'nat-upnp'
// extract-zip은 CommonJS 기본 export.
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
import type { import type {
ClientInstallPayload, ClientInstallPayload,
FetchedPack, FetchedPack,
@@ -201,30 +203,47 @@ ipcMain.handle('jdk:detect', async () => {
return { found: false, path: '' } return { found: false, path: '' }
}) })
async function downloadServerFiles(pack: PackDefinition, targetDir: string): Promise<void> { /**
const indexUrl = `${state.baseUrl}/file/${pack.packPath.replace(/^\/+|\/+$/g, '')}` * 입력값이 절대 URL이면 그대로, 상대값이면 manifest 도메인의 /file/<subDir>/<file> 로 해석.
sendLog(`서버 파일 인덱스: ${indexUrl}`) */
let listing: string[] = [] function resolveManifestRelative(input: string, subDir: string): string {
try { if (!input) return ''
const directoryHtml = (await fetchBuffer(indexUrl)).toString('utf8') if (/^https?:\/\//i.test(input)) return input
listing = Array.from(directoryHtml.matchAll(/href=\"([^\"]+)\"/g)) const fileName = input.replace(/^\/+/, '')
.map((match) => match[1]) return `${state.baseUrl}/file/${subDir}/${fileName}`
.filter((href) => !href.startsWith('?') && !href.endsWith('/')) }
} catch (error) {
sendLog(`서버 파일 인덱스 로드 실패: ${(error as Error).message}`)
}
if (listing.length === 0) { async function downloadAndExtractZip(url: string, label: string, extractDir: string): Promise<void> {
sendLog('서버 파일 인덱스를 가져올 수 없습니다. packPath 또는 사이트 디렉토리 인덱스 설정을 확인해 주세요.') 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 return
} }
const url = resolveManifestRelative(pack.serverPath, 'servers')
await downloadAndExtractZip(url, '서버 파일', targetDir)
}
for (const fileName of listing) { async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
const targetUrl = `${indexUrl.replace(/\/$/, '')}/${fileName}` if (!pack.mapPath) {
const target = path.join(targetDir, decodeURIComponent(fileName)) sendLog('맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.')
sendLog(`다운로드: ${fileName}`) return
await downloadFile(targetUrl, target)
} }
const url = resolveManifestRelative(pack.mapPath, 'maps')
const savesDir = path.join(customRoot, 'saves')
await downloadAndExtractZip(url, '맵', savesDir)
} }
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => { 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 }) await fsp.mkdir(installPath, { recursive: true })
sendLog(`서버 설치 경로: ${installPath}`) sendLog(`서버 설치 경로: ${installPath}`)
await downloadServerFiles(pack.pack, installPath) await downloadServerZip(pack.pack, installPath)
const eulaPath = path.join(installPath, 'eula.txt') const eulaPath = path.join(installPath, 'eula.txt')
if (fs.existsSync(eulaPath)) { 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 }) await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true })
if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) { 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') const cacheDir = path.join(customRoot, 'platform-cache')
await fsp.mkdir(cacheDir, { recursive: true }) await fsp.mkdir(cacheDir, { recursive: true })
const installerPath = path.join(cacheDir, deriveFileName(pack.pack.platform.downloadUrl) || 'platform-installer.jar') const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar')
sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${pack.pack.platform.downloadUrl}`) sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${platformUrl}`)
await downloadFile(pack.pack.platform.downloadUrl, installerPath) await downloadFile(platformUrl, installerPath)
sendLog(`플랫폼 설치파일 저장: ${installerPath} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)`) sendLog(`플랫폼 설치파일 저장: ${installerPath} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)`)
} else if (!payload.installPlatform) { } else if (!payload.installPlatform) {
sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.') sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.')
@@ -489,6 +509,8 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
await downloadFile(resourcePack.downloadUrl, target) await downloadFile(resourcePack.downloadUrl, target)
} }
await downloadMapZip(pack.pack, customRoot)
await updateLauncherProfile(pack.pack, customRoot) await updateLauncherProfile(pack.pack, customRoot)
}) })

View File

@@ -38,12 +38,11 @@ opRouter.get('/op', (req, res) => {
opRouter.post('/op', async (req, res, next) => { opRouter.post('/op', async (req, res, next) => {
try { try {
const id = pickFirstValue(req.body.id).trim()
const password = pickFirstValue(req.body.password) const password = pickFirstValue(req.body.password)
const accounts = await readAccounts() 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) { if (!matched) {
res.status(401).render('op/login', { error: '아이디 또는 비밀번호가 올바르지 않습니다.' }) res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
return return
} }
req.session.userId = matched.id 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)), serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)), clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
clientRecommendedRam: Number(pickFirstValue(req.body.clientRecommendedRam)), 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) const normalized = normalizePackDefinition(partial)

View File

@@ -38,10 +38,21 @@ export function defaultPackDefinition(name: string): PackDefinition {
serverMaxRam: 4096, serverMaxRam: 4096,
clientMinRam: 2048, clientMinRam: 2048,
clientRecommendedRam: 4096, 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'] const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition { 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), serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam), clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam), clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam),
packPath: typeof input.packPath === 'string' ? input.packPath.trim() : '' mapPath: sanitizeZipFileName(input.mapPath),
serverPath: sanitizeZipFileName(input.serverPath)
} }
} }

View File

@@ -20,7 +20,10 @@ export interface PackDefinition {
serverMaxRam: number serverMaxRam: number
clientMinRam: number clientMinRam: number
clientRecommendedRam: number clientRecommendedRam: number
packPath: string /** /file/maps/<mapPath> 에서 받아 .mc_custom/saves 로 풀 zip 파일 이름. */
mapPath: string
/** /file/servers/<serverPath> 에서 받아 서버 설치 경로로 풀 zip 파일 이름. */
serverPath: string
} }
export interface ManifestEntry { export interface ManifestEntry {

View File

@@ -49,7 +49,8 @@
</label> </label>
<label class="fullSpan" id="platformDownloadField"> <label class="fullSpan" id="platformDownloadField">
<span>플랫폼 설치파일 URL</span> <span>플랫폼 설치파일 URL</span>
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="https://example.com/forge-installer.jar" /> <input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/&lt;파일명&gt;</code>으로 해석됩니다.</small>
</label> </label>
<label> <label>
<span>서버 최소 램 (MB)</span> <span>서버 최소 램 (MB)</span>
@@ -67,9 +68,15 @@
<span>클라이언트 권장 램 (MB)</span> <span>클라이언트 권장 램 (MB)</span>
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required /> <input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
</label> </label>
<label class="fullSpan"> <label>
<span>packPath (서버 파일 경로, /file/ 이후만)</span> <span>맵 파일 (.zip)</span>
<input name="packPath" value="<%= pack.packPath %>" placeholder="music-quiz/files" /> <input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" />
<small class="muted">/file/maps/ 아래 zip 파일 이름.</small>
</label>
<label>
<span>서버 파일 (.zip)</span>
<input name="serverPath" value="<%= pack.serverPath %>" placeholder="my-server.zip" pattern=".+\.zip" />
<small class="muted">/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.</small>
</label> </label>
</div> </div>

View File

@@ -13,13 +13,9 @@
<p class="errorBanner"><%= error %></p> <p class="errorBanner"><%= error %></p>
<% } %> <% } %>
<form method="post" action="/op" class="loginForm"> <form method="post" action="/op" class="loginForm">
<label>
<span>아이디</span>
<input name="id" autocomplete="username" required autofocus />
</label>
<label> <label>
<span>비밀번호</span> <span>비밀번호</span>
<input name="password" type="password" autocomplete="current-password" required /> <input name="password" type="password" autocomplete="current-password" required autofocus />
</label> </label>
<button class="primaryButton" type="submit">로그인</button> <button class="primaryButton" type="submit">로그인</button>
</form> </form>