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

597
src/installer/main.ts Normal file
View File

@@ -0,0 +1,597 @@
import { BrowserWindow, app, dialog, ipcMain, shell } from 'electron'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import express from 'express'
import session from 'express-session'
import AdmZip from 'adm-zip'
import upnp from 'nat-upnp'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { createApp } from '../server/app'
import { InstallPayload, InstallSessionState, SelectedPackPayload } from './types'
import { PackDefinition, RootManifest } from '../shared/types'
const execFileAsync = promisify(execFile)
const DEFAULT_MANIFEST_URL = process.env.INSTALLER_MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
const DEFAULT_SITE_URL = process.env.MANAGEMENT_SITE_URL ?? 'http://127.0.0.1:3000'
let mainWindow: BrowserWindow | null = null
let currentInstall: InstallSessionState | null = null
let configEditorServer: ReturnType<express.Express['listen']> | null = null
let pendingEulaResolver: (() => void) | null = null
function sendLog(message: string, tone: 'info' | 'warn' | 'error' | 'success' = 'info') {
mainWindow?.webContents.send('installer:log', {
message,
tone,
timestamp: new Date().toISOString()
})
}
function hasHangul(input: string): boolean {
return /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(input)
}
function ensureWindow() {
if (mainWindow != null) {
return mainWindow
}
const appRoot = app.getAppPath()
mainWindow = new BrowserWindow({
width: 1320,
height: 860,
minWidth: 1180,
minHeight: 760,
webPreferences: {
preload: path.join(appRoot, 'dist', 'installer', 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.loadFile(path.join(appRoot, 'installer', 'index.html'))
mainWindow.on('closed', () => {
mainWindow = null
})
return mainWindow
}
async function startManagementSite(): Promise<void> {
const appInstance = await createApp()
await new Promise<void>((resolve, reject) => {
const server = appInstance.listen(3000, '127.0.0.1', () => resolve())
server.once('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
resolve()
return
}
reject(error)
})
})
}
async function chooseDirectory(): Promise<string | null> {
const targetWindow = ensureWindow()
const result = await dialog.showOpenDialog(targetWindow, {
properties: ['openDirectory', 'createDirectory']
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
return result.filePaths[0]
}
async function detectJdkCandidates(): Promise<string[]> {
const candidates = new Set<string>()
const envCandidates = [process.env.JAVA_HOME, process.env.JDK_HOME]
for (const candidate of envCandidates) {
if (candidate != null && candidate.trim().length > 0) {
candidates.add(candidate.trim())
}
}
if (process.platform === 'win32') {
const javaRoot = 'C:\\Program Files\\Java'
if (fs.existsSync(javaRoot)) {
const entries = await fsp.readdir(javaRoot, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
candidates.add(path.join(javaRoot, entry.name))
}
}
}
}
return [...candidates]
}
function resolveJavaExecutable(jdkPath: string): string {
return process.platform === 'win32'
? path.join(jdkPath, 'bin', 'java.exe')
: path.join(jdkPath, 'bin', 'java')
}
async function detectJdk(): Promise<{ detected: string | null; candidates: string[] }> {
const candidates = await detectJdkCandidates()
for (const candidate of candidates) {
if (fs.existsSync(resolveJavaExecutable(candidate))) {
return {
detected: candidate,
candidates
}
}
}
return {
detected: null,
candidates
}
}
async function fetchPackManifest(payload: SelectedPackPayload): Promise<{ baseUrl: string; packDefinition: PackDefinition; packName: string }> {
const manifestResponse = await fetch(payload.manifestUrl)
if (!manifestResponse.ok) {
throw new Error(`manifest.json 요청 실패: ${manifestResponse.status}`)
}
const rootManifest = await manifestResponse.json() as RootManifest
const packEntry = rootManifest.packs.find((entry) => entry.file === payload.pack.file)
if (packEntry == null) {
throw new Error('선택한 서버팩을 manifest.json에서 찾지 못했습니다.')
}
const manifestUrl = new URL(payload.manifestUrl)
const packUrl = new URL(`/manifest/${payload.pack.file}.json`, manifestUrl.origin)
const packResponse = await fetch(packUrl)
if (!packResponse.ok) {
throw new Error(`서버팩 JSON 요청 실패: ${packResponse.status}`)
}
return {
baseUrl: manifestUrl.origin,
packDefinition: await packResponse.json() as PackDefinition,
packName: packEntry.name
}
}
function resolveClientRamMb(pack: PackDefinition): { selected: number; warning: string | null } {
const systemRamMb = Math.floor(os.totalmem() / 1024 / 1024)
if (systemRamMb >= pack.clientRecommendedRam) {
return {
selected: pack.clientRecommendedRam,
warning: null
}
}
if (systemRamMb >= pack.clientMinRam) {
return {
selected: pack.clientMinRam,
warning: '권장 램보다 시스템 램이 적어 최소 램으로 설치합니다.'
}
}
throw new Error('플레이 불가: 시스템 램이 최소 램보다 적습니다.')
}
async function writeLauncherProfile(packName: string, installRoot: string, pack: PackDefinition): Promise<void> {
const appData = process.env.APPDATA
if (appData == null) {
return
}
const launcherProfilesPath = path.join(appData, '.minecraft', 'launcher_profiles.json')
const gameDir = path.join(installRoot, '.mc_custom')
const selectedRam = resolveClientRamMb(pack).selected
let payload: Record<string, unknown> = {}
if (fs.existsSync(launcherProfilesPath)) {
try {
payload = JSON.parse(await fsp.readFile(launcherProfilesPath, 'utf8')) as Record<string, unknown>
} catch {
payload = {}
}
}
const profiles = typeof payload.profiles === 'object' && payload.profiles != null
? payload.profiles as Record<string, unknown>
: {}
profiles[packName] = {
created: new Date().toISOString(),
gameDir,
icon: 'Grass',
javaArgs: `-Xms${Math.min(selectedRam, 2048)}M -Xmx${selectedRam}M`,
lastVersionId: pack.mcVersion,
name: packName,
type: 'custom'
}
payload.profiles = profiles
await fsp.mkdir(path.dirname(launcherProfilesPath), { recursive: true })
await fsp.writeFile(launcherProfilesPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
}
async function downloadAndExtractPack(baseUrl: string, pack: PackDefinition, installRoot: string): Promise<string> {
const customRoot = path.join(installRoot, '.mc_custom')
await fsp.mkdir(customRoot, { recursive: true })
if (pack.files != null && pack.files.length > 0) {
for (const filePath of pack.files) {
const targetUrl = new URL(`/file/${filePath}`, baseUrl).toString()
const targetPath = path.join(customRoot, filePath)
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
sendLog(`다운로드: ${filePath}`)
const response = await fetch(targetUrl)
if (!response.ok) {
throw new Error(`파일 다운로드 실패: ${filePath}`)
}
const arrayBuffer = await response.arrayBuffer()
await fsp.writeFile(targetPath, Buffer.from(arrayBuffer))
}
return customRoot
}
const archiveUrl = new URL(`/file/${pack.packPath}`, baseUrl).toString()
sendLog(`다운로드: ${archiveUrl}`)
const response = await fetch(archiveUrl)
if (!response.ok) {
throw new Error(`서버팩 다운로드 실패: ${response.status}`)
}
const archiveBuffer = Buffer.from(await response.arrayBuffer())
const archivePath = path.join(customRoot, 'server-pack.zip')
await fsp.writeFile(archivePath, archiveBuffer)
sendLog('압축 해제 시작')
const zip = new AdmZip(archivePath)
zip.extractAllTo(customRoot, true)
await fsp.unlink(archivePath)
return customRoot
}
async function waitForEulaAcceptance(): Promise<void> {
await new Promise<void>((resolve) => {
pendingEulaResolver = resolve
sendLog('Minecraft EULA 동의가 필요합니다.', 'warn')
mainWindow?.webContents.send('installer:log', { action: 'eula-required' })
})
}
async function findServerJar(root: string): Promise<string | null> {
const entries = await fsp.readdir(root, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(root, entry.name)
if (entry.isFile() && entry.name.endsWith('.jar')) {
return entryPath
}
}
return null
}
async function startInstall(payload: InstallPayload): Promise<{ nextStep: number; warning: string | null }> {
if (hasHangul(payload.installPath)) {
throw new Error('설치 경로에 한글이 포함되어 있습니다.')
}
if (!fs.existsSync(resolveJavaExecutable(payload.jdkPath))) {
throw new Error('유효한 JDK 경로를 지정해야 합니다.')
}
const packMeta = await fetchPackManifest({
manifestUrl: payload.manifestUrl,
pack: {
file: payload.packFile,
name: payload.packFile
}
})
const ramDecision = resolveClientRamMb(packMeta.packDefinition)
sendLog('서버팩 정보 확인 완료')
if (ramDecision.warning != null) {
sendLog(ramDecision.warning, 'warn')
}
const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath)
const eulaPath = path.join(extractedRoot, 'eula.txt')
if (fs.existsSync(eulaPath)) {
await fsp.unlink(eulaPath)
}
currentInstall = {
manifestUrl: payload.manifestUrl,
packFile: payload.packFile,
installPath: payload.installPath,
jdkPath: payload.jdkPath,
packDefinition: packMeta.packDefinition,
packName: packMeta.packName,
extractedRoot
}
await waitForEulaAcceptance()
await fsp.writeFile(eulaPath, 'eula=true\n', 'utf8')
sendLog('EULA 동의 반영 완료', 'success')
await writeLauncherProfile(packMeta.packName, payload.installPath, packMeta.packDefinition)
sendLog('Minecraft 런처 프로필 추가 완료', 'success')
return {
nextStep: 5,
warning: ramDecision.warning
}
}
async function stopConfigEditor(): Promise<void> {
await new Promise<void>((resolve) => {
if (configEditorServer == null) {
resolve()
return
}
configEditorServer.close(() => {
configEditorServer = null
resolve()
})
})
}
function parseProperties(raw: string): Record<string, string> {
const result: Record<string, string> = {}
for (const line of raw.split(/\r?\n/)) {
if (line.trim().length === 0 || line.trim().startsWith('#') || !line.includes('=')) {
continue
}
const [key, ...valueParts] = line.split('=')
result[key.trim()] = valueParts.join('=').trim()
}
return result
}
function stringifyProperties(values: Record<string, string>): string {
return Object.entries(values)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
}
async function openConfigEditor(): Promise<string> {
if (currentInstall == null) {
throw new Error('설치된 서버팩 정보가 없습니다.')
}
await stopConfigEditor()
const editorApp = express()
editorApp.use(express.urlencoded({ extended: true }))
editorApp.get('/', async (_req, res) => {
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
const serverPropertiesRaw = fs.existsSync(serverPropertiesPath)
? await fsp.readFile(serverPropertiesPath, 'utf8')
: ''
const parsed = parseProperties(serverPropertiesRaw)
const bukkitRaw = fs.existsSync(bukkitPath)
? await fsp.readFile(bukkitPath, 'utf8')
: ''
res.send(`<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>서버 설정 편집기</title>
<style>
body{font-family:Arial,sans-serif;background:#101412;color:#f5f5f5;margin:0;padding:24px;}
.wrap{max-width:960px;margin:0 auto;}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
label{display:block;font-weight:700;margin-bottom:6px;}
input,textarea{width:100%;padding:10px;border-radius:10px;border:1px solid #2c3a34;background:#171d1a;color:#fff;}
textarea{min-height:220px;}
button{padding:12px 18px;border:none;border-radius:999px;background:#f0bf57;color:#111;font-weight:700;cursor:pointer;}
.card{background:#171d1a;padding:18px;border-radius:18px;margin-bottom:18px;}
.desc{color:#b9c0bc;font-size:14px;margin-bottom:12px;}
</style>
</head>
<body>
<div class="wrap">
<h1>서버 설정 편집기</h1>
<form method="post" action="/save">
<div class="card">
<h2>server.properties</h2>
<div class="desc">메모장 대신 주요 항목을 설명과 함께 수정합니다.</div>
<div class="grid">
<div><label>MOTD</label><input name="motd" value="${parsed.motd ?? ''}" /></div>
<div><label>서버 포트</label><input name="server-port" value="${parsed['server-port'] ?? '25565'}" /></div>
<div><label>최대 인원수</label><input name="max-players" value="${parsed['max-players'] ?? '20'}" /></div>
<div><label>화이트리스트</label><input name="white-list" value="${parsed['white-list'] ?? 'false'}" /></div>
<div><label>PvP</label><input name="pvp" value="${parsed.pvp ?? 'true'}" /></div>
<div><label>온라인 모드</label><input name="online-mode" value="${parsed['online-mode'] ?? 'true'}" /></div>
</div>
</div>
<div class="card">
<h2>bukkit.yml</h2>
<div class="desc">기타 Bukkit 설정은 전체 파일을 직접 수정합니다.</div>
<textarea name="bukkitRaw">${bukkitRaw.replace(/</g, '&lt;')}</textarea>
</div>
<button type="submit">적용</button>
</form>
</div>
</body>
</html>`)
})
editorApp.post('/save', async (req, res) => {
const serverPropertiesPath = path.join(currentInstall!.extractedRoot, 'server.properties')
const bukkitPath = path.join(currentInstall!.extractedRoot, 'bukkit.yml')
const values = {
motd: String(req.body.motd ?? ''),
'server-port': String(req.body['server-port'] ?? '25565'),
'max-players': String(req.body['max-players'] ?? '20'),
'white-list': String(req.body['white-list'] ?? 'false'),
pvp: String(req.body.pvp ?? 'true'),
'online-mode': String(req.body['online-mode'] ?? 'true')
}
await fsp.writeFile(serverPropertiesPath, `${stringifyProperties(values)}\n`, 'utf8')
await fsp.writeFile(bukkitPath, String(req.body.bukkitRaw ?? ''), 'utf8')
res.redirect('/')
})
const url = await new Promise<string>((resolve) => {
configEditorServer = editorApp.listen(0, '127.0.0.1', () => {
const address = configEditorServer?.address()
const port = typeof address === 'object' && address != null ? address.port : 0
resolve(`http://127.0.0.1:${port}`)
})
})
currentInstall.configEditorUrl = url
await shell.openExternal(url)
sendLog(`설정 편집기 실행: ${url}`, 'success')
return url
}
async function configurePort(): Promise<{ status: string; message: string; externalAddress?: string }> {
if (currentInstall == null) {
throw new Error('설치된 서버 정보가 없습니다.')
}
const port = 25565
const client = upnp.createClient()
const externalIpResponse = await fetch('https://api.ipify.org?format=json')
const externalIp = externalIpResponse.ok
? (await externalIpResponse.json() as { ip?: string }).ip
: undefined
const mapPort = () => new Promise<void>((resolve, reject) => {
client.portMapping(
{
public: port,
private: port,
ttl: 3600
},
(error) => {
if (error != null) {
reject(error)
return
}
resolve()
}
)
})
try {
await mapPort()
currentInstall.externalPort = port
currentInstall.externalAddress = externalIp
const message = externalIp != null
? `UPnP 자동 개방 성공: ${externalIp}:${port}`
: `UPnP 자동 개방 성공: 포트 ${port}`
sendLog(message, 'success')
return {
status: 'success',
message,
externalAddress: externalIp
}
} catch {
const message = '자동 포트 개방 실패. 직접 포트포워딩을 해주세요.'
sendLog(message, 'error')
return {
status: 'manual',
message
}
}
}
async function openInstalledFolder(): Promise<void> {
if (currentInstall == null) {
return
}
await shell.openPath(currentInstall.installPath)
}
async function createDesktopShortcut(enabled: boolean): Promise<void> {
if (!enabled || currentInstall == null) {
return
}
const desktopDir = path.join(os.homedir(), 'Desktop')
const shortcutPath = path.join(desktopDir, `${currentInstall.packName} 서버 실행.cmd`)
const serverJar = await findServerJar(currentInstall.extractedRoot)
if (serverJar == null) {
return
}
const contents = [
'@echo off',
`cd /d "${currentInstall.extractedRoot}"`,
`"${resolveJavaExecutable(currentInstall.jdkPath)}" -Xms${currentInstall.packDefinition.serverMinRam}M -Xmx${currentInstall.packDefinition.serverMaxRam}M -jar "${serverJar}" nogui`
].join('\r\n')
await fsp.writeFile(shortcutPath, contents, 'utf8')
}
async function runServer(enabled: boolean): Promise<void> {
if (!enabled || currentInstall == null) {
return
}
const serverJar = await findServerJar(currentInstall.extractedRoot)
if (serverJar == null) {
sendLog('서버 JAR을 찾지 못해 자동 실행을 생략합니다.', 'warn')
return
}
const javaExec = resolveJavaExecutable(currentInstall.jdkPath)
execFile(javaExec, [
`-Xms${currentInstall.packDefinition.serverMinRam}M`,
`-Xmx${currentInstall.packDefinition.serverMaxRam}M`,
'-jar',
serverJar,
'nogui'
], {
cwd: currentInstall.extractedRoot
})
sendLog('서버 실행 시작', 'success')
}
function bindIpcHandlers() {
ipcMain.handle('installer:get-defaults', async () => ({
manifestUrl: DEFAULT_MANIFEST_URL,
managementSiteUrl: DEFAULT_SITE_URL
}))
ipcMain.handle('installer:load-packs', async (_event, manifestUrl: string) => {
const response = await fetch(manifestUrl)
if (!response.ok) {
throw new Error(`manifest.json 요청 실패: ${response.status}`)
}
return response.json() as Promise<RootManifest>
})
ipcMain.handle('installer:choose-directory', async () => chooseDirectory())
ipcMain.handle('installer:detect-jdk', async () => detectJdk())
ipcMain.handle('installer:choose-jdk', async () => chooseDirectory())
ipcMain.handle('installer:start-install', async (_event, payload: InstallPayload) => startInstall(payload))
ipcMain.handle('installer:accept-eula', async () => {
pendingEulaResolver?.()
pendingEulaResolver = null
})
ipcMain.handle('installer:open-config-editor', async () => openConfigEditor())
ipcMain.handle('installer:configure-port', async () => configurePort())
ipcMain.handle('installer:open-folder', async () => openInstalledFolder())
ipcMain.handle('installer:create-shortcut', async (_event, enabled: boolean) => createDesktopShortcut(enabled))
ipcMain.handle('installer:run-server', async (_event, enabled: boolean) => runServer(enabled))
}
app.whenReady().then(async () => {
await startManagementSite()
bindIpcHandlers()
ensureWindow()
})
app.on('window-all-closed', async () => {
await stopConfigEditor()
if (process.platform !== 'darwin') {
app.quit()
}
})

17
src/installer/preload.ts Normal file
View File

@@ -0,0 +1,17 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('installerApi', {
getDefaults: () => ipcRenderer.invoke('installer:get-defaults'),
loadPacks: (manifestUrl: string) => ipcRenderer.invoke('installer:load-packs', manifestUrl),
chooseDirectory: () => ipcRenderer.invoke('installer:choose-directory'),
detectJdk: () => ipcRenderer.invoke('installer:detect-jdk'),
chooseJdk: () => ipcRenderer.invoke('installer:choose-jdk'),
startInstall: (payload: unknown) => ipcRenderer.invoke('installer:start-install', payload),
acceptEula: () => ipcRenderer.invoke('installer:accept-eula'),
openConfigEditor: () => ipcRenderer.invoke('installer:open-config-editor'),
configurePort: () => ipcRenderer.invoke('installer:configure-port'),
openFolder: () => ipcRenderer.invoke('installer:open-folder'),
createShortcut: (enabled: boolean) => ipcRenderer.invoke('installer:create-shortcut', enabled),
runServer: (enabled: boolean) => ipcRenderer.invoke('installer:run-server', enabled),
onLog: (handler: (entry: unknown) => void) => ipcRenderer.on('installer:log', (_event, entry) => handler(entry))
})

30
src/installer/types.ts Normal file
View File

@@ -0,0 +1,30 @@
import { PackDefinition, PackListEntry } from '../shared/types'
export interface InstallerDefaults {
manifestUrl: string
}
export interface SelectedPackPayload {
manifestUrl: string
pack: PackListEntry
}
export interface InstallPayload {
manifestUrl: string
packFile: string
installPath: string
jdkPath: string
}
export interface InstallSessionState {
manifestUrl: string
packFile: string
installPath: string
jdkPath: string
packDefinition: PackDefinition
packName: string
extractedRoot: string
externalAddress?: string
externalPort?: number
configEditorUrl?: string
}

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)
}
})

