Reset repository to README title only
Approach is changing entirely; clearing prior implementation to start over from a clean slate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
release
|
||||
logs/*.log
|
||||
*.tsbuildinfo
|
||||
65
README.md
65
README.md
@@ -1,66 +1 @@
|
||||
# Minecraft Server Pack Easy Installer
|
||||
|
||||
마인크래프트 서버팩 관리 사이트와 Windows 설치기 프로젝트입니다.
|
||||
|
||||
- 관리 사이트: `Node.js + TypeScript + Express + EJS`
|
||||
- 설치기: `Electron`
|
||||
|
||||
## 요구사항
|
||||
|
||||
- Node.js 22
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 관리 사이트 실행
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
- 주소: `http://127.0.0.1:3000`
|
||||
- 관리자 로그인:
|
||||
- `id: admin`
|
||||
- `password: change-me`
|
||||
|
||||
## 설치기 실행
|
||||
|
||||
```bash
|
||||
npm run installer
|
||||
```
|
||||
|
||||
- 기본 `manifest.json` 주소: `http://127.0.0.1:3000/manifest.json`
|
||||
- 설치기는 서버팩 목록 조회, 경로 검사, JDK 탐색, 다운로드/압축 해제, EULA 동의, 설정 편집기, 포트 개방 시도, 완료 단계를 제공합니다.
|
||||
|
||||
## Windows `.exe` 빌드
|
||||
|
||||
```bash
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
- 결과물은 `release/` 아래에 생성됩니다.
|
||||
|
||||
## 빠른 테스트 순서
|
||||
|
||||
1. `npm install`
|
||||
2. `npm start`
|
||||
3. 브라우저에서 `http://127.0.0.1:3000` 확인
|
||||
4. 새 터미널에서 `npm run installer`
|
||||
|
||||
## 기본 데이터
|
||||
|
||||
- 서버팩 목록: [manifest.json](manifest.json)
|
||||
- 샘플 서버팩: [manifest/sample-pack.json](manifest/sample-pack.json)
|
||||
- 샘플 ZIP: [file/sample-pack.zip](file/sample-pack.zip)
|
||||
|
||||
샘플 ZIP은 흐름 확인용입니다. 실제 서버 실행까지 테스트하려면 `file/` 아래에 실제 서버팩 ZIP을 넣고 `manifest/*.json`의 `packPath`를 그 파일명으로 바꾸면 됩니다.
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run smoke
|
||||
```
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "admin",
|
||||
"password": "change-me"
|
||||
}
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
appId: kr.tkrmagid.mc-custom-suite
|
||||
productName: MC Custom Installer
|
||||
directories:
|
||||
output: release
|
||||
files:
|
||||
- dist/**/*
|
||||
- installer/**/*
|
||||
- views/**/*
|
||||
- public/**/*
|
||||
- manifest.json
|
||||
- account.json
|
||||
- manifest/**/*
|
||||
- file/**/*
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
Binary file not shown.
@@ -1,150 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MC Custom Installer</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="steps">
|
||||
<h1>MC Custom Installer</h1>
|
||||
<ol>
|
||||
<li data-step="1" class="active">서버팩 선택</li>
|
||||
<li data-step="2">설치 경로 설정</li>
|
||||
<li data-step="3">JDK 확인 / 설치</li>
|
||||
<li data-step="4">다운로드 및 설치</li>
|
||||
<li data-step="5">서버 설정</li>
|
||||
<li data-step="6">포트포워딩 설정</li>
|
||||
<li data-step="7">클라이언트 적용</li>
|
||||
<li data-step="8">완료</li>
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<section class="panel active" data-panel="1">
|
||||
<p class="eyebrow">STEP 1</p>
|
||||
<h2>서버팩 선택</h2>
|
||||
<label class="field">
|
||||
<span>manifest.json URL</span>
|
||||
<input id="manifestUrl" />
|
||||
</label>
|
||||
<p class="infoHint">설치기가 시작되면 서버팩 목록을 자동으로 불러옵니다.</p>
|
||||
<div id="packList" class="packList"></div>
|
||||
<div class="buttonRow end">
|
||||
<button id="toStep2" class="primary">다음</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="2">
|
||||
<p class="eyebrow">STEP 2</p>
|
||||
<h2>설치 경로 설정</h2>
|
||||
<label class="field">
|
||||
<span>설치 경로</span>
|
||||
<div class="inputRow">
|
||||
<input id="installPath" />
|
||||
<button id="browseInstallPath">폴더 선택</button>
|
||||
</div>
|
||||
</label>
|
||||
<p id="installPathWarning" class="warningText"></p>
|
||||
<div class="buttonRow between">
|
||||
<button data-back="1">이전</button>
|
||||
<button id="toStep3" class="primary">다음</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="3">
|
||||
<p class="eyebrow">STEP 3</p>
|
||||
<h2>JDK 확인 / 설치</h2>
|
||||
<div id="jdkRecommended" class="infoBox">선택한 서버팩의 권장 JDK 버전을 확인 중입니다.</div>
|
||||
<label class="field">
|
||||
<span>JDK 경로</span>
|
||||
<div class="inputRow">
|
||||
<input id="jdkPath" />
|
||||
<button id="browseJdkPath">폴더 선택</button>
|
||||
</div>
|
||||
</label>
|
||||
<div id="jdkStatus" class="infoBox"></div>
|
||||
<div class="buttonRow between">
|
||||
<button data-back="2">이전</button>
|
||||
<button id="toStep4" class="primary">다음</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="4">
|
||||
<p class="eyebrow">STEP 4</p>
|
||||
<h2>다운로드 및 설치</h2>
|
||||
<div class="buttonRow">
|
||||
<button id="startInstallButton" class="primary">설치 시작</button>
|
||||
</div>
|
||||
<div id="logView" class="logView"></div>
|
||||
<div id="eulaBlock" class="eulaBlock hidden">
|
||||
<p>Minecraft EULA를 확인한 뒤 동의 버튼을 눌러야 다음 단계로 진행됩니다.</p>
|
||||
<a id="eulaLink" class="eulaLink" href="#" target="_blank" rel="noreferrer">공식 EULA 원문 열기</a>
|
||||
<pre id="eulaText" class="eulaText"></pre>
|
||||
<button id="acceptEulaButton" class="primary">EULA 동의 후 계속</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="5">
|
||||
<p class="eyebrow">STEP 5</p>
|
||||
<h2>서버 설정</h2>
|
||||
<p>로컬 웹서버를 띄워 브라우저에서 설정 파일을 수정합니다.</p>
|
||||
<div class="buttonRow">
|
||||
<button id="openConfigEditorButton" class="primary">설정 편집기 열기</button>
|
||||
</div>
|
||||
<div id="configEditorStatus" class="infoBox"></div>
|
||||
<div class="buttonRow between">
|
||||
<button data-back="4">이전</button>
|
||||
<button id="toStep6" class="primary">다음</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="6">
|
||||
<p class="eyebrow">STEP 6</p>
|
||||
<h2>포트포워딩 설정</h2>
|
||||
<div class="buttonRow">
|
||||
<button id="configurePortButton" class="primary">포트 개방 확인 / 시도</button>
|
||||
</div>
|
||||
<div id="portStatusBox" class="infoBox"></div>
|
||||
<div class="buttonRow between">
|
||||
<button data-back="5">이전</button>
|
||||
<button id="toStep7" class="primary">다음</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="7">
|
||||
<p class="eyebrow">STEP 7</p>
|
||||
<h2>클라이언트 적용</h2>
|
||||
<p class="infoHint">런처 프로필, 로더 설치, 리소스팩, 쉐이더를 한 번에 적용합니다.</p>
|
||||
<div class="buttonRow">
|
||||
<button id="applyClientButton" class="primary">클라이언트 적용</button>
|
||||
</div>
|
||||
<div id="clientApplyStatus" class="infoBox"></div>
|
||||
<div class="buttonRow between">
|
||||
<button data-back="6">이전</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="8">
|
||||
<p class="eyebrow">STEP 8</p>
|
||||
<h2>완료</h2>
|
||||
<label class="toggleRow">
|
||||
<input type="checkbox" id="createShortcutToggle" checked />
|
||||
<span>바탕화면에 서버 실행 바로가기 만들기</span>
|
||||
</label>
|
||||
<label class="toggleRow">
|
||||
<input type="checkbox" id="runServerToggle" checked />
|
||||
<span>서버 바로 실행</span>
|
||||
</label>
|
||||
<div class="buttonRow">
|
||||
<button id="openFolderButton">서버 폴더 열기</button>
|
||||
<button id="finishButton" class="primary">적용 및 완료</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="./renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,304 +0,0 @@
|
||||
const state = {
|
||||
manifestUrl: '',
|
||||
selectedPack: null,
|
||||
selectedPackMeta: null,
|
||||
installPath: '',
|
||||
jdkPath: ''
|
||||
}
|
||||
|
||||
const panelMap = new Map([...document.querySelectorAll('.panel')].map((panel) => [panel.dataset.panel, panel]))
|
||||
const stepMap = new Map([...document.querySelectorAll('.steps li')].map((step) => [step.dataset.step, step]))
|
||||
const logView = document.getElementById('logView')
|
||||
const packList = document.getElementById('packList')
|
||||
const manifestUrlInput = document.getElementById('manifestUrl')
|
||||
const installPathInput = document.getElementById('installPath')
|
||||
const jdkPathInput = document.getElementById('jdkPath')
|
||||
const jdkStatus = document.getElementById('jdkStatus')
|
||||
const jdkRecommended = document.getElementById('jdkRecommended')
|
||||
const eulaBlock = document.getElementById('eulaBlock')
|
||||
const eulaText = document.getElementById('eulaText')
|
||||
const eulaLink = document.getElementById('eulaLink')
|
||||
const startInstallButton = document.getElementById('startInstallButton')
|
||||
const applyClientButton = document.getElementById('applyClientButton')
|
||||
const clientApplyStatus = document.getElementById('clientApplyStatus')
|
||||
|
||||
function setActiveStep(step) {
|
||||
for (const [key, panel] of panelMap.entries()) {
|
||||
panel.classList.toggle('active', key === String(step))
|
||||
}
|
||||
|
||||
for (const [key, item] of stepMap.entries()) {
|
||||
item.classList.toggle('active', key === String(step))
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(entry) {
|
||||
if (entry?.action === 'eula-required') {
|
||||
eulaText.textContent = entry.eulaText ?? ''
|
||||
eulaLink.href = entry.eulaUrl ?? '#'
|
||||
eulaBlock.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
const line = `[${entry?.tone ?? 'info'}] ${entry?.message ?? ''}`
|
||||
logView.textContent += `${line}\n`
|
||||
logView.scrollTop = logView.scrollHeight
|
||||
}
|
||||
|
||||
function validateInstallPath(pathValue) {
|
||||
const warning = document.getElementById('installPathWarning')
|
||||
const hasHangul = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(pathValue)
|
||||
warning.textContent = hasHangul ? '경로에 한글이 포함되면 안 됩니다.' : ''
|
||||
return !hasHangul && pathValue.trim().length > 0
|
||||
}
|
||||
|
||||
function renderPackList(packs) {
|
||||
packList.innerHTML = ''
|
||||
packs.forEach((pack) => {
|
||||
const label = document.createElement('label')
|
||||
label.className = 'packOption'
|
||||
label.innerHTML = `
|
||||
<input type="radio" name="packChoice" value="${pack.file}" data-pack-name="${pack.name}" />
|
||||
<div>
|
||||
<strong>${pack.name}</strong>
|
||||
<span>${pack.file}</span>
|
||||
</div>
|
||||
`
|
||||
packList.appendChild(label)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadPackManifest() {
|
||||
state.manifestUrl = manifestUrlInput.value.trim()
|
||||
state.selectedPack = null
|
||||
state.selectedPackMeta = null
|
||||
packList.innerHTML = '<div class="infoBox">서버팩 목록을 불러오는 중입니다.</div>'
|
||||
try {
|
||||
const manifest = await window.installerApi.loadPacks(state.manifestUrl)
|
||||
renderPackList(manifest.packs)
|
||||
} catch (error) {
|
||||
packList.innerHTML = `<div class="infoBox">${error.message}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedPackMeta() {
|
||||
if (state.selectedPack == null) {
|
||||
state.selectedPackMeta = null
|
||||
return null
|
||||
}
|
||||
|
||||
const packMeta = await window.installerApi.inspectPack(state.manifestUrl, state.selectedPack.file)
|
||||
state.selectedPackMeta = packMeta
|
||||
return packMeta
|
||||
}
|
||||
|
||||
async function autoDetectJdkForSelectedPack() {
|
||||
const packMeta = await loadSelectedPackMeta()
|
||||
if (packMeta == null) {
|
||||
jdkRecommended.textContent = '먼저 서버팩을 선택하세요.'
|
||||
jdkStatus.textContent = ''
|
||||
return
|
||||
}
|
||||
|
||||
const recommendedVersion = packMeta.packDefinition.recommendedJdkVersion ?? null
|
||||
jdkRecommended.textContent = recommendedVersion != null
|
||||
? `선택한 서버팩의 권장 JDK 버전: ${recommendedVersion}`
|
||||
: '선택한 서버팩에 권장 JDK 버전 정보가 없습니다.'
|
||||
|
||||
jdkStatus.textContent = 'JDK 자동 탐색 중입니다.'
|
||||
const result = await window.installerApi.detectJdk(recommendedVersion)
|
||||
if (result.detected != null) {
|
||||
state.jdkPath = result.detected
|
||||
jdkPathInput.value = result.detected
|
||||
}
|
||||
|
||||
if (result.detected == null) {
|
||||
jdkStatus.textContent = '설치 가능한 JDK를 찾지 못했습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
const pickedCandidate = result.candidates.find((candidate) => candidate.path === result.detected)
|
||||
const versionLabel = pickedCandidate?.majorVersion != null ? `JDK ${pickedCandidate.majorVersion}` : '버전 미확인 JDK'
|
||||
if (result.recommendedVersion != null && result.exactMatch) {
|
||||
jdkStatus.textContent = `권장 버전과 일치하는 ${versionLabel}를 자동 선택했습니다: ${result.detected}`
|
||||
return
|
||||
}
|
||||
|
||||
if (result.recommendedVersion != null) {
|
||||
jdkStatus.textContent = `권장 JDK ${result.recommendedVersion}은 찾지 못해 ${versionLabel}를 대신 선택했습니다: ${result.detected}`
|
||||
return
|
||||
}
|
||||
|
||||
jdkStatus.textContent = `자동 탐색 성공: ${versionLabel} / ${result.detected}`
|
||||
}
|
||||
|
||||
async function goToStep(step) {
|
||||
setActiveStep(step)
|
||||
|
||||
if (step === 3) {
|
||||
try {
|
||||
await autoDetectJdkForSelectedPack()
|
||||
} catch (error) {
|
||||
jdkStatus.textContent = error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const defaults = await window.installerApi.getDefaults()
|
||||
state.manifestUrl = defaults.manifestUrl
|
||||
manifestUrlInput.value = defaults.manifestUrl
|
||||
await loadPackManifest()
|
||||
}
|
||||
|
||||
window.installerApi.onLog(appendLog)
|
||||
|
||||
packList.addEventListener('change', () => {
|
||||
const checked = packList.querySelector('input[name="packChoice"]:checked')
|
||||
if (checked == null) {
|
||||
state.selectedPack = null
|
||||
state.selectedPackMeta = null
|
||||
return
|
||||
}
|
||||
state.selectedPack = {
|
||||
file: checked.value,
|
||||
name: checked.dataset.packName ?? checked.value
|
||||
}
|
||||
state.selectedPackMeta = null
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-back]').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
await goToStep(button.dataset.back)
|
||||
})
|
||||
})
|
||||
|
||||
manifestUrlInput.addEventListener('change', async () => {
|
||||
await loadPackManifest()
|
||||
})
|
||||
|
||||
document.getElementById('toStep2').addEventListener('click', async () => {
|
||||
if (state.selectedPack == null) {
|
||||
alert('서버팩을 먼저 선택하세요.')
|
||||
return
|
||||
}
|
||||
await goToStep(2)
|
||||
})
|
||||
|
||||
document.getElementById('browseInstallPath').addEventListener('click', async () => {
|
||||
const selected = await window.installerApi.chooseDirectory()
|
||||
if (selected != null) {
|
||||
state.installPath = selected
|
||||
installPathInput.value = selected
|
||||
validateInstallPath(selected)
|
||||
}
|
||||
})
|
||||
|
||||
installPathInput.addEventListener('input', (event) => {
|
||||
state.installPath = event.target.value
|
||||
validateInstallPath(state.installPath)
|
||||
})
|
||||
|
||||
document.getElementById('toStep3').addEventListener('click', async () => {
|
||||
if (!validateInstallPath(state.installPath)) {
|
||||
alert('올바른 설치 경로를 입력하세요.')
|
||||
return
|
||||
}
|
||||
await goToStep(3)
|
||||
})
|
||||
|
||||
document.getElementById('browseJdkPath').addEventListener('click', async () => {
|
||||
const selected = await window.installerApi.chooseJdk()
|
||||
if (selected != null) {
|
||||
state.jdkPath = selected
|
||||
jdkPathInput.value = selected
|
||||
}
|
||||
})
|
||||
|
||||
jdkPathInput.addEventListener('input', (event) => {
|
||||
state.jdkPath = event.target.value
|
||||
})
|
||||
|
||||
document.getElementById('toStep4').addEventListener('click', async () => {
|
||||
if (state.jdkPath.trim().length === 0) {
|
||||
alert('JDK 경로를 지정하세요.')
|
||||
return
|
||||
}
|
||||
await goToStep(4)
|
||||
})
|
||||
|
||||
startInstallButton.addEventListener('click', async () => {
|
||||
if (state.selectedPack == null) {
|
||||
alert('서버팩을 먼저 선택하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
logView.textContent = ''
|
||||
eulaBlock.classList.add('hidden')
|
||||
eulaText.textContent = ''
|
||||
startInstallButton.disabled = true
|
||||
|
||||
try {
|
||||
const result = await window.installerApi.startInstall({
|
||||
manifestUrl: state.manifestUrl,
|
||||
packFile: state.selectedPack.file,
|
||||
installPath: state.installPath,
|
||||
jdkPath: state.jdkPath
|
||||
})
|
||||
if (result.warning != null) {
|
||||
appendLog({ message: result.warning, tone: 'warn' })
|
||||
}
|
||||
await goToStep(result.nextStep)
|
||||
} finally {
|
||||
startInstallButton.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('acceptEulaButton').addEventListener('click', async () => {
|
||||
await window.installerApi.acceptEula()
|
||||
eulaBlock.classList.add('hidden')
|
||||
})
|
||||
|
||||
document.getElementById('openConfigEditorButton').addEventListener('click', async () => {
|
||||
const url = await window.installerApi.openConfigEditor()
|
||||
document.getElementById('configEditorStatus').textContent = `브라우저에서 열림: ${url}`
|
||||
})
|
||||
|
||||
document.getElementById('toStep6').addEventListener('click', async () => {
|
||||
await goToStep(6)
|
||||
})
|
||||
|
||||
document.getElementById('configurePortButton').addEventListener('click', async () => {
|
||||
const result = await window.installerApi.configurePort()
|
||||
document.getElementById('portStatusBox').textContent = result.message
|
||||
})
|
||||
|
||||
document.getElementById('toStep7').addEventListener('click', async () => {
|
||||
await goToStep(7)
|
||||
})
|
||||
|
||||
applyClientButton.addEventListener('click', async () => {
|
||||
applyClientButton.disabled = true
|
||||
clientApplyStatus.textContent = '클라이언트 적용 중입니다.'
|
||||
try {
|
||||
const result = await window.installerApi.applyClient()
|
||||
clientApplyStatus.textContent = result.message
|
||||
await goToStep(result.nextStep)
|
||||
} finally {
|
||||
applyClientButton.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('openFolderButton').addEventListener('click', async () => {
|
||||
await window.installerApi.openFolder()
|
||||
})
|
||||
|
||||
document.getElementById('finishButton').addEventListener('click', async () => {
|
||||
const createShortcut = document.getElementById('createShortcutToggle').checked
|
||||
const runServer = document.getElementById('runServerToggle').checked
|
||||
await window.installerApi.createShortcut(createShortcut)
|
||||
await window.installerApi.runServer(runServer)
|
||||
alert('설치가 완료되었습니다.')
|
||||
})
|
||||
|
||||
bootstrap()
|
||||
@@ -1,196 +0,0 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b100d;
|
||||
--panel: #141b17;
|
||||
--soft: #1d2721;
|
||||
--line: #2a362f;
|
||||
--text: #f2f4f3;
|
||||
--muted: #a9b2ad;
|
||||
--accent: #f0bf57;
|
||||
--warn: #ffb36b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(240, 191, 87, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, #0b100d 0%, #121914 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.steps {
|
||||
padding: 30px 24px;
|
||||
background: rgba(10, 14, 12, 0.78);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.steps h1 {
|
||||
margin: 0 0 20px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.steps ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.steps li.active {
|
||||
background: var(--accent);
|
||||
color: #1b1408;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 34px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: none;
|
||||
max-width: 920px;
|
||||
background: rgba(20, 27, 23, 0.92);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.panel.active { display: block; }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.28em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 { margin: 0 0 20px; font-size: 34px; }
|
||||
|
||||
.field { display: grid; gap: 8px; margin-bottom: 16px; }
|
||||
|
||||
.infoHint {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.inputRow,
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.buttonRow { margin-top: 18px; }
|
||||
.buttonRow.end { justify-content: flex-end; }
|
||||
.buttonRow.between { justify-content: space-between; }
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #1c1509;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.packList {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.packOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.packOption strong {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.warningText {
|
||||
color: var(--warn);
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.infoBox,
|
||||
.logView,
|
||||
.eulaBlock {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--soft);
|
||||
}
|
||||
|
||||
.logView {
|
||||
height: 320px;
|
||||
overflow: auto;
|
||||
font-family: Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.eulaLink {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.eulaText {
|
||||
margin-top: 14px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"packs": [
|
||||
{
|
||||
"name": "Sample Pack",
|
||||
"file": "sample-pack"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"mcVersion": "1.20.1",
|
||||
"recommendedJdkVersion": 17,
|
||||
"loaderType": "vanilla",
|
||||
"loaderVersion": "",
|
||||
"loaderInstallerPath": "",
|
||||
"serverMinRam": 2048,
|
||||
"serverMaxRam": 4096,
|
||||
"clientMinRam": 4096,
|
||||
"clientRecommendedRam": 8192,
|
||||
"packPath": "sample-pack.zip",
|
||||
"description": "새 서버팩",
|
||||
"configEditableFiles": [
|
||||
"server.properties",
|
||||
"bukkit.yml"
|
||||
],
|
||||
"resourcePackFiles": [],
|
||||
"shaderPackFiles": []
|
||||
}
|
||||
4956
package-lock.json
generated
4956
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "mc-custom-suite",
|
||||
"version": "1.0.0",
|
||||
"description": "Minecraft server pack easy installer and management site",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "npm run build && node dist/server/app.js",
|
||||
"installer": "npm run build && electron .",
|
||||
"dist:win": "npm run build && electron-builder --win nsis",
|
||||
"smoke": "npm run build && node -e \"require('./dist/shared/store').ensureProjectFiles(); console.log('smoke ok')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/multer": "^2.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.2",
|
||||
"multer": "^2.1.1",
|
||||
"nat-upnp": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0e1411;
|
||||
--panel: #151d19;
|
||||
--panel-strong: #1d2822;
|
||||
--text: #f3f5f4;
|
||||
--muted: #a7b2ac;
|
||||
--line: #2b3831;
|
||||
--accent: #f0bf57;
|
||||
--danger: #ff7a6b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(240, 191, 87, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, #0b100d 0%, #101712 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.siteBody {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.adminHeader,
|
||||
.pageWrap,
|
||||
.loginPanel {
|
||||
width: min(1180px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 56px 0 26px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.28em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.loginPanel h1,
|
||||
.editorHeader h1 {
|
||||
margin: 0;
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.heroText {
|
||||
color: var(--muted);
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.pageWrap {
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
|
||||
.cardRow {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.packCard {
|
||||
min-width: 260px;
|
||||
background: rgba(21, 29, 25, 0.92);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.packCard h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.ghostLink,
|
||||
.primaryLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
padding: 0 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.primaryLink,
|
||||
.primaryButton {
|
||||
background: var(--accent);
|
||||
color: #16130e;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
background: var(--danger);
|
||||
color: #180d0b;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.dangerButton {
|
||||
min-height: 46px;
|
||||
padding: 0 20px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.adminHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 26px 0;
|
||||
}
|
||||
|
||||
.brandLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: var(--accent);
|
||||
color: #17120d;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.userMenu summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.userMenu[open] form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.userMenu button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.deleteForm {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.deleteToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.deleteActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selectableCard {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selectionBox {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
}
|
||||
|
||||
.ghostButton {
|
||||
min-height: 46px;
|
||||
padding: 0 20px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.selectionTitle {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.warningBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 191, 87, 0.16);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.adminLoginBody {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loginPanel,
|
||||
.editorWrap {
|
||||
background: rgba(21, 29, 25, 0.94);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.loginPanel {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.stackForm,
|
||||
.editorForm {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stackForm label,
|
||||
.editorForm label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gridTwo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.fullSpan {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.assetSection {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.assetCard {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
background: rgba(21, 29, 25, 0.72);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.assetCard h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.assetForm {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assetList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.assetItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: #ffb7ae;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.hero,
|
||||
.adminHeader,
|
||||
.editorHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.gridTwo {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('installerApi', {
|
||||
getDefaults: () => ipcRenderer.invoke('installer:get-defaults'),
|
||||
loadPacks: (manifestUrl: string) => ipcRenderer.invoke('installer:load-packs', manifestUrl),
|
||||
inspectPack: (manifestUrl: string, packFile: string) => ipcRenderer.invoke('installer:inspect-pack', manifestUrl, packFile),
|
||||
chooseDirectory: () => ipcRenderer.invoke('installer:choose-directory'),
|
||||
detectJdk: (recommendedVersion?: number | null) => ipcRenderer.invoke('installer:detect-jdk', recommendedVersion),
|
||||
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'),
|
||||
applyClient: () => ipcRenderer.invoke('installer:apply-client'),
|
||||
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))
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { PackDefinition, PackListEntry } from '../shared/types'
|
||||
|
||||
export interface InstallerDefaults {
|
||||
manifestUrl: string
|
||||
}
|
||||
|
||||
export interface PackMetadata {
|
||||
packName: string
|
||||
packDefinition: PackDefinition
|
||||
}
|
||||
|
||||
export interface JdkCandidate {
|
||||
path: string
|
||||
majorVersion: number | null
|
||||
}
|
||||
|
||||
export interface DetectJdkResult {
|
||||
detected: string | null
|
||||
candidates: JdkCandidate[]
|
||||
recommendedVersion: number | null
|
||||
exactMatch: boolean
|
||||
}
|
||||
|
||||
export interface SelectedPackPayload {
|
||||
manifestUrl: string
|
||||
pack: PackListEntry
|
||||
}
|
||||
|
||||
export interface InstallPayload {
|
||||
manifestUrl: string
|
||||
packFile: string
|
||||
installPath: string
|
||||
jdkPath: string
|
||||
}
|
||||
|
||||
export interface InstallSessionState {
|
||||
manifestUrl: string
|
||||
baseUrl: string
|
||||
packFile: string
|
||||
installPath: string
|
||||
jdkPath: string
|
||||
packDefinition: PackDefinition
|
||||
packName: string
|
||||
extractedRoot: string
|
||||
externalAddress?: string
|
||||
externalPort?: number
|
||||
configEditorUrl?: string
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -1,293 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
import multer from 'multer'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||
import { fileDir } from '../../shared/paths'
|
||||
import {
|
||||
createNewPack,
|
||||
deletePacks,
|
||||
listDashboardPacks,
|
||||
loadAccounts,
|
||||
loadPackDefinition,
|
||||
loadRootManifest,
|
||||
normalizePackDefinition,
|
||||
savePackDefinition,
|
||||
updatePack
|
||||
} from '../../shared/store'
|
||||
import { PackDefinition } from '../../shared/types'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
|
||||
export const opRouter = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage() })
|
||||
|
||||
function pickFirstValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return typeof value[0] === 'string' ? value[0] : ''
|
||||
}
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function sanitizeUploadFileName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-')
|
||||
}
|
||||
|
||||
function normalizeAssetPathForWeb(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
async function saveUploadedPackAsset(packKey: string, bucket: 'loaders' | 'resourcepacks' | 'shaderpacks', file: Express.Multer.File): Promise<string> {
|
||||
const safeName = sanitizeUploadFileName(file.originalname)
|
||||
const relativePath = path.join('uploads', packKey, bucket, `${Date.now()}-${safeName}`)
|
||||
const absolutePath = path.join(fileDir, relativePath)
|
||||
await fsp.mkdir(path.dirname(absolutePath), { recursive: true })
|
||||
await fsp.writeFile(absolutePath, file.buffer)
|
||||
return normalizeAssetPathForWeb(relativePath)
|
||||
}
|
||||
|
||||
async function mutatePackDefinition(packKey: string, mutate: (pack: PackDefinition) => void): Promise<void> {
|
||||
const current = await loadPackDefinition(packKey)
|
||||
if (current == null) {
|
||||
throw new Error('서버팩 JSON을 찾을 수 없습니다.')
|
||||
}
|
||||
|
||||
const next = normalizePackDefinition(current)
|
||||
mutate(next)
|
||||
await savePackDefinition(packKey, next)
|
||||
}
|
||||
|
||||
async function removeUploadedAsset(relativePath: string): Promise<void> {
|
||||
const absolutePath = path.join(fileDir, relativePath)
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
await fsp.unlink(absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
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 { password } = req.body as { password?: string }
|
||||
const accounts = await loadAccounts()
|
||||
const matched = accounts.find((entry) => 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 packs = await listDashboardPacks()
|
||||
res.render('op/dashboard', {
|
||||
userId: _req.session.userId,
|
||||
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 currentDefinition = await loadPackDefinition(packKey)
|
||||
if (currentDefinition == null) {
|
||||
throw new Error('서버팩 JSON을 찾을 수 없습니다.')
|
||||
}
|
||||
|
||||
const normalized = normalizePackDefinition({
|
||||
...currentDefinition,
|
||||
mcVersion: pickFirstValue(req.body.mcVersion),
|
||||
recommendedJdkVersion: Number(pickFirstValue(req.body.recommendedJdkVersion)),
|
||||
loaderType: pickFirstValue(req.body.loaderType) as PackDefinition['loaderType'],
|
||||
loaderVersion: pickFirstValue(req.body.loaderVersion),
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/:packName/assets/loader', requireAuth, upload.single('asset'), async (req, res, next) => {
|
||||
try {
|
||||
const packKey = pickFirstValue(req.params.packName)
|
||||
if (req.file == null) {
|
||||
throw new Error('업로드된 로더 파일이 없습니다.')
|
||||
}
|
||||
|
||||
const relativePath = await saveUploadedPackAsset(packKey, 'loaders', req.file)
|
||||
await mutatePackDefinition(packKey, (pack) => {
|
||||
pack.loaderInstallerPath = relativePath
|
||||
})
|
||||
|
||||
res.redirect(`/op/dashboard/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/:packName/assets/resource-pack', requireAuth, upload.single('asset'), async (req, res, next) => {
|
||||
try {
|
||||
const packKey = pickFirstValue(req.params.packName)
|
||||
if (req.file == null) {
|
||||
throw new Error('업로드된 리소스팩 파일이 없습니다.')
|
||||
}
|
||||
|
||||
const relativePath = await saveUploadedPackAsset(packKey, 'resourcepacks', req.file)
|
||||
await mutatePackDefinition(packKey, (pack) => {
|
||||
pack.resourcePackFiles = [...(pack.resourcePackFiles ?? []), relativePath]
|
||||
})
|
||||
|
||||
res.redirect(`/op/dashboard/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/:packName/assets/shader-pack', requireAuth, upload.single('asset'), async (req, res, next) => {
|
||||
try {
|
||||
const packKey = pickFirstValue(req.params.packName)
|
||||
if (req.file == null) {
|
||||
throw new Error('업로드된 쉐이더 파일이 없습니다.')
|
||||
}
|
||||
|
||||
const relativePath = await saveUploadedPackAsset(packKey, 'shaderpacks', req.file)
|
||||
await mutatePackDefinition(packKey, (pack) => {
|
||||
pack.shaderPackFiles = [...(pack.shaderPackFiles ?? []), relativePath]
|
||||
})
|
||||
|
||||
res.redirect(`/op/dashboard/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/:packName/assets/loader/remove', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = pickFirstValue(req.params.packName)
|
||||
const targetPath = pickFirstValue(req.body.assetPath)
|
||||
await removeUploadedAsset(targetPath)
|
||||
await mutatePackDefinition(packKey, (pack) => {
|
||||
if (pack.loaderInstallerPath === targetPath) {
|
||||
pack.loaderInstallerPath = ''
|
||||
}
|
||||
})
|
||||
res.redirect(`/op/dashboard/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/:packName/assets/resource-pack/remove', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = pickFirstValue(req.params.packName)
|
||||
const targetPath = pickFirstValue(req.body.assetPath)
|
||||
await removeUploadedAsset(targetPath)
|
||||
await mutatePackDefinition(packKey, (pack) => {
|
||||
pack.resourcePackFiles = (pack.resourcePackFiles ?? []).filter((entry) => entry !== targetPath)
|
||||
})
|
||||
res.redirect(`/op/dashboard/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/:packName/assets/shader-pack/remove', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = pickFirstValue(req.params.packName)
|
||||
const targetPath = pickFirstValue(req.body.assetPath)
|
||||
await removeUploadedAsset(targetPath)
|
||||
await mutatePackDefinition(packKey, (pack) => {
|
||||
pack.shaderPackFiles = (pack.shaderPackFiles ?? []).filter((entry) => entry !== targetPath)
|
||||
})
|
||||
res.redirect(`/op/dashboard/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
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
9
src/shared/nat-upnp.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
declare module 'nat-upnp' {
|
||||
const upnp: {
|
||||
createClient(): {
|
||||
portMapping(options: Record<string, unknown>, callback: (error?: Error | null) => void): void
|
||||
}
|
||||
}
|
||||
|
||||
export default upnp
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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')
|
||||
@@ -1,247 +0,0 @@
|
||||
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, DashboardPackEntry, 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',
|
||||
recommendedJdkVersion: 17,
|
||||
loaderType: 'vanilla',
|
||||
loaderVersion: '',
|
||||
loaderInstallerPath: '',
|
||||
serverMinRam: 2048,
|
||||
serverMaxRam: 4096,
|
||||
clientMinRam: 4096,
|
||||
clientRecommendedRam: 8192,
|
||||
packPath: 'sample-pack.zip',
|
||||
description: '새 서버팩',
|
||||
configEditableFiles: ['server.properties', 'bukkit.yml'],
|
||||
resourcePackFiles: [],
|
||||
shaderPackFiles: []
|
||||
}
|
||||
|
||||
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 listDashboardPacks(): Promise<DashboardPackEntry[]> {
|
||||
const [manifestFiles, rootManifest] = await Promise.all([
|
||||
listManifestFiles(),
|
||||
loadRootManifest()
|
||||
])
|
||||
|
||||
return manifestFiles.map((file) => {
|
||||
const registeredPack = rootManifest.packs.find((entry) => entry.file === file)
|
||||
return {
|
||||
file,
|
||||
name: registeredPack?.name ?? file,
|
||||
registered: registeredPack != null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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',
|
||||
recommendedJdkVersion: Number.isFinite(Number(input.recommendedJdkVersion))
|
||||
? Number(input.recommendedJdkVersion)
|
||||
: 17,
|
||||
loaderType: ['vanilla', 'forge', 'fabric', 'neoforge'].includes(String(input.loaderType ?? 'vanilla'))
|
||||
? String(input.loaderType ?? 'vanilla') as PackDefinition['loaderType']
|
||||
: 'vanilla',
|
||||
loaderVersion: String(input.loaderVersion ?? '').trim(),
|
||||
loaderInstallerPath: String(input.loaderInstallerPath ?? '').trim(),
|
||||
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'],
|
||||
resourcePackFiles: Array.isArray(input.resourcePackFiles)
|
||||
? input.resourcePackFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
|
||||
: [],
|
||||
shaderPackFiles: Array.isArray(input.shaderPackFiles)
|
||||
? input.shaderPackFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
|
||||
: []
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
export interface PackListEntry {
|
||||
name: string
|
||||
file: string
|
||||
}
|
||||
|
||||
export interface DashboardPackEntry extends PackListEntry {
|
||||
registered: boolean
|
||||
}
|
||||
|
||||
export interface RootManifest {
|
||||
packs: PackListEntry[]
|
||||
}
|
||||
|
||||
export interface PackDefinition {
|
||||
mcVersion: string
|
||||
recommendedJdkVersion?: number
|
||||
loaderType?: 'vanilla' | 'forge' | 'fabric' | 'neoforge'
|
||||
loaderVersion?: string
|
||||
loaderInstallerPath?: string
|
||||
serverMinRam: number
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
clientRecommendedRam: number
|
||||
packPath: string
|
||||
description?: string
|
||||
files?: string[]
|
||||
configEditableFiles?: string[]
|
||||
resourcePackFiles?: string[]
|
||||
shaderPackFiles?: string[]
|
||||
}
|
||||
|
||||
export interface AccountEntry {
|
||||
id: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface MinecraftRelease {
|
||||
id: string
|
||||
type: string
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>서버팩 목록</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">MC CUSTOM</p>
|
||||
<h1>마인크래프트 서버팩 목록</h1>
|
||||
<p class="heroText">manifest.json에 등록된 서버팩을 한 줄 카드 목록으로 제공합니다.</p>
|
||||
</div>
|
||||
<a class="primaryLink" href="/op">관리자 로그인</a>
|
||||
</header>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="cardRow">
|
||||
<% packs.forEach((pack) => { %>
|
||||
<article class="packCard">
|
||||
<h2><%= pack.name %></h2>
|
||||
<p>manifest 키: <code><%= pack.file %></code></p>
|
||||
<a class="ghostLink" href="/manifest/<%= pack.file %>.json">JSON 보기</a>
|
||||
</article>
|
||||
<% }) %>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,70 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 대시보드</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="toolbar">
|
||||
<form method="post" action="/op/dashboard/packs">
|
||||
<button class="primaryButton" type="submit">서버팩 추가</button>
|
||||
</form>
|
||||
<form method="post" action="/op/dashboard/packs/delete" class="deleteForm" data-delete-form>
|
||||
<div class="deleteToolbar">
|
||||
<button class="dangerButton" type="button" data-delete-toggle>서버팩 삭제</button>
|
||||
<div class="deleteActions hidden" data-delete-actions>
|
||||
<button class="ghostButton" type="button" data-delete-cancel>취소</button>
|
||||
<button class="dangerButton" type="submit">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardRow">
|
||||
<% packs.forEach((pack) => { %>
|
||||
<label class="packCard selectableCard">
|
||||
<input class="selectionBox hidden" type="checkbox" name="packKeys" value="<%= pack.file %>" />
|
||||
<span class="selectionTitle"><%= pack.name %></span>
|
||||
<span><code><%= pack.file %></code></span>
|
||||
<% if (!pack.registered) { %>
|
||||
<span class="warningBadge">manifest.json 미등록</span>
|
||||
<% } %>
|
||||
<a class="ghostLink" href="/op/dashboard/<%= pack.file %>">편집</a>
|
||||
</label>
|
||||
<% }) %>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector('[data-delete-form]')
|
||||
if (form == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const toggleButton = form.querySelector('[data-delete-toggle]')
|
||||
const cancelButton = form.querySelector('[data-delete-cancel]')
|
||||
const actions = form.querySelector('[data-delete-actions]')
|
||||
const checkboxes = form.querySelectorAll('.selectionBox')
|
||||
|
||||
const setDeleteMode = (enabled) => {
|
||||
form.classList.toggle('deleteMode', enabled)
|
||||
actions.classList.toggle('hidden', !enabled)
|
||||
checkboxes.forEach((checkbox) => {
|
||||
checkbox.classList.toggle('hidden', !enabled)
|
||||
if (!enabled) {
|
||||
checkbox.checked = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toggleButton?.addEventListener('click', () => setDeleteMode(true))
|
||||
cancelButton?.addEventListener('click', () => setDeleteMode(false))
|
||||
setDeleteMode(false)
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,149 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= packEntry?.name ?? packKey %> 편집</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="editorWrap">
|
||||
<div class="editorHeader">
|
||||
<div>
|
||||
<p class="eyebrow">PACK EDITOR</p>
|
||||
<h1><%= packEntry?.name ?? packKey %></h1>
|
||||
</div>
|
||||
<a class="ghostLink" href="/op/dashboard">목록으로</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="editorForm">
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>서버팩 이름</span>
|
||||
<input name="displayName" value="<%= packEntry?.name ?? '' %>" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>JSON 파일 이름</span>
|
||||
<input name="fileName" value="<%= packKey %>" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>설명</span>
|
||||
<textarea name="description"><%= pack.description ?? '' %></textarea>
|
||||
</label>
|
||||
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>마인크래프트 버전</span>
|
||||
<select name="mcVersion" required>
|
||||
<% releases.forEach((release) => { %>
|
||||
<option value="<%= release.id %>" <%= release.id === pack.mcVersion ? 'selected' : '' %>><%= release.id %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>권장 JDK 버전</span>
|
||||
<input type="number" name="recommendedJdkVersion" value="<%= pack.recommendedJdkVersion ?? 17 %>" min="8" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 로더 종류</span>
|
||||
<select name="loaderType">
|
||||
<option value="vanilla" <%= (pack.loaderType ?? 'vanilla') === 'vanilla' ? 'selected' : '' %>>vanilla</option>
|
||||
<option value="forge" <%= pack.loaderType === 'forge' ? 'selected' : '' %>>forge</option>
|
||||
<option value="fabric" <%= pack.loaderType === 'fabric' ? 'selected' : '' %>>fabric</option>
|
||||
<option value="neoforge" <%= pack.loaderType === 'neoforge' ? 'selected' : '' %>>neoforge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>로더 버전</span>
|
||||
<input name="loaderVersion" value="<%= pack.loaderVersion ?? '' %>" placeholder="예: 0.16.14 / 47.3.0 / 21.4.111-beta" />
|
||||
</label>
|
||||
<label>
|
||||
<span>packPath</span>
|
||||
<input name="packPath" value="<%= pack.packPath %>" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최소 램</span>
|
||||
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최대 램</span>
|
||||
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 최소 램</span>
|
||||
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 권장 램</span>
|
||||
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primaryButton" type="submit">적용</button>
|
||||
</form>
|
||||
|
||||
<section class="assetSection">
|
||||
<div class="assetCard">
|
||||
<h2>로더 설치파일 업로드</h2>
|
||||
<form method="post" action="/op/dashboard/<%= packKey %>/assets/loader" enctype="multipart/form-data" class="assetForm">
|
||||
<input type="file" name="asset" required />
|
||||
<button class="primaryButton" type="submit">로더 업로드</button>
|
||||
</form>
|
||||
<% if (pack.loaderInstallerPath) { %>
|
||||
<div class="assetItem">
|
||||
<code><%= pack.loaderInstallerPath %></code>
|
||||
<form method="post" action="/op/dashboard/<%= packKey %>/assets/loader/remove">
|
||||
<input type="hidden" name="assetPath" value="<%= pack.loaderInstallerPath %>" />
|
||||
<button class="dangerButton" type="submit">삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class="assetCard">
|
||||
<h2>리소스팩 업로드</h2>
|
||||
<form method="post" action="/op/dashboard/<%= packKey %>/assets/resource-pack" enctype="multipart/form-data" class="assetForm">
|
||||
<input type="file" name="asset" required />
|
||||
<button class="primaryButton" type="submit">리소스팩 추가</button>
|
||||
</form>
|
||||
<div class="assetList">
|
||||
<% (pack.resourcePackFiles ?? []).forEach((resourcePack) => { %>
|
||||
<div class="assetItem">
|
||||
<code><%= resourcePack %></code>
|
||||
<form method="post" action="/op/dashboard/<%= packKey %>/assets/resource-pack/remove">
|
||||
<input type="hidden" name="assetPath" value="<%= resourcePack %>" />
|
||||
<button class="dangerButton" type="submit">삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="assetCard">
|
||||
<h2>쉐이더 업로드</h2>
|
||||
<form method="post" action="/op/dashboard/<%= packKey %>/assets/shader-pack" enctype="multipart/form-data" class="assetForm">
|
||||
<input type="file" name="asset" required />
|
||||
<button class="primaryButton" type="submit">쉐이더 추가</button>
|
||||
</form>
|
||||
<div class="assetList">
|
||||
<% (pack.shaderPackFiles ?? []).forEach((shaderPack) => { %>
|
||||
<div class="assetItem">
|
||||
<code><%= shaderPack %></code>
|
||||
<form method="post" action="/op/dashboard/<%= packKey %>/assets/shader-pack/remove">
|
||||
<input type="hidden" name="assetPath" value="<%= shaderPack %>" />
|
||||
<button class="dangerButton" type="submit">삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 로그인</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody adminLoginBody">
|
||||
<main class="loginPanel">
|
||||
<p class="eyebrow">OP LOGIN</p>
|
||||
<h1>관리자 로그인</h1>
|
||||
<form method="post" action="/op/login" class="stackForm">
|
||||
<label>
|
||||
<span>비밀번호</span>
|
||||
<input type="password" name="password" required />
|
||||
</label>
|
||||
<% if (errorMessage) { %>
|
||||
<p class="errorText"><%= errorMessage %></p>
|
||||
<% } %>
|
||||
<button class="primaryButton" type="submit">로그인</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +0,0 @@
|
||||
<header class="adminHeader">
|
||||
<a class="brandLink" href="/op/dashboard">
|
||||
<span class="brandMark">MC</span>
|
||||
<span>관리자 페이지</span>
|
||||
</a>
|
||||
<details class="userMenu">
|
||||
<summary><%= userId %></summary>
|
||||
<form method="post" action="/op/logout">
|
||||
<button type="submit">로그아웃</button>
|
||||
</form>
|
||||
</details>
|
||||
</header>
|
||||
Reference in New Issue
Block a user