Switch mods to per-folder auto-discovery and resourcepack to single zip

- PackDefinition: replace mods[]/resourcepacks[] with modsFolder (string) + resourcepackPath (string); drop PackAsset
- Editor: replace dynamic add/remove lists with two single inputs; remove the now-dead JS for adding/removing rows
- Server: expose GET /file/mods/<folder>/index.json that returns the list of .jar names; folder name restricted to [a-zA-Z0-9_-]+
- Installer: fetch the listing JSON and download each jar from /file/mods/<folder>/<file>.jar; download the single resourcepack from /file/resourcepacks/<file>.zip directly into resourcepacks/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 20:51:44 +09:00
parent 9c4f0e8dbc
commit 44847b8a55
9 changed files with 99 additions and 122 deletions

View File

@@ -246,6 +246,43 @@ async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise
await downloadAndExtractZip(url, '맵', savesDir)
}
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
if (!pack.modsFolder) {
sendLog('modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.')
return
}
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
sendLog(`모드 목록 조회: ${indexUrl}`)
const listing = await fetchJson<{ files?: unknown }>(indexUrl)
const files = Array.isArray(listing.files)
? listing.files.filter((name): name is string => typeof name === 'string' && /\.jar$/i.test(name))
: []
if (files.length === 0) {
sendLog(`/file/mods/${pack.modsFolder}/ 안에 .jar 파일이 없습니다.`)
return
}
const modsDir = path.join(customRoot, 'mods')
await fsp.mkdir(modsDir, { recursive: true })
for (const fileName of files) {
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
const target = path.join(modsDir, fileName)
sendLog(`모드 다운로드: ${fileName}`)
await downloadFile(url, target)
}
}
async function downloadResourcepackZip(pack: PackDefinition, customRoot: string): Promise<void> {
if (!pack.resourcepackPath) {
sendLog('resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.')
return
}
const url = `${state.baseUrl}/file/resourcepacks/${pack.resourcepackPath.replace(/^\/+/, '')}`
const target = path.join(customRoot, 'resourcepacks', pack.resourcepackPath.replace(/^\/+/, ''))
await fsp.mkdir(path.dirname(target), { recursive: true })
sendLog(`리소스팩 다운로드: ${url}`)
await downloadFile(url, target)
}
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
const pack = state.packs.get(payload.packKey)
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
@@ -496,18 +533,8 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.')
}
for (const mod of pack.pack.mods) {
if (!mod.downloadUrl) continue
const target = path.join(customRoot, 'mods', deriveFileName(mod.downloadUrl) || `${mod.name}.jar`)
sendLog(`모드 다운로드: ${mod.name}`)
await downloadFile(mod.downloadUrl, target)
}
for (const resourcePack of pack.pack.resourcepacks) {
if (!resourcePack.downloadUrl) continue
const target = path.join(customRoot, 'resourcepacks', deriveFileName(resourcePack.downloadUrl) || `${resourcePack.name}.zip`)
sendLog(`리소스팩 다운로드: ${resourcePack.name}`)
await downloadFile(resourcePack.downloadUrl, target)
}
await downloadModsFolder(pack.pack, customRoot)
await downloadResourcepackZip(pack.pack, customRoot)
await downloadMapZip(pack.pack, customRoot)

View File

@@ -1,6 +1,7 @@
import express from 'express'
import session from 'express-session'
import path from 'node:path'
import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths'
import { indexRouter } from './routes/index'
import { opRouter } from './routes/op'
@@ -69,6 +70,30 @@ app.use((req, res, next) => {
next()
})
// 모드 폴더 안의 .jar 파일 목록을 JSON으로 반환. 설치기가 자동 다운로드용으로 사용.
app.get('/file/mods/:folder/index.json', async (req, res) => {
const folder = req.params.folder
if (!/^[a-zA-Z0-9_\-]+$/.test(folder)) {
res.status(404).json({ files: [] })
return
}
const dir = path.join(fileDirPath, 'mods', folder)
try {
const entries = await fsp.readdir(dir)
const files = entries
.filter((name) => /\.jar$/i.test(name))
.filter((name) => !name.includes('/') && !name.includes('\\'))
.sort()
res.json({ files })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
res.status(404).json({ files: [] })
return
}
throw error
}
})
app.use('/file', express.static(fileDirPath, { fallthrough: true, index: false }))
app.use('/', indexRouter)