30
src/shared/mojang.ts Normal file
View File

@@ -0,0 +1,30 @@
import { MinecraftRelease } from './types'
const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json'
let cachedReleases: MinecraftRelease[] | null = null
export async function fetchReleaseVersions(): Promise<MinecraftRelease[]> {
if (cachedReleases != null) {
return cachedReleases
}
try {
const response = await fetch(VERSION_MANIFEST_URL)
if (!response.ok) {
throw new Error(`Failed to fetch versions: ${response.status}`)
}
const payload = await response.json() as { versions?: MinecraftRelease[] }
cachedReleases = (payload.versions ?? []).filter((entry) => entry.type === 'release')
return cachedReleases
} catch {
cachedReleases = [
{ id: '1.21.4', type: 'release' },
{ id: '1.21.1', type: 'release' },
{ id: '1.20.6', type: 'release' },
{ id: '1.20.1', type: 'release' }
]
return cachedReleases
}
}

9
src/shared/nat-upnp.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module 'nat-upnp' {
const upnp: {
createClient(): {
portMapping(options: Record<string, unknown>, callback: (error?: Error | null) => void): void
}
}
export default upnp
}

9
src/shared/paths.ts Normal file
View File

@@ -0,0 +1,9 @@
import path from 'node:path'
export const projectRoot = process.cwd()
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
export const accountPath = path.join(projectRoot, 'account.json')
export const manifestDir = path.join(projectRoot, 'manifest')
export const fileDir = path.join(projectRoot, 'file')
export const viewsDir = path.join(projectRoot, 'views')
export const publicDir = path.join(projectRoot, 'public')

