diff --git a/manifest/music-quiz.json b/manifest/music-quiz.json index 34fcd97..a71f64b 100644 --- a/manifest/music-quiz.json +++ b/manifest/music-quiz.json @@ -5,18 +5,8 @@ "type": "forge", "downloadUrl": "/forge-installer.jar" }, - "mods": [ - { - "name": "ExampleMod", - "downloadUrl": "https://example.com/examplemod.jar" - } - ], - "resourcepacks": [ - { - "name": "ExampleResourcePack", - "downloadUrl": "https://example.com/resourcepack.zip" - } - ], + "modsFolder": "music-quiz", + "resourcepackPath": "music-quiz.zip", "serverMinRam": 2048, "serverMaxRam": 8192, "clientMinRam": 4096, diff --git a/src/installer/main.ts b/src/installer/main.ts index fd089c0..b7ef68a 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -246,6 +246,43 @@ async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise await downloadAndExtractZip(url, '맵', savesDir) } +async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise { + 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 { + 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) diff --git a/src/server/app.ts b/src/server/app.ts index 4c47fa9..343c95d 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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) diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index 6c31290..f6d369c 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -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)), diff --git a/src/shared/store.ts b/src/shared/store.ts index beabda7..29e51bd 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -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 & Record): PackDefinition { @@ -62,28 +71,6 @@ export function normalizePackDefinition(input: Partial & Record< ? (platform.type as LoaderType) : 'vanilla' - const modsSource = Array.isArray(input.mods) ? input.mods : [] - const mods = modsSource - .map((entry) => { - const value = entry as Partial - 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 - 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 & 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), diff --git a/src/shared/types.ts b/src/shared/types.ts index 7d7cf8e..5f64155 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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// 폴더 안의 모든 .jar을 자동 다운로드. */ + modsFolder: string + /** /file/resourcepacks/ 의 단일 .zip을 그대로 다운로드. */ + resourcepackPath: string serverMinRam: number serverMaxRam: number clientMinRam: number diff --git a/views/index.ejs b/views/index.ejs index fdfc2df..96a396c 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -25,7 +25,7 @@
  • 마인크래프트 <%= entry.definition.mcVersion %>
  • 플랫폼 <%= entry.definition.platform.type %>
  • -
  • 모드 <%= entry.definition.mods.length %>개 / 리소스팩 <%= entry.definition.resourcepacks.length %>개
  • +
  • 모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %>
<% } %> diff --git a/views/op/dashboard.ejs b/views/op/dashboard.ejs index ee2764a..ae26949 100644 --- a/views/op/dashboard.ejs +++ b/views/op/dashboard.ejs @@ -38,7 +38,7 @@
  • MC <%= item.definition.mcVersion %>
  • 플랫폼 <%= item.definition.platform.type %>
  • -
  • 모드 <%= item.definition.mods.length %>개
  • +
  • 모드 폴더 <%= item.definition.modsFolder || '없음' %>
<% } %> diff --git a/views/op/editor.ejs b/views/op/editor.ejs index 99a4477..a5788c8 100644 --- a/views/op/editor.ejs +++ b/views/op/editor.ejs @@ -80,33 +80,18 @@ -
- 모드 (.jar) -
- <% pack.mods.forEach(function (mod) { %> -
- - - -
- <% }) %> -
- -
- -
- 리소스팩 (.zip) -
- <% pack.resourcepacks.forEach(function (resourcePack) { %> -
- - - -
- <% }) %> -
- -
+
+ + +
@@ -129,32 +114,6 @@ platformSelect.addEventListener('change', syncPlatformVisibility) syncPlatformVisibility() - document.querySelectorAll('[data-add]').forEach(function (button) { - button.addEventListener('click', function () { - var listKey = button.getAttribute('data-add') - var list = document.querySelector('[data-list="' + listKey + '"]') - if (!list) return - var nameField = listKey === 'mods' ? 'modName' : 'resourceName' - var urlField = listKey === 'mods' ? 'modUrl' : 'resourceUrl' - var placeholder = listKey === 'mods' ? '모드 이름' : '리소스팩 이름' - var row = document.createElement('div') - row.className = 'dynamicRow' - row.innerHTML = - '' + - '' + - '' - list.appendChild(row) - }) - }) - - document.addEventListener('click', function (event) { - var target = event.target - if (!(target instanceof HTMLElement)) return - if (!target.classList.contains('dynamicRemove')) return - var row = target.closest('.dynamicRow') - if (row) row.remove() - }) - var form = document.getElementById('editorForm') form.addEventListener('submit', function (event) { var clientMin = Number(form.clientMinRam.value)