View File

@@ -122,14 +122,6 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const requestedKey = sanitizePackKey(pickFirstValue(req.body.fileName)) || packKey
const modNames = pickStringArray(req.body['modName']).map((value) => value.trim())
const modUrls = pickStringArray(req.body['modUrl']).map((value) => value.trim())
const mods = modNames.map((name, index) => ({ name, downloadUrl: modUrls[index] ?? '' }))
const resourceNames = pickStringArray(req.body['resourceName']).map((value) => value.trim())
const resourceUrls = pickStringArray(req.body['resourceUrl']).map((value) => value.trim())
const resourcepacks = resourceNames.map((name, index) => ({ name, downloadUrl: resourceUrls[index] ?? '' }))
const platformType = pickFirstValue(req.body.platformType)
const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim()
@@ -140,8 +132,8 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
type: (platformType as PackDefinition['platform']['type']) || 'vanilla',
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined
},
mods,
resourcepacks,
modsFolder: pickFirstValue(req.body.modsFolder),
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),

View File

@@ -32,8 +32,8 @@ export function defaultPackDefinition(name: string): PackDefinition {
name,
mcVersion: '1.20.1',
platform: { type: 'vanilla' },
mods: [],
resourcepacks: [],
modsFolder: '',
resourcepackPath: '',
serverMinRam: 2048,
serverMaxRam: 4096,
clientMinRam: 2048,
@@ -53,6 +53,15 @@ function sanitizeZipFileName(input: unknown): string {
return trimmed
}
// 모드 폴더명: 영문/숫자/언더스코어/하이픈만 허용. 빈 값 허용.
function sanitizeFolderName(input: unknown): string {
if (typeof input !== 'string') return ''
const trimmed = input.trim().replace(/^\/+|\/+$/g, '')
if (trimmed.length === 0) return ''
if (!/^[a-zA-Z0-9_\-]+$/.test(trimmed)) return ''
return trimmed
}
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
@@ -62,28 +71,6 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
? (platform.type as LoaderType)
: 'vanilla'
const modsSource = Array.isArray(input.mods) ? input.mods : []
const mods = modsSource
.map((entry) => {
const value = entry as Partial<PackDefinition['mods'][number]>
return {
name: typeof value?.name === 'string' ? value.name.trim() : '',
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
}
})
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
const resourcePacksSource = Array.isArray(input.resourcepacks) ? input.resourcepacks : []
const resourcepacks = resourcePacksSource
.map((entry) => {
const value = entry as Partial<PackDefinition['resourcepacks'][number]>
return {
name: typeof value?.name === 'string' ? value.name.trim() : '',
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
}
})
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
return {
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
@@ -95,8 +82,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
? platform.downloadUrl.trim()
: undefined
},
mods,
resourcepacks,
modsFolder: sanitizeFolderName(input.modsFolder),
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),

View File

@@ -5,17 +5,14 @@ export interface PackPlatform {
downloadUrl?: string
}
export interface PackAsset {
name: string
downloadUrl: string
}
export interface PackDefinition {
name: string
mcVersion: string
platform: PackPlatform
mods: PackAsset[]
resourcepacks: PackAsset[]
/** /file/mods/<modsFolder>/ 폴더 안의 모든 .jar을 자동 다운로드. */
modsFolder: string
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
resourcepackPath: string
serverMinRam: number
serverMaxRam: number
clientMinRam: number