Add admin distribution editor
Some checks failed
Build / release (macos-latest) (push) Has been cancelled
Build / release (ubuntu-latest) (push) Has been cancelled
Build / release (windows-latest) (push) Has been cancelled
Windows Smoke Test / windows-smoke (push) Has been cancelled

This commit is contained in:
2026-05-05 19:16:09 +09:00
parent c4cdd0ceba
commit e266387784
11 changed files with 417 additions and 1652 deletions

View File

@@ -80,6 +80,7 @@ npm run admin
``` ```
설치 페이지용 프로필을 웹 UI로 관리할 수 있습니다. 설치 페이지용 프로필을 웹 UI로 관리할 수 있습니다.
- `distribution.json`도 사이트 안에서 직접 만들고 수정할 수 있습니다.
- 문서: [docs/admin-site.md](docs/admin-site.md) - 문서: [docs/admin-site.md](docs/admin-site.md)

View File

@@ -6,10 +6,8 @@
"name": "Concatenation Lite", "name": "Concatenation Lite",
"kind": "modpack", "kind": "modpack",
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.", "description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution URL과 기본 접속 주소를 유지하거나 교체해서 실서비스용 항목으로 바꿀 수 있습니다.", "details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution 파일만 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json", "distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json"
"defaultServerAddress": "play.mysticred.space",
"allowCustomServerAddress": true
}, },
{ {
"id": "template-original-map", "id": "template-original-map",
@@ -18,8 +16,6 @@
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.", "description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.", "details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json", "distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
"defaultServerAddress": "",
"allowCustomServerAddress": false,
"worldArchiveUrl": "https://example.com/maps/original-map.zip", "worldArchiveUrl": "https://example.com/maps/original-map.zip",
"worldDirectoryName": "Original Map" "worldDirectoryName": "Original Map"
}, },
@@ -30,8 +26,6 @@
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.", "description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.", "details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json", "distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
"defaultServerAddress": "",
"allowCustomServerAddress": true,
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip", "serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
"serverDirectoryName": "plugin-world-server", "serverDirectoryName": "plugin-world-server",
"serverLaunchCommand": "java -jar server.jar nogui", "serverLaunchCommand": "java -jar server.jar nogui",

View File

@@ -0,0 +1 @@

View File

