const express = require('express') const fs = require('fs-extra') const multer = require('multer') const path = require('path') const HOST = process.env.LAUNCHER_ADMIN_HOST || '127.0.0.1' const PORT = Number.parseInt(process.env.LAUNCHER_ADMIN_PORT || '8787', 10) const PROJECT_ROOT = path.resolve(__dirname, '..') const RUNTIME_DATA_DIR = path.join(__dirname, 'data') const UPLOADS_DIR = path.join(RUNTIME_DATA_DIR, 'uploads') const DISTRIBUTIONS_DIR = path.join(RUNTIME_DATA_DIR, 'distributions') const RUNTIME_CATALOG_PATH = path.join(RUNTIME_DATA_DIR, 'catalog.json') const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher', 'catalog.json') const PUBLIC_DIR = path.join(__dirname, 'public') const SAMPLE_DISTRIBUTION_PATH = path.join(PROJECT_ROOT, 'docs', 'sample_distribution.json') const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack']) function normalizeText(value){ return typeof value === 'string' ? value.trim() : '' } function normalizeMultilineText(value){ return typeof value === 'string' ? value.replace(/\r\n/g, '\n').trim() : '' } function normalizePort(value){ const port = Number.parseInt(String(value ?? ''), 10) if(Number.isFinite(port) && port >= 1 && port <= 65535){ return port } return 25565 } function resolveSafeProjectPath(relativePath){ const resolvedPath = path.resolve(PROJECT_ROOT, relativePath) if(!resolvedPath.startsWith(PROJECT_ROOT + path.sep) && resolvedPath !== PROJECT_ROOT){ throw new Error('허용되지 않은 경로입니다.') } return resolvedPath } function createDistributionFileName(profileId){ const safeId = String(profileId ?? 'distribution') .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/-+/g, '-') .replace(/^[-_.]+|[-_.]+$/g, '') return `${safeId.length > 0 ? safeId : 'distribution'}.distribution.json` } function sanitizeProfile(rawProfile, index){ const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack' const sanitized = { id: normalizeText(rawProfile?.id) || `profile-${index + 1}`, name: normalizeText(rawProfile?.name) || `새 프로필 ${index + 1}`, kind, description: normalizeText(rawProfile?.description), details: normalizeMultilineText(rawProfile?.details), distributionUrl: normalizeText(rawProfile?.distributionUrl) } if(kind === 'map'){ sanitized.worldArchiveUrl = normalizeText(rawProfile?.worldArchiveUrl) sanitized.worldDirectoryName = normalizeText(rawProfile?.worldDirectoryName) } if(kind === 'server-pack'){ sanitized.serverBundleUrl = normalizeText(rawProfile?.serverBundleUrl) sanitized.serverDirectoryName = normalizeText(rawProfile?.serverDirectoryName) || `${sanitized.id}-server` sanitized.serverLaunchCommand = normalizeText(rawProfile?.serverLaunchCommand) sanitized.serverWorkingDirectory = normalizeText(rawProfile?.serverWorkingDirectory) sanitized.serverPort = normalizePort(rawProfile?.serverPort) sanitized.tunnelCommand = normalizeText(rawProfile?.tunnelCommand) sanitized.tunnelAddressRegex = normalizeText(rawProfile?.tunnelAddressRegex) } if(normalizeText(rawProfile?.artwork).length > 0){ sanitized.artwork = normalizeText(rawProfile.artwork) } return sanitized } function sanitizeCatalog(payload){ const profiles = Array.isArray(payload?.profiles) ? payload.profiles : [] return { version: 1, profiles: profiles.map((profile, index) => sanitizeProfile(profile, index)) } } function toProjectRelativePath(targetPath){ return path.relative(PROJECT_ROOT, targetPath).split(path.sep).join('/') } async function ensureRuntimeCatalog(){ await fs.ensureDir(RUNTIME_DATA_DIR) await fs.ensureDir(UPLOADS_DIR) await fs.ensureDir(DISTRIBUTIONS_DIR) if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){ if(await fs.pathExists(LAUNCHER_CATALOG_PATH)){ await fs.copy(LAUNCHER_CATALOG_PATH, RUNTIME_CATALOG_PATH, { overwrite: true }) } else { await fs.writeJson(RUNTIME_CATALOG_PATH, { version: 1, profiles: [] }, { spaces: 2 }) } } } async function readCatalog(){ await ensureRuntimeCatalog() return sanitizeCatalog(await fs.readJson(RUNTIME_CATALOG_PATH)) } async function writeCatalog(catalog){ const sanitizedCatalog = sanitizeCatalog(catalog) await fs.ensureDir(path.dirname(LAUNCHER_CATALOG_PATH)) await fs.writeJson(RUNTIME_CATALOG_PATH, sanitizedCatalog, { spaces: 2 }) await fs.writeJson(LAUNCHER_CATALOG_PATH, sanitizedCatalog, { spaces: 2 }) return sanitizedCatalog } function createUploadStorage(){ return multer.diskStorage({ destination(_req, _file, callback){ fs.ensureDir(UPLOADS_DIR) .then(() => callback(null, UPLOADS_DIR)) .catch((error) => callback(error)) }, filename(_req, file, callback){ const extension = path.extname(file.originalname) const baseName = path.basename(file.originalname, extension) .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/-+/g, '-') .replace(/^[-_.]+|[-_.]+$/g, '') const timestamp = Date.now() const safeBaseName = baseName.length > 0 ? baseName : 'file' callback(null, `${timestamp}-${safeBaseName}${extension.toLowerCase()}`) } }) } async function start(){ await ensureRuntimeCatalog() const app = express() const upload = multer({ storage: createUploadStorage(), limits: { fileSize: 1024 * 1024 * 1024 } }) app.use(express.json({ limit: '5mb' })) app.use('/uploads', express.static(UPLOADS_DIR)) app.use('/admin/data/distributions', express.static(DISTRIBUTIONS_DIR)) app.get('/api/meta', async (_req, res) => { res.json({ host: HOST, port: PORT, runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH), launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH), localCatalogUrl: `http://${HOST}:${PORT}/catalog.json`, distributionsPath: toProjectRelativePath(DISTRIBUTIONS_DIR) }) }) app.get('/api/distribution/template', async (_req, res, next) => { try { const content = await fs.readFile(SAMPLE_DISTRIBUTION_PATH, 'utf8') res.json({ ok: true, content }) } catch (error) { next(error) } }) app.get('/api/distribution/content', async (req, res, next) => { try { const requestedPath = normalizeText(req.query.path) if(requestedPath.length === 0){ res.status(400).json({ ok: false, message: '불러올 distribution 경로가 없습니다.' }) return } if(/^https?:\/\//i.test(requestedPath)){ res.status(400).json({ ok: false, message: '원격 URL은 사이트에서 직접 수정할 수 없습니다. 업로드하거나 새로 생성하세요.' }) return } const resolvedPath = resolveSafeProjectPath(requestedPath) const content = await fs.readFile(resolvedPath, 'utf8') res.json({ ok: true, content }) } catch (error) { next(error) } }) app.get('/api/catalog', async (_req, res, next) => { try { res.json(await readCatalog()) } catch (error) { next(error) } }) app.put('/api/catalog', async (req, res, next) => { try { const savedCatalog = await writeCatalog(req.body) res.json({ ok: true, catalog: savedCatalog }) } catch (error) { next(error) } }) app.post('/api/upload', upload.single('file'), async (req, res, next) => { try { if(req.file == null){ res.status(400).json({ ok: false, message: '업로드할 파일이 없습니다.' }) return } const relativePath = toProjectRelativePath(req.file.path) res.json({ ok: true, file: { name: req.file.originalname, storedName: req.file.filename, size: req.file.size, path: relativePath, localUrl: `http://${HOST}:${PORT}/uploads/${encodeURIComponent(req.file.filename)}` } }) } catch (error) { next(error) } }) app.post('/api/distribution/save', async (req, res, next) => { try { const profileId = normalizeText(req.body?.profileId) const rawContent = typeof req.body?.content === 'string' ? req.body.content : '' if(profileId.length === 0){ res.status(400).json({ ok: false, message: '프로필 ID가 필요합니다.' }) return } const parsed = JSON.parse(rawContent) const fileName = createDistributionFileName(profileId) const targetPath = path.join(DISTRIBUTIONS_DIR, fileName) const relativePath = toProjectRelativePath(targetPath) await fs.writeFile(targetPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8') res.json({ ok: true, file: { path: relativePath, localUrl: `http://${HOST}:${PORT}/${relativePath}` } }) } catch (error) { next(error) } }) app.get('/catalog.json', async (_req, res, next) => { try { res.sendFile(RUNTIME_CATALOG_PATH) } catch (error) { next(error) } }) app.use(express.static(PUBLIC_DIR)) app.get(/.*/, (_req, res) => { res.sendFile(path.join(PUBLIC_DIR, 'index.html')) }) app.use((error, _req, res, _next) => { console.error(error) res.status(500).json({ ok: false, message: error instanceof Error ? error.message : '관리자 서버 처리 중 오류가 발생했습니다.' }) }) app.listen(PORT, HOST, () => { console.log(`[launcher-admin] running on http://${HOST}:${PORT}`) console.log(`[launcher-admin] runtime catalog: ${toProjectRelativePath(RUNTIME_CATALOG_PATH)}`) console.log(`[launcher-admin] launcher catalog mirror: ${toProjectRelativePath(LAUNCHER_CATALOG_PATH)}`) }) } start().catch((error) => { console.error(error) process.exit(1) })