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:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
for (const fileName of listing) {
|
await downloadAndExtractZip(url, '서버 파일', targetDir)
|
||||||
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) => {
|
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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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/<파일명></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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user