455 lines
14 KiB
JavaScript
455 lines
14 KiB
JavaScript
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 EXAMPLE_DISTRIBUTION_PREFIX = 'https://example.com/launcher/'
|
|
const PUBLIC_BASE_URL = normalizeText(process.env.LAUNCHER_PUBLIC_BASE_URL).replace(/\/+$/, '')
|
|
|
|
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 normalizePositiveInteger(value, fallback, minimum = 1){
|
|
const parsed = Number.parseInt(String(value ?? ''), 10)
|
|
if(Number.isFinite(parsed) && parsed >= minimum){
|
|
return parsed
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
function normalizeBoolean(value){
|
|
return value === true
|
|
}
|
|
|
|
function getPublicBaseUrl(){
|
|
if(PUBLIC_BASE_URL.length > 0){
|
|
return PUBLIC_BASE_URL
|
|
}
|
|
return `http://${HOST}:${PORT}`
|
|
}
|
|
|
|
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 deriveFeatureFlags(rawProfile){
|
|
const legacyKind = normalizeText(rawProfile?.kind)
|
|
|
|
let modsEnabled = normalizeBoolean(rawProfile?.modsEnabled)
|
|
let pluginsEnabled = normalizeBoolean(rawProfile?.pluginsEnabled)
|
|
let serverEnabled = normalizeBoolean(rawProfile?.serverEnabled)
|
|
|
|
if(legacyKind === 'modpack'){
|
|
modsEnabled = true
|
|
} else if(legacyKind === 'server-pack'){
|
|
pluginsEnabled = true
|
|
serverEnabled = true
|
|
}
|
|
|
|
if(pluginsEnabled){
|
|
serverEnabled = true
|
|
}
|
|
|
|
return {
|
|
modsEnabled,
|
|
pluginsEnabled,
|
|
serverEnabled
|
|
}
|
|
}
|
|
|
|
function normalizeServerJarSource(rawProfile){
|
|
const directValue = normalizeText(rawProfile?.serverJarUrl)
|
|
if(directValue.length > 0){
|
|
return directValue
|
|
}
|
|
|
|
const legacyBundle = normalizeText(rawProfile?.serverBundleUrl)
|
|
if(legacyBundle.toLowerCase().endsWith('.jar')){
|
|
return legacyBundle
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function sanitizeProfile(rawProfile, index){
|
|
const {
|
|
modsEnabled,
|
|
pluginsEnabled,
|
|
serverEnabled
|
|
} = deriveFeatureFlags(rawProfile)
|
|
|
|
const sanitized = {
|
|
id: normalizeText(rawProfile?.id) || `profile-${index + 1}`,
|
|
name: normalizeText(rawProfile?.name) || `새 프로필 ${index + 1}`,
|
|
description: normalizeText(rawProfile?.description),
|
|
details: normalizeMultilineText(rawProfile?.details),
|
|
distributionUrl: normalizeText(rawProfile?.distributionUrl),
|
|
modsEnabled,
|
|
pluginsEnabled,
|
|
serverEnabled,
|
|
worldArchiveUrl: normalizeText(rawProfile?.worldArchiveUrl),
|
|
worldDirectoryName: normalizeText(rawProfile?.worldDirectoryName)
|
|
}
|
|
|
|
if(serverEnabled){
|
|
sanitized.serverJarUrl = normalizeServerJarSource(rawProfile)
|
|
sanitized.serverDirectoryName = normalizeText(rawProfile?.serverDirectoryName) || `${sanitized.id}-server`
|
|
sanitized.serverPort = normalizePort(rawProfile?.serverPort)
|
|
sanitized.serverMemoryMb = normalizePositiveInteger(rawProfile?.serverMemoryMb, 4096, 512)
|
|
sanitized.serverMaxPlayers = normalizePositiveInteger(rawProfile?.serverMaxPlayers, 20, 1)
|
|
sanitized.serverWhitelistEnabled = normalizeBoolean(rawProfile?.serverWhitelistEnabled)
|
|
}
|
|
|
|
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 })
|
|
}
|
|
}
|
|
|
|
await migrateExampleCatalog()
|
|
}
|
|
|
|
async function ensureLocalSampleDistribution(profileId, profileName){
|
|
const fileName = createDistributionFileName(profileId)
|
|
const targetPath = path.join(DISTRIBUTIONS_DIR, fileName)
|
|
|
|
if(!(await fs.pathExists(targetPath))){
|
|
const template = JSON.parse(await fs.readFile(SAMPLE_DISTRIBUTION_PATH, 'utf8'))
|
|
template.servers = [
|
|
{
|
|
id: profileId,
|
|
name: profileName || profileId,
|
|
description: '관리자 사이트 로컬 샘플 distribution입니다.',
|
|
version: '1.20.1',
|
|
minecraftVersion: '1.20.1',
|
|
address: '127.0.0.1:25565',
|
|
mainServer: true,
|
|
autoconnect: false,
|
|
modules: []
|
|
}
|
|
]
|
|
await fs.writeFile(targetPath, JSON.stringify(template, null, 2) + '\n', 'utf8')
|
|
}
|
|
|
|
return toProjectRelativePath(targetPath)
|
|
}
|
|
|
|
async function migrateExampleCatalog(){
|
|
if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){
|
|
return
|
|
}
|
|
|
|
const runtimeCatalog = sanitizeCatalog(await fs.readJson(RUNTIME_CATALOG_PATH))
|
|
let changed = false
|
|
|
|
for(const profile of runtimeCatalog.profiles){
|
|
if(normalizeText(profile.distributionUrl).startsWith(EXAMPLE_DISTRIBUTION_PREFIX)){
|
|
profile.distributionUrl = await ensureLocalSampleDistribution(profile.id, profile.name)
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
if(changed){
|
|
await fs.writeJson(RUNTIME_CATALOG_PATH, runtimeCatalog, { spaces: 2 })
|
|
await fs.writeJson(LAUNCHER_CATALOG_PATH, runtimeCatalog, { 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((_req, res, next) => {
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate')
|
|
res.setHeader('Pragma', 'no-cache')
|
|
res.setHeader('Expires', '0')
|
|
next()
|
|
})
|
|
|
|
app.use(express.json({ limit: '5mb' }))
|
|
app.use('/uploads', express.static(UPLOADS_DIR))
|
|
app.use('/admin/data/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: `${getPublicBaseUrl()}/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)){
|
|
const response = await fetch(requestedPath, {
|
|
signal: AbortSignal.timeout(10000)
|
|
})
|
|
if(!response.ok){
|
|
throw new Error(`원격 distribution을 불러오지 못했습니다. (${response.status})`)
|
|
}
|
|
|
|
const content = await response.text()
|
|
JSON.parse(content)
|
|
res.json({
|
|
ok: true,
|
|
content
|
|
})
|
|
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: `${getPublicBaseUrl()}/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: `${getPublicBaseUrl()}/${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)
|
|
})
|