@@ -21,6 +21,14 @@ const duplicateProfileButton = document.getElementById('duplicateProfileButton')
const deleteProfileButton = document.getElementById('deleteProfileButton') const deleteProfileButton = document.getElementById('deleteProfileButton')
const mapSection = document.getElementById('mapSection') const mapSection = document.getElementById('mapSection')
const serverPackSection = document.getElementById('serverPackSection') const serverPackSection = document.getElementById('serverPackSection')
const editDistributionButton = document.getElementById('editDistributionButton')
const createDistributionButton = document.getElementById('createDistributionButton')
const distributionEditorModal = document.getElementById('distributionEditorModal')
const distributionEditorHint = document.getElementById('distributionEditorHint')
const distributionEditorTextarea = document.getElementById('distributionEditorTextarea')
const closeDistributionEditorButton = document.getElementById('closeDistributionEditorButton')
const loadDistributionTemplateButton = document.getElementById('loadDistributionTemplateButton')
const saveDistributionFileButton = document.getElementById('saveDistributionFileButton')
const fieldElements = { const fieldElements = {
id: document.getElementById('field-id'), id: document.getElementById('field-id'),
@@ -29,8 +37,6 @@ const fieldElements = {
description: document.getElementById('field-description'), description: document.getElementById('field-description'),
details: document.getElementById('field-details'), details: document.getElementById('field-details'),
distributionUrl: document.getElementById('field-distributionUrl'), distributionUrl: document.getElementById('field-distributionUrl'),
defaultServerAddress: document.getElementById('field-defaultServerAddress'),
allowCustomServerAddress: document.getElementById('field-allowCustomServerAddress'),
worldArchiveUrl: document.getElementById('field-worldArchiveUrl'), worldArchiveUrl: document.getElementById('field-worldArchiveUrl'),
worldDirectoryName: document.getElementById('field-worldDirectoryName'), worldDirectoryName: document.getElementById('field-worldDirectoryName'),
serverBundleUrl: document.getElementById('field-serverBundleUrl'), serverBundleUrl: document.getElementById('field-serverBundleUrl'),
@@ -50,6 +56,10 @@ function slugify(value){
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
} }
function isRemoteUrl(value){
return /^https?:\/\//i.test(String(value ?? '').trim())
}
function createProfile(kind){ function createProfile(kind){
const timestamp = Date.now() const timestamp = Date.now()
return { return {
@@ -59,8 +69,6 @@ function createProfile(kind){
description: '', description: '',
details: '', details: '',
distributionUrl: '', distributionUrl: '',
defaultServerAddress: '',
allowCustomServerAddress: kind !== 'map',
worldArchiveUrl: '', worldArchiveUrl: '',
worldDirectoryName: '', worldDirectoryName: '',
serverBundleUrl: '', serverBundleUrl: '',
@@ -143,6 +151,27 @@ function syncKindSections(kind){
serverPackSection.hidden = kind !== 'server-pack' serverPackSection.hidden = kind !== 'server-pack'
} }
function updateDistributionEditorHint(profile, pathOverride){
if(!profile){
distributionEditorHint.textContent = '프로필에 연결할 distribution.json 내용을 사이트 안에서 직접 관리합니다.'
return
}
const currentPath = String(pathOverride ?? profile.distributionUrl ?? '').trim()
if(currentPath.length === 0){
distributionEditorHint.textContent = '연결된 distribution 파일이 없습니다. 샘플을 불러오거나 새로 저장해서 이 프로필에 연결하세요.'
return
}
if(isRemoteUrl(currentPath)){
distributionEditorHint.textContent = `현재 값은 원격 URL입니다: ${currentPath} | 원격 URL은 여기서 직접 수정할 수 없습니다. 샘플을 불러온 뒤 새 파일로 저장하세요.`
return
}
distributionEditorHint.textContent = `현재 연결된 distribution 파일: ${currentPath}`
}
function populateEditor(){ function populateEditor(){
const profile = getSelectedProfile() const profile = getSelectedProfile()
const hasSelection = profile != null const hasSelection = profile != null
@@ -154,6 +183,7 @@ function populateEditor(){
if(!profile){ if(!profile){
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.' editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
updateDistributionEditorHint(null)
return return
} }
@@ -165,8 +195,6 @@ function populateEditor(){
fieldElements.description.value = profile.description ?? '' fieldElements.description.value = profile.description ?? ''
fieldElements.details.value = profile.details ?? '' fieldElements.details.value = profile.details ?? ''
fieldElements.distributionUrl.value = profile.distributionUrl ?? '' fieldElements.distributionUrl.value = profile.distributionUrl ?? ''
fieldElements.defaultServerAddress.value = profile.defaultServerAddress ?? ''
fieldElements.allowCustomServerAddress.checked = profile.allowCustomServerAddress === true
fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? '' fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? ''
fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? '' fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? ''
fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? '' fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? ''
@@ -178,6 +206,7 @@ function populateEditor(){
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? '' fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
syncKindSections(profile.kind) syncKindSections(profile.kind)
updateDistributionEditorHint(profile)
} }
function updateSelectedProfile(patch){ function updateSelectedProfile(patch){
@@ -185,9 +214,14 @@ function updateSelectedProfile(patch){
if(!profile){ if(!profile){
return return
} }
Object.assign(profile, patch) Object.assign(profile, patch)
markDirty(true) markDirty(true)
renderSidebar() renderSidebar()
if(Object.prototype.hasOwnProperty.call(patch, 'distributionUrl')){
updateDistributionEditorHint(profile, patch.distributionUrl)
}
} }
function bindTextField(fieldName){ function bindTextField(fieldName){
@@ -214,14 +248,6 @@ function bindTextField(fieldName){
}) })
} }
function bindCheckboxField(fieldName){
fieldElements[fieldName].addEventListener('change', (event) => {
updateSelectedProfile({
[fieldName]: event.target.checked
})
})
}
function bindNumberField(fieldName){ function bindNumberField(fieldName){
fieldElements[fieldName].addEventListener('input', (event) => { fieldElements[fieldName].addEventListener('input', (event) => {
const value = Number.parseInt(event.target.value || '25565', 10) const value = Number.parseInt(event.target.value || '25565', 10)
@@ -232,13 +258,15 @@ function bindNumberField(fieldName){
} }
function bindProfileForm(){ function bindProfileForm(){
profileEditorForm.addEventListener('submit', (event) => {
event.preventDefault()
})
bindTextField('id') bindTextField('id')
bindTextField('name') bindTextField('name')
bindTextField('description') bindTextField('description')
bindTextField('details') bindTextField('details')
bindTextField('distributionUrl') bindTextField('distributionUrl')
bindTextField('defaultServerAddress')
bindCheckboxField('allowCustomServerAddress')
bindTextField('worldArchiveUrl') bindTextField('worldArchiveUrl')
bindTextField('worldDirectoryName') bindTextField('worldDirectoryName')
bindTextField('serverBundleUrl') bindTextField('serverBundleUrl')
@@ -257,10 +285,7 @@ function bindProfileForm(){
profile.kind = event.target.value profile.kind = event.target.value
if(profile.kind === 'map'){ if(profile.kind === 'server-pack' && !profile.serverDirectoryName){
profile.allowCustomServerAddress = false
}
if(profile.kind !== 'server-pack' && !profile.serverDirectoryName){
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server` profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
} }
@@ -312,6 +337,11 @@ async function uploadIntoField(targetField, accept){
fieldElements[targetField].value = result.file.path fieldElements[targetField].value = result.file.path
markDirty(true) markDirty(true)
renderSidebar() renderSidebar()
if(targetField === 'distributionUrl'){
updateDistributionEditorHint(profile, result.file.path)
}
showStatus(`업로드 완료: ${result.file.path}`, 'success') showStatus(`업로드 완료: ${result.file.path}`, 'success')
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -322,6 +352,152 @@ async function uploadIntoField(targetField, accept){
picker.click() picker.click()
} }
function openDistributionEditorModal(){
distributionEditorModal.hidden = false
document.body.style.overflow = 'hidden'
}
function closeDistributionEditorModal(){
distributionEditorModal.hidden = true
document.body.style.overflow = ''
}
async function loadDistributionTemplate(){
const response = await fetch('/api/distribution/template')
const result = await response.json()
if(!response.ok || result.ok !== true){
throw new Error(result.message || 'distribution 샘플을 불러오지 못했습니다.')
}
distributionEditorTextarea.value = result.content
}
async function openDistributionEditor(mode){
const profile = getSelectedProfile()
if(!profile){
showStatus('먼저 프로필을 선택하세요.', 'error')
return
}
openDistributionEditorModal()
distributionEditorTextarea.value = ''
updateDistributionEditorHint(profile)
try {
const currentPath = String(profile.distributionUrl ?? '').trim()
if(mode === 'create' || currentPath.length === 0){
await loadDistributionTemplate()
updateDistributionEditorHint(profile, '')
showStatus('distribution 템플릿을 불러왔습니다.', 'success')
return
}
if(isRemoteUrl(currentPath)){
await loadDistributionTemplate()
updateDistributionEditorHint(profile, currentPath)
showStatus('원격 URL은 직접 수정할 수 없어 샘플을 대신 불러왔습니다.', 'info')
return
}
showStatus('distribution 파일을 불러오는 중...', 'info')
const response = await fetch(`/api/distribution/content?path=${encodeURIComponent(currentPath)}`)
const result = await response.json()
if(!response.ok || result.ok !== true){
throw new Error(result.message || 'distribution 파일을 불러오지 못했습니다.')
}
distributionEditorTextarea.value = result.content
updateDistributionEditorHint(profile, currentPath)
showStatus('distribution 파일을 불러왔습니다.', 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : 'distribution 파일을 불러오지 못했습니다.', 'error')
}
}
async function saveDistributionFile(){
const profile = getSelectedProfile()
if(!profile){
showStatus('먼저 프로필을 선택하세요.', 'error')
return
}
try {
saveDistributionFileButton.disabled = true
showStatus('distribution 파일 저장 중...', 'info')
const response = await fetch('/api/distribution/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
profileId: profile.id,
content: distributionEditorTextarea.value
})
})
const result = await response.json()
if(!response.ok || result.ok !== true){
throw new Error(result.message || 'distribution 파일 저장에 실패했습니다.')
}
profile.distributionUrl = result.file.path
fieldElements.distributionUrl.value = result.file.path
markDirty(true)
renderSidebar()
populateEditor()
closeDistributionEditorModal()
showStatus('distribution 파일을 저장했습니다. 카탈로그 저장을 누르면 런처에 반영됩니다.', 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : 'distribution 파일 저장에 실패했습니다.', 'error')
} finally {
saveDistributionFileButton.disabled = false
}
}
function bindDistributionEditor(){
editDistributionButton.addEventListener('click', async () => {
await openDistributionEditor('edit')
})
createDistributionButton.addEventListener('click', async () => {
await openDistributionEditor('create')
})
closeDistributionEditorButton.addEventListener('click', () => {
closeDistributionEditorModal()
})
loadDistributionTemplateButton.addEventListener('click', async () => {
try {
await loadDistributionTemplate()
updateDistributionEditorHint(getSelectedProfile(), '')
showStatus('distribution 템플릿을 다시 불러왔습니다.', 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : 'distribution 템플릿을 불러오지 못했습니다.', 'error')
}
})
saveDistributionFileButton.addEventListener('click', async () => {
await saveDistributionFile()
})
distributionEditorModal.addEventListener('click', (event) => {
if(event.target === distributionEditorModal){
closeDistributionEditorModal()
}
})
document.addEventListener('keydown', (event) => {
if(event.key === 'Escape' && distributionEditorModal.hidden === false){
closeDistributionEditorModal()
}
})
}
async function loadMeta(){ async function loadMeta(){
const response = await fetch('/api/meta') const response = await fetch('/api/meta')
const meta = await response.json() const meta = await response.json()
@@ -450,6 +626,7 @@ function bindTopLevelActions(){
async function bootstrap(){ async function bootstrap(){
bindProfileForm() bindProfileForm()
bindDistributionEditor()
bindTopLevelActions() bindTopLevelActions()
try { try {

View File

@@ -99,20 +99,17 @@
</div> </div>
<div class="fieldGrid"> <div class="fieldGrid">
<label class="fieldBlock fieldBlockFull"> <label class="fieldBlock fieldBlockFull">
<span>distribution.json 경로 또는 URL</span> <span>distribution 파일</span>
<div class="uploadField"> <div class="uploadField">
<input id="field-distributionUrl" type="text" autocomplete="off"> <input id="field-distributionUrl" type="text" autocomplete="off">
<button type="button" class="secondaryAction uploadButton" data-upload-target="distributionUrl" data-upload-accept=".json,application/json">파일 업로드</button> <button type="button" class="secondaryAction uploadButton" data-upload-target="distributionUrl" data-upload-accept=".json,application/json">JSON 업로드</button>
<button type="button" id="editDistributionButton" class="secondaryAction">JSON 편집</button>
<button type="button" id="createDistributionButton" class="secondaryAction">새로 만들기</button>
</div> </div>
</label> </label>
<label class="fieldBlock"> <div class="fieldHelpText fieldBlockFull">
<span>기본 접속 주소</span> distribution.json은 여기서 직접 업로드하거나 JSON 편집기로 새로 만들 수 있습니다.
<input id="field-defaultServerAddress" type="text" autocomplete="off" placeholder="example.com:25565"> </div>
</label>
<label class="toggleBlock">
<input id="field-allowCustomServerAddress" type="checkbox">
<span>사용자가 라이브러리에서 주소를 바꿀 수 있음</span>
</label>
</div> </div>
</section> </section>
@@ -184,6 +181,24 @@
</main> </main>
</div> </div>
<div id="distributionEditorModal" class="modalBackdrop" hidden>
<div class="modalPanel">
<div class="modalHeader">
<div>
<span class="eyebrow">Distribution Editor</span>
<h3>distribution.json 편집</h3>
<p id="distributionEditorHint">프로필에 연결할 distribution.json 내용을 사이트 안에서 직접 관리합니다.</p>
</div>
<button type="button" id="closeDistributionEditorButton" class="secondaryAction">닫기</button>
</div>
<textarea id="distributionEditorTextarea" class="distributionTextarea" spellcheck="false"></textarea>
<div class="modalActions">
<button type="button" id="loadDistributionTemplateButton" class="secondaryAction">샘플 불러오기</button>
<button type="button" id="saveDistributionFileButton" class="primaryAction">distribution 저장</button>
</div>
</div>
</div>
<script src="./app.js"></script> <script src="./app.js"></script>
</body> </body>
</html> </html>

View File

@@ -278,11 +278,17 @@ textarea {
.uploadField { .uploadField {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
} }
.fieldHelpText {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.primaryAction, .primaryAction,
.secondaryAction, .secondaryAction,
.dangerAction { .dangerAction {
@@ -346,6 +352,53 @@ textarea {
background: rgba(240, 191, 87, 0.1); background: rgba(240, 191, 87, 0.1);
} }
.modalBackdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(5, 6, 6, 0.72);
backdrop-filter: blur(12px);
z-index: 20;
}
.modalPanel {
display: flex;
flex-direction: column;
gap: 16px;
width: min(1100px, 100%);
max-height: calc(100vh - 48px);
padding: 24px;
border: 1px solid var(--line);
border-radius: 24px;
background: #111412;
box-shadow: var(--shadow);
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.distributionTextarea {
min-height: 420px;
resize: vertical;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 13px;
line-height: 1.55;
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.adminShell { .adminShell {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -8,9 +8,11 @@ const PORT = Number.parseInt(process.env.LAUNCHER_ADMIN_PORT || '8787', 10)
const PROJECT_ROOT = path.resolve(__dirname, '..') const PROJECT_ROOT = path.resolve(__dirname, '..')
const RUNTIME_DATA_DIR = path.join(__dirname, 'data') const RUNTIME_DATA_DIR = path.join(__dirname, 'data')
const UPLOADS_DIR = path.join(RUNTIME_DATA_DIR, 'uploads') const UPLOADS_DIR = path.join(RUNTIME_DATA_DIR, 'uploads')
const DISTRIBUTIONS_DIR = path.join(RUNTIME_DATA_DIR, 'distributions')
const RUNTIME_CATALOG_PATH = path.join(RUNTIME_DATA_DIR, 'catalog.json') const RUNTIME_CATALOG_PATH = path.join(RUNTIME_DATA_DIR, 'catalog.json')
const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher', 'catalog.json') const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher', 'catalog.json')
const PUBLIC_DIR = path.join(__dirname, 'public') const PUBLIC_DIR = path.join(__dirname, 'public')
const SAMPLE_DISTRIBUTION_PATH = path.join(PROJECT_ROOT, 'docs', 'sample_distribution.json')
const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack']) const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack'])
@@ -24,10 +26,6 @@ function normalizeMultilineText(value){
: '' : ''
} }
function normalizeBoolean(value){
return value === true
}
function normalizePort(value){ function normalizePort(value){
const port = Number.parseInt(String(value ?? ''), 10) const port = Number.parseInt(String(value ?? ''), 10)
if(Number.isFinite(port) && port >= 1 && port <= 65535){ if(Number.isFinite(port) && port >= 1 && port <= 65535){
@@ -36,6 +34,25 @@ function normalizePort(value){
return 25565 return 25565
} }
function resolveSafeProjectPath(relativePath){
const resolvedPath = path.resolve(PROJECT_ROOT, relativePath)
if(!resolvedPath.startsWith(PROJECT_ROOT + path.sep) && resolvedPath !== PROJECT_ROOT){
throw new Error('허용되지 않은 경로입니다.')
}
return resolvedPath
}
function createDistributionFileName(profileId){
const safeId = String(profileId ?? 'distribution')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_.]+|[-_.]+$/g, '')
return `${safeId.length > 0 ? safeId : 'distribution'}.distribution.json`
}
function sanitizeProfile(rawProfile, index){ function sanitizeProfile(rawProfile, index){
const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack' const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack'
const sanitized = { const sanitized = {
@@ -44,9 +61,7 @@ function sanitizeProfile(rawProfile, index){
kind, kind,
description: normalizeText(rawProfile?.description), description: normalizeText(rawProfile?.description),
details: normalizeMultilineText(rawProfile?.details), details: normalizeMultilineText(rawProfile?.details),
distributionUrl: normalizeText(rawProfile?.distributionUrl), distributionUrl: normalizeText(rawProfile?.distributionUrl)
defaultServerAddress: normalizeText(rawProfile?.defaultServerAddress),
allowCustomServerAddress: normalizeBoolean(rawProfile?.allowCustomServerAddress)
} }
if(kind === 'map'){ if(kind === 'map'){
@@ -87,6 +102,7 @@ function toProjectRelativePath(targetPath){
async function ensureRuntimeCatalog(){ async function ensureRuntimeCatalog(){
await fs.ensureDir(RUNTIME_DATA_DIR) await fs.ensureDir(RUNTIME_DATA_DIR)
await fs.ensureDir(UPLOADS_DIR) await fs.ensureDir(UPLOADS_DIR)
await fs.ensureDir(DISTRIBUTIONS_DIR)
if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){ if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){
if(await fs.pathExists(LAUNCHER_CATALOG_PATH)){ if(await fs.pathExists(LAUNCHER_CATALOG_PATH)){
@@ -143,6 +159,7 @@ async function start(){
app.use(express.json({ limit: '5mb' })) app.use(express.json({ limit: '5mb' }))
app.use('/uploads', express.static(UPLOADS_DIR)) app.use('/uploads', express.static(UPLOADS_DIR))
app.use('/admin/data/distributions', express.static(DISTRIBUTIONS_DIR))
app.get('/api/meta', async (_req, res) => { app.get('/api/meta', async (_req, res) => {
res.json({ res.json({
@@ -150,10 +167,53 @@ async function start(){
port: PORT, port: PORT,
runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH), runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH),
launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH), launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH),
localCatalogUrl: `http://${HOST}:${PORT}/catalog.json` localCatalogUrl: `http://${HOST}:${PORT}/catalog.json`,
distributionsPath: toProjectRelativePath(DISTRIBUTIONS_DIR)
}) })
}) })
app.get('/api/distribution/template', async (_req, res, next) => {
try {
const content = await fs.readFile(SAMPLE_DISTRIBUTION_PATH, 'utf8')
res.json({
ok: true,
content
})
} catch (error) {
next(error)
}
})
app.get('/api/distribution/content', async (req, res, next) => {
try {
const requestedPath = normalizeText(req.query.path)
if(requestedPath.length === 0){
res.status(400).json({
ok: false,
message: '불러올 distribution 경로가 없습니다.'
})
return
}
if(/^https?:\/\//i.test(requestedPath)){
res.status(400).json({
ok: false,
message: '원격 URL은 사이트에서 직접 수정할 수 없습니다. 업로드하거나 새로 생성하세요.'
})
return
}
const resolvedPath = resolveSafeProjectPath(requestedPath)
const content = await fs.readFile(resolvedPath, 'utf8')
res.json({
ok: true,
content
})
} catch (error) {
next(error)
}
})
app.get('/api/catalog', async (_req, res, next) => { app.get('/api/catalog', async (_req, res, next) => {
try { try {
res.json(await readCatalog()) res.json(await readCatalog())
@@ -201,6 +261,38 @@ async function start(){
} }
}) })
app.post('/api/distribution/save', async (req, res, next) => {
try {
const profileId = normalizeText(req.body?.profileId)
const rawContent = typeof req.body?.content === 'string' ? req.body.content : ''
if(profileId.length === 0){
res.status(400).json({
ok: false,
message: '프로필 ID가 필요합니다.'
})
return
}
const parsed = JSON.parse(rawContent)
const fileName = createDistributionFileName(profileId)
const targetPath = path.join(DISTRIBUTIONS_DIR, fileName)
const relativePath = toProjectRelativePath(targetPath)
await fs.writeFile(targetPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8')
res.json({
ok: true,
file: {
path: relativePath,
localUrl: `http://${HOST}:${PORT}/${relativePath}`
}
})
} catch (error) {
next(error)
}
})
app.get('/catalog.json', async (_req, res, next) => { app.get('/catalog.json', async (_req, res, next) => {
try { try {
res.sendFile(RUNTIME_CATALOG_PATH) res.sendFile(RUNTIME_CATALOG_PATH)

View File

@@ -6,10 +6,8 @@
"name": "Concatenation Lite", "name": "Concatenation Lite",
"kind": "modpack", "kind": "modpack",
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.", "description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution URL과 기본 접속 주소를 유지하거나 교체해서 실서비스용 항목으로 바꿀 수 있습니다.", "details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution 파일만 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json", "distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json"
"defaultServerAddress": "play.mysticred.space",
"allowCustomServerAddress": true
}, },
{ {
"id": "template-original-map", "id": "template-original-map",
@@ -18,8 +16,6 @@
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.", "description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.", "details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json", "distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
"defaultServerAddress": "",
"allowCustomServerAddress": false,
"worldArchiveUrl": "https://example.com/maps/original-map.zip", "worldArchiveUrl": "https://example.com/maps/original-map.zip",
"worldDirectoryName": "Original Map" "worldDirectoryName": "Original Map"
}, },
@@ -30,8 +26,6 @@
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.", "description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.", "details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json", "distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
"defaultServerAddress": "",
"allowCustomServerAddress": true,
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip", "serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
"serverDirectoryName": "plugin-world-server", "serverDirectoryName": "plugin-world-server",
"serverLaunchCommand": "java -jar server.jar nogui", "serverLaunchCommand": "java -jar server.jar nogui",

View File

@@ -16,11 +16,23 @@ npm run admin
- 프로필 추가 / 수정 / 삭제 / 복제 - 프로필 추가 / 수정 / 삭제 / 복제
- `modpack`, `map`, `server-pack` 종류별 입력 폼 - `modpack`, `map`, `server-pack` 종류별 입력 폼
- `distribution.json`, 맵 ZIP, 서버 번들 ZIP 업로드 - `distribution.json` 업로드 / 새로 만들기 / 직접 편집
- 맵 ZIP, 서버 번들 ZIP 업로드
- 저장 시 아래 두 파일을 동시에 갱신 - 저장 시 아래 두 파일을 동시에 갱신
- `admin/data/catalog.json` - `admin/data/catalog.json`
- `app/assets/launcher/catalog.json` - `app/assets/launcher/catalog.json`
## distribution 편집
- `distribution 파일` 칸에서 JSON 업로드 가능
- `JSON 편집` 버튼으로 현재 연결된 로컬 `distribution.json` 수정 가능
- `새로 만들기` 버튼으로 샘플 템플릿에서 새 `distribution.json` 생성 가능
- 저장된 distribution 파일은 아래에 생성됩니다.
- `admin/data/distributions/`
- 접속주소 필드는 관리자 사이트에서 다루지 않습니다. 런처 내부 접속 흐름과 분리해서 프로필 자료만 관리합니다.
## 업로드 동작 ## 업로드 동작
업로드 버튼으로 올린 파일은 아래에 저장됩니다. 업로드 버튼으로 올린 파일은 아래에 저장됩니다.
@@ -43,6 +55,6 @@ npm run admin
## 추천 운영 방식 ## 추천 운영 방식
1. 관리자 사이트에서 프로필과 자료 경로를 입력 1. 관리자 사이트에서 프로필과 자료 파일을 입력
2. 로컬 런처에서 실제 표시와 실행 확인 2. 로컬 런처에서 실제 표시와 실행 확인
3. 이후 필요하면 업로드 경로를 공개 URL 기반으로 확장 3. 이후 필요하면 업로드 경로를 공개 URL 기반으로 확장

View File

@@ -19,9 +19,7 @@
"kind": "modpack", "kind": "modpack",
"description": "설명", "description": "설명",
"details": "설치 페이지 상세 패널에 표시할 긴 설명", "details": "설치 페이지 상세 패널에 표시할 긴 설명",
"distributionUrl": "https://example.com/launcher/distribution.json", "distributionUrl": "admin/data/distributions/my-modpack.distribution.json"
"defaultServerAddress": "example.com:25565",
"allowCustomServerAddress": true
}, },
{ {
"id": "my-map", "id": "my-map",
@@ -29,10 +27,9 @@
"kind": "map", "kind": "map",
"description": "싱글플레이 월드", "description": "싱글플레이 월드",
"details": "월드와 플레이 방식에 대한 상세 설명", "details": "월드와 플레이 방식에 대한 상세 설명",
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json", "distributionUrl": "admin/data/distributions/my-map.distribution.json",
"worldArchiveUrl": "https://example.com/worlds/my-map.zip", "worldArchiveUrl": "https://example.com/worlds/my-map.zip",
"worldDirectoryName": "My Map", "worldDirectoryName": "My Map"
"allowCustomServerAddress": false
}, },
{ {
"id": "my-server-pack", "id": "my-server-pack",
@@ -40,14 +37,13 @@
"kind": "server-pack", "kind": "server-pack",
"description": "클라이언트 + 로컬 서버 번들", "description": "클라이언트 + 로컬 서버 번들",
"details": "서버 실행 방법, 권장 인원, 접속 방식 등 상세 설명", "details": "서버 실행 방법, 권장 인원, 접속 방식 등 상세 설명",
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json", "distributionUrl": "admin/data/distributions/my-server-pack.distribution.json",
"serverBundleUrl": "https://example.com/serverpacks/my-server-pack.zip", "serverBundleUrl": "https://example.com/serverpacks/my-server-pack.zip",
"serverDirectoryName": "my-server-pack", "serverDirectoryName": "my-server-pack",
"serverLaunchCommand": "java -jar server.jar nogui", "serverLaunchCommand": "java -jar server.jar nogui",
"serverPort": 25565, "serverPort": 25565,
"tunnelCommand": "playit-cli --port ${port}", "tunnelCommand": "playit-cli --port ${port}",
"tunnelAddressRegex": "([a-zA-Z0-9.-]+:\\d+)", "tunnelAddressRegex": "([a-zA-Z0-9.-]+:\\d+)"
"allowCustomServerAddress": true
} }
] ]
} }
@@ -60,9 +56,7 @@
- `kind`: `modpack`, `map`, `server-pack` - `kind`: `modpack`, `map`, `server-pack`
- `description`: 표시 설명 - `description`: 표시 설명
- `details`: 설치 페이지 상세 패널에 표시할 긴 설명 - `details`: 설치 페이지 상세 패널에 표시할 긴 설명
- `distributionUrl`: Helios distribution.json URL 또는 로컬 경로 - `distributionUrl`: Helios distribution.json URL 또는 로컬 경로. 관리자 사이트에서 직접 만들거나 업로드 가능
- `defaultServerAddress`: 기본 자동 접속 주소
- `allowCustomServerAddress`: 사용자가 라이브러리에서 주소를 덮어쓸 수 있는지 여부
- `worldArchiveUrl`: `kind: map` 일 때 사용할 월드 ZIP 또는 로컬 경로 - `worldArchiveUrl`: `kind: map` 일 때 사용할 월드 ZIP 또는 로컬 경로
- `worldDirectoryName`: 게임 `saves/` 아래에 설치될 월드 폴더 이름 - `worldDirectoryName`: 게임 `saves/` 아래에 설치될 월드 폴더 이름
- `serverBundleUrl`: `kind: server-pack` 일 때 사용할 서버 ZIP 또는 로컬 디렉터리/경로 - `serverBundleUrl`: `kind: server-pack` 일 때 사용할 서버 ZIP 또는 로컬 디렉터리/경로

File diff suppressed because it is too large Load Diff