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