Add launcher admin catalog site
This commit is contained in:
44
admin/data/catalog.json
Normal file
44
admin/data/catalog.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": [
|
||||
{
|
||||
"id": "mrs-concatenation-lite",
|
||||
"name": "Concatenation Lite",
|
||||
"kind": "modpack",
|
||||
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
|
||||
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution URL과 기본 접속 주소를 유지하거나 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
|
||||
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json",
|
||||
"defaultServerAddress": "play.mysticred.space",
|
||||
"allowCustomServerAddress": true
|
||||
},
|
||||
{
|
||||
"id": "template-original-map",
|
||||
"name": "Original Map Template",
|
||||
"kind": "map",
|
||||
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
|
||||
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
|
||||
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
|
||||
"defaultServerAddress": "",
|
||||
"allowCustomServerAddress": false,
|
||||
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
|
||||
"worldDirectoryName": "Original Map"
|
||||
},
|
||||
{
|
||||
"id": "template-plugin-server-pack",
|
||||
"name": "Plugin Server Pack Template",
|
||||
"kind": "server-pack",
|
||||
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
|
||||
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
|
||||
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
|
||||
"defaultServerAddress": "",
|
||||
"allowCustomServerAddress": true,
|
||||
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
|
||||
"serverDirectoryName": "plugin-world-server",
|
||||
"serverLaunchCommand": "java -jar server.jar nogui",
|
||||
"serverWorkingDirectory": "",
|
||||
"serverPort": 25565,
|
||||
"tunnelCommand": "",
|
||||
"tunnelAddressRegex": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
1
admin/data/uploads/.gitkeep
Normal file
1
admin/data/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
467
admin/public/app.js
Normal file
467
admin/public/app.js
Normal file
@@ -0,0 +1,467 @@
|
||||
const state = {
|
||||
catalog: {
|
||||
version: 1,
|
||||
profiles: []
|
||||
},
|
||||
meta: null,
|
||||
selectedProfileId: null,
|
||||
dirty: false
|
||||
}
|
||||
|
||||
const profileList = document.getElementById('profileList')
|
||||
const profileCount = document.getElementById('profileCount')
|
||||
const localCatalogUrl = document.getElementById('localCatalogUrl')
|
||||
const launcherCatalogPath = document.getElementById('launcherCatalogPath')
|
||||
const emptyState = document.getElementById('emptyState')
|
||||
const profileEditorForm = document.getElementById('profileEditorForm')
|
||||
const statusBanner = document.getElementById('statusBanner')
|
||||
const editorHint = document.getElementById('editorHint')
|
||||
const saveCatalogButton = document.getElementById('saveCatalogButton')
|
||||
const duplicateProfileButton = document.getElementById('duplicateProfileButton')
|
||||
const deleteProfileButton = document.getElementById('deleteProfileButton')
|
||||
const mapSection = document.getElementById('mapSection')
|
||||
const serverPackSection = document.getElementById('serverPackSection')
|
||||
|
||||
const fieldElements = {
|
||||
id: document.getElementById('field-id'),
|
||||
kind: document.getElementById('field-kind'),
|
||||
name: document.getElementById('field-name'),
|
||||
description: document.getElementById('field-description'),
|
||||
details: document.getElementById('field-details'),
|
||||
distributionUrl: document.getElementById('field-distributionUrl'),
|
||||
defaultServerAddress: document.getElementById('field-defaultServerAddress'),
|
||||
allowCustomServerAddress: document.getElementById('field-allowCustomServerAddress'),
|
||||
worldArchiveUrl: document.getElementById('field-worldArchiveUrl'),
|
||||
worldDirectoryName: document.getElementById('field-worldDirectoryName'),
|
||||
serverBundleUrl: document.getElementById('field-serverBundleUrl'),
|
||||
serverDirectoryName: document.getElementById('field-serverDirectoryName'),
|
||||
serverLaunchCommand: document.getElementById('field-serverLaunchCommand'),
|
||||
serverWorkingDirectory: document.getElementById('field-serverWorkingDirectory'),
|
||||
serverPort: document.getElementById('field-serverPort'),
|
||||
tunnelCommand: document.getElementById('field-tunnelCommand'),
|
||||
tunnelAddressRegex: document.getElementById('field-tunnelAddressRegex')
|
||||
}
|
||||
|
||||
function slugify(value){
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function createProfile(kind){
|
||||
const timestamp = Date.now()
|
||||
return {
|
||||
id: `${kind}-${timestamp}`,
|
||||
name: kind === 'map' ? '새 맵 프로필' : kind === 'server-pack' ? '새 서버팩 프로필' : '새 모드팩 프로필',
|
||||
kind,
|
||||
description: '',
|
||||
details: '',
|
||||
distributionUrl: '',
|
||||
defaultServerAddress: '',
|
||||
allowCustomServerAddress: kind !== 'map',
|
||||
worldArchiveUrl: '',
|
||||
worldDirectoryName: '',
|
||||
serverBundleUrl: '',
|
||||
serverDirectoryName: `${kind}-${timestamp}-server`,
|
||||
serverLaunchCommand: '',
|
||||
serverWorkingDirectory: '',
|
||||
serverPort: 25565,
|
||||
tunnelCommand: '',
|
||||
tunnelAddressRegex: ''
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedProfile(){
|
||||
return state.catalog.profiles.find((profile) => profile.id === state.selectedProfileId) ?? null
|
||||
}
|
||||
|
||||
function markDirty(nextDirty = true){
|
||||
state.dirty = nextDirty
|
||||
saveCatalogButton.textContent = nextDirty ? '카탈로그 저장 *' : '카탈로그 저장'
|
||||
}
|
||||
|
||||
function showStatus(message, tone = 'info'){
|
||||
statusBanner.hidden = false
|
||||
statusBanner.dataset.tone = tone
|
||||
statusBanner.textContent = message
|
||||
}
|
||||
|
||||
function clearStatus(){
|
||||
statusBanner.hidden = true
|
||||
statusBanner.textContent = ''
|
||||
delete statusBanner.dataset.tone
|
||||
}
|
||||
|
||||
function selectProfile(profileId){
|
||||
state.selectedProfileId = profileId
|
||||
renderSidebar()
|
||||
populateEditor()
|
||||
}
|
||||
|
||||
function renderSidebar(){
|
||||
profileList.innerHTML = ''
|
||||
profileCount.textContent = `${state.catalog.profiles.length}개`
|
||||
|
||||
for(const profile of state.catalog.profiles){
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'profileListItem'
|
||||
if(profile.id === state.selectedProfileId){
|
||||
button.setAttribute('selected', 'true')
|
||||
}
|
||||
|
||||
const title = document.createElement('div')
|
||||
title.className = 'profileListName'
|
||||
title.textContent = profile.name || profile.id
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'profileListMeta'
|
||||
meta.innerHTML = `
|
||||
<span class="badge">${profile.kind}</span>
|
||||
${profile.distributionUrl ? '<span class="badge">distribution 연결</span>' : '<span class="badge">distribution 비어있음</span>'}
|
||||
`
|
||||
|
||||
const description = document.createElement('div')
|
||||
description.className = 'profileListDescription'
|
||||
description.textContent = profile.description || '설명이 없습니다.'
|
||||
|
||||
button.appendChild(title)
|
||||
button.appendChild(meta)
|
||||
button.appendChild(description)
|
||||
button.addEventListener('click', () => {
|
||||
selectProfile(profile.id)
|
||||
})
|
||||
|
||||
profileList.appendChild(button)
|
||||
}
|
||||
}
|
||||
|
||||
function syncKindSections(kind){
|
||||
mapSection.hidden = kind !== 'map'
|
||||
serverPackSection.hidden = kind !== 'server-pack'
|
||||
}
|
||||
|
||||
function populateEditor(){
|
||||
const profile = getSelectedProfile()
|
||||
const hasSelection = profile != null
|
||||
|
||||
emptyState.hidden = hasSelection
|
||||
profileEditorForm.hidden = !hasSelection
|
||||
duplicateProfileButton.disabled = !hasSelection
|
||||
deleteProfileButton.disabled = !hasSelection
|
||||
|
||||
if(!profile){
|
||||
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
|
||||
return
|
||||
}
|
||||
|
||||
editorHint.textContent = `${profile.kind} 프로필을 편집 중입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.`
|
||||
|
||||
fieldElements.id.value = profile.id ?? ''
|
||||
fieldElements.kind.value = profile.kind ?? 'modpack'
|
||||
fieldElements.name.value = profile.name ?? ''
|
||||
fieldElements.description.value = profile.description ?? ''
|
||||
fieldElements.details.value = profile.details ?? ''
|
||||
fieldElements.distributionUrl.value = profile.distributionUrl ?? ''
|
||||
fieldElements.defaultServerAddress.value = profile.defaultServerAddress ?? ''
|
||||
fieldElements.allowCustomServerAddress.checked = profile.allowCustomServerAddress === true
|
||||
fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? ''
|
||||
fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? ''
|
||||
fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? ''
|
||||
fieldElements.serverDirectoryName.value = profile.serverDirectoryName ?? ''
|
||||
fieldElements.serverLaunchCommand.value = profile.serverLaunchCommand ?? ''
|
||||
fieldElements.serverWorkingDirectory.value = profile.serverWorkingDirectory ?? ''
|
||||
fieldElements.serverPort.value = profile.serverPort ?? 25565
|
||||
fieldElements.tunnelCommand.value = profile.tunnelCommand ?? ''
|
||||
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
|
||||
|
||||
syncKindSections(profile.kind)
|
||||
}
|
||||
|
||||
function updateSelectedProfile(patch){
|
||||
const profile = getSelectedProfile()
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
Object.assign(profile, patch)
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
}
|
||||
|
||||
function bindTextField(fieldName){
|
||||
fieldElements[fieldName].addEventListener('input', (event) => {
|
||||
if(fieldName === 'id'){
|
||||
const profile = getSelectedProfile()
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
|
||||
const previousId = profile.id
|
||||
profile.id = event.target.value
|
||||
state.selectedProfileId = profile.id
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
|
||||
if(previousId !== profile.id){
|
||||
showStatus('프로필 ID를 바꿨습니다. 저장하면 이 ID로 반영됩니다.', 'info')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
updateSelectedProfile({ [fieldName]: event.target.value })
|
||||
})
|
||||
}
|
||||
|
||||
function bindCheckboxField(fieldName){
|
||||
fieldElements[fieldName].addEventListener('change', (event) => {
|
||||
updateSelectedProfile({
|
||||
[fieldName]: event.target.checked
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function bindNumberField(fieldName){
|
||||
fieldElements[fieldName].addEventListener('input', (event) => {
|
||||
const value = Number.parseInt(event.target.value || '25565', 10)
|
||||
updateSelectedProfile({
|
||||
[fieldName]: Number.isFinite(value) ? value : 25565
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function bindProfileForm(){
|
||||
bindTextField('id')
|
||||
bindTextField('name')
|
||||
bindTextField('description')
|
||||
bindTextField('details')
|
||||
bindTextField('distributionUrl')
|
||||
bindTextField('defaultServerAddress')
|
||||
bindCheckboxField('allowCustomServerAddress')
|
||||
bindTextField('worldArchiveUrl')
|
||||
bindTextField('worldDirectoryName')
|
||||
bindTextField('serverBundleUrl')
|
||||
bindTextField('serverDirectoryName')
|
||||
bindTextField('serverLaunchCommand')
|
||||
bindTextField('serverWorkingDirectory')
|
||||
bindNumberField('serverPort')
|
||||
bindTextField('tunnelCommand')
|
||||
bindTextField('tunnelAddressRegex')
|
||||
|
||||
fieldElements.kind.addEventListener('change', (event) => {
|
||||
const profile = getSelectedProfile()
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
|
||||
profile.kind = event.target.value
|
||||
|
||||
if(profile.kind === 'map'){
|
||||
profile.allowCustomServerAddress = false
|
||||
}
|
||||
if(profile.kind !== 'server-pack' && !profile.serverDirectoryName){
|
||||
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
|
||||
}
|
||||
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
populateEditor()
|
||||
})
|
||||
|
||||
for(const button of document.querySelectorAll('.uploadButton')){
|
||||
button.addEventListener('click', async () => {
|
||||
const targetField = button.dataset.uploadTarget
|
||||
const accept = button.dataset.uploadAccept
|
||||
await uploadIntoField(targetField, accept)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadIntoField(targetField, accept){
|
||||
const profile = getSelectedProfile()
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
|
||||
const picker = document.createElement('input')
|
||||
picker.type = 'file'
|
||||
picker.accept = accept || ''
|
||||
picker.addEventListener('change', async () => {
|
||||
const file = picker.files?.[0]
|
||||
if(!file){
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
showStatus(`${file.name} 업로드 중...`, 'info')
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
const result = await response.json()
|
||||
if(!response.ok || result.ok !== true){
|
||||
throw new Error(result.message || '업로드에 실패했습니다.')
|
||||
}
|
||||
|
||||
profile[targetField] = result.file.path
|
||||
fieldElements[targetField].value = result.file.path
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
showStatus(`업로드 완료: ${result.file.path}`, 'success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showStatus(error instanceof Error ? error.message : '업로드에 실패했습니다.', 'error')
|
||||
}
|
||||
})
|
||||
|
||||
picker.click()
|
||||
}
|
||||
|
||||
async function loadMeta(){
|
||||
const response = await fetch('/api/meta')
|
||||
const meta = await response.json()
|
||||
state.meta = meta
|
||||
launcherCatalogPath.textContent = meta.launcherCatalogPath
|
||||
localCatalogUrl.textContent = meta.localCatalogUrl
|
||||
localCatalogUrl.href = meta.localCatalogUrl
|
||||
}
|
||||
|
||||
async function loadCatalog(){
|
||||
const response = await fetch('/api/catalog')
|
||||
const catalog = await response.json()
|
||||
state.catalog = {
|
||||
version: 1,
|
||||
profiles: Array.isArray(catalog.profiles) ? catalog.profiles : []
|
||||
}
|
||||
|
||||
if(state.catalog.profiles.length > 0){
|
||||
state.selectedProfileId = state.catalog.profiles[0].id
|
||||
} else {
|
||||
state.selectedProfileId = null
|
||||
}
|
||||
|
||||
markDirty(false)
|
||||
renderSidebar()
|
||||
populateEditor()
|
||||
}
|
||||
|
||||
function addProfile(kind){
|
||||
const profile = createProfile(kind)
|
||||
state.catalog.profiles.push(profile)
|
||||
markDirty(true)
|
||||
selectProfile(profile.id)
|
||||
showStatus(`${profile.name} 프로필을 추가했습니다.`, 'success')
|
||||
}
|
||||
|
||||
function duplicateSelectedProfile(){
|
||||
const profile = getSelectedProfile()
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
|
||||
const clonedProfile = structuredClone(profile)
|
||||
clonedProfile.id = `${slugify(profile.id || profile.name) || 'profile'}-copy-${Date.now()}`
|
||||
clonedProfile.name = `${profile.name} 복제본`
|
||||
|
||||
state.catalog.profiles.push(clonedProfile)
|
||||
markDirty(true)
|
||||
selectProfile(clonedProfile.id)
|
||||
showStatus('복제본을 만들었습니다.', 'success')
|
||||
}
|
||||
|
||||
function deleteSelectedProfile(){
|
||||
const profile = getSelectedProfile()
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`'${profile.name}' 프로필을 삭제할까요?`)
|
||||
if(!confirmed){
|
||||
return
|
||||
}
|
||||
|
||||
state.catalog.profiles = state.catalog.profiles.filter((entry) => entry.id !== profile.id)
|
||||
state.selectedProfileId = state.catalog.profiles[0]?.id ?? null
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
populateEditor()
|
||||
showStatus('프로필을 삭제했습니다.', 'success')
|
||||
}
|
||||
|
||||
async function saveCatalog(){
|
||||
try {
|
||||
clearStatus()
|
||||
saveCatalogButton.disabled = true
|
||||
showStatus('카탈로그 저장 중...', 'info')
|
||||
|
||||
const response = await fetch('/api/catalog', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(state.catalog)
|
||||
})
|
||||
const result = await response.json()
|
||||
if(!response.ok || result.ok !== true){
|
||||
throw new Error(result.message || '카탈로그 저장에 실패했습니다.')
|
||||
}
|
||||
|
||||
state.catalog = result.catalog
|
||||
if(!state.catalog.profiles.some((profile) => profile.id === state.selectedProfileId)){
|
||||
state.selectedProfileId = state.catalog.profiles[0]?.id ?? null
|
||||
}
|
||||
|
||||
markDirty(false)
|
||||
renderSidebar()
|
||||
populateEditor()
|
||||
showStatus('카탈로그를 저장했고 런처 카탈로그에도 동기화했습니다.', 'success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showStatus(error instanceof Error ? error.message : '카탈로그 저장에 실패했습니다.', 'error')
|
||||
} finally {
|
||||
saveCatalogButton.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
function bindTopLevelActions(){
|
||||
for(const button of document.querySelectorAll('[data-add-kind]')){
|
||||
button.addEventListener('click', () => {
|
||||
addProfile(button.dataset.addKind)
|
||||
})
|
||||
}
|
||||
|
||||
saveCatalogButton.addEventListener('click', async () => {
|
||||
await saveCatalog()
|
||||
})
|
||||
|
||||
duplicateProfileButton.addEventListener('click', () => {
|
||||
duplicateSelectedProfile()
|
||||
})
|
||||
|
||||
deleteProfileButton.addEventListener('click', () => {
|
||||
deleteSelectedProfile()
|
||||
})
|
||||
}
|
||||
|
||||
async function bootstrap(){
|
||||
bindProfileForm()
|
||||
bindTopLevelActions()
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
loadMeta(),
|
||||
loadCatalog()
|
||||
])
|
||||
showStatus('관리자 사이트를 불러왔습니다. 저장하면 런처 카탈로그가 같이 갱신됩니다.', 'info')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showStatus(error instanceof Error ? error.message : '관리자 사이트를 불러오지 못했습니다.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
189
admin/public/index.html
Normal file
189
admin/public/index.html
Normal file
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Launcher Admin</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="adminShell">
|
||||
<aside class="sidebar">
|
||||
<div class="brandBlock">
|
||||
<span class="eyebrow">Launcher Admin</span>
|
||||
<h1>프로필 관리</h1>
|
||||
<p>설치 페이지에 표시할 실제 프로필을 UI로 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="metaPanel">
|
||||
<div class="metaRow">
|
||||
<span class="metaLabel">런처 카탈로그</span>
|
||||
<code id="launcherCatalogPath">불러오는 중</code>
|
||||
</div>
|
||||
<div class="metaRow">
|
||||
<span class="metaLabel">로컬 catalog URL</span>
|
||||
<a id="localCatalogUrl" href="#" target="_blank" rel="noreferrer">불러오는 중</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addButtons">
|
||||
<button type="button" class="primaryAction" data-add-kind="modpack">모드팩 추가</button>
|
||||
<button type="button" class="secondaryAction" data-add-kind="map">맵 추가</button>
|
||||
<button type="button" class="secondaryAction" data-add-kind="server-pack">서버팩 추가</button>
|
||||
</div>
|
||||
|
||||
<div class="profileListHeader">
|
||||
<span>프로필 목록</span>
|
||||
<span id="profileCount">0개</span>
|
||||
</div>
|
||||
<div id="profileList" class="profileList"></div>
|
||||
</aside>
|
||||
|
||||
<main class="editorPane">
|
||||
<div class="topBar">
|
||||
<div>
|
||||
<span class="eyebrow">Catalog Editor</span>
|
||||
<h2>선택한 프로필 편집</h2>
|
||||
<p id="editorHint">왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.</p>
|
||||
</div>
|
||||
<div class="topBarActions">
|
||||
<button type="button" id="duplicateProfileButton" class="secondaryAction" disabled>복제</button>
|
||||
<button type="button" id="deleteProfileButton" class="dangerAction" disabled>삭제</button>
|
||||
<button type="button" id="saveCatalogButton" class="primaryAction">카탈로그 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="statusBanner" class="statusBanner" hidden></div>
|
||||
|
||||
<div id="emptyState" class="emptyState">
|
||||
<h3>프로필이 없습니다</h3>
|
||||
<p>왼쪽 버튼으로 새 모드팩, 맵, 서버팩 프로필을 추가하세요.</p>
|
||||
</div>
|
||||
|
||||
<form id="profileEditorForm" class="editorForm" hidden>
|
||||
<section class="fieldSection">
|
||||
<div class="sectionHeader">
|
||||
<h3>기본 정보</h3>
|
||||
</div>
|
||||
<div class="fieldGrid">
|
||||
<label class="fieldBlock">
|
||||
<span>프로필 ID</span>
|
||||
<input id="field-id" type="text" autocomplete="off">
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>종류</span>
|
||||
<select id="field-kind">
|
||||
<option value="modpack">modpack</option>
|
||||
<option value="map">map</option>
|
||||
<option value="server-pack">server-pack</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>표시 이름</span>
|
||||
<input id="field-name" type="text" autocomplete="off">
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>짧은 설명</span>
|
||||
<input id="field-description" type="text" autocomplete="off">
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>자세한 설명</span>
|
||||
<textarea id="field-details" rows="6"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fieldSection">
|
||||
<div class="sectionHeader">
|
||||
<h3>클라이언트 배포</h3>
|
||||
</div>
|
||||
<div class="fieldGrid">
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>distribution.json 경로 또는 URL</span>
|
||||
<div class="uploadField">
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>기본 접속 주소</span>
|
||||
<input id="field-defaultServerAddress" type="text" autocomplete="off" placeholder="example.com:25565">
|
||||
</label>
|
||||
<label class="toggleBlock">
|
||||
<input id="field-allowCustomServerAddress" type="checkbox">
|
||||
<span>사용자가 라이브러리에서 주소를 바꿀 수 있음</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="mapSection" class="fieldSection" hidden>
|
||||
<div class="sectionHeader">
|
||||
<h3>맵 자료</h3>
|
||||
</div>
|
||||
<div class="fieldGrid">
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>월드 ZIP 경로 또는 URL</span>
|
||||
<div class="uploadField">
|
||||
<input id="field-worldArchiveUrl" type="text" autocomplete="off">
|
||||
<button type="button" class="secondaryAction uploadButton" data-upload-target="worldArchiveUrl" data-upload-accept=".zip,application/zip">파일 업로드</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>월드 폴더 이름</span>
|
||||
<input id="field-worldDirectoryName" type="text" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="serverPackSection" class="fieldSection" hidden>
|
||||
<div class="sectionHeader">
|
||||
<h3>서버팩 자료</h3>
|
||||
</div>
|
||||
<div class="fieldGrid">
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>서버 번들 ZIP 경로 또는 URL</span>
|
||||
<div class="uploadField">
|
||||
<input id="field-serverBundleUrl" type="text" autocomplete="off">
|
||||
<button type="button" class="secondaryAction uploadButton" data-upload-target="serverBundleUrl" data-upload-accept=".zip,application/zip">파일 업로드</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>서버 폴더 이름</span>
|
||||
<input id="field-serverDirectoryName" type="text" autocomplete="off">
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>서버 실행 명령</span>
|
||||
<input id="field-serverLaunchCommand" type="text" autocomplete="off" placeholder="java -jar server.jar nogui">
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>작업 디렉터리</span>
|
||||
<input id="field-serverWorkingDirectory" type="text" autocomplete="off" placeholder="빈 값이면 서버 루트">
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>서버 포트</span>
|
||||
<input id="field-serverPort" type="number" min="1" max="65535" step="1">
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>터널 명령</span>
|
||||
<input id="field-tunnelCommand" type="text" autocomplete="off" placeholder="예: playit-cli --port ${port}">
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>터널 주소 추출 정규식</span>
|
||||
<input id="field-tunnelAddressRegex" type="text" autocomplete="off" placeholder="예: ([a-zA-Z0-9.-]+:\\d+)">
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fieldSection helperSection">
|
||||
<div class="sectionHeader">
|
||||
<h3>운영 메모</h3>
|
||||
</div>
|
||||
<p>업로드 버튼은 현재 프로젝트 기준 상대 경로를 자동으로 채웁니다. 로컬 테스트에는 바로 쓸 수 있고, 외부 배포용으로는 URL로 바꿔도 됩니다.</p>
|
||||
</section>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
376
admin/public/styles.css
Normal file
376
admin/public/styles.css
Normal file
@@ -0,0 +1,376 @@
|
||||
:root {
|
||||
--bg: #0f1110;
|
||||
--panel: #161a18;
|
||||
--panel-strong: #1d231f;
|
||||
--panel-soft: rgba(255, 255, 255, 0.04);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--text: #f5f4ef;
|
||||
--muted: rgba(245, 244, 239, 0.68);
|
||||
--accent: #f0bf57;
|
||||
--accent-strong: #ffd980;
|
||||
--danger: #ff7b63;
|
||||
--shadow: 0 24px 50px rgba(0, 0, 0, 0.28);
|
||||
--radius: 22px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(240, 191, 87, 0.12), transparent 30%),
|
||||
linear-gradient(180deg, #0d0f0e 0%, #111512 100%);
|
||||
color: var(--text);
|
||||
font-family: "Pretendard Variable", "Noto Sans KR", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff4d8;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.adminShell {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 28px 24px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(10, 12, 11, 0.86);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.brandBlock h1,
|
||||
.topBar h2,
|
||||
.emptyState h3,
|
||||
.sectionHeader h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brandBlock p,
|
||||
.topBar p,
|
||||
.emptyState p,
|
||||
.helperSection p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metaPanel,
|
||||
.emptyState,
|
||||
.fieldSection,
|
||||
.statusBanner {
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metaPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metaLabel,
|
||||
.profileListHeader,
|
||||
.fieldBlock span,
|
||||
.toggleBlock span {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.profileListHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.addButtons {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profileList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.profileListItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.profileListItem:hover,
|
||||
.profileListItem:focus {
|
||||
border-color: rgba(240, 191, 87, 0.45);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.profileListItem[selected="true"] {
|
||||
border-color: rgba(240, 191, 87, 0.72);
|
||||
background: rgba(240, 191, 87, 0.1);
|
||||
}
|
||||
|
||||
.profileListName {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profileListMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.profileListDescription {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editorPane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.topBarActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editorForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fieldBlock,
|
||||
.toggleBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fieldBlockFull {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.toggleBlock {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toggleBlock input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggleBlock {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.uploadField {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.dangerAction {
|
||||
padding: 13px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
background: var(--accent);
|
||||
color: #211808;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.dangerAction {
|
||||
background: rgba(255, 123, 99, 0.1);
|
||||
border-color: rgba(255, 123, 99, 0.3);
|
||||
color: #ffb4a5;
|
||||
}
|
||||
|
||||
.primaryAction:hover,
|
||||
.secondaryAction:hover,
|
||||
.dangerAction:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primaryAction:disabled,
|
||||
.secondaryAction:disabled,
|
||||
.dangerAction:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.statusBanner {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.statusBanner[data-tone="success"] {
|
||||
border-color: rgba(115, 208, 144, 0.3);
|
||||
background: rgba(115, 208, 144, 0.1);
|
||||
}
|
||||
|
||||
.statusBanner[data-tone="error"] {
|
||||
border-color: rgba(255, 123, 99, 0.3);
|
||||
background: rgba(255, 123, 99, 0.1);
|
||||
}
|
||||
|
||||
.statusBanner[data-tone="info"] {
|
||||
border-color: rgba(240, 191, 87, 0.28);
|
||||
background: rgba(240, 191, 87, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.adminShell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.editorPane,
|
||||
.sidebar {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.fieldGrid,
|
||||
.uploadField,
|
||||
.topBar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
236
admin/server.js
Normal file
236
admin/server.js
Normal file
@@ -0,0 +1,236 @@
|
||||
const express = require('express')
|
||||
const fs = require('fs-extra')
|
||||
const multer = require('multer')
|
||||
const path = require('path')
|
||||
|
||||
const HOST = process.env.LAUNCHER_ADMIN_HOST || '127.0.0.1'
|
||||
const PORT = Number.parseInt(process.env.LAUNCHER_ADMIN_PORT || '8787', 10)
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..')
|
||||
const RUNTIME_DATA_DIR = path.join(__dirname, 'data')
|
||||
const UPLOADS_DIR = path.join(RUNTIME_DATA_DIR, 'uploads')
|
||||
const RUNTIME_CATALOG_PATH = path.join(RUNTIME_DATA_DIR, 'catalog.json')
|
||||
const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher', 'catalog.json')
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public')
|
||||
|
||||
const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack'])
|
||||
|
||||
function normalizeText(value){
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function normalizeMultilineText(value){
|
||||
return typeof value === 'string'
|
||||
? value.replace(/\r\n/g, '\n').trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
function normalizeBoolean(value){
|
||||
return value === true
|
||||
}
|
||||
|
||||
function normalizePort(value){
|
||||
const port = Number.parseInt(String(value ?? ''), 10)
|
||||
if(Number.isFinite(port) && port >= 1 && port <= 65535){
|
||||
return port
|
||||
}
|
||||
return 25565
|
||||
}
|
||||
|
||||
function sanitizeProfile(rawProfile, index){
|
||||
const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack'
|
||||
const sanitized = {
|
||||
id: normalizeText(rawProfile?.id) || `profile-${index + 1}`,
|
||||
name: normalizeText(rawProfile?.name) || `새 프로필 ${index + 1}`,
|
||||
kind,
|
||||
description: normalizeText(rawProfile?.description),
|
||||
details: normalizeMultilineText(rawProfile?.details),
|
||||
distributionUrl: normalizeText(rawProfile?.distributionUrl),
|
||||
defaultServerAddress: normalizeText(rawProfile?.defaultServerAddress),
|
||||
allowCustomServerAddress: normalizeBoolean(rawProfile?.allowCustomServerAddress)
|
||||
}
|
||||
|
||||
if(kind === 'map'){
|
||||
sanitized.worldArchiveUrl = normalizeText(rawProfile?.worldArchiveUrl)
|
||||
sanitized.worldDirectoryName = normalizeText(rawProfile?.worldDirectoryName)
|
||||
}
|
||||
|
||||
if(kind === 'server-pack'){
|
||||
sanitized.serverBundleUrl = normalizeText(rawProfile?.serverBundleUrl)
|
||||
sanitized.serverDirectoryName = normalizeText(rawProfile?.serverDirectoryName) || `${sanitized.id}-server`
|
||||
sanitized.serverLaunchCommand = normalizeText(rawProfile?.serverLaunchCommand)
|
||||
sanitized.serverWorkingDirectory = normalizeText(rawProfile?.serverWorkingDirectory)
|
||||
sanitized.serverPort = normalizePort(rawProfile?.serverPort)
|
||||
sanitized.tunnelCommand = normalizeText(rawProfile?.tunnelCommand)
|
||||
sanitized.tunnelAddressRegex = normalizeText(rawProfile?.tunnelAddressRegex)
|
||||
}
|
||||
|
||||
if(normalizeText(rawProfile?.artwork).length > 0){
|
||||
sanitized.artwork = normalizeText(rawProfile.artwork)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function sanitizeCatalog(payload){
|
||||
const profiles = Array.isArray(payload?.profiles) ? payload.profiles : []
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
profiles: profiles.map((profile, index) => sanitizeProfile(profile, index))
|
||||
}
|
||||
}
|
||||
|
||||
function toProjectRelativePath(targetPath){
|
||||
return path.relative(PROJECT_ROOT, targetPath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
async function ensureRuntimeCatalog(){
|
||||
await fs.ensureDir(RUNTIME_DATA_DIR)
|
||||
await fs.ensureDir(UPLOADS_DIR)
|
||||
|
||||
if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){
|
||||
if(await fs.pathExists(LAUNCHER_CATALOG_PATH)){
|
||||
await fs.copy(LAUNCHER_CATALOG_PATH, RUNTIME_CATALOG_PATH, { overwrite: true })
|
||||
} else {
|
||||
await fs.writeJson(RUNTIME_CATALOG_PATH, { version: 1, profiles: [] }, { spaces: 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCatalog(){
|
||||
await ensureRuntimeCatalog()
|
||||
return sanitizeCatalog(await fs.readJson(RUNTIME_CATALOG_PATH))
|
||||
}
|
||||
|
||||
async function writeCatalog(catalog){
|
||||
const sanitizedCatalog = sanitizeCatalog(catalog)
|
||||
await fs.ensureDir(path.dirname(LAUNCHER_CATALOG_PATH))
|
||||
await fs.writeJson(RUNTIME_CATALOG_PATH, sanitizedCatalog, { spaces: 2 })
|
||||
await fs.writeJson(LAUNCHER_CATALOG_PATH, sanitizedCatalog, { spaces: 2 })
|
||||
return sanitizedCatalog
|
||||
}
|
||||
|
||||
function createUploadStorage(){
|
||||
return multer.diskStorage({
|
||||
destination(_req, _file, callback){
|
||||
fs.ensureDir(UPLOADS_DIR)
|
||||
.then(() => callback(null, UPLOADS_DIR))
|
||||
.catch((error) => callback(error))
|
||||
},
|
||||
filename(_req, file, callback){
|
||||
const extension = path.extname(file.originalname)
|
||||
const baseName = path.basename(file.originalname, extension)
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[-_.]+|[-_.]+$/g, '')
|
||||
const timestamp = Date.now()
|
||||
const safeBaseName = baseName.length > 0 ? baseName : 'file'
|
||||
callback(null, `${timestamp}-${safeBaseName}${extension.toLowerCase()}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function start(){
|
||||
await ensureRuntimeCatalog()
|
||||
|
||||
const app = express()
|
||||
const upload = multer({
|
||||
storage: createUploadStorage(),
|
||||
limits: {
|
||||
fileSize: 1024 * 1024 * 1024
|
||||
}
|
||||
})
|
||||
|
||||
app.use(express.json({ limit: '5mb' }))
|
||||
app.use('/uploads', express.static(UPLOADS_DIR))
|
||||
|
||||
app.get('/api/meta', async (_req, res) => {
|
||||
res.json({
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH),
|
||||
launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH),
|
||||
localCatalogUrl: `http://${HOST}:${PORT}/catalog.json`
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/catalog', async (_req, res, next) => {
|
||||
try {
|
||||
res.json(await readCatalog())
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/catalog', async (req, res, next) => {
|
||||
try {
|
||||
const savedCatalog = await writeCatalog(req.body)
|
||||
res.json({
|
||||
ok: true,
|
||||
catalog: savedCatalog
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/upload', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if(req.file == null){
|
||||
res.status(400).json({
|
||||
ok: false,
|
||||
message: '업로드할 파일이 없습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const relativePath = toProjectRelativePath(req.file.path)
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
file: {
|
||||
name: req.file.originalname,
|
||||
storedName: req.file.filename,
|
||||
size: req.file.size,
|
||||
path: relativePath,
|
||||
localUrl: `http://${HOST}:${PORT}/uploads/${encodeURIComponent(req.file.filename)}`
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/catalog.json', async (_req, res, next) => {
|
||||
try {
|
||||
res.sendFile(RUNTIME_CATALOG_PATH)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.use(express.static(PUBLIC_DIR))
|
||||
|
||||
app.get(/.*/, (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_DIR, 'index.html'))
|
||||
})
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
console.error(error)
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : '관리자 서버 처리 중 오류가 발생했습니다.'
|
||||
})
|
||||
})
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[launcher-admin] running on http://${HOST}:${PORT}`)
|
||||
console.log(`[launcher-admin] runtime catalog: ${toProjectRelativePath(RUNTIME_CATALOG_PATH)}`)
|
||||
console.log(`[launcher-admin] launcher catalog mirror: ${toProjectRelativePath(LAUNCHER_CATALOG_PATH)}`)
|
||||
})
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user