Compare commits
2 Commits
cd79378f3c
...
8fd7cfaaef
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fd7cfaaef | |||
| 42a7cf3426 |
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
file/*
|
||||||
|
!file/.gitkeep
|
||||||
357
README.md
357
README.md
@@ -1 +1,356 @@
|
|||||||
# Minecraft Server Pack Easy Installer
|
# 마인크래프트 음악퀴즈 간편설치기 + 관리 사이트 개발 명세서
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
마인크래프트 음악퀴즈를 `.exe` 하나로 간편하게 설치할 수 있는 설치기와, 음악퀴즈 정보를 관리하는 웹사이트를 개발한다.
|
||||||
|
|
||||||
|
### 핵심 컨셉
|
||||||
|
- 이 프로젝트는 `%appdata%\.mc_custom` 폴더를 생성하여 모드 적용 및 서버 실행을 독립적으로 관리한다.
|
||||||
|
- 음악퀴즈 정보는 **`manifest.json`** 으로 중앙 관리한다.
|
||||||
|
|
||||||
|
### .mc_custom 폴더 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
%appdata%\.mc_custom\
|
||||||
|
├── mods/ ← 모드 (.jar) 저장 및 자동 적용
|
||||||
|
├── resourcepacks/ ← 리소스팩 (.zip) 저장 및 자동 적용
|
||||||
|
├── saves/ ← 월드 저장
|
||||||
|
├── config/ ← 모드 설정 파일
|
||||||
|
├── screenshots/ ← 스크린샷
|
||||||
|
└── options.txt ← 게임 설정
|
||||||
|
```
|
||||||
|
|
||||||
|
- 마인크래프트 런처 프로필의 `gameDir`을 `%appdata%\.mc_custom`으로 설정하면, 마인크래프트가 이 폴더를 기준으로 모든 파일을 읽고 저장한다.
|
||||||
|
- 버전 파일과 assets는 기존 `%appdata%\.minecraft`를 그대로 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파트 1. 간편설치기 (`.exe`)
|
||||||
|
|
||||||
|
> 설치기는 아래 단계를 순서대로 페이지 단위로 진행한다. 각 번호 = 1페이지.
|
||||||
|
|
||||||
|
### 1단계: 음악퀴즈 선택
|
||||||
|
|
||||||
|
- 음악퀴즈사이트(아래 파트 2 참고)에서 `manifest.json`을 가져와 등록된 음악퀴즈 목록을 표시한다.
|
||||||
|
- 사용자가 설치할 음악퀴즈를 선택한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2단계: 싱글 / 멀티 선택
|
||||||
|
|
||||||
|
- 싱글 또는 멀티 중 하나를 선택하는 화면을 표시한다.
|
||||||
|
- **멀티 선택 시**: 3단계(서버 설치)를 거친 후 4단계로 진행한다.
|
||||||
|
- **싱글 선택 시**: 3단계를 건너뛰고 4단계로 바로 진행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3단계: 서버 관련 설정
|
||||||
|
|
||||||
|
- 2단계에서 **멀티**를 선택한 경우에만 진입한다. 싱글 선택 시 이 단계 전체를 건너뛴다.
|
||||||
|
- 3단계의 각 소항목(3-1 ~ 3-5)은 완료되어도 자동으로 다음으로 넘어가지 않으며, 사용자가 **확인 버튼**을 눌러야 다음 소항목으로 진행된다.
|
||||||
|
|
||||||
|
#### 3-1. 서버 설치 경로 설정
|
||||||
|
|
||||||
|
- 서버를 생성할 폴더 경로를 사용자가 직접 지정한다.
|
||||||
|
- **경로에 한글이 포함되면 안 된다.** 한글 포함 시 경고 메시지를 표시하고 다음 단계로 진행 불가.
|
||||||
|
|
||||||
|
#### 3-2. JDK 확인 / 설치
|
||||||
|
|
||||||
|
- 폴더 선택 UI로 JDK 경로를 지정할 수 있다.
|
||||||
|
- JDK 자동 탐색 우선순위:
|
||||||
|
1. 시스템 환경변수 (`JAVA_HOME` 등)
|
||||||
|
2. 로컬 환경변수
|
||||||
|
3. `C:\Program Files\Java` (JDK 기본 설치 경로)
|
||||||
|
- 위 경로에서 JDK가 발견되면 해당 경로를 기본값으로 자동 설정한다.
|
||||||
|
- JDK가 없으면 설치를 안내한다.
|
||||||
|
|
||||||
|
#### 3-3. 서버 다운로드 및 설치
|
||||||
|
- 처음 파일 다운로드는 자동으로 시작
|
||||||
|
|
||||||
|
##### 3-3-1. 파일 다운로드
|
||||||
|
- JSON의 `packPath` 필드 값을 `도메인/file/` 뒤에 붙여 서버 파일 다운로드 URL을 구성한다.
|
||||||
|
- 예: `packPath`가 `music-quiz/files`이면 → `도메인/file/music-quiz/files`
|
||||||
|
- 해당 경로의 모든 파일을 순서대로 다운로드한다.
|
||||||
|
|
||||||
|
##### 3-3-2. 설치 로그
|
||||||
|
- 다른 프로그램 설치 화면처럼 실시간 로그를 표시하는 로그 뷰어를 제공한다.
|
||||||
|
|
||||||
|
##### 3-3-3. EULA 동의
|
||||||
|
- 설치 중간에 Minecraft EULA 동의 화면을 표시하고, 사용자가 직접 동의해야 다음 단계로 진행된다.
|
||||||
|
- 음악퀴즈 내에 `eula.txt`가 포함되어 있으면 **삭제하고** 새로 동의를 받는다.
|
||||||
|
|
||||||
|
##### 3-3-4. 램 검사 로직
|
||||||
|
|
||||||
|
> 아래 기준은 모두 JSON의 서버 램 필드(`serverMinRam`, `serverMaxRam`)를 사용한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
유저 시스템 램 >= serverMaxRam → serverMaxRam으로 설정
|
||||||
|
유저 시스템 램 >= serverMinRam → serverMinRam으로 설정 (경고 메시지 표시)
|
||||||
|
유저 시스템 램 < serverMinRam → "플레이 불가" 메시지 출력 후 설치 중단
|
||||||
|
```
|
||||||
|
|
||||||
|
- 서버 실행 시 `-Xmx`에 `serverMaxRam`, `-Xms`에 `serverMinRam` 값을 JVM 인자로 사용한다.
|
||||||
|
|
||||||
|
#### 3-4. 서버 설정
|
||||||
|
|
||||||
|
- **로컬 웹서버**를 띄워 브라우저에서 서버 설정 파일을 GUI로 편집할 수 있게 한다.
|
||||||
|
- 메모장으로 수정해야 했던 파일들을 설명과 함께 편리하게 수정 가능:
|
||||||
|
- `bukkit.yml`
|
||||||
|
- `server.properties`
|
||||||
|
- 기타 설정 파일
|
||||||
|
- 수정 후 "적용" 버튼으로 실제 파일에 반영한다.
|
||||||
|
|
||||||
|
#### 3-5. 서버 포트포워딩 설정
|
||||||
|
|
||||||
|
1. **이미 포트포워딩 되어 있는 경우** (UPnP 포함): 외부 접속 주소를 화면에 표시하고 다음 단계로 진행.
|
||||||
|
2. **포트포워딩 안 된 경우 → UPnP 시도**:
|
||||||
|
- UPnP로 포트 개방 가능 여부 확인
|
||||||
|
- 가능하면 자동으로 개방 후 외부 접속 테스트
|
||||||
|
- 접속 확인되면 다음 단계로 진행 가능
|
||||||
|
3. **UPnP 불가 시**: "직접 포트포워딩을 해주세요." 메시지를 안내와 함께 표시.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4단계: 유저 클라이언트 설정
|
||||||
|
- 음악퀴즈 JSON 파일에 명시된 클라이언트 설정을 기반으로 설치한다.
|
||||||
|
|
||||||
|
#### 4-1. 모드 플랫폼 설치
|
||||||
|
- JSON 파일의 `platform.type`에 명시된 플랫폼 이름을 화면에 표시하고, **설치** / **건너뛰기** 버튼으로 사용자가 직접 선택한다.
|
||||||
|
- `vanilla`가 아닌 경우: `platform.downloadUrl`을 기반으로 다운로드 후 설치한다.
|
||||||
|
- **건너뛰기** 선택 시: 플랫폼 설치 없이 바닐라로 진행한다.
|
||||||
|
|
||||||
|
#### 4-2. 설치설정 설정
|
||||||
|
- `%appdata%\.minecraft\launcher_profiles.json`에 프로필을 추가한다.
|
||||||
|
- 이미 동일한 이름의 프로필이 있으면 새로 만들지 않고 기존 프로필을 수정한다.
|
||||||
|
- **`gameDir`을 `%appdata%\.mc_custom`으로 설정한다.**
|
||||||
|
- 이 설정으로 인해 모드, 리소스팩, 세이브 등 모든 파일이 `.mc_custom` 기준으로 읽히고 저장된다.
|
||||||
|
- 램 설정(`javaArgs`)을 JSON의 서버 램 필드 기준으로 적용한다.
|
||||||
|
- `-Xmx`에 `serverMaxRam`, `-Xms`에 `serverMinRam` 값을 사용한다.
|
||||||
|
- 플랫폼(Forge 등) 설치 버전을 `lastVersionId`로 설정한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// launcher_profiles.json 프로필 예시
|
||||||
|
{
|
||||||
|
"음악퀴즈": {
|
||||||
|
"name": "음악퀴즈",
|
||||||
|
"type": "custom",
|
||||||
|
"gameDir": "%appdata%\\.mc_custom",
|
||||||
|
"lastVersionId": "1.20.1-forge-47.2.0",
|
||||||
|
"javaArgs": "-Xmx4G -Xms2G"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-3. 모드 및 리소스팩 설치
|
||||||
|
- JSON 파일에 명시된 모드와 리소스팩을 다운로드하여 `.mc_custom` 하위 폴더에 저장한다.
|
||||||
|
- 다운로드 진행 상황을 실시간 로그 뷰어로 표시한다. (다른 프로그램 설치 화면과 동일한 형태)
|
||||||
|
|
||||||
|
| 파일 종류 | 저장 경로 |
|
||||||
|
|-----------|-----------|
|
||||||
|
| 모드 (`.jar`) | `%appdata%\.mc_custom\mods\` |
|
||||||
|
| 리소스팩 (`.zip`) | `%appdata%\.mc_custom\resourcepacks\` |
|
||||||
|
|
||||||
|
- 이미 동일한 파일명이 존재하면 덮어쓴다.
|
||||||
|
- 다운로드 완료 후 마인크래프트 실행 시 자동으로 적용된다. (별도 설정 불필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5단계: 완료 페이지
|
||||||
|
|
||||||
|
- 멀티로 진행해서 서버도 설치했다면:
|
||||||
|
- **서버 폴더 열기** 버튼
|
||||||
|
- **바탕화면에 서버 실행 바로가기 만들기** 토글 (기본값: ON)
|
||||||
|
- **서버 바로 실행** 토글 (기본값: ON)
|
||||||
|
- 마인크래프트 런처 실행 토글 (기본값: ON)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파트 2. 음악퀴즈 관리 웹사이트
|
||||||
|
|
||||||
|
> **기술 스택: Node.js + TypeScript + Express + EJS**
|
||||||
|
|
||||||
|
### 라우팅 구조
|
||||||
|
|
||||||
|
| 경로 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `/` | 메인 페이지 (음악퀴즈 목록) |
|
||||||
|
| `/manifest.json` | manifest.json 파일 직접 접근 |
|
||||||
|
| `/file/` | 음악퀴즈 파일 제공 경로 |
|
||||||
|
| `/op` | 관리자 로그인 페이지 |
|
||||||
|
| `/op/dashboard` | 관리자 대시보드 |
|
||||||
|
| `/op/dashboard/:packName` | 음악퀴즈 JSON 편집 페이지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 메인 페이지 (`/`)
|
||||||
|
|
||||||
|
- `manifest.json`에 등록된 음악퀴즈를 **가로 한 줄 목록(카드) 형식**으로 표시한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 관리자 인증 (`/op`)
|
||||||
|
|
||||||
|
- `/op` 하위 모든 경로는 로그인 없이 접근 불가 (미들웨어로 처리).
|
||||||
|
- 로그인 화면: 아이디 + 비밀번호 입력.
|
||||||
|
- 계정 정보는 **`account.json`** 파일에 저장.
|
||||||
|
- `account.json`은 **서버 내부에서만 접근 가능**, 외부 HTTP 요청으로 절대 노출되지 않아야 함.
|
||||||
|
- 로그인 성공 시 `/op/dashboard`로 리다이렉트.
|
||||||
|
|
||||||
|
#### account.json 예시 구조
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "admin",
|
||||||
|
"password": "admin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 관리자 대시보드 (`/op/dashboard`)
|
||||||
|
|
||||||
|
#### 공통 레이아웃 (메뉴바)
|
||||||
|
- **왼쪽**: 로고 + "관리자 페이지" 텍스트 → 클릭 시 `/op/dashboard`로 이동
|
||||||
|
- **오른쪽**: 로그인한 아이디 표시 → 클릭 시 드롭다운 메뉴 표시
|
||||||
|
- 드롭다운 항목: **로그아웃** 버튼
|
||||||
|
|
||||||
|
#### 음악퀴즈 목록
|
||||||
|
- `/manifest` 폴더 안의 JSON 파일들을 가져와 **가로 한 줄 카드 형식**으로 표시.
|
||||||
|
- 카드 클릭 → `/op/dashboard/:packName` 으로 이동하여 JSON 편집 시작.
|
||||||
|
|
||||||
|
#### 음악퀴즈 추가 버튼
|
||||||
|
- `/manifest/` 폴더 안에 새 JSON 파일 생성.
|
||||||
|
- 기본 이름: `new.json`
|
||||||
|
- 이미 존재하면: `new2.json`, `new3.json` ... 순으로 증가.
|
||||||
|
- 생성과 동시에 `manifest.json`에도 자동 등록.
|
||||||
|
|
||||||
|
#### 음악퀴즈 삭제 버튼
|
||||||
|
- 버튼 클릭 시:
|
||||||
|
- 목록 카드에 **체크박스** 표시
|
||||||
|
- 버튼 아래 **취소** / **확인** 버튼 표시
|
||||||
|
- 확인 클릭 시 체크된 JSON 파일 삭제 + `manifest.json`에서도 자동 제거.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 음악퀴즈 편집 페이지 (`/op/dashboard/:packName`)
|
||||||
|
|
||||||
|
- 해당 JSON 파일의 내용을 GUI 폼으로 편집한다.
|
||||||
|
- **JSON 파일 이름 변경** 기능 제공.
|
||||||
|
- 이름 변경 후 적용 클릭 시 현재 브라우저 URL의 `:packName` 부분도 자동 변경 (리다이렉트).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파트 3. 음악퀴즈 JSON 구조 및 편집 항목
|
||||||
|
|
||||||
|
> `/manifest/*.json` 파일의 구조. 관리자 편집 페이지에서 아래 항목들을 GUI로 수정 가능.
|
||||||
|
|
||||||
|
### JSON 필드 정의
|
||||||
|
|
||||||
|
| 필드명 | 타입 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `name` | `string` | 음악퀴즈 이름 |
|
||||||
|
| `mcVersion` | `string` | 마인크래프트 버전 (스냅샷 제외한 정식 릴리즈만) |
|
||||||
|
| `platform` | `object` | 모드 플랫폼 정보 (아래 참고) |
|
||||||
|
| `platform.type` | `string` | 플랫폼 종류 (`vanilla` / `forge` / `fabric` / `neoforge` 등) |
|
||||||
|
| `platform.downloadUrl` | `string` | 플랫폼 설치파일 다운로드 URL (바닐라면 생략) |
|
||||||
|
| `mods` | `array` | 설치할 모드 목록 (아래 참고) |
|
||||||
|
| `mods[].name` | `string` | 모드 이름 |
|
||||||
|
| `mods[].downloadUrl` | `string` | 모드 다운로드 URL |
|
||||||
|
| `resourcepacks` | `array` | 설치할 리소스팩 목록 |
|
||||||
|
| `resourcepacks[].name` | `string` | 리소스팩 이름 |
|
||||||
|
| `resourcepacks[].downloadUrl` | `string` | 리소스팩 다운로드 URL |
|
||||||
|
| `serverMinRam` | `number` | 서버 최소 램 (MB 단위) |
|
||||||
|
| `serverMaxRam` | `number` | 서버 최대 램 (MB 단위) |
|
||||||
|
| `clientMinRam` | `number` | 유저(클라이언트) 최소 램 (MB 단위) |
|
||||||
|
| `clientRecommendedRam` | `number` | 유저(클라이언트) 권장 램 (MB 단위) |
|
||||||
|
| `packPath` | `string` | 서버 파일 경로 (`/file/` 이후의 경로, 멀티 전용) |
|
||||||
|
|
||||||
|
### JSON 예시
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "음악퀴즈 v1",
|
||||||
|
"mcVersion": "1.20.1",
|
||||||
|
"platform": {
|
||||||
|
"type": "forge",
|
||||||
|
"downloadUrl": "https://example.com/forge-installer.jar"
|
||||||
|
},
|
||||||
|
"mods": [
|
||||||
|
{
|
||||||
|
"name": "ExampleMod",
|
||||||
|
"downloadUrl": "https://example.com/examplemod.jar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resourcepacks": [
|
||||||
|
{
|
||||||
|
"name": "ExampleResourcePack",
|
||||||
|
"downloadUrl": "https://example.com/resourcepack.zip"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serverMinRam": 2048,
|
||||||
|
"serverMaxRam": 8192,
|
||||||
|
"clientMinRam": 4096,
|
||||||
|
"clientRecommendedRam": 8192,
|
||||||
|
"packPath": "music-quiz/files"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 편집 UI 항목별 비고
|
||||||
|
|
||||||
|
| 항목 | UI 형태 | 비고 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `mcVersion` | 드롭다운 | Mojang API에서 정식 릴리즈만 가져와 표시, 스냅샷 제외 |
|
||||||
|
| `platform.type` | 드롭다운 | `vanilla` 선택 시 `downloadUrl` 입력란 숨김 |
|
||||||
|
| `mods` | 동적 목록 | 항목 추가 / 삭제 가능 |
|
||||||
|
| `resourcepacks` | 동적 목록 | 항목 추가 / 삭제 가능 |
|
||||||
|
| 램 관련 필드 | 숫자 입력 | MB 단위, `clientMinRam` ≤ `clientRecommendedRam` 유효성 검사 |
|
||||||
|
| `packPath` | 텍스트 입력 | `/file/` 이후 경로만 입력 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파트 4. manifest.json 구조
|
||||||
|
|
||||||
|
> 사이트 루트의 `manifest.json`. 설치기와 메인 페이지가 이 파일을 읽는다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"packs": [
|
||||||
|
{
|
||||||
|
"name": "음악퀴즈 이름",
|
||||||
|
"file": "new"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `file`: `/manifest/` 폴더 안의 JSON 파일 이름 (확장자 제외).
|
||||||
|
- 음악퀴즈 추가/삭제 시 자동으로 이 파일도 업데이트된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디렉토리 구조 (웹사이트)
|
||||||
|
|
||||||
|
```
|
||||||
|
project-root/
|
||||||
|
├── manifest.json # 음악퀴즈 목록 (외부 접근 가능)
|
||||||
|
├── account.json # 관리자 계정 정보 (외부 접근 절대 불가)
|
||||||
|
├── /manifest/ # 음악퀴즈 JSON 파일 저장 폴더
|
||||||
|
│ ├── new.json
|
||||||
|
│ └── music-quiz.json
|
||||||
|
├── /file/ # 서버 파일 제공 경로 (멀티 전용)
|
||||||
|
├── /src/ # TypeScript 소스
|
||||||
|
│ ├── app.ts
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── index.ts # 메인 페이지
|
||||||
|
│ │ └── op.ts # 관리자 라우트
|
||||||
|
│ └── middleware/
|
||||||
|
│ └── auth.ts # /op 인증 미들웨어
|
||||||
|
└── /views/ # EJS 템플릿
|
||||||
|
├── index.ejs
|
||||||
|
├── op/
|
||||||
|
│ ├── login.ejs
|
||||||
|
│ ├── dashboard.ejs
|
||||||
|
│ └── editor.ejs
|
||||||
|
└── partials/
|
||||||
|
└── navbar.ejs
|
||||||
|
```
|
||||||
|
|||||||
6
account.json
Normal file
6
account.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "admin",
|
||||||
|
"password": "admin"
|
||||||
|
}
|
||||||
|
]
|
||||||
15
electron-builder.yml
Normal file
15
electron-builder.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
appId: kr.tkrmagid.musicquiz.installer
|
||||||
|
productName: MusicQuizInstaller
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
files:
|
||||||
|
- dist/installer/**
|
||||||
|
- installer/**
|
||||||
|
- package.json
|
||||||
|
win:
|
||||||
|
target: nsis
|
||||||
|
artifactName: ${productName}-${version}-Setup.${ext}
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
perMachine: false
|
||||||
0
file/.gitkeep
Normal file
0
file/.gitkeep
Normal file
29
installer/index.html
Normal file
29
installer/index.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>마인크래프트 음악퀴즈 간편설치기</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="appHeader">
|
||||||
|
<h1>마인크래프트 음악퀴즈 간편설치기</h1>
|
||||||
|
<ol class="stepIndicator" id="stepIndicator">
|
||||||
|
<li data-step="1">1. 음악퀴즈</li>
|
||||||
|
<li data-step="2">2. 모드</li>
|
||||||
|
<li data-step="3">3. 서버</li>
|
||||||
|
<li data-step="4">4. 클라이언트</li>
|
||||||
|
<li data-step="5">5. 완료</li>
|
||||||
|
</ol>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="pageHost"></main>
|
||||||
|
|
||||||
|
<aside class="logViewer" id="logViewer" hidden>
|
||||||
|
<header><h2>설치 로그</h2><button type="button" id="logToggle">접기</button></header>
|
||||||
|
<pre id="logBody"></pre>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<script src="./renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
513
installer/renderer.js
Normal file
513
installer/renderer.js
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const installerApi = window.installer
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
packs: [],
|
||||||
|
selectedPackKey: null,
|
||||||
|
mode: null, // 'single' | 'multi'
|
||||||
|
serverInstall: {
|
||||||
|
path: '',
|
||||||
|
jdk: '',
|
||||||
|
eulaAccepted: false,
|
||||||
|
ram: null,
|
||||||
|
portStatus: null
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
installPlatform: true
|
||||||
|
},
|
||||||
|
finishToggles: {
|
||||||
|
desktopShortcut: true,
|
||||||
|
startServer: true,
|
||||||
|
startLauncher: true
|
||||||
|
},
|
||||||
|
stepDone: { 1: false, 2: false, 3: false, 4: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageHost = document.getElementById('pageHost')
|
||||||
|
const stepIndicator = document.getElementById('stepIndicator')
|
||||||
|
const logViewer = document.getElementById('logViewer')
|
||||||
|
const logBody = document.getElementById('logBody')
|
||||||
|
const logToggle = document.getElementById('logToggle')
|
||||||
|
|
||||||
|
logToggle.addEventListener('click', function () {
|
||||||
|
logViewer.classList.toggle('collapsed')
|
||||||
|
if (logViewer.classList.contains('collapsed')) {
|
||||||
|
logViewer.style.height = '36px'
|
||||||
|
logToggle.textContent = '펼치기'
|
||||||
|
} else {
|
||||||
|
logViewer.style.height = ''
|
||||||
|
logToggle.textContent = '접기'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
installerApi.onLog(function (line) {
|
||||||
|
logViewer.hidden = false
|
||||||
|
logBody.textContent += line + '\n'
|
||||||
|
logBody.scrollTop = logBody.scrollHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
function setActiveStep(step) {
|
||||||
|
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
||||||
|
var index = Number(item.getAttribute('data-step'))
|
||||||
|
item.classList.remove('active', 'done')
|
||||||
|
if (index < step) item.classList.add('done')
|
||||||
|
if (index === step) item.classList.add('active')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPage() {
|
||||||
|
pageHost.innerHTML = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep1() {
|
||||||
|
setActiveStep(1)
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>1단계. 설치할 음악퀴즈 선택</h2>' +
|
||||||
|
'<p>관리 사이트의 manifest.json에서 음악퀴즈 목록을 가져옵니다.</p>' +
|
||||||
|
'<div class="fieldset"><label>manifest URL <input id="manifestUrl" type="url" value="' +
|
||||||
|
(state.manifestUrl || 'http://127.0.0.1:3000/manifest.json') + '" /></label>' +
|
||||||
|
'<button class="secondaryBtn" id="reload">목록 새로고침</button></div>' +
|
||||||
|
'<div id="packList" class="cardChoice"></div>' +
|
||||||
|
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
var listEl = section.querySelector('#packList')
|
||||||
|
var nextBtn = section.querySelector('#next')
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
listEl.innerHTML = ''
|
||||||
|
if (state.packs.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="formMessage">아직 음악퀴즈가 없습니다. "목록 새로고침"을 눌러 주세요.</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.packs.forEach(function (pack) {
|
||||||
|
var btn = document.createElement('button')
|
||||||
|
btn.type = 'button'
|
||||||
|
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>마인크래프트 ' + pack.pack.mcVersion + ' / ' + pack.pack.platform.type + '</small>'
|
||||||
|
if (state.selectedPackKey === pack.key) btn.classList.add('selected')
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
state.selectedPackKey = pack.key
|
||||||
|
nextBtn.disabled = false
|
||||||
|
renderList()
|
||||||
|
})
|
||||||
|
listEl.appendChild(btn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
section.querySelector('#reload').addEventListener('click', async function () {
|
||||||
|
var manifestUrl = section.querySelector('#manifestUrl').value
|
||||||
|
state.manifestUrl = manifestUrl
|
||||||
|
try {
|
||||||
|
var packs = await installerApi.loadPacks(manifestUrl)
|
||||||
|
state.packs = packs
|
||||||
|
renderList()
|
||||||
|
} catch (err) {
|
||||||
|
alert('manifest 다운로드 실패: ' + err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nextBtn.addEventListener('click', async function () {
|
||||||
|
if (!state.selectedPackKey) return
|
||||||
|
await installerApi.setSelectedPack(state.selectedPackKey)
|
||||||
|
state.stepDone[1] = true
|
||||||
|
renderStep2()
|
||||||
|
})
|
||||||
|
|
||||||
|
renderList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep2() {
|
||||||
|
setActiveStep(2)
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>2단계. 싱글 / 멀티 선택</h2>' +
|
||||||
|
'<div class="cardChoice">' +
|
||||||
|
'<button id="single" type="button"><strong>싱글</strong><br><small>혼자 즐기는 모드. 4단계만 진행합니다.</small></button>' +
|
||||||
|
'<button id="multi" type="button"><strong>멀티</strong><br><small>친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다.</small></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><span></span></div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#single').addEventListener('click', function () {
|
||||||
|
state.mode = 'single'
|
||||||
|
state.stepDone[2] = true
|
||||||
|
renderStep4()
|
||||||
|
})
|
||||||
|
section.querySelector('#multi').addEventListener('click', function () {
|
||||||
|
state.mode = 'multi'
|
||||||
|
state.stepDone[2] = true
|
||||||
|
renderStep3()
|
||||||
|
})
|
||||||
|
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep3() {
|
||||||
|
setActiveStep(3)
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>3단계. 서버 관련 설정</h2>' +
|
||||||
|
'<div id="sub31" class="subStep"></div>' +
|
||||||
|
'<div id="sub32" class="subStep" hidden></div>' +
|
||||||
|
'<div id="sub33" class="subStep" hidden></div>' +
|
||||||
|
'<div id="sub34" class="subStep" hidden></div>' +
|
||||||
|
'<div id="sub35" class="subStep" hidden></div>' +
|
||||||
|
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="proceedClient" hidden>4단계로 진행</button></div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#back').addEventListener('click', renderStep2)
|
||||||
|
|
||||||
|
renderSubStep31(section.querySelector('#sub31'), function () {
|
||||||
|
section.querySelector('#sub32').hidden = false
|
||||||
|
renderSubStep32(section.querySelector('#sub32'), function () {
|
||||||
|
section.querySelector('#sub33').hidden = false
|
||||||
|
renderSubStep33(section.querySelector('#sub33'), function () {
|
||||||
|
section.querySelector('#sub34').hidden = false
|
||||||
|
renderSubStep34(section.querySelector('#sub34'), function () {
|
||||||
|
section.querySelector('#sub35').hidden = false
|
||||||
|
renderSubStep35(section.querySelector('#sub35'), function () {
|
||||||
|
section.querySelector('#proceedClient').hidden = false
|
||||||
|
section.querySelector('#proceedClient').addEventListener('click', function () {
|
||||||
|
state.stepDone[3] = true
|
||||||
|
renderStep4()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubStep31(host, done) {
|
||||||
|
host.innerHTML =
|
||||||
|
'<h3>3-1. 서버 설치 경로</h3>' +
|
||||||
|
'<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' +
|
||||||
|
'<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' +
|
||||||
|
'<button class="secondaryBtn" id="pickFolder">폴더 선택</button></div>' +
|
||||||
|
'<div class="formMessage" id="msg"></div>' +
|
||||||
|
'<button class="primaryBtn" id="confirm">확인</button>'
|
||||||
|
var input = host.querySelector('#installPath')
|
||||||
|
var msg = host.querySelector('#msg')
|
||||||
|
host.querySelector('#pickFolder').addEventListener('click', async function () {
|
||||||
|
var picked = await installerApi.pickFolder()
|
||||||
|
if (picked) input.value = picked
|
||||||
|
})
|
||||||
|
host.querySelector('#confirm').addEventListener('click', async function () {
|
||||||
|
var result = await installerApi.validateInstallPath(input.value.trim())
|
||||||
|
if (!result.ok) {
|
||||||
|
msg.textContent = result.message || '경로가 유효하지 않습니다.'
|
||||||
|
msg.classList.add('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.textContent = '경로 확정: ' + result.message
|
||||||
|
msg.classList.remove('error')
|
||||||
|
msg.classList.add('success')
|
||||||
|
state.serverInstall.path = input.value.trim()
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubStep32(host, done) {
|
||||||
|
host.innerHTML =
|
||||||
|
'<h3>3-2. JDK 확인</h3>' +
|
||||||
|
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.</p>' +
|
||||||
|
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
|
||||||
|
'<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' +
|
||||||
|
'<button class="secondaryBtn" id="auto">자동 탐색</button></div>' +
|
||||||
|
'<div class="formMessage" id="msg"></div>' +
|
||||||
|
'<button class="primaryBtn" id="confirm">확인</button>'
|
||||||
|
var input = host.querySelector('#jdkPath')
|
||||||
|
var msg = host.querySelector('#msg')
|
||||||
|
host.querySelector('#auto').addEventListener('click', async function () {
|
||||||
|
var detect = await installerApi.detectJdk()
|
||||||
|
if (detect.found) {
|
||||||
|
input.value = detect.path
|
||||||
|
msg.textContent = 'JDK 발견: ' + detect.path
|
||||||
|
msg.classList.remove('error')
|
||||||
|
msg.classList.add('success')
|
||||||
|
} else {
|
||||||
|
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. 직접 선택해 주세요.'
|
||||||
|
msg.classList.add('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
host.querySelector('#pickJdk').addEventListener('click', async function () {
|
||||||
|
var picked = await installerApi.pickFolder()
|
||||||
|
if (picked) input.value = picked
|
||||||
|
})
|
||||||
|
host.querySelector('#confirm').addEventListener('click', function () {
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
msg.textContent = 'JDK 경로를 입력해 주세요.'
|
||||||
|
msg.classList.add('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.serverInstall.jdk = input.value.trim()
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
;(async function () {
|
||||||
|
var detect = await installerApi.detectJdk()
|
||||||
|
if (detect.found && !input.value) {
|
||||||
|
input.value = detect.path
|
||||||
|
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
|
||||||
|
msg.classList.add('success')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubStep33(host, done) {
|
||||||
|
host.innerHTML =
|
||||||
|
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
|
||||||
|
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
|
||||||
|
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
|
||||||
|
'<button class="primaryBtn" id="startDownload">다운로드 시작</button>' +
|
||||||
|
'<div id="eulaSection" hidden style="margin-top:14px;">' +
|
||||||
|
'<h3>3-3-3. EULA 동의</h3>' +
|
||||||
|
'<div class="eulaBox">Minecraft EULA: 본 설치는 Minecraft End User License Agreement (https://www.minecraft.net/ko-kr/eula) 동의가 필요합니다. 동의 시 eula.txt가 새로 작성됩니다.</div>' +
|
||||||
|
'<label class="toggleRow"><input id="eulaCheck" type="checkbox" /> Minecraft EULA에 동의합니다.</label>' +
|
||||||
|
'<div class="formMessage" id="eulaMsg"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="ramSection" hidden style="margin-top:14px;">' +
|
||||||
|
'<h3>3-3-4. 램 검사</h3>' +
|
||||||
|
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="actionRow"><span></span><button class="primaryBtn" id="confirm" hidden>다음</button></div>'
|
||||||
|
|
||||||
|
var startBtn = host.querySelector('#startDownload')
|
||||||
|
var statusEl = host.querySelector('#downloadStatus')
|
||||||
|
var eulaSection = host.querySelector('#eulaSection')
|
||||||
|
var ramSection = host.querySelector('#ramSection')
|
||||||
|
var ramMsg = host.querySelector('#ramMsg')
|
||||||
|
var confirmBtn = host.querySelector('#confirm')
|
||||||
|
var eulaCheck = host.querySelector('#eulaCheck')
|
||||||
|
var eulaMsg = host.querySelector('#eulaMsg')
|
||||||
|
|
||||||
|
startBtn.addEventListener('click', async function () {
|
||||||
|
startBtn.disabled = true
|
||||||
|
statusEl.textContent = '다운로드 중...'
|
||||||
|
try {
|
||||||
|
await installerApi.startServerInstall({
|
||||||
|
packKey: state.selectedPackKey,
|
||||||
|
installPath: state.serverInstall.path,
|
||||||
|
jdkPath: state.serverInstall.jdk
|
||||||
|
})
|
||||||
|
statusEl.textContent = '다운로드 완료. EULA 동의가 필요합니다.'
|
||||||
|
eulaSection.hidden = false
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = '다운로드 실패: ' + err.message
|
||||||
|
startBtn.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eulaCheck.addEventListener('change', async function () {
|
||||||
|
if (!eulaCheck.checked) return
|
||||||
|
try {
|
||||||
|
await installerApi.acceptEula(state.serverInstall.path)
|
||||||
|
eulaMsg.textContent = 'EULA 동의 저장됨.'
|
||||||
|
eulaMsg.classList.add('success')
|
||||||
|
ramSection.hidden = false
|
||||||
|
var result = await installerApi.checkRam(state.selectedPackKey)
|
||||||
|
state.serverInstall.ram = result
|
||||||
|
if (result.decision === 'tooLow') {
|
||||||
|
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + (state.packs.find(function (p) { return p.key === state.selectedPackKey }).pack.serverMinRam) + 'MB)에 미치지 못합니다. 설치를 중단합니다.'
|
||||||
|
ramMsg.classList.add('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.decision === 'minOk') {
|
||||||
|
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
|
||||||
|
ramMsg.classList.add('warn')
|
||||||
|
} else {
|
||||||
|
ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
|
||||||
|
ramMsg.classList.add('success')
|
||||||
|
}
|
||||||
|
confirmBtn.hidden = false
|
||||||
|
} catch (err) {
|
||||||
|
eulaMsg.textContent = 'EULA 저장 실패: ' + err.message
|
||||||
|
eulaMsg.classList.add('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', function () {
|
||||||
|
state.serverInstall.eulaAccepted = true
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubStep34(host, done) {
|
||||||
|
host.innerHTML =
|
||||||
|
'<h3>3-4. 서버 설정 편집</h3>' +
|
||||||
|
'<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' +
|
||||||
|
'<button class="secondaryBtn" id="open">편집기 열기</button>' +
|
||||||
|
'<div class="formMessage" id="editorMsg"></div>' +
|
||||||
|
'<button class="primaryBtn" id="confirm">확인</button>'
|
||||||
|
host.querySelector('#open').addEventListener('click', async function () {
|
||||||
|
var msg = host.querySelector('#editorMsg')
|
||||||
|
try {
|
||||||
|
var result = await installerApi.startServerConfigEditor(state.serverInstall.path)
|
||||||
|
msg.innerHTML = '편집기 주소: <a href="' + result.url + '" target="_blank">' + result.url + '</a>'
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = '편집기 실행 실패: ' + err.message
|
||||||
|
msg.classList.add('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
host.querySelector('#confirm').addEventListener('click', done)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubStep35(host, done) {
|
||||||
|
host.innerHTML =
|
||||||
|
'<h3>3-5. 포트포워딩 점검</h3>' +
|
||||||
|
'<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' +
|
||||||
|
'<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' +
|
||||||
|
'<button class="secondaryBtn" id="run">검사 시작</button>' +
|
||||||
|
'<div class="formMessage" id="resultMsg"></div>' +
|
||||||
|
'<button class="primaryBtn" id="confirm" hidden>확인</button>'
|
||||||
|
var resultMsg = host.querySelector('#resultMsg')
|
||||||
|
var confirmBtn = host.querySelector('#confirm')
|
||||||
|
host.querySelector('#run').addEventListener('click', async function () {
|
||||||
|
var port = Number(host.querySelector('#port').value) || 25565
|
||||||
|
resultMsg.textContent = '확인 중...'
|
||||||
|
var result = await installerApi.checkPortForward(port)
|
||||||
|
state.serverInstall.portStatus = result
|
||||||
|
if (result.status === 'preForwarded') {
|
||||||
|
resultMsg.innerHTML = '이미 외부 접속 가능: ' + result.externalIp + ':' + result.port
|
||||||
|
resultMsg.classList.add('success')
|
||||||
|
} else if (result.status === 'upnpOk') {
|
||||||
|
resultMsg.innerHTML = 'UPnP로 자동 개방 완료: ' + result.externalIp + ':' + result.port
|
||||||
|
resultMsg.classList.add('success')
|
||||||
|
} else {
|
||||||
|
resultMsg.innerHTML = (result.message || '직접 포트포워딩을 해주세요.') +
|
||||||
|
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
|
||||||
|
resultMsg.classList.add('warn')
|
||||||
|
}
|
||||||
|
confirmBtn.hidden = false
|
||||||
|
})
|
||||||
|
confirmBtn.addEventListener('click', done)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep4() {
|
||||||
|
setActiveStep(4)
|
||||||
|
clearPage()
|
||||||
|
var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>4단계. 유저 클라이언트 설정</h2>' +
|
||||||
|
'<div class="subStep" id="sub41"></div>' +
|
||||||
|
'<div class="subStep" id="sub42" hidden></div>' +
|
||||||
|
'<div class="subStep" id="sub43" hidden></div>' +
|
||||||
|
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" hidden>5단계로</button></div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#back').addEventListener('click', function () {
|
||||||
|
if (state.mode === 'multi') renderStep3(); else renderStep2()
|
||||||
|
})
|
||||||
|
|
||||||
|
renderSubStep41(section.querySelector('#sub41'), pack, function () {
|
||||||
|
section.querySelector('#sub42').hidden = false
|
||||||
|
renderSubStep42(section.querySelector('#sub42'), function () {
|
||||||
|
section.querySelector('#sub43').hidden = false
|
||||||
|
renderSubStep43(section.querySelector('#sub43'), function () {
|
||||||
|
var nextBtn = section.querySelector('#next')
|
||||||
|
nextBtn.hidden = false
|
||||||
|
nextBtn.addEventListener('click', function () {
|
||||||
|
state.stepDone[4] = true
|
||||||
|
renderStep5()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubStep41(host, pack, done) {
|
||||||
|
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||||
|
host.innerHTML =
|
||||||
|
'<h3>4-1. 모드 플랫폼</h3>' +
|
||||||
|
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>' + platformType + '</strong></p>' +
|
||||||
|
(platformType === 'vanilla'
|
||||||
|
? '<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p><button class="primaryBtn" id="next">다음</button>'
|
||||||
|
: '<div class="actionRow"><button class="primaryBtn" id="install">설치</button><button class="secondaryBtn" id="skip">건너뛰기</button></div>')
|
||||||
|
if (platformType === 'vanilla') {
|
||||||
|
state.client.installPlatform = false
|
||||||
|
host.querySelector('#next').addEventListener('click', done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host.querySelector('#install').addEventListener('click', function () {
|
||||||
|
state.client.installPlatform = true
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
host.querySelector('#skip').addEventListener('click', function () {
|
||||||
|
state.client.installPlatform = false
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubStep42(host, done) {
|
||||||
|
host.innerHTML =
|
||||||
|
'<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' +
|
||||||
|
'<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' +
|
||||||
|
'<button class="primaryBtn" id="run">설치 시작</button>' +
|
||||||
|
'<div class="formMessage" id="msg"></div>'
|
||||||
|
host.querySelector('#run').addEventListener('click', async function () {
|
||||||
|
var msg = host.querySelector('#msg')
|
||||||
|
msg.textContent = '설치 중...'
|
||||||
|
msg.classList.remove('error', 'success')
|
||||||
|
try {
|
||||||
|
await installerApi.installClient({
|
||||||
|
packKey: state.selectedPackKey,
|
||||||
|
installPlatform: !!state.client.installPlatform
|
||||||
|
})
|
||||||
|
msg.textContent = '클라이언트 설치 완료.'
|
||||||
|
msg.classList.add('success')
|
||||||
|
done()
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = '설치 실패: ' + err.message
|
||||||
|
msg.classList.add('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubStep43(host, done) {
|
||||||
|
host.innerHTML = '<h3>4-3. 완료 확인</h3><p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p><button class="primaryBtn" id="confirm">5단계로</button>'
|
||||||
|
host.querySelector('#confirm').addEventListener('click', done)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep5() {
|
||||||
|
setActiveStep(5)
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
var multi = state.mode === 'multi'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>5단계. 설치 완료</h2>' +
|
||||||
|
'<p>모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.</p>' +
|
||||||
|
(multi ? '<div class="subStep">' +
|
||||||
|
'<h3>서버</h3>' +
|
||||||
|
'<button class="secondaryBtn" id="openFolder">서버 폴더 열기</button>' +
|
||||||
|
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> 바탕화면에 서버 실행 바로가기 만들기</label>' +
|
||||||
|
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> 서버 바로 실행</label>' +
|
||||||
|
'</div>' : '') +
|
||||||
|
'<div class="subStep">' +
|
||||||
|
'<h3>마인크래프트 런처</h3>' +
|
||||||
|
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> 마인크래프트 런처 실행</label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="finish">완료</button></div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#back').addEventListener('click', renderStep4)
|
||||||
|
if (multi) {
|
||||||
|
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||||
|
installerApi.openServerFolder()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
section.querySelector('#finish').addEventListener('click', async function () {
|
||||||
|
if (multi) {
|
||||||
|
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
|
||||||
|
if (section.querySelector('#startServer').checked) await installerApi.startServer()
|
||||||
|
}
|
||||||
|
if (section.querySelector('#startLauncher').checked) await installerApi.startMinecraftLauncher()
|
||||||
|
section.querySelector('#finish').disabled = true
|
||||||
|
section.querySelector('#finish').textContent = '완료됨'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStep1()
|
||||||
162
installer/styles.css
Normal file
162
installer/styles.css
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-alt: #161b22;
|
||||||
|
--bg-card: #1f242c;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--accent: #2f81f7;
|
||||||
|
--danger: #f85149;
|
||||||
|
--success: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Pretendard', -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--bg-alt);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.appHeader h1 { margin: 0; font-size: 18px; }
|
||||||
|
|
||||||
|
.stepIndicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepIndicator li {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepIndicator li.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(47, 129, 247, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepIndicator li.done {
|
||||||
|
border-color: var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 28px 32px 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page { max-width: 720px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.page h2 { margin: 0 0 16px; }
|
||||||
|
|
||||||
|
.page p { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.cardChoice {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardChoice button {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardChoice button:hover { border-color: var(--accent); }
|
||||||
|
.cardChoice button.selected { border-color: var(--accent); background: rgba(47, 129, 247, 0.15); }
|
||||||
|
|
||||||
|
.actionRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 24px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn,
|
||||||
|
.secondaryBtn,
|
||||||
|
.dangerBtn {
|
||||||
|
font: inherit;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn { background: var(--accent); color: white; }
|
||||||
|
.primaryBtn:disabled { background: #2c3849; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.secondaryBtn { background: var(--bg-card); border-color: var(--border); color: var(--text); }
|
||||||
|
.secondaryBtn:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.dangerBtn { background: var(--danger); color: white; }
|
||||||
|
|
||||||
|
.fieldset { display: flex; flex-direction: column; gap: 8px; margin: 16px 0; }
|
||||||
|
.fieldset label { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.fieldset input[type="text"], .fieldset input[type="url"] { flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 8px 10px; border-radius: 6px; }
|
||||||
|
|
||||||
|
.formMessage { font-size: 13px; color: var(--text-muted); margin-top: 6px; }
|
||||||
|
.formMessage.error { color: var(--danger); }
|
||||||
|
.formMessage.success { color: var(--success); }
|
||||||
|
|
||||||
|
.subStep { padding: 14px 16px; border: 1px solid var(--border); border-radius: 12px; margin-bottom: 12px; background: var(--bg-card); }
|
||||||
|
.subStep h3 { margin: 0 0 8px; font-size: 16px; }
|
||||||
|
|
||||||
|
.logViewer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 200px;
|
||||||
|
background: #0a0d11;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logViewer header { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: var(--bg-alt); }
|
||||||
|
.logViewer header h2 { margin: 0; font-size: 13px; }
|
||||||
|
.logViewer header button { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 4px 8px; cursor: pointer; }
|
||||||
|
.logViewer pre { margin: 0; padding: 8px 12px; overflow-y: auto; font-family: 'Consolas', monospace; font-size: 12px; }
|
||||||
|
|
||||||
|
.eulaBox { background: var(--bg-card); border: 1px solid var(--border); padding: 16px; border-radius: 10px; max-height: 200px; overflow-y: auto; font-size: 12px; line-height: 1.6; }
|
||||||
|
|
||||||
|
.toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
|
||||||
|
|
||||||
|
.statusBadge { display: inline-flex; padding: 3px 10px; border-radius: 999px; font-size: 12px; }
|
||||||
|
.statusBadge.pending { background: #2c3849; color: var(--text-muted); }
|
||||||
|
.statusBadge.ok { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
||||||
|
.statusBadge.warn { background: rgba(248, 197, 49, 0.2); color: #f0c244; }
|
||||||
|
.statusBadge.fail { background: rgba(248, 81, 73, 0.2); color: var(--danger); }
|
||||||
8
manifest.json
Normal file
8
manifest.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"packs": [
|
||||||
|
{
|
||||||
|
"name": "음악퀴즈 v1",
|
||||||
|
"file": "music-quiz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
25
manifest/music-quiz.json
Normal file
25
manifest/music-quiz.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "음악퀴즈 v1",
|
||||||
|
"mcVersion": "1.20.1",
|
||||||
|
"platform": {
|
||||||
|
"type": "forge",
|
||||||
|
"downloadUrl": "https://example.com/forge-installer.jar"
|
||||||
|
},
|
||||||
|
"mods": [
|
||||||
|
{
|
||||||
|
"name": "ExampleMod",
|
||||||
|
"downloadUrl": "https://example.com/examplemod.jar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resourcepacks": [
|
||||||
|
{
|
||||||
|
"name": "ExampleResourcePack",
|
||||||
|
"downloadUrl": "https://example.com/resourcepack.zip"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serverMinRam": 2048,
|
||||||
|
"serverMaxRam": 8192,
|
||||||
|
"clientMinRam": 4096,
|
||||||
|
"clientRecommendedRam": 8192,
|
||||||
|
"packPath": "music-quiz/files"
|
||||||
|
}
|
||||||
4983
package-lock.json
generated
Normal file
4983
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "minecraft-music-quiz-installer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||||
|
"main": "dist/installer/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||||
|
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||||
|
"installer": "tsc -p tsconfig.installer.json && electron .",
|
||||||
|
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nat-upnp": "^1.1.1",
|
||||||
|
"extract-zip": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"@types/node": "^22.5.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/express-session": "^1.18.0",
|
||||||
|
"@types/ejs": "^3.1.5",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"electron": "^31.4.0",
|
||||||
|
"electron-builder": "^24.13.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
359
public/styles.css
Normal file
359
public/styles.css
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-alt: #161b22;
|
||||||
|
--bg-card: #1f242c;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--accent: #2f81f7;
|
||||||
|
--accent-hover: #1f6feb;
|
||||||
|
--danger: #f85149;
|
||||||
|
--success: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body.siteBody {
|
||||||
|
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.siteBody.centerLayout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageWrap {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topNav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: var(--bg-alt);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBrand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLogo { font-size: 22px; }
|
||||||
|
.navTitle { font-size: 16px; }
|
||||||
|
|
||||||
|
.navUser { position: relative; }
|
||||||
|
|
||||||
|
.navUserButton {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navUserButton:hover { background: var(--bg-card); }
|
||||||
|
|
||||||
|
.navUserMenu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow: 0 12px 24px rgba(0,0,0,0.4);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navUserMenu form { margin: 0; }
|
||||||
|
|
||||||
|
.dangerLink {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dangerLink:hover { background: rgba(248, 81, 73, 0.1); border-radius: 6px; }
|
||||||
|
|
||||||
|
.hero h1 { margin: 0 0 8px; font-size: 30px; }
|
||||||
|
.hero p { color: var(--text-muted); margin: 0 0 32px; }
|
||||||
|
|
||||||
|
.muted { color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.cardRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontalScroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packCard {
|
||||||
|
flex: 0 0 280px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packCard h2 { margin: 0; font-size: 18px; }
|
||||||
|
|
||||||
|
.metaList {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardLink {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardLink[data-disabled="true"] { pointer-events: none; opacity: 0.85; }
|
||||||
|
|
||||||
|
.cardCheckbox {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboardHeader h1 { margin: 0; font-size: 24px; }
|
||||||
|
|
||||||
|
.dashboardActions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.inlineForm { margin: 0; }
|
||||||
|
|
||||||
|
.deleteConfirmRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
|
.secondaryButton {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryButton:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.dangerButton {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dangerButton:hover { background: #d73a48; }
|
||||||
|
|
||||||
|
.ghostLink {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghostLink:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.editorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorHeader h1 { margin: 0; font-size: 24px; }
|
||||||
|
|
||||||
|
.editorForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridTwo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridTwo > .fullSpan { grid-column: span 2; }
|
||||||
|
|
||||||
|
.editorForm label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorForm input,
|
||||||
|
.editorForm select,
|
||||||
|
.editorForm textarea {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorForm input:focus,
|
||||||
|
.editorForm select:focus,
|
||||||
|
.editorForm textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamicListFieldset {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamicListFieldset legend { padding: 0 8px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.dynamicList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamicRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginCard {
|
||||||
|
width: 360px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginCard h1 { margin: 0 0 16px; font-size: 22px; }
|
||||||
|
|
||||||
|
.loginForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginForm label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginForm input {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBanner {
|
||||||
|
background: rgba(248, 81, 73, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid rgba(248, 81, 73, 0.4);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
592
src/installer/main.ts
Normal file
592
src/installer/main.ts
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||||
|
import http from 'node:http'
|
||||||
|
import https from 'node:https'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import fsp from 'node:fs/promises'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { URL } from 'node:url'
|
||||||
|
import natUpnp from 'nat-upnp'
|
||||||
|
import type {
|
||||||
|
ClientInstallPayload,
|
||||||
|
FetchedPack,
|
||||||
|
PortForwardResult,
|
||||||
|
RamCheckResult,
|
||||||
|
ServerInstallPayload
|
||||||
|
} from './types'
|
||||||
|
import type { Manifest, PackDefinition } from '../shared/types'
|
||||||
|
import { normalizePackDefinition } from '../shared/store'
|
||||||
|
|
||||||
|
interface InstallerState {
|
||||||
|
manifestUrl: string
|
||||||
|
baseUrl: string
|
||||||
|
packs: Map<string, FetchedPack>
|
||||||
|
selectedKey: string | null
|
||||||
|
installPath: string | null
|
||||||
|
configEditorServer: http.Server | null
|
||||||
|
configEditorPort: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MANIFEST_URL = process.env.MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
|
||||||
|
|
||||||
|
const state: InstallerState = {
|
||||||
|
manifestUrl: DEFAULT_MANIFEST_URL,
|
||||||
|
baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
|
||||||
|
packs: new Map(),
|
||||||
|
selectedKey: null,
|
||||||
|
installPath: null,
|
||||||
|
configEditorServer: null,
|
||||||
|
configEditorPort: null
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
function deriveBaseUrl(manifestUrl: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(manifestUrl)
|
||||||
|
return `${parsed.protocol}//${parsed.host}`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMainWindow(): void {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 980,
|
||||||
|
height: 720,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mainWindow.removeMenu()
|
||||||
|
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer', 'index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendLog(line: string): void {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||||
|
const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}`
|
||||||
|
mainWindow.webContents.send('log', stamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchBuffer(url: string): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const target = new URL(url)
|
||||||
|
const transport = target.protocol === 'https:' ? https : http
|
||||||
|
const request = transport.get(target, { timeout: 30000 }, (response) => {
|
||||||
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||||
|
const redirect = response.headers.location
|
||||||
|
if (redirect) {
|
||||||
|
response.resume()
|
||||||
|
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((response.statusCode ?? 0) >= 400) {
|
||||||
|
response.resume()
|
||||||
|
reject(new Error(`HTTP ${response.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
response.on('end', () => resolve(Buffer.concat(chunks)))
|
||||||
|
})
|
||||||
|
request.on('error', reject)
|
||||||
|
request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const buffer = await fetchBuffer(url)
|
||||||
|
return JSON.parse(buffer.toString('utf8')) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(url: string, target: string): Promise<void> {
|
||||||
|
await fsp.mkdir(path.dirname(target), { recursive: true })
|
||||||
|
const buffer = await fetchBuffer(url)
|
||||||
|
await fsp.writeFile(target, buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsHangul(text: string): boolean {
|
||||||
|
return /[\u3131-\u318E\uAC00-\uD7A3\u1100-\u11FF]/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<FetchedPack[]> => {
|
||||||
|
if (typeof manifestUrlInput === 'string' && manifestUrlInput.length > 0) {
|
||||||
|
state.manifestUrl = manifestUrlInput
|
||||||
|
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
||||||
|
}
|
||||||
|
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
|
||||||
|
const manifest = await fetchJson<Manifest>(state.manifestUrl)
|
||||||
|
const results: FetchedPack[] = []
|
||||||
|
for (const entry of manifest.packs ?? []) {
|
||||||
|
if (typeof entry?.file !== 'string') continue
|
||||||
|
const packUrl = `${state.baseUrl}/manifest.json`.replace(/manifest\.json$/, `manifest/${entry.file}.json`)
|
||||||
|
try {
|
||||||
|
const raw = await fetchJson<Partial<PackDefinition>>(packUrl)
|
||||||
|
const pack = normalizePackDefinition(raw)
|
||||||
|
results.push({ key: entry.file, name: entry.name || pack.name, pack })
|
||||||
|
} catch (error) {
|
||||||
|
sendLog(`pack 로드 실패 (${entry.file}): ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.packs.clear()
|
||||||
|
for (const item of results) state.packs.set(item.key, item)
|
||||||
|
sendLog(`로드된 음악퀴즈: ${results.length}개`)
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
||||||
|
if (!state.packs.has(packKey)) {
|
||||||
|
throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
}
|
||||||
|
state.selectedKey = packKey
|
||||||
|
sendLog(`선택: ${packKey}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:pickFolder', async (): Promise<string | null> => {
|
||||||
|
if (!mainWindow) return null
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openDirectory', 'createDirectory']
|
||||||
|
})
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null
|
||||||
|
return result.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('install:validatePath', async (_event, target: string) => {
|
||||||
|
if (!target || target.trim().length === 0) {
|
||||||
|
return { ok: false, message: '서버 설치 경로를 입력해 주세요.' }
|
||||||
|
}
|
||||||
|
if (containsHangul(target)) {
|
||||||
|
return { ok: false, message: '경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.' }
|
||||||
|
}
|
||||||
|
const absolute = path.resolve(target)
|
||||||
|
state.installPath = absolute
|
||||||
|
return { ok: true, message: absolute }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('jdk:detect', async () => {
|
||||||
|
const candidates: string[] = []
|
||||||
|
if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME)
|
||||||
|
if (process.env.JDK_HOME) candidates.push(process.env.JDK_HOME)
|
||||||
|
candidates.push('C:\\Program Files\\Java')
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate) continue
|
||||||
|
try {
|
||||||
|
const stat = await fsp.stat(candidate)
|
||||||
|
if (stat.isFile()) {
|
||||||
|
return { found: true, path: candidate }
|
||||||
|
}
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const javaExe = path.join(candidate, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
|
||||||
|
if (fs.existsSync(javaExe)) {
|
||||||
|
return { found: true, path: candidate }
|
||||||
|
}
|
||||||
|
const entries = await fsp.readdir(candidate)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const child = path.join(candidate, entry)
|
||||||
|
const childJava = path.join(child, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
|
||||||
|
if (fs.existsSync(childJava)) {
|
||||||
|
return { found: true, path: child }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { found: false, path: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function downloadServerFiles(pack: PackDefinition, targetDir: string): Promise<void> {
|
||||||
|
const indexUrl = `${state.baseUrl}/file/${pack.packPath.replace(/^\/+|\/+$/g, '')}`
|
||||||
|
sendLog(`서버 파일 인덱스: ${indexUrl}`)
|
||||||
|
let listing: string[] = []
|
||||||
|
try {
|
||||||
|
const directoryHtml = (await fetchBuffer(indexUrl)).toString('utf8')
|
||||||
|
listing = Array.from(directoryHtml.matchAll(/href=\"([^\"]+)\"/g))
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((href) => !href.startsWith('?') && !href.endsWith('/'))
|
||||||
|
} catch (error) {
|
||||||
|
sendLog(`서버 파일 인덱스 로드 실패: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.length === 0) {
|
||||||
|
sendLog('서버 파일 인덱스를 가져올 수 없습니다. packPath 또는 사이트 디렉토리 인덱스 설정을 확인해 주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fileName of listing) {
|
||||||
|
const targetUrl = `${indexUrl.replace(/\/$/, '')}/${fileName}`
|
||||||
|
const target = path.join(targetDir, decodeURIComponent(fileName))
|
||||||
|
sendLog(`다운로드: ${fileName}`)
|
||||||
|
await downloadFile(targetUrl, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
|
||||||
|
const pack = state.packs.get(payload.packKey)
|
||||||
|
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
if (containsHangul(payload.installPath)) {
|
||||||
|
throw new Error('경로에 한글이 포함되면 안 됩니다.')
|
||||||
|
}
|
||||||
|
const installPath = path.resolve(payload.installPath)
|
||||||
|
state.installPath = installPath
|
||||||
|
await fsp.mkdir(installPath, { recursive: true })
|
||||||
|
sendLog(`서버 설치 경로: ${installPath}`)
|
||||||
|
|
||||||
|
await downloadServerFiles(pack.pack, installPath)
|
||||||
|
|
||||||
|
const eulaPath = path.join(installPath, 'eula.txt')
|
||||||
|
if (fs.existsSync(eulaPath)) {
|
||||||
|
await fsp.unlink(eulaPath)
|
||||||
|
sendLog('기존 eula.txt 삭제, 사용자 동의를 다시 받습니다.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('server:acceptEula', async (_event, installPath: string) => {
|
||||||
|
const target = path.join(installPath, 'eula.txt')
|
||||||
|
await fsp.writeFile(target, `# Generated by music quiz installer\neula=true\n`, 'utf8')
|
||||||
|
sendLog('EULA 동의 저장 완료.')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('server:checkRam', async (_event, packKey: string): Promise<RamCheckResult> => {
|
||||||
|
const pack = state.packs.get(packKey)
|
||||||
|
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
const systemRamMb = Math.floor(os.totalmem() / (1024 * 1024))
|
||||||
|
if (systemRamMb >= pack.pack.serverMaxRam) {
|
||||||
|
return { systemRamMb, decision: 'maxOk', appliedRamMb: pack.pack.serverMaxRam }
|
||||||
|
}
|
||||||
|
if (systemRamMb >= pack.pack.serverMinRam) {
|
||||||
|
return { systemRamMb, decision: 'minOk', appliedRamMb: pack.pack.serverMinRam }
|
||||||
|
}
|
||||||
|
return { systemRamMb, decision: 'tooLow', appliedRamMb: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('server:configEditor', async (_event, installPath: string) => {
|
||||||
|
if (state.configEditorServer) {
|
||||||
|
state.configEditorServer.close()
|
||||||
|
state.configEditorServer = null
|
||||||
|
}
|
||||||
|
const port = await pickPort()
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
await handleConfigEditorRequest(installPath, req, res)
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 500
|
||||||
|
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
||||||
|
res.end(`서버 오류: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
|
||||||
|
state.configEditorServer = server
|
||||||
|
state.configEditorPort = port
|
||||||
|
const url = `http://127.0.0.1:${port}/`
|
||||||
|
sendLog(`서버 설정 편집기 실행: ${url}`)
|
||||||
|
await shell.openExternal(url)
|
||||||
|
return { url }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function pickPort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = http.createServer()
|
||||||
|
probe.unref()
|
||||||
|
probe.on('error', reject)
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = probe.address()
|
||||||
|
probe.close(() => {
|
||||||
|
if (address && typeof address === 'object') resolve(address.port)
|
||||||
|
else reject(new Error('포트를 할당할 수 없습니다.'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_CONFIG_FILES = ['server.properties', 'bukkit.yml', 'spigot.yml', 'paper-global.yml']
|
||||||
|
|
||||||
|
async function handleConfigEditorRequest(installPath: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||||
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
||||||
|
if (req.method === 'GET' && url.pathname === '/') {
|
||||||
|
const fileSet = await collectConfigFiles(installPath)
|
||||||
|
res.setHeader('content-type', 'text/html; charset=utf-8')
|
||||||
|
res.end(renderConfigEditorPage(fileSet))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && url.pathname === '/file') {
|
||||||
|
const target = url.searchParams.get('name')
|
||||||
|
if (!target || !SERVER_CONFIG_FILES.includes(target)) {
|
||||||
|
res.statusCode = 400
|
||||||
|
res.end('알 수 없는 파일')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const filePath = path.join(installPath, target)
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
||||||
|
res.end('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const content = await fsp.readFile(filePath, 'utf8')
|
||||||
|
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
||||||
|
res.end(content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && url.pathname === '/save') {
|
||||||
|
const body = await readBody(req)
|
||||||
|
const params = new URLSearchParams(body)
|
||||||
|
const target = params.get('name') ?? ''
|
||||||
|
const content = params.get('content') ?? ''
|
||||||
|
if (!SERVER_CONFIG_FILES.includes(target)) {
|
||||||
|
res.statusCode = 400
|
||||||
|
res.end('알 수 없는 파일')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const filePath = path.join(installPath, target)
|
||||||
|
await fsp.writeFile(filePath, content, 'utf8')
|
||||||
|
res.statusCode = 200
|
||||||
|
res.setHeader('content-type', 'application/json')
|
||||||
|
res.end(JSON.stringify({ ok: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end('Not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectConfigFiles(installPath: string): Promise<string[]> {
|
||||||
|
const result: string[] = []
|
||||||
|
for (const fileName of SERVER_CONFIG_FILES) {
|
||||||
|
const filePath = path.join(installPath, fileName)
|
||||||
|
if (fs.existsSync(filePath)) result.push(fileName)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfigEditorPage(fileSet: string[]): string {
|
||||||
|
const safeList = fileSet.length > 0 ? fileSet : SERVER_CONFIG_FILES.slice(0, 2)
|
||||||
|
const optionMarkup = safeList
|
||||||
|
.map((file, index) => `<option value="${file}" ${index === 0 ? 'selected' : ''}>${file}</option>`)
|
||||||
|
.join('')
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ko"><head><meta charset="utf-8"/><title>서버 설정 편집기</title>
|
||||||
|
<style>body{font-family:sans-serif;background:#0d1117;color:#e6edf3;padding:24px;}select,textarea,button{font:inherit;}textarea{width:100%;height:60vh;background:#161b22;color:#e6edf3;border:1px solid #30363d;padding:12px;border-radius:8px;}button{background:#2f81f7;color:#fff;border:none;padding:10px 16px;border-radius:8px;cursor:pointer;margin-top:12px;}small{color:#8b949e;}</style>
|
||||||
|
</head><body>
|
||||||
|
<h1>서버 설정 편집기</h1>
|
||||||
|
<p><small>아래 파일을 직접 편집한 후 "적용" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.</small></p>
|
||||||
|
<label>대상 파일 <select id="file">${optionMarkup}</select></label>
|
||||||
|
<textarea id="content"></textarea>
|
||||||
|
<button id="save">적용</button>
|
||||||
|
<p id="status"><small></small></p>
|
||||||
|
<script>
|
||||||
|
const file=document.getElementById('file');
|
||||||
|
const content=document.getElementById('content');
|
||||||
|
const status=document.querySelector('#status small');
|
||||||
|
async function load(){const r=await fetch('/file?name='+encodeURIComponent(file.value));content.value=await r.text();}
|
||||||
|
file.addEventListener('change',load);
|
||||||
|
document.getElementById('save').addEventListener('click',async()=>{const body=new URLSearchParams();body.set('name',file.value);body.set('content',content.value);const r=await fetch('/save',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body.toString()});status.textContent=r.ok?'저장 완료':'저장 실패';});
|
||||||
|
load();
|
||||||
|
</script></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortForwardResult> => {
|
||||||
|
const targetPort = Number.isFinite(port) && port > 0 ? port : 25565
|
||||||
|
const externalIp = await detectExternalIp()
|
||||||
|
if (await testPortReachable(externalIp, targetPort)) {
|
||||||
|
sendLog(`외부에서 ${externalIp}:${targetPort} 접근 확인됨. 포트포워딩 됨.`)
|
||||||
|
return { status: 'preForwarded', externalIp, port: targetPort }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await openPortViaUpnp(targetPort)
|
||||||
|
if (await testPortReachable(externalIp, targetPort)) {
|
||||||
|
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료.`)
|
||||||
|
return { status: 'upnpOk', externalIp, port: targetPort }
|
||||||
|
}
|
||||||
|
sendLog('UPnP 개방은 시도했지만 외부 접근이 확인되지 않았습니다.')
|
||||||
|
return { status: 'upnpFailed', externalIp, port: targetPort, message: '직접 포트포워딩을 해주세요.' }
|
||||||
|
} catch (error) {
|
||||||
|
sendLog(`UPnP 시도 실패: ${(error as Error).message}`)
|
||||||
|
return { status: 'upnpFailed', externalIp, port: targetPort, message: '직접 포트포워딩을 해주세요.' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function detectExternalIp(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const buffer = await fetchBuffer('https://api.ipify.org')
|
||||||
|
return buffer.toString('utf8').trim()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPortReachable(host: string, port: number): Promise<boolean> {
|
||||||
|
if (!host) return Promise.resolve(false)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
import('node:net').then((net) => {
|
||||||
|
const socket = net.createConnection({ host, port })
|
||||||
|
socket.setTimeout(3000)
|
||||||
|
socket.once('connect', () => {
|
||||||
|
socket.end()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
socket.once('error', () => resolve(false))
|
||||||
|
socket.once('timeout', () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
}).catch(() => resolve(false))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPortViaUpnp(port: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = natUpnp.createClient()
|
||||||
|
client.portMapping({ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' }, (error) => {
|
||||||
|
client.close()
|
||||||
|
if (error) reject(error)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => {
|
||||||
|
const pack = state.packs.get(payload.packKey)
|
||||||
|
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
const customRoot = path.join(getAppDataDir(), '.mc_custom')
|
||||||
|
await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true })
|
||||||
|
await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true })
|
||||||
|
|
||||||
|
if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) {
|
||||||
|
const cacheDir = path.join(customRoot, 'platform-cache')
|
||||||
|
await fsp.mkdir(cacheDir, { recursive: true })
|
||||||
|
const installerPath = path.join(cacheDir, deriveFileName(pack.pack.platform.downloadUrl) || 'platform-installer.jar')
|
||||||
|
sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${pack.pack.platform.downloadUrl}`)
|
||||||
|
await downloadFile(pack.pack.platform.downloadUrl, installerPath)
|
||||||
|
sendLog(`플랫폼 설치파일 저장: ${installerPath} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)`)
|
||||||
|
} else if (!payload.installPlatform) {
|
||||||
|
sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mod of pack.pack.mods) {
|
||||||
|
if (!mod.downloadUrl) continue
|
||||||
|
const target = path.join(customRoot, 'mods', deriveFileName(mod.downloadUrl) || `${mod.name}.jar`)
|
||||||
|
sendLog(`모드 다운로드: ${mod.name}`)
|
||||||
|
await downloadFile(mod.downloadUrl, target)
|
||||||
|
}
|
||||||
|
for (const resourcePack of pack.pack.resourcepacks) {
|
||||||
|
if (!resourcePack.downloadUrl) continue
|
||||||
|
const target = path.join(customRoot, 'resourcepacks', deriveFileName(resourcePack.downloadUrl) || `${resourcePack.name}.zip`)
|
||||||
|
sendLog(`리소스팩 다운로드: ${resourcePack.name}`)
|
||||||
|
await downloadFile(resourcePack.downloadUrl, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateLauncherProfile(pack.pack, customRoot)
|
||||||
|
})
|
||||||
|
|
||||||
|
function deriveFileName(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const last = parsed.pathname.split('/').filter(Boolean).pop() ?? ''
|
||||||
|
return decodeURIComponent(last)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppDataDir(): string {
|
||||||
|
if (process.platform === 'win32' && process.env.APPDATA) return process.env.APPDATA
|
||||||
|
return app.getPath('appData')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Promise<void> {
|
||||||
|
const launcherPath = path.join(getAppDataDir(), '.minecraft', 'launcher_profiles.json')
|
||||||
|
if (!fs.existsSync(launcherPath)) {
|
||||||
|
sendLog(`launcher_profiles.json을 찾을 수 없습니다: ${launcherPath}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const raw = await fsp.readFile(launcherPath, 'utf8')
|
||||||
|
const json = JSON.parse(raw) as { profiles?: Record<string, Record<string, unknown>> }
|
||||||
|
json.profiles = json.profiles ?? {}
|
||||||
|
const profileKey = pack.name
|
||||||
|
const javaArgs = `-Xmx${pack.serverMaxRam}M -Xms${pack.serverMinRam}M`
|
||||||
|
const lastVersionId = pack.platform.type === 'vanilla'
|
||||||
|
? pack.mcVersion
|
||||||
|
: `${pack.mcVersion}-${pack.platform.type}`
|
||||||
|
json.profiles[profileKey] = {
|
||||||
|
...(json.profiles[profileKey] ?? {}),
|
||||||
|
name: profileKey,
|
||||||
|
type: 'custom',
|
||||||
|
gameDir,
|
||||||
|
lastVersionId,
|
||||||
|
javaArgs
|
||||||
|
}
|
||||||
|
await fsp.writeFile(launcherPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8')
|
||||||
|
sendLog(`launcher_profiles.json 갱신: 프로필 "${profileKey}", gameDir=${gameDir}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('finish:openServerFolder', async () => {
|
||||||
|
if (!state.installPath) return
|
||||||
|
await shell.openPath(state.installPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('finish:desktopShortcut', async () => {
|
||||||
|
if (process.platform !== 'win32' || !state.installPath) return
|
||||||
|
const desktopDir = app.getPath('desktop')
|
||||||
|
const shortcutPath = path.join(desktopDir, 'MusicQuiz Server.lnk')
|
||||||
|
const runBat = path.join(state.installPath, 'run.bat')
|
||||||
|
const ok = require('electron').shell.writeShortcutLink(shortcutPath, 'create', {
|
||||||
|
target: runBat,
|
||||||
|
cwd: state.installPath,
|
||||||
|
description: '음악퀴즈 서버 실행'
|
||||||
|
})
|
||||||
|
sendLog(ok ? `바로가기 생성: ${shortcutPath}` : '바로가기 생성 실패')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('finish:startServer', async () => {
|
||||||
|
if (!state.installPath) return
|
||||||
|
const runBat = path.join(state.installPath, 'run.bat')
|
||||||
|
if (!fs.existsSync(runBat)) {
|
||||||
|
sendLog(`run.bat을 찾을 수 없습니다: ${runBat}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spawn('cmd.exe', ['/c', 'start', '', runBat], { cwd: state.installPath, detached: true, stdio: 'ignore' }).unref()
|
||||||
|
sendLog('서버 실행 요청 완료.')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('finish:startLauncher', async () => {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Minecraft Launcher', 'MinecraftLauncher.exe'),
|
||||||
|
path.join(process.env['ProgramFiles'] ?? 'C:\\Program Files', 'Minecraft Launcher', 'MinecraftLauncher.exe')
|
||||||
|
]
|
||||||
|
const target = candidates.find((candidate) => fs.existsSync(candidate))
|
||||||
|
if (!target) {
|
||||||
|
sendLog('Minecraft Launcher를 찾을 수 없습니다. 직접 실행해 주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spawn(target, [], { detached: true, stdio: 'ignore' }).unref()
|
||||||
|
sendLog('마인크래프트 런처 실행 요청 완료.')
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createMainWindow()
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (state.configEditorServer) {
|
||||||
|
state.configEditorServer.close()
|
||||||
|
state.configEditorServer = null
|
||||||
|
}
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
59
src/installer/preload.ts
Normal file
59
src/installer/preload.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types'
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
// 1단계
|
||||||
|
loadPacks: (manifestUrl?: string): Promise<FetchedPack[]> =>
|
||||||
|
ipcRenderer.invoke('packs:load', manifestUrl),
|
||||||
|
setSelectedPack: (packKey: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('packs:select', packKey),
|
||||||
|
|
||||||
|
// 3-1
|
||||||
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
||||||
|
validateInstallPath: (target: string): Promise<{ ok: boolean; message?: string }> =>
|
||||||
|
ipcRenderer.invoke('install:validatePath', target),
|
||||||
|
|
||||||
|
// 3-2
|
||||||
|
detectJdk: (): Promise<{ found: boolean; path: string }> => ipcRenderer.invoke('jdk:detect'),
|
||||||
|
|
||||||
|
// 3-3
|
||||||
|
startServerInstall: (payload: ServerInstallPayload): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('server:install', payload),
|
||||||
|
acceptEula: (installPath: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('server:acceptEula', installPath),
|
||||||
|
checkRam: (packKey: string): Promise<RamCheckResult> =>
|
||||||
|
ipcRenderer.invoke('server:checkRam', packKey),
|
||||||
|
|
||||||
|
// 3-4
|
||||||
|
startServerConfigEditor: (installPath: string): Promise<{ url: string }> =>
|
||||||
|
ipcRenderer.invoke('server:configEditor', installPath),
|
||||||
|
|
||||||
|
// 3-5
|
||||||
|
checkPortForward: (port: number): Promise<PortForwardResult> =>
|
||||||
|
ipcRenderer.invoke('server:portForward', port),
|
||||||
|
|
||||||
|
// 4단계
|
||||||
|
installClient: (payload: ClientInstallPayload): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('client:install', payload),
|
||||||
|
|
||||||
|
// 5단계
|
||||||
|
openServerFolder: (): Promise<void> => ipcRenderer.invoke('finish:openServerFolder'),
|
||||||
|
createDesktopShortcut: (): Promise<void> => ipcRenderer.invoke('finish:desktopShortcut'),
|
||||||
|
startServer: (): Promise<void> => ipcRenderer.invoke('finish:startServer'),
|
||||||
|
startMinecraftLauncher: (): Promise<void> => ipcRenderer.invoke('finish:startLauncher'),
|
||||||
|
|
||||||
|
// log stream
|
||||||
|
onLog: (handler: (line: string) => void): (() => void) => {
|
||||||
|
const listener = (_event: unknown, line: string) => handler(line)
|
||||||
|
ipcRenderer.on('log', listener)
|
||||||
|
return () => ipcRenderer.removeListener('log', listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('installer', api)
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
installer: typeof api
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/installer/types.ts
Normal file
41
src/installer/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Manifest, PackDefinition } from '../shared/types'
|
||||||
|
|
||||||
|
export interface InstallerConfig {
|
||||||
|
manifestUrl: string
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchedPack {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
pack: PackDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchedManifest {
|
||||||
|
manifest: Manifest
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerInstallPayload {
|
||||||
|
packKey: string
|
||||||
|
installPath: string
|
||||||
|
jdkPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientInstallPayload {
|
||||||
|
packKey: string
|
||||||
|
installPlatform: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RamCheckResult {
|
||||||
|
systemRamMb: number
|
||||||
|
decision: 'maxOk' | 'minOk' | 'tooLow'
|
||||||
|
appliedRamMb: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortForwardResult {
|
||||||
|
status: 'preForwarded' | 'upnpOk' | 'upnpFailed'
|
||||||
|
externalIp?: string
|
||||||
|
port: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
61
src/server/app.ts
Normal file
61
src/server/app.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import session from 'express-session'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { manifestRootPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths'
|
||||||
|
import { indexRouter } from './routes/index'
|
||||||
|
import { opRouter } from './routes/op'
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT ?? 3000)
|
||||||
|
const HOST = process.env.HOST ?? '0.0.0.0'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.set('view engine', 'ejs')
|
||||||
|
app.set('views', viewsDirPath)
|
||||||
|
app.set('trust proxy', 1)
|
||||||
|
|
||||||
|
app.use(express.urlencoded({ extended: true }))
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 1000 * 60 * 60 * 8
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 외부에서 account.json, /manifest 폴더 등에 절대 접근 불가하도록 가장 먼저 차단한다.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (/^\/account\.json/i.test(req.path) || /^\/manifest\//i.test(req.path)) {
|
||||||
|
res.status(404).send('Not Found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/static', express.static(publicDirPath))
|
||||||
|
|
||||||
|
// 외부 노출이 필요한 정적 자원만 화이트리스트로 라우팅한다.
|
||||||
|
app.get('/manifest.json', (_req, res) => {
|
||||||
|
res.sendFile(manifestRootPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/file', express.static(fileDirPath, { fallthrough: true, index: false }))
|
||||||
|
|
||||||
|
app.use('/', indexRouter)
|
||||||
|
app.use('/', opRouter)
|
||||||
|
|
||||||
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
console.error(err)
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
res.status(500).send(`서버 오류: ${message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`[server] http://${HOST}:${PORT}`)
|
||||||
|
console.log(`[server] views: ${path.relative(process.cwd(), viewsDirPath)}`)
|
||||||
|
})
|
||||||
19
src/server/middleware/auth.ts
Normal file
19
src/server/middleware/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
|
||||||
|
declare module 'express-session' {
|
||||||
|
interface SessionData {
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (req.session?.userId) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
res.redirect('/op')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.status(401).send('인증이 필요합니다.')
|
||||||
|
}
|
||||||
23
src/server/routes/index.ts
Normal file
23
src/server/routes/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { listPackKeys, loadPackDefinition, readManifest } from '../../shared/store'
|
||||||
|
|
||||||
|
export const indexRouter = Router()
|
||||||
|
|
||||||
|
indexRouter.get('/', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const manifest = await readManifest()
|
||||||
|
const definitionMap = new Map<string, Awaited<ReturnType<typeof loadPackDefinition>>>()
|
||||||
|
const keys = await listPackKeys()
|
||||||
|
for (const key of keys) {
|
||||||
|
definitionMap.set(key, await loadPackDefinition(key))
|
||||||
|
}
|
||||||
|
const packs = manifest.packs.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
file: entry.file,
|
||||||
|
definition: definitionMap.get(entry.file) ?? null
|
||||||
|
}))
|
||||||
|
res.render('index', { packs })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
163
src/server/routes/op.ts
Normal file
163
src/server/routes/op.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
createPack,
|
||||||
|
deletePackKeys,
|
||||||
|
listPackKeys,
|
||||||
|
loadPackDefinition,
|
||||||
|
normalizePackDefinition,
|
||||||
|
readAccounts,
|
||||||
|
renamePack,
|
||||||
|
sanitizePackKey
|
||||||
|
} from '../../shared/store'
|
||||||
|
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||||
|
import { requireAuth } from '../middleware/auth'
|
||||||
|
import type { PackDefinition } from '../../shared/types'
|
||||||
|
|
||||||
|
export const opRouter = Router()
|
||||||
|
|
||||||
|
function pickFirstValue(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickStringArray(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item): item is string => typeof item === 'string')
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') return [value]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
opRouter.get('/op', (req, res) => {
|
||||||
|
if (req.session?.userId) {
|
||||||
|
res.redirect('/op/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.render('op/login', { error: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = pickFirstValue(req.body.id).trim()
|
||||||
|
const password = pickFirstValue(req.body.password)
|
||||||
|
const accounts = await readAccounts()
|
||||||
|
const matched = accounts.find((entry) => entry.id === id && entry.password === password)
|
||||||
|
if (!matched) {
|
||||||
|
res.status(401).render('op/login', { error: '아이디 또는 비밀번호가 올바르지 않습니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.session.userId = matched.id
|
||||||
|
res.redirect('/op/dashboard')
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.redirect('/op')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keys = await listPackKeys()
|
||||||
|
const items = await Promise.all(keys.map(async (key) => ({
|
||||||
|
key,
|
||||||
|
definition: await loadPackDefinition(key)
|
||||||
|
})))
|
||||||
|
res.render('op/dashboard', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
items
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/dashboard/create', requireAuth, async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { key } = await createPack()
|
||||||
|
res.redirect(`/op/dashboard/${key}`)
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/dashboard/delete', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keys = pickStringArray(req.body.targetKey)
|
||||||
|
.map((key) => sanitizePackKey(key))
|
||||||
|
.filter((key) => key.length > 0)
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await deletePackKeys(keys)
|
||||||
|
}
|
||||||
|
res.redirect('/op/dashboard')
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
const definition = await loadPackDefinition(packKey)
|
||||||
|
if (!definition) {
|
||||||
|
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const releases = await fetchReleaseVersions()
|
||||||
|
res.render('op/editor', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
packKey,
|
||||||
|
pack: definition,
|
||||||
|
releases
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
const requestedKey = sanitizePackKey(pickFirstValue(req.body.fileName)) || packKey
|
||||||
|
|
||||||
|
const modNames = pickStringArray(req.body['modName']).map((value) => value.trim())
|
||||||
|
const modUrls = pickStringArray(req.body['modUrl']).map((value) => value.trim())
|
||||||
|
const mods = modNames.map((name, index) => ({ name, downloadUrl: modUrls[index] ?? '' }))
|
||||||
|
|
||||||
|
const resourceNames = pickStringArray(req.body['resourceName']).map((value) => value.trim())
|
||||||
|
const resourceUrls = pickStringArray(req.body['resourceUrl']).map((value) => value.trim())
|
||||||
|
const resourcepacks = resourceNames.map((name, index) => ({ name, downloadUrl: resourceUrls[index] ?? '' }))
|
||||||
|
|
||||||
|
const platformType = pickFirstValue(req.body.platformType)
|
||||||
|
const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim()
|
||||||
|
|
||||||
|
const partial: Partial<PackDefinition> & Record<string, unknown> = {
|
||||||
|
name: pickFirstValue(req.body.displayName),
|
||||||
|
mcVersion: pickFirstValue(req.body.mcVersion),
|
||||||
|
platform: {
|
||||||
|
type: (platformType as PackDefinition['platform']['type']) || 'vanilla',
|
||||||
|
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined
|
||||||
|
},
|
||||||
|
mods,
|
||||||
|
resourcepacks,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizePackDefinition(partial)
|
||||||
|
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
|
||||||
|
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const finalKey = await renamePack(packKey, requestedKey, normalized)
|
||||||
|
res.redirect(`/op/dashboard/${finalKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
60
src/shared/mojang.ts
Normal file
60
src/shared/mojang.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import https from 'node:https'
|
||||||
|
|
||||||
|
interface MojangVersionEntry {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MojangVersionManifest {
|
||||||
|
versions: MojangVersionEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANIFEST_URL = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'
|
||||||
|
|
||||||
|
let cachedReleases: string[] | null = null
|
||||||
|
let cachedAt = 0
|
||||||
|
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
export async function fetchReleaseVersions(): Promise<string[]> {
|
||||||
|
if (cachedReleases && Date.now() - cachedAt < CACHE_TTL_MS) {
|
||||||
|
return cachedReleases
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await fetchJson<MojangVersionManifest>(MANIFEST_URL)
|
||||||
|
const releases = data.versions.filter((entry) => entry.type === 'release').map((entry) => entry.id)
|
||||||
|
cachedReleases = releases
|
||||||
|
cachedAt = Date.now()
|
||||||
|
return releases
|
||||||
|
} catch {
|
||||||
|
return cachedReleases ?? FALLBACK_RELEASES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = https.get(url, { timeout: 8000 }, (response) => {
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
response.resume()
|
||||||
|
reject(new Error(`Mojang manifest HTTP ${response.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
response.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as T)
|
||||||
|
} catch (error) {
|
||||||
|
reject(error as Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
request.on('error', reject)
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy(new Error('Mojang manifest timeout'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_RELEASES = [
|
||||||
|
'1.21', '1.20.6', '1.20.4', '1.20.2', '1.20.1', '1.19.4', '1.19.2', '1.18.2', '1.17.1', '1.16.5'
|
||||||
|
]
|
||||||
18
src/shared/nat-upnp.d.ts
vendored
Normal file
18
src/shared/nat-upnp.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
declare module 'nat-upnp' {
|
||||||
|
interface PortMappingOptions {
|
||||||
|
public: number | { host?: string; port: number }
|
||||||
|
private: number | { host?: string; port: number }
|
||||||
|
ttl?: number
|
||||||
|
description?: string
|
||||||
|
protocol?: 'tcp' | 'udp'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpnpClient {
|
||||||
|
portMapping(options: PortMappingOptions, callback: (err: Error | null) => void): void
|
||||||
|
portUnmapping(options: { public: number; protocol?: 'tcp' | 'udp' }, callback: (err: Error | null) => void): void
|
||||||
|
externalIp(callback: (err: Error | null, ip: string) => void): void
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClient(): UpnpClient
|
||||||
|
}
|
||||||
10
src/shared/paths.ts
Normal file
10
src/shared/paths.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
// 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트.
|
||||||
|
export const projectRoot = path.resolve(__dirname, '..', '..')
|
||||||
|
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||||
|
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||||
|
export const accountFilePath = path.join(projectRoot, 'account.json')
|
||||||
|
export const fileDirPath = path.join(projectRoot, 'file')
|
||||||
|
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||||
|
export const publicDirPath = path.join(projectRoot, 'public')
|
||||||
219
src/shared/store.ts
Normal file
219
src/shared/store.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import fsp from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { manifestRootPath, manifestDirPath, accountFilePath } from './paths'
|
||||||
|
import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType } from './types'
|
||||||
|
|
||||||
|
export async function readManifest(): Promise<Manifest> {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(manifestRootPath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Manifest>
|
||||||
|
if (!parsed || !Array.isArray(parsed.packs)) {
|
||||||
|
return { packs: [] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
packs: parsed.packs.filter((entry): entry is ManifestEntry =>
|
||||||
|
typeof entry?.name === 'string' && typeof entry?.file === 'string')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return { packs: [] }
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeManifest(manifest: Manifest): Promise<void> {
|
||||||
|
await fsp.writeFile(manifestRootPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPackDefinition(name: string): PackDefinition {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mcVersion: '1.20.1',
|
||||||
|
platform: { type: 'vanilla' },
|
||||||
|
mods: [],
|
||||||
|
resourcepacks: [],
|
||||||
|
serverMinRam: 2048,
|
||||||
|
serverMaxRam: 4096,
|
||||||
|
clientMinRam: 2048,
|
||||||
|
clientRecommendedRam: 4096,
|
||||||
|
packPath: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
|
||||||
|
|
||||||
|
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
|
||||||
|
const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new')
|
||||||
|
const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']>
|
||||||
|
const platformType = ALLOWED_LOADERS.includes(platform.type as LoaderType)
|
||||||
|
? (platform.type as LoaderType)
|
||||||
|
: 'vanilla'
|
||||||
|
|
||||||
|
const modsSource = Array.isArray(input.mods) ? input.mods : []
|
||||||
|
const mods = modsSource
|
||||||
|
.map((entry) => {
|
||||||
|
const value = entry as Partial<PackDefinition['mods'][number]>
|
||||||
|
return {
|
||||||
|
name: typeof value?.name === 'string' ? value.name.trim() : '',
|
||||||
|
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
|
||||||
|
|
||||||
|
const resourcePacksSource = Array.isArray(input.resourcepacks) ? input.resourcepacks : []
|
||||||
|
const resourcepacks = resourcePacksSource
|
||||||
|
.map((entry) => {
|
||||||
|
const value = entry as Partial<PackDefinition['resourcepacks'][number]>
|
||||||
|
return {
|
||||||
|
name: typeof value?.name === 'string' ? value.name.trim() : '',
|
||||||
|
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
|
||||||
|
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
|
||||||
|
? input.mcVersion.trim()
|
||||||
|
: fallback.mcVersion,
|
||||||
|
platform: {
|
||||||
|
type: platformType,
|
||||||
|
downloadUrl: typeof platform.downloadUrl === 'string' && platform.downloadUrl.trim().length > 0
|
||||||
|
? platform.downloadUrl.trim()
|
||||||
|
: undefined
|
||||||
|
},
|
||||||
|
mods,
|
||||||
|
resourcepacks,
|
||||||
|
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
||||||
|
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
||||||
|
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
||||||
|
clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam),
|
||||||
|
packPath: typeof input.packPath === 'string' ? input.packPath.trim() : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(input: unknown, fallback: number): number {
|
||||||
|
const value = typeof input === 'number' ? input : Number(input)
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return fallback
|
||||||
|
return Math.floor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packKeyFromFile(fileName: string): string {
|
||||||
|
return fileName.replace(/\.json$/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
|
||||||
|
const filePath = path.join(manifestDirPath, `${packKey}.json`)
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(filePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as Partial<PackDefinition>
|
||||||
|
return normalizePackDefinition(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePackDefinition(packKey: string, pack: PackDefinition): Promise<void> {
|
||||||
|
await fsp.mkdir(manifestDirPath, { recursive: true })
|
||||||
|
const filePath = path.join(manifestDirPath, `${packKey}.json`)
|
||||||
|
await fsp.writeFile(filePath, `${JSON.stringify(pack, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPackKeys(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(manifestDirPath)
|
||||||
|
return entries
|
||||||
|
.filter((name) => name.toLowerCase().endsWith('.json'))
|
||||||
|
.map(packKeyFromFile)
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'ko'))
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nextNewKey(): Promise<string> {
|
||||||
|
const used = new Set(await listPackKeys())
|
||||||
|
if (!used.has('new')) return 'new'
|
||||||
|
for (let i = 2; i < 1000; i += 1) {
|
||||||
|
const candidate = `new${i}`
|
||||||
|
if (!used.has(candidate)) return candidate
|
||||||
|
}
|
||||||
|
return `new-${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPack(): Promise<{ key: string; pack: PackDefinition }> {
|
||||||
|
const key = await nextNewKey()
|
||||||
|
const pack = defaultPackDefinition(key)
|
||||||
|
await savePackDefinition(key, pack)
|
||||||
|
await syncManifestWith(key, pack.name, 'add')
|
||||||
|
return { key, pack }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePackKeys(keys: string[]): Promise<void> {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key) continue
|
||||||
|
const filePath = path.join(manifestDirPath, `${key}.json`)
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
await syncManifestWith(key, '', 'remove')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renamePack(oldKey: string, newKey: string, pack: PackDefinition): Promise<string> {
|
||||||
|
const safeNew = sanitizePackKey(newKey) || oldKey
|
||||||
|
const targetPath = path.join(manifestDirPath, `${safeNew}.json`)
|
||||||
|
const sourcePath = path.join(manifestDirPath, `${oldKey}.json`)
|
||||||
|
|
||||||
|
if (safeNew !== oldKey && fs.existsSync(targetPath)) {
|
||||||
|
throw new Error(`이미 ${safeNew}.json 이름의 음악퀴즈가 있습니다.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await savePackDefinition(safeNew, pack)
|
||||||
|
if (safeNew !== oldKey) {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(sourcePath)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
await syncManifestWith(oldKey, '', 'remove')
|
||||||
|
}
|
||||||
|
await syncManifestWith(safeNew, pack.name, 'upsert')
|
||||||
|
return safeNew
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizePackKey(value: string): string {
|
||||||
|
return value.replace(/[^a-zA-Z0-9_\-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManifestSyncAction = 'add' | 'remove' | 'upsert'
|
||||||
|
|
||||||
|
async function syncManifestWith(key: string, name: string, action: ManifestSyncAction): Promise<void> {
|
||||||
|
const manifest = await readManifest()
|
||||||
|
const filtered = manifest.packs.filter((entry) => entry.file !== key)
|
||||||
|
if (action === 'remove') {
|
||||||
|
await writeManifest({ packs: filtered })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filtered.push({ name: name || key, file: key })
|
||||||
|
await writeManifest({ packs: filtered })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed.filter((entry): entry is AccountEntry =>
|
||||||
|
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/shared/types.ts
Normal file
38
src/shared/types.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type LoaderType = 'vanilla' | 'forge' | 'fabric' | 'neoforge'
|
||||||
|
|
||||||
|
export interface PackPlatform {
|
||||||
|
type: LoaderType
|
||||||
|
downloadUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackAsset {
|
||||||
|
name: string
|
||||||
|
downloadUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackDefinition {
|
||||||
|
name: string
|
||||||
|
mcVersion: string
|
||||||
|
platform: PackPlatform
|
||||||
|
mods: PackAsset[]
|
||||||
|
resourcepacks: PackAsset[]
|
||||||
|
serverMinRam: number
|
||||||
|
serverMaxRam: number
|
||||||
|
clientMinRam: number
|
||||||
|
clientRecommendedRam: number
|
||||||
|
packPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManifestEntry {
|
||||||
|
name: string
|
||||||
|
file: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Manifest {
|
||||||
|
packs: ManifestEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountEntry {
|
||||||
|
id: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
4
tsconfig.installer.json
Normal file
4
tsconfig.installer.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src/installer/**/*.ts", "src/shared/**/*.ts"]
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
4
tsconfig.server.json
Normal file
4
tsconfig.server.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src/server/**/*.ts", "src/shared/**/*.ts"]
|
||||||
|
}
|
||||||
36
views/index.ejs
Normal file
36
views/index.ejs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!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">
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>마인크래프트 음악퀴즈</h1>
|
||||||
|
<p>설치기에서 사용 가능한 음악퀴즈 목록입니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cardRow horizontalScroll">
|
||||||
|
<% if (packs.length === 0) { %>
|
||||||
|
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<% packs.forEach(function (entry) { %>
|
||||||
|
<article class="packCard">
|
||||||
|
<h2><%= entry.name %></h2>
|
||||||
|
<p class="muted">파일: <%= entry.file %>.json</p>
|
||||||
|
<% if (entry.definition) { %>
|
||||||
|
<ul class="metaList">
|
||||||
|
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
|
||||||
|
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
|
||||||
|
<li>모드 <%= entry.definition.mods.length %>개 / 리소스팩 <%= entry.definition.resourcepacks.length %>개</li>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
96
views/op/dashboard.ejs
Normal file
96
views/op/dashboard.ejs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<!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="dashboardHeader">
|
||||||
|
<h1>음악퀴즈 목록</h1>
|
||||||
|
<div class="dashboardActions">
|
||||||
|
<form method="post" action="/op/dashboard/create" class="inlineForm">
|
||||||
|
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
|
||||||
|
<section class="cardRow horizontalScroll">
|
||||||
|
<% if (items.length === 0) { %>
|
||||||
|
<p class="muted">등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.</p>
|
||||||
|
<% } %>
|
||||||
|
<% items.forEach(function (item) { %>
|
||||||
|
<article class="packCard editableCard" data-key="<%= item.key %>">
|
||||||
|
<label class="cardCheckbox" hidden>
|
||||||
|
<input type="checkbox" name="targetKey" value="<%= item.key %>" />
|
||||||
|
<span>선택</span>
|
||||||
|
</label>
|
||||||
|
<a class="cardLink" href="/op/dashboard/<%= item.key %>">
|
||||||
|
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
||||||
|
<p class="muted"><%= item.key %>.json</p>
|
||||||
|
<% if (item.definition) { %>
|
||||||
|
<ul class="metaList">
|
||||||
|
<li>MC <%= item.definition.mcVersion %></li>
|
||||||
|
<li>플랫폼 <%= item.definition.platform.type %></li>
|
||||||
|
<li>모드 <%= item.definition.mods.length %>개</li>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
|
||||||
|
<button type="button" class="secondaryButton" id="deleteCancel">취소</button>
|
||||||
|
<button type="submit" class="dangerButton">삭제 확인</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var toggleButton = document.getElementById('deleteToggle')
|
||||||
|
var confirmRow = document.getElementById('deleteConfirm')
|
||||||
|
var cancelButton = document.getElementById('deleteCancel')
|
||||||
|
var checkboxLabels = document.querySelectorAll('.cardCheckbox')
|
||||||
|
var cardLinks = document.querySelectorAll('.cardLink')
|
||||||
|
|
||||||
|
function setSelectMode(active) {
|
||||||
|
confirmRow.hidden = !active
|
||||||
|
checkboxLabels.forEach(function (label) {
|
||||||
|
if (active) label.removeAttribute('hidden')
|
||||||
|
else label.setAttribute('hidden', '')
|
||||||
|
})
|
||||||
|
cardLinks.forEach(function (link) {
|
||||||
|
if (active) {
|
||||||
|
link.setAttribute('data-disabled', 'true')
|
||||||
|
link.addEventListener('click', preventNavigation)
|
||||||
|
} else {
|
||||||
|
link.removeAttribute('data-disabled')
|
||||||
|
link.removeEventListener('click', preventNavigation)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function preventNavigation(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleButton.addEventListener('click', function () {
|
||||||
|
setSelectMode(true)
|
||||||
|
})
|
||||||
|
cancelButton.addEventListener('click', function () {
|
||||||
|
setSelectMode(false)
|
||||||
|
document.querySelectorAll('input[name="targetKey"]').forEach(function (input) {
|
||||||
|
input.checked = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
163
views/op/editor.ejs
Normal file
163
views/op/editor.ejs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= pack.name %> 편집</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<%- include('../partials/navbar', { userId }) %>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="editorHeader">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">PACK EDITOR</p>
|
||||||
|
<h1><%= pack.name %></h1>
|
||||||
|
</div>
|
||||||
|
<a class="ghostLink" href="/op/dashboard">목록으로</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" class="editorForm" id="editorForm">
|
||||||
|
<div class="gridTwo">
|
||||||
|
<label>
|
||||||
|
<span>음악퀴즈 이름</span>
|
||||||
|
<input name="displayName" value="<%= pack.name %>" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>JSON 파일 이름 (확장자 제외)</span>
|
||||||
|
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gridTwo">
|
||||||
|
<label>
|
||||||
|
<span>마인크래프트 버전</span>
|
||||||
|
<select name="mcVersion" required>
|
||||||
|
<% releases.forEach(function (release) { %>
|
||||||
|
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>모드 플랫폼</span>
|
||||||
|
<select name="platformType" id="platformType">
|
||||||
|
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
|
||||||
|
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="fullSpan" id="platformDownloadField">
|
||||||
|
<span>플랫폼 설치파일 URL</span>
|
||||||
|
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="https://example.com/forge-installer.jar" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>서버 최소 램 (MB)</span>
|
||||||
|
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>서버 최대 램 (MB)</span>
|
||||||
|
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>클라이언트 최소 램 (MB)</span>
|
||||||
|
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>클라이언트 권장 램 (MB)</span>
|
||||||
|
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
|
||||||
|
</label>
|
||||||
|
<label class="fullSpan">
|
||||||
|
<span>packPath (서버 파일 경로, /file/ 이후만)</span>
|
||||||
|
<input name="packPath" value="<%= pack.packPath %>" placeholder="music-quiz/files" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="dynamicListFieldset">
|
||||||
|
<legend>모드 (.jar)</legend>
|
||||||
|
<div class="dynamicList" data-list="mods">
|
||||||
|
<% pack.mods.forEach(function (mod) { %>
|
||||||
|
<div class="dynamicRow">
|
||||||
|
<input name="modName" placeholder="모드 이름" value="<%= mod.name %>" />
|
||||||
|
<input name="modUrl" placeholder="다운로드 URL" value="<%= mod.downloadUrl %>" />
|
||||||
|
<button type="button" class="dangerLink dynamicRemove">삭제</button>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondaryButton" data-add="mods">모드 추가</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="dynamicListFieldset">
|
||||||
|
<legend>리소스팩 (.zip)</legend>
|
||||||
|
<div class="dynamicList" data-list="resourcepacks">
|
||||||
|
<% pack.resourcepacks.forEach(function (resourcePack) { %>
|
||||||
|
<div class="dynamicRow">
|
||||||
|
<input name="resourceName" placeholder="리소스팩 이름" value="<%= resourcePack.name %>" />
|
||||||
|
<input name="resourceUrl" placeholder="다운로드 URL" value="<%= resourcePack.downloadUrl %>" />
|
||||||
|
<button type="button" class="dangerLink dynamicRemove">삭제</button>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondaryButton" data-add="resourcepacks">리소스팩 추가</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button class="primaryButton" type="submit">저장</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var platformSelect = document.getElementById('platformType')
|
||||||
|
var downloadField = document.getElementById('platformDownloadField')
|
||||||
|
|
||||||
|
function syncPlatformVisibility() {
|
||||||
|
if (platformSelect.value === 'vanilla') {
|
||||||
|
downloadField.setAttribute('hidden', '')
|
||||||
|
downloadField.querySelector('input').value = ''
|
||||||
|
} else {
|
||||||
|
downloadField.removeAttribute('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
platformSelect.addEventListener('change', syncPlatformVisibility)
|
||||||
|
syncPlatformVisibility()
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-add]').forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
var listKey = button.getAttribute('data-add')
|
||||||
|
var list = document.querySelector('[data-list="' + listKey + '"]')
|
||||||
|
if (!list) return
|
||||||
|
var nameField = listKey === 'mods' ? 'modName' : 'resourceName'
|
||||||
|
var urlField = listKey === 'mods' ? 'modUrl' : 'resourceUrl'
|
||||||
|
var placeholder = listKey === 'mods' ? '모드 이름' : '리소스팩 이름'
|
||||||
|
var row = document.createElement('div')
|
||||||
|
row.className = 'dynamicRow'
|
||||||
|
row.innerHTML =
|
||||||
|
'<input name="' + nameField + '" placeholder="' + placeholder + '" />' +
|
||||||
|
'<input name="' + urlField + '" placeholder="다운로드 URL" />' +
|
||||||
|
'<button type="button" class="dangerLink dynamicRemove">삭제</button>'
|
||||||
|
list.appendChild(row)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var target = event.target
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
if (!target.classList.contains('dynamicRemove')) return
|
||||||
|
var row = target.closest('.dynamicRow')
|
||||||
|
if (row) row.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
var form = document.getElementById('editorForm')
|
||||||
|
form.addEventListener('submit', function (event) {
|
||||||
|
var clientMin = Number(form.clientMinRam.value)
|
||||||
|
var clientReco = Number(form.clientRecommendedRam.value)
|
||||||
|
if (clientMin > clientReco) {
|
||||||
|
event.preventDefault()
|
||||||
|
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
views/op/login.ejs
Normal file
28
views/op/login.ejs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!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 centerLayout">
|
||||||
|
<main class="loginCard">
|
||||||
|
<h1>관리자 로그인</h1>
|
||||||
|
<% if (error) { %>
|
||||||
|
<p class="errorBanner"><%= error %></p>
|
||||||
|
<% } %>
|
||||||
|
<form method="post" action="/op" class="loginForm">
|
||||||
|
<label>
|
||||||
|
<span>아이디</span>
|
||||||
|
<input name="id" autocomplete="username" required autofocus />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>비밀번호</span>
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
<button class="primaryButton" type="submit">로그인</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
views/partials/navbar.ejs
Normal file
30
views/partials/navbar.ejs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<header class="topNav">
|
||||||
|
<a class="navBrand" href="/op/dashboard">
|
||||||
|
<span class="navLogo">🎵</span>
|
||||||
|
<span class="navTitle">관리자 페이지</span>
|
||||||
|
</a>
|
||||||
|
<div class="navUser">
|
||||||
|
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
|
||||||
|
<div class="navUserMenu" id="userMenu" hidden>
|
||||||
|
<form method="post" action="/op/logout">
|
||||||
|
<button type="submit" class="dangerLink">로그아웃</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var toggle = document.getElementById('userMenuToggle')
|
||||||
|
var menu = document.getElementById('userMenu')
|
||||||
|
if (!toggle || !menu) return
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
var hidden = menu.hasAttribute('hidden')
|
||||||
|
if (hidden) menu.removeAttribute('hidden')
|
||||||
|
else menu.setAttribute('hidden', '')
|
||||||
|
})
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (event.target === toggle || menu.contains(event.target)) return
|
||||||
|
menu.setAttribute('hidden', '')
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user