Add launcher admin catalog site
Some checks failed
Build / release (macos-latest) (push) Has been cancelled
Build / release (ubuntu-latest) (push) Has been cancelled
Build / release (windows-latest) (push) Has been cancelled
Windows Smoke Test / windows-smoke (push) Has been cancelled

This commit is contained in:
2026-05-05 18:48:13 +09:00
parent 9251fabdf8
commit c4cdd0ceba
12 changed files with 7999 additions and 5916 deletions

236
admin/server.js Normal file
View File

@@ -0,0 +1,236 @@
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 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 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 normalizeBoolean(value){
return value === true
}
function normalizePort(value){
const port = Number.parseInt(String(value ?? ''), 10)
if(Number.isFinite(port) && port >= 1 && port <= 65535){
return port
}
return 25565
}
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),
defaultServerAddress: normalizeText(rawProfile?.defaultServerAddress),
allowCustomServerAddress: normalizeBoolean(rawProfile?.allowCustomServerAddress)
}
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)
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.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`
})
})
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.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)
})