211
src/shared/store.ts Normal file
View File

@@ -0,0 +1,211 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { accountPath, fileDir, manifestDir, manifestRootPath } from './paths'
import { AccountEntry, PackDefinition, PackListEntry, RootManifest } from './types'
const defaultRootManifest: RootManifest = {
packs: [
{
name: 'Sample Pack',
file: 'sample-pack'
}
]
}
const defaultAccount: AccountEntry[] = [
{
id: 'admin',
password: 'change-me'
}
]
const defaultPackDefinition: PackDefinition = {
mcVersion: '1.20.1',
serverMinRam: 2048,
serverMaxRam: 4096,
clientMinRam: 4096,
clientRecommendedRam: 8192,
packPath: 'sample-pack.zip',
description: '새 서버팩',
configEditableFiles: ['server.properties', 'bukkit.yml']
}
async function ensureDir(targetPath: string): Promise<void> {
await fsp.mkdir(targetPath, { recursive: true })
}
async function ensureJsonFile<T>(targetPath: string, defaultValue: T): Promise<void> {
if (!fs.existsSync(targetPath)) {
await fsp.writeFile(targetPath, `${JSON.stringify(defaultValue, null, 2)}\n`, 'utf8')
}
}
export async function ensureProjectFiles(): Promise<void> {
await ensureDir(manifestDir)
await ensureDir(fileDir)
await ensureJsonFile(manifestRootPath, defaultRootManifest)
await ensureJsonFile(accountPath, defaultAccount)
const samplePackPath = path.join(manifestDir, 'sample-pack.json')
await ensureJsonFile(samplePackPath, defaultPackDefinition)
}
async function readJsonFile<T>(targetPath: string, fallback: T): Promise<T> {
try {
const raw = await fsp.readFile(targetPath, 'utf8')
return JSON.parse(raw) as T
} catch {
return fallback
}
}
async function writeJsonFile(targetPath: string, payload: unknown): Promise<void> {
await fsp.writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
}
function sanitizePackKey(name: string): string {
const trimmed = name.trim().replace(/\.json$/i, '')
const normalized = trimmed.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-')
return normalized.length > 0 ? normalized : 'new'
}
export async function loadRootManifest(): Promise<RootManifest> {
await ensureProjectFiles()
return readJsonFile<RootManifest>(manifestRootPath, defaultRootManifest)
}
export async function saveRootManifest(manifest: RootManifest): Promise<void> {
await writeJsonFile(manifestRootPath, manifest)
}
export async function loadAccounts(): Promise<AccountEntry[]> {
await ensureProjectFiles()
return readJsonFile<AccountEntry[]>(accountPath, defaultAccount)
}
export async function listManifestFiles(): Promise<string[]> {
await ensureProjectFiles()
const entries = await fsp.readdir(manifestDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => entry.name.replace(/\.json$/i, ''))
.sort((left, right) => left.localeCompare(right))
}
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
await ensureProjectFiles()
const safeKey = sanitizePackKey(packKey)
const filePath = path.join(manifestDir, `${safeKey}.json`)
if (!fs.existsSync(filePath)) {
return null
}
return readJsonFile<PackDefinition>(filePath, defaultPackDefinition)
}
export async function savePackDefinition(packKey: string, payload: PackDefinition): Promise<void> {
const safeKey = sanitizePackKey(packKey)
await writeJsonFile(path.join(manifestDir, `${safeKey}.json`), payload)
}
function nextAvailableNewKey(existing: string[]): string {
if (!existing.includes('new')) {
return 'new'
}
let index = 2
while (existing.includes(`new${index}`)) {
index += 1
}
return `new${index}`
}
export async function createNewPack(): Promise<string> {
const existing = await listManifestFiles()
const packKey = nextAvailableNewKey(existing)
await savePackDefinition(packKey, {
...defaultPackDefinition,
description: `새 서버팩 (${packKey})`
})
const manifest = await loadRootManifest()
manifest.packs.push({
name: `새 서버팩 (${packKey})`,
file: packKey
})
await saveRootManifest(manifest)
return packKey
}
export async function deletePacks(packKeys: string[]): Promise<void> {
const targetKeys = new Set(packKeys.map((entry) => sanitizePackKey(entry)))
const manifest = await loadRootManifest()
manifest.packs = manifest.packs.filter((entry) => !targetKeys.has(entry.file))
await saveRootManifest(manifest)
await Promise.all(
[...targetKeys].map(async (packKey) => {
const filePath = path.join(manifestDir, `${packKey}.json`)
if (fs.existsSync(filePath)) {
await fsp.unlink(filePath)
}
})
)
}
export async function updatePack(
currentKey: string,
nextName: string,
nextKey: string,
definition: PackDefinition
): Promise<string> {
const safeCurrentKey = sanitizePackKey(currentKey)
const safeNextKey = sanitizePackKey(nextKey)
const currentPath = path.join(manifestDir, `${safeCurrentKey}.json`)
const nextPath = path.join(manifestDir, `${safeNextKey}.json`)
if (safeCurrentKey !== safeNextKey && fs.existsSync(nextPath)) {
throw new Error('같은 이름의 JSON 파일이 이미 존재합니다.')
}
await savePackDefinition(safeNextKey, definition)
if (safeCurrentKey !== safeNextKey && fs.existsSync(currentPath)) {
await fsp.unlink(currentPath)
}
const manifest = await loadRootManifest()
const targetIndex = manifest.packs.findIndex((entry) => entry.file === safeCurrentKey)
const nextEntry: PackListEntry = {
name: nextName.trim(),
file: safeNextKey
}
if (targetIndex >= 0) {
manifest.packs[targetIndex] = nextEntry
} else {
manifest.packs.push(nextEntry)
}
await saveRootManifest(manifest)
return safeNextKey
}
export function normalizePackDefinition(input: Partial<PackDefinition>): PackDefinition {
return {
mcVersion: String(input.mcVersion ?? '1.20.1').trim() || '1.20.1',
serverMinRam: Number(input.serverMinRam ?? 2048),
serverMaxRam: Number(input.serverMaxRam ?? 4096),
clientMinRam: Number(input.clientMinRam ?? 4096),
clientRecommendedRam: Number(input.clientRecommendedRam ?? 8192),
packPath: String(input.packPath ?? '').trim(),
description: String(input.description ?? '').trim(),
files: Array.isArray(input.files)
? input.files.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
: undefined,
configEditableFiles: Array.isArray(input.configEditableFiles)
? input.configEditableFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
: ['server.properties', 'bukkit.yml']
}
}

30
src/shared/types.ts Normal file
View File

@@ -0,0 +1,30 @@
export interface PackListEntry {
name: string
file: string
}
export interface RootManifest {
packs: PackListEntry[]
}
export interface PackDefinition {
mcVersion: string
serverMinRam: number
serverMaxRam: number
clientMinRam: number
clientRecommendedRam: number
packPath: string
description?: string
files?: string[]
configEditableFiles?: string[]
}
export interface AccountEntry {
id: string
password: string
}
export interface MinecraftRelease {
id: string
type: string
}