Initial commit: CraftDock as of 2026-11-01
The previous GitHub history was lost with the account (the local .git was missing its refs and most objects), so this repository starts from the existing code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
640e52c0c9
99 files changed
+12115
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
test
|
||||||
|
*.md
|
||||||
|
coverage
|
||||||
|
src/public/assets/mc
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# CraftDock environment — every variable the app reads (see src/config.js).
|
||||||
|
# Copy to .env and fill in. Full descriptions are in the README env-var table.
|
||||||
|
|
||||||
|
# --- Auth (required) ---
|
||||||
|
# Generate both forms with: npm run hash -- <your-password>. Set exactly ONE.
|
||||||
|
# ADMIN_PASSWORD_HASH raw bcrypt hash — use with `npm run dev` (plain .env)
|
||||||
|
# ADMIN_PASSWORD_HASH_B64 base64 of the hash — use with docker compose / Coolify
|
||||||
|
# (bcrypt hashes contain `$`, which those interpolate)
|
||||||
|
ADMIN_PASSWORD_HASH=
|
||||||
|
ADMIN_PASSWORD_HASH_B64=
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# --- Minecraft target ---
|
||||||
|
MC_CONTAINER_NAME=craftdock-mc-server # container name (matched first)
|
||||||
|
MC_SERVICE_NAME=minecraft-server # compose service name (fallback when the
|
||||||
|
# container name is auto-generated, e.g. Coolify)
|
||||||
|
MC_DATA_PATH=/minecraft/data # shared mc-data volume path inside the panel
|
||||||
|
MC_EDITION=auto # auto | java | bedrock
|
||||||
|
MC_WORLD_NAME=world # world folder under MC_DATA_PATH (Bedrock: worlds)
|
||||||
|
|
||||||
|
# --- RCON (Java only; leave empty for Bedrock) ---
|
||||||
|
RCON_HOST=craftdock-mc-server
|
||||||
|
RCON_PORT=25575
|
||||||
|
RCON_PASSWORD=
|
||||||
|
|
||||||
|
# --- Map tab (optional) ---
|
||||||
|
MAP_VERSION= # mcseedmap version override, e.g. 1.21-Java or 26.30.0-Bedrock
|
||||||
|
|
||||||
|
# --- App ---
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production # use `development` for plain-http local testing
|
||||||
|
LOG_LEVEL=info # error | warn | info | debug
|
||||||
|
MAX_UPLOAD_MB=1024
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
panel-data/
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
docs/
|
||||||
|
.superpowers/
|
||||||
|
world.zip
|
||||||
|
logs.txt
|
||||||
|
src/public/assets/mc/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# DS-0002: "Specify at least 1 USER command in Dockerfile with non-root user".
|
||||||
|
# Accepted risk, deliberately: CraftDock's entire purpose is to control the
|
||||||
|
# Minecraft container through /var/run/docker.sock (root-equivalent by design)
|
||||||
|
# and to read/write world files the game server creates with mixed ownership.
|
||||||
|
# Running as a non-root user would break the docker.sock connection on most
|
||||||
|
# hosts (socket is root:docker with a host-specific GID) without reducing the
|
||||||
|
# real attack surface — the socket mount, not the container user, is the power.
|
||||||
|
# Access is gated by the panel's bcrypt-authenticated session on every route.
|
||||||
|
DS-0002
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for Claude Code when working in this repository.
|
||||||
|
|
||||||
|
## What CraftDock is
|
||||||
|
|
||||||
|
A lightweight, self-hosted web panel that manages a **single** Minecraft server (Java *or* Bedrock) running as a Docker container. Node.js + Express + Socket.io backend, vanilla-JS tabbed SPA frontend (Tailwind via CDN). The panel never modifies the Minecraft core: it controls the game container over the Docker socket and sends commands via RCON (Java) or container stdin (Bedrock).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # full Vitest suite — must pass before any commit
|
||||||
|
npm run test:watch
|
||||||
|
npm run dev # dev server (loads .env, watches files), panel on :3000
|
||||||
|
npm run hash -- <pw> # generate the admin bcrypt hash (raw + base64 forms)
|
||||||
|
docker compose -f docker-compose.bedrock.yaml up -d --build # full Bedrock stack
|
||||||
|
docker compose -f docker-compose.java.yaml up -d --build # full Java stack
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single test file: `npx vitest run test/services/playerDetailService.test.js`.
|
||||||
|
|
||||||
|
## Git workflow (mandatory)
|
||||||
|
|
||||||
|
- **One numbered branch per feature/fix**: `feature/N-slug` or `fix/N-slug`, where N is sequential across the repo (check `git branch -a` for the highest existing N).
|
||||||
|
- **Every commit message is prefixed with the branch number**: `#N: message` (e.g. `#10: resolve Bedrock player detail via player_<uuid> ServerId mapping`). This makes it trivial to see which branch a change came from.
|
||||||
|
- **Merge into `main` with `--no-ff`** so the graph shows one merge bubble per feature. Never commit directly to `main`.
|
||||||
|
- **Squash small commits**: repeated touch-ups to the same file or tiny adjustments belong inside one larger, complete commit — don't inflate the tree.
|
||||||
|
- Messages in English, imperative, descriptive of the *why* when non-obvious.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Composition root + dependency injection
|
||||||
|
|
||||||
|
`src/main.js` is the only place that wires real dependencies; `src/server.js` composes the Express app + Socket.io namespaces from injected pieces. Every service/route/adapter is a **factory function taking its collaborators as arguments** (`createXxx({ dep1, dep2 })`), so unit tests inject fakes — never module-level singletons for anything that touches I/O.
|
||||||
|
|
||||||
|
### Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.js # composition root (real deps wired here, and only here)
|
||||||
|
├── server.js # Express + Socket.io composition (takes deps)
|
||||||
|
├── config.js # env loading + validation (single source of env truth)
|
||||||
|
├── logger.js # structured JSON logger (ts/level/component/msg/meta)
|
||||||
|
├── appState.js # cached edition detection + adapter resolution
|
||||||
|
├── middleware/auth.js # session auth for HTTP routes AND socket handshake
|
||||||
|
├── adapters/ # ServerAdapter interface: java (RCON) / bedrock (stdin)
|
||||||
|
│ └── playerFile/ # read-only world-save readers (see below)
|
||||||
|
├── services/ # docker, rcon, stdin, properties, world, seed, auth,
|
||||||
|
│ # playerDirectory, playerDetailService, bedrockIdentityBridge
|
||||||
|
├── routes/ # auth, status, players, properties, world (Express routers)
|
||||||
|
├── sockets/ # logs stream + status push namespaces
|
||||||
|
└── public/ # vanilla-JS SPA: js/app.js shell + js/tabs/*.js (one file per tab)
|
||||||
|
```
|
||||||
|
|
||||||
|
### The adapter pattern (edition differences)
|
||||||
|
|
||||||
|
`ServerAdapter` abstracts the two editions behind one interface. Capability differences are declared, not special-cased in routes/UI:
|
||||||
|
|
||||||
|
| | Java | Bedrock |
|
||||||
|
|---|---|---|
|
||||||
|
| Command channel | RCON (`rconService`) | container stdin via `docker attach` (`stdinService`) |
|
||||||
|
| whitelist | `whitelist ...` | `allowlist ...` |
|
||||||
|
| ban/pardon | supported | **no native command** → `NotSupportedError`, buttons hidden |
|
||||||
|
| seed | `seed` command | **no command** — read `RandomSeed` from `level.dat` (`seedService`) |
|
||||||
|
| save snapshot | `save-all flush` | `save hold` → poll `save query` → read → `save resume` |
|
||||||
|
|
||||||
|
The frontend gets `capabilities` from `/api/players` and only renders controls the edition supports. When adding a command, add it to both adapters (or throw `NotSupportedError`) and update `CAPABILITIES` in `serverAdapter.js`.
|
||||||
|
|
||||||
|
### Player data: read-only from world saves
|
||||||
|
|
||||||
|
**Rule: ALL player data (position, health, food, inventory, gamemode, XP) is read directly from the world save files, read-only — never via console commands.** Per-edition file adapters in `src/adapters/playerFile/`:
|
||||||
|
|
||||||
|
- `nbtPlayer.js` — normalizes decoded NBT into one edition-agnostic `PlayerData` shape.
|
||||||
|
- `javaPlayerFile.js` — reads `world/playerdata/<uuid>.dat` (gzip NBT, big-endian).
|
||||||
|
- `bedrockPlayerFile.js` — reads the world **LevelDB** (`leveldb-zlib`, Mojang variant, little-endian NBT).
|
||||||
|
|
||||||
|
While the server runs, reads happen inside a save snapshot (`save hold`/`save-all flush`) orchestrated by `playerDetailService` — never open the live LevelDB without it.
|
||||||
|
|
||||||
|
### Bedrock identity model (critical, hard-won knowledge)
|
||||||
|
|
||||||
|
Bedrock never stores the gamertag or XUID readably inside the world DB. Three identity layers exist:
|
||||||
|
|
||||||
|
1. **XUID + gamertag** — only appear in the server log (`Player Spawned: <name> xuid: <n>`). `playerDirectory.js` scans docker logs for these lines and persists an XUID-keyed directory at `<dataRoot>/craftdock_players.json` (Java seeds from `usercache.json` instead).
|
||||||
|
2. **`uniqueId` UUID** (MsaId/SelfSignedId) — returned by the `querytarget` console command for an *online* player. ⚠️ `querytarget` also returns an int64 `id` (the entity UniqueID); do not confuse the two.
|
||||||
|
3. **LevelDB records** — `player_<uuid>` mapping records (keyed by MsaId or SelfSignedId) whose NBT `ServerId` field points to the actual data record `player_server_<leveldb-uuid>`.
|
||||||
|
|
||||||
|
`bedrockIdentityBridge.js` learns and persists the XUID → leveldbUuid binding at `<dataRoot>/craftdock_bedrock_ids.json` (learned once while the player is online, works offline forever after). Resolution order in `bedrockPlayerFile.findByUniqueId`: direct `player_<uuid>` mapping lookup first, NBT `UniqueID` scan as fallback.
|
||||||
|
|
||||||
|
### Properties (Options tab)
|
||||||
|
|
||||||
|
`PROPERTY_SCHEMA` in `src/routes/properties.js` is the single source of managed keys: type-based validation on PUT **and** vanilla defaults merged into GET (so the UI always starts fully populated; `motd` deliberately has no default). The frontend `options.js` renders from its own `CATEGORIES` list — when adding a property, update both the schema (backend) and CATEGORIES (frontend).
|
||||||
|
|
||||||
|
### Item icon assets (generated, never committed)
|
||||||
|
|
||||||
|
The player inspector renders Minecraft item icons served from `src/public/assets/mc/items/` — that directory is **gitignored and generated**: `scripts/extract-mc-assets.mjs` copies flat item PNGs out of the `minecraft-assets` devDependency. It runs automatically via `postinstall` for local dev, and in the Dockerfile `assets` stage at image build time (no runtime download — the panel stays offline-capable). Bump the game version with the `ASSETS_VERSION` build arg or `npm run assets -- <version>`. Unknown/modded items fall back to a text tile in the UI.
|
||||||
|
|
||||||
|
### CraftDock metadata files
|
||||||
|
|
||||||
|
Panel-owned files live at the data root, siblings of the world, so they ride along in world exports/backups: `craftdock_players.json` (XUID directory), `craftdock_bedrock_ids.json` (identity bridge).
|
||||||
|
|
||||||
|
## Testing rules
|
||||||
|
|
||||||
|
- **Vitest**, tests mirror `src/` under `test/`. TDD: write the failing test before the fix/feature.
|
||||||
|
- Unit tests inject fake collaborators (plain objects with `vi.fn()`); route tests use `supertest` against a minimal Express app.
|
||||||
|
- `test/fixtures/bedrock-player.nbt` is a **real captured Bedrock player NBT**; the bedrockPlayerFile tests build a real LevelDB in a temp dir around it. Prefer real-format fixtures over hand-mocked parsing.
|
||||||
|
- The whole suite must stay green (`npm test`) before committing.
|
||||||
|
|
||||||
|
## Environment & deployment facts
|
||||||
|
|
||||||
|
- Panel listens on **3000** (config default). The composes only `expose` it (reverse-proxy first); publish on the host only for local testing. Under Coolify the service's target port must be 3000.
|
||||||
|
- Auth: single admin password; prefer `ADMIN_PASSWORD_HASH_B64` under docker compose/Coolify (bcrypt `$` gets eaten by interpolation). `SESSION_SECRET` signs the cookie; `NODE_ENV=production` marks it `secure` (needs HTTPS).
|
||||||
|
- The Minecraft service **must** have `stdin_open: true` + `tty: true` (stdin channel + non-multiplexed logs) and `OVERRIDE_SERVER_PROPERTIES=false` (so the Options tab persists).
|
||||||
|
- The `mc-data` volume is shared panel ↔ game container; `MC_DATA_PATH` points at it inside the panel.
|
||||||
|
- Edition: `MC_EDITION=auto|java|bedrock` (auto reads the container's `TYPE` env). `MC_WORLD_NAME` is `world` (Java) / `worlds` (Bedrock).
|
||||||
|
- Local integration testing: the maintainer runs the stacks via **WSL docker**; the world lives in the `craftdock_mc-data` volume. To inspect real world data safely, stop the containers and copy it out (e.g. `docker run --rm -v craftdock_mc-data:/d alpine tar cf - -C /d worlds`).
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
- ES modules (`"type": "module"`), Node ≥ 18, no build step, no TypeScript.
|
||||||
|
- Factory functions + closures over classes. Small focused files — split before a file grows into multiple responsibilities.
|
||||||
|
- Frontend: no framework, no bundler; each tab exports `render<Tab>(root)` and returns an optional cleanup function. Escape all user-derived strings with the local `esc()` before injecting into HTML.
|
||||||
|
- Comments explain *why*/constraints (protocol quirks, Bedrock oddities), not what the next line does.
|
||||||
|
- UI text is Brazilian Portuguese; code, comments and commits are English.
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# Multi-stage: compile native deps on Debian (glibc) where leveldb-zlib's
|
||||||
|
# cmake-js build is well-supported, then ship a slim runtime.
|
||||||
|
#
|
||||||
|
# Why not Alpine? `leveldb-zlib` is a native addon that compiles from source via
|
||||||
|
# cmake-js. On Alpine/musl (esp. ARM64) that build repeatedly failed at the CMake
|
||||||
|
# configure step; Debian glibc is the reliable path for this toolchain.
|
||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
# scripts/ must exist before npm ci: the postinstall hook runs the (no-op here)
|
||||||
|
# icon extraction. --ignore-scripts is NOT an option — leveldb-zlib compiles
|
||||||
|
# through its own install scripts.
|
||||||
|
COPY scripts ./scripts
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ cmake zlib1g-dev ca-certificates \
|
||||||
|
&& npm ci --omit=dev
|
||||||
|
|
||||||
|
# Item icons are generated at build time (not committed, not downloaded at
|
||||||
|
# runtime): install the versioned minecraft-assets package and extract the flat
|
||||||
|
# item PNGs. Bump ASSETS_VERSION (or rebuild) to pick up new game items.
|
||||||
|
FROM node:20-bookworm-slim AS assets
|
||||||
|
ARG ASSETS_VERSION=latest
|
||||||
|
WORKDIR /app
|
||||||
|
COPY scripts/extract-mc-assets.mjs scripts/
|
||||||
|
RUN npm install --no-save --ignore-scripts minecraft-assets@${ASSETS_VERSION} \
|
||||||
|
&& node scripts/extract-mc-assets.mjs --required
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
# Patch fixable OS CVEs in the base image at build time (trivy image scan).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get upgrade -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# The compiled node-leveldb.node links libz (zlib1g) and libstdc++6, both already
|
||||||
|
# present in the slim base image — so no extra runtime packages are needed.
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY --from=assets /app/src/public/assets/mc ./src/public/assets/mc
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
# Composes may override with their own healthcheck block; this is the built-in
|
||||||
|
# default so the image is self-monitoring anywhere it runs (trivy DS-0026).
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||||
|
CMD ["node", "src/healthcheck.js"]
|
||||||
|
CMD ["node", "src/main.js"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Lucas Rayan Guerra
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# CraftDock
|
||||||
|
|
||||||
|
Lightweight, self-hosted web panel to manage a **single** Minecraft server (Java *or* Bedrock) running as a Docker container — an Aternos-style dashboard you host yourself. Built for a Docker/Coolify deployment.
|
||||||
|
|
||||||
|
The panel never modifies the Minecraft core. It controls the game container over the Docker socket (start/stop/restart/kill, stats, logs) and sends commands through **RCON** (Java) or **container stdin** (Bedrock).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Tab | What it does |
|
||||||
|
|-----|--------------|
|
||||||
|
| **Início / Status** | Start / Stop / Restart / Kill, live CPU / RAM / player count, edition badge |
|
||||||
|
| **Console** | Real-time log stream over WebSocket + command input (RCON / stdin) |
|
||||||
|
| **Jogadores** | Persistent world player directory (paginated, searchable), per-player **inspector modal** (position, health, food, inventory, XP, gamemode — read straight from the world save), whitelist / ban / op management, kick & teleport — controls adapt to what the edition supports |
|
||||||
|
| **Opções** | Friendly editor for `server.properties` (difficulty, PvP, max-players, gamemode, …) — keys missing from the file are pre-filled with their vanilla defaults |
|
||||||
|
| **Mundo** | Download the world as `.zip`, upload/replace a world, regenerate a new world |
|
||||||
|
| **Mapa** | Shows the world seed and embeds a seed map (mcseedmap.net) rendered for that seed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Editions
|
||||||
|
|
||||||
|
CraftDock supports both Minecraft editions through an adapter layer, but they are **not** equivalent:
|
||||||
|
|
||||||
|
- **Java** — commands go over **RCON**. All player controls work: whitelist, ban/pardon, op/deop, give, gamemode, kick, teleport.
|
||||||
|
- **Bedrock** — commands go over the container's **stdin** (`docker attach`). `whitelist` becomes `allowlist`, and **ban / pardon have no native command** — those buttons are hidden in the UI.
|
||||||
|
|
||||||
|
The edition is detected from the container's `TYPE` env var when `MC_EDITION=auto`, or forced with `MC_EDITION=java|bedrock`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Player inspector (world-save reads)
|
||||||
|
|
||||||
|
The Players tab keeps a persistent directory of everyone who ever spawned in the world and can open a per-player detail modal with position, health, food, inventory, XP and gamemode. All of it is **read directly from the world save, read-only** — never via console commands:
|
||||||
|
|
||||||
|
- **Java** — `world/playerdata/<uuid>.dat` (names seeded from `usercache.json`).
|
||||||
|
- **Bedrock** — the world **LevelDB**. Bedrock never stores gamertags in the DB, so CraftDock builds the identity chain itself: XUID+name from `Player Spawned` log lines (`craftdock_players.json`), then — the first time you open an **online** player — `querytarget` yields the player's `uniqueId` UUID, which keys the DB's `player_<uuid>` mapping record pointing to the real `player_server_<uuid>` data record. The binding is persisted (`craftdock_bedrock_ids.json`), so offline reads work forever after.
|
||||||
|
|
||||||
|
While the server is running, reads happen inside a save snapshot (`save hold`/`save query`/`save resume` on Bedrock, `save-all flush` on Java) so files are consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live map & seed
|
||||||
|
|
||||||
|
The Map tab shows the world seed and embeds [mcseedmap.net](https://mcseedmap.net) pointed at it (a real biome/structure map that allows iframe embedding). The seed is resolved automatically:
|
||||||
|
|
||||||
|
1. `level-seed` from `server.properties` (when the operator set a fixed seed), else
|
||||||
|
2. **Bedrock** — read from `worlds/<level-name>/level.dat` (the `RandomSeed` NBT long). Bedrock has **no** `seed` console command, so this is the only reliable source.
|
||||||
|
3. **Java** — the `seed` command over RCON.
|
||||||
|
|
||||||
|
The mcseedmap version segment defaults per edition; override it with `MAP_VERSION` (e.g. `1.21-Java`, `26.30.0-Bedrock`) if the biomes don't match your server version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The panel emits structured JSON logs (one record per line: `ts`, `level`, `component`, `msg`, optional `meta`) to stdout. Level is controlled by `LOG_LEVEL` (`error` < `warn` < `info` < `debug`; default `info`). Command channels (RCON / stdin), seed resolution, HTTP requests, and unhandled errors are all logged — run with `LOG_LEVEL=debug` to see every command sent to the server and its response.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.bedrock.yaml logs -f # follow panel + server logs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Node.js ≥ 18** (dev) or Docker (deploy).
|
||||||
|
- A Docker host — the panel mounts `/var/run/docker.sock` to control the game container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start (local development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> && cd CraftDock
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 1. Generate the admin password hash (prints a raw and a base64 line):
|
||||||
|
npm run hash -- <your-password>
|
||||||
|
|
||||||
|
# 2. Create .env from the template and fill it in:
|
||||||
|
cp .env.example .env
|
||||||
|
# - ADMIN_PASSWORD_HASH = the raw hash line from step 1 (best for `npm run dev`)
|
||||||
|
# - SESSION_SECRET = any long random string
|
||||||
|
# - RCON_PASSWORD = your server's RCON password (leave empty for Bedrock)
|
||||||
|
|
||||||
|
# 3. Run the tests and start the dev server:
|
||||||
|
npm test
|
||||||
|
npm run dev # loads .env automatically, hot-reloads on change
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:3000> and log in with your password.
|
||||||
|
|
||||||
|
> `npm run dev` uses `node --env-file=.env`, so your `.env` is loaded automatically.
|
||||||
|
> `npm start` (production) does **not** load `.env` — it reads real environment
|
||||||
|
> variables provided by the container/host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running with Docker
|
||||||
|
|
||||||
|
The panel image is built locally from the included `Dockerfile` — it is **not** published to any registry, so a plain `docker compose pull`/`up` without a build step fails with *"pull access denied"*. Always build it:
|
||||||
|
|
||||||
|
Two reference stacks are provided:
|
||||||
|
|
||||||
|
### Java (`docker-compose.java.yaml`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# secrets are read from .env by docker compose (${ADMIN_PASSWORD_HASH}, etc.)
|
||||||
|
docker compose -f docker-compose.java.yaml up -d --build
|
||||||
|
docker compose -f docker-compose.java.yaml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bedrock (`docker-compose.bedrock.yaml`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.bedrock.yaml up -d --build
|
||||||
|
docker compose -f docker-compose.bedrock.yaml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Both stacks share the **same** panel env block (only the edition, world folder and
|
||||||
|
Minecraft image differ) and read their values from `.env` — see [`.env.example`](.env.example)
|
||||||
|
for the canonical list.
|
||||||
|
|
||||||
|
> **The panel only `expose`s port 3000** (reachable over the Docker network) —
|
||||||
|
> it is **not** published on the host by default, because the composes target a
|
||||||
|
> reverse proxy (Coolify/Traefik terminates TLS on 443 and forwards to 3000).
|
||||||
|
> In Coolify set the service domain **without a port** (`https://your.domain`,
|
||||||
|
> not `:3000`) and make sure the service's **target port is 3000** — a mismatch
|
||||||
|
> (e.g. Traefik still forwarding to an old port) makes plain 443 return
|
||||||
|
> *"no available server"*.
|
||||||
|
> For **local** testing without a proxy, comment out `expose` and uncomment the
|
||||||
|
> `ports:` line in the panel service, set `NODE_ENV=development` in `.env` (so
|
||||||
|
> the secure cookie isn't dropped over plain HTTP), then open
|
||||||
|
> <http://localhost:3000>.
|
||||||
|
|
||||||
|
> **Rebuild after changing panel code:** `--build` is required the first time and
|
||||||
|
> whenever you change the panel source. A runtime-only change (env var) just needs
|
||||||
|
> `up -d --force-recreate`.
|
||||||
|
|
||||||
|
> **First Bedrock boot is slow** — `itzg/minecraft-bedrock-server` downloads the
|
||||||
|
> latest version. Watch the logs until you see the server report it has started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment prerequisites (Coolify / production)
|
||||||
|
|
||||||
|
The panel controls an existing, **fixed** container — it never recreates it or changes `TYPE`/`VERSION`. For everything to work, the Minecraft service must be configured as follows:
|
||||||
|
|
||||||
|
1. **`stdin_open: true` and `tty: true`** on the Minecraft service. Without a TTY, Docker returns a *multiplexed* log stream (8-byte frame headers) that garbles the Console tab, and stdin (the Bedrock command channel) never reaches the server process.
|
||||||
|
2. **`OVERRIDE_SERVER_PROPERTIES=false`**, otherwise the `itzg` image regenerates `server.properties` from env vars on every start and the Options tab won't persist.
|
||||||
|
3. **Java only:** `ENABLE_RCON=true` + a shared `RCON_PASSWORD`.
|
||||||
|
4. Panel and Minecraft server on the **same Docker network**.
|
||||||
|
5. The `mc-data` volume **shared** between the panel and the Minecraft server (the panel reads/writes world files directly).
|
||||||
|
6. Set `ADMIN_PASSWORD_HASH_B64` (the base64 line from `npm run hash`) and a strong `SESSION_SECRET`. Prefer the base64 form here — a raw bcrypt hash's `$` chars get mangled by docker compose / Coolify interpolation.
|
||||||
|
7. Run the panel with **`NODE_ENV=production`** so the session cookie is marked `secure` — this requires serving the panel over **HTTPS** (Coolify/Traefik terminate TLS on 443). The app already calls `trust proxy`, so the secure cookie works correctly behind the reverse proxy.
|
||||||
|
8. *(Optional)* Set `MAP_VERSION` if the Map tab's mcseedmap version doesn't match your server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
|
| `ADMIN_PASSWORD_HASH` | ✅¹ | — | Raw bcrypt hash (`npm run hash -- <pw>`). Best for `npm run dev` |
|
||||||
|
| `ADMIN_PASSWORD_HASH_B64` | ✅¹ | — | Base64 of the bcrypt hash. Best for docker compose / Coolify (no `$` escaping) |
|
||||||
|
| `SESSION_SECRET` | ✅ | — | Long random string signing the session cookie |
|
||||||
|
| `MC_CONTAINER_NAME` | | `craftdock-mc-server` | Minecraft container name (matched first) |
|
||||||
|
| `MC_SERVICE_NAME` | | `minecraft-server` | Compose service name — fallback match when the container name is auto-generated (e.g. Coolify) |
|
||||||
|
| `MC_DATA_PATH` | | `/minecraft/data` | Path (inside the panel) to the shared MC data volume |
|
||||||
|
| `MC_EDITION` | | `auto` | `auto` \| `java` \| `bedrock` |
|
||||||
|
| `MC_WORLD_NAME` | | `world` | World folder under `MC_DATA_PATH` (Bedrock: `worlds`) |
|
||||||
|
| `RCON_HOST` | | `craftdock-mc-server` | RCON host (Java) |
|
||||||
|
| `RCON_PORT` | | `25575` | RCON port (Java) |
|
||||||
|
| `RCON_PASSWORD` | | *(empty)* | RCON password — **required for Java**, leave empty for Bedrock |
|
||||||
|
| `MAP_VERSION` | | *(per edition)* | mcseedmap version segment override (e.g. `1.21-Java`, `26.30.0-Bedrock`) |
|
||||||
|
| `PORT` | | `3000` | Port the panel listens on (also the exposed/target port in the composes) |
|
||||||
|
| `NODE_ENV` | | `development` | `production` marks the session cookie `secure` (needs HTTPS) |
|
||||||
|
| `LOG_LEVEL` | | `info` | `error` \| `warn` \| `info` \| `debug` |
|
||||||
|
| `MAX_UPLOAD_MB` | | `1024` | Max world upload size (MB) |
|
||||||
|
|
||||||
|
¹ Provide **one** of `ADMIN_PASSWORD_HASH` or `ADMIN_PASSWORD_HASH_B64`. `npm run hash -- <pw>` prints both; the raw hash wins if both are set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- The panel mounts the Docker socket, which is **equivalent to root on the host**. Never expose it unauthenticated. Access is gated by a single admin password (bcrypt) with a signed session, enforced on every `/api/*` route **and** the Socket.io handshake.
|
||||||
|
- Always serve the panel over **HTTPS** in production (secure session cookie).
|
||||||
|
- Keep `SESSION_SECRET` secret and unique per deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`pull access denied for craftdock-panel` on `docker compose up`**
|
||||||
|
The panel image is built locally, not pulled. Add `--build`: `docker compose up -d --build`.
|
||||||
|
|
||||||
|
**`failed to bind host port 0.0.0.0:3000: address already in use`**
|
||||||
|
Something else is on port 3000 — usually a leftover `npm run dev`. Find and stop it:
|
||||||
|
```powershell
|
||||||
|
Get-NetTCPConnection -LocalPort 3000 -State Listen # find the PID
|
||||||
|
Stop-Process -Id <PID> -Force # stop it
|
||||||
|
```
|
||||||
|
Then bring the stack up again.
|
||||||
|
|
||||||
|
**`ERR_CONNECTION_REFUSED` even though the panel logs "listening on :3000"**
|
||||||
|
The container is listening internally but the host port wasn't published — typically a container that was *created* during a failed port-bind and then only restarted. Recreate it:
|
||||||
|
```bash
|
||||||
|
docker compose -f <compose-file> down
|
||||||
|
docker compose -f <compose-file> up -d --force-recreate
|
||||||
|
```
|
||||||
|
Confirm the host is listening: `Get-NetTCPConnection -LocalPort 3000 -State Listen`.
|
||||||
|
|
||||||
|
**Login succeeds (no error) but the page stays on the login screen**
|
||||||
|
The session cookie isn't being stored. It's marked `secure` (from `NODE_ENV=production`) while you're on plain `http://localhost` — browsers drop secure cookies over HTTP. For local HTTP testing set `NODE_ENV=development`; in production serve over HTTPS. (Behind a reverse proxy, the app already trusts `X-Forwarded-Proto` via `trust proxy`.)
|
||||||
|
|
||||||
|
**Console tab shows garbled/binary characters**
|
||||||
|
The Minecraft container is missing `stdin_open: true` + `tty: true`. Add both and recreate it.
|
||||||
|
|
||||||
|
**Options tab changes don't persist after restart**
|
||||||
|
The Minecraft service is missing `OVERRIDE_SERVER_PROPERTIES=false`.
|
||||||
|
|
||||||
|
**Coolify: `Bind for 0.0.0.0:3000 failed: port is already allocated`**
|
||||||
|
Don't publish the panel's port on the host under Coolify — its Traefik reaches the container over the network. The composes already ship with `expose: ["3000"]` and the `ports:` line commented out (this is the default); keep it that way and set the service **domain** in Coolify. Keep `ports:` only on the Minecraft service (the raw game port).
|
||||||
|
|
||||||
|
**Coolify: `no available server` when opening `https://your.domain/`**
|
||||||
|
Traefik has a route for the domain but is forwarding to the wrong container port, so the backend looks empty. Two things to check: (1) the Coolify domain must be set **without a port** (`https://your.domain`, not `:3000`); (2) the service's **target port must be 3000** — the panel listens on 3000. A common trap is an old target port left over from a previous setup (e.g. Traefik still pointing at a port the app no longer listens on): align it to 3000 and redeploy.
|
||||||
|
|
||||||
|
**Coolify / docker compose: login fails, or warning `The "…" variable is not set. Defaulting to a blank string.`**
|
||||||
|
The `…` is the tail of your bcrypt hash — bcrypt hashes contain `$`, which docker compose interprets as variable interpolation, corrupting the hash. **Use `ADMIN_PASSWORD_HASH_B64`** (the base64 line from `npm run hash`) instead of the raw `ADMIN_PASSWORD_HASH` — base64 has no `$`, so nothing gets mangled. (Alternatively, keep the raw hash but escape every `$` as `$$`.)
|
||||||
|
|
||||||
|
**Status shows `not_found` on Coolify (panel can't see the server)**
|
||||||
|
Coolify ignores `container_name` and auto-generates names like `minecraft-server-<project>-<hash>`, so matching by `MC_CONTAINER_NAME` fails. The panel falls back to the compose **service** name — make sure the Minecraft service is named `minecraft-server` (or set `MC_SERVICE_NAME` to match) and that both services are in the **same** Coolify resource (project). The panel also needs the Docker socket mounted (`/var/run/docker.sock`).
|
||||||
|
|
||||||
|
**Map tab shows "waiting for seed" / no map**
|
||||||
|
The seed is resolved only while the server is **running**. For Bedrock it comes from `worlds/<level-name>/level.dat`, so the `mc-data` volume must be shared with the panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # run the full Vitest suite
|
||||||
|
npm run test:watch
|
||||||
|
npm run dev # start with --env-file=.env and --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── server.js # Express + Socket.io composition
|
||||||
|
├── main.js # composition root (wires real dependencies)
|
||||||
|
├── config.js # env var loading + validation
|
||||||
|
├── logger.js # structured JSON logger
|
||||||
|
├── healthcheck.js # container healthcheck script
|
||||||
|
├── appState.js # cached edition/adapter resolution
|
||||||
|
├── middleware/ # auth (HTTP + socket handshake)
|
||||||
|
├── services/ # docker, rcon, stdin, properties, world, auth, seed,
|
||||||
|
│ # playerDirectory, playerDetailService, bedrockIdentityBridge
|
||||||
|
├── adapters/ # ServerAdapter: java (RCON) / bedrock (stdin)
|
||||||
|
│ └── playerFile/ # read-only world-save readers (Java NBT / Bedrock LevelDB)
|
||||||
|
├── routes/ # auth, status, players, properties, world
|
||||||
|
├── sockets/ # logs stream + status push namespaces
|
||||||
|
└── public/ # vanilla-JS tabbed SPA (Tailwind via CDN)
|
||||||
|
```
|
||||||
|
|
||||||
|
CraftDock also keeps two metadata files at the data root (bundled into world backups): `craftdock_players.json` (persistent XUID→name player directory) and `craftdock_bedrock_ids.json` (Bedrock identity bridge). See [CLAUDE.md](CLAUDE.md) for the full architecture and contribution conventions (numbered feature branches, `#N:` commit prefix).
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# CraftDock — Bedrock stack.
|
||||||
|
# Usage: docker compose -f docker-compose.bedrock.yaml up -d --build
|
||||||
|
# Secrets/overrides (${...}) are read from the project .env by docker compose.
|
||||||
|
# See the "Environment variables" table in README.md for every variable.
|
||||||
|
services:
|
||||||
|
craftdock-panel:
|
||||||
|
build: .
|
||||||
|
container_name: craftdock-panel
|
||||||
|
# Behind a reverse proxy (Coolify/Traefik) the panel is reached over the
|
||||||
|
# internal Docker network, so only EXPOSE the port — do NOT publish it on the
|
||||||
|
# host. In Coolify, set the service domain WITHOUT a port
|
||||||
|
# (https://your.domain, not https://your.domain:3000) and make sure the
|
||||||
|
# service's target port is 3000 — otherwise Traefik forwards to the wrong
|
||||||
|
# port and plain 443 returns "no available server".
|
||||||
|
#
|
||||||
|
# For LOCAL testing without a proxy, comment out `expose` and uncomment
|
||||||
|
# `ports` to publish on the host, then open http://localhost:3000.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
# ports:
|
||||||
|
# - "${PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- mc-data:/minecraft/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
# base64 of the bcrypt hash — avoids docker/Coolify eating the `$` chars.
|
||||||
|
- ADMIN_PASSWORD_HASH_B64=${ADMIN_PASSWORD_HASH_B64}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- MC_CONTAINER_NAME=craftdock-mc-server
|
||||||
|
- MC_DATA_PATH=/minecraft/data
|
||||||
|
- MC_EDITION=bedrock
|
||||||
|
- MC_WORLD_NAME=worlds
|
||||||
|
- RCON_HOST=craftdock-mc-server
|
||||||
|
- RCON_PORT=25575
|
||||||
|
- RCON_PASSWORD=${RCON_PASSWORD:-} # Bedrock ignores RCON; leave empty
|
||||||
|
- MAP_VERSION=${MAP_VERSION:-}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "src/healthcheck.js"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks: [craftdock-net]
|
||||||
|
# `always` (not `unless-stopped`): the panel must come back after ANY exit —
|
||||||
|
# a crash, its own healthcheck self-restart (kill PID 1), a manual stop, or a
|
||||||
|
# host/daemon reboot. It's the control plane, so it should never stay down.
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
minecraft-server:
|
||||||
|
image: itzg/minecraft-bedrock-server:latest
|
||||||
|
container_name: craftdock-mc-server
|
||||||
|
# stdin_open + tty are REQUIRED by CraftDock: without a TTY, stdin (the Bedrock
|
||||||
|
# command channel via `docker attach`) never reaches the server, and the log
|
||||||
|
# stream is multiplexed (garbles the Console tab).
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
ports:
|
||||||
|
- "19132:19132/udp" # Bedrock game port (raw UDP — open it in the host firewall)
|
||||||
|
volumes:
|
||||||
|
- mc-data:/data
|
||||||
|
environment:
|
||||||
|
- EULA=TRUE
|
||||||
|
- OVERRIDE_SERVER_PROPERTIES=false # so the Options tab persists
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "/usr/local/bin/mc-health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 120s
|
||||||
|
networks: [craftdock-net]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mc-data:
|
||||||
|
networks:
|
||||||
|
craftdock-net:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# CraftDock — Java stack.
|
||||||
|
# Usage: docker compose -f docker-compose.java.yaml up -d --build
|
||||||
|
# Secrets/overrides (${...}) are read from the project .env by docker compose.
|
||||||
|
# See the "Environment variables" table in README.md for every variable.
|
||||||
|
services:
|
||||||
|
craftdock-panel:
|
||||||
|
build: .
|
||||||
|
container_name: craftdock-panel
|
||||||
|
# Behind a reverse proxy (Coolify/Traefik) the panel is reached over the
|
||||||
|
# internal Docker network, so only EXPOSE the port — do NOT publish it on the
|
||||||
|
# host. In Coolify, set the service domain WITHOUT a port
|
||||||
|
# (https://your.domain, not https://your.domain:3000) and make sure the
|
||||||
|
# service's target port is 3000 — otherwise Traefik forwards to the wrong
|
||||||
|
# port and plain 443 returns "no available server".
|
||||||
|
#
|
||||||
|
# For LOCAL testing without a proxy, comment out `expose` and uncomment
|
||||||
|
# `ports` to publish on the host, then open http://localhost:3000.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
# ports:
|
||||||
|
# - "${PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- mc-data:/minecraft/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
# base64 of the bcrypt hash — avoids docker/Coolify eating the `$` chars.
|
||||||
|
- ADMIN_PASSWORD_HASH_B64=${ADMIN_PASSWORD_HASH_B64}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- MC_CONTAINER_NAME=craftdock-mc-server
|
||||||
|
- MC_DATA_PATH=/minecraft/data
|
||||||
|
- MC_EDITION=java
|
||||||
|
- MC_WORLD_NAME=world
|
||||||
|
- RCON_HOST=craftdock-mc-server
|
||||||
|
- RCON_PORT=25575
|
||||||
|
- RCON_PASSWORD=${RCON_PASSWORD:-}
|
||||||
|
- MAP_VERSION=${MAP_VERSION:-}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "src/healthcheck.js"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks: [craftdock-net]
|
||||||
|
# `always` (not `unless-stopped`): the panel must come back after ANY exit —
|
||||||
|
# a crash, its own healthcheck self-restart (kill PID 1), a manual stop, or a
|
||||||
|
# host/daemon reboot. It's the control plane, so it should never stay down.
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
minecraft-server:
|
||||||
|
image: itzg/minecraft-server:latest
|
||||||
|
container_name: craftdock-mc-server
|
||||||
|
# stdin_open + tty are REQUIRED by CraftDock: without a TTY, Docker returns a
|
||||||
|
# multiplexed log stream (8-byte frame headers) that garbles the Console tab.
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
ports:
|
||||||
|
- "25565:25565" # Java game port (raw TCP — open it in the host firewall)
|
||||||
|
volumes:
|
||||||
|
- mc-data:/data
|
||||||
|
environment:
|
||||||
|
- EULA=TRUE
|
||||||
|
- TYPE=PAPER
|
||||||
|
- VERSION=LATEST
|
||||||
|
- ENABLE_RCON=true
|
||||||
|
- RCON_PASSWORD=${RCON_PASSWORD:-} # required for the panel's Java command channel
|
||||||
|
- OVERRIDE_SERVER_PROPERTIES=false # so the Options tab persists
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "/usr/local/bin/mc-health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 120s
|
||||||
|
networks: [craftdock-net]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mc-data:
|
||||||
|
networks:
|
||||||
|
craftdock-net:
|
||||||
|
driver: bridge
|
||||||
Generated
+4899
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "craftdock",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/main.js",
|
||||||
|
"dev": "node --watch --env-file=.env src/main.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"hash": "node scripts/hash-password.js",
|
||||||
|
"postinstall": "node scripts/extract-mc-assets.mjs",
|
||||||
|
"assets": "node scripts/extract-mc-assets.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"archiver": "^6.0.1",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"dockerode": "^4.0.2",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"leveldb-zlib": "^1.2.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"prismarine-nbt": "^2.8.0",
|
||||||
|
"rcon-client": "^4.2.4",
|
||||||
|
"socket.io": "^4.7.5",
|
||||||
|
"unzipper": "^0.10.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"minecraft-assets": "^1.17.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"axios": "^1.18.0",
|
||||||
|
"tar": "^7.5.18",
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Extracts flat item icons from the minecraft-assets devDependency into
|
||||||
|
// src/public/assets/mc/items/<item>.png so the panel serves them locally
|
||||||
|
// (offline-friendly, no runtime dependency, tiny footprint vs the full pkg).
|
||||||
|
//
|
||||||
|
// Usage: node scripts/extract-mc-assets.mjs [version] [--required]
|
||||||
|
// version defaults to the newest available in the package
|
||||||
|
// --required fail (exit 1) when minecraft-assets is not installed — used by
|
||||||
|
// the Dockerfile assets stage. Without it the script is a no-op
|
||||||
|
// warning, so `npm ci --omit=dev` (postinstall) doesn't break.
|
||||||
|
//
|
||||||
|
// The output dir is gitignored: icons are generated at Docker build time (see
|
||||||
|
// Dockerfile "assets" stage) and by postinstall for local dev.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dataRoot = path.join(here, '..', 'node_modules', 'minecraft-assets', 'minecraft-assets', 'data');
|
||||||
|
const outDir = path.join(here, '..', 'src', 'public', 'assets', 'mc', 'items');
|
||||||
|
|
||||||
|
const required = process.argv.includes('--required');
|
||||||
|
const args = process.argv.slice(2).filter((a) => a !== '--required');
|
||||||
|
|
||||||
|
if (!fs.existsSync(dataRoot)) {
|
||||||
|
if (required) {
|
||||||
|
console.error('minecraft-assets is not installed — run `npm install` (with dev deps) first');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.warn('minecraft-assets not installed (prod install?) — skipping icon extraction');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = fs.readdirSync(dataRoot)
|
||||||
|
.filter((v) => /^\d+\.\d+(\.\d+)?$/.test(v))
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||||
|
const version = args[0] || versions.at(-1);
|
||||||
|
const src = path.join(dataRoot, version);
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
console.error(`version ${version} not found; available: ${versions.join(', ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = JSON.parse(fs.readFileSync(path.join(src, 'items_textures.json'), 'utf8'));
|
||||||
|
fs.rmSync(outDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
let copied = 0, skipped = 0;
|
||||||
|
for (const { name, texture } of entries) {
|
||||||
|
if (!name || !texture || texture.includes('missingno')) { skipped++; continue; }
|
||||||
|
// texture is like "minecraft:item/iron_sword" or "minecraft:block/dirt"
|
||||||
|
const rel = texture.replace(/^minecraft:/, '').replace(/^item\//, 'items/').replace(/^block\//, 'blocks/');
|
||||||
|
const file = path.join(src, `${rel}.png`);
|
||||||
|
if (!fs.existsSync(file)) { skipped++; continue; }
|
||||||
|
fs.copyFileSync(file, path.join(outDir, `${name}.png`));
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`minecraft ${version}: ${copied} item icons → ${path.relative(process.cwd(), outDir)} (${skipped} without usable texture)`);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// scripts/hash-password.js
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const password = process.argv[2];
|
||||||
|
if (!password) {
|
||||||
|
console.error('Usage: npm run hash -- <password>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
|
const b64 = Buffer.from(hash, 'utf8').toString('base64');
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`# Pick ONE. Set it as an environment variable.\n` +
|
||||||
|
`\n` +
|
||||||
|
`# Raw hash — use with 'npm run dev' (plain .env, no interpolation):\n` +
|
||||||
|
`ADMIN_PASSWORD_HASH=${hash}\n` +
|
||||||
|
`\n` +
|
||||||
|
`# Base64 hash — use with docker compose / Coolify (no '$' escaping needed):\n` +
|
||||||
|
`ADMIN_PASSWORD_HASH_B64=${b64}\n`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { parsePlayerList, parseWhitelistList, CAPABILITIES, NotSupportedError } from './serverAdapter.js';
|
||||||
|
|
||||||
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Parse the console output of `querytarget` into its first target object, or
|
||||||
|
// null. Strips color codes, log-line prefixes and the "Target data:" label.
|
||||||
|
export function parseQueryTarget(res) {
|
||||||
|
if (!res) return null;
|
||||||
|
try {
|
||||||
|
let clean = res.replace(/§[0-9a-fk-or]/ig, '').trim();
|
||||||
|
clean = clean.replace(/^\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}:\d{3}\s+\w+\]\s*/gm, '');
|
||||||
|
if (clean.includes('Target data:')) {
|
||||||
|
clean = clean.substring(clean.indexOf('Target data:') + 'Target data:'.length);
|
||||||
|
}
|
||||||
|
const s = clean.indexOf('['), e = clean.lastIndexOf(']');
|
||||||
|
if (s === -1 || e === -1 || e <= s) return null;
|
||||||
|
const data = JSON.parse(clean.substring(s, e + 1));
|
||||||
|
return data?.[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBedrockAdapter(stdinService) {
|
||||||
|
const send = (cmd) => stdinService.send(cmd);
|
||||||
|
return {
|
||||||
|
get capabilities() { return CAPABILITIES.BEDROCK; },
|
||||||
|
sendCommand: (raw) => send(raw.startsWith('/') ? raw.slice(1) : raw),
|
||||||
|
async listPlayers() { return parsePlayerList(await send('list')); },
|
||||||
|
whitelistAdd: (n) => send(`allowlist add "${n}"`),
|
||||||
|
whitelistRemove: (n) => send(`allowlist remove "${n}"`),
|
||||||
|
whitelistOn: () => send('allowlist on'),
|
||||||
|
whitelistOff: () => send('allowlist off'),
|
||||||
|
async whitelistList() { return parseWhitelistList(await send('allowlist list')); },
|
||||||
|
async ban() { throw new NotSupportedError('ban'); },
|
||||||
|
async pardon() { throw new NotSupportedError('pardon'); },
|
||||||
|
op: (n) => send(`op "${n}"`),
|
||||||
|
deop: (n) => send(`deop "${n}"`),
|
||||||
|
kick: (n, reason = '') => send(`kick "${n}" ${reason}`.trim()),
|
||||||
|
give: (n, item, count = 1) => send(`give "${n}" ${item} ${count}`),
|
||||||
|
gamemode: (n, mode) => send(`gamemode ${mode} "${n}"`),
|
||||||
|
teleport: (n, target) => send(`tp "${n}" ${target}`),
|
||||||
|
// Snapshot protocol for safely reading the LevelDB while the server runs:
|
||||||
|
// `save hold` → poll `save query` until the files are flushed and ready →
|
||||||
|
// (caller reads the files) → `saveResume`. Returns true when ready.
|
||||||
|
async saveHold() {
|
||||||
|
try {
|
||||||
|
await send('save hold');
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const out = await send('save query');
|
||||||
|
if (/ready to be copied|Data saved/i.test(out)) return true;
|
||||||
|
await delay(250);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Command stream issue or timeout — snapshot hold failed
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
saveResume: () => send('save resume').catch(() => {}),
|
||||||
|
|
||||||
|
// Returns the live entity's uniqueId (the player's MsaId/SelfSignedId UUID,
|
||||||
|
// which keys the LevelDB player_<uuid> mapping record), used to bridge a
|
||||||
|
// gamertag to its LevelDB record. Online players only.
|
||||||
|
async queryUniqueId(n) {
|
||||||
|
try {
|
||||||
|
const res = await send(`querytarget @a[name="${n}"]`);
|
||||||
|
const target = parseQueryTarget(res);
|
||||||
|
if (target?.uniqueId != null) return String(target.uniqueId);
|
||||||
|
// Fallback: regex over raw output when the JSON block is mangled
|
||||||
|
const match = res?.match(/"uniqueId"\s*:\s*(-?\d+|"[^"]+")/i);
|
||||||
|
return match ? match[1].replace(/"/g, '') : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// NOTE: Bedrock has no `seed` console command (Java-only). The seed is read
|
||||||
|
// from worlds/<level-name>/level.dat by seedService — do NOT add getSeed here.
|
||||||
|
async getPlayerPosition(n) {
|
||||||
|
try {
|
||||||
|
const target = parseQueryTarget(await send(`querytarget @a[name="${n}"]`));
|
||||||
|
if (!target?.position) return null;
|
||||||
|
const { x, y, z } = target.position;
|
||||||
|
const dimension = target.dimension === 1 ? 'nether' : target.dimension === 2 ? 'end' : 'overworld';
|
||||||
|
return { x, y, z, dimension };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createJavaAdapter } from './javaAdapter.js';
|
||||||
|
import { createBedrockAdapter } from './bedrockAdapter.js';
|
||||||
|
|
||||||
|
export function isBedrock(type) {
|
||||||
|
return typeof type === 'string' && type.toUpperCase() === 'BEDROCK';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdapter(type, { rconService, stdinService }) {
|
||||||
|
return isBedrock(type)
|
||||||
|
? createBedrockAdapter(stdinService)
|
||||||
|
: createJavaAdapter(rconService);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { parsePlayerList, parseWhitelistList, CAPABILITIES } from './serverAdapter.js';
|
||||||
|
|
||||||
|
export function createJavaAdapter(rconService) {
|
||||||
|
const send = (cmd) => rconService.send(cmd);
|
||||||
|
return {
|
||||||
|
get capabilities() { return CAPABILITIES.JAVA; },
|
||||||
|
sendCommand: (raw) => send(raw.startsWith('/') ? raw.slice(1) : raw),
|
||||||
|
async listPlayers() { return parsePlayerList(await send('list')); },
|
||||||
|
whitelistAdd: (n) => send(`whitelist add ${n}`),
|
||||||
|
whitelistRemove: (n) => send(`whitelist remove ${n}`),
|
||||||
|
whitelistOn: () => send('whitelist on'),
|
||||||
|
whitelistOff: () => send('whitelist off'),
|
||||||
|
async whitelistList() { return parseWhitelistList(await send('whitelist list')); },
|
||||||
|
ban: (n, reason = '') => send(`ban ${n} ${reason}`.trim()),
|
||||||
|
pardon: (n) => send(`pardon ${n}`),
|
||||||
|
op: (n) => send(`op ${n}`),
|
||||||
|
deop: (n) => send(`deop ${n}`),
|
||||||
|
kick: (n, reason = '') => send(`kick ${n} ${reason}`.trim()),
|
||||||
|
give: (n, item, count = 1) => send(`give ${n} ${item} ${count}`),
|
||||||
|
gamemode: (n, mode) => send(`gamemode ${mode} ${n}`),
|
||||||
|
teleport: (n, target) => send(`tp ${n} ${target}`),
|
||||||
|
// Flush all worlds to disk so a subsequent playerdata read is fresh. Java's
|
||||||
|
// save is atomic per file, so no hold/resume dance is needed.
|
||||||
|
forceSave: () => send('save-all flush'),
|
||||||
|
async getSeed() {
|
||||||
|
const res = await send('seed');
|
||||||
|
const clean = res.replace(/§[0-9a-fk-or]/ig, '');
|
||||||
|
const m = clean.match(/Seed:\s*\[?(-?\d+)\]?/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
},
|
||||||
|
async getPlayerPosition(n) {
|
||||||
|
try {
|
||||||
|
const res = await send(`tp ${n} ~ ~ ~`);
|
||||||
|
const clean = res.replace(/§[0-9a-fk-or]/ig, '');
|
||||||
|
const m = clean.match(/to\s+(-?\d+(?:\.\d+)?)(?:,\s*|\s+)(-?\d+(?:\.\d+)?)(?:,\s*|\s+)(-?\d+(?:\.\d+)?)/i);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
let dimension = 'overworld';
|
||||||
|
try {
|
||||||
|
const resDim = await send(`data get entity ${n} Dimension`);
|
||||||
|
const cleanDim = resDim.replace(/§[0-9a-fk-or]/ig, '');
|
||||||
|
if (cleanDim.includes('the_nether')) dimension = 'nether';
|
||||||
|
else if (cleanDim.includes('the_end')) dimension = 'end';
|
||||||
|
} catch { /* ignore dimension if command fails */ }
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: parseFloat(m[1]),
|
||||||
|
y: parseFloat(m[2]),
|
||||||
|
z: parseFloat(m[3]),
|
||||||
|
dimension
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import pkg from 'leveldb-zlib';
|
||||||
|
import nbt from 'prismarine-nbt';
|
||||||
|
import { normalizeBedrock } from './nbtPlayer.js';
|
||||||
|
|
||||||
|
const { LevelDB, Iterator } = pkg;
|
||||||
|
|
||||||
|
const SERVER_PREFIX = 'player_server_';
|
||||||
|
const PLAYER_PREFIX = 'player_';
|
||||||
|
|
||||||
|
// Helper for matching uniqueId whether given as a decimal integer string or UUID format string
|
||||||
|
export function matchUniqueId(a, b) {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
const cleanA = String(a).replace(/-/g, '').toLowerCase();
|
||||||
|
const cleanB = String(b).replace(/-/g, '').toLowerCase();
|
||||||
|
if (cleanA === cleanB) return true;
|
||||||
|
|
||||||
|
// Bedrock querytarget uniqueId can be a 128-bit UUID string or 64-bit int string / decimal
|
||||||
|
// Compare low 64 bits or numeric BigInt representations if compatible
|
||||||
|
try {
|
||||||
|
const toBigInt = (str) => {
|
||||||
|
const c = str.replace(/-/g, '');
|
||||||
|
if (/^[0-9a-f]{32}$/i.test(c)) {
|
||||||
|
// Take lower 64 bits of hex UUID
|
||||||
|
const lowHex = c.slice(16);
|
||||||
|
const big = BigInt('0x' + lowHex);
|
||||||
|
const shift = 1n << 63n;
|
||||||
|
const mask = (1n << 64n) - 1n;
|
||||||
|
const val = big & mask;
|
||||||
|
return val >= shift ? val - (1n << 64n) : val;
|
||||||
|
}
|
||||||
|
if (/^-?\d+$/.test(str)) {
|
||||||
|
return BigInt(str);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const bigA = toBigInt(String(a));
|
||||||
|
const bigB = toBigInt(String(b));
|
||||||
|
if (bigA !== null && bigB !== null && bigA === bigB) return true;
|
||||||
|
} catch { /* ignore parse error */ }
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse one Bedrock player NBT value (little-endian) into PlayerData.
|
||||||
|
export async function parsePlayerBuffer(buf) {
|
||||||
|
const { parsed } = await nbt.parse(Buffer.from(buf), 'little');
|
||||||
|
return normalizeBedrock(nbt.simplify(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the world's LevelDB directory. Bedrock stores it at
|
||||||
|
// <mcDataPath>/<mcWorldName>/<Level Name>/db (CraftDock uses MC_WORLD_NAME=worlds
|
||||||
|
// with the actual level folder inside). Falls back to <mcDataPath>/<mcWorldName>/db.
|
||||||
|
export function resolveDbPath(config) {
|
||||||
|
const base = path.join(config.mcDataPath, config.mcWorldName || 'worlds');
|
||||||
|
try {
|
||||||
|
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory() && fs.existsSync(path.join(base, entry.name, 'db'))) {
|
||||||
|
return path.join(base, entry.name, 'db');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* base missing */ }
|
||||||
|
const direct = path.join(base, 'db');
|
||||||
|
return fs.existsSync(direct) ? direct : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBedrockPlayerFile(config) {
|
||||||
|
const latin1 = (b) => Buffer.from(b).toString('latin1');
|
||||||
|
|
||||||
|
// Open read-only, run fn, always close. Ultra-light: one short-lived handle
|
||||||
|
// per read. Concurrency with the running server is handled upstream by the
|
||||||
|
// detail service issuing `save hold` before reading.
|
||||||
|
async function withDb(fn) {
|
||||||
|
const dbPath = resolveDbPath(config);
|
||||||
|
if (!dbPath) return null;
|
||||||
|
let db = null;
|
||||||
|
try {
|
||||||
|
db = new LevelDB(dbPath, { createIfMissing: false });
|
||||||
|
let openTimer;
|
||||||
|
const openPromise = db.open();
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
openTimer = setTimeout(() => reject(new Error('LevelDB open timeout')), 2000);
|
||||||
|
});
|
||||||
|
await Promise.race([openPromise, timeoutPromise]).finally(() => clearTimeout(openTimer));
|
||||||
|
return await fn(db);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (db) {
|
||||||
|
try { await db.close(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function readPlayer(uuid) {
|
||||||
|
const data = await withDb(async (db) => {
|
||||||
|
const val = await db.get(Buffer.from(SERVER_PREFIX + uuid, 'latin1'));
|
||||||
|
return val ? parsePlayerBuffer(val) : null;
|
||||||
|
});
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
data.savedAt = fs.statSync(resolveDbPath(config)).mtime.toISOString();
|
||||||
|
} catch { data.savedAt = null; }
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DB stores identity mapping records keyed player_<uuid> (uuid = MsaId or
|
||||||
|
// SelfSignedId — querytarget's `uniqueId` is one of these) whose NBT holds
|
||||||
|
// ServerId = "player_server_<leveldb uuid>". Direct lookup, no scan.
|
||||||
|
async function resolveViaMapping(db, uniqueId) {
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(String(uniqueId))) return null;
|
||||||
|
const val = await db.get(Buffer.from(PLAYER_PREFIX + String(uniqueId).toLowerCase(), 'latin1')).catch(() => null);
|
||||||
|
if (!val) return null;
|
||||||
|
try {
|
||||||
|
const { parsed } = await nbt.parse(Buffer.from(val), 'little');
|
||||||
|
const serverId = nbt.simplify(parsed)?.ServerId;
|
||||||
|
if (typeof serverId === 'string' && serverId.startsWith(SERVER_PREFIX)) {
|
||||||
|
return serverId.slice(SERVER_PREFIX.length);
|
||||||
|
}
|
||||||
|
} catch { /* not a mapping record */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a live uniqueId to its player_server_ record: first via the
|
||||||
|
// player_<uuid> mapping record, then by scanning player_server_* entries for
|
||||||
|
// a matching NBT UniqueID. NOTE the leveldb-zlib iterator yields [value, key].
|
||||||
|
async function findByUniqueId(uniqueId) {
|
||||||
|
const targetStr = String(uniqueId);
|
||||||
|
return withDb(async (db) => {
|
||||||
|
const mapped = await resolveViaMapping(db, targetStr);
|
||||||
|
if (mapped) {
|
||||||
|
const val = await db.get(Buffer.from(SERVER_PREFIX + mapped, 'latin1')).catch(() => null);
|
||||||
|
return { uuid: mapped, data: val ? await parsePlayerBuffer(val) : null };
|
||||||
|
}
|
||||||
|
const iter = new Iterator(db, { keys: true, values: true });
|
||||||
|
try {
|
||||||
|
let e;
|
||||||
|
while ((e = await iter.next()) != null) {
|
||||||
|
const key = latin1(e[1]);
|
||||||
|
if (!key.startsWith(SERVER_PREFIX)) continue;
|
||||||
|
const data = await parsePlayerBuffer(e[0]);
|
||||||
|
if (!data?.uniqueId) continue;
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.uniqueId === targetStr ||
|
||||||
|
matchUniqueId(data.uniqueId, targetStr)
|
||||||
|
) {
|
||||||
|
return { uuid: key.slice(SERVER_PREFIX.length), data };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await iter.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listServerUuids() {
|
||||||
|
return withDb(async (db) => {
|
||||||
|
const iter = new Iterator(db, { keys: true, values: true });
|
||||||
|
const out = [];
|
||||||
|
try {
|
||||||
|
let e;
|
||||||
|
while ((e = await iter.next()) != null) {
|
||||||
|
const key = latin1(e[1]); // tuple is [value, key]
|
||||||
|
if (key.startsWith(SERVER_PREFIX)) out.push(key.slice(SERVER_PREFIX.length));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
await iter.end();
|
||||||
|
}
|
||||||
|
}) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { readPlayer, findByUniqueId, listServerUuids };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createJavaPlayerFile } from './javaPlayerFile.js';
|
||||||
|
import { createBedrockPlayerFile } from './bedrockPlayerFile.js';
|
||||||
|
|
||||||
|
// Returns the read-only player-file adapter for the given edition. Both expose
|
||||||
|
// `readPlayer(id)`; the Bedrock adapter adds `findByUniqueId`/`listServerUuids`
|
||||||
|
// used by the identity bridge.
|
||||||
|
export function createPlayerFileAdapter(edition, config) {
|
||||||
|
return edition === 'bedrock'
|
||||||
|
? createBedrockPlayerFile(config)
|
||||||
|
: createJavaPlayerFile(config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import nbt from 'prismarine-nbt';
|
||||||
|
import { normalizeJava } from './nbtPlayer.js';
|
||||||
|
|
||||||
|
// Reads Java per-player save data from <world>/playerdata/<uuid>.dat (gzip'd
|
||||||
|
// big-endian NBT). `id` is the player UUID (from usercache.json).
|
||||||
|
export function createJavaPlayerFile(config) {
|
||||||
|
const playerdataDir = path.join(config.mcDataPath, config.mcWorldName || 'world', 'playerdata');
|
||||||
|
|
||||||
|
async function readPlayer(uuid) {
|
||||||
|
const file = path.join(playerdataDir, `${uuid}.dat`);
|
||||||
|
let buf, stat;
|
||||||
|
try {
|
||||||
|
buf = await fsp.readFile(file);
|
||||||
|
stat = await fsp.stat(file);
|
||||||
|
} catch {
|
||||||
|
return null; // no save yet for this player
|
||||||
|
}
|
||||||
|
const { parsed } = await nbt.parse(buf); // auto-detects gzip + endianness
|
||||||
|
return { ...normalizeJava(nbt.simplify(parsed)), savedAt: stat.mtime.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { readPlayer };
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Normalizes decoded player NBT (post `nbt.simplify`) into a stable, edition-
|
||||||
|
// agnostic PlayerData shape consumed by the detail service and the UI.
|
||||||
|
//
|
||||||
|
// PlayerData = {
|
||||||
|
// position: { x, y, z } | null,
|
||||||
|
// dimension: 'overworld' | 'nether' | 'end',
|
||||||
|
// health: { current, max } | null,
|
||||||
|
// food: number | null,
|
||||||
|
// inventory: [ { slot, name, count } ], // slots 0-8 hotbar, 9-35 main grid
|
||||||
|
// armor: { head, chest, legs, feet }, // each { name, count } | null
|
||||||
|
// offhand: { name, count } | null,
|
||||||
|
// gamemode: string | null,
|
||||||
|
// xp: { level } | null,
|
||||||
|
// uniqueId: string | null, // Bedrock only — used by the identity bridge
|
||||||
|
// }
|
||||||
|
|
||||||
|
// prismarine-nbt represents a `long` as a [high32, low32] signed-int pair.
|
||||||
|
// Combine to a decimal string (matches Bedrock's querytarget uniqueId format).
|
||||||
|
export function int64ToString(v) {
|
||||||
|
if (typeof v === 'bigint') return v.toString();
|
||||||
|
if (typeof v === 'number') return String(v);
|
||||||
|
if (Array.isArray(v) && v.length === 2) {
|
||||||
|
const [hi, lo] = v;
|
||||||
|
return (BigInt(hi) * (1n << 32n) + BigInt(lo >>> 0)).toString();
|
||||||
|
}
|
||||||
|
return v == null ? null : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bedrockDimension(code) {
|
||||||
|
return code === 1 ? 'nether' : code === 2 ? 'end' : 'overworld';
|
||||||
|
}
|
||||||
|
|
||||||
|
function javaDimension(dim) {
|
||||||
|
const s = String(dim);
|
||||||
|
if (s.includes('nether') || dim === -1) return 'nether';
|
||||||
|
if (s.includes('end') || dim === 1) return 'end';
|
||||||
|
return 'overworld';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BEDROCK_GAMEMODE = { 0: 'survival', 1: 'creative', 2: 'adventure', 5: 'default', 6: 'spectator' };
|
||||||
|
const JAVA_GAMEMODE = { 0: 'survival', 1: 'creative', 2: 'adventure', 3: 'spectator' };
|
||||||
|
|
||||||
|
function pos(arr) {
|
||||||
|
return Array.isArray(arr) && arr.length === 3
|
||||||
|
? { x: arr[0], y: arr[1], z: arr[2] }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = (name, count) => (name ? { name, count } : null);
|
||||||
|
|
||||||
|
export function normalizeBedrock(s = {}) {
|
||||||
|
const attr = (name) => (s.Attributes || []).find((a) => a.Name === name);
|
||||||
|
const hp = attr('minecraft:health');
|
||||||
|
const hunger = attr('minecraft:player.hunger');
|
||||||
|
return {
|
||||||
|
position: pos(s.Pos),
|
||||||
|
dimension: bedrockDimension(s.DimensionId),
|
||||||
|
health: hp ? { current: hp.Current, max: hp.Max } : null,
|
||||||
|
food: hunger ? hunger.Current : null,
|
||||||
|
inventory: (s.Inventory || [])
|
||||||
|
.filter((i) => i && i.Name)
|
||||||
|
.map((i) => ({ slot: i.Slot, name: i.Name, count: i.Count })),
|
||||||
|
// Bedrock keeps armor/offhand in their own lists: Armor = [head, chest, legs, feet(, body)]
|
||||||
|
armor: {
|
||||||
|
head: item(s.Armor?.[0]?.Name, s.Armor?.[0]?.Count),
|
||||||
|
chest: item(s.Armor?.[1]?.Name, s.Armor?.[1]?.Count),
|
||||||
|
legs: item(s.Armor?.[2]?.Name, s.Armor?.[2]?.Count),
|
||||||
|
feet: item(s.Armor?.[3]?.Name, s.Armor?.[3]?.Count),
|
||||||
|
},
|
||||||
|
offhand: item(s.Offhand?.[0]?.Name, s.Offhand?.[0]?.Count),
|
||||||
|
gamemode: BEDROCK_GAMEMODE[s.PlayerGameMode] ?? null,
|
||||||
|
xp: typeof s.PlayerLevel === 'number' ? { level: s.PlayerLevel } : null,
|
||||||
|
uniqueId: 'UniqueID' in s ? int64ToString(s.UniqueID) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Java stores everything in one Inventory list: 0-8 hotbar, 9-35 main grid,
|
||||||
|
// 100=feet 101=legs 102=chest 103=head, -106=offhand.
|
||||||
|
const JAVA_ARMOR_SLOT = { 100: 'feet', 101: 'legs', 102: 'chest', 103: 'head' };
|
||||||
|
|
||||||
|
export function normalizeJava(s = {}) {
|
||||||
|
const all = (s.Inventory || []).filter((i) => i && i.id);
|
||||||
|
const armor = { head: null, chest: null, legs: null, feet: null };
|
||||||
|
let offhand = null;
|
||||||
|
const inventory = [];
|
||||||
|
for (const i of all) {
|
||||||
|
const entry = { slot: i.Slot, name: i.id, count: i.Count };
|
||||||
|
if (JAVA_ARMOR_SLOT[i.Slot]) armor[JAVA_ARMOR_SLOT[i.Slot]] = item(i.id, i.Count);
|
||||||
|
else if (i.Slot === -106) offhand = item(i.id, i.Count);
|
||||||
|
else inventory.push(entry);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
position: pos(s.Pos),
|
||||||
|
dimension: javaDimension(s.Dimension),
|
||||||
|
health: typeof s.Health === 'number' ? { current: s.Health, max: 20 } : null,
|
||||||
|
food: typeof s.foodLevel === 'number' ? s.foodLevel : null,
|
||||||
|
inventory,
|
||||||
|
armor,
|
||||||
|
offhand,
|
||||||
|
gamemode: JAVA_GAMEMODE[s.playerGameType] ?? null,
|
||||||
|
xp: typeof s.XpLevel === 'number' ? { level: s.XpLevel } : null,
|
||||||
|
uniqueId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export class NotSupportedError extends Error {
|
||||||
|
constructor(action) {
|
||||||
|
super(`Action not supported on this edition: ${action}`);
|
||||||
|
this.name = 'NotSupportedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON = ['listPlayers', 'whitelistAdd', 'whitelistRemove', 'whitelistOn', 'whitelistOff', 'whitelistList', 'op', 'deop', 'kick', 'give', 'gamemode', 'teleport', 'sendCommand'];
|
||||||
|
|
||||||
|
export const CAPABILITIES = {
|
||||||
|
JAVA: new Set([...COMMON, 'ban', 'pardon']),
|
||||||
|
BEDROCK: new Set([...COMMON]),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAYER_NAME_REGEX = /^[a-zA-Z0-9_ ]{3,16}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the output of `whitelist list` / `allowlist list`.
|
||||||
|
* Typical formats:
|
||||||
|
* Java: "There are 2 whitelisted players: steve, alex"
|
||||||
|
* Bedrock: "There are 2 allowlisted players: steve, alex"
|
||||||
|
*/
|
||||||
|
export function parseWhitelistList(text) {
|
||||||
|
// Bedrock returns a structured JSON block, e.g.
|
||||||
|
// ###* {"command":"allowlist","result":[{"name":"steve"}]} *###
|
||||||
|
// When a `result` field is present it is AUTHORITATIVE: a null/empty result
|
||||||
|
// means the list is empty. We must never fall through to text parsing here —
|
||||||
|
// doing so scrapes ghost "names" out of console log lines (the bug that
|
||||||
|
// produced `allowlist remove 53:21:185 INFO] ###* {`).
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonMatch[0]);
|
||||||
|
if (data && 'result' in data) {
|
||||||
|
return Array.isArray(data.result) ? data.result.map((p) => p.name).filter((s) => s && PLAYER_NAME_REGEX.test(s)) : [];
|
||||||
|
}
|
||||||
|
} catch { /* not the JSON we expected — fall through to Java text form */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Java text form only: "There are N whitelisted players: a, b". Matched
|
||||||
|
// strictly so unrelated log lines (timestamps, errors) never yield names.
|
||||||
|
const m = text.replace(/\s+/g, ' ').match(/There are \d+ \w+ players:\s*(.+)$/i);
|
||||||
|
if (m && m[1].trim()) return m[1].split(',').map((s) => s.trim()).filter((s) => PLAYER_NAME_REGEX.test(s));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePlayerList(text) {
|
||||||
|
const clean = text.replace(/\s+/g, ' ').trim();
|
||||||
|
// Java: "There are 2 of a max of 20 players online: a, b"
|
||||||
|
let m = clean.match(/There are (\d+) of a max of (\d+) players online:?\s*(.*)$/i);
|
||||||
|
// Bedrock: "There are 2/20 players online: a, b"
|
||||||
|
if (!m) m = clean.match(/There are (\d+)\/(\d+) players online:?\s*(.*)$/i);
|
||||||
|
if (!m) return { online: 0, max: 0, players: [] };
|
||||||
|
const online = Number(m[1]);
|
||||||
|
const max = Number(m[2]);
|
||||||
|
if (online === 0) return { online: 0, max, players: [] };
|
||||||
|
const players = (m[3] || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => PLAYER_NAME_REGEX.test(s));
|
||||||
|
return { online, max, players };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createAdapter, isBedrock } from './adapters/index.js';
|
||||||
|
|
||||||
|
export function createAppState({ config, dockerService, rconService, stdinService }) {
|
||||||
|
let cached = null;
|
||||||
|
|
||||||
|
// Returns { type, definitive }. `definitive` is false when we could not read a
|
||||||
|
// real TYPE (e.g. container stopped) and had to fall back — such results must
|
||||||
|
// not be cached, or the edition can get stuck wrong until restart.
|
||||||
|
async function resolveType() {
|
||||||
|
if (config.mcEdition === 'bedrock') return { type: 'BEDROCK', definitive: true };
|
||||||
|
if (config.mcEdition === 'java') return { type: 'PAPER', definitive: true };
|
||||||
|
const info = await dockerService.inspect();
|
||||||
|
return info.type
|
||||||
|
? { type: info.type, definitive: true }
|
||||||
|
: { type: 'PAPER', definitive: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(type) {
|
||||||
|
const adapter = createAdapter(type, { rconService, stdinService });
|
||||||
|
adapter._edition = isBedrock(type) ? 'bedrock' : 'java';
|
||||||
|
|
||||||
|
let listPlayersCache = null;
|
||||||
|
let listPlayersTime = 0;
|
||||||
|
let whitelistCache = null;
|
||||||
|
let whitelistTime = 0;
|
||||||
|
|
||||||
|
const originalListPlayers = adapter.listPlayers;
|
||||||
|
adapter.listPlayers = async function() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (listPlayersCache && (now - listPlayersTime < 10000)) {
|
||||||
|
return listPlayersCache;
|
||||||
|
}
|
||||||
|
listPlayersCache = await originalListPlayers.call(adapter);
|
||||||
|
listPlayersTime = now;
|
||||||
|
return listPlayersCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalWhitelistList = adapter.whitelistList;
|
||||||
|
adapter.whitelistList = async function() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (whitelistCache && (now - whitelistTime < 30000)) {
|
||||||
|
return whitelistCache;
|
||||||
|
}
|
||||||
|
whitelistCache = await originalWhitelistList.call(adapter);
|
||||||
|
whitelistTime = now;
|
||||||
|
return whitelistCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.clearCaches = () => {
|
||||||
|
listPlayersCache = null;
|
||||||
|
listPlayersTime = 0;
|
||||||
|
whitelistCache = null;
|
||||||
|
whitelistTime = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapClear = (methodName) => {
|
||||||
|
if (typeof adapter[methodName] === 'function') {
|
||||||
|
const original = adapter[methodName];
|
||||||
|
adapter[methodName] = async function(...args) {
|
||||||
|
const res = await original.apply(adapter, args);
|
||||||
|
adapter.clearCaches();
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapClear('whitelistAdd');
|
||||||
|
wrapClear('whitelistRemove');
|
||||||
|
wrapClear('whitelistOn');
|
||||||
|
wrapClear('whitelistOff');
|
||||||
|
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getAdapter() {
|
||||||
|
if (cached) return cached;
|
||||||
|
const { type, definitive } = await resolveType();
|
||||||
|
const adapter = build(type);
|
||||||
|
if (definitive) cached = adapter;
|
||||||
|
return adapter;
|
||||||
|
},
|
||||||
|
async getEdition() {
|
||||||
|
return (await this.getAdapter())._edition;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// SESSION_SECRET is always required. RCON_PASSWORD is Java-only (optional). The
|
||||||
|
// admin hash may come from ADMIN_PASSWORD_HASH (raw) or ADMIN_PASSWORD_HASH_B64
|
||||||
|
// (base64 of the same hash — avoids `$` interpolation issues under docker/Coolify).
|
||||||
|
const REQUIRED = ['SESSION_SECRET'];
|
||||||
|
|
||||||
|
// bcrypt hashes contain `$`, which docker compose / Coolify interpret as variable
|
||||||
|
// interpolation. Providing the hash base64-encoded sidesteps that entirely.
|
||||||
|
function resolveAdminHash(env) {
|
||||||
|
const raw = (env.ADMIN_PASSWORD_HASH || '').trim();
|
||||||
|
if (raw) return raw;
|
||||||
|
const b64 = (env.ADMIN_PASSWORD_HASH_B64 || '').trim();
|
||||||
|
if (b64) return Buffer.from(b64, 'base64').toString('utf8').trim();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env = process.env) {
|
||||||
|
const missing = REQUIRED.filter((k) => !env[k]);
|
||||||
|
const adminPasswordHash = resolveAdminHash(env);
|
||||||
|
if (!adminPasswordHash) missing.push('ADMIN_PASSWORD_HASH (or ADMIN_PASSWORD_HASH_B64)');
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`Missing required env vars: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
const num = (v, d) => {
|
||||||
|
if (v == null || v === '') return d;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isNaN(n) ? d : n;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
adminPasswordHash,
|
||||||
|
sessionSecret: env.SESSION_SECRET,
|
||||||
|
mcContainerName: env.MC_CONTAINER_NAME || 'craftdock-mc-server',
|
||||||
|
// Compose service name of the MC container, used as a fallback when the
|
||||||
|
// container name is auto-generated (e.g. Coolify ignores container_name).
|
||||||
|
mcServiceName: env.MC_SERVICE_NAME || 'minecraft-server',
|
||||||
|
mcDataPath: env.MC_DATA_PATH || '/minecraft/data',
|
||||||
|
mcEdition: env.MC_EDITION || 'auto',
|
||||||
|
mcWorldName: env.MC_WORLD_NAME || 'world',
|
||||||
|
rconHost: env.RCON_HOST || 'craftdock-mc-server',
|
||||||
|
rconPort: num(env.RCON_PORT, 25575),
|
||||||
|
rconPassword: env.RCON_PASSWORD || '',
|
||||||
|
mapVersion: env.MAP_VERSION || '',
|
||||||
|
port: num(env.PORT, 3000),
|
||||||
|
nodeEnv: env.NODE_ENV || 'development',
|
||||||
|
maxUploadBytes: num(env.MAX_UPLOAD_MB, 1024) * 1024 * 1024,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const failFile = '/tmp/health_failures';
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
// Abort before Docker's own `timeout` (5s) SIGKILLs this process. If the app
|
||||||
|
// hangs (event loop blocked), an untimed fetch stays pending until Docker kills
|
||||||
|
// us — the catch below never runs, the failure counter never advances, and the
|
||||||
|
// self-restart (kill PID 1) never fires. A 4s AbortSignal guarantees the catch
|
||||||
|
// runs first so the counter advances and the container can heal itself.
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||||
|
signal: AbortSignal.timeout(4000),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
try { fs.unlinkSync(failFile); } catch {}
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
throw new Error('Not OK');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
let fails = 0;
|
||||||
|
try {
|
||||||
|
fails = parseInt(fs.readFileSync(failFile, 'utf8')) || 0;
|
||||||
|
} catch {}
|
||||||
|
fails++;
|
||||||
|
|
||||||
|
if (fails >= 3) {
|
||||||
|
try { fs.unlinkSync(failFile); } catch {}
|
||||||
|
// Kill PID 1 to force compose restart
|
||||||
|
process.kill(1, 'SIGKILL');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(failFile, String(fails));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check();
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Lightweight structured logger. No dependencies — writes one record per line
|
||||||
|
// to a stream (stdout by default). Level is controlled by LOG_LEVEL
|
||||||
|
// (error < warn < info < debug); anything above the threshold is dropped.
|
||||||
|
// Format is controlled by LOG_FORMAT (json | text, default: text).
|
||||||
|
|
||||||
|
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
|
||||||
|
|
||||||
|
function serializeMeta(meta) {
|
||||||
|
if (meta == null) return undefined;
|
||||||
|
if (meta instanceof Error) {
|
||||||
|
return { error: meta.message, stack: meta.stack, cause: meta.cause };
|
||||||
|
}
|
||||||
|
if (typeof meta !== 'object') {
|
||||||
|
return { value: meta };
|
||||||
|
}
|
||||||
|
const out = {};
|
||||||
|
for (const [key, value] of Object.entries(meta)) {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
out[key] = { error: value.message, stack: value.stack, cause: value.cause };
|
||||||
|
} else {
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTextRecord(record) {
|
||||||
|
const lvlStr = record.level.toUpperCase().padEnd(5);
|
||||||
|
const metaStr = record.meta ? ` ${JSON.stringify(record.meta)}` : '';
|
||||||
|
return `[${record.ts}] [${lvlStr}] [${record.component}] ${record.msg}${metaStr}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(component, {
|
||||||
|
level = process.env.LOG_LEVEL || 'info',
|
||||||
|
format = process.env.LOG_FORMAT || 'text',
|
||||||
|
stream = process.stdout,
|
||||||
|
} = {}) {
|
||||||
|
const threshold = LEVELS[level] ?? LEVELS.info;
|
||||||
|
const isJson = format === 'json';
|
||||||
|
|
||||||
|
function log(lvl, msg, meta) {
|
||||||
|
if (LEVELS[lvl] > threshold) return;
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
level: lvl,
|
||||||
|
component,
|
||||||
|
msg,
|
||||||
|
};
|
||||||
|
const m = serializeMeta(meta);
|
||||||
|
if (m !== undefined) record.meta = m;
|
||||||
|
|
||||||
|
if (isJson) {
|
||||||
|
stream.write(JSON.stringify(record) + '\n');
|
||||||
|
} else {
|
||||||
|
stream.write(formatTextRecord(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: (msg, meta) => log('error', msg, meta),
|
||||||
|
warn: (msg, meta) => log('warn', msg, meta),
|
||||||
|
info: (msg, meta) => log('info', msg, meta),
|
||||||
|
debug: (msg, meta) => log('debug', msg, meta),
|
||||||
|
time: (label, lvl = 'info') => {
|
||||||
|
const start = Date.now();
|
||||||
|
return (meta = {}) => {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
log(lvl, `${label} took ${durationMs}ms`, { ...meta, durationMs });
|
||||||
|
};
|
||||||
|
},
|
||||||
|
child: (sub) => createLogger(`${component}:${sub}`, { level, format, stream }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default application logger.
|
||||||
|
export const logger = createLogger('craftdock');
|
||||||
|
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import archiver from 'archiver';
|
||||||
|
import unzipper from 'unzipper';
|
||||||
|
import multer from 'multer';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { createDockerService } from './services/dockerService.js';
|
||||||
|
import { createRconService } from './services/rconService.js';
|
||||||
|
import { createStdinService } from './services/stdinService.js';
|
||||||
|
import { createPropertiesService } from './services/propertiesService.js';
|
||||||
|
import { createWorldService } from './services/worldService.js';
|
||||||
|
import { createAuthService } from './services/authService.js';
|
||||||
|
import { createSeedService } from './services/seedService.js';
|
||||||
|
import { ensureFirstBootDefaults } from './services/firstBootService.js';
|
||||||
|
import { createAppState } from './appState.js';
|
||||||
|
import { startServer } from './server.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
logger.error('uncaught exception', { error: err.message, stack: err.stack, cause: err.cause });
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
logger.error('unhandled promise rejection', {
|
||||||
|
error: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure shared configuration files have correct permissions (writable by anyone)
|
||||||
|
// so the Minecraft container (running as user minecraft UID 1000) does not hit write permission errors.
|
||||||
|
const FILES_TO_CHMOD = [
|
||||||
|
'allowlist.json', 'permissions.json',
|
||||||
|
'whitelist.json', 'ops.json', 'usercache.json',
|
||||||
|
'banned-players.json', 'banned-ips.json',
|
||||||
|
'craftdock_players_history.json', 'craftdock_players.json',
|
||||||
|
'server.properties'
|
||||||
|
];
|
||||||
|
for (const file of FILES_TO_CHMOD) {
|
||||||
|
const p = path.join(config.mcDataPath, file);
|
||||||
|
fsp.chmod(p, 0o666).catch(() => {});
|
||||||
|
}
|
||||||
|
const docker = new Docker(); // uses /var/run/docker.sock by default
|
||||||
|
|
||||||
|
const dockerService = createDockerService(config, docker, logger.child('docker'));
|
||||||
|
|
||||||
|
const rconService = createRconService(config, undefined, logger.child('rcon'));
|
||||||
|
const stdinService = createStdinService(dockerService, { logger: logger.child('stdin') });
|
||||||
|
const propertiesService = createPropertiesService(config);
|
||||||
|
const authService = createAuthService(config);
|
||||||
|
const seedService = createSeedService({ config, propertiesService, logger: logger.child('seed') });
|
||||||
|
const appState = createAppState({ config, dockerService, rconService, stdinService });
|
||||||
|
|
||||||
|
// First boot of this data volume only: force the whitelist off (see service).
|
||||||
|
await ensureFirstBootDefaults({ config, propertiesService, logger: logger.child('first-boot') });
|
||||||
|
|
||||||
|
const extractZip = (zip, dest) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
createReadStream(zip)
|
||||||
|
.pipe(unzipper.Extract({ path: dest }))
|
||||||
|
.on('close', resolve)
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const worldService = createWorldService({ config, dockerService, propertiesService, archiver, extractZip });
|
||||||
|
const upload = multer({
|
||||||
|
dest: os.tmpdir(),
|
||||||
|
limits: { fileSize: config.maxUploadBytes },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('=== CraftDock Container Started ===', {
|
||||||
|
nodeEnv: config.nodeEnv,
|
||||||
|
port: config.port,
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
logFormat: process.env.LOG_FORMAT || 'text',
|
||||||
|
mcContainerName: config.mcContainerName,
|
||||||
|
mcServiceName: config.mcServiceName,
|
||||||
|
mcDataPath: config.mcDataPath,
|
||||||
|
mcEdition: config.mcEdition,
|
||||||
|
mcWorldName: config.mcWorldName,
|
||||||
|
});
|
||||||
|
|
||||||
|
startServer({
|
||||||
|
config, dockerService, appState, propertiesService,
|
||||||
|
worldService, authService, upload, seedService, logger,
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function requireAuth(req, res, next) {
|
||||||
|
if (req.session && req.session.authed === true) return next();
|
||||||
|
return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function socketAuth(sessionMiddleware) {
|
||||||
|
return (socket, next) => {
|
||||||
|
sessionMiddleware(socket.request, {}, () => {
|
||||||
|
if (socket.request.session && socket.request.session.authed === true) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
next(new Error('unauthorized'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* Custom styles for CraftDock */
|
||||||
|
|
||||||
|
/* Scrollbar customization */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(51, 65, 85, 0.8);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(71, 85, 105, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism styling */
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(30, 41, 59, 0.4);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px fill rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Tab Buttons */
|
||||||
|
.tab-btn {
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #94a3b8; /* slate-400 */
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: #f1f5f9; /* slate-100 */
|
||||||
|
background: rgba(30, 41, 59, 0.5);
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
background: #10b981; /* emerald-500 */
|
||||||
|
color: #0f172a; /* slate-900 */
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monospace Console Styling */
|
||||||
|
#console {
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: #020617; /* slate-950 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth fade-in animation for panel updates */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease forwards;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CraftDock</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Outfit', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'monospace'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100 min-h-screen flex flex-col font-sans antialiased selection:bg-emerald-500/30 selection:text-emerald-300">
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<div id="login" class="hidden flex-1 flex flex-col items-center justify-center p-6 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950">
|
||||||
|
<div class="w-full max-w-md p-8 rounded-2xl bg-slate-900/60 border border-slate-800/80 backdrop-blur-xl shadow-2xl relative overflow-hidden group">
|
||||||
|
<div class="absolute -top-12 -left-12 w-24 h-24 bg-emerald-500/10 rounded-full blur-2xl group-hover:bg-emerald-500/20 transition-all duration-500"></div>
|
||||||
|
<div class="absolute -bottom-12 -right-12 w-24 h-24 bg-blue-500/10 rounded-full blur-2xl group-hover:bg-blue-500/20 transition-all duration-500"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mb-8">
|
||||||
|
<div class="h-10 w-10 rounded-xl bg-gradient-to-tr from-emerald-500 to-teal-400 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||||
|
<svg class="w-6 h-6 text-slate-950 font-bold" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-slate-400 bg-clip-text text-transparent">CraftDock</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-400 text-sm mb-6">Insira sua senha de administrador para gerenciar o servidor de Minecraft.</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Senha de Acesso</label>
|
||||||
|
<input id="password" type="password" placeholder="••••••••"
|
||||||
|
class="w-full px-4 py-3 rounded-xl bg-slate-950/80 border border-slate-800 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all duration-300 shadow-inner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="loginBtn" class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-slate-950 font-semibold shadow-lg shadow-emerald-500/25 active:scale-[0.98] transition-all duration-200">
|
||||||
|
Entrar no Painel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p id="loginError" class="text-red-400 text-sm font-medium text-center bg-red-500/10 border border-red-500/20 py-2.5 rounded-lg hidden">
|
||||||
|
Senha incorreta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App Main Screen -->
|
||||||
|
<div id="app" class="hidden flex-grow flex flex-col min-h-screen">
|
||||||
|
<header class="flex items-center justify-between px-6 py-4 bg-slate-900/60 border-b border-slate-800/80 backdrop-blur-md sticky top-0 z-50">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-8 w-8 rounded-lg bg-gradient-to-tr from-emerald-500 to-teal-400 flex items-center justify-center shadow-md shadow-emerald-500/10">
|
||||||
|
<svg class="w-5 h-5 text-slate-950 font-bold" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-xl font-bold tracking-tight bg-gradient-to-r from-white to-slate-300 bg-clip-text text-transparent">CraftDock</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav id="tabs" class="flex p-1 bg-slate-950/80 rounded-xl border border-slate-800/50 gap-1"></nav>
|
||||||
|
|
||||||
|
<button id="logoutBtn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800/80 transition-all duration-200">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
|
||||||
|
Sair
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-grow max-w-7xl w-full mx-auto p-6 md:p-8 flex flex-col">
|
||||||
|
<div id="panels" class="flex-grow flex flex-col"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { api } from './socket.js';
|
||||||
|
import { renderStatus } from './tabs/status.js';
|
||||||
|
import { renderConsole } from './tabs/console.js';
|
||||||
|
import { renderPlayers } from './tabs/players.js';
|
||||||
|
import { renderOptions } from './tabs/options.js';
|
||||||
|
import { renderWorld } from './tabs/world.js';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'status', label: 'Início', render: renderStatus },
|
||||||
|
{ id: 'console', label: 'Console', render: renderConsole },
|
||||||
|
{ id: 'players', label: 'Jogadores', render: renderPlayers },
|
||||||
|
{ id: 'options', label: 'Opções', render: renderOptions },
|
||||||
|
{ id: 'world', label: 'Mundo', render: renderWorld },
|
||||||
|
];
|
||||||
|
|
||||||
|
let cleanup = null;
|
||||||
|
|
||||||
|
function showTab(id) {
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
cleanup = null;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.tab-btn').forEach((b) =>
|
||||||
|
b.classList.toggle('active', b.dataset.tab === id));
|
||||||
|
|
||||||
|
const panels = document.getElementById('panels');
|
||||||
|
panels.innerHTML = '';
|
||||||
|
|
||||||
|
// Create a container with fade-in effect
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'fade-in flex-grow flex flex-col';
|
||||||
|
panels.appendChild(wrapper);
|
||||||
|
|
||||||
|
const tab = TABS.find((t) => t.id === id);
|
||||||
|
cleanup = tab.render(wrapper) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
const nav = document.getElementById('tabs');
|
||||||
|
nav.innerHTML = '';
|
||||||
|
for (const tab of TABS) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'tab-btn';
|
||||||
|
btn.dataset.tab = tab.id;
|
||||||
|
btn.textContent = tab.label;
|
||||||
|
btn.onclick = () => showTab(tab.id);
|
||||||
|
nav.appendChild(btn);
|
||||||
|
}
|
||||||
|
showTab('status');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
try {
|
||||||
|
const me = await api('/api/auth/me');
|
||||||
|
if (me?.authed) {
|
||||||
|
document.getElementById('app').classList.remove('hidden');
|
||||||
|
initTabs();
|
||||||
|
} else {
|
||||||
|
document.getElementById('login').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Boot error:', err);
|
||||||
|
document.getElementById('login').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('loginBtn').onclick = async () => {
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const errorEl = document.getElementById('loginError');
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('logoutBtn').onclick = async () => {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
boot();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export async function api(path, opts = {}) {
|
||||||
|
const headers = { ...opts.headers };
|
||||||
|
if (opts.body && !(opts.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...opts,
|
||||||
|
headers,
|
||||||
|
body: opts.body && !(opts.body instanceof FormData) ? JSON.stringify(opts.body) : opts.body,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
return ct.includes('application/json') ? res.json() : res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export function renderConsole(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="flex-grow flex flex-col h-[70vh] max-w-5xl w-full mx-auto bg-slate-950/80 border border-slate-800/80 rounded-2xl overflow-hidden shadow-2xl backdrop-blur-md">
|
||||||
|
<!-- Terminal Header -->
|
||||||
|
<div class="px-4 py-3 bg-slate-900/60 border-b border-slate-850 flex items-center justify-between text-xs font-semibold text-slate-400 tracking-wider">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span class="w-3.5 h-3.5 rounded-full bg-rose-500/20 border border-rose-500/40"></span>
|
||||||
|
<span class="w-3.5 h-3.5 rounded-full bg-amber-500/20 border border-amber-500/40"></span>
|
||||||
|
<span class="w-3.5 h-3.5 rounded-full bg-emerald-500/20 border border-emerald-500/40"></span>
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 font-mono">interactive_console.sh</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-emerald-500 uppercase tracking-widest font-mono text-[10px] bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">Conectado</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs Area -->
|
||||||
|
<div id="console" class="flex-grow overflow-y-auto p-4 font-mono text-slate-350 text-sm selection:bg-emerald-500/20 selection:text-emerald-300"></div>
|
||||||
|
|
||||||
|
<!-- Command Bar -->
|
||||||
|
<form id="cmdForm" class="p-3 bg-slate-900/45 border-t border-slate-850 flex gap-2 items-center">
|
||||||
|
<span class="font-mono text-emerald-500 font-bold pl-2 select-none">></span>
|
||||||
|
<input id="cmd" class="flex-1 p-2.5 rounded-xl bg-slate-950/90 border border-slate-800/80 text-slate-100 placeholder-slate-600 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 font-mono text-sm transition-all duration-200" placeholder="Comando (ex: list)" autocomplete="off" />
|
||||||
|
<button class="px-5 py-2.5 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-slate-950 font-semibold shadow-lg shadow-emerald-500/10 active:scale-[0.98] transition-all duration-200 text-sm">
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const consoleEl = root.querySelector('#console');
|
||||||
|
const MAX = 500;
|
||||||
|
|
||||||
|
const append = (text) => {
|
||||||
|
// Sanitize chunk styling a bit if necessary or just write raw
|
||||||
|
// Convert newlines to breaks or use textContent
|
||||||
|
consoleEl.textContent += text;
|
||||||
|
const lines = consoleEl.textContent.split('\n');
|
||||||
|
if (lines.length > MAX) {
|
||||||
|
consoleEl.textContent = lines.slice(-MAX).join('\n');
|
||||||
|
}
|
||||||
|
consoleEl.scrollTop = consoleEl.scrollHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket = io('/logs');
|
||||||
|
socket.on('log', append);
|
||||||
|
socket.on('connect_error', () => append('\n[craftdock] conexão perdida\n'));
|
||||||
|
|
||||||
|
root.querySelector('#cmdForm').onsubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = root.querySelector('#cmd');
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (val) {
|
||||||
|
socket.emit('command', val);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => socket.disconnect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { api } from '../socket.js';
|
||||||
|
|
||||||
|
// mcseedmap.net is embeddable (no X-Frame-Options) and renders the REAL biome/
|
||||||
|
// structure map from the seed. URL: https://mcseedmap.net/<lang>/<version>/<seed>
|
||||||
|
const DEFAULT_VERSION = { bedrock: '26.30.0-Bedrock', java: '1.21-Java' };
|
||||||
|
|
||||||
|
function mapUrlFor(seed, edition, mapVersion) {
|
||||||
|
const version = mapVersion || DEFAULT_VERSION[edition] || DEFAULT_VERSION.java;
|
||||||
|
return `https://mcseedmap.net/pt/${version}/${encodeURIComponent(seed)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMap(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="flex flex-col h-[82vh] w-full max-w-7xl mx-auto gap-3">
|
||||||
|
<!-- Seed bar -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 bg-slate-900/50 border border-slate-800/80 rounded-2xl px-4 py-3 backdrop-blur-md">
|
||||||
|
<span class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Seed do mundo</span>
|
||||||
|
<code id="seedVal" class="font-mono text-sm text-emerald-400 bg-slate-950/70 border border-slate-800 rounded-lg px-3 py-1">carregando…</code>
|
||||||
|
<button id="copySeed" class="text-xs px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700/60 text-slate-200 transition-all disabled:opacity-40" disabled>Copiar</button>
|
||||||
|
<a id="openMap" href="#" target="_blank" rel="noopener" class="text-xs px-3 py-1.5 rounded-lg bg-emerald-600/10 hover:bg-emerald-600 border border-emerald-500/20 hover:border-emerald-500 text-emerald-400 hover:text-slate-950 transition-all pointer-events-none opacity-40">Abrir no mcseedmap ↗</a>
|
||||||
|
<span id="edition" class="ml-auto text-xs text-slate-500"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embedded map -->
|
||||||
|
<div class="flex-grow relative bg-slate-950/60 border border-slate-800/80 rounded-2xl overflow-hidden">
|
||||||
|
<div id="mapPlaceholder" class="absolute inset-0 flex items-center justify-center text-center p-6">
|
||||||
|
<div class="text-slate-500 text-sm">
|
||||||
|
<p class="mb-1">Aguardando a seed do servidor…</p>
|
||||||
|
<p class="text-xs text-slate-600">O mapa aparece quando o servidor estiver ligado e a seed for lida.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<iframe id="mapFrame" class="w-full h-full hidden" style="border:0" referrerpolicy="no-referrer"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const seedEl = root.querySelector('#seedVal');
|
||||||
|
const copyBtn = root.querySelector('#copySeed');
|
||||||
|
const openLink = root.querySelector('#openMap');
|
||||||
|
const editionEl = root.querySelector('#edition');
|
||||||
|
const frame = root.querySelector('#mapFrame');
|
||||||
|
const placeholder = root.querySelector('#mapPlaceholder');
|
||||||
|
|
||||||
|
let currentSeed = null;
|
||||||
|
let edition = 'java';
|
||||||
|
let mapVersion = '';
|
||||||
|
|
||||||
|
function applySeed(seed) {
|
||||||
|
if (seed == null || seed === '' || String(seed) === currentSeed) return;
|
||||||
|
currentSeed = String(seed);
|
||||||
|
seedEl.textContent = currentSeed;
|
||||||
|
copyBtn.disabled = false;
|
||||||
|
|
||||||
|
const url = mapUrlFor(currentSeed, edition, mapVersion);
|
||||||
|
openLink.href = url;
|
||||||
|
openLink.classList.remove('pointer-events-none', 'opacity-40');
|
||||||
|
|
||||||
|
// Only (re)load the iframe when the seed actually changes, so panning/zoom
|
||||||
|
// inside mcseedmap isn't reset on every status tick.
|
||||||
|
frame.src = url;
|
||||||
|
frame.classList.remove('hidden');
|
||||||
|
placeholder.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
copyBtn.onclick = async () => {
|
||||||
|
if (!currentSeed) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(currentSeed);
|
||||||
|
copyBtn.textContent = 'Copiado!';
|
||||||
|
setTimeout(() => { copyBtn.textContent = 'Copiar'; }, 1500);
|
||||||
|
} catch { /* clipboard blocked */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial config (seed + edition + optional version override).
|
||||||
|
api('/api/client-config').then((cfg) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
if (cfg.edition) edition = cfg.edition;
|
||||||
|
if (cfg.mapVersion) mapVersion = cfg.mapVersion;
|
||||||
|
editionEl.textContent = cfg.edition ? `Edição: ${cfg.edition}` : '';
|
||||||
|
if (cfg.seed) applySeed(cfg.seed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep listening — the seed becomes available once the server is running.
|
||||||
|
const socket = io('/status');
|
||||||
|
socket.on('status', (s) => {
|
||||||
|
if (s.type) edition = s.type.toUpperCase() === 'BEDROCK' ? 'bedrock' : 'java';
|
||||||
|
editionEl.textContent = s.edition ? `Edição: ${s.edition}` : editionEl.textContent;
|
||||||
|
if (s.seed) applySeed(s.seed);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => socket.disconnect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { api } from '../socket.js';
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{
|
||||||
|
title: 'Jogabilidade & Regras',
|
||||||
|
icon: `<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'difficulty',
|
||||||
|
label: 'Dificuldade',
|
||||||
|
type: 'enum',
|
||||||
|
values: [
|
||||||
|
{ val: 'peaceful', label: 'Pacífico' },
|
||||||
|
{ val: 'easy', label: 'Fácil' },
|
||||||
|
{ val: 'normal', label: 'Normal' },
|
||||||
|
{ val: 'hard', label: 'Difícil' },
|
||||||
|
],
|
||||||
|
description: 'Define o nível de dificuldade do jogo, controlando dano de monstros, fome e geração de mobs hostis.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gamemode',
|
||||||
|
label: 'Modo de Jogo Padrão',
|
||||||
|
type: 'enum',
|
||||||
|
values: [
|
||||||
|
{ val: 'survival', label: 'Sobrevivência' },
|
||||||
|
{ val: 'creative', label: 'Criativo' },
|
||||||
|
{ val: 'adventure', label: 'Aventura' },
|
||||||
|
{ val: 'spectator', label: 'Espectador' },
|
||||||
|
],
|
||||||
|
description: 'Determina o modo de jogo atribuído aos novos jogadores que se conectarem ao mundo.',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'pvp',
|
||||||
|
label: 'Habilitar PvP (Combate)',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Permite que jogadores ataquem e causem dano a outros jogadores no servidor.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keep-inventory',
|
||||||
|
label: 'Manter Itens ao Morrer (Keep Inventory)',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Quando ativado, os jogadores não perdem inventário nem pontos de experiência ao morrer.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hardcore',
|
||||||
|
label: 'Modo Hardcore',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Dificuldade travada no máximo e morte permanente (jogadores entram em modo espectador após morrer).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'allow-flight',
|
||||||
|
label: 'Permitir Voar (Flight)',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Permite voo no modo Sobrevivência se o jogador possuir um mod instalado, evitando que o servidor o desconecte por "kick for flying".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'force-gamemode',
|
||||||
|
label: 'Forçar Modo de Jogo ao Entrar',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Força o jogador a redefinir seu modo para o padrão do servidor toda vez que se reconectar.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Mundo & Entidades',
|
||||||
|
icon: `<svg class="w-4 h-4 text-teal-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 002 2h1.5a2.5 2.5 0 002.5-2.5V11a2 2 0 012-2h1.055M11 20.055V18a2 2 0 00-2-2h-1a2 2 0 00-2 2v2.055"></path></svg>`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'allow-nether',
|
||||||
|
label: 'Permitir Dimensão Nether',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Habilita ou desabilita a criação e teletransporte através de portais do Nether.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enable-command-block',
|
||||||
|
label: 'Ativar Blocos de Comando',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Permite a execução de comandos dentro de Blocos de Comando (Command Blocks).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spawn-monsters',
|
||||||
|
label: 'Gerar Monstros (Hostis)',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Controla se criaturas hostis (Creepers, Zumbis, Esqueletos) aparecem naturalmente no mundo.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spawn-animals',
|
||||||
|
label: 'Gerar Animais (Pacíficos)',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Controla se animais (Vacas, Ovelhas, Galinhas) surgem no mundo.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spawn-npcs',
|
||||||
|
label: 'Gerar NPCs / Aldeões',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Controla a criação e permanência de Villagers (Aldeões) nas vilas.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Desempenho, Limites e Apresentação',
|
||||||
|
icon: `<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'max-players',
|
||||||
|
label: 'Limite Máximo de Jogadores',
|
||||||
|
type: 'int',
|
||||||
|
description: 'Número máximo de conexões simultâneas permitidas no servidor.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'view-distance',
|
||||||
|
label: 'Distância de Visão (Chunks)',
|
||||||
|
type: 'int',
|
||||||
|
description: 'Quantidade de chunks enviadas para o cliente do jogador em um raio em redor dele.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'simulation-distance',
|
||||||
|
label: 'Distância de Simulação (Chunks)',
|
||||||
|
type: 'int',
|
||||||
|
description: 'Raio de chunks em que blocos, vegetação e entidades permanecem fisicamente ativos e processando.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spawn-protection',
|
||||||
|
label: 'Proteção do Spawn (Blocos)',
|
||||||
|
type: 'int',
|
||||||
|
description: 'Raio de blocos ao redor do ponto de spawn onde apenas Operadores (OP) podem construir ou quebrar.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'motd',
|
||||||
|
label: 'Mensagem do Dia (MOTD)',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Texto de exibição do servidor na lista de mundos dos clientes.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function renderOptions(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="max-w-3xl w-full mx-auto bg-slate-900/50 border border-slate-800/80 p-6 md:p-8 rounded-2xl backdrop-blur-md shadow-2xl">
|
||||||
|
<div class="flex items-center justify-between pb-4 mb-6 border-b border-slate-800/80">
|
||||||
|
<h2 class="text-xl font-bold text-slate-100 flex items-center gap-2.5">
|
||||||
|
<svg class="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||||
|
Configurações do Servidor
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs font-mono text-slate-400 bg-slate-800/60 px-2.5 py-1 rounded-md border border-slate-700/50">server.properties</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="opts" class="space-y-8">
|
||||||
|
<div class="text-center text-xs text-slate-500 py-8">Carregando propriedades do servidor...</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-5 border-t border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<span class="text-xs text-amber-400 flex items-center gap-2 bg-amber-500/10 px-3.5 py-2 rounded-xl border border-amber-500/20">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||||
|
Algumas alterações exigem reinicialização do servidor.
|
||||||
|
</span>
|
||||||
|
<div id="optMsg" class="text-xs font-semibold"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
function renderLabelWithTooltip(f) {
|
||||||
|
return `
|
||||||
|
<div class="flex items-center gap-1.5 group relative cursor-help">
|
||||||
|
<span class="text-sm font-medium text-slate-300">${f.label || f.key}</span>
|
||||||
|
<span class="inline-flex items-center justify-center w-4 h-4 rounded-full bg-slate-800 border border-slate-700 text-slate-400 group-hover:text-emerald-400 group-hover:border-emerald-500/50 transition-all text-[11px] font-bold leading-none">?</span>
|
||||||
|
<!-- Tooltip box -->
|
||||||
|
<div class="pointer-events-none absolute left-0 bottom-full mb-2 hidden group-hover:block w-64 p-2.5 bg-slate-950 border border-slate-700/80 text-xs text-slate-300 rounded-xl shadow-xl z-50 transition-all duration-200">
|
||||||
|
<div class="font-semibold text-emerald-400 mb-1 flex items-center gap-1">
|
||||||
|
<span>${f.label || f.key}</span>
|
||||||
|
<span class="text-[10px] text-slate-500 font-mono">(${f.key})</span>
|
||||||
|
</div>
|
||||||
|
<p class="leading-relaxed text-slate-300">${f.description || 'Sem descrição.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderField(f, value) {
|
||||||
|
const labelHtml = renderLabelWithTooltip(f);
|
||||||
|
|
||||||
|
if (f.type === 'enum') {
|
||||||
|
const optionsHtml = f.values.map((v) => {
|
||||||
|
const val = typeof v === 'object' ? v.val : v;
|
||||||
|
const lbl = typeof v === 'object' ? v.label : v;
|
||||||
|
return `<option value="${val}" ${val === value ? 'selected' : ''}>${lbl}</option>`;
|
||||||
|
}).join('');
|
||||||
|
return `
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 py-2.5 border-b border-slate-800/30 last:border-0">
|
||||||
|
${labelHtml}
|
||||||
|
<select name="${f.key}" class="w-full sm:w-56 px-3 py-2 text-sm rounded-xl bg-slate-950/80 border border-slate-800 text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all duration-200">
|
||||||
|
${optionsHtml}
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (f.type === 'boolean') {
|
||||||
|
const isChecked = value === 'true';
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-between py-2.5 border-b border-slate-800/30 last:border-0">
|
||||||
|
${labelHtml}
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" name="${f.key}" ${isChecked ? 'checked' : ''} class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-slate-400 after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-600 peer-checked:after:bg-slate-900 peer-checked:after:border-transparent"></div>
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputType = f.type === 'int' ? 'number' : 'text';
|
||||||
|
return `
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 py-2.5 border-b border-slate-800/30 last:border-0">
|
||||||
|
${labelHtml}
|
||||||
|
<input type="${inputType}" name="${f.key}" value="${value ?? ''}" class="w-full sm:w-56 px-3 py-2 text-sm rounded-xl bg-slate-950/80 border border-slate-800 text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all duration-200" />
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
api('/api/properties').then((data) => {
|
||||||
|
if (!data) return;
|
||||||
|
const props = data.properties || {};
|
||||||
|
const form = root.querySelector('#opts');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const cat of CATEGORIES) {
|
||||||
|
html += `
|
||||||
|
<div class="bg-slate-950/40 border border-slate-800/60 rounded-xl p-4 md:p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-200 flex items-center gap-2 mb-4 pb-2 border-b border-slate-800/80">
|
||||||
|
${cat.icon}
|
||||||
|
${cat.title}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-1">
|
||||||
|
${cat.fields.map((f) => renderField(f, props[f.key])).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="pt-2">
|
||||||
|
<button class="w-full py-3 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-slate-950 font-bold shadow-lg shadow-emerald-500/10 active:scale-[0.98] transition-all duration-200 text-sm flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Salvar Todas as Configurações
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
form.innerHTML = html;
|
||||||
|
|
||||||
|
form.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const patch = {};
|
||||||
|
const allFields = CATEGORIES.flatMap((c) => c.fields);
|
||||||
|
for (const f of allFields) {
|
||||||
|
const el = form.elements[f.key];
|
||||||
|
if (!el) continue;
|
||||||
|
patch[f.key] = f.type === 'boolean' ? String(el.checked) : el.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api('/api/properties', { method: 'PUT', body: { properties: patch } });
|
||||||
|
const msg = root.querySelector('#optMsg');
|
||||||
|
|
||||||
|
if (res?.ok) {
|
||||||
|
msg.textContent = 'Configurações salvas com sucesso!';
|
||||||
|
msg.className = 'text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 px-3.5 py-2 rounded-xl flex items-center gap-1.5';
|
||||||
|
} else {
|
||||||
|
msg.textContent = `Erro ao salvar o campo: ${res?.field || 'geral'}`;
|
||||||
|
msg.className = 'text-red-400 text-sm bg-red-500/10 border border-red-500/20 px-3.5 py-2 rounded-xl flex items-center gap-1.5';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { api } from '../socket.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
export function renderPlayers(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="max-w-6xl w-full mx-auto grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start">
|
||||||
|
|
||||||
|
<!-- Directory: the main content -->
|
||||||
|
<div class="bg-slate-900/40 border border-slate-800/80 p-6 rounded-2xl backdrop-blur-md min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-4 pb-3 border-b border-slate-800/60">
|
||||||
|
<h2 class="text-lg font-bold text-slate-100 flex items-center gap-2 mr-auto">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||||
|
Jogadores do Mundo
|
||||||
|
</h2>
|
||||||
|
<span id="player-count" class="text-xs font-semibold px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400">0 online</span>
|
||||||
|
<div class="relative w-full sm:w-64">
|
||||||
|
<svg class="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z"></path></svg>
|
||||||
|
<input id="player-search" type="text" placeholder="Filtrar por nome..." class="w-full pl-9 pr-3 py-2 text-sm rounded-xl bg-slate-950/80 border border-slate-800 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all duration-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul id="directory" class="space-y-2 min-h-[30vh]">
|
||||||
|
<li class="text-slate-400 text-sm italic py-4 text-center">Carregando...</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div id="pagination" class="flex items-center justify-between mt-4 pt-3 border-t border-slate-800/60 text-xs text-slate-400"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Allowlist: everything about it in ONE card -->
|
||||||
|
<div class="bg-slate-900/40 border border-slate-800/80 p-5 rounded-2xl backdrop-blur-md lg:sticky lg:top-6">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<h2 class="text-base font-bold text-slate-100 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||||
|
Allowlist
|
||||||
|
</h2>
|
||||||
|
<button id="allowlist-toggle" class="relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 focus:ring-emerald-500">
|
||||||
|
<span class="sr-only">Ativar/desativar Allowlist</span>
|
||||||
|
<span id="allowlist-knob" class="inline-block h-4 w-4 rounded-full bg-white shadow-md transform transition-all duration-300" style="transform:translateX(0.25rem)"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="allowlist-hint" class="text-[11px] text-slate-500 mb-4"></p>
|
||||||
|
|
||||||
|
<form id="allowlist-add" class="hidden gap-2 mb-4">
|
||||||
|
<input name="name" required placeholder="Adicionar jogador..." class="flex-grow min-w-0 px-3 py-2 text-sm rounded-xl bg-slate-950/80 border border-slate-800 text-white placeholder-slate-600 focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500 transition-all duration-200" />
|
||||||
|
<button class="px-3 py-2 rounded-xl bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/30 text-sm font-bold transition-all duration-200 active:scale-[0.96]" title="Adicionar à allowlist">+</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-[11px] font-semibold text-slate-400 uppercase tracking-wide">Membros</span>
|
||||||
|
<span id="allowlist-count" class="text-xs font-semibold px-2 py-0.5 rounded-full bg-slate-800 border border-slate-700/50 text-slate-400">0</span>
|
||||||
|
</div>
|
||||||
|
<ul id="allowlist-list" class="space-y-1.5 max-h-[45vh] overflow-y-auto pr-1">
|
||||||
|
<li class="text-slate-500 text-xs italic py-2">Carregando...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail modal root -->
|
||||||
|
<div id="detail-modal"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
let capabilities = new Set();
|
||||||
|
let currentWhitelistEnabled = false;
|
||||||
|
let page = 1;
|
||||||
|
let query = '';
|
||||||
|
|
||||||
|
const action = (act, body) => api(`/api/players/${act}`, { method: 'POST', body }).then(() => { refresh(); loadDirectory(); });
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
|
||||||
|
// --- Directory (server-side paginated) ---
|
||||||
|
|
||||||
|
// Per-player actions instead of type-a-name forms: primary buttons inline,
|
||||||
|
// privilege actions (op/deop/ban) behind a native <details> kebab menu.
|
||||||
|
function rowActions(e) {
|
||||||
|
const iconBtn = (data, title, cls, path) => `
|
||||||
|
<button ${data} title="${title}" class="p-1.5 rounded-lg border transition-all duration-200 active:scale-[0.94] ${cls}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="${path}"></path></svg>
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
const detail = e.xuid
|
||||||
|
? `<button data-detail="${esc(e.xuid)}" data-name="${esc(e.name)}" class="text-xs px-3 py-1.5 bg-slate-700/40 hover:bg-slate-600 border border-slate-600/40 hover:border-slate-500 text-slate-200 rounded-lg font-medium transition-all duration-200 active:scale-[0.96]">Detalhes</button>`
|
||||||
|
: '';
|
||||||
|
const online = e.online
|
||||||
|
? iconBtn(`data-tp="${esc(e.name)}"`, 'Teleportar', 'bg-blue-600/10 hover:bg-blue-600 border-blue-500/20 hover:border-blue-500 text-blue-400 hover:text-slate-950', 'M13 10V3L4 14h7v7l9-11h-7z')
|
||||||
|
+ iconBtn(`data-kick="${esc(e.name)}"`, 'Expulsar', 'bg-red-600/10 hover:bg-red-600 border-red-500/20 hover:border-red-600 text-red-400 hover:text-white', 'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const menuItem = (act, label, danger = false) => capabilities.has(act)
|
||||||
|
? `<button data-menu-act="${act}" data-menu-name="${esc(e.name)}" class="w-full text-left px-3 py-2 text-xs ${danger ? 'text-red-400 hover:bg-red-500/10' : 'text-slate-300 hover:bg-slate-800'} transition-colors">${label}</button>`
|
||||||
|
: '';
|
||||||
|
const items = menuItem('op', 'Tornar OP') + menuItem('deop', 'Remover OP') + menuItem('ban', 'Banir jogador', true);
|
||||||
|
const menu = items
|
||||||
|
? `<details class="relative" data-kebab>
|
||||||
|
<summary class="list-none cursor-pointer p-1.5 rounded-lg border border-slate-700/40 hover:bg-slate-800 text-slate-400 hover:text-slate-200 transition-all [&::-webkit-details-marker]:hidden" title="Mais ações">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg>
|
||||||
|
</summary>
|
||||||
|
<div class="absolute right-0 mt-1 w-40 py-1 bg-slate-900 border border-slate-700/70 rounded-xl shadow-2xl z-20 overflow-hidden">${items}</div>
|
||||||
|
</details>`
|
||||||
|
: '';
|
||||||
|
return detail + online + menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDirectory() {
|
||||||
|
const data = await api(`/api/players/directory?page=${page}&pageSize=${PAGE_SIZE}&q=${encodeURIComponent(query)}`);
|
||||||
|
if (!data) return;
|
||||||
|
const list = root.querySelector('#directory');
|
||||||
|
// Don't re-render under an open kebab menu (the 5s poll would close it).
|
||||||
|
if (list.querySelector('[data-kebab][open]')) return;
|
||||||
|
const items = data.items || [];
|
||||||
|
|
||||||
|
list.innerHTML = items.length
|
||||||
|
? items.map((e) => {
|
||||||
|
const initials = esc(e.name.substring(0, 2).toUpperCase());
|
||||||
|
const dot = e.online
|
||||||
|
? `<span class="absolute -top-1 -right-1 flex h-2.5 w-2.5"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span><span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span></span>`
|
||||||
|
: '';
|
||||||
|
const avatar = e.online ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800/40 text-slate-500';
|
||||||
|
const nameCls = e.online ? 'text-slate-200' : 'text-slate-400';
|
||||||
|
const statusLine = `${e.online ? '<span class="text-emerald-400 font-semibold">Online</span>' : '<span class="text-slate-500">Offline</span>'}${e.xuid ? ` · <span class="text-slate-500 font-mono">${esc(e.xuid)}</span>` : ''}`;
|
||||||
|
return `
|
||||||
|
<li class="flex items-center justify-between gap-3 bg-slate-950/40 border border-slate-800 px-4 py-3 rounded-xl hover:border-slate-700 transition-all duration-200 ${e.online ? '' : 'opacity-80'}">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="relative h-9 w-9 rounded-lg ${avatar} flex items-center justify-center font-bold text-xs select-none shrink-0">${initials}${dot}</div>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span class="font-medium ${nameCls} text-sm truncate">${esc(e.name)}</span>
|
||||||
|
<span class="text-[10px] truncate">${statusLine}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 shrink-0">${rowActions(e)}</div>
|
||||||
|
</li>`;
|
||||||
|
}).join('')
|
||||||
|
: `<div class="flex flex-col items-center justify-center py-10 text-slate-500">
|
||||||
|
<svg class="w-8 h-8 opacity-40 mb-2" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 15.182a4.5 4.5 0 01-6.364 0M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span class="text-xs">${query ? 'Nenhum jogador corresponde ao filtro' : 'Nenhum jogador encontrado'}</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Pagination controls
|
||||||
|
const totalPages = Math.max(1, Math.ceil((data.total || 0) / PAGE_SIZE));
|
||||||
|
const pag = root.querySelector('#pagination');
|
||||||
|
pag.innerHTML = `
|
||||||
|
<button data-page="prev" class="px-3 py-1.5 rounded-lg border border-slate-700/60 ${data.page <= 1 ? 'opacity-40 cursor-not-allowed' : 'hover:bg-slate-800'}" ${data.page <= 1 ? 'disabled' : ''}>‹ Anterior</button>
|
||||||
|
<span>Página ${data.page} de ${totalPages} · ${data.total || 0} jogador${(data.total || 0) === 1 ? '' : 'es'}</span>
|
||||||
|
<button data-page="next" class="px-3 py-1.5 rounded-lg border border-slate-700/60 ${data.page >= totalPages ? 'opacity-40 cursor-not-allowed' : 'hover:bg-slate-800'}" ${data.page >= totalPages ? 'disabled' : ''}>Próxima ›</button>`;
|
||||||
|
|
||||||
|
pag.querySelector('[data-page="prev"]').onclick = () => { if (page > 1) { page--; loadDirectory(); } };
|
||||||
|
pag.querySelector('[data-page="next"]').onclick = () => { if (page < totalPages) { page++; loadDirectory(); } };
|
||||||
|
|
||||||
|
list.querySelectorAll('[data-kick]').forEach((b) => { b.onclick = () => action('kick', { name: b.dataset.kick }); });
|
||||||
|
list.querySelectorAll('[data-tp]').forEach((b) => {
|
||||||
|
b.onclick = () => { const t = prompt('Teleportar para (jogador ou coordenadas x y z):'); if (t) action('teleport', { name: b.dataset.tp, target: t }); };
|
||||||
|
});
|
||||||
|
list.querySelectorAll('[data-detail]').forEach((b) => { b.onclick = () => openDetail(b.dataset.detail, b.dataset.name); });
|
||||||
|
list.querySelectorAll('[data-menu-act]').forEach((b) => {
|
||||||
|
b.onclick = () => {
|
||||||
|
const { menuAct, menuName } = b.dataset;
|
||||||
|
b.closest('details')?.removeAttribute('open');
|
||||||
|
if (menuAct === 'ban' && !confirm(`Banir ${menuName} permanentemente do servidor?`)) return;
|
||||||
|
action(menuAct, { name: menuName });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Only one kebab menu open at a time; close on outside click.
|
||||||
|
list.querySelectorAll('[data-kebab]').forEach((d) => {
|
||||||
|
d.addEventListener('toggle', () => {
|
||||||
|
if (d.open) list.querySelectorAll('[data-kebab][open]').forEach((o) => { if (o !== d) o.removeAttribute('open'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root.addEventListener('click', (ev) => {
|
||||||
|
if (!ev.target.closest('[data-kebab]')) root.querySelectorAll('[data-kebab][open]').forEach((o) => o.removeAttribute('open'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Detail modal ---
|
||||||
|
let modalTimer = null;
|
||||||
|
function closeModal() {
|
||||||
|
if (modalTimer) { clearInterval(modalTimer); modalTimer = null; }
|
||||||
|
root.querySelector('#detail-modal').innerHTML = '';
|
||||||
|
document.removeEventListener('keydown', onEsc);
|
||||||
|
}
|
||||||
|
function onEsc(e) { if (e.key === 'Escape') closeModal(); }
|
||||||
|
|
||||||
|
function bar(label, value, max, colorClass) {
|
||||||
|
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-[11px] text-slate-400 mb-1"><span>${label}</span><span>${value} / ${max}</span></div>
|
||||||
|
<div class="h-2.5 rounded-full bg-slate-800 overflow-hidden"><div class="h-full ${colorClass} rounded-full transition-all duration-300" style="width:${pct}%"></div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Minecraft-style inventory rendering ---
|
||||||
|
// Slot layout follows the game: 0-8 hotbar, 9-35 main grid (3 rows of 9).
|
||||||
|
const ICON_BASE = '/assets/mc/items';
|
||||||
|
const shortName = (n) => String(n || '').replace(/^minecraft:/, '');
|
||||||
|
const prettyName = (n) => shortName(n).replace(/_/g, ' ');
|
||||||
|
|
||||||
|
function slotHtml(it, { placeholder = '' } = {}) {
|
||||||
|
const inner = it && it.name
|
||||||
|
? `<img src="${ICON_BASE}/${esc(shortName(it.name))}.png" alt="" loading="lazy"
|
||||||
|
class="w-7 h-7 [image-rendering:pixelated]"
|
||||||
|
onerror="this.outerHTML='<span class=\\'mc-slot-fallback\\'>${esc(prettyName(it.name)).replace(/'/g, ''')}</span>'">
|
||||||
|
${it.count > 1 ? `<span class="mc-count">${it.count}</span>` : ''}`
|
||||||
|
: placeholder;
|
||||||
|
const title = it && it.name ? ` title="${esc(prettyName(it.name))}${it.count > 1 ? ` ×${it.count}` : ''}"` : '';
|
||||||
|
return `<div class="mc-slot"${title}>${inner}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryHtml(d) {
|
||||||
|
const bySlot = new Map((d.inventory || []).filter((i) => i && i.name).map((i) => [i.slot, i]));
|
||||||
|
const row = (from, to) => {
|
||||||
|
let cells = '';
|
||||||
|
for (let s = from; s <= to; s++) cells += slotHtml(bySlot.get(s));
|
||||||
|
return `<div class="grid grid-cols-9 gap-[3px]">${cells}</div>`;
|
||||||
|
};
|
||||||
|
const armor = d.armor || {};
|
||||||
|
const armorIcons = { head: '🪖', chest: '🦺', legs: '👖', feet: '🥾' };
|
||||||
|
const equipment = ['head', 'chest', 'legs', 'feet']
|
||||||
|
.map((k) => slotHtml(armor[k], { placeholder: `<span class="mc-slot-ph">${armorIcons[k]}</span>` }))
|
||||||
|
.join('');
|
||||||
|
return `
|
||||||
|
<div class="mc-inv select-none">
|
||||||
|
<div class="flex items-center justify-between mb-2.5">
|
||||||
|
<div class="flex gap-[3px]">${equipment}</div>
|
||||||
|
<div class="flex items-center gap-[3px]">
|
||||||
|
<span class="text-[10px] text-[#404040] font-semibold mr-1">Escudo</span>
|
||||||
|
${slotHtml(d.offhand, { placeholder: '<span class="mc-slot-ph">🛡️</span>' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${row(9, 17)}${row(18, 26)}${row(27, 35)}
|
||||||
|
<div class="mt-2">${row(0, 8)}</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.mc-inv { background:#c6c6c6; border-radius:6px; padding:10px;
|
||||||
|
border-top:2px solid #fff; border-left:2px solid #fff;
|
||||||
|
border-right:2px solid #555; border-bottom:2px solid #555; width:fit-content; margin:0 auto; }
|
||||||
|
.mc-inv .grid + .grid { margin-top: 3px; }
|
||||||
|
.mc-slot { position:relative; width:36px; height:36px; background:#8b8b8b;
|
||||||
|
border-top:2px solid #373737; border-left:2px solid #373737;
|
||||||
|
border-bottom:2px solid #fff; border-right:2px solid #fff;
|
||||||
|
display:flex; align-items:center; justify-content:center; overflow:hidden; }
|
||||||
|
.mc-count { position:absolute; right:1px; bottom:-1px; font-size:12px; font-weight:700;
|
||||||
|
color:#fff; text-shadow:1px 1px 0 #3f3f3f; line-height:1; pointer-events:none; }
|
||||||
|
.mc-slot-fallback { font-size:7px; line-height:1.1; color:#2f2f2f; text-align:center;
|
||||||
|
word-break:break-word; padding:1px; font-weight:600; }
|
||||||
|
.mc-slot-ph { opacity:.35; font-size:14px; filter:grayscale(1); }
|
||||||
|
</style>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModalBody(d, name) {
|
||||||
|
if (d.needsBridge) {
|
||||||
|
return `<p class="text-sm text-amber-400/90 bg-amber-500/10 border border-amber-500/20 rounded-xl p-4">Abra este jogador uma vez enquanto ele está <b>online</b> para vincular os dados do save (o Bedrock não liga o nome ao arquivo até o primeiro acesso observado).</p>`;
|
||||||
|
}
|
||||||
|
if (d.empty || !d.position) {
|
||||||
|
return `<p class="text-sm text-slate-400 italic py-4">Sem dados salvos para este jogador ainda.</p>`;
|
||||||
|
}
|
||||||
|
const dimLabel = { overworld: 'Overworld', nether: 'Nether', end: 'End' }[d.dimension] || d.dimension;
|
||||||
|
const p = d.position;
|
||||||
|
const invGrid = inventoryHtml(d);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="bg-slate-950/50 border border-slate-800 rounded-xl p-3">
|
||||||
|
<div class="text-[11px] text-slate-500 mb-1">Posição</div>
|
||||||
|
<div class="text-sm text-slate-200 font-mono">${Math.round(p.x)}, ${Math.round(p.y)}, ${Math.round(p.z)}</div>
|
||||||
|
<div class="text-[11px] text-slate-400 mt-1">${dimLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-950/50 border border-slate-800 rounded-xl p-3 space-y-2 flex flex-col justify-center">
|
||||||
|
${d.health ? bar('Vida', Math.round(d.health.current * 10) / 10, d.health.max, 'bg-red-500') : ''}
|
||||||
|
${typeof d.food === 'number' ? bar('Fome', d.food, 20, 'bg-amber-500') : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] text-slate-500 mb-2">Inventário${d.xp ? ` · <span class="text-slate-400">Nível ${d.xp.level}</span>` : ''}${d.gamemode ? ` · <span class="text-slate-400 capitalize">${esc(d.gamemode)}</span>` : ''}</div>
|
||||||
|
<div class="overflow-x-auto">${invGrid}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(xuid, name) {
|
||||||
|
const host = root.querySelector('#detail-modal');
|
||||||
|
const shell = (body, footer = '') => `
|
||||||
|
<div data-backdrop class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div class="w-full max-w-lg bg-slate-900 border border-slate-700/70 rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-800">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="h-9 w-9 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center font-bold text-sm select-none">${esc(name.substring(0, 2).toUpperCase())}</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-bold text-slate-100 truncate">${esc(name)}</div>
|
||||||
|
<div class="text-[10px] text-slate-500 font-mono truncate">${esc(xuid)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button data-close class="text-slate-500 hover:text-slate-200 transition-colors"><svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">${body}</div>
|
||||||
|
<div class="flex items-center justify-between px-5 py-3 border-t border-slate-800 bg-slate-950/40">${footer}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
host.innerHTML = shell('<p class="text-sm text-slate-400 italic py-6 text-center">Carregando dados do save...</p>');
|
||||||
|
document.addEventListener('keydown', onEsc);
|
||||||
|
|
||||||
|
const wire = () => {
|
||||||
|
host.querySelector('[data-close]').onclick = closeModal;
|
||||||
|
host.querySelector('[data-backdrop]').onclick = (ev) => { if (ev.target === ev.currentTarget) closeModal(); };
|
||||||
|
const rb = host.querySelector('[data-refresh]');
|
||||||
|
if (rb) rb.onclick = () => load();
|
||||||
|
};
|
||||||
|
wire();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const d = await api(`/api/players/detail?xuid=${encodeURIComponent(xuid)}`);
|
||||||
|
if (!d) { host.innerHTML = shell('<p class="text-sm text-red-400 py-4">Falha ao ler os dados do jogador.</p>', footerHtml('')); wire(); return; }
|
||||||
|
host.innerHTML = shell(renderModalBody(d, name), footerHtml(d));
|
||||||
|
wire();
|
||||||
|
}
|
||||||
|
function footerHtml(d) {
|
||||||
|
const online = d && d.online ? '<span class="text-[11px] text-emerald-400 font-semibold">● Online</span>' : '<span class="text-[11px] text-slate-500">Offline</span>';
|
||||||
|
const saved = d && d.savedAt ? `<span class="text-[11px] text-slate-500">atualizado às ${new Date(d.savedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>` : '<span></span>';
|
||||||
|
return `${online}${saved}<button data-refresh class="text-xs px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700/60 text-slate-200 transition-all">Atualizar</button>`;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Allowlist card (single home for toggle + add + members) ---
|
||||||
|
function renderAllowlist(data) {
|
||||||
|
currentWhitelistEnabled = data.whitelistEnabled;
|
||||||
|
const toggle = root.querySelector('#allowlist-toggle');
|
||||||
|
const knob = root.querySelector('#allowlist-knob');
|
||||||
|
const hint = root.querySelector('#allowlist-hint');
|
||||||
|
toggle.classList.toggle('bg-emerald-600', currentWhitelistEnabled);
|
||||||
|
toggle.classList.toggle('bg-slate-700', !currentWhitelistEnabled);
|
||||||
|
knob.style.transform = currentWhitelistEnabled ? 'translateX(1.5rem)' : 'translateX(0.25rem)';
|
||||||
|
hint.textContent = currentWhitelistEnabled
|
||||||
|
? 'Ativa — apenas os membros abaixo podem entrar.'
|
||||||
|
: 'Desativada — qualquer jogador pode entrar no servidor.';
|
||||||
|
toggle.onclick = () => action(currentWhitelistEnabled ? 'whitelistOff' : 'whitelistOn', {});
|
||||||
|
|
||||||
|
const addForm = root.querySelector('#allowlist-add');
|
||||||
|
addForm.classList.toggle('hidden', !capabilities.has('whitelistAdd'));
|
||||||
|
addForm.classList.toggle('flex', capabilities.has('whitelistAdd'));
|
||||||
|
addForm.onsubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = addForm.querySelector('input[name="name"]');
|
||||||
|
if (input.value.trim()) action('whitelistAdd', { name: input.value.trim() });
|
||||||
|
input.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = root.querySelector('#allowlist-list');
|
||||||
|
const members = data.whitelist || [];
|
||||||
|
root.querySelector('#allowlist-count').textContent = members.length;
|
||||||
|
list.innerHTML = members.length
|
||||||
|
? members.map((name) => `
|
||||||
|
<li class="flex items-center gap-2.5 bg-slate-950/50 border border-slate-800/60 px-3 py-2 rounded-xl group hover:border-amber-500/30 transition-all duration-200">
|
||||||
|
<div class="h-6 w-6 rounded-md bg-amber-500/10 text-amber-400 flex items-center justify-center font-bold text-[10px] select-none shrink-0">${esc(name.substring(0, 2).toUpperCase())}</div>
|
||||||
|
<span class="text-sm text-slate-300 font-medium truncate mr-auto">${esc(name)}</span>
|
||||||
|
${capabilities.has('whitelistRemove') ? `<button data-wl-remove="${esc(name)}" class="text-slate-600 hover:text-red-400 transition-colors duration-200 opacity-0 group-hover:opacity-100 shrink-0" title="Remover da allowlist"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg></button>` : ''}
|
||||||
|
</li>`).join('')
|
||||||
|
: '<li class="text-slate-500 text-xs italic py-2">Nenhum jogador na allowlist.</li>';
|
||||||
|
list.querySelectorAll('[data-wl-remove]').forEach((b) => { b.onclick = () => action('whitelistRemove', { name: b.dataset.wlRemove }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const data = await api('/api/players');
|
||||||
|
if (!data) return;
|
||||||
|
capabilities = new Set(data.capabilities);
|
||||||
|
root.querySelector('#player-count').textContent = `${data.players?.online || 0}/${data.players?.max || 20} online`;
|
||||||
|
renderAllowlist(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search → server-side query, debounced, resets to page 1.
|
||||||
|
let searchTimer = null;
|
||||||
|
root.querySelector('#player-search').addEventListener('input', (e) => {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { query = e.target.value.trim(); page = 1; loadDirectory(); }, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
loadDirectory();
|
||||||
|
const timer = setInterval(() => { refresh(); loadDirectory(); }, 5000);
|
||||||
|
return () => { clearInterval(timer); closeModal(); };
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { api } from '../socket.js';
|
||||||
|
|
||||||
|
export function renderStatus(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="space-y-6 max-w-4xl mx-auto w-full">
|
||||||
|
<!-- Status Summary Panel -->
|
||||||
|
<div class="p-6 rounded-2xl bg-slate-900/40 border border-slate-800/80 backdrop-blur-md flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="relative flex h-3 w-3">
|
||||||
|
<span id="state-ping" class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"></span>
|
||||||
|
<span id="state-dot" class="relative inline-flex rounded-full h-3 w-3"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span id="state" class="text-lg font-semibold capitalize text-slate-200">Carregando...</span>
|
||||||
|
<span id="edition" class="text-xs px-2.5 py-0.5 rounded-full bg-slate-800 text-slate-400 border border-slate-700/50"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-500 mt-0.5">Status do servidor em tempo real</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button data-act="start" class="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-emerald-600/10 hover:bg-emerald-600 border border-emerald-500/20 hover:border-emerald-500 text-emerald-400 hover:text-slate-950 font-medium transition-all duration-300 shadow-lg shadow-emerald-500/5 hover:shadow-emerald-500/20 active:scale-[0.98]">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"></path></svg>
|
||||||
|
Ligar
|
||||||
|
</button>
|
||||||
|
<button data-act="stop" class="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-amber-600/10 hover:bg-amber-600 border border-amber-500/20 hover:border-amber-500 text-amber-400 hover:text-slate-950 font-medium transition-all duration-300 active:scale-[0.98]">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
|
||||||
|
Desligar
|
||||||
|
</button>
|
||||||
|
<button data-act="restart" class="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-blue-600/10 hover:bg-blue-600 border border-blue-500/20 hover:border-blue-500 text-blue-400 hover:text-slate-950 font-medium transition-all duration-300 active:scale-[0.98]">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg>
|
||||||
|
Reiniciar
|
||||||
|
</button>
|
||||||
|
<button data-act="kill" class="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-rose-600/10 hover:bg-rose-700 border border-rose-500/20 hover:border-rose-600 text-rose-400 hover:text-white font-medium transition-all duration-300 active:scale-[0.98]">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||||
|
Forçar Parada
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resource Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- CPU Card -->
|
||||||
|
<div class="bg-slate-900/40 border border-slate-800/80 p-6 rounded-2xl backdrop-blur-md relative overflow-hidden group">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Processador</span>
|
||||||
|
<div class="h-8 w-8 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cpu" class="text-3xl font-bold tracking-tight text-slate-100">–</div>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">Uso de CPU do container</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory Card -->
|
||||||
|
<div class="bg-slate-900/40 border border-slate-800/80 p-6 rounded-2xl backdrop-blur-md relative overflow-hidden group">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Memória RAM</span>
|
||||||
|
<div class="h-8 w-8 rounded-lg bg-blue-500/10 text-blue-400 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mem" class="text-3xl font-bold tracking-tight text-slate-100">–</div>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">Utilização da memória física</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Players Card -->
|
||||||
|
<div class="bg-slate-900/40 border border-slate-800/80 p-6 rounded-2xl backdrop-blur-md relative overflow-hidden group">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Jogadores</span>
|
||||||
|
<div class="h-8 w-8 rounded-lg bg-violet-500/10 text-violet-400 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="players" class="text-3xl font-bold tracking-tight text-slate-100">–</div>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">Conectados atualmente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
root.querySelectorAll('button[data-act]').forEach((b) => {
|
||||||
|
b.onclick = () => api(`/api/status/${b.dataset.act}`, { method: 'POST' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const socket = io('/status');
|
||||||
|
const pingEl = root.querySelector('#state-ping');
|
||||||
|
const dotEl = root.querySelector('#state-dot');
|
||||||
|
|
||||||
|
socket.on('status', (s) => {
|
||||||
|
const stateEl = root.querySelector('#state');
|
||||||
|
stateEl.textContent = s.state;
|
||||||
|
root.querySelector('#edition').textContent = s.edition ? `Edição: ${s.edition}` : '';
|
||||||
|
root.querySelector('#cpu').textContent = s.state === 'running' ? `${s.cpuPct}%` : '–';
|
||||||
|
root.querySelector('#mem').textContent = s.state === 'running' ? `${s.memUsedMb} MB` : '–';
|
||||||
|
root.querySelector('#players').textContent = s.players ? `${s.players.online}/${s.players.max}` : '–';
|
||||||
|
|
||||||
|
// Color code ping & dot based on state
|
||||||
|
if (s.state === 'running') {
|
||||||
|
pingEl.className = 'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-emerald-500';
|
||||||
|
dotEl.className = 'relative inline-flex rounded-full h-3 w-3 bg-emerald-500';
|
||||||
|
stateEl.className = 'text-lg font-semibold capitalize text-emerald-400';
|
||||||
|
} else if (s.state === 'starting' || s.state === 'stopping' || s.state === 'restarting') {
|
||||||
|
pingEl.className = 'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-amber-500';
|
||||||
|
dotEl.className = 'relative inline-flex rounded-full h-3 w-3 bg-amber-500';
|
||||||
|
stateEl.className = 'text-lg font-semibold capitalize text-amber-400';
|
||||||
|
} else {
|
||||||
|
pingEl.className = 'absolute inline-flex h-full w-full rounded-full opacity-75 bg-rose-500';
|
||||||
|
dotEl.className = 'relative inline-flex rounded-full h-3 w-3 bg-rose-500';
|
||||||
|
stateEl.className = 'text-lg font-semibold capitalize text-rose-400';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => socket.disconnect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { api } from '../socket.js';
|
||||||
|
|
||||||
|
// mcseedmap.net is embeddable (no X-Frame-Options) and renders the REAL biome/
|
||||||
|
// structure map from the seed. URL: https://mcseedmap.net/<lang>/<version>/<seed>
|
||||||
|
const DEFAULT_VERSION = { bedrock: '26.30.0-Bedrock', java: '1.21-Java' };
|
||||||
|
|
||||||
|
function mapUrlFor(seed, edition, mapVersion) {
|
||||||
|
const version = mapVersion || DEFAULT_VERSION[edition] || DEFAULT_VERSION.java;
|
||||||
|
return `https://mcseedmap.net/pt/${version}/${encodeURIComponent(seed)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWorld(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="space-y-6 max-w-6xl w-full mx-auto">
|
||||||
|
|
||||||
|
<!-- World Management Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-stretch">
|
||||||
|
|
||||||
|
<!-- Download Card -->
|
||||||
|
<div class="bg-slate-900/40 border border-slate-800/80 p-6 rounded-2xl backdrop-blur-md flex flex-col">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="h-9 w-9 rounded-xl bg-blue-500/10 text-blue-400 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-slate-200">Baixar mundo</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 leading-relaxed mb-4">Faça o download do seu mapa atual compactado em formato <strong>.zip</strong>.</p>
|
||||||
|
<div class="text-xs text-amber-500/90 flex items-start gap-1 bg-amber-500/10 p-2.5 rounded-lg border border-amber-500/20 mb-4">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||||
|
<span>Recomendado desligar o servidor para evitar corrupção de arquivos.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/api/world/download" class="block w-full text-center py-2.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold text-sm transition-all duration-200 shadow-lg shadow-blue-500/10 active:scale-[0.98]">
|
||||||
|
Baixar .zip do Mundo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Card -->
|
||||||
|
<div class="bg-slate-900/40 border border-slate-800/80 p-6 rounded-2xl backdrop-blur-md flex flex-col">
|
||||||
|
<div class="flex-grow flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="h-9 w-9 rounded-xl bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-slate-200">Subir novo mundo</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 leading-relaxed mb-4">Substitua o mapa do servidor enviando um arquivo <strong>.zip</strong> contendo o novo mundo.</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<input id="worldFile" type="file" accept=".zip" class="hidden" />
|
||||||
|
<label id="fileLabel" for="worldFile" class="w-full flex flex-col items-center justify-center border border-dashed border-slate-700/80 hover:border-emerald-500/60 rounded-xl p-4 bg-slate-950/40 hover:bg-slate-950/80 transition-all duration-250 cursor-pointer text-center group">
|
||||||
|
<svg class="w-6 h-6 text-slate-500 group-hover:text-emerald-400 mb-1 transition-all" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>
|
||||||
|
<span id="fileName" class="text-xs text-slate-400 font-medium truncate w-full px-2">Selecionar arquivo .zip</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="upMsg" class="text-xs text-center mb-4 min-h-[1.25rem]"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="uploadBtn" class="w-full py-2.5 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-slate-950 font-semibold text-sm shadow-lg shadow-emerald-500/10 active:scale-[0.98] transition-all duration-200">
|
||||||
|
Enviar e Substituir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Regen Card -->
|
||||||
|
<div class="bg-slate-900/40 border border-slate-800/80 p-6 rounded-2xl backdrop-blur-md flex flex-col">
|
||||||
|
<div class="flex-grow flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="h-9 w-9 rounded-xl bg-rose-500/10 text-rose-400 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-slate-200">Regerar mundo</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 leading-relaxed mb-4">Esta ação apaga permanentemente o mundo ativo e força a criação de um mapa novo.</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="regenSeed" class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1.5">Seed personalizada (opcional)</label>
|
||||||
|
<input id="regenSeed" type="text" placeholder="Deixe em branco para aleatória" class="w-full px-3 py-2 text-xs rounded-xl bg-slate-950/80 border border-slate-800 text-white placeholder-slate-650 focus:outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-500 transition-all duration-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-rose-500/90 flex items-start gap-1 bg-rose-500/10 p-2.5 rounded-lg border border-rose-500/20 mb-4 font-medium">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||||
|
<span>Esta operação é IRREVERSÍVEL. Faça backup antes.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="regenBtn" class="w-full py-2.5 rounded-xl bg-rose-600/10 hover:bg-rose-700 border border-rose-500/20 hover:border-rose-600 text-rose-400 hover:text-white font-semibold text-sm transition-all duration-200 active:scale-[0.98]">
|
||||||
|
Apagar e Gerar Novo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seed Map Section -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Seed bar -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 bg-slate-900/50 border border-slate-800/80 rounded-2xl px-4 py-3 backdrop-blur-md">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Seed do mundo</span>
|
||||||
|
</div>
|
||||||
|
<code id="seedVal" class="font-mono text-sm text-emerald-400 bg-slate-950/70 border border-slate-800 rounded-lg px-3 py-1">carregando…</code>
|
||||||
|
<button id="copySeed" class="text-xs px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700/60 text-slate-200 transition-all disabled:opacity-40" disabled>Copiar</button>
|
||||||
|
<a id="openMap" href="#" target="_blank" rel="noopener" class="text-xs px-3 py-1.5 rounded-lg bg-emerald-600/10 hover:bg-emerald-600 border border-emerald-500/20 hover:border-emerald-500 text-emerald-400 hover:text-slate-950 transition-all pointer-events-none opacity-40">Abrir no mcseedmap ↗</a>
|
||||||
|
<span id="edition" class="ml-auto text-xs text-slate-500"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embedded map -->
|
||||||
|
<div class="relative bg-slate-950/60 border border-slate-800/80 rounded-2xl overflow-hidden" style="height: 85vh">
|
||||||
|
<div id="mapPlaceholder" class="absolute inset-0 flex items-center justify-center text-center p-6">
|
||||||
|
<div class="text-slate-500 text-sm">
|
||||||
|
<svg class="w-10 h-10 mx-auto mb-3 opacity-30" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z"></path></svg>
|
||||||
|
<p class="mb-1">Aguardando a seed do servidor…</p>
|
||||||
|
<p class="text-xs text-slate-600">O mapa aparece quando o servidor estiver ligado e a seed for lida.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<iframe id="mapFrame" class="w-full h-full hidden" style="border:0" referrerpolicy="no-referrer"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// --- World management handlers ---
|
||||||
|
|
||||||
|
const fileInput = root.querySelector('#worldFile');
|
||||||
|
const fileName = root.querySelector('#fileName');
|
||||||
|
fileInput.onchange = () => {
|
||||||
|
if (fileInput.files.length) {
|
||||||
|
fileName.textContent = fileInput.files[0].name;
|
||||||
|
fileName.classList.remove('text-slate-400');
|
||||||
|
fileName.classList.add('text-emerald-400', 'font-semibold');
|
||||||
|
} else {
|
||||||
|
fileName.textContent = 'Selecionar arquivo .zip';
|
||||||
|
fileName.classList.remove('text-emerald-400', 'font-semibold');
|
||||||
|
fileName.classList.add('text-slate-400');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
root.querySelector('#regenBtn').onclick = async () => {
|
||||||
|
const seed = root.querySelector('#regenSeed').value.trim();
|
||||||
|
if (!confirm('Isto APAGA o mundo atual. Continuar?')) return;
|
||||||
|
if (!confirm('Tem certeza absoluta? Não há como desfazer.')) return;
|
||||||
|
await api('/api/world/regen', { method: 'POST', body: { seed } });
|
||||||
|
alert('Novo mundo sendo gerado. O servidor irá reiniciar.');
|
||||||
|
};
|
||||||
|
|
||||||
|
root.querySelector('#uploadBtn').onclick = async () => {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!confirm('Isto substitui o mundo atual pelo novo arquivo enviado. Continuar?')) return;
|
||||||
|
const msg = root.querySelector('#upMsg');
|
||||||
|
msg.textContent = 'Enviando...';
|
||||||
|
msg.className = 'text-xs text-center mt-2.5 text-blue-400 font-semibold';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('world', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/world/upload', { method: 'POST', body: fd });
|
||||||
|
if (res.ok) {
|
||||||
|
msg.textContent = 'Mundo importado. Servidor reiniciando.';
|
||||||
|
msg.className = 'text-xs text-center mt-2.5 text-emerald-400 font-semibold bg-emerald-500/10 border border-emerald-500/20 py-1.5 rounded-lg';
|
||||||
|
fileInput.value = '';
|
||||||
|
fileName.textContent = 'Selecionar arquivo .zip';
|
||||||
|
fileName.classList.remove('text-emerald-400', 'font-semibold');
|
||||||
|
fileName.classList.add('text-slate-400');
|
||||||
|
} else {
|
||||||
|
msg.textContent = 'Falha: arquivo inválido.';
|
||||||
|
msg.className = 'text-xs text-center mt-2.5 text-rose-450 font-semibold bg-rose-500/10 border border-rose-500/20 py-1.5 rounded-lg';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
msg.textContent = 'Falha de conexão ao enviar.';
|
||||||
|
msg.className = 'text-xs text-center mt-2.5 text-rose-450 font-semibold bg-rose-500/10 border border-rose-500/20 py-1.5 rounded-lg';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Seed map handlers ---
|
||||||
|
|
||||||
|
const seedEl = root.querySelector('#seedVal');
|
||||||
|
const copyBtn = root.querySelector('#copySeed');
|
||||||
|
const openLink = root.querySelector('#openMap');
|
||||||
|
const editionEl = root.querySelector('#edition');
|
||||||
|
const frame = root.querySelector('#mapFrame');
|
||||||
|
const placeholder = root.querySelector('#mapPlaceholder');
|
||||||
|
|
||||||
|
let currentSeed = null;
|
||||||
|
let edition = 'java';
|
||||||
|
let mapVersion = '';
|
||||||
|
|
||||||
|
function applySeed(seed) {
|
||||||
|
if (seed == null || seed === '' || String(seed) === currentSeed) return;
|
||||||
|
currentSeed = String(seed);
|
||||||
|
seedEl.textContent = currentSeed;
|
||||||
|
copyBtn.disabled = false;
|
||||||
|
|
||||||
|
const url = mapUrlFor(currentSeed, edition, mapVersion);
|
||||||
|
openLink.href = url;
|
||||||
|
openLink.classList.remove('pointer-events-none', 'opacity-40');
|
||||||
|
|
||||||
|
// Only (re)load the iframe when the seed actually changes, so panning/zoom
|
||||||
|
// inside mcseedmap isn't reset on every status tick.
|
||||||
|
frame.src = url;
|
||||||
|
frame.classList.remove('hidden');
|
||||||
|
placeholder.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
copyBtn.onclick = async () => {
|
||||||
|
if (!currentSeed) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(currentSeed);
|
||||||
|
copyBtn.textContent = 'Copiado!';
|
||||||
|
setTimeout(() => { copyBtn.textContent = 'Copiar'; }, 1500);
|
||||||
|
} catch { /* clipboard blocked */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial config (seed + edition + optional version override).
|
||||||
|
api('/api/client-config').then((cfg) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
if (cfg.edition) edition = cfg.edition;
|
||||||
|
if (cfg.mapVersion) mapVersion = cfg.mapVersion;
|
||||||
|
editionEl.textContent = cfg.edition ? `Edição: ${cfg.edition}` : '';
|
||||||
|
if (cfg.seed) applySeed(cfg.seed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep listening — the seed becomes available once the server is running.
|
||||||
|
const socket = io('/status');
|
||||||
|
socket.on('status', (s) => {
|
||||||
|
if (s.type) edition = s.type.toUpperCase() === 'BEDROCK' ? 'bedrock' : 'java';
|
||||||
|
editionEl.textContent = s.edition ? `Edição: ${s.edition}` : editionEl.textContent;
|
||||||
|
if (s.seed) applySeed(s.seed);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => socket.disconnect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
export function createAuthRouter(authService) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const ok = await authService.verifyPassword(req.body?.password);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
|
||||||
|
req.session.authed = true;
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => res.json({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', (req, res) => {
|
||||||
|
res.json({ authed: req.session?.authed === true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { NotSupportedError } from '../adapters/serverAdapter.js';
|
||||||
|
import { resolveName, queryDirectory, readDirectory } from '../services/playerDirectory.js';
|
||||||
|
import { createPlayerFileAdapter } from '../adapters/playerFile/index.js';
|
||||||
|
import { createBedrockIdentityBridge } from '../services/bedrockIdentityBridge.js';
|
||||||
|
import { createPlayerDetailService } from '../services/playerDetailService.js';
|
||||||
|
|
||||||
|
const ARG_MAP = {
|
||||||
|
whitelistAdd: (b) => [b.name],
|
||||||
|
whitelistRemove: (b) => [b.name],
|
||||||
|
whitelistOn: () => [],
|
||||||
|
whitelistOff: () => [],
|
||||||
|
op: (b) => [b.name],
|
||||||
|
deop: (b) => [b.name],
|
||||||
|
pardon: (b) => [b.name],
|
||||||
|
ban: (b) => [b.name, b.reason || ''],
|
||||||
|
kick: (b) => [b.name, b.reason || ''],
|
||||||
|
give: (b) => [b.name, b.item, Number(b.count) || 1],
|
||||||
|
gamemode: (b) => [b.name, b.mode],
|
||||||
|
teleport: (b) => [b.name, b.target],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actions whose first argument is a player name. Guarding against an empty name
|
||||||
|
// stops commands like `allowlist remove ` (which the server rejects) from ever
|
||||||
|
// being sent — defense in depth against a bad/ghost name reaching the console.
|
||||||
|
const NAME_REQUIRED = new Set([
|
||||||
|
'whitelistAdd', 'whitelistRemove', 'op', 'deop', 'pardon', 'ban', 'kick', 'give', 'gamemode', 'teleport',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Returns the server.properties key for the whitelist toggle. */
|
||||||
|
function whitelistPropKey(edition) {
|
||||||
|
return edition === 'bedrock' ? 'allow-list' : 'white-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlayersRouter({ appState, propertiesService, config, dockerService }) {
|
||||||
|
const router = Router();
|
||||||
|
const dataRoot = config?.mcDataPath || '/minecraft/data';
|
||||||
|
|
||||||
|
const detailService = createPlayerDetailService({
|
||||||
|
config, appState, dockerService,
|
||||||
|
readDirectory,
|
||||||
|
createFileAdapter: createPlayerFileAdapter,
|
||||||
|
createBridge: createBedrockIdentityBridge,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Online players + whitelist + capabilities. The (potentially huge) directory
|
||||||
|
// is NOT embedded here — it is fetched page-by-page from GET /directory.
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const adapter = await appState.getAdapter();
|
||||||
|
const [players, whitelist, props] = await Promise.all([
|
||||||
|
adapter.listPlayers(),
|
||||||
|
adapter.whitelistList(),
|
||||||
|
propertiesService.read(),
|
||||||
|
]);
|
||||||
|
const edition = adapter._edition;
|
||||||
|
const propKey = whitelistPropKey(edition);
|
||||||
|
const whitelistEnabled = props[propKey] === 'true';
|
||||||
|
res.json({
|
||||||
|
players,
|
||||||
|
capabilities: [...adapter.capabilities],
|
||||||
|
whitelistEnabled,
|
||||||
|
whitelist,
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server-side paginated + filtered directory; each item annotated `online`.
|
||||||
|
router.get('/directory', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const page = Number(req.query.page) || 1;
|
||||||
|
const pageSize = Number(req.query.pageSize) || 25;
|
||||||
|
const q = req.query.q || '';
|
||||||
|
const result = await queryDirectory(dataRoot, { page, pageSize, q });
|
||||||
|
let onlineNames = [];
|
||||||
|
try {
|
||||||
|
const adapter = await appState.getAdapter();
|
||||||
|
onlineNames = (await adapter.listPlayers())?.players || [];
|
||||||
|
} catch { /* server may be down — everyone shows offline */ }
|
||||||
|
const online = new Set(onlineNames);
|
||||||
|
res.json({
|
||||||
|
...result,
|
||||||
|
items: result.items.map((e) => ({ ...e, online: online.has(e.name) })),
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rich per-player data read from the world save (position/health/food/inventory).
|
||||||
|
router.get('/detail', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const xuid = String(req.query.xuid || '');
|
||||||
|
if (!xuid) return res.status(400).json({ error: 'xuid_required' });
|
||||||
|
const detail = await detailService.getDetail(xuid);
|
||||||
|
if (!detail) return res.status(404).json({ error: 'not_found' });
|
||||||
|
res.json(detail);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:action', async (req, res) => {
|
||||||
|
const { action } = req.params;
|
||||||
|
const argsFn = ARG_MAP[action];
|
||||||
|
if (!argsFn) return res.status(400).json({ error: 'unknown_action' });
|
||||||
|
|
||||||
|
req.body = req.body || {};
|
||||||
|
// XUID is the canonical identity. When the client targets a known player by
|
||||||
|
// xuid, resolve it to the current gamertag here — Bedrock console commands are
|
||||||
|
// name-based, so every action is ultimately issued by name.
|
||||||
|
if (req.body.xuid && !String(req.body.name ?? '').trim()) {
|
||||||
|
const name = await resolveName(dataRoot, String(req.body.xuid));
|
||||||
|
if (name) req.body.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NAME_REQUIRED.has(action) && !String(req.body?.name ?? '').trim()) {
|
||||||
|
return res.status(400).json({ error: 'name_required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const adapter = await appState.getAdapter();
|
||||||
|
if (typeof adapter[action] !== 'function') {
|
||||||
|
return res.status(400).json({ error: 'unknown_action' });
|
||||||
|
}
|
||||||
|
const output = await adapter[action](...argsFn(req.body || {}));
|
||||||
|
|
||||||
|
// Persist toggle state to server.properties
|
||||||
|
if (action === 'whitelistOn' || action === 'whitelistOff') {
|
||||||
|
const propKey = whitelistPropKey(adapter._edition);
|
||||||
|
const value = action === 'whitelistOn' ? 'true' : 'false';
|
||||||
|
await propertiesService.update({ [propKey]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, output });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotSupportedError) {
|
||||||
|
return res.status(409).json({ error: 'not_supported' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
// Managed keys: type drives validation; `default` (vanilla server default) is
|
||||||
|
// filled into GET responses when the key is missing from server.properties, so
|
||||||
|
// the Options tab always starts fully populated. motd intentionally has none.
|
||||||
|
export const PROPERTY_SCHEMA = {
|
||||||
|
difficulty: { type: 'enum', values: ['peaceful', 'easy', 'normal', 'hard'], default: 'normal' },
|
||||||
|
gamemode: { type: 'enum', values: ['survival', 'creative', 'adventure', 'spectator'], default: 'survival' },
|
||||||
|
pvp: { type: 'boolean', default: 'true' },
|
||||||
|
'allow-flight': { type: 'boolean', default: 'false' },
|
||||||
|
hardcore: { type: 'boolean', default: 'false' },
|
||||||
|
'keep-inventory': { type: 'boolean', default: 'false' },
|
||||||
|
'allow-nether': { type: 'boolean', default: 'true' },
|
||||||
|
'enable-command-block': { type: 'boolean', default: 'false' },
|
||||||
|
'force-gamemode': { type: 'boolean', default: 'false' },
|
||||||
|
'spawn-monsters': { type: 'boolean', default: 'true' },
|
||||||
|
'spawn-animals': { type: 'boolean', default: 'true' },
|
||||||
|
'spawn-npcs': { type: 'boolean', default: 'true' },
|
||||||
|
'max-players': { type: 'int', default: '20' },
|
||||||
|
'view-distance': { type: 'int', default: '10' },
|
||||||
|
'simulation-distance': { type: 'int', default: '10' },
|
||||||
|
'spawn-protection': { type: 'int', default: '16' },
|
||||||
|
motd: { type: 'string' },
|
||||||
|
'level-name': { type: 'string' },
|
||||||
|
'level-seed': { type: 'string' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge schema defaults under the actual file contents (file always wins).
|
||||||
|
export function withDefaults(properties) {
|
||||||
|
const out = { ...properties };
|
||||||
|
for (const [key, schema] of Object.entries(PROPERTY_SCHEMA)) {
|
||||||
|
if (schema.default !== undefined && out[key] === undefined) out[key] = schema.default;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(properties) {
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
const schema = PROPERTY_SCHEMA[key];
|
||||||
|
if (!schema) continue; // unmanaged keys pass through
|
||||||
|
const str = String(value);
|
||||||
|
if (schema.type === 'enum' && !schema.values.includes(str)) return key;
|
||||||
|
if (schema.type === 'boolean' && str !== 'true' && str !== 'false') return key;
|
||||||
|
if (schema.type === 'int' && !/^-?\d+$/.test(str)) return key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPropertiesRouter({ propertiesService }) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
let current = {};
|
||||||
|
try {
|
||||||
|
current = await propertiesService.read();
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.code !== 'ENOENT') throw err; // no file yet → serve defaults
|
||||||
|
}
|
||||||
|
res.json({ properties: withDefaults(current) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/', async (req, res, next) => {
|
||||||
|
const properties = req.body?.properties || {};
|
||||||
|
const bad = validate(properties);
|
||||||
|
if (bad) return res.status(400).json({ error: 'invalid_value', field: bad });
|
||||||
|
try {
|
||||||
|
const merged = await propertiesService.update(properties);
|
||||||
|
res.json({ ok: true, properties: merged });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { ContainerNotFoundError } from '../services/dockerService.js';
|
||||||
|
|
||||||
|
export function createStatusRouter({ dockerService, appState }) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const info = await dockerService.inspect();
|
||||||
|
let stats = { cpuPct: 0, memUsedMb: 0, memPct: 0 };
|
||||||
|
let players = { online: 0, max: 0, players: [] };
|
||||||
|
let edition = null;
|
||||||
|
if (info.state === 'running') {
|
||||||
|
stats = await dockerService.stats();
|
||||||
|
edition = await appState.getEdition();
|
||||||
|
try {
|
||||||
|
const adapter = await appState.getAdapter();
|
||||||
|
players = await adapter.listPlayers();
|
||||||
|
} catch { /* server up but not accepting commands yet */ }
|
||||||
|
}
|
||||||
|
res.json({ state: info.state, type: info.type, edition, ...stats, players });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const action of ['start', 'stop', 'restart', 'kill']) {
|
||||||
|
router.post(`/${action}`, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await dockerService[action]();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ContainerNotFoundError) {
|
||||||
|
return res.status(404).json({ error: 'not_found' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
export function createWorldRouter({ worldService, upload }) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/regen', async (req, res, next) => {
|
||||||
|
const { seed } = req.body || {};
|
||||||
|
try { res.json(await worldService.regen(seed)); } catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/download', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="world.zip"');
|
||||||
|
const archive = await worldService.createDownloadStream();
|
||||||
|
archive.on('error', () => res.destroy());
|
||||||
|
archive.pipe(res);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/upload', upload.single('world'), async (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
||||||
|
try {
|
||||||
|
await worldService.importWorld(req.file.path);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ error: 'invalid_world' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import session from 'express-session';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { Server as IOServer } from 'socket.io';
|
||||||
|
|
||||||
|
import { requireAuth } from './middleware/auth.js';
|
||||||
|
import { createAuthRouter } from './routes/auth.js';
|
||||||
|
import { createStatusRouter } from './routes/status.js';
|
||||||
|
import { createPlayersRouter } from './routes/players.js';
|
||||||
|
import { createPropertiesRouter } from './routes/properties.js';
|
||||||
|
import { createWorldRouter } from './routes/world.js';
|
||||||
|
import { registerSockets } from './sockets/index.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export function createApp(deps) {
|
||||||
|
const { config, dockerService, appState, propertiesService, worldService, authService, upload, seedService, logger } = deps;
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Comprehensive request logging (info level).
|
||||||
|
if (logger) {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const ip = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'unknown';
|
||||||
|
const isHealth = req.originalUrl === '/api/health';
|
||||||
|
const logLevel = isHealth ? 'debug' : 'info';
|
||||||
|
|
||||||
|
logger[logLevel](`--> ${req.method} ${req.originalUrl}`, { ip, userAgent: req.headers['user-agent'] });
|
||||||
|
|
||||||
|
let logged = false;
|
||||||
|
const logFinish = (status, extra = {}) => {
|
||||||
|
if (logged) return;
|
||||||
|
logged = true;
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
const finishLevel = isHealth ? 'debug' : (status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info');
|
||||||
|
logger[finishLevel](`<-- ${req.method} ${req.originalUrl} ${status} - ${durationMs}ms`, {
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
logFinish(res.statusCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('close', () => {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
logFinish(499, { aborted: true, note: 'Client/Gateway closed connection before response finished' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast, un-intercepted health check endpoint for container probes
|
||||||
|
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Behind a TLS-terminating reverse proxy (e.g. Coolify/Traefik on 443), the
|
||||||
|
// app receives plain http with X-Forwarded-Proto: https. Trusting the proxy
|
||||||
|
// lets express-session recognize the request as secure and actually set the
|
||||||
|
// `secure` session cookie — without this, login silently fails in production.
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
const sessionMiddleware = session({
|
||||||
|
secret: config.sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: config.nodeEnv === 'production',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
|
||||||
|
// public
|
||||||
|
app.use('/api/auth', createAuthRouter(authService));
|
||||||
|
|
||||||
|
|
||||||
|
// guarded
|
||||||
|
app.use('/api', requireAuth);
|
||||||
|
app.use('/api/status', createStatusRouter({ dockerService, appState }));
|
||||||
|
app.use('/api/players', createPlayersRouter({ appState, propertiesService, config, dockerService }));
|
||||||
|
app.use('/api/properties', createPropertiesRouter({ propertiesService }));
|
||||||
|
app.use('/api/world', createWorldRouter({ worldService, upload }));
|
||||||
|
|
||||||
|
// config exposure for the SPA (no secrets): map settings + resolved world seed
|
||||||
|
app.get('/api/client-config', requireAuth, async (req, res) => {
|
||||||
|
let seed = null;
|
||||||
|
let edition = null;
|
||||||
|
try {
|
||||||
|
const adapter = await appState.getAdapter();
|
||||||
|
edition = adapter._edition || null;
|
||||||
|
seed = await seedService.resolve(adapter, edition);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn('client-config: seed resolution failed', { error: err.message });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
mapVersion: config.mapVersion || '',
|
||||||
|
seed,
|
||||||
|
edition,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// static SPA
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// error handler
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger?.error('unhandled request error', { url: req.originalUrl, error: err.message, stack: err.stack });
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, sessionMiddleware };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startServer(deps) {
|
||||||
|
const { app, sessionMiddleware } = createApp(deps);
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = new IOServer(server);
|
||||||
|
registerSockets(io, {
|
||||||
|
dockerService: deps.dockerService,
|
||||||
|
appState: deps.appState,
|
||||||
|
seedService: deps.seedService,
|
||||||
|
sessionMiddleware,
|
||||||
|
logger: deps.logger,
|
||||||
|
config: deps.config,
|
||||||
|
});
|
||||||
|
server.listen(deps.config.port, () => {
|
||||||
|
(deps.logger?.info ? deps.logger.info(`listening on :${deps.config.port}`) : console.log(`CraftDock listening on :${deps.config.port}`));
|
||||||
|
});
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export function createAuthService(config) {
|
||||||
|
return {
|
||||||
|
async verifyPassword(plain) {
|
||||||
|
if (!plain) return false;
|
||||||
|
return bcrypt.compare(plain, config.adminPasswordHash);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Persistent bridge between a Bedrock player's identity (numeric XUID + gamertag,
|
||||||
|
// known only from the log directory) and their LevelDB record (keyed by a
|
||||||
|
// self-signed UUID that never references the XUID). Learned while a player is
|
||||||
|
// online by matching the live entity `uniqueId` (from querytarget) to a LevelDB
|
||||||
|
// player's `UniqueID`; persisted so offline reads work forever afterwards.
|
||||||
|
//
|
||||||
|
// File: <dataRoot>/craftdock_bedrock_ids.json
|
||||||
|
// { "<xuid>": { name, leveldbUuid, uniqueId, boundAt } }
|
||||||
|
|
||||||
|
const FILE = 'craftdock_bedrock_ids.json';
|
||||||
|
|
||||||
|
export function createBedrockIdentityBridge({ dataRoot, fileAdapter }) {
|
||||||
|
const filePath = path.join(dataRoot, FILE);
|
||||||
|
|
||||||
|
async function readMap() {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await fsp.readFile(filePath, 'utf8'));
|
||||||
|
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeMap(map) {
|
||||||
|
try {
|
||||||
|
await fsp.writeFile(filePath, JSON.stringify(map, null, 2), 'utf8');
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind xuid → LevelDB uuid using the live uniqueId or fallbackUuid.
|
||||||
|
async function learn({ xuid, name, uniqueId, fallbackUuid }) {
|
||||||
|
if (!xuid) return null;
|
||||||
|
let targetUuid = fallbackUuid;
|
||||||
|
if (!targetUuid && uniqueId != null) {
|
||||||
|
const hit = await fileAdapter.findByUniqueId(String(uniqueId));
|
||||||
|
if (hit) targetUuid = hit.uuid;
|
||||||
|
}
|
||||||
|
if (!targetUuid) return null;
|
||||||
|
|
||||||
|
const map = await readMap();
|
||||||
|
const binding = {
|
||||||
|
name: name ?? map[xuid]?.name ?? null,
|
||||||
|
leveldbUuid: targetUuid,
|
||||||
|
uniqueId: uniqueId ? String(uniqueId) : (map[xuid]?.uniqueId || null),
|
||||||
|
boundAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
map[xuid] = binding;
|
||||||
|
await writeMap(map);
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function resolveLeveldbUuid(xuid) {
|
||||||
|
const map = await readMap();
|
||||||
|
return map[xuid]?.leveldbUuid || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { learn, resolveLeveldbUuid };
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
export class ContainerNotFoundError extends Error {
|
||||||
|
constructor(name) {
|
||||||
|
super(`Container not found: ${name}`);
|
||||||
|
this.name = 'ContainerNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseType(env = []) {
|
||||||
|
const entry = env.find((e) => e.startsWith('TYPE='));
|
||||||
|
return entry ? entry.slice('TYPE='.length) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_LABEL = 'com.docker.compose.service';
|
||||||
|
const PROJECT_LABEL = 'com.docker.compose.project';
|
||||||
|
|
||||||
|
function withTimeout(promise, ms, label, logger) {
|
||||||
|
let timer;
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const err = new Error(`Docker API call timed out (${ms}ms): ${label}`);
|
||||||
|
logger?.error('docker API timeout', { label, timeoutMs: ms });
|
||||||
|
reject(err);
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDockerService(config, docker, logger) {
|
||||||
|
const name = config.mcContainerName;
|
||||||
|
const serviceName = config.mcServiceName;
|
||||||
|
let ownProjectCache;
|
||||||
|
let statsCache = null;
|
||||||
|
let lastStatsTime = 0;
|
||||||
|
let inFlightStatsPromise = null;
|
||||||
|
|
||||||
|
// The panel's own compose project, read by self-inspecting the panel container
|
||||||
|
// (its hostname is its container id). Used to scope the service-label fallback
|
||||||
|
// so we never pick up a same-named service from another project.
|
||||||
|
async function ownProject() {
|
||||||
|
if (ownProjectCache !== undefined) return ownProjectCache;
|
||||||
|
try {
|
||||||
|
const self = await withTimeout(docker.getContainer(os.hostname()).inspect(), 3000, 'selfInspect', logger);
|
||||||
|
ownProjectCache = self.Config?.Labels?.[PROJECT_LABEL] || null;
|
||||||
|
} catch (err) {
|
||||||
|
logger?.debug('ownProject inspect failed/skipped', { error: err.message });
|
||||||
|
ownProjectCache = null;
|
||||||
|
}
|
||||||
|
return ownProjectCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve() {
|
||||||
|
let list;
|
||||||
|
try {
|
||||||
|
list = await withTimeout(docker.listContainers({ all: true }), 5000, 'listContainers', logger);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.error('docker listContainers failed', { error: err.message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Exact container name (works locally where container_name is honored).
|
||||||
|
let match = list.find((c) => c.Names?.some((n) => n === `/${name}` || n === name));
|
||||||
|
|
||||||
|
// 2. Fallback: compose service label (handles Coolify's auto-generated names),
|
||||||
|
// scoped to the panel's own project when it can be determined.
|
||||||
|
if (!match && serviceName) {
|
||||||
|
const project = await ownProject();
|
||||||
|
match = list.find((c) =>
|
||||||
|
c.Labels?.[SERVICE_LABEL] === serviceName &&
|
||||||
|
(!project || c.Labels?.[PROJECT_LABEL] === project));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) throw new ContainerNotFoundError(name);
|
||||||
|
return docker.getContainer(match.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lifecycle(method) {
|
||||||
|
logger?.info(`docker container lifecycle: ${method}`, { name });
|
||||||
|
const container = await resolve();
|
||||||
|
await withTimeout(container[method](), 10000, `lifecycle:${method}`, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspect() {
|
||||||
|
let container;
|
||||||
|
try {
|
||||||
|
container = await resolve();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ContainerNotFoundError) {
|
||||||
|
return { found: false, state: 'not_found', type: null };
|
||||||
|
}
|
||||||
|
logger?.warn('docker inspect container resolve error', { error: err.message });
|
||||||
|
return { found: false, state: 'error', type: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await withTimeout(container.inspect(), 5000, 'inspect', logger);
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
state: data.State?.Status || 'unknown',
|
||||||
|
type: parseType(data.Config?.Env),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn('docker container inspect error', { error: err.message });
|
||||||
|
return { found: true, state: 'unknown', type: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getContainer() { return resolve(); },
|
||||||
|
start: () => lifecycle('start'),
|
||||||
|
stop: () => lifecycle('stop'),
|
||||||
|
restart: () => lifecycle('restart'),
|
||||||
|
kill: () => lifecycle('kill'),
|
||||||
|
inspect,
|
||||||
|
|
||||||
|
async getState() {
|
||||||
|
return (await inspect()).state;
|
||||||
|
},
|
||||||
|
|
||||||
|
async stats() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (statsCache && (now - lastStatsTime < 2000)) {
|
||||||
|
return statsCache;
|
||||||
|
}
|
||||||
|
if (inFlightStatsPromise) {
|
||||||
|
return inFlightStatsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlightStatsPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const container = await resolve();
|
||||||
|
const s = await container.stats({ stream: false });
|
||||||
|
const cpuDelta =
|
||||||
|
(s.cpu_stats?.cpu_usage?.total_usage ?? 0) -
|
||||||
|
(s.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
||||||
|
const sysDelta =
|
||||||
|
(s.cpu_stats?.system_cpu_usage ?? 0) - (s.precpu_stats?.system_cpu_usage ?? 0);
|
||||||
|
const cpus = s.cpu_stats?.online_cpus || 1;
|
||||||
|
const cpuPct = sysDelta > 0 ? (cpuDelta / sysDelta) * cpus * 100 : 0;
|
||||||
|
const memUsed = s.memory_stats?.usage ?? 0;
|
||||||
|
const memLimit = s.memory_stats?.limit || 1;
|
||||||
|
|
||||||
|
statsCache = {
|
||||||
|
cpuPct: Math.round(cpuPct * 10) / 10,
|
||||||
|
memUsedMb: Math.round(memUsed / (1024 * 1024)),
|
||||||
|
memPct: Math.round((memUsed / memLimit) * 1000) / 10,
|
||||||
|
};
|
||||||
|
lastStatsTime = Date.now();
|
||||||
|
return statsCache;
|
||||||
|
} catch {
|
||||||
|
return statsCache || { cpuPct: 0, memUsedMb: 0, memPct: 0 };
|
||||||
|
} finally {
|
||||||
|
inFlightStatsPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return inFlightStatsPromise;
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
async logStream(tail = 200) {
|
||||||
|
const container = await resolve();
|
||||||
|
return container.logs({ follow: true, stdout: true, stderr: true, tail });
|
||||||
|
},
|
||||||
|
|
||||||
|
async attach() {
|
||||||
|
const container = await resolve();
|
||||||
|
return container.attach({ stream: true, stdin: true, stdout: true, stderr: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// One-time defaults applied the FIRST time CraftDock sees a data volume.
|
||||||
|
// Currently: force the whitelist off, so a brand-new server always starts open.
|
||||||
|
// A marker file at the data root (sibling of the other craftdock_* metadata)
|
||||||
|
// records that initialization happened — plain restarts never touch anything,
|
||||||
|
// preserving whatever the admin toggled afterwards.
|
||||||
|
|
||||||
|
export const MARKER_FILE = 'craftdock_initialized.json';
|
||||||
|
|
||||||
|
const WHITELIST_KEYS = { bedrock: ['allow-list'], java: ['white-list'] };
|
||||||
|
|
||||||
|
export async function ensureFirstBootDefaults({ config, propertiesService, logger }) {
|
||||||
|
const marker = path.join(config.mcDataPath, MARKER_FILE);
|
||||||
|
try {
|
||||||
|
await fsp.access(marker);
|
||||||
|
return; // not the first boot
|
||||||
|
} catch { /* marker absent → first boot */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keys = WHITELIST_KEYS[config.mcEdition] || [...WHITELIST_KEYS.bedrock, ...WHITELIST_KEYS.java];
|
||||||
|
const patch = Object.fromEntries(keys.map((k) => [k, 'false']));
|
||||||
|
await propertiesService.update(patch);
|
||||||
|
|
||||||
|
await fsp.writeFile(marker, JSON.stringify({ initializedAt: new Date().toISOString(), applied: patch }, null, 2), 'utf8');
|
||||||
|
await fsp.chmod(marker, 0o666).catch(() => {});
|
||||||
|
logger?.info('first boot: whitelist disabled by default', { keys });
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort: log and leave the marker unwritten so the next boot retries.
|
||||||
|
logger?.warn('first-boot defaults failed', { error: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Orchestrates a fresh, safe read of one player's data from the world save.
|
||||||
|
//
|
||||||
|
// Flow (getDetail(xuid)):
|
||||||
|
// 1. Resolve the gamertag from the directory (xuid is the canonical key; for
|
||||||
|
// Java the key is the player's UUID).
|
||||||
|
// 2. Determine online (listPlayers) and whether the server is running.
|
||||||
|
// 3. Bedrock: while online, learn the identity bridge (querytarget uniqueId →
|
||||||
|
// LevelDB UniqueID); resolve the LevelDB uuid. If never bridged → needsBridge.
|
||||||
|
// Read under a `save hold` snapshot when the server is running.
|
||||||
|
// 4. Java: force `save-all flush` when the target is online, then read the .dat.
|
||||||
|
//
|
||||||
|
// Collaborators are injected so the whole flow is unit-testable.
|
||||||
|
|
||||||
|
const SUPPORTED = { health: true, food: true, inventory: true };
|
||||||
|
|
||||||
|
async function withSnapshot(adapter, fn) {
|
||||||
|
const held = await adapter.saveHold();
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
if (held) {
|
||||||
|
await adapter.saveResume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function createPlayerDetailService({
|
||||||
|
config, appState, dockerService, readDirectory, createFileAdapter, createBridge,
|
||||||
|
}) {
|
||||||
|
const dataRoot = config.mcDataPath;
|
||||||
|
|
||||||
|
async function getDetail(xuid) {
|
||||||
|
const dir = await readDirectory(dataRoot);
|
||||||
|
const entry = dir[xuid];
|
||||||
|
if (!entry) return null;
|
||||||
|
const name = entry.name;
|
||||||
|
|
||||||
|
const adapter = await appState.getAdapter();
|
||||||
|
const edition = adapter._edition;
|
||||||
|
const list = await adapter.listPlayers();
|
||||||
|
const online = Array.isArray(list?.players) && list.players.includes(name);
|
||||||
|
const serverRunning = (await dockerService.inspect()).state === 'running';
|
||||||
|
const fileAdapter = createFileAdapter(edition, config);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
if (edition === 'bedrock') {
|
||||||
|
const bridge = createBridge({ dataRoot, fileAdapter });
|
||||||
|
|
||||||
|
// Bridge lookup → live querytarget learn (online only) → single-player
|
||||||
|
// world fallback. Identical whether or not we're inside a snapshot.
|
||||||
|
const resolveUuid = async () => {
|
||||||
|
let uuid = await bridge.resolveLeveldbUuid(xuid);
|
||||||
|
if (!uuid && online) {
|
||||||
|
const uniqueId = await adapter.queryUniqueId(name);
|
||||||
|
if (uniqueId) await bridge.learn({ xuid, name, uniqueId });
|
||||||
|
uuid = await bridge.resolveLeveldbUuid(xuid);
|
||||||
|
}
|
||||||
|
if (!uuid && typeof fileAdapter.listServerUuids === 'function') {
|
||||||
|
try {
|
||||||
|
const uuids = await fileAdapter.listServerUuids();
|
||||||
|
if (Array.isArray(uuids) && uuids.length === 1) {
|
||||||
|
uuid = uuids[0];
|
||||||
|
await bridge.learn({ xuid, name, fallbackUuid: uuid });
|
||||||
|
}
|
||||||
|
} catch { /* ignore fallback error */ }
|
||||||
|
}
|
||||||
|
return uuid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readViaUuid = async () => {
|
||||||
|
// Resolve inside the snapshot (when held) to avoid LevelDB lock conflicts.
|
||||||
|
const leveldbUuid = await resolveUuid();
|
||||||
|
return leveldbUuid ? { leveldbUuid, data: await fileAdapter.readPlayer(leveldbUuid) } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serverRunning
|
||||||
|
? await withSnapshot(adapter, readViaUuid)
|
||||||
|
: await readViaUuid();
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return { xuid, name, online, needsBridge: true, supported: SUPPORTED };
|
||||||
|
}
|
||||||
|
data = result.data;
|
||||||
|
} else {
|
||||||
|
const uuid = entry.xuid || xuid; // Java directory key is the player UUID
|
||||||
|
if (online && serverRunning) await adapter.forceSave();
|
||||||
|
data = await fileAdapter.readPlayer(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return { xuid, name, online, needsBridge: false, empty: true, position: null, supported: SUPPORTED };
|
||||||
|
}
|
||||||
|
return { xuid, name, online, needsBridge: false, supported: SUPPORTED, ...data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getDetail };
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { PLAYER_NAME_REGEX } from '../adapters/serverAdapter.js';
|
||||||
|
|
||||||
|
// A persistent XUID→name directory of players who REALLY accessed the world.
|
||||||
|
//
|
||||||
|
// Why not the world files directly? A Bedrock world is a compressed LevelDB that
|
||||||
|
// stores player data keyed by XUID with NO readable gamertag — the name only ever
|
||||||
|
// appears in the server log, at login. So we build the mapping from the log, but
|
||||||
|
// keyed by XUID (the canonical identity: survives renames, never duplicates) and
|
||||||
|
// persisted here so it outlives log rotation.
|
||||||
|
//
|
||||||
|
// "Really accessed" = a `Player Spawned` line. A player rejected by the allowlist
|
||||||
|
// gets a `Player connected` line but is disconnected BEFORE spawning, so keying on
|
||||||
|
// spawn is what excludes them (Scenario 1). Because entries are persisted, players
|
||||||
|
// who joined while the server was public stay listed even after an allowlist is
|
||||||
|
// later enabled and they're no longer on it (Scenario 2).
|
||||||
|
//
|
||||||
|
// File lives at the data root (sibling of allowlist.json) so it's bundled into the
|
||||||
|
// world export/backup, like the rest of CraftDock's metadata.
|
||||||
|
|
||||||
|
const FILE = 'craftdock_players.json';
|
||||||
|
const LEGACY_HISTORY = 'craftdock_players_history.json';
|
||||||
|
|
||||||
|
// Bedrock spawn line, e.g.
|
||||||
|
// [2026-07-19 02:29:10:123 INFO] Player Spawned: Lucas Guerra xuid: 2535407895138987, pfid: ...
|
||||||
|
// Gamertags may contain spaces, so capture non-greedily up to " xuid:".
|
||||||
|
const SPAWN_RE = /Player Spawned:\s*(.+?)\s+xuid:\s*(\d+)/gi;
|
||||||
|
|
||||||
|
// Control chars docker interleaves as stream-framing bytes; strip them but keep
|
||||||
|
// newlines so log lines stay separated. Built from char codes to avoid embedding
|
||||||
|
// raw control bytes in source.
|
||||||
|
const CTRL_RE = new RegExp('[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]', 'g');
|
||||||
|
|
||||||
|
// Module-level cursor (single server instance): Unix seconds of the last log scan.
|
||||||
|
// First scan reads a bounded tail; later scans read only new lines via `since`, so
|
||||||
|
// the 2s status poll never re-parses the whole (ever-growing) container log.
|
||||||
|
let lastScanSec = 0;
|
||||||
|
|
||||||
|
function filePath(dataRoot) {
|
||||||
|
return path.join(dataRoot, FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readDirectory(dataRoot) {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(filePath(dataRoot), 'utf8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeDirectory(dataRoot, dir) {
|
||||||
|
try {
|
||||||
|
const file = filePath(dataRoot);
|
||||||
|
await fsp.writeFile(file, JSON.stringify(dir, null, 2), 'utf8');
|
||||||
|
await fsp.chmod(file, 0o666).catch(() => {});
|
||||||
|
} catch { /* best-effort: a failed write just means we re-scan next tick */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time import of the old name-only history array. Those entries have no XUID,
|
||||||
|
// so they're keyed `legacy:<name>` and superseded the first time the same name is
|
||||||
|
// seen spawning with a real XUID (see mergeSpawn).
|
||||||
|
async function migrateLegacy(dataRoot, dir) {
|
||||||
|
const legacyPath = path.join(dataRoot, LEGACY_HISTORY);
|
||||||
|
let names;
|
||||||
|
try {
|
||||||
|
names = JSON.parse(await fsp.readFile(legacyPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(names)) { await fsp.rm(legacyPath, { force: true }).catch(() => {}); return false; }
|
||||||
|
|
||||||
|
const known = new Set(Object.values(dir).map((e) => e.name));
|
||||||
|
for (const name of names) {
|
||||||
|
if (typeof name === 'string' && PLAYER_NAME_REGEX.test(name) && !known.has(name)) {
|
||||||
|
dir[`legacy:${name}`] = { name, xuid: null, firstSeen: null, lastSeen: null };
|
||||||
|
known.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fsp.rm(legacyPath, { force: true }).catch(() => {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge one spawn (xuid + current name) into the directory. Returns true if the
|
||||||
|
// directory changed. Promotes any matching `legacy:<name>` placeholder to the real
|
||||||
|
// XUID-keyed entry.
|
||||||
|
function mergeSpawn(dir, xuid, name, nowIso) {
|
||||||
|
if (!/^\d+$/.test(xuid) || !PLAYER_NAME_REGEX.test(name)) return false;
|
||||||
|
|
||||||
|
const legacyKey = `legacy:${name}`;
|
||||||
|
const hadLegacy = legacyKey in dir;
|
||||||
|
if (hadLegacy) delete dir[legacyKey];
|
||||||
|
|
||||||
|
const existing = dir[xuid];
|
||||||
|
if (!existing) {
|
||||||
|
dir[xuid] = { name, xuid, firstSeen: nowIso, lastSeen: nowIso };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Same identity: refresh the name (rename) and lastSeen.
|
||||||
|
const renamed = existing.name !== name;
|
||||||
|
existing.name = name;
|
||||||
|
existing.lastSeen = nowIso;
|
||||||
|
if (!existing.xuid) existing.xuid = xuid;
|
||||||
|
return renamed || hadLegacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastDirectoryUpdateMs = 0;
|
||||||
|
let cachedDirectoryList = null;
|
||||||
|
|
||||||
|
async function scanSpawns(dockerService, sinceSec, logger) {
|
||||||
|
const spawns = [];
|
||||||
|
try {
|
||||||
|
const container = await dockerService.getContainer();
|
||||||
|
const opts = { stdout: true, stderr: true, follow: false };
|
||||||
|
if (sinceSec) opts.since = sinceSec;
|
||||||
|
else opts.tail = 2000;
|
||||||
|
const buf = await container.logs(opts);
|
||||||
|
const text = buf.toString('utf8').replace(CTRL_RE, '');
|
||||||
|
let m;
|
||||||
|
while ((m = SPAWN_RE.exec(text)) !== null) {
|
||||||
|
spawns.push({ name: m[1].trim(), xuid: m[2] });
|
||||||
|
}
|
||||||
|
SPAWN_RE.lastIndex = 0;
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn('player directory scanSpawns failed', { error: err?.message });
|
||||||
|
}
|
||||||
|
return spawns;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Java, the world already carries name↔uuid in usercache.json (real world
|
||||||
|
// data), so seed the directory from it — no log scanning needed.
|
||||||
|
async function seedFromUsercache(dataRoot, dir, nowIso) {
|
||||||
|
let changed = false;
|
||||||
|
try {
|
||||||
|
const cache = JSON.parse(await fsp.readFile(path.join(dataRoot, 'usercache.json'), 'utf8'));
|
||||||
|
if (Array.isArray(cache)) {
|
||||||
|
for (const e of cache) {
|
||||||
|
if (e?.name && e?.uuid && PLAYER_NAME_REGEX.test(e.name) && !dir[e.uuid]) {
|
||||||
|
dir[e.uuid] = { name: e.name, xuid: e.uuid, firstSeen: nowIso, lastSeen: nowIso };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* no usercache (Bedrock, or none yet) */ }
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update and return the player directory as a flat, sorted list.
|
||||||
|
* @returns {Promise<Array<{xuid: string|null, name: string, firstSeen: string|null, lastSeen: string|null}>>}
|
||||||
|
*/
|
||||||
|
export async function updatePlayerDirectory({ dataRoot, dockerService, edition, logger, minIntervalMs = 0 }) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (minIntervalMs > 0 && cachedDirectoryList && (now - lastDirectoryUpdateMs < minIntervalMs)) {
|
||||||
|
return cachedDirectoryList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = await readDirectory(dataRoot);
|
||||||
|
const nowIso = new Date(now).toISOString();
|
||||||
|
let changed = await migrateLegacy(dataRoot, dir);
|
||||||
|
|
||||||
|
if (edition === 'java') {
|
||||||
|
if (await seedFromUsercache(dataRoot, dir, nowIso)) changed = true;
|
||||||
|
} else if (dockerService) {
|
||||||
|
// Capture the cursor BEFORE scanning so lines written during the scan aren't
|
||||||
|
// lost on the next pass.
|
||||||
|
const nowSec = Math.floor(now / 1000);
|
||||||
|
const spawns = await scanSpawns(dockerService, lastScanSec || undefined, logger);
|
||||||
|
lastScanSec = nowSec;
|
||||||
|
for (const s of spawns) {
|
||||||
|
if (mergeSpawn(dir, s.xuid, s.name, nowIso)) changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) await writeDirectory(dataRoot, dir);
|
||||||
|
cachedDirectoryList = Object.values(dir).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
lastDirectoryUpdateMs = Date.now();
|
||||||
|
return cachedDirectoryList;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Resolve an XUID to its current gamertag (Bedrock console commands are name-based). */
|
||||||
|
export async function resolveName(dataRoot, xuid) {
|
||||||
|
const dir = await readDirectory(dataRoot);
|
||||||
|
return dir[xuid]?.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side paginated + filtered view of the directory. Scales to very large
|
||||||
|
* directories: the client fetches one page at a time instead of the whole list.
|
||||||
|
* @returns {Promise<{ items: Array, total: number, page: number, pageSize: number }>}
|
||||||
|
*/
|
||||||
|
export async function queryDirectory(dataRoot, { page = 1, pageSize = 25, q = '' } = {}) {
|
||||||
|
const dir = await readDirectory(dataRoot);
|
||||||
|
const needle = String(q || '').trim().toLowerCase();
|
||||||
|
let items = Object.values(dir);
|
||||||
|
if (needle) items = items.filter((e) => e.name?.toLowerCase().includes(needle));
|
||||||
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const total = items.length;
|
||||||
|
const size = Math.max(1, pageSize | 0);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||||
|
const current = Math.min(Math.max(1, page | 0), pageCount);
|
||||||
|
const start = (current - 1) * size;
|
||||||
|
return { items: items.slice(start, start + size), total, page: current, pageSize: size };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export function createPropertiesService(config, fs = fsp) {
|
||||||
|
const file = path.join(config.mcDataPath, 'server.properties');
|
||||||
|
|
||||||
|
function parse(text) {
|
||||||
|
const obj = {};
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const idx = trimmed.indexOf('=');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
obj[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(obj, originalText = '') {
|
||||||
|
const seen = new Set();
|
||||||
|
const lines = originalText.split(/\r?\n/).map((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||||
|
const idx = trimmed.indexOf('=');
|
||||||
|
if (idx === -1) return line;
|
||||||
|
const key = trimmed.slice(0, idx);
|
||||||
|
if (!(key in obj)) return line;
|
||||||
|
seen.add(key);
|
||||||
|
return `${key}=${obj[key]}`;
|
||||||
|
});
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (!seen.has(key)) lines.push(`${key}=${value}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read() {
|
||||||
|
return parse(await fs.readFile(file, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(patch) {
|
||||||
|
let original = '';
|
||||||
|
try { original = await fs.readFile(file, 'utf8'); } catch { original = ''; }
|
||||||
|
const merged = { ...parse(original), ...patch };
|
||||||
|
await fs.writeFile(file, serialize(merged, original), 'utf8');
|
||||||
|
if (typeof fs.chmod === 'function') {
|
||||||
|
await fs.chmod(file, 0o666).catch(() => {});
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parse, serialize, read, update };
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Rcon } from 'rcon-client';
|
||||||
|
|
||||||
|
export class RconUnavailableError extends Error {
|
||||||
|
constructor(cause) {
|
||||||
|
super(`RCON unavailable: ${cause?.message || cause}`);
|
||||||
|
this.name = 'RconUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRconService(config, RconClass = Rcon, logger) {
|
||||||
|
let conn = null;
|
||||||
|
|
||||||
|
async function ensure() {
|
||||||
|
if (conn) return conn;
|
||||||
|
try {
|
||||||
|
conn = await RconClass.connect({
|
||||||
|
host: config.rconHost,
|
||||||
|
port: config.rconPort,
|
||||||
|
password: config.rconPassword,
|
||||||
|
});
|
||||||
|
logger?.info('rcon connected', { host: config.rconHost, port: config.rconPort });
|
||||||
|
conn.on?.('error', () => { conn = null; });
|
||||||
|
conn.on?.('end', () => { conn = null; });
|
||||||
|
return conn;
|
||||||
|
} catch (err) {
|
||||||
|
conn = null;
|
||||||
|
logger?.warn('rcon connect failed', { error: err.message });
|
||||||
|
throw new RconUnavailableError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async send(cmd) {
|
||||||
|
const c = await ensure();
|
||||||
|
logger?.debug('rcon >', { cmd });
|
||||||
|
try {
|
||||||
|
const out = await c.send(cmd);
|
||||||
|
logger?.debug('rcon <', { cmd, out });
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
conn = null;
|
||||||
|
logger?.warn('rcon send failed', { cmd, error: err.message });
|
||||||
|
throw new RconUnavailableError(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
if (conn) { await conn.end?.(); conn = null; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// MC data paths are always container (POSIX) paths, regardless of the host OS.
|
||||||
|
const posixJoin = path.posix.join;
|
||||||
|
|
||||||
|
// Bedrock stores the world seed in worlds/<level-name>/level.dat as a
|
||||||
|
// little-endian NBT Long named "RandomSeed" (there is NO `seed` console command
|
||||||
|
// on Bedrock — that is Java-only). The value bytes follow the tag name directly.
|
||||||
|
export function parseBedrockSeed(buffer) {
|
||||||
|
const marker = Buffer.from('RandomSeed', 'utf8');
|
||||||
|
const idx = buffer.indexOf(marker);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const valueOffset = idx + marker.length;
|
||||||
|
if (valueOffset + 8 > buffer.length) return null;
|
||||||
|
return buffer.readBigInt64LE(valueOffset).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSeedService({ config, propertiesService, fs = fsp, logger } = {}) {
|
||||||
|
let cachedSeed = null;
|
||||||
|
|
||||||
|
async function fromProperties() {
|
||||||
|
try {
|
||||||
|
const props = await propertiesService.read();
|
||||||
|
const raw = props['level-seed'];
|
||||||
|
if (raw != null && String(raw).trim() !== '') return String(raw).trim();
|
||||||
|
} catch (err) {
|
||||||
|
logger?.debug('seed: could not read server.properties', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bedrockLevelName() {
|
||||||
|
try {
|
||||||
|
const props = await propertiesService.read();
|
||||||
|
if (props['level-name']) return props['level-name'];
|
||||||
|
} catch { /* fall through to default */ }
|
||||||
|
return 'Bedrock level';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fromBedrockLevelDat() {
|
||||||
|
const levelName = await bedrockLevelName();
|
||||||
|
const datPath = posixJoin(config.mcDataPath, 'worlds', levelName, 'level.dat');
|
||||||
|
try {
|
||||||
|
const seed = parseBedrockSeed(await fs.readFile(datPath));
|
||||||
|
if (!seed) logger?.warn('seed: RandomSeed not found in level.dat', { datPath });
|
||||||
|
return seed;
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn('seed: could not read bedrock level.dat', { datPath, error: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fromJavaCommand(adapter) {
|
||||||
|
try {
|
||||||
|
const seed = adapter?.getSeed ? await adapter.getSeed() : null;
|
||||||
|
if (!seed) logger?.warn('seed: java command returned nothing');
|
||||||
|
return seed;
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn('seed: java seed command failed', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves the world seed for the given edition. `level-seed` (a fixed seed set
|
||||||
|
// by the operator) always wins; otherwise Bedrock reads level.dat and Java asks
|
||||||
|
// the server over RCON.
|
||||||
|
async function resolve(adapter, edition) {
|
||||||
|
if (cachedSeed !== null) {
|
||||||
|
return cachedSeed;
|
||||||
|
}
|
||||||
|
const fromProps = await fromProperties();
|
||||||
|
if (fromProps) {
|
||||||
|
logger?.debug('seed resolved from server.properties', { seed: fromProps });
|
||||||
|
cachedSeed = fromProps;
|
||||||
|
return fromProps;
|
||||||
|
}
|
||||||
|
const seed = edition === 'bedrock'
|
||||||
|
? await fromBedrockLevelDat()
|
||||||
|
: await fromJavaCommand(adapter);
|
||||||
|
logger?.info('seed resolved', { edition, seed });
|
||||||
|
if (seed !== null) {
|
||||||
|
cachedSeed = seed;
|
||||||
|
}
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
cachedSeed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resolve, fromProperties, fromBedrockLevelDat, fromJavaCommand, clearCache };
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
export function createStdinService(dockerService, { windowMs = 300, timeoutMs = 3000, logger } = {}) {
|
||||||
|
let stream = null;
|
||||||
|
// Mutex: each send() chains on the previous one so only one command is in
|
||||||
|
// flight at a time. This prevents response interleaving on the shared
|
||||||
|
// Docker attach stream (the root cause of Bedrock command corruption).
|
||||||
|
let tail = Promise.resolve();
|
||||||
|
|
||||||
|
async function ensure() {
|
||||||
|
if (stream && !stream.destroyed) return stream;
|
||||||
|
|
||||||
|
// Timeout for attach call in case Docker API stalls
|
||||||
|
let attachTimer;
|
||||||
|
const attachPromise = dockerService.attach();
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
attachTimer = setTimeout(() => reject(new Error('docker attach timeout')), timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = await Promise.race([attachPromise, timeoutPromise]);
|
||||||
|
clearTimeout(attachTimer);
|
||||||
|
stream = s;
|
||||||
|
// When the container stops/restarts (e.g. during a world import) the attach
|
||||||
|
// stream ends but is not always flagged `.destroyed`. Invalidate the cache on
|
||||||
|
// end/close/error so the next command re-attaches to the fresh process
|
||||||
|
// instead of writing into a dead stream (which left `list` returning nothing).
|
||||||
|
const invalidate = () => { if (stream === s) stream = null; };
|
||||||
|
s.once('end', invalidate);
|
||||||
|
s.once('close', invalidate);
|
||||||
|
s.once('error', invalidate);
|
||||||
|
return s;
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(attachTimer);
|
||||||
|
stream = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(cmd) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let s = null;
|
||||||
|
let safetyTimer = null;
|
||||||
|
let windowTimer = null;
|
||||||
|
let onData = null;
|
||||||
|
let isDone = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (safetyTimer) clearTimeout(safetyTimer);
|
||||||
|
if (windowTimer) clearTimeout(windowTimer);
|
||||||
|
if (s && onData) {
|
||||||
|
try { s.off('data', onData); } catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safety timeout for the entire command execution
|
||||||
|
safetyTimer = setTimeout(() => {
|
||||||
|
if (isDone) return;
|
||||||
|
isDone = true;
|
||||||
|
logger?.warn('stdin command timed out', { cmd, timeoutMs });
|
||||||
|
cleanup();
|
||||||
|
if (stream === s) stream = null;
|
||||||
|
if (s && !s.destroyed) {
|
||||||
|
try { s.destroy(); } catch {}
|
||||||
|
}
|
||||||
|
reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
ensure()
|
||||||
|
.then((attachedStream) => {
|
||||||
|
if (isDone) return;
|
||||||
|
s = attachedStream;
|
||||||
|
logger?.debug('stdin >', { cmd });
|
||||||
|
let buf = '';
|
||||||
|
onData = (chunk) => { buf += chunk.toString(); };
|
||||||
|
s.on('data', onData);
|
||||||
|
s.write(cmd + '\n', (err) => {
|
||||||
|
if (err) {
|
||||||
|
if (isDone) return;
|
||||||
|
isDone = true;
|
||||||
|
logger?.warn('stdin write error', { cmd, error: err.message });
|
||||||
|
cleanup();
|
||||||
|
if (stream === s) stream = null;
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
windowTimer = setTimeout(() => {
|
||||||
|
if (isDone) return;
|
||||||
|
isDone = true;
|
||||||
|
cleanup();
|
||||||
|
const out = buf.trim();
|
||||||
|
logger?.debug('stdin <', { cmd, out });
|
||||||
|
resolve(out);
|
||||||
|
}, windowMs);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (isDone) return;
|
||||||
|
isDone = true;
|
||||||
|
logger?.warn('stdin ensure failed', { cmd, error: err.message });
|
||||||
|
cleanup();
|
||||||
|
if (stream === s) stream = null;
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
send(cmd) {
|
||||||
|
const p = tail.then(() => exec(cmd));
|
||||||
|
// Always advance the tail, even on failure, so the queue never stalls
|
||||||
|
tail = p.catch(() => {});
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Player-data files kept at the data root (outside the world folder). Bundling
|
||||||
|
// them into the export makes a world backup portable between instances — Bedrock
|
||||||
|
// stores no readable player-name list in the world itself, so allowlist/history
|
||||||
|
// are the only way authorized/known players survive a migration.
|
||||||
|
const PLAYER_DATA_FILES = [
|
||||||
|
'allowlist.json', 'permissions.json', // Bedrock
|
||||||
|
'whitelist.json', 'ops.json', 'usercache.json', // Java
|
||||||
|
'banned-players.json', 'banned-ips.json', // Java bans
|
||||||
|
'craftdock_players_history.json', // CraftDock join history
|
||||||
|
];
|
||||||
|
const AUX_DIR = 'craftdock';
|
||||||
|
|
||||||
|
export function createWorldService({ config, dockerService, propertiesService, fs = fsp, archiver, extractZip }) {
|
||||||
|
const worldPath = path.join(config.mcDataPath, config.mcWorldName);
|
||||||
|
|
||||||
|
async function stopIfRunning() {
|
||||||
|
if ((await dockerService.getState()) === 'running') {
|
||||||
|
await dockerService.stop();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regen(seed) {
|
||||||
|
await stopIfRunning();
|
||||||
|
if (seed !== undefined && propertiesService) {
|
||||||
|
await propertiesService.update({ 'level-seed': seed });
|
||||||
|
}
|
||||||
|
await fs.rm(worldPath, { recursive: true, force: true });
|
||||||
|
await dockerService.start();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDownloadStream() {
|
||||||
|
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||||
|
archive.directory(worldPath, config.mcWorldName);
|
||||||
|
// Bundle player-data files (allowlist/permissions/history/...) under craftdock/
|
||||||
|
// so a downloaded world is a portable full backup.
|
||||||
|
for (const file of PLAYER_DATA_FILES) {
|
||||||
|
const full = path.join(config.mcDataPath, file);
|
||||||
|
try {
|
||||||
|
await fs.access(full);
|
||||||
|
archive.file(full, { name: `${AUX_DIR}/${file}` });
|
||||||
|
} catch { /* file absent — skip */ }
|
||||||
|
}
|
||||||
|
archive.finalize();
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importPath = worldPath + '.import';
|
||||||
|
const auxTemp = importPath + '.aux';
|
||||||
|
|
||||||
|
async function findLevelDatDir(dir) {
|
||||||
|
const entries = await fs.readdir(dir);
|
||||||
|
if (entries.includes('level.dat')) {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(dir, entry);
|
||||||
|
const stat = await fs.stat(entryPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const found = await findLevelDatDir(entryPath);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importWorld(zipPath) {
|
||||||
|
const wasRunning = await stopIfRunning();
|
||||||
|
let hasAux = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.rm(importPath, { recursive: true, force: true });
|
||||||
|
await fs.rm(auxTemp, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(importPath, { recursive: true });
|
||||||
|
await extractZip(zipPath, importPath);
|
||||||
|
|
||||||
|
// Rescue bundled player-data files (craftdock/) BEFORE world alignment,
|
||||||
|
// which deletes everything in importPath that is not the world.
|
||||||
|
const craftdockDir = path.join(importPath, AUX_DIR);
|
||||||
|
try {
|
||||||
|
await fs.access(craftdockDir);
|
||||||
|
await fs.rename(craftdockDir, auxTemp);
|
||||||
|
hasAux = true;
|
||||||
|
} catch { /* no bundled aux files (older export) */ }
|
||||||
|
|
||||||
|
const levelDatDir = await findLevelDatDir(importPath);
|
||||||
|
if (!levelDatDir) {
|
||||||
|
throw new Error('invalid_world: level.dat not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Adjust paths based on Edition / World Name configuration
|
||||||
|
if (config.mcWorldName === 'worlds') {
|
||||||
|
// Bedrock: We need the structure to be importPath/<level-name>/level.dat
|
||||||
|
let levelName = 'Bedrock level';
|
||||||
|
if (propertiesService) {
|
||||||
|
try {
|
||||||
|
const props = await propertiesService.read();
|
||||||
|
levelName = props['level-name'] || 'Bedrock level';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetWorldDir = path.join(importPath, levelName);
|
||||||
|
|
||||||
|
if (levelDatDir === importPath) {
|
||||||
|
// level.dat is in root. Move everything to a temp folder, then create <level-name> and move there.
|
||||||
|
const tempDir = importPath + '.temp';
|
||||||
|
await fs.mkdir(tempDir, { recursive: true });
|
||||||
|
const files = await fs.readdir(importPath);
|
||||||
|
for (const file of files) {
|
||||||
|
await fs.rename(path.join(importPath, file), path.join(tempDir, file));
|
||||||
|
}
|
||||||
|
await fs.mkdir(targetWorldDir, { recursive: true });
|
||||||
|
const tempFiles = await fs.readdir(tempDir);
|
||||||
|
for (const file of tempFiles) {
|
||||||
|
await fs.rename(path.join(tempDir, file), path.join(targetWorldDir, file));
|
||||||
|
}
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
// level.dat is in a subdirectory (levelDatDir). Rename that subdirectory to <level-name>.
|
||||||
|
const tempAligned = path.join(importPath, 'aligned_world');
|
||||||
|
await fs.rename(levelDatDir, tempAligned);
|
||||||
|
|
||||||
|
// Clean up any other files in importPath that are not tempAligned
|
||||||
|
const remaining = await fs.readdir(importPath);
|
||||||
|
for (const item of remaining) {
|
||||||
|
const itemPath = path.join(importPath, item);
|
||||||
|
if (itemPath !== tempAligned) {
|
||||||
|
await fs.rm(itemPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rename(tempAligned, targetWorldDir);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Java: We need the structure to be importPath/level.dat
|
||||||
|
if (levelDatDir !== importPath) {
|
||||||
|
const tempAligned = path.join(importPath, 'aligned_world');
|
||||||
|
await fs.rename(levelDatDir, tempAligned);
|
||||||
|
|
||||||
|
// Clean up any other files in importPath that are not tempAligned
|
||||||
|
const remaining = await fs.readdir(importPath);
|
||||||
|
for (const item of remaining) {
|
||||||
|
const itemPath = path.join(importPath, item);
|
||||||
|
if (itemPath !== tempAligned) {
|
||||||
|
await fs.rm(itemPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hoist all files from aligned_world to importPath
|
||||||
|
const files = await fs.readdir(tempAligned);
|
||||||
|
for (const file of files) {
|
||||||
|
await fs.rename(path.join(tempAligned, file), path.join(importPath, file));
|
||||||
|
}
|
||||||
|
await fs.rm(tempAligned, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
await fs.rm(importPath, { recursive: true, force: true });
|
||||||
|
await fs.rm(auxTemp, { recursive: true, force: true }).catch(() => {});
|
||||||
|
if (wasRunning) await dockerService.start();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await fs.rm(worldPath, { recursive: true, force: true });
|
||||||
|
await fs.rename(importPath, worldPath);
|
||||||
|
|
||||||
|
// Restore bundled player-data files to the data root (overwriting).
|
||||||
|
if (hasAux) {
|
||||||
|
const files = await fs.readdir(auxTemp);
|
||||||
|
for (const file of files) {
|
||||||
|
const dest = path.join(config.mcDataPath, file);
|
||||||
|
await fs.rename(path.join(auxTemp, file), dest);
|
||||||
|
if (typeof fs.chmod === 'function') {
|
||||||
|
await fs.chmod(dest, 0o666).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fs.rm(auxTemp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
await dockerService.start();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
regen,
|
||||||
|
createDownloadStream,
|
||||||
|
importWorld,
|
||||||
|
_worldPath: worldPath,
|
||||||
|
_stopIfRunning: stopIfRunning,
|
||||||
|
_archiver: archiver,
|
||||||
|
_extractZip: extractZip,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { registerLogsSocket } from './logsSocket.js';
|
||||||
|
import { registerStatusSocket } from './statusSocket.js';
|
||||||
|
import { socketAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
export function registerSockets(io, { dockerService, appState, seedService, sessionMiddleware, logger, config }) {
|
||||||
|
const auth = socketAuth(sessionMiddleware);
|
||||||
|
|
||||||
|
const logs = io.of('/logs');
|
||||||
|
logs.use(auth);
|
||||||
|
registerLogsSocket(logs, { dockerService, appState, logger: logger?.child('logs-socket') });
|
||||||
|
|
||||||
|
const status = io.of('/status');
|
||||||
|
status.use(auth);
|
||||||
|
registerStatusSocket(status, { dockerService, appState, seedService, logger: logger?.child('status-socket'), config });
|
||||||
|
|
||||||
|
return { logs, status };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export function registerLogsSocket(namespace, { dockerService, appState }) {
|
||||||
|
namespace.on('connection', async (socket) => {
|
||||||
|
let stream = null;
|
||||||
|
try {
|
||||||
|
stream = await dockerService.logStream();
|
||||||
|
const onData = (chunk) => socket.emit('log', chunk.toString());
|
||||||
|
stream.on('data', onData);
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
stream?.off?.('data', onData);
|
||||||
|
stream?.destroy?.();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
socket.emit('log', `[craftdock] log stream unavailable: ${err.message}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('command', async (cmd) => {
|
||||||
|
try {
|
||||||
|
const adapter = await appState.getAdapter();
|
||||||
|
const output = await adapter.sendCommand(cmd);
|
||||||
|
if (output) socket.emit('log', `> ${cmd}\n${output}\n`);
|
||||||
|
} catch (err) {
|
||||||
|
socket.emit('log', `[craftdock] command failed: ${err.message}\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { updatePlayerDirectory } from '../services/playerDirectory.js';
|
||||||
|
|
||||||
|
|
||||||
|
export async function buildStatusPayload({ dockerService, appState, seedService, logger, config }) {
|
||||||
|
const info = await dockerService.inspect();
|
||||||
|
if (info.state !== 'running') {
|
||||||
|
if (seedService?.clearCache) {
|
||||||
|
seedService.clearCache();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: info.state, type: info.type,
|
||||||
|
cpuPct: 0, memUsedMb: 0, memPct: 0,
|
||||||
|
players: { online: 0, max: 0, players: [] },
|
||||||
|
seed: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const stats = await dockerService.stats();
|
||||||
|
let players = { online: 0, max: 0, players: [] };
|
||||||
|
let seed = null;
|
||||||
|
let type = info.type;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adapter = await appState.getAdapter();
|
||||||
|
const edition = adapter._edition === 'bedrock' ? 'bedrock' : 'java';
|
||||||
|
type = edition === 'bedrock' ? 'BEDROCK' : 'JAVA';
|
||||||
|
|
||||||
|
players = await adapter.listPlayers();
|
||||||
|
if (config) {
|
||||||
|
// Keep the XUID directory fresh (captures Player Spawned lines) but do NOT
|
||||||
|
// ship the whole list on the 2s status feed — it can hold thousands of
|
||||||
|
// entries. The Players tab fetches it page-by-page from GET /players/directory.
|
||||||
|
await updatePlayerDirectory({
|
||||||
|
dataRoot: config.mcDataPath,
|
||||||
|
dockerService,
|
||||||
|
edition,
|
||||||
|
logger,
|
||||||
|
minIntervalMs: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seed = await seedService.resolve(adapter, edition);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn('status payload: command channel or step failed', { error: err.message, stack: err.stack });
|
||||||
|
}
|
||||||
|
return { state: info.state, type, ...stats, players, seed };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function registerStatusSocket(namespace, { dockerService, appState, seedService, logger, config, intervalMs = 2000 }) {
|
||||||
|
namespace.on('connection', (socket) => {
|
||||||
|
logger?.info('status socket connected', { id: socket.id });
|
||||||
|
const push = async () => {
|
||||||
|
try {
|
||||||
|
socket.emit('status', await buildStatusPayload({ dockerService, appState, seedService, logger, config }));
|
||||||
|
} catch (err) {
|
||||||
|
logger?.error('status push failed', { error: err.message, stack: err.stack });
|
||||||
|
socket.emit('status', { state: 'error', error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
push();
|
||||||
|
const timer = setInterval(push, intervalMs);
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
logger?.info('status socket disconnected', { id: socket.id });
|
||||||
|
clearInterval(timer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createBedrockAdapter } from '../../src/adapters/bedrockAdapter.js';
|
||||||
|
import { NotSupportedError } from '../../src/adapters/serverAdapter.js';
|
||||||
|
|
||||||
|
function make(sendImpl) {
|
||||||
|
const stdin = { send: vi.fn(sendImpl || (async () => 'ok')) };
|
||||||
|
return { stdin, adapter: createBedrockAdapter(stdin) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BedrockAdapter', () => {
|
||||||
|
it('whitelistAdd maps to "allowlist add"', async () => {
|
||||||
|
const { stdin, adapter } = make();
|
||||||
|
await adapter.whitelistAdd('steve');
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('allowlist add "steve"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistRemove maps to "allowlist remove"', async () => {
|
||||||
|
const { stdin, adapter } = make();
|
||||||
|
await adapter.whitelistRemove('steve');
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('allowlist remove "steve"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ban throws NotSupportedError', async () => {
|
||||||
|
const { adapter } = make();
|
||||||
|
await expect(adapter.ban('x')).rejects.toBeInstanceOf(NotSupportedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not advertise ban capability', () => {
|
||||||
|
const { adapter } = make();
|
||||||
|
expect(adapter.capabilities.has('ban')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose getSeed (Bedrock has no seed command; read from level.dat)', () => {
|
||||||
|
const { adapter } = make();
|
||||||
|
expect(adapter.getSeed).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPlayerPosition parses querytarget response when it is clean JSON', async () => {
|
||||||
|
const { adapter } = make(async (cmd) => {
|
||||||
|
if (cmd.startsWith('querytarget')) {
|
||||||
|
return '[{"position":{"x":15.0,"y":70.0,"z":-30.0},"dimension":1}]';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const pos = await adapter.getPlayerPosition('alex');
|
||||||
|
expect(pos).toEqual({ x: 15, y: 70, z: -30, dimension: 'nether' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPlayerPosition parses querytarget response when it has a log/text prefix', async () => {
|
||||||
|
const { adapter } = make(async (cmd) => {
|
||||||
|
if (cmd.startsWith('querytarget')) {
|
||||||
|
return '[2026-07-18 23:08:38:396 INFO] Target data: [\n' +
|
||||||
|
' {\n' +
|
||||||
|
' "dimension" : 0,\n' +
|
||||||
|
' "position" : {\n' +
|
||||||
|
' "x" : 12.5,\n' +
|
||||||
|
' "y" : 64.0,\n' +
|
||||||
|
' "z" : -25.5\n' +
|
||||||
|
' }\n' +
|
||||||
|
' }\n' +
|
||||||
|
']';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const pos = await adapter.getPlayerPosition('alex');
|
||||||
|
expect(pos).toEqual({ x: 12.5, y: 64, z: -25.5, dimension: 'overworld' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPlayerPosition returns null when querytarget fails, and does not fall back to tp', async () => {
|
||||||
|
const { stdin, adapter } = make(async (cmd) => {
|
||||||
|
if (cmd.startsWith('querytarget')) {
|
||||||
|
return 'Unknown command or failure';
|
||||||
|
}
|
||||||
|
return 'Teleported alex to 12.5, 64, -25.5';
|
||||||
|
});
|
||||||
|
const pos = await adapter.getPlayerPosition('alex');
|
||||||
|
expect(pos).toBeNull();
|
||||||
|
expect(stdin.send).not.toHaveBeenCalledWith(expect.stringContaining('tp '));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistOn maps to "allowlist on"', async () => {
|
||||||
|
const { stdin, adapter } = make();
|
||||||
|
await adapter.whitelistOn();
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('allowlist on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistOff maps to "allowlist off"', async () => {
|
||||||
|
const { stdin, adapter } = make();
|
||||||
|
await adapter.whitelistOff();
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('allowlist off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistList parses "allowlist list" output into array', async () => {
|
||||||
|
const { adapter } = make(async () => 'There are 2 allowlisted players: steve, alex');
|
||||||
|
const list = await adapter.whitelistList();
|
||||||
|
expect(list).toEqual(['steve', 'alex']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistList returns empty array when no players', async () => {
|
||||||
|
const { adapter } = make(async () => 'There are 0 allowlisted players:');
|
||||||
|
const list = await adapter.whitelistList();
|
||||||
|
expect(list).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendCommand sends command unmodified if no slash', async () => {
|
||||||
|
const { stdin, adapter } = make();
|
||||||
|
await adapter.sendCommand('gamerule showcoordinates true');
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendCommand strips leading slash from command', async () => {
|
||||||
|
const { stdin, adapter } = make();
|
||||||
|
await adapter.sendCommand('/gamerule showcoordinates true');
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes whitelistOn/Off/List capabilities', () => {
|
||||||
|
const { adapter } = make();
|
||||||
|
expect(adapter.capabilities.has('whitelistOn')).toBe(true);
|
||||||
|
expect(adapter.capabilities.has('whitelistOff')).toBe(true);
|
||||||
|
expect(adapter.capabilities.has('whitelistList')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveHold sends "save hold" then polls "save query" until ready', async () => {
|
||||||
|
const { stdin, adapter } = make(async (cmd) =>
|
||||||
|
cmd === 'save query' ? 'Data saved. Files are now ready to be copied.' : 'ok');
|
||||||
|
const ok = await adapter.saveHold();
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('save hold');
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('save query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveResume sends "save resume"', async () => {
|
||||||
|
const { stdin, adapter } = make();
|
||||||
|
await adapter.saveResume();
|
||||||
|
expect(stdin.send).toHaveBeenCalledWith('save resume');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queryUniqueId extracts the uniqueId from querytarget output', async () => {
|
||||||
|
const { adapter } = make(async (cmd) =>
|
||||||
|
cmd.startsWith('querytarget')
|
||||||
|
? '[{"dimension":0,"position":{"x":1,"y":2,"z":3},"uniqueId":"-8589934591"}]'
|
||||||
|
: 'ok');
|
||||||
|
expect(await adapter.queryUniqueId('alex')).toBe('-8589934591');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queryUniqueId extracts decimal uniqueId from noisy log output', async () => {
|
||||||
|
const { adapter } = make(async (cmd) =>
|
||||||
|
cmd.startsWith('querytarget')
|
||||||
|
? '[2026-07-22 22:43:05:123 INFO] Target data: [{"dimension":0,"position":{"x":100,"y":64,"z":-200},"uniqueId":2535407895138987}]'
|
||||||
|
: 'ok');
|
||||||
|
expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('2535407895138987');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queryUniqueId parses the real multi-line 1.26 output (UUID uniqueId, id present)', async () => {
|
||||||
|
// Captured verbatim from a live bedrock 1.26.33.2 server
|
||||||
|
const real = [
|
||||||
|
'[2026-07-23 02:40:43:441 INFO] Target data: [',
|
||||||
|
' {',
|
||||||
|
' "dimension" : 0,',
|
||||||
|
' "id" : -64424509439,',
|
||||||
|
' "position" : {',
|
||||||
|
' "x" : 79.52253723144531,',
|
||||||
|
' "y" : 98.62001037597656,',
|
||||||
|
' "z" : 14.37582015991211',
|
||||||
|
' },',
|
||||||
|
' "uniqueId" : "657cb1d4-99dd-3123-b27f-d0c27df79710",',
|
||||||
|
' "yRot" : -82.85218048095703',
|
||||||
|
' }',
|
||||||
|
']',
|
||||||
|
].join('\n');
|
||||||
|
const { adapter } = make(async (cmd) => (cmd.startsWith('querytarget') ? real : 'ok'));
|
||||||
|
expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('657cb1d4-99dd-3123-b27f-d0c27df79710');
|
||||||
|
expect(await adapter.getPlayerPosition('Lucasrguerra')).toEqual({
|
||||||
|
x: 79.52253723144531, y: 98.62001037597656, z: 14.37582015991211, dimension: 'overworld',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queryUniqueId returns null when querytarget fails', async () => {
|
||||||
|
const { adapter } = make(async () => 'Unknown command');
|
||||||
|
expect(await adapter.queryUniqueId('alex')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import pkg from 'leveldb-zlib';
|
||||||
|
import nbt from 'prismarine-nbt';
|
||||||
|
import { createBedrockPlayerFile, parsePlayerBuffer } from '../../src/adapters/playerFile/bedrockPlayerFile.js';
|
||||||
|
|
||||||
|
const { LevelDB } = pkg;
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FIXTURE = path.join(here, '../fixtures/bedrock-player.nbt');
|
||||||
|
const UUID = '1f633c09-f1f1-481c-b811-1cfa15b21973';
|
||||||
|
// Real-world shape: querytarget's uniqueId is the player's MsaId, and the DB
|
||||||
|
// holds a mapping record player_<MsaId> → { MsaId, SelfSignedId, ServerId }.
|
||||||
|
const MSA_ID = '657cb1d4-99dd-3123-b27f-d0c27df79710';
|
||||||
|
const SELF_SIGNED_ID = 'a257319c-50e9-3aad-aef3-8fc3e0d4e4c2';
|
||||||
|
|
||||||
|
function mappingRecord() {
|
||||||
|
const str = (v) => ({ type: 'string', value: v });
|
||||||
|
return nbt.writeUncompressed({
|
||||||
|
type: 'compound',
|
||||||
|
name: '',
|
||||||
|
value: {
|
||||||
|
MsaId: str(MSA_ID),
|
||||||
|
SelfSignedId: str(SELF_SIGNED_ID),
|
||||||
|
ServerId: str('player_server_' + UUID),
|
||||||
|
},
|
||||||
|
}, 'little');
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp, config;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const buf = fs.readFileSync(FIXTURE);
|
||||||
|
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-bpf-'));
|
||||||
|
const dbPath = path.join(tmp, 'worlds', 'Bedrock level', 'db');
|
||||||
|
await fsp.mkdir(dbPath, { recursive: true });
|
||||||
|
// Build a real (Mojang-format) LevelDB holding the captured player NBT.
|
||||||
|
const db = new LevelDB(dbPath, { createIfMissing: true });
|
||||||
|
await db.open();
|
||||||
|
await db.put(Buffer.from('player_server_' + UUID, 'latin1'), buf);
|
||||||
|
await db.put(Buffer.from('player_' + MSA_ID, 'latin1'), mappingRecord());
|
||||||
|
await db.put(Buffer.from('player_' + SELF_SIGNED_ID, 'latin1'), mappingRecord());
|
||||||
|
await db.close();
|
||||||
|
config = { mcDataPath: tmp, mcWorldName: 'worlds' };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => { await fsp.rm(tmp, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
describe('bedrockPlayerFile', () => {
|
||||||
|
it('parsePlayerBuffer decodes the captured real-world fixture', async () => {
|
||||||
|
const d = await parsePlayerBuffer(fs.readFileSync(FIXTURE));
|
||||||
|
expect(d.position).not.toBeNull();
|
||||||
|
expect(d.health.max).toBe(20);
|
||||||
|
expect(typeof d.food).toBe('number');
|
||||||
|
expect(Array.isArray(d.inventory)).toBe(true);
|
||||||
|
expect(d.uniqueId).toBe('-8589934591');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readPlayer returns normalized data for an existing uuid', async () => {
|
||||||
|
const adapter = createBedrockPlayerFile(config);
|
||||||
|
const d = await adapter.readPlayer(UUID);
|
||||||
|
expect(d.uniqueId).toBe('-8589934591');
|
||||||
|
expect(d.position).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readPlayer returns null for a missing uuid', async () => {
|
||||||
|
const adapter = createBedrockPlayerFile(config);
|
||||||
|
expect(await adapter.readPlayer('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findByUniqueId locates the player by UniqueID (int64 or UUID string format)', async () => {
|
||||||
|
const adapter = createBedrockPlayerFile(config);
|
||||||
|
const hit = await adapter.findByUniqueId('-8589934591');
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit.uuid).toBe(UUID);
|
||||||
|
|
||||||
|
// Modern Bedrock servers output uniqueId in UUID hex string format via querytarget:
|
||||||
|
// e.g. "657cb1d4-99dd-3123-b27f-d0c27df79710" or corresponding int64 (-8589934591)
|
||||||
|
const hit2 = await adapter.findByUniqueId('00000000-0000-0000-ffff-fffe00000001');
|
||||||
|
expect(hit2).not.toBeNull();
|
||||||
|
expect(hit2.uuid).toBe(UUID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findByUniqueId resolves via the player_<uuid> ServerId mapping record', async () => {
|
||||||
|
const adapter = createBedrockPlayerFile(config);
|
||||||
|
// querytarget uniqueId (= MsaId) — the case seen against a live server
|
||||||
|
const hit = await adapter.findByUniqueId(MSA_ID);
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit.uuid).toBe(UUID);
|
||||||
|
// SelfSignedId mapping record also resolves
|
||||||
|
const hit2 = await adapter.findByUniqueId(SELF_SIGNED_ID);
|
||||||
|
expect(hit2.uuid).toBe(UUID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listServerUuids enumerates player_server_ keys', async () => {
|
||||||
|
const adapter = createBedrockPlayerFile(config);
|
||||||
|
expect(await adapter.listServerUuids()).toContain(UUID);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createAdapter, isBedrock } from '../../src/adapters/index.js';
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
rconService: { send: async () => 'r' },
|
||||||
|
stdinService: { send: async () => 's' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('adapter factory', () => {
|
||||||
|
it('isBedrock detects BEDROCK type case-insensitively', () => {
|
||||||
|
expect(isBedrock('BEDROCK')).toBe(true);
|
||||||
|
expect(isBedrock('bedrock')).toBe(true);
|
||||||
|
expect(isBedrock('PAPER')).toBe(false);
|
||||||
|
expect(isBedrock(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a java adapter for PAPER', () => {
|
||||||
|
const a = createAdapter('PAPER', deps);
|
||||||
|
expect(a.capabilities.has('ban')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a bedrock adapter for BEDROCK', () => {
|
||||||
|
const a = createAdapter('BEDROCK', deps);
|
||||||
|
expect(a.capabilities.has('ban')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createJavaAdapter } from '../../src/adapters/javaAdapter.js';
|
||||||
|
|
||||||
|
function make(sendImpl) {
|
||||||
|
const rcon = { send: vi.fn(sendImpl || (async () => 'ok')) };
|
||||||
|
return { rcon, adapter: createJavaAdapter(rcon) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JavaAdapter', () => {
|
||||||
|
it('whitelistAdd issues "whitelist add"', async () => {
|
||||||
|
const { rcon, adapter } = make();
|
||||||
|
await adapter.whitelistAdd('steve');
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith('whitelist add steve');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ban issues "ban" with reason', async () => {
|
||||||
|
const { rcon, adapter } = make();
|
||||||
|
await adapter.ban('griefer', 'no griefing');
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith('ban griefer no griefing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('give issues "give" with item and count', async () => {
|
||||||
|
const { rcon, adapter } = make();
|
||||||
|
await adapter.give('steve', 'minecraft:diamond', 5);
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith('give steve minecraft:diamond 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listPlayers parses rcon output', async () => {
|
||||||
|
const { adapter } = make(async () => 'There are 1 of a max of 20 players online: steve');
|
||||||
|
expect(await adapter.listPlayers()).toEqual({ online: 1, max: 20, players: ['steve'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes JAVA capabilities', () => {
|
||||||
|
const { adapter } = make();
|
||||||
|
expect(adapter.capabilities.has('ban')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSeed parses seed correctly', async () => {
|
||||||
|
const { adapter } = make(async () => 'Seed: [123456789]');
|
||||||
|
const seed = await adapter.getSeed();
|
||||||
|
expect(seed).toBe('123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPlayerPosition parses coordinates and dimension correctly', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const { rcon, adapter } = make(async (cmd) => {
|
||||||
|
callCount++;
|
||||||
|
if (cmd.startsWith('tp')) {
|
||||||
|
return 'Teleported steve to 10.5, 64, -20.3';
|
||||||
|
}
|
||||||
|
if (cmd.startsWith('data get')) {
|
||||||
|
return 'steve has the following entity data: "minecraft:overworld"';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const pos = await adapter.getPlayerPosition('steve');
|
||||||
|
expect(pos).toEqual({ x: 10.5, y: 64, z: -20.3, dimension: 'overworld' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistOn maps to "whitelist on"', async () => {
|
||||||
|
const { rcon, adapter } = make();
|
||||||
|
await adapter.whitelistOn();
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith('whitelist on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistOff maps to "whitelist off"', async () => {
|
||||||
|
const { rcon, adapter } = make();
|
||||||
|
await adapter.whitelistOff();
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith('whitelist off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistList parses "whitelist list" output into array', async () => {
|
||||||
|
const { adapter } = make(async () => 'There are 3 whitelisted players: steve, alex, notch');
|
||||||
|
const list = await adapter.whitelistList();
|
||||||
|
expect(list).toEqual(['steve', 'alex', 'notch']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitelistList returns empty array when no players', async () => {
|
||||||
|
const { adapter } = make(async () => 'There are 0 whitelisted players:');
|
||||||
|
const list = await adapter.whitelistList();
|
||||||
|
expect(list).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendCommand sends command unmodified if no slash', async () => {
|
||||||
|
const { rcon, adapter } = make();
|
||||||
|
await adapter.sendCommand('gamerule showcoordinates true');
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendCommand strips leading slash from command', async () => {
|
||||||
|
const { rcon, adapter } = make();
|
||||||
|
await adapter.sendCommand('/gamerule showcoordinates true');
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes whitelistOn/Off/List capabilities', () => {
|
||||||
|
const { adapter } = make();
|
||||||
|
expect(adapter.capabilities.has('whitelistOn')).toBe(true);
|
||||||
|
expect(adapter.capabilities.has('whitelistOff')).toBe(true);
|
||||||
|
expect(adapter.capabilities.has('whitelistList')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forceSave issues "save-all flush"', async () => {
|
||||||
|
const { rcon, adapter } = make();
|
||||||
|
await adapter.forceSave();
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith('save-all flush');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import zlib from 'node:zlib';
|
||||||
|
import nbt from 'prismarine-nbt';
|
||||||
|
import { createJavaPlayerFile } from '../../src/adapters/playerFile/javaPlayerFile.js';
|
||||||
|
|
||||||
|
const UUID = '11111111-2222-3333-4444-555555555555';
|
||||||
|
let tmp, config;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const doc = {
|
||||||
|
type: 'compound', name: '', value: {
|
||||||
|
Pos: { type: 'list', value: { type: 'double', value: [10.5, 64, -20.3] } },
|
||||||
|
Dimension: { type: 'string', value: 'minecraft:the_end' },
|
||||||
|
Health: { type: 'float', value: 18 },
|
||||||
|
foodLevel: { type: 'int', value: 15 },
|
||||||
|
Inventory: {
|
||||||
|
type: 'list', value: {
|
||||||
|
type: 'compound', value: [{
|
||||||
|
Slot: { type: 'byte', value: 0 },
|
||||||
|
id: { type: 'string', value: 'minecraft:dirt' },
|
||||||
|
Count: { type: 'byte', value: 64 },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
playerGameType: { type: 'int', value: 0 },
|
||||||
|
XpLevel: { type: 'int', value: 3 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dat = zlib.gzipSync(nbt.writeUncompressed(doc, 'big'));
|
||||||
|
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-jpf-'));
|
||||||
|
const pdir = path.join(tmp, 'world', 'playerdata');
|
||||||
|
await fsp.mkdir(pdir, { recursive: true });
|
||||||
|
await fsp.writeFile(path.join(pdir, `${UUID}.dat`), dat);
|
||||||
|
config = { mcDataPath: tmp, mcWorldName: 'world' };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => { if (tmp) await fsp.rm(tmp, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
describe('javaPlayerFile', () => {
|
||||||
|
it('reads and normalizes a playerdata .dat', async () => {
|
||||||
|
const d = await createJavaPlayerFile(config).readPlayer(UUID);
|
||||||
|
expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 });
|
||||||
|
expect(d.dimension).toBe('end');
|
||||||
|
expect(d.health).toEqual({ current: 18, max: 20 });
|
||||||
|
expect(d.food).toBe(15);
|
||||||
|
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||||
|
expect(d.gamemode).toBe('survival');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the player has no .dat', async () => {
|
||||||
|
const d = await createJavaPlayerFile(config).readPlayer('00000000-0000-0000-0000-000000000000');
|
||||||
|
expect(d).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { normalizeBedrock, normalizeJava, int64ToString } from '../../src/adapters/playerFile/nbtPlayer.js';
|
||||||
|
|
||||||
|
describe('int64ToString', () => {
|
||||||
|
it('combines [high, low] int32 pair like prismarine-nbt longs', () => {
|
||||||
|
expect(int64ToString([-2, 1])).toBe('-8589934591');
|
||||||
|
expect(int64ToString([0, 5])).toBe('5');
|
||||||
|
});
|
||||||
|
it('passes through number/bigint', () => {
|
||||||
|
expect(int64ToString(42)).toBe('42');
|
||||||
|
expect(int64ToString(7n)).toBe('7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeBedrock', () => {
|
||||||
|
it('extracts position, dimension, health, hunger, inventory, uniqueId', () => {
|
||||||
|
const d = normalizeBedrock({
|
||||||
|
Pos: [1.5, 2, 3], DimensionId: 1,
|
||||||
|
Attributes: [
|
||||||
|
{ Name: 'minecraft:health', Current: 9.5, Max: 20 },
|
||||||
|
{ Name: 'minecraft:player.hunger', Current: 17 },
|
||||||
|
],
|
||||||
|
Inventory: [
|
||||||
|
{ Slot: 0, Name: 'minecraft:iron_pickaxe', Count: 1 },
|
||||||
|
{ Slot: 1, Name: '', Count: 0 },
|
||||||
|
],
|
||||||
|
PlayerGameMode: 1, PlayerLevel: 10, UniqueID: [-2, 1],
|
||||||
|
});
|
||||||
|
expect(d.position).toEqual({ x: 1.5, y: 2, z: 3 });
|
||||||
|
expect(d.dimension).toBe('nether');
|
||||||
|
expect(d.health).toEqual({ current: 9.5, max: 20 });
|
||||||
|
expect(d.food).toBe(17);
|
||||||
|
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:iron_pickaxe', count: 1 }]);
|
||||||
|
expect(d.gamemode).toBe('creative');
|
||||||
|
expect(d.xp).toEqual({ level: 10 });
|
||||||
|
expect(d.uniqueId).toBe('-8589934591');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts armor (head/chest/legs/feet) and offhand from their own NBT lists', () => {
|
||||||
|
const d = normalizeBedrock({
|
||||||
|
Armor: [
|
||||||
|
{ Name: 'minecraft:iron_helmet', Count: 1 },
|
||||||
|
{ Name: 'minecraft:iron_chestplate', Count: 1 },
|
||||||
|
{ Name: '', Count: 0 },
|
||||||
|
{ Name: 'minecraft:leather_boots', Count: 1 },
|
||||||
|
{ Name: '', Count: 0 }, // 5th body slot on some versions — ignored
|
||||||
|
],
|
||||||
|
Offhand: [{ Name: 'minecraft:shield', Count: 1 }],
|
||||||
|
});
|
||||||
|
expect(d.armor).toEqual({
|
||||||
|
head: { name: 'minecraft:iron_helmet', count: 1 },
|
||||||
|
chest: { name: 'minecraft:iron_chestplate', count: 1 },
|
||||||
|
legs: null,
|
||||||
|
feet: { name: 'minecraft:leather_boots', count: 1 },
|
||||||
|
});
|
||||||
|
expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields empty armor/offhand when the lists are absent or empty-named', () => {
|
||||||
|
const d = normalizeBedrock({});
|
||||||
|
expect(d.armor).toEqual({ head: null, chest: null, legs: null, feet: null });
|
||||||
|
expect(d.offhand).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeJava', () => {
|
||||||
|
it('extracts fields from a java playerdata shape', () => {
|
||||||
|
const d = normalizeJava({
|
||||||
|
Pos: [10.5, 64, -20.3], Dimension: 'minecraft:the_end',
|
||||||
|
Health: 18.0, foodLevel: 15,
|
||||||
|
Inventory: [{ Slot: 0, id: 'minecraft:dirt', Count: 64 }],
|
||||||
|
playerGameType: 0, XpLevel: 3,
|
||||||
|
});
|
||||||
|
expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 });
|
||||||
|
expect(d.dimension).toBe('end');
|
||||||
|
expect(d.health).toEqual({ current: 18, max: 20 });
|
||||||
|
expect(d.food).toBe(15);
|
||||||
|
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||||
|
expect(d.gamemode).toBe('survival');
|
||||||
|
expect(d.xp).toEqual({ level: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits armor (slots 100-103) and offhand (-106) out of the java Inventory list', () => {
|
||||||
|
const d = normalizeJava({
|
||||||
|
Inventory: [
|
||||||
|
{ Slot: 0, id: 'minecraft:dirt', Count: 64 },
|
||||||
|
{ Slot: 100, id: 'minecraft:iron_boots', Count: 1 },
|
||||||
|
{ Slot: 101, id: 'minecraft:iron_leggings', Count: 1 },
|
||||||
|
{ Slot: 102, id: 'minecraft:iron_chestplate', Count: 1 },
|
||||||
|
{ Slot: 103, id: 'minecraft:iron_helmet', Count: 1 },
|
||||||
|
{ Slot: -106, id: 'minecraft:shield', Count: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// armor/offhand are NOT part of the regular inventory grid
|
||||||
|
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||||
|
expect(d.armor).toEqual({
|
||||||
|
head: { name: 'minecraft:iron_helmet', count: 1 },
|
||||||
|
chest: { name: 'minecraft:iron_chestplate', count: 1 },
|
||||||
|
legs: { name: 'minecraft:iron_leggings', count: 1 },
|
||||||
|
feet: { name: 'minecraft:iron_boots', count: 1 },
|
||||||
|
});
|
||||||
|
expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createPlayerFileAdapter } from '../../src/adapters/playerFile/index.js';
|
||||||
|
|
||||||
|
const config = { mcDataPath: '/data', mcWorldName: 'worlds' };
|
||||||
|
|
||||||
|
describe('createPlayerFileAdapter', () => {
|
||||||
|
it('returns the bedrock adapter (with findByUniqueId) for bedrock', () => {
|
||||||
|
const a = createPlayerFileAdapter('bedrock', config);
|
||||||
|
expect(typeof a.readPlayer).toBe('function');
|
||||||
|
expect(typeof a.findByUniqueId).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the java adapter for java', () => {
|
||||||
|
const a = createPlayerFileAdapter('java', config);
|
||||||
|
expect(typeof a.readPlayer).toBe('function');
|
||||||
|
expect(a.findByUniqueId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parsePlayerList, parseWhitelistList, CAPABILITIES } from '../../src/adapters/serverAdapter.js';
|
||||||
|
|
||||||
|
describe('parsePlayerList', () => {
|
||||||
|
it('parses Java list output', () => {
|
||||||
|
const r = parsePlayerList(
|
||||||
|
'There are 2 of a max of 20 players online: steve, alex'
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ online: 2, max: 20, players: ['steve', 'alex'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses Bedrock list output', () => {
|
||||||
|
const r = parsePlayerList('There are 1/10 players online:\nnotch');
|
||||||
|
expect(r).toEqual({ online: 1, max: 10, players: ['notch'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty server', () => {
|
||||||
|
const r = parsePlayerList('There are 0 of a max of 20 players online:');
|
||||||
|
expect(r).toEqual({ online: 0, max: 20, players: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CAPABILITIES', () => {
|
||||||
|
it('java supports ban, bedrock does not', () => {
|
||||||
|
expect(CAPABILITIES.JAVA.has('ban')).toBe(true);
|
||||||
|
expect(CAPABILITIES.BEDROCK.has('ban')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseWhitelistList', () => {
|
||||||
|
it('parses Java style text whitelist list', () => {
|
||||||
|
const res = parseWhitelistList('There are 2 whitelisted players: steve, alex');
|
||||||
|
expect(res).toEqual(['steve', 'alex']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses Bedrock style JSON allowlist list', () => {
|
||||||
|
const res = parseWhitelistList(
|
||||||
|
'41:55:938 INFO] ###* {"command":"allowlist", "result":[{"ignoresPlayerLimit":false,"name":"Lucasrguerra","xuid":"2535407895138987"}]} *###'
|
||||||
|
);
|
||||||
|
expect(res).toEqual(['Lucasrguerra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for an empty Bedrock allowlist (result: null), not garbage', () => {
|
||||||
|
// This is the exact line that produced ghost "names" from log fragments.
|
||||||
|
const res = parseWhitelistList(
|
||||||
|
'[2026-07-18 22:53:21:185 INFO] ###* {"command":"allowlist","result":null} *###'
|
||||||
|
);
|
||||||
|
expect(res).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for a Bedrock allowlist with an empty array', () => {
|
||||||
|
expect(parseWhitelistList('###* {"command":"allowlist","result":[]} *###')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never scrapes names out of an unrelated log line with a colon', () => {
|
||||||
|
expect(parseWhitelistList('[2026-07-18 22:53:23:695 ERROR] Syntax error: Unexpected token')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty (not [""]) for the Java empty-whitelist message', () => {
|
||||||
|
expect(parseWhitelistList('There are no whitelisted players')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createAppState } from '../src/appState.js';
|
||||||
|
|
||||||
|
const deps = () => ({
|
||||||
|
config: { mcEdition: 'auto' },
|
||||||
|
dockerService: { inspect: vi.fn().mockResolvedValue({ type: 'PAPER' }) },
|
||||||
|
rconService: { send: async () => 'r' },
|
||||||
|
stdinService: { send: async () => 's' },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appState', () => {
|
||||||
|
it('resolves a java adapter when TYPE is PAPER', async () => {
|
||||||
|
const state = createAppState(deps());
|
||||||
|
const a = await state.getAdapter();
|
||||||
|
expect(a.capabilities.has('ban')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors forced bedrock edition without inspecting', async () => {
|
||||||
|
const d = deps();
|
||||||
|
d.config.mcEdition = 'bedrock';
|
||||||
|
const state = createAppState(d);
|
||||||
|
const a = await state.getAdapter();
|
||||||
|
expect(a.capabilities.has('ban')).toBe(false);
|
||||||
|
expect(d.dockerService.inspect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache a fallback when TYPE is unknown, then re-resolves once known', async () => {
|
||||||
|
const d = deps();
|
||||||
|
// First inspect (container stopped) yields no type; second yields BEDROCK.
|
||||||
|
d.dockerService.inspect = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ type: null })
|
||||||
|
.mockResolvedValueOnce({ type: 'BEDROCK' });
|
||||||
|
const state = createAppState(d);
|
||||||
|
|
||||||
|
const first = await state.getAdapter();
|
||||||
|
expect(first.capabilities.has('ban')).toBe(true); // java fallback, not cached
|
||||||
|
|
||||||
|
const second = await state.getAdapter();
|
||||||
|
expect(second.capabilities.has('ban')).toBe(false); // now correctly bedrock
|
||||||
|
expect(d.dockerService.inspect).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches once a definitive TYPE is resolved', async () => {
|
||||||
|
const d = deps(); // inspect always returns PAPER
|
||||||
|
const state = createAppState(d);
|
||||||
|
await state.getAdapter();
|
||||||
|
await state.getAdapter();
|
||||||
|
expect(d.dockerService.inspect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
ADMIN_PASSWORD_HASH: 'hash',
|
||||||
|
SESSION_SECRET: 'secret',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('applies defaults for optional vars', () => {
|
||||||
|
const c = loadConfig(base);
|
||||||
|
expect(c.mcContainerName).toBe('craftdock-mc-server');
|
||||||
|
expect(c.rconPort).toBe(25575);
|
||||||
|
expect(c.port).toBe(3000);
|
||||||
|
expect(c.mcEdition).toBe('auto');
|
||||||
|
expect(c.maxUploadBytes).toBe(1024 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when required vars are missing', () => {
|
||||||
|
expect(() => loadConfig({})).toThrow(/ADMIN_PASSWORD_HASH/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require RCON_PASSWORD (Java-only), defaulting it to empty', () => {
|
||||||
|
const c = loadConfig(base);
|
||||||
|
expect(c.rconPassword).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a base64-encoded hash when the raw hash is absent', () => {
|
||||||
|
const realHash = '$2a$12$YjrtHh2BNcUT0P76L2ZUF.mCtxzUwN.gZ4siUqji2oxrDbIXRdvfe';
|
||||||
|
const b64 = Buffer.from(realHash, 'utf8').toString('base64');
|
||||||
|
const c = loadConfig({ SESSION_SECRET: 's', ADMIN_PASSWORD_HASH_B64: b64 });
|
||||||
|
expect(c.adminPasswordHash).toBe(realHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the raw hash over the base64 one when both are set', () => {
|
||||||
|
const b64 = Buffer.from('other', 'utf8').toString('base64');
|
||||||
|
const c = loadConfig({ SESSION_SECRET: 's', ADMIN_PASSWORD_HASH: 'raw', ADMIN_PASSWORD_HASH_B64: b64 });
|
||||||
|
expect(c.adminPasswordHash).toBe('raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when neither raw nor base64 hash is provided', () => {
|
||||||
|
expect(() => loadConfig({ SESSION_SECRET: 's' })).toThrow(/ADMIN_PASSWORD_HASH/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces numeric env vars', () => {
|
||||||
|
const c = loadConfig({ ...base, RCON_PORT: '25580', PORT: '8080' });
|
||||||
|
expect(c.rconPort).toBe(25580);
|
||||||
|
expect(c.port).toBe(8080);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to defaults for empty string or non-numeric numeric env vars', () => {
|
||||||
|
const c = loadConfig({ ...base, RCON_PORT: '', PORT: 'abc' });
|
||||||
|
expect(c.rconPort).toBe(25575);
|
||||||
|
expect(c.port).toBe(3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createLogger } from '../src/logger.js';
|
||||||
|
|
||||||
|
describe('logger', () => {
|
||||||
|
it('formats text logs with timestamp, level, component and message', () => {
|
||||||
|
let output = '';
|
||||||
|
const stream = {
|
||||||
|
write: (data) => { output += data; },
|
||||||
|
};
|
||||||
|
const log = createLogger('test-comp', { level: 'info', format: 'text', stream });
|
||||||
|
log.info('hello world', { foo: 'bar' });
|
||||||
|
|
||||||
|
expect(output).toContain('[INFO ] [test-comp] hello world {"foo":"bar"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats JSON logs when format is json', () => {
|
||||||
|
let output = '';
|
||||||
|
const stream = {
|
||||||
|
write: (data) => { output += data; },
|
||||||
|
};
|
||||||
|
const log = createLogger('json-comp', { level: 'info', format: 'json', stream });
|
||||||
|
log.info('json message', { count: 42 });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.component).toBe('json-comp');
|
||||||
|
expect(parsed.level).toBe('info');
|
||||||
|
expect(parsed.msg).toBe('json message');
|
||||||
|
expect(parsed.meta).toEqual({ count: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out logs above threshold level', () => {
|
||||||
|
let output = '';
|
||||||
|
const stream = {
|
||||||
|
write: (data) => { output += data; },
|
||||||
|
};
|
||||||
|
const log = createLogger('threshold-comp', { level: 'warn', format: 'text', stream });
|
||||||
|
log.info('should be ignored');
|
||||||
|
log.warn('should be logged');
|
||||||
|
|
||||||
|
expect(output).not.toContain('should be ignored');
|
||||||
|
expect(output).toContain('should be logged');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes Error objects correctly', () => {
|
||||||
|
let output = '';
|
||||||
|
const stream = {
|
||||||
|
write: (data) => { output += data; },
|
||||||
|
};
|
||||||
|
const log = createLogger('error-comp', { level: 'error', format: 'json', stream });
|
||||||
|
const err = new Error('something went wrong');
|
||||||
|
log.error('failure', { err });
|
||||||
|
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.meta.err.error).toBe('something went wrong');
|
||||||
|
expect(parsed.meta.err.stack).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { requireAuth, socketAuth } from '../../src/middleware/auth.js';
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) { this.statusCode = c; return this; },
|
||||||
|
json(b) { this.body = b; return this; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requireAuth', () => {
|
||||||
|
it('calls next when session is authed', () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
requireAuth({ session: { authed: true } }, mockRes(), next);
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when not authed', () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const res = mockRes();
|
||||||
|
requireAuth({ session: {} }, res, next);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: 'unauthorized' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('socketAuth', () => {
|
||||||
|
it('calls next with no error when session is authed', () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const socket = { request: {} };
|
||||||
|
const fakeSessionMiddleware = (req, res, callback) => {
|
||||||
|
req.session = { authed: true };
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const middleware = socketAuth(fakeSessionMiddleware);
|
||||||
|
middleware(socket, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls next with unauthorized error when not authed', () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const socket = { request: {} };
|
||||||
|
const fakeSessionMiddleware = (req, res, callback) => {
|
||||||
|
req.session = {};
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const middleware = socketAuth(fakeSessionMiddleware);
|
||||||
|
middleware(socket, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(next).toHaveBeenCalledWith(expect.any(Error));
|
||||||
|
expect(next.mock.calls[0][0].message).toBe('unauthorized');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import session from 'express-session';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createAuthRouter } from '../../src/routes/auth.js';
|
||||||
|
|
||||||
|
function makeApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(session({ secret: 't', resave: false, saveUninitialized: false }));
|
||||||
|
const authService = { verifyPassword: async (p) => p === 'right' };
|
||||||
|
app.use('/api/auth', createAuthRouter(authService));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth routes', () => {
|
||||||
|
it('logs in with correct password and reports authed', async () => {
|
||||||
|
const agent = request.agent(makeApp());
|
||||||
|
const login = await agent.post('/api/auth/login').send({ password: 'right' });
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
const me = await agent.get('/api/auth/me');
|
||||||
|
expect(me.body).toEqual({ authed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong password', async () => {
|
||||||
|
const res = await request(makeApp()).post('/api/auth/login').send({ password: 'no' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs out', async () => {
|
||||||
|
const agent = request.agent(makeApp());
|
||||||
|
await agent.post('/api/auth/login').send({ password: 'right' });
|
||||||
|
await agent.post('/api/auth/logout');
|
||||||
|
const me = await agent.get('/api/auth/me');
|
||||||
|
expect(me.body).toEqual({ authed: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createPlayersRouter } from '../../src/routes/players.js';
|
||||||
|
import { NotSupportedError } from '../../src/adapters/serverAdapter.js';
|
||||||
|
|
||||||
|
function makeApp(adapterOverrides = {}, { propertiesOverrides } = {}) {
|
||||||
|
const adapter = {
|
||||||
|
capabilities: new Set(['whitelistAdd', 'whitelistRemove', 'whitelistOn', 'whitelistOff', 'whitelistList', 'ban', 'give']),
|
||||||
|
_edition: 'java',
|
||||||
|
listPlayers: vi.fn().mockResolvedValue({ online: 0, max: 20, players: [] }),
|
||||||
|
whitelistAdd: vi.fn().mockResolvedValue('Added steve'),
|
||||||
|
whitelistRemove: vi.fn().mockResolvedValue('Removed steve'),
|
||||||
|
whitelistOn: vi.fn().mockResolvedValue('Whitelist enabled'),
|
||||||
|
whitelistOff: vi.fn().mockResolvedValue('Whitelist disabled'),
|
||||||
|
whitelistList: vi.fn().mockResolvedValue(['steve', 'alex']),
|
||||||
|
ban: vi.fn().mockResolvedValue('Banned'),
|
||||||
|
give: vi.fn().mockResolvedValue('Gave'),
|
||||||
|
...adapterOverrides,
|
||||||
|
};
|
||||||
|
const propertiesService = {
|
||||||
|
read: vi.fn().mockResolvedValue({ 'white-list': 'true' }),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
...propertiesOverrides,
|
||||||
|
};
|
||||||
|
const appState = { getAdapter: vi.fn().mockResolvedValue(adapter) };
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/players', createPlayersRouter({ appState, propertiesService, config: { mcDataPath: '/data' } }));
|
||||||
|
return { app, adapter, propertiesService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('players routes', () => {
|
||||||
|
it('rejects a name-requiring action with an empty name (does not call the adapter)', async () => {
|
||||||
|
const { app, adapter } = makeApp();
|
||||||
|
const res = await request(app).post('/api/players/whitelistRemove').send({ name: ' ' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body).toEqual({ error: 'name_required' });
|
||||||
|
expect(adapter.whitelistRemove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows name-less actions (whitelistOn) without a name', async () => {
|
||||||
|
const { app, adapter } = makeApp();
|
||||||
|
const res = await request(app).post('/api/players/whitelistOn').send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(adapter.whitelistOn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET / returns players and capability list (no embedded directory)', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/players');
|
||||||
|
expect(res.body.capabilities).toContain('ban');
|
||||||
|
expect(res.body.players).toEqual({ online: 0, max: 20, players: [] });
|
||||||
|
expect(res.body.players.directory).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /directory returns a paginated shape with online annotation', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/players/directory?page=1&pageSize=25');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ page: 1, pageSize: 25, total: expect.any(Number) });
|
||||||
|
expect(Array.isArray(res.body.items)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /detail requires an xuid', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/players/detail');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body).toEqual({ error: 'xuid_required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /detail returns 404 for an unknown player', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/players/detail?xuid=doesnotexist');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET / returns whitelistEnabled and whitelist', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/players');
|
||||||
|
expect(res.body.whitelistEnabled).toBe(true);
|
||||||
|
expect(res.body.whitelist).toEqual(['steve', 'alex']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET / returns whitelistEnabled=false when property is false', async () => {
|
||||||
|
const { app } = makeApp({}, {
|
||||||
|
propertiesOverrides: { read: vi.fn().mockResolvedValue({ 'white-list': 'false' }) },
|
||||||
|
});
|
||||||
|
const res = await request(app).get('/api/players');
|
||||||
|
expect(res.body.whitelistEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET / reads allow-list property for bedrock edition', async () => {
|
||||||
|
const { app } = makeApp({ _edition: 'bedrock' }, {
|
||||||
|
propertiesOverrides: { read: vi.fn().mockResolvedValue({ 'allow-list': 'true' }) },
|
||||||
|
});
|
||||||
|
const res = await request(app).get('/api/players');
|
||||||
|
expect(res.body.whitelistEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /whitelistAdd delegates with name', async () => {
|
||||||
|
const { app, adapter } = makeApp();
|
||||||
|
const res = await request(app).post('/api/players/whitelistAdd').send({ name: 'steve' });
|
||||||
|
expect(res.body).toEqual({ ok: true, output: 'Added steve' });
|
||||||
|
expect(adapter.whitelistAdd).toHaveBeenCalledWith('steve');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /whitelistOn calls adapter and persists to properties', async () => {
|
||||||
|
const { app, adapter, propertiesService } = makeApp();
|
||||||
|
const res = await request(app).post('/api/players/whitelistOn').send({});
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(adapter.whitelistOn).toHaveBeenCalled();
|
||||||
|
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'true' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /whitelistOff calls adapter and persists to properties', async () => {
|
||||||
|
const { app, adapter, propertiesService } = makeApp();
|
||||||
|
const res = await request(app).post('/api/players/whitelistOff').send({});
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(adapter.whitelistOff).toHaveBeenCalled();
|
||||||
|
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'false' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /whitelistOn for bedrock persists allow-list property', async () => {
|
||||||
|
const { app, adapter, propertiesService } = makeApp({ _edition: 'bedrock' });
|
||||||
|
const res = await request(app).post('/api/players/whitelistOn').send({});
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'true' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /give passes item and count', async () => {
|
||||||
|
const { app, adapter } = makeApp();
|
||||||
|
await request(app).post('/api/players/give').send({ name: 'steve', item: 'minecraft:dirt', count: 3 });
|
||||||
|
expect(adapter.give).toHaveBeenCalledWith('steve', 'minecraft:dirt', 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 on NotSupportedError', async () => {
|
||||||
|
const { app } = makeApp({ ban: vi.fn().mockRejectedValue(new NotSupportedError('ban')) });
|
||||||
|
const res = await request(app).post('/api/players/ban').send({ name: 'x' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body).toEqual({ error: 'not_supported' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for unknown action', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).post('/api/players/frobnicate').send({ name: 'x' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createPropertiesRouter } from '../../src/routes/properties.js';
|
||||||
|
|
||||||
|
function makeApp() {
|
||||||
|
const propertiesService = {
|
||||||
|
read: vi.fn().mockResolvedValue({ difficulty: 'easy', 'max-players': '20' }),
|
||||||
|
update: vi.fn().mockImplementation(async (p) => ({ difficulty: 'easy', 'max-players': '20', ...p })),
|
||||||
|
};
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/properties', createPropertiesRouter({ propertiesService }));
|
||||||
|
return { app, propertiesService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('properties routes', () => {
|
||||||
|
it('GET / returns properties', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/properties');
|
||||||
|
expect(res.body.properties.difficulty).toBe('easy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET / fills defaults for managed keys missing from the file (except motd)', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/properties');
|
||||||
|
const p = res.body.properties;
|
||||||
|
// present in the file → kept as-is
|
||||||
|
expect(p.difficulty).toBe('easy');
|
||||||
|
expect(p['max-players']).toBe('20');
|
||||||
|
// absent from the file → default applied
|
||||||
|
expect(p.pvp).toBe('true');
|
||||||
|
expect(p['keep-inventory']).toBe('false');
|
||||||
|
expect(p.hardcore).toBe('false');
|
||||||
|
expect(p['allow-flight']).toBe('false');
|
||||||
|
expect(p['force-gamemode']).toBe('false');
|
||||||
|
expect(p['allow-nether']).toBe('true');
|
||||||
|
expect(p['enable-command-block']).toBe('false');
|
||||||
|
expect(p['spawn-monsters']).toBe('true');
|
||||||
|
expect(p['spawn-animals']).toBe('true');
|
||||||
|
expect(p['spawn-npcs']).toBe('true');
|
||||||
|
expect(p.gamemode).toBe('survival');
|
||||||
|
expect(p['view-distance']).toBe('10');
|
||||||
|
expect(p['simulation-distance']).toBe('10');
|
||||||
|
expect(p['spawn-protection']).toBe('16');
|
||||||
|
// motd deliberately has no default
|
||||||
|
expect(p.motd).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET / serves pure defaults when server.properties does not exist yet', async () => {
|
||||||
|
const propertiesService = {
|
||||||
|
read: vi.fn().mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOENT' })),
|
||||||
|
};
|
||||||
|
const app = express();
|
||||||
|
app.use('/api/properties', createPropertiesRouter({ propertiesService }));
|
||||||
|
const res = await request(app).get('/api/properties');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.properties.pvp).toBe('true');
|
||||||
|
expect(res.body.properties.gamemode).toBe('survival');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT / accepts valid values', async () => {
|
||||||
|
const { app, propertiesService } = makeApp();
|
||||||
|
const res = await request(app).put('/api/properties').send({ properties: { difficulty: 'hard' } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(propertiesService.update).toHaveBeenCalledWith({ difficulty: 'hard' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT / rejects invalid enum with 400', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).put('/api/properties').send({ properties: { difficulty: 'banana' } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.field).toBe('difficulty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT / rejects non-numeric max-players', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).put('/api/properties').send({ properties: { 'max-players': 'lots' } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.field).toBe('max-players');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createStatusRouter } from '../../src/routes/status.js';
|
||||||
|
import { ContainerNotFoundError } from '../../src/services/dockerService.js';
|
||||||
|
|
||||||
|
function makeApp(overrides = {}) {
|
||||||
|
const dockerService = {
|
||||||
|
inspect: vi.fn().mockResolvedValue({ state: 'running', type: 'PAPER' }),
|
||||||
|
stats: vi.fn().mockResolvedValue({ cpuPct: 10, memUsedMb: 512, memPct: 50 }),
|
||||||
|
start: vi.fn().mockResolvedValue(),
|
||||||
|
stop: vi.fn().mockResolvedValue(),
|
||||||
|
restart: vi.fn().mockResolvedValue(),
|
||||||
|
kill: vi.fn().mockResolvedValue(),
|
||||||
|
...overrides.dockerService,
|
||||||
|
};
|
||||||
|
const adapter = { listPlayers: vi.fn().mockResolvedValue({ online: 1, max: 20, players: ['a'] }) };
|
||||||
|
const appState = {
|
||||||
|
getEdition: vi.fn().mockResolvedValue('java'),
|
||||||
|
getAdapter: vi.fn().mockResolvedValue(adapter),
|
||||||
|
};
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/status', createStatusRouter({ dockerService, appState }));
|
||||||
|
return { app, dockerService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('status routes', () => {
|
||||||
|
it('GET / returns aggregated status', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/status');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
state: 'running', edition: 'java', cpuPct: 10, memPct: 50,
|
||||||
|
players: { online: 1, max: 20, players: ['a'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /start delegates to dockerService', async () => {
|
||||||
|
const { app, dockerService } = makeApp();
|
||||||
|
const res = await request(app).post('/api/status/start');
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
expect(dockerService.start).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /start returns 404 when container missing', async () => {
|
||||||
|
const { app } = makeApp({
|
||||||
|
dockerService: { start: vi.fn().mockRejectedValue(new ContainerNotFoundError('mc')) },
|
||||||
|
});
|
||||||
|
const res = await request(app).post('/api/status/start');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toEqual({ error: 'not_found' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import { createWorldRouter } from '../../src/routes/world.js';
|
||||||
|
|
||||||
|
function noopUpload() {
|
||||||
|
// fake multer middleware that sets req.file
|
||||||
|
return { single: () => (req, _res, next) => { req.file = { path: '/tmp/up.zip' }; next(); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(worldOverrides = {}) {
|
||||||
|
const worldService = {
|
||||||
|
regen: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
importWorld: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
createDownloadStream: vi.fn().mockImplementation(() => {
|
||||||
|
const s = new PassThrough();
|
||||||
|
process.nextTick(() => { s.end('zipbytes'); });
|
||||||
|
return s;
|
||||||
|
}),
|
||||||
|
...worldOverrides,
|
||||||
|
};
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/world', createWorldRouter({ worldService, upload: noopUpload() }));
|
||||||
|
return { app, worldService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('world routes', () => {
|
||||||
|
it('POST /regen delegates with seed', async () => {
|
||||||
|
const { app, worldService } = makeApp();
|
||||||
|
const res = await request(app).post('/api/world/regen').send({ seed: '99988' });
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
expect(worldService.regen).toHaveBeenCalledWith('99988');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /download streams a zip', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const res = await request(app).get('/api/world/download');
|
||||||
|
expect(res.headers['content-type']).toContain('application/zip');
|
||||||
|
expect(res.text).toContain('zipbytes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /upload imports the world', async () => {
|
||||||
|
const { app, worldService } = makeApp();
|
||||||
|
const res = await request(app).post('/api/world/upload');
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
expect(worldService.importWorld).toHaveBeenCalledWith('/tmp/up.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /upload returns 400 on bad zip', async () => {
|
||||||
|
const { app } = makeApp({ importWorld: vi.fn().mockRejectedValue(new Error('bad zip')) });
|
||||||
|
const res = await request(app).post('/api/world/upload');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body).toEqual({ error: 'invalid_world' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { createApp } from '../src/server.js';
|
||||||
|
|
||||||
|
function fakeDeps() {
|
||||||
|
const config = {
|
||||||
|
adminPasswordHash: bcrypt.hashSync('pw', 10),
|
||||||
|
sessionSecret: 'test-secret',
|
||||||
|
nodeEnv: 'test',
|
||||||
|
mcDataPath: '/data',
|
||||||
|
};
|
||||||
|
const dockerService = {
|
||||||
|
inspect: vi.fn().mockResolvedValue({ state: 'exited', type: 'PAPER' }),
|
||||||
|
stats: vi.fn(), start: vi.fn(), stop: vi.fn(), restart: vi.fn(), kill: vi.fn(),
|
||||||
|
};
|
||||||
|
const appState = { getEdition: vi.fn().mockResolvedValue('java'), getAdapter: vi.fn() };
|
||||||
|
const propertiesService = { read: vi.fn().mockResolvedValue({}), update: vi.fn() };
|
||||||
|
const worldService = {};
|
||||||
|
const authService = { verifyPassword: async (p) => p === 'pw' };
|
||||||
|
const upload = { single: () => (req, _res, next) => next() };
|
||||||
|
return { config, dockerService, appState, propertiesService, worldService, authService, upload };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server auth guard', () => {
|
||||||
|
it('blocks /api/status when unauthenticated', async () => {
|
||||||
|
const { app } = createApp(fakeDeps());
|
||||||
|
const res = await request(app).get('/api/status');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows /api/status after login', async () => {
|
||||||
|
const { app } = createApp(fakeDeps());
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post('/api/auth/login').send({ password: 'pw' });
|
||||||
|
const res = await agent.get('/api/status');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.state).toBe('exited');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { createAuthService } from '../../src/services/authService.js';
|
||||||
|
|
||||||
|
describe('authService', () => {
|
||||||
|
let svc;
|
||||||
|
beforeAll(() => {
|
||||||
|
const hash = bcrypt.hashSync('correct', 12);
|
||||||
|
svc = createAuthService({ adminPasswordHash: hash });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the correct password', async () => {
|
||||||
|
expect(await svc.verifyPassword('correct')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong password', async () => {
|
||||||
|
expect(await svc.verifyPassword('wrong')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty input without throwing', async () => {
|
||||||
|
expect(await svc.verifyPassword('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import { createBedrockIdentityBridge } from '../../src/services/bedrockIdentityBridge.js';
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises');
|
||||||
|
|
||||||
|
describe('bedrockIdentityBridge', () => {
|
||||||
|
let store;
|
||||||
|
beforeEach(() => {
|
||||||
|
store = null;
|
||||||
|
vi.mocked(fsp.writeFile).mockImplementation(async (_p, data) => { store = data; });
|
||||||
|
vi.mocked(fsp.readFile).mockImplementation(async () => {
|
||||||
|
if (store == null) throw new Error('ENOENT');
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('learn binds xuid to the LevelDB uuid when uniqueId matches', async () => {
|
||||||
|
const fileAdapter = { findByUniqueId: vi.fn(async (u) => u === '-8589934591' ? { uuid: 'abc-uuid', data: {} } : null) };
|
||||||
|
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
|
||||||
|
|
||||||
|
const binding = await bridge.learn({ xuid: '2535407895138987', name: 'Lucasrguerra', uniqueId: '-8589934591' });
|
||||||
|
expect(binding.leveldbUuid).toBe('abc-uuid');
|
||||||
|
expect(await bridge.resolveLeveldbUuid('2535407895138987')).toBe('abc-uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('learn returns null and persists nothing when uniqueId matches no player', async () => {
|
||||||
|
const fileAdapter = { findByUniqueId: vi.fn(async () => null) };
|
||||||
|
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
|
||||||
|
expect(await bridge.learn({ xuid: 'x', name: 'n', uniqueId: '-1' })).toBeNull();
|
||||||
|
expect(store).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('learn binds xuid to fallbackUuid when provided even without uniqueId', async () => {
|
||||||
|
const fileAdapter = { findByUniqueId: vi.fn() };
|
||||||
|
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
|
||||||
|
|
||||||
|
const binding = await bridge.learn({ xuid: '2535407895138987', name: 'Lucasrguerra', fallbackUuid: 'fallback-uuid-123' });
|
||||||
|
expect(binding.leveldbUuid).toBe('fallback-uuid-123');
|
||||||
|
expect(await bridge.resolveLeveldbUuid('2535407895138987')).toBe('fallback-uuid-123');
|
||||||
|
expect(fileAdapter.findByUniqueId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveLeveldbUuid returns null for an unknown xuid', async () => {
|
||||||
|
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter: {} });
|
||||||
|
expect(await bridge.resolveLeveldbUuid('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createDockerService, ContainerNotFoundError } from '../../src/services/dockerService.js';
|
||||||
|
|
||||||
|
function mockDocker({ inspectData, exists = true } = {}) {
|
||||||
|
const container = {
|
||||||
|
start: vi.fn().mockResolvedValue(),
|
||||||
|
stop: vi.fn().mockResolvedValue(),
|
||||||
|
restart: vi.fn().mockResolvedValue(),
|
||||||
|
kill: vi.fn().mockResolvedValue(),
|
||||||
|
inspect: vi.fn().mockResolvedValue(inspectData),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
_container: container,
|
||||||
|
listContainers: vi.fn().mockResolvedValue(
|
||||||
|
exists ? [{ Names: ['/craftdock-mc-server'], Id: 'abc' }] : []
|
||||||
|
),
|
||||||
|
getContainer: vi.fn().mockReturnValue(container),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = { mcContainerName: 'craftdock-mc-server' };
|
||||||
|
|
||||||
|
describe('dockerService', () => {
|
||||||
|
it('inspect returns state and parsed TYPE', async () => {
|
||||||
|
const docker = mockDocker({
|
||||||
|
inspectData: {
|
||||||
|
State: { Status: 'running' },
|
||||||
|
Config: { Env: ['EULA=TRUE', 'TYPE=PAPER'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const svc = createDockerService(config, docker);
|
||||||
|
const info = await svc.inspect();
|
||||||
|
expect(info).toMatchObject({ found: true, state: 'running', type: 'PAPER' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inspect reports not_found when container is absent', async () => {
|
||||||
|
const svc = createDockerService(config, mockDocker({ exists: false }));
|
||||||
|
const info = await svc.inspect();
|
||||||
|
expect(info).toEqual({ found: false, state: 'not_found', type: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('start delegates to the resolved container', async () => {
|
||||||
|
const docker = mockDocker({ inspectData: { State: {}, Config: { Env: [] } } });
|
||||||
|
const svc = createDockerService(config, docker);
|
||||||
|
await svc.start();
|
||||||
|
expect(docker._container.start).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('start throws ContainerNotFoundError when absent', async () => {
|
||||||
|
const svc = createDockerService(config, mockDocker({ exists: false }));
|
||||||
|
await expect(svc.start()).rejects.toBeInstanceOf(ContainerNotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getState works when destructured (no `this` binding)', async () => {
|
||||||
|
const docker = mockDocker({
|
||||||
|
inspectData: { State: { Status: 'running' }, Config: { Env: [] } },
|
||||||
|
});
|
||||||
|
const { getState } = createDockerService(config, docker);
|
||||||
|
await expect(getState()).resolves.toBe('running');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { createDockerService } from '../../src/services/dockerService.js';
|
||||||
|
|
||||||
|
const config = { mcContainerName: 'craftdock-mc-server', mcServiceName: 'minecraft-server' };
|
||||||
|
|
||||||
|
// Simulates Coolify: the MC container has an auto-generated name (not
|
||||||
|
// craftdock-mc-server) but carries the compose service/project labels. The panel
|
||||||
|
// container (os.hostname()) shares the same project.
|
||||||
|
function mockCoolifyDocker() {
|
||||||
|
const selfId = os.hostname();
|
||||||
|
const mcData = { State: { Status: 'running' }, Config: { Env: ['TYPE=BEDROCK'] } };
|
||||||
|
const byId = {
|
||||||
|
[selfId]: { inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'com.docker.compose.project': 'proj' } } }) },
|
||||||
|
mcid: { inspect: vi.fn().mockResolvedValue(mcData), start: vi.fn().mockResolvedValue() },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
byId,
|
||||||
|
listContainers: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
Names: ['/minecraft-server-proj-abc123'],
|
||||||
|
Id: 'mcid',
|
||||||
|
Labels: { 'com.docker.compose.service': 'minecraft-server', 'com.docker.compose.project': 'proj' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
getContainer: vi.fn((id) => byId[id] || { inspect: vi.fn().mockRejectedValue(new Error('no such container')) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dockerService container resolution', () => {
|
||||||
|
it('resolves the MC container by compose service label when the name does not match', async () => {
|
||||||
|
const docker = mockCoolifyDocker();
|
||||||
|
const svc = createDockerService(config, docker);
|
||||||
|
const info = await svc.inspect();
|
||||||
|
expect(info).toMatchObject({ found: true, state: 'running', type: 'BEDROCK' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes the label match to the panel own compose project', async () => {
|
||||||
|
const docker = mockCoolifyDocker();
|
||||||
|
// Same service label but a DIFFERENT project — must NOT match.
|
||||||
|
docker.listContainers.mockResolvedValue([
|
||||||
|
{
|
||||||
|
Names: ['/minecraft-server-other-xyz'],
|
||||||
|
Id: 'mcid',
|
||||||
|
Labels: { 'com.docker.compose.service': 'minecraft-server', 'com.docker.compose.project': 'other-project' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const svc = createDockerService(config, docker);
|
||||||
|
const info = await svc.inspect();
|
||||||
|
expect(info).toEqual({ found: false, state: 'not_found', type: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createDockerService } from '../../src/services/dockerService.js';
|
||||||
|
|
||||||
|
const statsSample = {
|
||||||
|
cpu_stats: {
|
||||||
|
cpu_usage: { total_usage: 2000000000 },
|
||||||
|
system_cpu_usage: 20000000000,
|
||||||
|
online_cpus: 2,
|
||||||
|
},
|
||||||
|
precpu_stats: {
|
||||||
|
cpu_usage: { total_usage: 1000000000 },
|
||||||
|
system_cpu_usage: 10000000000,
|
||||||
|
},
|
||||||
|
memory_stats: { usage: 536870912, limit: 1073741824 }, // 512MB / 1GB
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockDocker() {
|
||||||
|
const container = {
|
||||||
|
inspect: vi.fn().mockResolvedValue({ State: { Status: 'running' }, Config: { Env: [] } }),
|
||||||
|
stats: vi.fn().mockResolvedValue(statsSample),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
listContainers: vi.fn().mockResolvedValue([{ Names: ['/mc'], Id: 'x' }]),
|
||||||
|
getContainer: vi.fn().mockReturnValue(container),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dockerService.stats', () => {
|
||||||
|
it('computes cpu and memory percentages', async () => {
|
||||||
|
const svc = createDockerService({ mcContainerName: 'mc' }, mockDocker());
|
||||||
|
const s = await svc.stats();
|
||||||
|
// cpuDelta=1e9, sysDelta=1e10 -> 0.1 * 2 cpus * 100 = 20%
|
||||||
|
expect(s.cpuPct).toBeCloseTo(20, 1);
|
||||||
|
expect(s.memUsedMb).toBeCloseTo(512, 0);
|
||||||
|
expect(s.memPct).toBeCloseTo(50, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zeros for a malformed/empty stats payload (e.g. stopped container)', async () => {
|
||||||
|
const docker = mockDocker();
|
||||||
|
docker.getContainer().stats.mockResolvedValue({
|
||||||
|
cpu_stats: {},
|
||||||
|
precpu_stats: {},
|
||||||
|
memory_stats: {},
|
||||||
|
});
|
||||||
|
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
|
||||||
|
const s = await svc.stats();
|
||||||
|
expect(s).toEqual({ cpuPct: 0, memUsedMb: 0, memPct: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zeros for a completely empty stats payload', async () => {
|
||||||
|
const docker = mockDocker();
|
||||||
|
docker.getContainer().stats.mockResolvedValue({});
|
||||||
|
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
|
||||||
|
const s = await svc.stats();
|
||||||
|
expect(s).toEqual({ cpuPct: 0, memUsedMb: 0, memPct: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// test/services/dockerServiceStreams.test.js
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createDockerService } from '../../src/services/dockerService.js';
|
||||||
|
|
||||||
|
function mockDocker() {
|
||||||
|
const container = {
|
||||||
|
inspect: vi.fn().mockResolvedValue({ State: {}, Config: { Env: [] } }),
|
||||||
|
logs: vi.fn().mockResolvedValue('LOGSTREAM'),
|
||||||
|
attach: vi.fn().mockResolvedValue('ATTACHSTREAM'),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
_container: container,
|
||||||
|
listContainers: vi.fn().mockResolvedValue([{ Names: ['/mc'], Id: 'x' }]),
|
||||||
|
getContainer: vi.fn().mockReturnValue(container),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dockerService streams', () => {
|
||||||
|
it('logStream requests a following log stream with tail', async () => {
|
||||||
|
const docker = mockDocker();
|
||||||
|
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
|
||||||
|
const stream = await svc.logStream();
|
||||||
|
expect(stream).toBe('LOGSTREAM');
|
||||||
|
expect(docker._container.logs).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ follow: true, stdout: true, stderr: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attach requests a duplex stream with stdin', async () => {
|
||||||
|
const docker = mockDocker();
|
||||||
|
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
|
||||||
|
const stream = await svc.attach();
|
||||||
|
expect(stream).toBe('ATTACHSTREAM');
|
||||||
|
expect(docker._container.attach).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ stream: true, stdin: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ensureFirstBootDefaults, MARKER_FILE } from '../../src/services/firstBootService.js';
|
||||||
|
|
||||||
|
let tmp, propertiesService, logger;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-fb-'));
|
||||||
|
propertiesService = { update: vi.fn().mockResolvedValue({}) };
|
||||||
|
logger = { info: vi.fn(), warn: vi.fn() };
|
||||||
|
});
|
||||||
|
afterEach(async () => { await fsp.rm(tmp, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
const run = (edition = 'bedrock') =>
|
||||||
|
ensureFirstBootDefaults({ config: { mcDataPath: tmp, mcEdition: edition }, propertiesService, logger });
|
||||||
|
|
||||||
|
describe('ensureFirstBootDefaults', () => {
|
||||||
|
it('first boot: forces the whitelist property to false and writes the marker', async () => {
|
||||||
|
await run('bedrock');
|
||||||
|
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'false' });
|
||||||
|
const marker = JSON.parse(await fsp.readFile(path.join(tmp, MARKER_FILE), 'utf8'));
|
||||||
|
expect(marker.initializedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('java edition writes white-list', async () => {
|
||||||
|
await run('java');
|
||||||
|
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'false' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto edition writes both keys (each server only reads its own)', async () => {
|
||||||
|
await run('auto');
|
||||||
|
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'false', 'white-list': 'false' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restart (marker present): does not touch properties', async () => {
|
||||||
|
await fsp.writeFile(path.join(tmp, MARKER_FILE), JSON.stringify({ initializedAt: 'x' }));
|
||||||
|
await run('bedrock');
|
||||||
|
expect(propertiesService.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never throws: a failing properties write is logged, and no marker is left so it retries next boot', async () => {
|
||||||
|
propertiesService.update.mockRejectedValue(new Error('disk full'));
|
||||||
|
await expect(run('bedrock')).resolves.toBeUndefined();
|
||||||
|
expect(logger.warn).toHaveBeenCalled();
|
||||||
|
await expect(fsp.access(path.join(tmp, MARKER_FILE))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createPlayerDetailService } from '../../src/services/playerDetailService.js';
|
||||||
|
|
||||||
|
function setup({ edition, online, running = true, dir, bridge, readPlayer, listServerUuids, extraAdapter = {} }) {
|
||||||
|
const adapter = {
|
||||||
|
_edition: edition,
|
||||||
|
listPlayers: vi.fn(async () => ({ players: online ? ['Lucas'] : [] })),
|
||||||
|
queryUniqueId: vi.fn(async () => '-8589934591'),
|
||||||
|
saveHold: vi.fn(async () => true),
|
||||||
|
saveResume: vi.fn(async () => {}),
|
||||||
|
forceSave: vi.fn(async () => {}),
|
||||||
|
...extraAdapter,
|
||||||
|
};
|
||||||
|
const deps = {
|
||||||
|
config: { mcDataPath: '/data' },
|
||||||
|
appState: { getAdapter: vi.fn(async () => adapter) },
|
||||||
|
dockerService: { inspect: vi.fn(async () => ({ state: running ? 'running' : 'exited' })) },
|
||||||
|
readDirectory: vi.fn(async () => dir),
|
||||||
|
createFileAdapter: vi.fn(() => ({
|
||||||
|
readPlayer: vi.fn(readPlayer),
|
||||||
|
findByUniqueId: vi.fn(),
|
||||||
|
listServerUuids: vi.fn(async () => listServerUuids ? listServerUuids() : []),
|
||||||
|
})),
|
||||||
|
createBridge: vi.fn(() => bridge),
|
||||||
|
};
|
||||||
|
return { adapter, service: createPlayerDetailService(deps), deps };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('playerDetailService', () => {
|
||||||
|
it('returns null for an unknown xuid', async () => {
|
||||||
|
const { service } = setup({ edition: 'bedrock', online: false, dir: {}, bridge: {} });
|
||||||
|
expect(await service.getDetail('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bedrock online: learns the bridge and reads under a save snapshot', async () => {
|
||||||
|
const bridge = {
|
||||||
|
learn: vi.fn(async () => ({})),
|
||||||
|
resolveLeveldbUuid: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('uuid-1'),
|
||||||
|
};
|
||||||
|
const { adapter, service } = setup({
|
||||||
|
edition: 'bedrock', online: true,
|
||||||
|
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
|
||||||
|
bridge,
|
||||||
|
readPlayer: async (id) => id === 'uuid-1' ? { position: { x: 1, y: 2, z: 3 }, health: { current: 9, max: 20 } } : null,
|
||||||
|
});
|
||||||
|
const d = await service.getDetail('2535');
|
||||||
|
expect(d.online).toBe(true);
|
||||||
|
expect(d.position).toEqual({ x: 1, y: 2, z: 3 });
|
||||||
|
expect(bridge.learn).toHaveBeenCalledWith({ xuid: '2535', name: 'Lucas', uniqueId: '-8589934591' });
|
||||||
|
expect(adapter.saveHold).toHaveBeenCalled();
|
||||||
|
expect(adapter.saveResume).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bedrock offline and never bridged: auto-binds single LevelDB uuid fallback', async () => {
|
||||||
|
const bridge = { learn: vi.fn(async () => ({})), resolveLeveldbUuid: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('uuid-fallback-1') };
|
||||||
|
const { service } = setup({
|
||||||
|
edition: 'bedrock', online: false, running: false,
|
||||||
|
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
|
||||||
|
bridge,
|
||||||
|
readPlayer: async (id) => id === 'uuid-fallback-1' ? { health: 20 } : null,
|
||||||
|
listServerUuids: () => ['uuid-fallback-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = await service.getDetail('2535');
|
||||||
|
expect(d.needsBridge).toBe(false);
|
||||||
|
expect(d.health).toBe(20);
|
||||||
|
expect(bridge.learn).toHaveBeenCalledWith({ xuid: '2535', name: 'Lucas', fallbackUuid: 'uuid-fallback-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bedrock offline and never bridged with multiple uuids: returns needsBridge', async () => {
|
||||||
|
const bridge = { learn: vi.fn(), resolveLeveldbUuid: vi.fn(async () => null) };
|
||||||
|
const { service } = setup({
|
||||||
|
edition: 'bedrock', online: false,
|
||||||
|
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
|
||||||
|
bridge,
|
||||||
|
readPlayer: async () => null,
|
||||||
|
listServerUuids: () => ['uuid-1', 'uuid-2'],
|
||||||
|
});
|
||||||
|
const d = await service.getDetail('2535');
|
||||||
|
expect(d.needsBridge).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
it('java online: forces save then reads the player .dat', async () => {
|
||||||
|
const { adapter, service } = setup({
|
||||||
|
edition: 'java', online: true,
|
||||||
|
dir: { 'uuid-xyz': { name: 'Lucas', xuid: 'uuid-xyz' } },
|
||||||
|
bridge: {},
|
||||||
|
readPlayer: async (id) => id === 'uuid-xyz' ? { position: { x: 0, y: 64, z: 0 }, food: 20 } : null,
|
||||||
|
});
|
||||||
|
const d = await service.getDetail('uuid-xyz');
|
||||||
|
expect(adapter.forceSave).toHaveBeenCalled();
|
||||||
|
expect(d.food).toBe(20);
|
||||||
|
expect(d.needsBridge).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import { updatePlayerDirectory, queryDirectory } from '../../src/services/playerDirectory.js';
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises');
|
||||||
|
|
||||||
|
// A dockerService whose container returns the given log text, recording the opts
|
||||||
|
// each `logs()` call was made with (to assert incremental scanning).
|
||||||
|
function mockDocker(logText) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
getContainer: async () => ({
|
||||||
|
logs: async (opts) => { calls.push(opts); return Buffer.from(logText); },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('playerDirectory', () => {
|
||||||
|
let store;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = null;
|
||||||
|
vi.mocked(fsp.writeFile).mockImplementation(async (_p, data) => { store = data; });
|
||||||
|
vi.mocked(fsp.rm).mockResolvedValue();
|
||||||
|
// No existing directory / no legacy file by default.
|
||||||
|
vi.mocked(fsp.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records only players that actually spawned, keyed by XUID', async () => {
|
||||||
|
const log = [
|
||||||
|
'Player connected: Rejected, xuid: 111', // connect only → rejected by allowlist
|
||||||
|
'Player Spawned: Lucasrguerra xuid: 222', // real access
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const list = await updatePlayerDirectory({ dataRoot: '/data', dockerService: mockDocker(log), edition: 'bedrock' });
|
||||||
|
|
||||||
|
expect(list).toEqual([
|
||||||
|
expect.objectContaining({ xuid: '222', name: 'Lucasrguerra' }),
|
||||||
|
]);
|
||||||
|
// Rejected player (connected but never spawned) is absent.
|
||||||
|
expect(list.find((e) => e.name === 'Rejected')).toBeUndefined();
|
||||||
|
expect(JSON.parse(store)).toHaveProperty('222');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses gamertags containing spaces', async () => {
|
||||||
|
const list = await updatePlayerDirectory({
|
||||||
|
dataRoot: '/data', dockerService: mockDocker('Player Spawned: Lucas Guerra xuid: 333'), edition: 'bedrock',
|
||||||
|
});
|
||||||
|
expect(list[0]).toMatchObject({ xuid: '333', name: 'Lucas Guerra' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scans logs incrementally: bounded tail first, then since', async () => {
|
||||||
|
// Fresh module so the module-level scan cursor starts at 0 for this test.
|
||||||
|
vi.resetModules();
|
||||||
|
const { updatePlayerDirectory: fresh } = await import('../../src/services/playerDirectory.js');
|
||||||
|
|
||||||
|
const docker = mockDocker('Player Spawned: steve xuid: 1');
|
||||||
|
await fresh({ dataRoot: '/data', dockerService: docker, edition: 'bedrock' });
|
||||||
|
expect(docker.calls[0].tail).toBe(2000);
|
||||||
|
expect(docker.calls[0].since).toBeUndefined();
|
||||||
|
|
||||||
|
await fresh({ dataRoot: '/data', dockerService: docker, edition: 'bedrock' });
|
||||||
|
expect(docker.calls[1].since).toBeGreaterThan(0);
|
||||||
|
expect(docker.calls[1].tail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps previously-seen players even when no longer spawning (Scenario 2)', async () => {
|
||||||
|
// Existing directory already has an old player; a new empty scan must not drop them.
|
||||||
|
vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({
|
||||||
|
'999': { name: 'OldPlayer', xuid: '999', firstSeen: 'x', lastSeen: 'x' },
|
||||||
|
}));
|
||||||
|
const list = await updatePlayerDirectory({ dataRoot: '/data', dockerService: mockDocker(''), edition: 'bedrock' });
|
||||||
|
expect(list).toEqual([expect.objectContaining({ xuid: '999', name: 'OldPlayer' })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('queryDirectory', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 30 players named Player00..Player29, keyed by xuid.
|
||||||
|
const dir = {};
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const n = String(i).padStart(2, '0');
|
||||||
|
dir[`x${n}`] = { xuid: `x${n}`, name: `Player${n}` };
|
||||||
|
}
|
||||||
|
vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify(dir));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the requested page with total', async () => {
|
||||||
|
const r = await queryDirectory('/data', { page: 2, pageSize: 25 });
|
||||||
|
expect(r.total).toBe(30);
|
||||||
|
expect(r.page).toBe(2);
|
||||||
|
expect(r.items).toHaveLength(5);
|
||||||
|
expect(r.items[0].name).toBe('Player25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by name (case-insensitive) and paginates the filtered set', async () => {
|
||||||
|
const r = await queryDirectory('/data', { q: 'player0', pageSize: 100 });
|
||||||
|
expect(r.total).toBe(10); // Player00..Player09
|
||||||
|
expect(r.items.every((e) => e.name.startsWith('Player0'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps an out-of-range page to the last page', async () => {
|
||||||
|
const r = await queryDirectory('/data', { page: 99, pageSize: 25 });
|
||||||
|
expect(r.page).toBe(2);
|
||||||
|
expect(r.items).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createPropertiesService } from '../../src/services/propertiesService.js';
|
||||||
|
|
||||||
|
const SAMPLE = `#Minecraft server properties
|
||||||
|
#Mon Jul 18
|
||||||
|
difficulty=easy
|
||||||
|
pvp=true
|
||||||
|
max-players=20`;
|
||||||
|
|
||||||
|
function makeFs(content) {
|
||||||
|
const store = { value: content };
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
readFile: vi.fn().mockImplementation(async () => store.value),
|
||||||
|
writeFile: vi.fn().mockImplementation(async (_p, data) => { store.value = data; }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('propertiesService', () => {
|
||||||
|
it('parses key=value ignoring comments', () => {
|
||||||
|
const svc = createPropertiesService({ mcDataPath: '/d' }, makeFs(SAMPLE));
|
||||||
|
const obj = svc.parse(SAMPLE);
|
||||||
|
expect(obj).toEqual({ difficulty: 'easy', pvp: 'true', 'max-players': '20' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialize preserves comments and updates values', () => {
|
||||||
|
const svc = createPropertiesService({ mcDataPath: '/d' }, makeFs(SAMPLE));
|
||||||
|
const out = svc.serialize({ difficulty: 'hard', pvp: 'true', 'max-players': '20' }, SAMPLE);
|
||||||
|
expect(out).toContain('#Minecraft server properties');
|
||||||
|
expect(out).toContain('difficulty=hard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update merges patch and writes file', async () => {
|
||||||
|
const fs = makeFs(SAMPLE);
|
||||||
|
const svc = createPropertiesService({ mcDataPath: '/d' }, fs);
|
||||||
|
const merged = await svc.update({ difficulty: 'hard' });
|
||||||
|
expect(merged.difficulty).toBe('hard');
|
||||||
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
|
expect(fs.store.value).toContain('difficulty=hard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update appends new keys not present originally', async () => {
|
||||||
|
const fs = makeFs(SAMPLE);
|
||||||
|
const svc = createPropertiesService({ mcDataPath: '/d' }, fs);
|
||||||
|
await svc.update({ motd: 'Hello' });
|
||||||
|
expect(fs.store.value).toContain('motd=Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createRconService, RconUnavailableError } from '../../src/services/rconService.js';
|
||||||
|
|
||||||
|
function fakeRconClass({ failConnect = false } = {}) {
|
||||||
|
return class {
|
||||||
|
static async connect() {
|
||||||
|
if (failConnect) throw new Error('ECONNREFUSED');
|
||||||
|
return {
|
||||||
|
send: vi.fn().mockResolvedValue('command output'),
|
||||||
|
end: vi.fn().mockResolvedValue(),
|
||||||
|
on: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = { rconHost: 'h', rconPort: 25575, rconPassword: 'p' };
|
||||||
|
|
||||||
|
describe('rconService', () => {
|
||||||
|
it('connects lazily and sends a command', async () => {
|
||||||
|
const svc = createRconService(config, fakeRconClass());
|
||||||
|
const out = await svc.send('list');
|
||||||
|
expect(out).toBe('command output');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws RconUnavailableError when connect fails', async () => {
|
||||||
|
const svc = createRconService(config, fakeRconClass({ failConnect: true }));
|
||||||
|
await expect(svc.send('list')).rejects.toBeInstanceOf(RconUnavailableError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { parseBedrockSeed, createSeedService } from '../../src/services/seedService.js';
|
||||||
|
|
||||||
|
function levelDatWithSeed(seed) {
|
||||||
|
const name = Buffer.from('RandomSeed', 'utf8');
|
||||||
|
const value = Buffer.alloc(8);
|
||||||
|
value.writeBigInt64LE(BigInt(seed));
|
||||||
|
return Buffer.concat([Buffer.from([0x04, 0x0a, 0x00]), name, value, Buffer.from('tail')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseBedrockSeed', () => {
|
||||||
|
it('reads the RandomSeed int64 (little-endian) from a level.dat buffer', () => {
|
||||||
|
expect(parseBedrockSeed(levelDatWithSeed('682981659723566343'))).toBe('682981659723566343');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative seeds', () => {
|
||||||
|
expect(parseBedrockSeed(levelDatWithSeed('-4707462683490150816'))).toBe('-4707462683490150816');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when RandomSeed is absent', () => {
|
||||||
|
expect(parseBedrockSeed(Buffer.from('no seed here'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedService.resolve', () => {
|
||||||
|
const config = { mcDataPath: '/data' };
|
||||||
|
|
||||||
|
it('prefers a non-empty level-seed from server.properties', async () => {
|
||||||
|
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '12345', 'level-name': 'Bedrock level' }) };
|
||||||
|
const svc = createSeedService({ config, propertiesService });
|
||||||
|
expect(await svc.resolve({}, 'bedrock')).toBe('12345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads bedrock seed from level.dat when level-seed is empty', async () => {
|
||||||
|
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '', 'level-name': 'Bedrock level' }) };
|
||||||
|
const fs = { readFile: vi.fn().mockResolvedValue(levelDatWithSeed('682981659723566343')) };
|
||||||
|
const svc = createSeedService({ config, propertiesService, fs });
|
||||||
|
expect(await svc.resolve({}, 'bedrock')).toBe('682981659723566343');
|
||||||
|
expect(fs.readFile).toHaveBeenCalledWith('/data/worlds/Bedrock level/level.dat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the java adapter command when edition is java', async () => {
|
||||||
|
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '' }) };
|
||||||
|
const adapter = { getSeed: vi.fn().mockResolvedValue('99') };
|
||||||
|
const svc = createSeedService({ config, propertiesService });
|
||||||
|
expect(await svc.resolve(adapter, 'java')).toBe('99');
|
||||||
|
expect(adapter.getSeed).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null (not throw) when bedrock level.dat is unreadable', async () => {
|
||||||
|
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '', 'level-name': 'Bedrock level' }) };
|
||||||
|
const fs = { readFile: vi.fn().mockRejectedValue(new Error('ENOENT')) };
|
||||||
|
const svc = createSeedService({ config, propertiesService, fs });
|
||||||
|
expect(await svc.resolve({}, 'bedrock')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import { createStdinService } from '../../src/services/stdinService.js';
|
||||||
|
|
||||||
|
describe('stdinService', () => {
|
||||||
|
it('writes command with newline and captures output window', async () => {
|
||||||
|
const stream = new PassThrough();
|
||||||
|
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
|
||||||
|
const svc = createStdinService(dockerService, { windowMs: 20 });
|
||||||
|
|
||||||
|
const p = svc.send('list');
|
||||||
|
// simulate server echoing output
|
||||||
|
setTimeout(() => stream.write('There are 2 players online\n'), 5);
|
||||||
|
const out = await p;
|
||||||
|
|
||||||
|
expect(out).toContain('2 players online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a newline to the written command', async () => {
|
||||||
|
const stream = new PassThrough();
|
||||||
|
const written = [];
|
||||||
|
stream.write = (chunk) => { written.push(chunk.toString()); return true; };
|
||||||
|
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
|
||||||
|
const svc = createStdinService(dockerService, { windowMs: 5 });
|
||||||
|
await svc.send('op steve');
|
||||||
|
expect(written[0]).toBe('op steve\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent sends (no interleaving)', async () => {
|
||||||
|
const stream = new PassThrough();
|
||||||
|
const order = [];
|
||||||
|
// Track write order to prove serialization
|
||||||
|
const originalWrite = stream.write.bind(stream);
|
||||||
|
stream.write = (...args) => {
|
||||||
|
order.push(args[0].toString().trim());
|
||||||
|
return originalWrite(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
|
||||||
|
const svc = createStdinService(dockerService, { windowMs: 40 });
|
||||||
|
|
||||||
|
// Fire 3 commands concurrently — they must NOT overlap
|
||||||
|
const p1 = svc.send('cmd1');
|
||||||
|
const p2 = svc.send('cmd2');
|
||||||
|
const p3 = svc.send('cmd3');
|
||||||
|
|
||||||
|
// Simulate server responses aimed at the MIDDLE of each serialized capture
|
||||||
|
// window ([0,40) / [40,80) / [80,120)ms) so timer jitter can't flake this.
|
||||||
|
setTimeout(() => stream.push('reply1\n'), 15);
|
||||||
|
setTimeout(() => stream.push('reply2\n'), 55);
|
||||||
|
setTimeout(() => stream.push('reply3\n'), 95);
|
||||||
|
|
||||||
|
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
||||||
|
|
||||||
|
// Commands must have been written in order (serialized)
|
||||||
|
expect(order).toEqual(['cmd1', 'cmd2', 'cmd3']);
|
||||||
|
// Each response captured in its own window
|
||||||
|
expect(r1).toContain('reply1');
|
||||||
|
expect(r2).toContain('reply2');
|
||||||
|
expect(r3).toContain('reply3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-attaches after the stream ends (e.g. container stop/start on world import)', async () => {
|
||||||
|
const stream1 = new PassThrough();
|
||||||
|
const stream2 = new PassThrough();
|
||||||
|
let n = 0;
|
||||||
|
const dockerService = {
|
||||||
|
attach: vi.fn().mockImplementation(async () => (++n === 1 ? stream1 : stream2)),
|
||||||
|
};
|
||||||
|
const svc = createStdinService(dockerService, { windowMs: 5 });
|
||||||
|
|
||||||
|
await svc.send('a');
|
||||||
|
expect(dockerService.attach).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Container stopped: the attach stream ends. A stale, non-destroyed stream
|
||||||
|
// must NOT be reused — the next command has to re-attach.
|
||||||
|
stream1.emit('end');
|
||||||
|
await new Promise((r) => setTimeout(r, 1));
|
||||||
|
|
||||||
|
setTimeout(() => stream2.push('ok\n'), 2);
|
||||||
|
const out = await svc.send('b');
|
||||||
|
expect(dockerService.attach).toHaveBeenCalledTimes(2);
|
||||||
|
expect(out).toContain('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queue continues even if a command rejects (stream error)', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const dockerService = {
|
||||||
|
attach: vi.fn().mockImplementation(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
// First call: return a stream that immediately errors on write
|
||||||
|
const bad = new PassThrough();
|
||||||
|
bad.write = () => { throw new Error('broken pipe'); };
|
||||||
|
return bad;
|
||||||
|
}
|
||||||
|
// Second call: return a healthy stream
|
||||||
|
const good = new PassThrough();
|
||||||
|
setTimeout(() => good.push('ok\n'), 3);
|
||||||
|
return good;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const svc = createStdinService(dockerService, { windowMs: 10 });
|
||||||
|
|
||||||
|
// First send should reject
|
||||||
|
await expect(svc.send('bad')).rejects.toThrow('broken pipe');
|
||||||
|
|
||||||
|
// The service must recover (re-attach) and the queue must not be stuck
|
||||||
|
const result = await svc.send('good');
|
||||||
|
expect(result).toContain('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import archiver from 'archiver';
|
||||||
|
import unzipper from 'unzipper';
|
||||||
|
import { createWorldService } from '../../src/services/worldService.js';
|
||||||
|
|
||||||
|
const extractZip = (zip, dest) =>
|
||||||
|
new Promise((res, rej) =>
|
||||||
|
createReadStream(zip).pipe(unzipper.Extract({ path: dest })).on('close', res).on('error', rej));
|
||||||
|
|
||||||
|
function makeService(mcDataPath) {
|
||||||
|
return createWorldService({
|
||||||
|
config: { mcDataPath, mcWorldName: 'worlds' },
|
||||||
|
dockerService: { getState: async () => 'exited', stop: async () => {}, start: async () => {} },
|
||||||
|
propertiesService: { read: async () => ({ 'level-name': 'Bedrock level' }) },
|
||||||
|
archiver,
|
||||||
|
extractZip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mkWorld(dataPath, levelDatContent) {
|
||||||
|
const world = path.join(dataPath, 'worlds', 'Bedrock level');
|
||||||
|
await fsp.mkdir(path.join(world, 'db'), { recursive: true });
|
||||||
|
await fsp.writeFile(path.join(world, 'level.dat'), levelDatContent);
|
||||||
|
await fsp.writeFile(path.join(world, 'db', '000001.ldb'), 'blocks');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('world export/import bundles player data (full backup)', () => {
|
||||||
|
it('carries allowlist + history from one instance to another', async () => {
|
||||||
|
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'craftdock-bk-'));
|
||||||
|
const src = path.join(tmp, 'src');
|
||||||
|
const dst = path.join(tmp, 'dst');
|
||||||
|
|
||||||
|
// Source: a world + player-data files at the data root.
|
||||||
|
await mkWorld(src, 'SOURCE-WORLD');
|
||||||
|
await fsp.writeFile(path.join(src, 'allowlist.json'),
|
||||||
|
JSON.stringify([{ name: 'Lucasrguerra', ignoresPlayerLimit: false }]));
|
||||||
|
await fsp.writeFile(path.join(src, 'permissions.json'), JSON.stringify([]));
|
||||||
|
await fsp.writeFile(path.join(src, 'craftdock_players_history.json'), JSON.stringify(['Lucasrguerra']));
|
||||||
|
|
||||||
|
// Export.
|
||||||
|
const zipPath = path.join(tmp, 'world.zip');
|
||||||
|
const archive = await makeService(src).createDownloadStream();
|
||||||
|
const out = createWriteStream(zipPath);
|
||||||
|
archive.pipe(out);
|
||||||
|
await new Promise((res, rej) => { out.on('close', res); archive.on('error', rej); });
|
||||||
|
|
||||||
|
// Destination: a different, older world and an empty allowlist.
|
||||||
|
await mkWorld(dst, 'OLD-WORLD');
|
||||||
|
await fsp.writeFile(path.join(dst, 'allowlist.json'), JSON.stringify([]));
|
||||||
|
|
||||||
|
// Import.
|
||||||
|
await makeService(dst).importWorld(zipPath);
|
||||||
|
|
||||||
|
// World replaced.
|
||||||
|
expect(await fsp.readFile(path.join(dst, 'worlds', 'Bedrock level', 'level.dat'), 'utf8'))
|
||||||
|
.toBe('SOURCE-WORLD');
|
||||||
|
// Allowlist migrated (Lucasrguerra now known on the destination).
|
||||||
|
const allow = JSON.parse(await fsp.readFile(path.join(dst, 'allowlist.json'), 'utf8'));
|
||||||
|
expect(allow.map((e) => e.name)).toContain('Lucasrguerra');
|
||||||
|
// History migrated.
|
||||||
|
const hist = JSON.parse(await fsp.readFile(path.join(dst, 'craftdock_players_history.json'), 'utf8'));
|
||||||
|
expect(hist).toContain('Lucasrguerra');
|
||||||
|
// permissions.json migrated too.
|
||||||
|
await expect(fsp.access(path.join(dst, 'permissions.json'))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createWorldService } from '../../src/services/worldService.js';
|
||||||
|
|
||||||
|
describe('worldService.createDownloadStream', () => {
|
||||||
|
it('queues the world directory and finalizes the archive', async () => {
|
||||||
|
const archive = { directory: vi.fn(), file: vi.fn(), finalize: vi.fn(), on: vi.fn() };
|
||||||
|
const archiver = vi.fn().mockReturnValue(archive);
|
||||||
|
const svc = createWorldService({
|
||||||
|
config: { mcDataPath: '/data', mcWorldName: 'world' },
|
||||||
|
dockerService: {},
|
||||||
|
// no player-data files present -> access rejects, nothing extra is bundled
|
||||||
|
fs: { access: vi.fn().mockRejectedValue(new Error('ENOENT')) },
|
||||||
|
archiver,
|
||||||
|
});
|
||||||
|
const out = await svc.createDownloadStream();
|
||||||
|
expect(archiver).toHaveBeenCalledWith('zip', expect.any(Object));
|
||||||
|
expect(archive.directory).toHaveBeenCalledWith(path.join('/data', 'world'), 'world');
|
||||||
|
expect(archive.finalize).toHaveBeenCalled();
|
||||||
|
expect(out).toBe(archive);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bundles existing player-data files under craftdock/', async () => {
|
||||||
|
const archive = { directory: vi.fn(), file: vi.fn(), finalize: vi.fn(), on: vi.fn() };
|
||||||
|
const archiver = vi.fn().mockReturnValue(archive);
|
||||||
|
const svc = createWorldService({
|
||||||
|
config: { mcDataPath: '/data', mcWorldName: 'world' },
|
||||||
|
dockerService: {},
|
||||||
|
// only allowlist.json exists
|
||||||
|
fs: { access: vi.fn().mockImplementation(async (p) => { if (!p.endsWith('allowlist.json')) throw new Error('ENOENT'); }) },
|
||||||
|
archiver,
|
||||||
|
});
|
||||||
|
await svc.createDownloadStream();
|
||||||
|
expect(archive.file).toHaveBeenCalledWith(path.join('/data', 'allowlist.json'), { name: 'craftdock/allowlist.json' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createWorldService } from '../../src/services/worldService.js';
|
||||||
|
|
||||||
|
function deps() {
|
||||||
|
return {
|
||||||
|
config: { mcDataPath: '/data', mcWorldName: 'world' },
|
||||||
|
dockerService: {
|
||||||
|
getState: vi.fn().mockResolvedValue('running'),
|
||||||
|
stop: vi.fn().mockResolvedValue(),
|
||||||
|
start: vi.fn().mockResolvedValue(),
|
||||||
|
},
|
||||||
|
propertiesService: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
fs: { rm: vi.fn().mockResolvedValue() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('worldService.regen', () => {
|
||||||
|
it('stops, removes world dir, restarts', async () => {
|
||||||
|
const d = deps();
|
||||||
|
const svc = createWorldService(d);
|
||||||
|
const res = await svc.regen();
|
||||||
|
expect(d.dockerService.stop).toHaveBeenCalled();
|
||||||
|
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world'), { recursive: true, force: true });
|
||||||
|
expect(d.dockerService.start).toHaveBeenCalled();
|
||||||
|
expect(res).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stop if already stopped', async () => {
|
||||||
|
const d = deps();
|
||||||
|
d.dockerService.getState.mockResolvedValue('exited');
|
||||||
|
const svc = createWorldService(d);
|
||||||
|
await svc.regen();
|
||||||
|
expect(d.dockerService.stop).not.toHaveBeenCalled();
|
||||||
|
expect(d.dockerService.start).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates level-seed in propertiesService if seed is provided', async () => {
|
||||||
|
const d = deps();
|
||||||
|
const svc = createWorldService(d);
|
||||||
|
await svc.regen('12345');
|
||||||
|
expect(d.propertiesService.update).toHaveBeenCalledWith({ 'level-seed': '12345' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createWorldService } from '../../src/services/worldService.js';
|
||||||
|
|
||||||
|
function deps(extractOk = true) {
|
||||||
|
return {
|
||||||
|
config: { mcDataPath: '/data', mcWorldName: 'world' },
|
||||||
|
dockerService: {
|
||||||
|
getState: vi.fn().mockResolvedValue('running'),
|
||||||
|
stop: vi.fn().mockResolvedValue(),
|
||||||
|
start: vi.fn().mockResolvedValue(),
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
rm: vi.fn().mockResolvedValue(),
|
||||||
|
rename: vi.fn().mockResolvedValue(),
|
||||||
|
mkdir: vi.fn().mockResolvedValue(),
|
||||||
|
readdir: vi.fn().mockResolvedValue(['level.dat']),
|
||||||
|
stat: vi.fn().mockResolvedValue({ isDirectory: () => false }),
|
||||||
|
},
|
||||||
|
// extractZip is injected for testability
|
||||||
|
extractZip: vi.fn().mockImplementation(async () => {
|
||||||
|
if (!extractOk) throw new Error('bad zip');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('worldService.importWorld', () => {
|
||||||
|
it('extracts to temp, swaps, restarts on success', async () => {
|
||||||
|
const d = deps(true);
|
||||||
|
const svc = createWorldService(d);
|
||||||
|
const res = await svc.importWorld('/tmp/upload.zip');
|
||||||
|
expect(d.extractZip).toHaveBeenCalledWith('/tmp/upload.zip', path.join('/data', 'world.import'));
|
||||||
|
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world'), { recursive: true, force: true });
|
||||||
|
expect(d.fs.rename).toHaveBeenCalledWith(path.join('/data', 'world.import'), path.join('/data', 'world'));
|
||||||
|
expect(d.dockerService.start).toHaveBeenCalled();
|
||||||
|
expect(res).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves live world intact and restarts server on bad zip', async () => {
|
||||||
|
const d = deps(false);
|
||||||
|
const svc = createWorldService(d);
|
||||||
|
await expect(svc.importWorld('/tmp/upload.zip')).rejects.toThrow('bad zip');
|
||||||
|
// live world never removed
|
||||||
|
expect(d.fs.rm).not.toHaveBeenCalledWith(path.join('/data', 'world'), expect.anything());
|
||||||
|
expect(d.fs.rename).not.toHaveBeenCalled();
|
||||||
|
// temp cleaned + server restarted
|
||||||
|
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world.import'), { recursive: true, force: true });
|
||||||
|
expect(d.dockerService.start).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hoists nested directory for Java', async () => {
|
||||||
|
const d = deps(true);
|
||||||
|
d.fs.readdir = vi.fn().mockImplementation(async (dir) => {
|
||||||
|
if (dir.endsWith('world.import')) {
|
||||||
|
return ['my_world'];
|
||||||
|
}
|
||||||
|
if (dir.endsWith('my_world') || dir.endsWith('aligned_world')) {
|
||||||
|
return ['level.dat', 'db'];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
d.fs.stat = vi.fn().mockImplementation(async (filePath) => {
|
||||||
|
return { isDirectory: () => filePath.endsWith('my_world') || filePath.endsWith('aligned_world') };
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = createWorldService(d);
|
||||||
|
await svc.importWorld('/tmp/upload.zip');
|
||||||
|
expect(d.fs.rename).toHaveBeenCalledWith(path.join('/data', 'world.import/my_world'), path.join('/data', 'world.import/aligned_world'));
|
||||||
|
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world.import/aligned_world'), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns directory to level-name for Bedrock flat zip', async () => {
|
||||||
|
const d = deps(true);
|
||||||
|
d.config.mcWorldName = 'worlds';
|
||||||
|
d.propertiesService = {
|
||||||
|
read: vi.fn().mockResolvedValue({ 'level-name': 'My Bedrock World' })
|
||||||
|
};
|
||||||
|
d.fs.readdir = vi.fn().mockImplementation(async (dir) => {
|
||||||
|
if (dir.endsWith('worlds.import') || dir.endsWith('worlds.import.temp')) {
|
||||||
|
return ['level.dat', 'db'];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
d.fs.stat = vi.fn().mockResolvedValue({ isDirectory: () => false });
|
||||||
|
|
||||||
|
const svc = createWorldService(d);
|
||||||
|
await svc.importWorld('/tmp/upload.zip');
|
||||||
|
expect(d.fs.mkdir).toHaveBeenCalledWith(path.join('/data', 'worlds.import/My Bedrock World'), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bundled player-data (craftdock/) migration is covered end-to-end in
|
||||||
|
// worldServiceBackup.test.js (real archiver + unzipper round-trip).
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { registerSockets } from '../../src/sockets/index.js';
|
||||||
|
|
||||||
|
describe('registerSockets', () => {
|
||||||
|
it('creates /logs and /status namespaces with auth middleware', () => {
|
||||||
|
const namespaces = {};
|
||||||
|
const io = {
|
||||||
|
of: vi.fn((name) => {
|
||||||
|
const ns = { use: vi.fn(), on: vi.fn() };
|
||||||
|
namespaces[name] = ns;
|
||||||
|
return ns;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
registerSockets(io, {
|
||||||
|
dockerService: {}, appState: {}, sessionMiddleware: (req, res, next) => next(),
|
||||||
|
});
|
||||||
|
expect(io.of).toHaveBeenCalledWith('/logs');
|
||||||
|
expect(io.of).toHaveBeenCalledWith('/status');
|
||||||
|
expect(namespaces['/logs'].use).toHaveBeenCalled();
|
||||||
|
expect(namespaces['/status'].use).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import { registerLogsSocket } from '../../src/sockets/logsSocket.js';
|
||||||
|
|
||||||
|
function fakeNamespace() {
|
||||||
|
const ns = new EventEmitter();
|
||||||
|
ns.on = ns.on.bind(ns);
|
||||||
|
return ns;
|
||||||
|
}
|
||||||
|
function fakeSocket() {
|
||||||
|
const s = new EventEmitter();
|
||||||
|
s.emit = s.emit.bind(s);
|
||||||
|
s.emitted = [];
|
||||||
|
const origEmit = s.emit;
|
||||||
|
s.emit = (ev, payload) => { s.emitted.push([ev, payload]); return origEmit(ev, payload); };
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('logsSocket', () => {
|
||||||
|
it('streams log chunks to the client', async () => {
|
||||||
|
const logStream = new PassThrough();
|
||||||
|
const dockerService = { logStream: vi.fn().mockResolvedValue(logStream) };
|
||||||
|
const appState = { getAdapter: vi.fn().mockResolvedValue({ sendCommand: vi.fn() }) };
|
||||||
|
const ns = fakeNamespace();
|
||||||
|
registerLogsSocket(ns, { dockerService, appState });
|
||||||
|
|
||||||
|
const socket = fakeSocket();
|
||||||
|
ns.emit('connection', socket);
|
||||||
|
await new Promise((r) => setTimeout(r, 10)); // allow async logStream()
|
||||||
|
|
||||||
|
logStream.write('hello world');
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
|
||||||
|
const logs = socket.emitted.filter(([ev]) => ev === 'log').map(([, p]) => p);
|
||||||
|
expect(logs.join('')).toContain('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes command events to adapter.sendCommand', async () => {
|
||||||
|
const logStream = new PassThrough();
|
||||||
|
const sendCommand = vi.fn().mockResolvedValue('command result');
|
||||||
|
const dockerService = { logStream: vi.fn().mockResolvedValue(logStream) };
|
||||||
|
const appState = { getAdapter: vi.fn().mockResolvedValue({ sendCommand }) };
|
||||||
|
const ns = fakeNamespace();
|
||||||
|
registerLogsSocket(ns, { dockerService, appState });
|
||||||
|
|
||||||
|
const socket = fakeSocket();
|
||||||
|
ns.emit('connection', socket);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
socket.emit('command', 'list');
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
|
||||||
|
expect(sendCommand).toHaveBeenCalledWith('list');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { buildStatusPayload } from '../../src/sockets/statusSocket.js';
|
||||||
|
|
||||||
|
describe('buildStatusPayload', () => {
|
||||||
|
it('aggregates state, stats and players when running', async () => {
|
||||||
|
const dockerService = {
|
||||||
|
inspect: vi.fn().mockResolvedValue({ state: 'running', type: 'PAPER' }),
|
||||||
|
stats: vi.fn().mockResolvedValue({ cpuPct: 5, memUsedMb: 256, memPct: 25 }),
|
||||||
|
};
|
||||||
|
const appState = {
|
||||||
|
getAdapter: vi.fn().mockResolvedValue({
|
||||||
|
listPlayers: vi.fn().mockResolvedValue({ online: 2, max: 20, players: ['a', 'b'] }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const payload = await buildStatusPayload({ dockerService, appState });
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
state: 'running', cpuPct: 5, memPct: 25,
|
||||||
|
players: { online: 2, max: 20, players: ['a', 'b'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zeros and empty players when not running', async () => {
|
||||||
|
const dockerService = {
|
||||||
|
inspect: vi.fn().mockResolvedValue({ state: 'exited', type: 'PAPER' }),
|
||||||
|
stats: vi.fn(),
|
||||||
|
};
|
||||||
|
const appState = { getAdapter: vi.fn() };
|
||||||
|
const payload = await buildStatusPayload({ dockerService, appState });
|
||||||
|
expect(payload.state).toBe('exited');
|
||||||
|
expect(payload.players).toEqual({ online: 0, max: 0, players: [] });
|
||||||
|
expect(dockerService.stats).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['test/**/*.test.js'],
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user