Add admin distribution editor
This commit is contained in:
108
admin/server.js
108
admin/server.js
@@ -8,9 +8,11 @@ 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'])
|
||||
|
||||
@@ -24,10 +26,6 @@ function normalizeMultilineText(value){
|
||||
: ''
|
||||
}
|
||||
|
||||
function normalizeBoolean(value){
|
||||
return value === true
|
||||
}
|
||||
|
||||
function normalizePort(value){
|
||||
const port = Number.parseInt(String(value ?? ''), 10)
|
||||
if(Number.isFinite(port) && port >= 1 && port <= 65535){
|
||||
@@ -36,6 +34,25 @@ function normalizePort(value){
|
||||
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 = {
|
||||
@@ -44,9 +61,7 @@ function sanitizeProfile(rawProfile, index){
|
||||
kind,
|
||||
description: normalizeText(rawProfile?.description),
|
||||
details: normalizeMultilineText(rawProfile?.details),
|
||||
distributionUrl: normalizeText(rawProfile?.distributionUrl),
|
||||
defaultServerAddress: normalizeText(rawProfile?.defaultServerAddress),
|
||||
allowCustomServerAddress: normalizeBoolean(rawProfile?.allowCustomServerAddress)
|
||||
distributionUrl: normalizeText(rawProfile?.distributionUrl)
|
||||
}
|
||||
|
||||
if(kind === 'map'){
|
||||
@@ -87,6 +102,7 @@ function toProjectRelativePath(targetPath){
|
||||
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)){
|
||||
@@ -143,6 +159,7 @@ async function start(){
|
||||
|
||||
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({
|
||||
@@ -150,10 +167,53 @@ async function start(){
|
||||
port: PORT,
|
||||
runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH),
|
||||
launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH),
|
||||
localCatalogUrl: `http://${HOST}:${PORT}/catalog.json`
|
||||
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())
|
||||
@@ -201,6 +261,38 @@ async function start(){
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user