Build installer and management site from spec

This commit is contained in:
2026-05-07 23:22:34 +09:00
parent 0b061e63b7
commit af6e559682
33 changed files with 7125 additions and 1 deletions

58
src/server/app.ts Normal file
View File

@@ -0,0 +1,58 @@
import express from 'express'
import session from 'express-session'
import path from 'node:path'
import { fileDir, manifestDir, publicDir, viewsDir } from '../shared/paths'
import { ensureProjectFiles } from '../shared/store'
import { indexRouter } from './routes/index'
import { opRouter } from './routes/op'
export async function createApp() {
await ensureProjectFiles()
const app = express()
app.set('view engine', 'ejs')
app.set('views', viewsDir)
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
app.use(
session({
secret: 'mc-custom-suite-session-secret',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax'
}
})
)
app.use('/static', express.static(publicDir))
app.use('/manifest', express.static(manifestDir))
app.use('/file', express.static(fileDir))
app.use(indexRouter)
app.use(opRouter)
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(error)
res.status(500).send('서버 내부 오류가 발생했습니다.')
})
return app
}
async function bootstrap() {
const app = await createApp()
const port = Number(process.env.PORT ?? 3000)
app.listen(port, '127.0.0.1', () => {
console.log(`Management site listening on http://127.0.0.1:${port}`)
})
}
if (require.main === module) {
bootstrap().catch((error) => {
console.error(error)
process.exit(1)
})
}

View File

@@ -0,0 +1,15 @@
import { NextFunction, Request, Response } from 'express'
declare module 'express-session' {
interface SessionData {
userId?: string
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (req.session.userId == null) {
res.redirect('/op')
return
}
next()
}

View File

@@ -0,0 +1,31 @@
import { Router } from 'express'
import path from 'node:path'
import { manifestRootPath } from '../../shared/paths'
import { fetchReleaseVersions } from '../../shared/mojang'
import { loadRootManifest } from '../../shared/store'
export const indexRouter = Router()
indexRouter.get('/', async (_req, res, next) => {
try {
const manifest = await loadRootManifest()
res.render('index', {
packs: manifest.packs
})
} catch (error) {
next(error)
}
})
indexRouter.get('/manifest.json', (_req, res) => {
res.sendFile(path.resolve(manifestRootPath))
})
indexRouter.get('/api/releases', async (_req, res, next) => {
try {
const releases = await fetchReleaseVersions()
res.json(releases)
} catch (error) {
next(error)
}
})

143
src/server/routes/op.ts Normal file
View File

@@ -0,0 +1,143 @@
import { Router } from 'express'
import { fetchReleaseVersions } from '../../shared/mojang'
import {
createNewPack,
deletePacks,
loadAccounts,
loadPackDefinition,
loadRootManifest,
normalizePackDefinition,
updatePack
} from '../../shared/store'
import { requireAuth } from '../middleware/auth'
export const opRouter = Router()
function pickFirstValue(value: unknown): string {
if (Array.isArray(value)) {
return typeof value[0] === 'string' ? value[0] : ''
}
return typeof value === 'string' ? value : ''
}
opRouter.get('/op', (req, res) => {
if (req.session.userId != null) {
res.redirect('/op/dashboard')
return
}
res.render('op/login', {
errorMessage: null
})
})
opRouter.post('/op/login', async (req, res, next) => {
try {
const { id, password } = req.body as { id?: string; password?: string }
const accounts = await loadAccounts()
const matched = accounts.find((entry) => entry.id === id && entry.password === password)
if (matched == null) {
res.status(401).render('op/login', {
errorMessage: '아이디 또는 비밀번호가 올바르지 않습니다.'
})
return
}
req.session.userId = matched.id
res.redirect('/op/dashboard')
} catch (error) {
next(error)
}
})
opRouter.post('/op/logout', requireAuth, (req, res) => {
req.session.destroy(() => {
res.redirect('/op')
})
})
opRouter.get('/op/dashboard', requireAuth, async (_req, res, next) => {
try {
const manifest = await loadRootManifest()
res.render('op/dashboard', {
userId: _req.session.userId,
packs: manifest.packs
})
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/packs', requireAuth, async (_req, res, next) => {
try {
const packKey = await createNewPack()
res.redirect(`/op/dashboard/${packKey}`)
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/packs/delete', requireAuth, async (req, res, next) => {
try {
const rawSelection = req.body.packKeys
const packKeys = Array.isArray(rawSelection)
? rawSelection.map(String)
: typeof rawSelection === 'string'
? [rawSelection]
: []
await deletePacks(packKeys)
res.redirect('/op/dashboard')
} catch (error) {
next(error)
}
})
opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
try {
const packName = pickFirstValue(req.params.packName)
const definition = await loadPackDefinition(packName)
if (definition == null) {
res.status(404).send('서버팩 JSON을 찾을 수 없습니다.')
return
}
const rootManifest = await loadRootManifest()
const packEntry = rootManifest.packs.find((entry) => entry.file === packName)
const releases = await fetchReleaseVersions()
res.render('op/editor', {
userId: req.session.userId,
packKey: packName,
packEntry,
pack: definition,
releases
})
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = pickFirstValue(req.params.packName)
const nextPackName = pickFirstValue(req.body.displayName).trim() || packKey
const nextJsonKey = pickFirstValue(req.body.fileName).trim() || packKey
const normalized = normalizePackDefinition({
mcVersion: pickFirstValue(req.body.mcVersion),
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
clientRecommendedRam: Number(pickFirstValue(req.body.clientRecommendedRam)),
packPath: pickFirstValue(req.body.packPath),
description: pickFirstValue(req.body.description)
})
const changedKey = await updatePack(packKey, nextPackName, nextJsonKey, normalized)
res.redirect(`/op/dashboard/${changedKey}`)
} catch (error) {
next(error)
}
})