diff --git a/README.md b/README.md index 77f2f6f..7362282 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@
![license](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square) -![version](https://img.shields.io/badge/version-1.1.0-brightgreen?style=flat-square) +![version](https://img.shields.io/badge/version-1.2.0--beta.7-orange?style=flat-square) ![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord) diff --git a/docs/API.md b/docs/API.md index 69585ee..a35075b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -83,6 +83,8 @@ Completion is signalled over the WebSocket as an `operation` message (see [WebSo Allowed lifecycle actions: **start** from `stopped`/`crashed`; **stop** from `running`/`starting`; **restart** from `running`; **kill** from `running`/`starting`/`stopping`. +> **Provisioning is exclusive.** A server created, imported, duplicated or built from a modpack stays `provisioning` until its directory is fully assembled, and can only leave that state for `stopped` or `crashed`. Backups, restores, jar upgrades, restarts, and the settings/properties restore-point saves all reject with `409 {"error": "Wait for the server to finish provisioning."}` until it clears — `stopFirst` does not override this. Poll `GET /servers/:id` or watch the WebSocket `state` message to know when it is ready. + ## Servers @@ -121,6 +123,20 @@ The server object returned by these endpoints contains the full configuration (n | POST | `/servers/:id/kill` | Force-kill the process | | POST | `/servers/:id/command` | Send a console line. Body: `{command}`. `409` if not running | +### Console + +The [WebSocket](#websocket-protocol) is the live feed, but it does not accept bearer keys — this is how an API-key client reads console output. It pairs with `POST /servers/:id/command`, which sends a line but returns nothing of the reply. + +| Method | Path | Description | +|---|---|---| +| GET | `/servers/:id/console?limit=&source=` | Recent console output, oldest first. Returns `{"source": "file"\|"memory", "truncated": bool, "lines": [{timestamp, line}]}`. `limit` 1–1000 (default 200); `truncated` means older output exists beyond what was returned | + +`source` selects where the output comes from, and the two differ: + +- **`file`** — `logs/craftbox-console.log` in the server directory. Durable, timestamped, survives a panel restart, and is what you want for automation. Append-only and never rotated, so reads are tailed from the end. +- **`memory`** — the live process buffer. A few hundred lines at most, `timestamp` is always `null`, and it is discarded whenever the process object is rebuilt (which includes every start of a stopped server). It does hold the handful of `[Craftbox] ...` lines emitted after the log stream closes on exit, which never reach disk. +- **`auto`** (default) — `file`, falling back to `memory` for a server that has never been started on this install. + ### Settings | Method | Path | Description | @@ -129,11 +145,47 @@ The server object returned by these endpoints contains the full configuration (n | POST | `/servers/:id/group` | Assign the dashboard group. Body: `{group}` (empty/null to ungroup). Returns `{"group": ..., "color": ...}` — `color` is the group's folder color (null when ungrouped) | | POST | `/servers/:id/autorestart` | Body: `{enabled: bool}`. Returns `{"autoRestart": bool}` | | POST | `/servers/:id/autostart` | Body: `{enabled: bool}`. Returns `{"autoStart": bool}` | -| POST | `/servers/:id/statuspublic` | Toggle the public status page. Body: `{enabled: bool}` | +| POST | `/servers/:id/statuspublic` | Toggle listing on the `/status` index. Body: `{enabled: bool}`. Does **not** gate direct access — see [Public status endpoints](#public-status-endpoints) | | POST | `/servers/:id/advertisedip` | Set the address shown on the status page. Body: `{value}` | | POST | `/servers/:id/motd` | Set the MOTD. Body: `{motd}` | | POST | `/servers/:id/properties` | Update `server.properties`. Body: an object keyed by property name, plus an optional `backup` flag (reserved — never written as a property). With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead of `{"success": true}` | -| POST | `/servers/:id/edit-file` | Save a text file inside the server directory. Body: `{filePath, content}`. `403` on path traversal, `400` for non-text extensions | +| POST | `/servers/:id/edit-file` | Save a text file inside the server directory. Body: `{filePath, content}`. `403` on path traversal, `400` if the target is not text (see [Text vs binary](#files)) | + +### Files + +Paths are relative to the server directory and are resolved against it with symlinks fully resolved — anything landing outside returns `403 {"error": "Access denied."}`. + +| Method | Path | Description | +|---|---|---| +| GET | `/servers/:id/files?path=` | List a directory (`path` omitted = server root). Returns `{"path", "files": [{name, isDirectory, size, sizeFormatted, modified, modifiedISO, editable}]}`, directories first then by name. `editable` marks files the editor will open in one piece — text **and** within the 5 MB limit; a larger text file lists as `editable: false` but is still readable in windows via `/file` | +| GET | `/servers/:id/file?path=` | Read a text file. **Works while the server is running** — unlike `/download` — which makes it the way to read a log or a feed a plugin is still appending to. Returns `{"file": {name, path, size, modifiedISO, offset, length, truncated, content}}`, where `size` is the whole file and `offset`/`length` describe the bytes returned. `400` if the file is not text (use `/download`), `413` if it is over 5 MB and no window was requested | +| GET | `/servers/:id/download?path=` | Stream any single file as `application/octet-stream`. Requires the server `stopped`/`crashed` (`409` otherwise), since a running server holds handles on world data and jars; a read that fails mid-stream with `EBUSY` also returns `409` | +| POST | `/servers/:id/files/upload` | Upload file(s) into a directory. Multipart, any field names, plus a `path` text field naming the destination directory (omitted = server root) — on the multipart path it must precede the files in the stream. Any file type, no size cap (bounded by disk space). An existing file of the same name is **overwritten**; a name already taken by a folder is rejected, as is one that would replace a file a running server holds open (`reason: "file is in use by the server"`). Returns `{"success": true, "count", "uploaded": [...], "replaced": , "rejected": [{name, reason}]}`. `404` if the destination is not a directory. Also accepts [chunked uploads](#chunked-uploads-dgup) (one file per session) at `/servers/:id/files/upload/*` | +| POST | `/servers/:id/files/mkdir` | Create a directory. Body: `{path, name}` — `path` is the parent (omitted = server root). `409` if the name is taken | +| POST | `/servers/:id/files/mkfile` | Create an empty file. Body: `{path, name}` — `path` is the parent directory (omitted = server root). Any extension; an existing file is never truncated — `409` if the name is taken | +| POST | `/servers/:id/files/rename` | Rename a file or directory in place. Body: `{path, newName}`. Requires the server `stopped`/`crashed` (`409` otherwise). `409` if the new name is taken, or if the entry is held open by the server; changing only the letter case is allowed | +| POST | `/servers/:id/files/delete` | Delete a file, or a directory and everything inside it. Body: `{path}`. Requires the server `stopped`/`crashed` (`409` otherwise); `409` if the entry is held open by the server. `400` for the server directory itself | + +> **Text vs binary is decided by content, not by extension.** There is no list of readable extensions to keep up with: a file is text if its first 8 KB decode as UTF-8, contain no NUL byte, and are not mostly control characters. So `.jsonl`, `.json5`, a mod's own invented config extension and a name with no extension at all all open, without anyone having to add them anywhere. Two shortcuts sit either side of that check — always-binary extensions (`.jar .zip .png .dat .nbt .mca .mrpack .exe .db`, and the rest of the usual archive/image/media/compiled set) are refused without a read, so listing a `mods/` folder stays cheap; and when there is nothing to read at all — the path does not exist yet, or the running server holds it locked — a list of known text extensions stands in. +> +> The content check also catches the reverse case: a UTF-16 or latin-1 file wearing a `.txt` is refused, because the panel reads and writes UTF-8 throughout and would show it as mojibake and mangle it on save. `.nbt` and `.dat` are refused for the same reason — they are gzipped binary, and earlier versions wrongly offered them for editing. + +> **Reading a file larger than 5 MB.** `/file` returns the whole file up to 5 MB and `413` past it. Beyond that, ask for a byte window with **`?tail=`** (last N bytes) or **`?offset=`&`limit=`** (explicit window) — the two forms are mutually exclusive, and both are byte counts, not lines or characters. A window is clamped to 5 MB and to the file's actual length, so an over-large ask returns short rather than failing, and `truncated` in the response says whether anything was left out. A window landing mid-character is trimmed back to a whole one, so `content` never contains a replacement character from the cut; `offset` reports where the returned bytes actually start after that trim. +> +> ``` +> GET /servers/:id/file?path=exchange/telemetry.jsonl&tail=65536 +> → {"file": {"size": 41203847, "offset": 41138311, "length": 65530, "truncated": true, "content": "..."}} +> ``` +> +> The editor UI never takes a window: it posts the whole textarea back, so opening a partial file would truncate the rest away on save. It refuses oversized files outright and points at the download instead. + +> **Creating is ungated, destroying is not.** Upload, mkdir and mkfile work in any server state, matching `/edit-file`, which already writes into a running server's directory. Rename and delete require the server stopped: they are the destructive pair, and a running server holds open handles. Uploading, creating or deleting `server.properties` or `eula.txt` in the server root re-syncs the mirrored database fields, exactly as `/edit-file` does. +> +> **Replacing what a running server holds open is the one upload that is gated.** While a server is not `stopped`/`crashed`, an upload that would overwrite its jar, or any existing file under its world folders, `logs/`, or `mods/`/`plugins/`, is rejected per-file with `reason: "file is in use by the server"` — the rest of the batch still lands. Windows fails that write with `EBUSY` anyway; Linux does not, and would silently corrupt a live server. New files in those folders are unaffected: nothing can hold a handle on a name that isn't there yet. +> +> New names supplied to `rename`, `mkdir` and `mkfile` must be a single path segment. A name is rejected (`400`) if it contains a slash or backslash, contains `< > : " | ? *` or a control character, ends in a dot, is `.` or `..`, is longer than 255 characters, or is a reserved device name (`CON`, `NUL`, `COM1`…) — the last few would fail confusingly at the filesystem layer, on Windows now or after an export/import later. Leading and trailing whitespace is trimmed rather than rejected, so `"notes.txt "` creates `notes.txt`. +> +> The slash rule is a rejection, not a rewrite: `sub/notes.txt` returns `400` rather than quietly creating `notes.txt` in the current folder. Create the directory first, then the file inside it. This differs from **upload**, where a name is reduced to its last segment on purpose — a browser sends a whole relative path as the filename when a folder is dropped in, and only the basename is meaningful there. ### Restore-point backups @@ -161,9 +213,11 @@ The response is `202 {"success": true, "status": "started"}` instead of the endp | Method | Path | Description | |---|---|---| -| GET | `/servers/:id/check-upgrade` | Returns `{"upgradeAvailable": bool, "currentBuild", "latestBuild", "channel", ...}` — `latestBuild` is the newest *stable* build where the version has stable builds, so stable servers are never offered alpha/beta builds | +| GET | `/servers/:id/check-upgrade` | Returns `{"upgradeAvailable": bool, "currentBuild", "latestBuild", "channel", "reason"?}` — `latestBuild` is the newest *stable* build where the version has stable builds, so stable servers are never offered alpha/beta builds. A server with no recorded build (`currentBuild: null`) reports `upgradeAvailable: true` with a `reason`: upgrading is what records a build. `reason` is also set, with `upgradeAvailable: false`, when the type has no build tracking (`custom`, `vanilla`) or the version has no published builds | | POST | `/servers/:id/upgrade-jar` | Download the newer build. Body: `{version?, jarUrl?, backup?}` — `version` upgrades a tracked server to that version in the same operation (upgrade-only, same downgrade rules as `/edit`); `jarUrl` (custom servers only — required there, ignored otherwise) replaces the jar from a new http/https URL, downloading to a sidecar so a failed fetch leaves the old jar intact; `backup: true` creates a backup first (state passes through `backing_up`, then `upgrading_jar`; `409` if a backup is already in progress). Returns `202`; `409` if running. Completes via WS `operation: "jar-upgrade"` with a payload of `{build, version}` | +> **`build` is not one type.** Paper, Purpur and Folia report an integer build number; Forge, NeoForge and Fabric report a dotted version string (Fabric's is its loader version, which is what a modpack pins). Compare builds segment-wise rather than lexically — `"21.1.100"` is newer than `"21.1.95"`. `vanilla` and `custom` servers have no build at all. + ## Backups @@ -175,8 +229,7 @@ The response is `202 {"success": true, "status": "started"}` instead of the endp | DELETE | `/servers/:id/backups/:backupId` | Delete a backup | | POST | `/servers/:id/backup-schedule` | Body: `{enabled, intervalHours (1–168), countdownMinutes (1–30)}`. Returns `{"backupSchedule": {...}, "nextBackupAt": ...}` | | POST | `/servers/:id/backup-retention` | Body: `{retentionCount (0–100), retentionDays (0–365)}` (0 = unlimited) | - -> Backup archive downloads are served by the browser-facing panel route `GET /servers/:id/backups/:backupId/download` (session auth, outside `/api/v1`). +| GET | `/servers/:id/backups/:backupId/download` | Stream the backup archive as `application/zip`. `404` if the backup does not belong to this server | ## Server transfer @@ -185,7 +238,7 @@ Move a server — files, Craftbox settings, and optionally backups and event his ### Export -`GET /servers/:id/export?backups=true&events=true&start=true` (browser-facing panel route, session auth, outside `/api/v1`) streams the download as `.cbx` with `Content-Type: application/x-craftbox-export+zip`. The server must be `stopped` or `crashed`. Query flags (`true` to enable): `backups` and `events` select the optional payloads; `start` starts the server once the archive has finished streaming (used by the panel's "Start server after export" option). +`GET /servers/:id/export?backups=true&events=true&start=true` streams the download as `.cbx` with `Content-Type: application/x-craftbox-export+zip`. The server must be `stopped` or `crashed` (`409` otherwise). Query flags (`true` to enable): `backups` and `events` select the optional payloads; `start` starts the server once the archive has finished streaming (used by the panel's "Start server after export" option). Requesting `backups` holds the backup lock for the duration, so a scheduled backup cannot write a partial archive into the export; `409` if a backup is already running. > **`.cbx` is Craftbox's transfer-archive extension.** The container is an ordinary zip, so any zip tool can open one for inspection — only the extension and media type are Craftbox-specific. Import requires the `.cbx` extension but never trusts it: the upload is also checked against the zip magic bytes and must carry a valid `craftbox-manifest.json`, so renaming an arbitrary zip to `.cbx` is still rejected. @@ -224,11 +277,12 @@ Each upload endpoint exposes a DGUP sub-resource: /servers/from-mrpack/upload/{init,chunk,complete,cancel} /servers/:id/icon/upload/{init,chunk,complete,cancel} /servers/:id/plugins/upload/{init,chunk,complete,cancel} (one file per session) +/servers/:id/files/upload/{init,chunk,complete,cancel} (one file per session) ``` All four are `POST` and require the same auth (and, for session auth, `X-CSRF-Token`) as the parent endpoint. -> `/servers/from-mrpack` takes form fields alongside the file (`name`, `port`, …). On the chunked path, send them as additional keys in the `complete` request body — the handler sees the same fields either way. +> `/servers/from-mrpack` takes form fields alongside the file (`name`, `port`, …). On the chunked path, send them as additional keys in the `complete` request body — the handler sees the same fields either way. `/servers/:id/files/upload` takes its destination `path` the same way — which is why `init` cannot pre-validate the destination directory for that endpoint, only that the server exists. ### Lifecycle @@ -285,12 +339,14 @@ Only `started`, `stopped`, `crashed` and `restarted` are exposed on public statu ## Plugins & mods -The server must be `stopped` or `crashed` for all of these. +Reads work in any state. The **mutating** routes require the server to be `stopped` or `crashed`. All of these `404` on server types with no plugin/mod folder (`vanilla`, `custom`). | Method | Path | Description | |---|---|---| -| POST | `/servers/:id/plugins/upload` | Upload jar(s). Multipart, any field names, `.jar` only, no size cap (bounded by disk space); files are verified to be real zip archives. Returns `{"success": true, "count", "uploaded": [...], "rejected": [{name, reason}]}`. Also accepts [chunked uploads](#chunked-uploads-dgup) (one jar per session) at `/servers/:id/plugins/upload/*` | -| POST | `/servers/:id/plugins/delete` | Body: `{filename}` | +| GET | `/servers/:id/plugins` | List installed plugins/mods. Returns `{"contentType": {label, folder}, "files": [{name, size, sizeFormatted, modifiedISO, environment}]}` — `label` is `Plugins` (Paper/Purpur/Folia) or `Mods` (Fabric/Forge/NeoForge), and `environment` is always `both` for plugin loaders. Empty `files` when the folder does not exist yet | +| GET | `/servers/:id/plugins/environment` | Mod-loader servers only (`400` otherwise). Returns `{"environment": {".jar": "client"\|"server"}}`. Only non-default entries are stored, so a mod absent from the map is `both` | +| POST | `/servers/:id/plugins/upload` | Upload jar(s). Multipart, any field names, `.jar` only, no size cap (bounded by disk space); files are verified to be real zip archives. An existing copy is overwritten, including a `.jar.disabled` one (which is removed, and the mod's environment tag reset to `both` — uploading is an explicit "put this on the server"). Returns `{"success": true, "count", "uploaded": [...], "replaced": , "rejected": [{name, reason}]}`. Also accepts [chunked uploads](#chunked-uploads-dgup) (one jar per session) at `/servers/:id/plugins/upload/*` | +| POST | `/servers/:id/plugins/delete` | Body: `{filename}`. Removes both on-disk forms (`.jar` and `.jar.disabled`), since one listed mod can stand for either | | POST | `/servers/:id/plugins/delete-all` | Delete all plugins/mods | | POST | `/servers/:id/plugins/environment` | Mod-loader servers only. Body: `{filename, environment}` where environment is `client`, `server`, or `both`. Client-only mods are disabled on the server but still offered on the status page mods download | @@ -354,23 +410,25 @@ Session auth **only** — bearer tokens are rejected with `403 {"error": "sessio ## Public status endpoints -Unauthenticated, mounted at the site root (not `/api/v1`). Only servers with the public status page enabled are exposed. +Unauthenticated, mounted at the site root (not `/api/v1`). The `statusPagePublic` flag controls **listing only** — it decides whether a server appears in the `/status` index. An individual server's status page, its JSON, and its mods zip are reachable by anyone holding the server's UUID regardless of that flag. | Method | Path | Description | |---|---|---| -| GET | `/status` | HTML index of public servers | +| GET | `/status` | HTML index of servers with the public status page enabled | | GET | `/status/:id` | HTML status page for one server | | GET | `/status/:id/api` | JSON: `{"server": {id, name, state, port, version, serverType, playerCount, players, uptime, uptimeFormatted, statusPagePublic, advertisedIp}}` | | GET | `/status/:id/mods` | Zip of client-facing mods; `404` if none | Public responses are sanitized: internal states (`provisioning`, `backing_up`, `restoring`, `upgrading_jar`) are reported as `stopped`, and crash details, file paths, and JVM configuration are never exposed. +> **Note:** unauthenticated `GET` access to these per-server endpoints is intentional, not a security gap. The server UUID *is* the capability token — that is what lets you hand a status link or a client-mods download to players who have no panel account, and keeps that link working. Guessing a v4 UUID is not a practical attack, and the payloads are sanitized as described above: server-only mods are excluded from the zip, and no file paths, JVM configuration, or crash details are ever exposed. Automated scanners sometimes flag these routes as "unauthenticated data exposure"; treat that as a false positive. If you do not want a server reachable this way at all, do not distribute its UUID — there is no per-server toggle that disables the direct link, because share links are the feature. + ## WebSocket protocol The WebSocket shares the panel's HTTP port (`ws://:6464/` or `wss://` behind TLS). -- **Authenticated socket** — connect to the root path with a valid **session cookie**. Bearer API keys are **not** accepted on the WebSocket; the upgrade is rejected with `401` when no session exists. +- **Authenticated socket** — connect to the root path with a valid **session cookie**. Bearer API keys are **not** accepted on the WebSocket; the upgrade is rejected with `401` when no session exists. Bearer clients should poll [`GET /servers/:id/console`](#console) instead. - **Public socket** — connect to `/ws/status` (no auth). Receives the sanitized subset only: no console history/output, public state mapping, crash messages reduced to "Server crashed". The server pings every 30 seconds and drops sockets that miss a pong. diff --git a/package-lock.json b/package-lock.json index f7de3f5..0b704d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { "name": "craftbox", - "version": "1.1.0", + "version": "1.2.0-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftbox", - "version": "1.1.0", + "version": "1.2.0-beta.7", "license": "AGPL-3.0-only", "dependencies": { "archiver": "^7.0.1", "bcrypt": "^6.0.0", - "better-sqlite3": "^13.0.1", + "better-sqlite3": "^13.0.2", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", "content-disposition": "^1.0.1", "ejs": "^6.0.1", "express": "^5.2.1", - "express-rate-limit": "^8.6.0", + "express-rate-limit": "^8.6.1", "express-session": "^1.19.0", "material-icons": "^1.13.14", "multer": "^2.1.1", @@ -25,7 +25,7 @@ "passport": "^0.7.0", "passport-local": "^1.0.0", "quick.db": "^9.1.7", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "uuid": "^14.0.1", "ws": "^8.21.1" }, @@ -35,9 +35,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -54,9 +54,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -66,19 +66,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -88,19 +88,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -114,9 +133,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -130,9 +149,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -149,9 +168,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -168,9 +187,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -187,9 +206,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -206,9 +225,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -225,9 +244,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -244,9 +263,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -263,9 +282,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -282,9 +301,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -297,19 +316,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -322,19 +341,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -347,19 +366,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -372,19 +391,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -397,19 +416,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -422,19 +441,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -447,19 +466,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -472,38 +491,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -513,16 +548,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -532,16 +567,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -551,7 +586,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -833,9 +868,9 @@ } }, "node_modules/better-sqlite3": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz", - "integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -902,9 +937,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1362,9 +1397,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -1657,10 +1692,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "engines": { "node": ">= 12" } @@ -2353,47 +2387,52 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { diff --git a/package.json b/package.json index 77f4589..b462928 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbox", - "version": "1.1.0", + "version": "1.2.0-beta.7", "description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.", "main": "src/server.js", "funding": { @@ -31,13 +31,13 @@ "dependencies": { "archiver": "^7.0.1", "bcrypt": "^6.0.0", - "better-sqlite3": "^13.0.1", + "better-sqlite3": "^13.0.2", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", "content-disposition": "^1.0.1", "ejs": "^6.0.1", "express": "^5.2.1", - "express-rate-limit": "^8.6.0", + "express-rate-limit": "^8.6.1", "express-session": "^1.19.0", "material-icons": "^1.13.14", "multer": "^2.1.1", @@ -45,13 +45,13 @@ "passport": "^0.7.0", "passport-local": "^1.0.0", "quick.db": "^9.1.7", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "uuid": "^14.0.1", "ws": "^8.21.1" }, "allowScripts": { "bcrypt@6.0.0": true, - "better-sqlite3@13.0.1": true, - "sharp@0.34.5": true + "better-sqlite3@13.0.2": true, + "sharp@0.35.3": true } } diff --git a/public/js/account.js b/public/js/account.js index 5fc271c..13d0a8e 100644 --- a/public/js/account.js +++ b/public/js/account.js @@ -1,6 +1,4 @@ document.addEventListener('DOMContentLoaded', function () { - var csrfToken = document.querySelector('input[name="_csrf"]').value; - // ═══════════════════════════════════════════ // Change Username / Password // ═══════════════════════════════════════════ @@ -105,15 +103,11 @@ document.addEventListener('DOMContentLoaded', function () { showOverlay('Generating key...', 'Please wait while the key is created.'); try { - var res = await fetch('/api/v1/account/apikeys', { + var res = await apiFetch('/api/v1/account/apikeys', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrfToken - }, - body: JSON.stringify({ name: name }) + body: { name: name } }); - var data = await res.json().catch(function () { return {}; }); + var data = res.data || {}; if (!res.ok) { hideOverlay(); confirmCreateBtn.disabled = false; @@ -186,13 +180,12 @@ document.addEventListener('DOMContentLoaded', function () { showOverlay('Deleting key...', 'Please wait while the key is removed.'); try { - var res = await fetch('/api/v1/account/apikeys/' + encodeURIComponent(pendingDeleteId), { - method: 'DELETE', - headers: { 'X-CSRF-Token': csrfToken } + var res = await apiFetch('/api/v1/account/apikeys/' + encodeURIComponent(pendingDeleteId), { + method: 'DELETE' }); if (!res.ok && res.status !== 204) { - var data = await res.json().catch(function () { return {}; }); + var data = res.data || {}; hideOverlay(); confirmDeleteBtn.disabled = false; showToast(data.message || data.error || 'Failed to delete key.', 'danger'); diff --git a/public/js/app.js b/public/js/app.js index 869cde6..2337031 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -39,9 +39,42 @@ async function apiFetch(path, options) { if (res.status !== 204) { try { data = await res.json(); } catch (_) { data = null; } } + if (res.status === 401) _handleSessionExpired(); return { ok: res.ok, status: res.status, data: data }; } +// ── Session expiry ── +// Sessions are a 1-hour rolling idle timeout, so a tab left open overnight is +// signed out without anything on screen saying so. Every frontend call goes to +// /api/v1, which is guarded by ensureApiAuth ahead of CSRF validation, so an +// expired session is always a clean 401 — whose bare {error:'unauthorized'} +// body would otherwise reach the user as an unexplained "unauthorized" toast. +// Explain it instead and send them to sign in; ensureAuth's returnTo brings +// them back to the page they were on. +// The latch matters: pages fire several calls at once, and without it each one +// queues its own toast and races its own redirect. +var _sessionExpiredHandled = false; +function _handleSessionExpired() { + if (_sessionExpiredHandled) return; + if (window.location.pathname === '/login') return; + _sessionExpiredHandled = true; + flashToast('Your session has expired. Please sign in again.', 'warning'); + window.location.href = '/login'; +} + +// The server rejects a WebSocket upgrade from an expired session with a 401, +// but browsers hide the handshake status from JS — all a client sees is a close +// with code 1006, identical to a network blip. So once a socket has failed to +// reconnect a few times, spend one cheap authenticated request to find out +// which it is: a 401 routes into the handling above, anything else means the +// panel is simply unreachable and the existing backoff should carry on. +// Called from every reconnect loop; probes at the 3rd failure and every 3rd +// after, which the 30s backoff cap keeps to at most one probe per 90s. +function probeSessionAfterFailures(attempts) { + if (attempts < 3 || attempts % 3 !== 0) return; + apiFetch('/api/v1/servers'); +} + // ── Client-side date formatting ── // Formats an ISO string to the user's local date/time. // style: 'datetime' (default) = full date+time, 'date' = date only @@ -56,6 +89,18 @@ function formatDate(isoString, style) { }); } +// Formats a Date as a short relative age: "just now", "5m ago", "2h ago", "3d ago". +function timeAgo(date) { + var seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 60) return 'just now'; + var minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + 'm ago'; + var hours = Math.floor(minutes / 60); + if (hours < 24) return hours + 'h ago'; + var days = Math.floor(hours / 24); + return days + 'd ago'; +} + // Auto-format all .format-date elements on page load document.querySelectorAll('.format-date[data-iso]').forEach(function (el) { el.textContent = formatDate(el.dataset.iso, el.dataset.style); @@ -209,6 +254,110 @@ function guardFileInput(input, extensions, message) { }); } +// ── Live state gating ── +// Controls that require a stopped server used to be gated once, server-side, at +// render time. The page then receives live state over the WebSocket, so the gate +// froze at whatever the state was when the page loaded: stop a server and the +// upload button stayed dead until a manual reload. +// +// Mark a control `data-enable-when="stopped crashed"` and it tracks the live +// state. `data-show-when` / `data-hide-when` toggle `.d-none` on the same basis +// — use them for the explanatory alerts that accompany a gate. +// Optional `data-disabled-title` / `data-enabled-title` swap the tooltip. +// +// The live state is read from #server-nav-header's data-state, which both +// WebSocket owners (serverState.js and console.js) write on every update. +function currentServerState() { + var el = document.getElementById('server-nav-header'); + return (el && el.dataset.state) || ''; +} + +function isServerStopped(state) { + return ['stopped', 'crashed'].indexOf(state || currentServerState()) !== -1; +} + +function applyStateGates(state) { + state = state || currentServerState(); + + document.querySelectorAll('[data-enable-when]').forEach(function (el) { + var ok = el.dataset.enableWhen.split(/\s+/).indexOf(state) !== -1; + if ('disabled' in el) { + el.disabled = !ok; + } else { + // Anchors have no disabled property. Bootstrap's .disabled kills + // pointer events on .btn; the attributes keep it out of the tab + // order and announce the state. + el.classList.toggle('disabled', !ok); + el.setAttribute('aria-disabled', String(!ok)); + if (ok) el.removeAttribute('tabindex'); + else el.setAttribute('tabindex', '-1'); + } + var title = ok ? el.dataset.enabledTitle : el.dataset.disabledTitle; + if (title !== undefined) el.title = title; + }); + + document.querySelectorAll('[data-show-when]').forEach(function (el) { + el.classList.toggle('d-none', el.dataset.showWhen.split(/\s+/).indexOf(state) === -1); + }); + + document.querySelectorAll('[data-hide-when]').forEach(function (el) { + el.classList.toggle('d-none', el.dataset.hideWhen.split(/\s+/).indexOf(state) !== -1); + }); + + // Pages with bespoke gating (button labels, request payloads) listen for + // this rather than duplicating the attribute walk. + document.dispatchEvent(new CustomEvent('craftbox:stategates', { detail: { state: state } })); +} + +document.addEventListener('craftbox:state', function (e) { + applyStateGates((e.detail && e.detail.state) || currentServerState()); +}); + +// Server-rendered markup is already correct on load; this only matters for +// elements whose gate attributes were added without a matching server-side +// render, and it keeps the two paths from drifting. +applyStateGates(); + +// ── Lock every control inside a container during an async operation ── +// Buttons that dismiss a modal are deliberately left enabled: the upload flows +// wire `hide.bs.modal` to abort the transfer, so Cancel / X / Esc must stay +// reachable while everything else is frozen. +// Forms are marked [data-busy] so the required-field validator below cannot +// re-enable the submit button out from under the lock. +// Unlocking re-enables every control, so callers that derive a button's state +// from validation should re-run that check afterwards. +function setControlsLocked(root, locked) { + if (!root) return; + root.querySelectorAll('input, select, textarea, button:not([data-bs-dismiss="modal"])') + .forEach(function (el) { el.disabled = locked; }); + + var forms = Array.prototype.slice.call(root.querySelectorAll('form')); + if (root.tagName === 'FORM') forms.push(root); + forms.forEach(function (form) { + if (locked) form.setAttribute('data-busy', ''); + else form.removeAttribute('data-busy'); + }); +} + +// ── Centre form fields left alone on their row ── +// A .row down to one visible column renders as a lopsided half-width field +// pinned to the left edge: the create form's port field once modpack mode +// hides the version picker, or Assign Group, which sits alone by design. +// Centre those, and un-centre again if a sibling column comes back — callers +// with columns that appear and disappear re-run this as the layout changes. +// `root` scopes it to one form; every other row on the page is left alone. +function centerLoneRowItems(root) { + if (!root) return; + root.querySelectorAll('.row').forEach(function (row) { + var cols = row.querySelectorAll(':scope > [class*="col-"]'); + if (cols.length === 0) return; + var visible = Array.prototype.filter.call(cols, function (c) { + return !c.classList.contains('d-none'); + }); + row.classList.toggle('justify-content-center', visible.length === 1); + }); +} + // ── Required field validation — disable submit until all required fields are filled ── // Applies to any
with a [data-validate-required] submit button inside it. // The button stays disabled/muted until every [required] input in the form has a value. @@ -219,6 +368,9 @@ function guardFileInput(input, extensions, message) { if (!form) return; function check() { + // A busy form is locked by setControlsLocked — leave its submit + // button alone or an incidental input/change event unlocks it. + if (form.hasAttribute('data-busy')) return; var fields = form.querySelectorAll('[required]'); var allFilled = true; fields.forEach(function (f) { diff --git a/public/js/backups.js b/public/js/backups.js index c095070..36c3ae4 100644 --- a/public/js/backups.js +++ b/public/js/backups.js @@ -58,11 +58,8 @@ document.addEventListener('craftbox:operation', handleOperation); function resetBackupButton() { - var btn = document.getElementById('confirm-backup-btn'); - if (btn) { - btn.disabled = false; - btn.textContent = needsStop ? 'Stop & Backup' : 'Create Backup'; - } + if (confirmBackupBtn) confirmBackupBtn.disabled = false; + refreshBackupButton(); } function resetRestoreButton() { var btn = document.getElementById('confirm-restore-btn'); @@ -77,10 +74,25 @@ var createBackupBtn = document.getElementById('create-backup-btn'); var backupForm = document.getElementById('backup-form'); var backupNameInput = document.getElementById('backupName'); - var backupStartAfterInput = document.getElementById('backupStartAfter'); var startAfterBackupCheckbox = document.getElementById('startAfterBackup'); - var stopFirstInput = document.getElementById('backupStopFirst'); - var needsStop = stopFirstInput && stopFirstInput.value === 'true'; + var confirmBackupBtn = document.getElementById('confirm-backup-btn'); + + // Whether a backup has to stop the server first depends on the state at the + // moment you press the button, not the state the page was rendered with. + function needsStopNow() { + return !isServerStopped(); + } + + // Keep the confirm button honest as the state changes underneath the page. + function refreshBackupButton() { + if (!confirmBackupBtn) return; + var stop = needsStopNow(); + confirmBackupBtn.classList.toggle('btn-warning', stop); + confirmBackupBtn.classList.toggle('btn-success', !stop); + confirmBackupBtn.textContent = stop ? 'Stop & Backup' : 'Create Backup'; + } + document.addEventListener('craftbox:stategates', refreshBackupButton); + refreshBackupButton(); if (createBackupBtn) { createBackupBtn.addEventListener('click', function () { @@ -97,11 +109,6 @@ }); } - if (startAfterBackupCheckbox && backupStartAfterInput) { - startAfterBackupCheckbox.addEventListener('change', function () { - backupStartAfterInput.value = startAfterBackupCheckbox.checked ? 'true' : 'false'; - }); - } if (backupForm) { backupForm.addEventListener('submit', async function (e) { @@ -114,7 +121,8 @@ btn.innerHTML = ' Creating...'; } createBackupModal.hide(); - var overlayTitle = needsStop ? 'Stopping server & creating backup...' : 'Creating backup...'; + var stopFirst = needsStopNow(); + var overlayTitle = stopFirst ? 'Stopping server & creating backup...' : 'Creating backup...'; showOverlay(overlayTitle, 'Compressing server files. This may take a moment.'); var name = backupNameInput ? backupNameInput.value.trim() : 'Manual Backup'; @@ -122,8 +130,10 @@ method: 'POST', body: { name: name || 'Manual Backup', - stopFirst: stopFirstInput ? stopFirstInput.value : 'false', - startAfter: backupStartAfterInput ? backupStartAfterInput.value : 'false' + stopFirst: stopFirst ? 'true' : 'false', + // Only meaningful when we're stopping it ourselves. + startAfter: (stopFirst && startAfterBackupCheckbox && startAfterBackupCheckbox.checked) + ? 'true' : 'false' } }); if (!res.ok) { diff --git a/public/js/console.js b/public/js/console.js index 3cdc648..001a3b3 100644 --- a/public/js/console.js +++ b/public/js/console.js @@ -30,6 +30,7 @@ let reconnectAttempts = 0; let autoScroll = true; let currentState = wrapper.dataset.serverState || 'stopped'; + let isRestarting = false; var serverLastStarted = null; function connect() { @@ -55,7 +56,7 @@ if (msg.history && msg.history.length > 0) { msg.history.forEach(line => appendLine(line)); } - if (msg.state) updateState(msg.state, msg.crashReason, msg.exitCode); + if (msg.state) updateState(msg.state, msg.crashReason, msg.exitCode, msg.restarting); updateLastStarted(msg.state, msg.lastStarted); if (typeof msg.playerCount === 'number') updatePlayerCount(msg.playerCount); scrollToBottom(); @@ -76,7 +77,7 @@ case 'state': if (msg.serverId === serverId) { - updateState(msg.state, msg.crashReason, msg.exitCode); + updateState(msg.state, msg.crashReason, msg.exitCode, msg.restarting); updateLastStarted(msg.state, msg.lastStarted); } break; @@ -98,6 +99,7 @@ ws.onclose = () => { reconnectAttempts++; + probeSessionAfterFailures(reconnectAttempts); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); setTimeout(connect, delay); }; @@ -139,11 +141,22 @@ output.scrollTop = output.scrollHeight; } - function updateState(state, crashReason, exitCode) { + function updateState(state, crashReason, exitCode, restarting) { currentState = state; + // A restart passes through `stopped` on its way back up. Without this the + // buttons would re-enable for the couple of seconds before the respawn, + // inviting a Start (or Delete) that races it. + isRestarting = restarting === true; // Update data-state on parent for CSS animations if (navHeader) navHeader.dataset.state = state; + if (navHeader) navHeader.dataset.restarting = isRestarting ? 'true' : 'false'; + + // Re-gate state-dependent controls elsewhere on the page (applyStateGates + // in app.js). The console's own buttons are handled directly below. + document.dispatchEvent(new CustomEvent('craftbox:state', { + detail: { serverId: serverId, state: state, restarting: isRestarting } + })); // Update badge if (stateBadge) { @@ -160,7 +173,9 @@ // Update button states document.querySelectorAll('.server-action-btn').forEach(btn => { const action = btn.dataset.action; - if (actionStates[action]) { + if (isRestarting) { + btn.disabled = true; + } else if (actionStates[action]) { btn.disabled = !actionStates[action].includes(state); } else if (action === 'delete') { btn.disabled = !['stopped', 'crashed'].includes(state); @@ -286,7 +301,14 @@ } // ── Server action buttons (start/stop/restart/kill/delete) ── + // One power action at a time. The buttons are re-derived from WebSocket + // state, which arrives after the response, so without this latch a double + // click sends the request twice before the first result is reflected. + var actionInFlight = false; + async function doAction(action, body) { + if (actionInFlight) return; + actionInFlight = true; var labels = { start: { title: 'Starting server...', desc: 'Please wait while the command is sent.' }, stop: { title: 'Stopping server...', desc: 'Please wait while the command is sent.' }, @@ -295,11 +317,16 @@ }; if (labels[action]) showOverlay(labels[action].title, labels[action].desc); - var res = await apiFetch('/api/v1/servers/' + serverId + '/' + action, { - method: 'POST', - body: body || {} - }); - hideOverlay(); + var res; + try { + res = await apiFetch('/api/v1/servers/' + serverId + '/' + action, { + method: 'POST', + body: body || {} + }); + } finally { + actionInFlight = false; + hideOverlay(); + } if (!res.ok) { showToast((res.data && (res.data.message || res.data.error)) || ('Failed to ' + action + '.'), 'danger'); return; @@ -315,6 +342,7 @@ document.querySelectorAll('.server-action-btn').forEach(function (btn) { btn.addEventListener('click', function () { + if (btn.disabled || isRestarting || actionInFlight) return; var action = btn.dataset.action; if (action === 'kill' && killModal) { killModal.show(); return; } if (action === 'delete' && deleteModal) { deleteModal.show(); return; } @@ -590,9 +618,12 @@ async function fetchStats() { try { - var res = await fetch('/api/v1/servers/' + serverId + '/stats'); + // Polled, so this doubles as a passive session heartbeat: apiFetch + // turns a 401 here into the expiry redirect without the user having + // to click anything first. + var res = await apiFetch('/api/v1/servers/' + serverId + '/stats'); if (!res.ok) return; - var data = await res.json(); + var data = res.data || {}; var s = data.stats; var isRunning = s.state === 'running'; diff --git a/public/js/create.js b/public/js/create.js index 2fbc504..ee5b2f4 100644 --- a/public/js/create.js +++ b/public/js/create.js @@ -65,24 +65,12 @@ function setCustomNoticeVisible(visible) { customTypeNotice.classList.toggle('d-flex', visible); } -// ── Center form fields left alone on their row ── -// A row whose other columns are hidden (e.g. the port field once the version -// picker is gone in modpack mode, or the group picker on its own row) looks -// lopsided half-width on the left; center it instead. -function centerLoneRowItems() { - form.querySelectorAll('.row').forEach(function (row) { - var cols = row.querySelectorAll(':scope > [class*="col-"]'); - if (cols.length === 0) return; - var visible = Array.prototype.filter.call(cols, function (c) { - return !c.classList.contains('d-none'); - }); - row.classList.toggle('justify-content-center', visible.length === 1); - }); -} - // ── Required field validation + EULA gating ── function validateCreateForm() { - centerLoneRowItems(); + // Columns come and go here (modpack mode hides the version picker), so the + // centring is re-run with every validation pass. centerLoneRowItems is in + // app.js — the settings form uses it too. + centerLoneRowItems(form); if (!eulaCheck.checked) { createBtn.disabled = true; return; } var fields = form.querySelectorAll('[required]'); var allFilled = true; @@ -147,12 +135,11 @@ form.addEventListener('submit', async (e) => { (async () => { // Modpack modes hide the type/version selectors entirely if (createMode !== 'normal') return; - try { - const res = await fetch('/api/v1/server-types'); - const data = await res.json(); - typesData = data.types || []; + const res = await apiFetch('/api/v1/server-types'); + if (res.ok && res.data && res.data.types) { + typesData = res.data.types; renderTypeCards(typesData); - } catch { + } else { typeSelector.innerHTML = '
Failed to load server types.
'; } @@ -201,7 +188,7 @@ async function selectType(typeId) { customUrlGroup.classList.remove('d-none'); versionDisplay.removeAttribute('required'); setCustomNoticeVisible(true); - centerLoneRowItems(); + centerLoneRowItems(form); } else { versionGroup.classList.remove('d-none'); customUrlGroup.classList.add('d-none'); @@ -238,23 +225,22 @@ const templateGroup = document.getElementById('template-group'); (async () => { // Templates pick a type/version themselves — not applicable to modpack modes if (createMode !== 'normal') return; - try { - const res = await fetch('/api/v1/templates'); - const data = await res.json(); - if (data.templates && data.templates.length > 0) { - // Unlock the Template card in the Create From picker; the select - // itself only shows once that source is picked. - sourceTemplateCard.classList.remove('type-card-disabled'); - sourceTemplateCard.removeAttribute('title'); - for (const t of data.templates) { - const opt = document.createElement('option'); - opt.value = t.id; - const typeName = (t.serverType || 'vanilla').charAt(0).toUpperCase() + (t.serverType || 'vanilla').slice(1); - opt.textContent = `${t.name} (${typeName}${t.serverType === 'custom' ? '' : ` ${t.version}` || ''})`.trim(); - templateSelect.appendChild(opt); - } + // Templates are optional — a failure here just leaves the card locked. + const res = await apiFetch('/api/v1/templates'); + const data = res.data || {}; + if (data.templates && data.templates.length > 0) { + // Unlock the Template card in the Create From picker; the select + // itself only shows once that source is picked. + sourceTemplateCard.classList.remove('type-card-disabled'); + sourceTemplateCard.removeAttribute('title'); + for (const t of data.templates) { + const opt = document.createElement('option'); + opt.value = t.id; + const typeName = (t.serverType || 'vanilla').charAt(0).toUpperCase() + (t.serverType || 'vanilla').slice(1); + opt.textContent = `${t.name} (${typeName}${t.serverType === 'custom' ? '' : ` ${t.version}` || ''})`.trim(); + templateSelect.appendChild(opt); } - } catch { /* ignore — templates are optional */ } + } })(); function setTypeAndVersionLocked(locked) { @@ -306,8 +292,8 @@ templateSelect.addEventListener('change', async () => { } try { - const res = await fetch(`/api/v1/templates/${id}`); - const data = await res.json(); + const res = await apiFetch(`/api/v1/templates/${id}`); + const data = res.data || {}; const t = data.template; if (!t) return; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 8e1e633..822a241 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -82,6 +82,7 @@ ws.onclose = () => { reconnectAttempts++; + probeSessionAfterFailures(reconnectAttempts); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); setTimeout(connect, delay); }; diff --git a/public/js/edit.js b/public/js/edit.js index be5c9a3..c3d26cc 100644 --- a/public/js/edit.js +++ b/public/js/edit.js @@ -24,6 +24,12 @@ function _formToBody(form) { var backupCheck = document.getElementById('saveBackup'); var SAVE_BTN_HTML = 'save Save Changes'; + // Assign Group sits alone on the last row of Advanced Options, so it gets + // centred the same way the create form centres a lone column. Once is + // enough here: which columns render is decided server-side (custom JAR URL + // vs version and port), and nothing hides one after load. + centerLoneRowItems(form); + form.addEventListener('submit', async function (e) { e.preventDefault(); if (!form.reportValidity()) return; @@ -390,11 +396,9 @@ function _formToBody(form) { function showRestartModal() { var modalEl = document.getElementById('restartModal'); - if (modalEl) { - var state = modalEl.dataset.serverState; - if (state !== 'stopped' && state !== 'crashed') { - new bootstrap.Modal(modalEl).show(); - } + // Live state — a server that has since stopped needs no restart prompt. + if (modalEl && !isServerStopped()) { + new bootstrap.Modal(modalEl).show(); } } @@ -560,7 +564,7 @@ function _formToBody(form) { var serverId = form.dataset.serverId; async function submitDuplicate() { - var btn = form.querySelector('button[type="submit"]') || document.getElementById('duplicate-running-btn'); + var btn = document.getElementById('duplicate-btn'); if (btn) { btn.disabled = true; btn.innerHTML = ' Duplicating...'; @@ -579,24 +583,25 @@ function _formToBody(form) { window.location.href = newId ? '/servers/' + newId : '/dashboard'; } - // Direct submit (server already stopped) - form.addEventListener('submit', function (e) { - e.preventDefault(); - if (!form.reportValidity()) return; - submitDuplicate(); - }); - - // Stop-then-duplicate modal flow - var dupRunningBtn = document.getElementById('duplicate-running-btn'); - if (dupRunningBtn) { - var modal = new bootstrap.Modal(document.getElementById('stopDuplicateModal')); + var modalEl = document.getElementById('stopDuplicateModal'); + if (modalEl) { + var modal = new bootstrap.Modal(modalEl); var confirmBtn = document.getElementById('confirm-stop-duplicate-btn'); var startAfterCheckbox = document.getElementById('dupStartAfter'); - dupRunningBtn.addEventListener('click', function () { + // Duplicate directly when the server is already down, otherwise offer to + // stop it first. Decided here rather than by rendering two different + // buttons, so it follows the live state. + form.addEventListener('submit', function (e) { + e.preventDefault(); if (!form.reportValidity()) return; + if (isServerStopped()) { + submitDuplicate(); + return; + } modal.show(); }); + startAfterCheckbox.addEventListener('change', function () { document.getElementById('dup-start-after').value = startAfterCheckbox.checked ? 'true' : 'false'; }); @@ -615,7 +620,7 @@ function _formToBody(form) { if (!form) return; async function submitTemplate() { - var btn = form.querySelector('button[type="submit"]') || document.getElementById('template-running-btn'); + var btn = document.getElementById('template-btn'); if (btn) { btn.disabled = true; btn.innerHTML = ' Saving...'; @@ -633,22 +638,24 @@ function _formToBody(form) { window.location.href = '/templates'; } - form.addEventListener('submit', function (e) { - e.preventDefault(); - if (!form.reportValidity()) return; - submitTemplate(); - }); - - var tmplRunningBtn = document.getElementById('template-running-btn'); - if (tmplRunningBtn) { - var modal = new bootstrap.Modal(document.getElementById('stopTemplateModal')); + var modalEl = document.getElementById('stopTemplateModal'); + if (modalEl) { + var modal = new bootstrap.Modal(modalEl); var confirmBtn = document.getElementById('confirm-stop-template-btn'); var startAfterCheckbox = document.getElementById('tmplStartAfter'); - tmplRunningBtn.addEventListener('click', function () { + // Same shape as duplicate: save straight away when already stopped, + // otherwise offer to stop first. Live state, not render-time state. + form.addEventListener('submit', function (e) { + e.preventDefault(); if (!form.reportValidity()) return; + if (isServerStopped()) { + submitTemplate(); + return; + } modal.show(); }); + startAfterCheckbox.addEventListener('change', function () { document.getElementById('tmpl-start-after').value = startAfterCheckbox.checked ? 'true' : 'false'; }); @@ -671,7 +678,7 @@ function _formToBody(form) { function exportUrl(startAfter) { var backups = document.getElementById('export-backups').checked ? 'true' : 'false'; var events = document.getElementById('export-events').checked ? 'true' : 'false'; - var url = '/servers/' + serverId + '/export?backups=' + backups + '&events=' + events; + var url = '/api/v1/servers/' + serverId + '/export?backups=' + backups + '&events=' + events; if (startAfter) url += '&start=true'; return url; } @@ -724,7 +731,9 @@ function _formToBody(form) { }); exportBtn.addEventListener('click', function () { - if (exportBtn.dataset.serverStopped === 'true') { + // Read the live state, not the value baked in at render time — the + // server may have stopped or started since the page loaded. + if (isServerStopped()) { startDownload(false); } else { new bootstrap.Modal(stopExportModalEl).show(); @@ -769,8 +778,8 @@ function _formToBody(form) { resultDiv.classList.add('d-none'); try { - const res = await fetch('/api/v1/servers/' + serverId + '/check-upgrade'); - const data = await res.json(); + const res = await apiFetch('/api/v1/servers/' + serverId + '/check-upgrade'); + const data = res.data || {}; if (!res.ok) { showResult('danger', data.error || 'Failed to check for upgrades.'); @@ -778,8 +787,11 @@ function _formToBody(form) { } if (data.upgradeAvailable) { - showResult('warning', - 'Upgrade available: build #' + data.currentBuild + ' → #' + data.latestBuild); + // A server with no recorded build (imported, duplicated) sends a + // reason explaining that upgrading is what records one — there is + // no "from" build to name in the usual message. + showResult('warning', data.reason || + ('Upgrade available: build #' + data.currentBuild + ' → #' + data.latestBuild)); showUpgradeButton(); } else if (data.reason) { showResult('secondary', data.reason); diff --git a/public/js/events.js b/public/js/events.js index 3ee1b7e..0c00473 100644 --- a/public/js/events.js +++ b/public/js/events.js @@ -10,22 +10,162 @@ }); } - // Format event timestamps as relative time - document.querySelectorAll('.event-time').forEach(function (el) { - var time = new Date(el.dataset.time); - el.textContent = timeAgo(time); - el.title = time.toLocaleString(); - }); - - function timeAgo(date) { - var seconds = Math.floor((Date.now() - date.getTime()) / 1000); - if (seconds < 60) return 'just now'; - var minutes = Math.floor(seconds / 60); - if (minutes < 60) return minutes + 'm ago'; - var hours = Math.floor(minutes / 60); - if (hours < 24) return hours + 'h ago'; - var days = Math.floor(hours / 24); - return days + 'd ago'; + // Format event timestamps as an absolute date/time followed by its relative + // age — "08/03/2026, 14:05:09 (5m ago)". formatDate is the same helper the + // backups, files, plugins and account pages use, so the absolute half reads + // identically across the panel. + function refreshEventTimes() { + document.querySelectorAll('.event-time').forEach(function (el) { + if (!el.dataset.time) return; + var time = new Date(el.dataset.time); + el.textContent = formatDate(el.dataset.time) + ' (' + timeAgo(time) + ')'; + }); + } + refreshEventTimes(); + // Re-tick every 30s so the relative half stays honest on a long-open tab. + setInterval(refreshEventTimes, 30000); + + // ── Live event rows ── + // The server pushes every logged event to this page's WebSocket (see + // utils/eventLogger). Rows are built here to match what the view renders, + // using the same badge/icon map, which the view hands over as JSON so the + // vocabulary stays defined in exactly one place. + var tbody = document.getElementById('events-tbody'); + var emptyCard = document.getElementById('events-empty'); + var eventsCard = document.getElementById('events-card'); + var countBadge = document.getElementById('event-count'); + var clearFormEl = document.getElementById('clear-events-form'); + + if (tbody) { + var eventMeta = {}; + var fallbackMeta = { icon: 'info', color: 'text-body-secondary', badge: 'secondary', label: null }; + try { eventMeta = JSON.parse(tbody.dataset.eventMeta || '{}'); } catch (_) { /* keep defaults */ } + try { fallbackMeta = JSON.parse(tbody.dataset.fallbackMeta || 'null') || fallbackMeta; } catch (_) { /* keep defaults */ } + + var activeFilter = tbody.dataset.typeFilter || ''; + var maxRows = parseInt(tbody.dataset.maxRows, 10) || 500; + + function metaFor(type) { + return Object.prototype.hasOwnProperty.call(eventMeta, type) ? eventMeta[type] : fallbackMeta; + } + + // Icon shown next to the actor, mirroring the view's ternary. + function actorIcon(initiatedBy) { + if (initiatedBy === 'Backup Scheduler') return 'schedule'; + if (initiatedBy === 'Auto Start') return 'play_circle'; + return 'person'; + } + + function cell(html) { + var td = document.createElement('td'); + td.innerHTML = html; + return td; + } + + function buildRow(evt) { + var meta = metaFor(evt.type); + var row = document.createElement('tr'); + + var icon = document.createElement('td'); + icon.className = 'text-center'; + var iconSpan = document.createElement('span'); + iconSpan.className = 'material-icons-outlined ' + meta.color; + iconSpan.style.fontSize = '1.1rem'; + iconSpan.textContent = meta.icon; + icon.appendChild(iconSpan); + row.appendChild(icon); + + var badgeTd = document.createElement('td'); + var badge = document.createElement('span'); + badge.className = 'badge bg-' + meta.badge; + badge.style.fontSize = '0.75rem'; + badge.textContent = meta.label || evt.type; + badgeTd.appendChild(badge); + row.appendChild(badgeTd); + + var msg = document.createElement('td'); + msg.textContent = evt.message || ''; + row.appendChild(msg); + + // Same three branches as the view: named actor, player, or System. + var by = document.createElement('td'); + if (evt.initiatedBy && evt.initiatedBy !== 'System') { + by.appendChild(actorSpan(actorIcon(evt.initiatedBy), evt.initiatedBy)); + } else if (evt.playerName) { + by.appendChild(actorSpan('sports_esports', evt.playerName)); + } else { + var sys = document.createElement('span'); + sys.className = 'text-body-secondary'; + sys.style.fontSize = '0.85rem'; + sys.textContent = 'System'; + by.appendChild(sys); + } + row.appendChild(by); + + var timeTd = document.createElement('td'); + var time = document.createElement('small'); + time.className = 'text-body-secondary text-nowrap event-time'; + time.dataset.time = evt.createdAt; + time.textContent = evt.createdAt; + timeTd.appendChild(time); + row.appendChild(timeTd); + + return row; + } + + function actorSpan(iconName, text) { + var wrap = document.createElement('span'); + wrap.className = 'd-flex align-items-center gap-1'; + wrap.style.fontSize = '0.85rem'; + var i = document.createElement('span'); + i.className = 'material-icons-outlined'; + i.style.fontSize = '0.9rem'; + i.textContent = iconName; + wrap.appendChild(i); + wrap.appendChild(document.createTextNode(' ' + text)); + return wrap; + } + + function addEvent(evt) { + // A filtered view only shows its own type; anything else would + // silently contradict the dropdown. + if (activeFilter && evt.type !== activeFilter) return; + + tbody.insertBefore(buildRow(evt), tbody.firstChild); + + // The log is capped server-side at 500; drop the oldest rows so the + // page can't grow past it either. + while (tbody.children.length > maxRows) { + tbody.removeChild(tbody.lastElementChild); + } + + if (emptyCard) emptyCard.classList.add('d-none'); + if (eventsCard) eventsCard.classList.remove('d-none'); + if (clearFormEl) clearFormEl.classList.remove('d-none'); + if (countBadge) countBadge.textContent = String(tbody.children.length); + + refreshEventTimes(); + } + + document.addEventListener('craftbox:event', function (e) { + var msg = e.detail || {}; + addEvent({ + type: msg.eventType, + message: msg.message, + createdAt: msg.createdAt, + initiatedBy: msg.initiatedBy, + playerName: msg.playerName + }); + }); + + // The log was wiped elsewhere (or in another tab) — reflect it here. + document.addEventListener('craftbox:events-cleared', function () { + tbody.innerHTML = ''; + if (eventsCard) eventsCard.classList.add('d-none'); + if (emptyCard) emptyCard.classList.remove('d-none'); + if (clearFormEl) clearFormEl.classList.add('d-none'); + if (countBadge) countBadge.textContent = '0'; + }); } // Clear events: modal confirmation + overlay diff --git a/public/js/files.js b/public/js/files.js new file mode 100644 index 0000000..40f5a9a --- /dev/null +++ b/public/js/files.js @@ -0,0 +1,475 @@ +/* global bootstrap */ +(function () { + 'use strict'; + + var serverId = window.location.pathname.split('/')[2]; + var csrf = document.getElementById('csrf-token')?.value || ''; + // The directory this page is showing, relative to the server root ('' = root). + var currentPath = document.getElementById('current-path')?.value || ''; + var locationLabel = currentPath || 'the server root'; + + // Names are external data — always set them via textContent. + function nameText(parent, name) { + var strong = document.createElement('strong'); + strong.textContent = name; + parent.appendChild(strong); + } + + // Mirrors newNameError (src/utils/fileBrowser.js) check for check, so the + // confirm button only lights up for a name the API would actually accept. + // The server still re-checks — this just saves a round trip to be told no. + // Keep the two in step: this list was the stricter of the pair for a while, + // refusing a slash that the API then quietly stripped to a basename. + var RESERVED_DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i; + + function nameError(name) { + var trimmed = String(name || '').trim(); + if (!trimmed) return 'Enter a name.'; + if (trimmed.length > 255) return 'A name cannot be longer than 255 characters.'; + if (trimmed === '.' || trimmed === '..') return 'That name cannot be used.'; + if (/[/\\]/.test(trimmed)) return 'A name cannot contain a slash.'; + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(trimmed)) return 'A name cannot contain control characters.'; + if (/[<>:"|?*]/.test(trimmed)) return 'A name cannot contain any of: < > : " | ? *'; + if (/\.$/.test(trimmed)) return 'A name cannot end with a dot.'; + if (RESERVED_DEVICE_NAMES.test(trimmed)) return '"' + trimmed + '" is a reserved name and cannot be used.'; + return null; + } + + // ── Search / Filter ── + + var searchInput = document.getElementById('search-input'); + if (searchInput) { + searchInput.addEventListener('input', function () { + var query = searchInput.value.toLowerCase(); + document.querySelectorAll('table tbody tr[data-filename]').forEach(function (row) { + var name = row.getAttribute('data-filename').toLowerCase(); + row.style.display = (!query || name.includes(query)) ? '' : 'none'; + }); + }); + } + + // ── Upload ── + + var fileInput = document.getElementById('file-input'); + var uploadBtn = document.getElementById('upload-btn'); + + async function uploadFiles(files) { + var list = Array.from(files); + if (list.length === 0) return; + + if (uploadBtn) uploadBtn.disabled = true; + if (fileInput) fileInput.disabled = true; + showOverlay('Uploading files...', 'This may take a moment for large files.'); + + var uploaded = []; + var rejected = []; + var replaced = 0; + var failure = null; + + try { + var totalBytes = list.reduce(function (sum, f) { return sum + f.size; }, 0); + if (totalBytes <= DGUP_THRESHOLD) { + // Small selection — one multipart request for all files. + // `path` is appended first: multer only exposes text fields on + // req.body if they precede the files in the stream. + var formData = new FormData(); + formData.append('path', currentPath); + for (var i = 0; i < list.length; i++) { + formData.append('files', list[i]); + } + var res = await apiFetch('/api/v1/servers/' + serverId + '/files/upload', { + method: 'POST', + body: formData + }); + var data = res.data || {}; + if (res.ok && data.success) { + uploaded = data.uploaded || []; + rejected = rejected.concat(data.rejected || []); + replaced += data.replaced || 0; + } else { + failure = (data && data.error) || 'Upload failed.'; + } + } else { + // Large selection — one upload per file (uploadFile chunks + // anything over the threshold so multi-GB worlds survive + // proxies with request-body caps), merging the results. + for (var j = 0; j < list.length; j++) { + var file = list[j]; + var prefix = (list.length > 1 ? (j + 1) + ' of ' + list.length + ' — ' : '') + file.name; + showOverlay('Uploading files...', prefix); + var result = await uploadFile('/api/v1/servers/' + serverId + '/files/upload', file, { + fieldName: 'files', + fields: { path: currentPath }, + csrfToken: csrf, + onProgress: function (loaded, total) { + showOverlay('Uploading files...', + prefix + ' (' + Math.round((loaded / total) * 100) + '%)'); + } + }); + if (result.ok && result.data && result.data.success) { + uploaded = uploaded.concat(result.data.uploaded || []); + rejected = rejected.concat(result.data.rejected || []); + replaced += result.data.replaced || 0; + } else { + failure = (result.data && result.data.error) || 'Upload failed.'; + break; + } + } + } + } catch { + failure = 'Upload failed. Please try again.'; + } + + var uploadedCount = uploaded.length; + var rejectedCount = rejected.length; + var noun = uploadedCount === 1 ? 'file' : 'files'; + var replacedNote = replaced > 0 ? ', ' + replaced + ' replaced' : ''; + + function unlock() { + if (uploadBtn) uploadBtn.disabled = false; + if (fileInput) fileInput.disabled = false; + hideOverlay(); + } + + if (failure && uploadedCount > 0) { + // Some files landed before the failure — reload to show them. + flashToast(uploadedCount + ' ' + noun + ' uploaded, then: ' + failure, 'warning'); + window.location.reload(); + } else if (failure) { + showToast(failure, 'danger'); + unlock(); + } else if (uploadedCount === 0) { + // Nothing made it through — show a danger toast and stay put. + showToast(rejectedCount === 1 + ? 'File rejected: ' + ((rejected[0] && rejected[0].reason) || 'unknown reason') + '.' + : 'No files uploaded — all ' + rejectedCount + ' were rejected.', 'danger'); + unlock(); + } else if (rejectedCount > 0) { + // Partial success — reload to show what landed, with a warning toast. + flashToast(uploadedCount + ' ' + noun + ' uploaded' + replacedNote + + ', ' + rejectedCount + ' rejected.', 'warning'); + window.location.reload(); + } else { + flashToast(uploadedCount + ' ' + noun + ' uploaded' + replacedNote + '.', 'success'); + window.location.reload(); + } + } + + // Uploading is allowed in any server state, so the only gate is whether + // anything is selected — no craftbox:stategates listener needed here. + if (fileInput && uploadBtn) { + fileInput.addEventListener('change', function () { + uploadBtn.disabled = fileInput.files.length === 0; + }); + + uploadBtn.addEventListener('click', function () { + if (fileInput.files.length === 0) return; + uploadFiles(fileInput.files); + }); + } + + // ── Drag & Drop ── + + // Always prevent default drop behavior so Chrome doesn't open files in a new tab + document.addEventListener('dragover', function (e) { e.preventDefault(); }); + document.addEventListener('drop', function (e) { e.preventDefault(); }); + + var dropOverlay = document.getElementById('drop-overlay'); + if (dropOverlay) { + var dragCounter = 0; + + document.addEventListener('dragenter', function (e) { + e.preventDefault(); + if (isOverlayVisible()) return; + dragCounter++; + if (dragCounter === 1) { + dropOverlay.classList.remove('d-none'); + dropOverlay.classList.add('d-flex'); + } + }); + + document.addEventListener('dragleave', function (e) { + e.preventDefault(); + if (isOverlayVisible()) return; + dragCounter--; + if (dragCounter === 0) { + dropOverlay.classList.add('d-none'); + dropOverlay.classList.remove('d-flex'); + } + }); + + document.addEventListener('drop', function (e) { + if (isOverlayVisible()) return; + dragCounter = 0; + dropOverlay.classList.add('d-none'); + dropOverlay.classList.remove('d-flex'); + + if (e.dataTransfer && e.dataTransfer.files.length > 0) { + uploadFiles(e.dataTransfer.files); + } + }); + } + + // ── Delete ── + + var deleteModal = document.getElementById('deleteModal'); + var deleteTitleEl = document.getElementById('delete-title'); + var deleteBodyEl = document.getElementById('delete-body'); + var confirmDeleteBtn = document.getElementById('confirm-delete-btn'); + var pendingDelete = null; + + if (deleteModal) { + var bsDeleteModal = new bootstrap.Modal(deleteModal); + + document.querySelectorAll('.delete-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = btn.closest('tr[data-path]'); + if (!row) return; + pendingDelete = { + path: row.getAttribute('data-path'), + name: row.getAttribute('data-filename'), + isDirectory: row.getAttribute('data-directory') === 'true' + }; + + deleteTitleEl.textContent = pendingDelete.isDirectory ? 'Delete Folder' : 'Delete File'; + deleteBodyEl.textContent = pendingDelete.isDirectory + ? 'Permanently delete the folder ' + : 'Permanently delete '; + nameText(deleteBodyEl, pendingDelete.name); + deleteBodyEl.appendChild(document.createTextNode(pendingDelete.isDirectory + ? ' and everything inside it? This cannot be undone.' + : '? This cannot be undone.')); + + bsDeleteModal.show(); + }); + }); + + if (confirmDeleteBtn) { + confirmDeleteBtn.addEventListener('click', async function () { + if (!pendingDelete) return; + + confirmDeleteBtn.disabled = true; + confirmDeleteBtn.innerHTML = ' Deleting...'; + + try { + var res = await apiFetch('/api/v1/servers/' + serverId + '/files/delete', { + method: 'POST', + body: { path: pendingDelete.path } + }); + + var data = res.data || {}; + if (res.ok && data.success) { + bsDeleteModal.hide(); + flashToast((pendingDelete.isDirectory ? 'Folder' : 'File') + ' deleted.', 'success'); + window.location.reload(); + } else { + showToast(data.error || 'Delete failed.', 'danger'); + confirmDeleteBtn.disabled = false; + confirmDeleteBtn.textContent = 'Delete'; + } + } catch { + showToast('Delete failed. Please try again.', 'danger'); + confirmDeleteBtn.disabled = false; + confirmDeleteBtn.textContent = 'Delete'; + } + }); + } + } + + // ── Rename ── + + var renameModal = document.getElementById('renameModal'); + var renameTitleEl = document.getElementById('rename-title'); + var renameInput = document.getElementById('rename-input'); + var confirmRenameBtn = document.getElementById('confirm-rename-btn'); + var pendingRename = null; + + if (renameModal) { + var bsRenameModal = new bootstrap.Modal(renameModal); + + // Renaming to the current name is a no-op the modal handles by just + // closing, so only the name's own validity gates the button. + function updateRenameConfirm() { + confirmRenameBtn.disabled = !!nameError(renameInput.value); + } + + renameInput.addEventListener('input', updateRenameConfirm); + + document.querySelectorAll('.rename-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = btn.closest('tr[data-path]'); + if (!row) return; + pendingRename = { + path: row.getAttribute('data-path'), + name: row.getAttribute('data-filename'), + isDirectory: row.getAttribute('data-directory') === 'true' + }; + renameTitleEl.textContent = pendingRename.isDirectory ? 'Rename Folder' : 'Rename File'; + renameInput.value = pendingRename.name; + updateRenameConfirm(); + bsRenameModal.show(); + }); + }); + + // Focus only lands once the modal is actually visible. + renameModal.addEventListener('shown.bs.modal', function () { + renameInput.focus(); + // Select the base name so typing replaces it but keeps the + // extension — retyping ".properties" every time is a nuisance. + var dot = renameInput.value.lastIndexOf('.'); + var end = (!pendingRename.isDirectory && dot > 0) ? dot : renameInput.value.length; + renameInput.setSelectionRange(0, end); + }); + + renameInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + confirmRenameBtn.click(); + } + }); + + if (confirmRenameBtn) { + confirmRenameBtn.addEventListener('click', async function () { + if (!pendingRename) return; + var newName = renameInput.value.trim(); + var problem = nameError(newName); + if (problem) { + showToast(problem, 'warning'); + return; + } + if (newName === pendingRename.name) { + bsRenameModal.hide(); + return; + } + + confirmRenameBtn.disabled = true; + confirmRenameBtn.innerHTML = ' Renaming...'; + + try { + var res = await apiFetch('/api/v1/servers/' + serverId + '/files/rename', { + method: 'POST', + body: { path: pendingRename.path, newName: newName } + }); + + var data = res.data || {}; + if (res.ok && data.success) { + bsRenameModal.hide(); + flashToast('Renamed to "' + data.name + '".', 'success'); + window.location.reload(); + } else { + showToast(data.error || 'Rename failed.', 'danger'); + confirmRenameBtn.textContent = 'Rename'; + updateRenameConfirm(); + } + } catch { + showToast('Rename failed. Please try again.', 'danger'); + confirmRenameBtn.textContent = 'Rename'; + updateRenameConfirm(); + } + }); + } + } + + // ── New Folder / New Text File ── + + // The two create modals are the same dialog pointed at a different + // endpoint: same name rules, same Enter-to-submit, same confirm gating. + // Wiring both through one function is what keeps them identical, rather + // than two near-copies that drift the next time one of them is touched. + // + // `prefill` seeds the input (the file modal opens on ".txt"); the caret + // always goes to position 0, so typing builds a name in front of the + // extension. On the empty folder input that is where it lands anyway. + function wireCreateModal(opts) { + var openBtn = document.getElementById(opts.buttonId); + var modal = document.getElementById(opts.modalId); + var input = document.getElementById(opts.inputId); + var confirmBtn = document.getElementById(opts.confirmId); + if (!openBtn || !modal || !input || !confirmBtn) return; + + var bsModal = new bootstrap.Modal(modal); + + function updateConfirm() { + confirmBtn.disabled = !!nameError(input.value); + } + + input.addEventListener('input', updateConfirm); + + openBtn.addEventListener('click', function () { + input.value = opts.prefill || ''; + updateConfirm(); + bsModal.show(); + }); + + // Focus only lands once the modal is actually visible. + modal.addEventListener('shown.bs.modal', function () { + input.focus(); + input.setSelectionRange(0, 0); + }); + + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + confirmBtn.click(); + } + }); + + confirmBtn.addEventListener('click', async function () { + var name = input.value.trim(); + var problem = nameError(name); + if (problem) { + showToast(problem, 'warning'); + return; + } + + confirmBtn.disabled = true; + confirmBtn.innerHTML = ' Creating...'; + + function failed(message) { + showToast(message, 'danger'); + confirmBtn.textContent = 'Create'; + updateConfirm(); + } + + try { + var res = await apiFetch('/api/v1/servers/' + serverId + '/files/' + opts.endpoint, { + method: 'POST', + body: { path: currentPath, name: name } + }); + + var data = res.data || {}; + if (res.ok && data.success) { + bsModal.hide(); + flashToast(opts.label + ' "' + data.name + '" created in ' + locationLabel + '.', 'success'); + window.location.reload(); + } else { + failed(data.error || 'Could not create the ' + opts.noun + '.'); + } + } catch { + failed('Could not create the ' + opts.noun + '. Please try again.'); + } + }); + } + + wireCreateModal({ + buttonId: 'new-folder-btn', + modalId: 'newFolderModal', + inputId: 'new-folder-input', + confirmId: 'confirm-new-folder-btn', + endpoint: 'mkdir', + label: 'Folder', + noun: 'folder' + }); + + wireCreateModal({ + buttonId: 'new-file-btn', + modalId: 'newFileModal', + inputId: 'new-file-input', + confirmId: 'confirm-new-file-btn', + endpoint: 'mkfile', + label: 'File', + noun: 'file', + prefill: '.txt' + }); +})(); diff --git a/public/js/import.js b/public/js/import.js index 9654a65..a67679b 100644 --- a/public/js/import.js +++ b/public/js/import.js @@ -21,8 +21,8 @@ function resetModal() { uploading = false; + setControlsLocked(modalEl, false); fileInput.value = ''; - fileInput.disabled = false; confirmBtn.disabled = true; confirmBtn.innerHTML = confirmBtnHtml; progressWrap.classList.add('d-none'); @@ -48,15 +48,16 @@ uploading = true; currentUpload = new AbortController(); - confirmBtn.disabled = true; confirmBtn.innerHTML = ' Importing...'; - fileInput.disabled = true; + // Freeze the modal for the duration of the transfer — Cancel and the + // header X stay live so the upload can still be aborted. + setControlsLocked(modalEl, true); progressWrap.classList.remove('d-none'); function fail(message) { showToast(message || 'Import failed.', 'danger'); uploading = false; - fileInput.disabled = false; + setControlsLocked(modalEl, false); confirmBtn.innerHTML = confirmBtnHtml; confirmBtn.disabled = !fileInput.files.length; progressWrap.classList.add('d-none'); @@ -250,6 +251,7 @@ if (!mrpackModalEl || !mrpackForm) return; mrpackDroppedFile = file; mrpackUploading = false; + setControlsLocked(mrpackModalEl, false); mrpackConfirmBtn.innerHTML = mrpackConfirmHtml; mrpackProgressWrap.classList.add('d-none'); mrpackProgressBar.style.width = '0%'; @@ -278,9 +280,11 @@ mrpackUploading = true; mrpackUpload = new AbortController(); - mrpackConfirmBtn.disabled = true; mrpackConfirmBtn.innerHTML = ' Creating...'; + // The field values were snapshotted into `fields` below, so leaving + // the inputs editable during the upload would silently discard edits. + setControlsLocked(mrpackModalEl, true); mrpackProgressWrap.classList.remove('d-none'); uploadFile('/api/v1/servers/from-mrpack', mrpackDroppedFile, { @@ -300,12 +304,14 @@ mrpackUploading = false; if (res.aborted) { showToast('Upload cancelled.', 'info'); + setControlsLocked(mrpackModalEl, false); mrpackConfirmBtn.innerHTML = mrpackConfirmHtml; mrpackForm.dispatchEvent(new Event('input')); return; } if (res.status !== 201) { showToast((res.data && (res.data.message || res.data.error)) || 'Failed to create server from modpack.', 'danger'); + setControlsLocked(mrpackModalEl, false); mrpackConfirmBtn.innerHTML = mrpackConfirmHtml; mrpackProgressWrap.classList.add('d-none'); mrpackProgressBar.style.width = '0%'; diff --git a/public/js/modpacks.js b/public/js/modpacks.js index 9286f68..31e1f84 100644 --- a/public/js/modpacks.js +++ b/public/js/modpacks.js @@ -410,8 +410,8 @@ // ── Minecraft version filter options (vanilla release list) ── (async function loadVersionFilter() { try { - var res = await fetch('/api/v1/versions?type=vanilla'); - var data = await res.json(); + var res = await apiFetch('/api/v1/versions?type=vanilla'); + var data = res.data || {}; (data.versions || []).forEach(function (v) { var opt = document.createElement('option'); opt.value = v.id; diff --git a/public/js/motd.js b/public/js/motd.js index c9c61fe..4497370 100644 --- a/public/js/motd.js +++ b/public/js/motd.js @@ -184,11 +184,9 @@ showMotdStatus('success', 'Restart the server for changes to take effect.'); showToast('MOTD saved.', 'success'); var modalEl = document.getElementById('restartModal'); - if (modalEl) { - var state = modalEl.dataset.serverState; - if (state !== 'stopped' && state !== 'crashed') { - new bootstrap.Modal(modalEl).show(); - } + // Live state — a server that has since stopped needs no restart prompt. + if (modalEl && !isServerStopped()) { + new bootstrap.Modal(modalEl).show(); } } else { saveBtn.textContent = 'Error'; diff --git a/public/js/plugins.js b/public/js/plugins.js index f3dc92f..340622b 100644 --- a/public/js/plugins.js +++ b/public/js/plugins.js @@ -52,15 +52,11 @@ var newValue = sel.value; sel.disabled = true; try { - var res = await fetch('/api/v1/servers/' + serverId + '/plugins/environment', { + var res = await apiFetch('/api/v1/servers/' + serverId + '/plugins/environment', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrf - }, - body: JSON.stringify({ filename: filename, environment: newValue }) + body: { filename: filename, environment: newValue } }); - var data = await res.json(); + var data = res.data || {}; if (res.ok && data.success) { var row = sel.closest('tr[data-filename]'); if (row) row.setAttribute('data-env', newValue); @@ -100,6 +96,7 @@ var uploaded = []; var rejected = []; + var replaced = 0; var failure = null; try { @@ -110,15 +107,15 @@ for (var i = 0; i < jarFiles.length; i++) { formData.append('files', jarFiles[i]); } - var res = await fetch('/api/v1/servers/' + serverId + '/plugins/upload', { + var res = await apiFetch('/api/v1/servers/' + serverId + '/plugins/upload', { method: 'POST', - headers: { 'X-CSRF-Token': csrf }, body: formData }); - var data = await res.json(); + var data = res.data || {}; if (res.ok && data.success) { uploaded = data.uploaded || []; rejected = rejected.concat(data.rejected || []); + replaced += data.replaced || 0; } else { failure = (data && data.error) || 'Upload failed.'; } @@ -141,6 +138,7 @@ if (result.ok && result.data && result.data.success) { uploaded = uploaded.concat(result.data.uploaded || []); rejected = rejected.concat(result.data.rejected || []); + replaced += result.data.replaced || 0; } else { failure = (result.data && result.data.error) || 'Upload failed.'; break; @@ -154,6 +152,7 @@ var uploadedCount = uploaded.length; var rejectedCount = rejected.length; var noun = uploadedCount === 1 ? contentSingular : contentLabel; + var replacedNote = replaced > 0 ? ', ' + replaced + ' replaced' : ''; if (failure && uploadedCount > 0) { // Some files landed before the failure — reload to show them. @@ -175,28 +174,40 @@ hideOverlay(); } else if (rejectedCount > 0) { // Partial success — reload to show what landed, with a warning toast. - flashToast(uploadedCount + ' ' + noun + ' uploaded, ' + rejectedCount + ' rejected.', 'warning'); + flashToast(uploadedCount + ' ' + noun + ' uploaded' + replacedNote + + ', ' + rejectedCount + ' rejected.', 'warning'); window.location.reload(); } else { // Clean success path. - flashToast(uploadedCount + ' ' + noun + ' uploaded.', 'success'); + flashToast(uploadedCount + ' ' + noun + ' uploaded' + replacedNote + '.', 'success'); window.location.reload(); } } + // Upload needs both a stopped server AND a file selection, so it can't use + // data-enable-when (which knows only about state). Re-derive it here and on + // every state change instead. + function refreshUploadBtn() { + if (!uploadBtn) return; + var stopped = isServerStopped(); + uploadBtn.disabled = !stopped || !fileInput || fileInput.files.length === 0; + uploadBtn.title = stopped ? '' : 'Stop the server to upload'; + } + if (fileInput && uploadBtn) { guardFileInput(fileInput, ['.jar'], 'Only .jar files can be uploaded.'); - fileInput.addEventListener('change', function () { - uploadBtn.disabled = fileInput.files.length === 0; - }); + fileInput.addEventListener('change', refreshUploadBtn); uploadBtn.addEventListener('click', function () { - if (fileInput.files.length === 0) return; + if (!isServerStopped() || fileInput.files.length === 0) return; uploadFiles(fileInput.files); }); } + document.addEventListener('craftbox:stategates', refreshUploadBtn); + refreshUploadBtn(); + // ── Drag & Drop ── // Always prevent default drop behavior so Chrome doesn't open files in a new tab @@ -209,7 +220,9 @@ document.addEventListener('dragenter', function (e) { e.preventDefault(); - if (isOverlayVisible()) return; + // Dropping only uploads while the server is stopped, so don't invite + // it otherwise. Checked live rather than at render time. + if (isOverlayVisible() || !isServerStopped()) return; dragCounter++; if (dragCounter === 1) { dropOverlay.classList.remove('d-none'); @@ -233,6 +246,10 @@ dropOverlay.classList.add('d-none'); dropOverlay.classList.remove('d-flex'); + if (!isServerStopped()) { + showToast('Stop the server before uploading ' + contentLabel + '.', 'danger'); + return; + } if (e.dataTransfer && e.dataTransfer.files.length > 0) { uploadFiles(e.dataTransfer.files); } @@ -265,16 +282,12 @@ confirmDeleteBtn.innerHTML = ' Deleting...'; try { - var res = await fetch('/api/v1/servers/' + serverId + '/plugins/delete', { + var res = await apiFetch('/api/v1/servers/' + serverId + '/plugins/delete', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrf - }, - body: JSON.stringify({ filename: pendingDeleteFilename }) + body: { filename: pendingDeleteFilename } }); - var data = await res.json(); + var data = res.data || {}; if (res.ok && data.success) { bsDeleteModal.hide(); flashToast(contentSingularCap + ' deleted.', 'success'); @@ -313,16 +326,12 @@ showOverlay('Deleting all ' + uploadLabel + '...', 'Please wait while all files are removed.'); try { - var res = await fetch('/api/v1/servers/' + serverId + '/plugins/delete-all', { + var res = await apiFetch('/api/v1/servers/' + serverId + '/plugins/delete-all', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrf - }, - body: JSON.stringify({}) + body: {} }); - var data = await res.json(); + var data = res.data || {}; if (res.ok && data.success) { flashToast('All ' + contentLabel + ' deleted.', 'success'); window.location.reload(); diff --git a/public/js/restart-modal.js b/public/js/restart-modal.js index 86eb987..9767156 100644 --- a/public/js/restart-modal.js +++ b/public/js/restart-modal.js @@ -42,8 +42,8 @@ window.history.replaceState({}, '', window.location.pathname); - var serverState = modalEl.dataset.serverState; - if (serverState === 'stopped' || serverState === 'crashed') return; + // Live state — the server may have stopped between the save and this check. + if (isServerStopped()) return; new bootstrap.Modal(modalEl).show(); })(); diff --git a/public/js/serverState.js b/public/js/serverState.js index 4a76089..f9500a6 100644 --- a/public/js/serverState.js +++ b/public/js/serverState.js @@ -50,10 +50,18 @@ if (msg.type === 'operation' && msg.serverId === serverId) { document.dispatchEvent(new CustomEvent('craftbox:operation', { detail: msg })); } + // Relayed for the event log page, which renders live rows from these. + if (msg.type === 'event' && msg.serverId === serverId) { + document.dispatchEvent(new CustomEvent('craftbox:event', { detail: msg })); + } + if (msg.type === 'events_cleared' && msg.serverId === serverId) { + document.dispatchEvent(new CustomEvent('craftbox:events-cleared', { detail: msg })); + } }; ws.onclose = function () { reconnectAttempts++; + probeSessionAfterFailures(reconnectAttempts); var delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); setTimeout(connect, delay); }; @@ -73,11 +81,35 @@ badge.id = 'server-state-badge'; if (stateIconEl) stateIconEl.textContent = icon; if (stateTextEl) stateTextEl.textContent = displayName; + + // Re-gate every state-dependent control on the page (see applyStateGates + // in app.js). Without this the page's disabled controls stay frozen at + // whatever the state was when it was rendered. + document.dispatchEvent(new CustomEvent('craftbox:state', { + detail: { serverId: serverId, state: state } + })); } connect(); })(); +// ── Unlock the page when provisioning finishes ── +// The nav links and the console's action buttons are rendered server-side from +// the provisioning state, and the management pages are closed behind +// blockWhileProvisioning. Rather than patch each of those live, reload once the +// server leaves provisioning so everything re-renders from the real state. +// Both state updaters (this file's and console.js's) write data-state on the +// nav header, so watching that attribute covers every server page. +(function () { + var navHeader = document.getElementById('server-nav-header'); + if (!navHeader || navHeader.dataset.state !== 'provisioning') return; + + new MutationObserver(function () { + if (navHeader.dataset.state === 'provisioning') return; + window.location.reload(); + }).observe(navHeader, { attributes: true, attributeFilter: ['data-state'] }); +})(); + // ── Live version label ── // The nav header's " " text goes stale when a version upgrade // finishes. Runs on every server sub-page, including the console page (where diff --git a/public/js/status.js b/public/js/status.js index ec70b38..241edbf 100644 --- a/public/js/status.js +++ b/public/js/status.js @@ -42,17 +42,6 @@ // Re-tick every 30s so "just now" rolls over to "1m ago", "2m ago", ... setInterval(refreshEventTimes, 30000); - function timeAgo(date) { - var seconds = Math.floor((Date.now() - date.getTime()) / 1000); - if (seconds < 60) return 'just now'; - var minutes = Math.floor(seconds / 60); - if (minutes < 60) return minutes + 'm ago'; - var hours = Math.floor(minutes / 60); - if (hours < 24) return hours + 'h ago'; - var days = Math.floor(hours / 24); - return days + 'd ago'; - } - // Collect all server IDs on the page function getServerIds() { var cards = document.querySelectorAll('[data-server-id]'); diff --git a/src/mc/BackupScheduler.js b/src/mc/BackupScheduler.js index d3ca199..337af7d 100644 --- a/src/mc/BackupScheduler.js +++ b/src/mc/BackupScheduler.js @@ -6,11 +6,33 @@ const { STATES } = require('./stateMachine'); /** * Check if a Minecraft version supports tellraw (added in 1.7.2). - * Falls back to false for unparseable or missing versions. + * + * Handles both id eras: legacy 1.x.y and the year.drop.patch versioning that + * started in 2026 ("26.2"), where the leading number is a year rather than a + * fixed 1. Snapshot ids ("25w03a") map by snapshot year; pre/rc suffixes + * resolve like their target release. + * + * Falls back to false (plain `say`) for missing, pre-1.0 or unparseable ids. */ function supportsTellraw(version) { if (!version || typeof version !== 'string') return false; - const parts = version.split('.').map(Number); + + const snapshot = /^(\d{2})w\d{2}/.exec(version); + if (snapshot) { + // 1.7.2 landed late in 2013, so only 14w+ is unambiguously post-tellraw + return parseInt(snapshot[1], 10) >= 14; + } + + const cleaned = version.replace(/[ _-]?(?:pre|rc).*$/i, ''); + const parts = cleaned.split('.').map(Number); + if (parts.some(Number.isNaN)) return false; // pre-1.0 ids ("b1.7.3", "rd-132211") + + const major = parts[0] || 0; + if (major > 1) return true; // year.drop.patch era (26.x+) — well past 1.7.2 + if (major < 1) return false; // classic/indev ids ("0.30") + + // Legacy 1.x.y versioning. NeoForge-style pseudo ids ("1.26.1" for MC 26.1) + // land here too and still read as newer than 1.7.2. const minor = parts[1] || 0; const patch = parts[2] || 0; if (minor > 7) return true; @@ -47,8 +69,12 @@ class BackupScheduler { for (const row of all) { const server = row.value; if (server?.backupSchedule?.enabled) { - this.startSchedule(server.id); + // Catch up first. A catch-up backup resets the cycle and clears + // the stored due time, so starting the schedule before it would + // have this boot's timers counting down from a time that has + // just been superseded. await this._catchUpIfMissed(server); + await this.startSchedule(server.id); } } } catch (err) { @@ -56,58 +82,108 @@ class BackupScheduler { } } + /** + * Read-modify-write the stored due time, awaited. + * + * Pass null to remove it. Re-reads the record rather than writing back a + * caller's copy, so this never reinstates fields that changed in between, + * and does nothing at all if the server has since been deleted. + */ + async _persistNextBackupAt(serverId, nextBackupAt) { + try { + const server = await serversDb.get(`server_${serverId}`); + if (!server?.backupSchedule) return; + if (nextBackupAt) { + server.backupSchedule.nextBackupAt = nextBackupAt.toISOString(); + } else { + delete server.backupSchedule.nextBackupAt; + } + await serversDb.set(`server_${serverId}`, server); + } catch (err) { + log('error', `Failed to persist nextBackupAt for ${serverId}: ${err.message}`); + } + } + /** * If a scheduled backup was missed while Craftbox was offline, run one now. - * A backup is "missed" when the last scheduled backup is older than the interval. + * + * `nextBackupAt` is the schedule's own answer to when the next backup is due, + * and the only thing that knows about deferrals — re-saving a schedule pushes + * the due time out by a full interval. So a stored time still in the future + * means nothing was missed, however old the last backup happens to be. + * Measuring from the last backup instead is what used to run a deferred + * backup on the old timing the first time Craftbox restarted. + * + * Only a record with no stored time falls back to that measurement: a + * schedule enabled before this field existed, or one that has not yet + * completed a cycle. */ async _catchUpIfMissed(server) { try { const schedule = server.backupSchedule; const intervalMs = (schedule.intervalHours || 24) * 60 * 60 * 1000; + const dueAt = schedule.nextBackupAt ? new Date(schedule.nextBackupAt).getTime() : NaN; + let missedSince; + + if (!Number.isNaN(dueAt)) { + if (dueAt > Date.now()) return; // still ahead of us — nothing was missed + missedSince = `due ${new Date(dueAt).toISOString()}`; + } else { + const backups = await listBackups(server.id); + const lastScheduled = backups.find(b => b.type === 'scheduled'); + + if (!lastScheduled) { + // No scheduled backup has ever been made — don't force one on first boot + return; + } - const backups = await listBackups(server.id); - const lastScheduled = backups.find(b => b.type === 'scheduled'); + const timeSinceLast = Date.now() - new Date(lastScheduled.createdAt).getTime(); + if (timeSinceLast <= intervalMs) return; + missedSince = `last: ${lastScheduled.createdAt}`; + } - if (!lastScheduled) { - // No scheduled backup has ever been made — don't force one on first boot + if (isBackupInProgress(server.id)) { + log('info', `[${server.name}] Skipping catch-up backup: another backup is already in progress.`); return; } + if (this.serverManager.getState(server) === STATES.PROVISIONING) { + log('info', `[${server.name}] Skipping catch-up backup: server is still provisioning.`); + return; + } + log('info', `[${server.name}] Missed scheduled backup detected (${missedSince}). Creating catch-up backup...`); + + // Stop server if running before creating backup + const proc = this.serverManager.getProcess(server.id); + const wasRunning = proc && proc.state === STATES.RUNNING; + if (wasRunning) { + log('info', `[${server.name}] Stopping server for catch-up backup...`); + await this.serverManager.stopServer(server.id, { initiatedBy: 'Backup Scheduler' }); + await proc.waitForState(STATES.STOPPED, 60000); + } - const timeSinceLast = Date.now() - new Date(lastScheduled.createdAt).getTime(); - if (timeSinceLast > intervalMs) { - if (isBackupInProgress(server.id)) { - log('info', `[${server.name}] Skipping catch-up backup: another backup is already in progress.`); - return; - } - log('info', `[${server.name}] Missed scheduled backup detected (last: ${lastScheduled.createdAt}). Creating catch-up backup...`); - - // Stop server if running before creating backup - const proc = this.serverManager.getProcess(server.id); - const wasRunning = proc && proc.state === STATES.RUNNING; - if (wasRunning) { - log('info', `[${server.name}] Stopping server for catch-up backup...`); - await this.serverManager.stopServer(server.id, { initiatedBy: 'Backup Scheduler' }); - await proc.waitForState(STATES.STOPPED, 60000); - } + await this.serverManager.setOperationalState(server.id, STATES.BACKING_UP); + try { + const backup = await createBackup(server.id, 'Scheduled Backup (Catch-up)', 'scheduled'); + await applyRetention(server.id, schedule.retentionCount || 0, schedule.retentionDays || 0); + logEvent(server.id, 'backup_create', `Scheduled backup created (${formatSize(backup.size)})`, { initiatedBy: 'Backup Scheduler' }).catch(() => {}); + log('info', `[${server.name}] Catch-up backup completed.`); + } catch (err) { + log('error', `[${server.name}] Catch-up backup failed: ${err.message}`); + logEvent(server.id, 'backup_create_fail', `Scheduled backup failed: ${err.message}`, { initiatedBy: 'Backup Scheduler' }).catch(() => {}); + } finally { + await this.serverManager.setOperationalState(server.id, STATES.STOPPED); + } - await this.serverManager.setOperationalState(server.id, STATES.BACKING_UP); - try { - const backup = await createBackup(server.id, 'Scheduled Backup (Catch-up)', 'scheduled'); - await applyRetention(server.id, schedule.retentionCount || 0, schedule.retentionDays || 0); - logEvent(server.id, 'backup_create', `Scheduled backup created (${formatSize(backup.size)})`, { initiatedBy: 'Backup Scheduler' }).catch(() => {}); - log('info', `[${server.name}] Catch-up backup completed.`); - } catch (err) { - log('error', `[${server.name}] Catch-up backup failed: ${err.message}`); - logEvent(server.id, 'backup_create_fail', `Scheduled backup failed: ${err.message}`, { initiatedBy: 'Backup Scheduler' }).catch(() => {}); - } finally { - await this.serverManager.setOperationalState(server.id, STATES.STOPPED); - } + // The cycle restarts from this backup, so drop the due time it just + // satisfied. startSchedule runs straight after and would otherwise + // read a time already in the past and fire again on its 1s grace. + await this._persistNextBackupAt(server.id, null); + if (server.backupSchedule) delete server.backupSchedule.nextBackupAt; - // Restart if it was running before - if (wasRunning) { - log('info', `[${server.name}] Restarting server after catch-up backup...`); - await this.serverManager.startServer(server.id, { initiatedBy: 'Backup Scheduler' }); - } + // Restart if it was running before + if (wasRunning) { + log('info', `[${server.name}] Restarting server after catch-up backup...`); + await this.serverManager.startServer(server.id, { initiatedBy: 'Backup Scheduler' }); } } catch (err) { log('error', `[${server.name}] Catch-up backup check failed: ${err.message}`); @@ -173,12 +249,7 @@ class BackupScheduler { entry.nextBackupAt = nextBackupAt; // Persist nextBackupAt to DB so it survives restarts - serversDb.get(`server_${serverId}`).then(server => { - if (server?.backupSchedule) { - server.backupSchedule.nextBackupAt = nextBackupAt.toISOString(); - serversDb.set(`server_${serverId}`, server); - } - }).catch(err => log('error', `Failed to persist nextBackupAt for ${serverId}: ${err.message}`)); + this._persistNextBackupAt(serverId, nextBackupAt); // Schedule countdown to start at (delay - countdown) before backup const countdownDelay = Math.max(effectiveDelay - countdownMs, 0); @@ -210,7 +281,15 @@ class BackupScheduler { } /** - * Stop the backup schedule for a server. + * Stop the backup schedule for a server. Cancels this process's timers only — + * the stored due time is left alone deliberately. + * + * Shutdown runs through here via stopAll, and wiping the due time there is + * what made a deferred backup revert to its old timing on the next boot: the + * deferral only exists as that stored time, so erasing it left the catch-up + * check with nothing to go on. Whoever ends a schedule for real owns clearing + * it — the schedule endpoint does so on every save, and import strips any + * value carried in from another instance. */ stopSchedule(serverId) { const entry = this.timers.get(serverId); @@ -222,14 +301,6 @@ class BackupScheduler { } this.timers.delete(serverId); - // Clear persisted nextBackupAt so a stale time isn't used if re-enabled later - serversDb.get(`server_${serverId}`).then(server => { - if (server?.backupSchedule?.nextBackupAt) { - delete server.backupSchedule.nextBackupAt; - serversDb.set(`server_${serverId}`, server); - } - }).catch(err => log('error', `Failed to clear nextBackupAt for ${serverId}: ${err.message}`)); - log('info', `Backup schedule stopped for server ${serverId}`); } @@ -300,6 +371,15 @@ class BackupScheduler { return; } + // A provisioning server has no process yet, so it would otherwise fall + // into the "not running, back up directly" branch below and archive a + // half-assembled directory. setOperationalState would refuse the + // transition anyway; skip cleanly rather than logging a failure. + if (this.serverManager.getState(server) === STATES.PROVISIONING) { + log('info', `[${server.name}] Skipping scheduled backup: server is still provisioning.`); + return; + } + const schedule = server.backupSchedule || {}; const p = this.serverManager.getProcess(serverId); diff --git a/src/mc/ServerManager.js b/src/mc/ServerManager.js index f2de1fd..6a17af8 100644 --- a/src/mc/ServerManager.js +++ b/src/mc/ServerManager.js @@ -1,6 +1,6 @@ const ServerProcess = require('./ServerProcess'); const { serversDb } = require('../db'); -const { canPerformAction } = require('./stateMachine'); +const { canPerformAction, canTransition } = require('./stateMachine'); const { syncServerConfig } = require('./syncServerConfig'); const { log } = require('../utils/log'); @@ -16,6 +16,23 @@ class ServerManager { return this.processes.get(serverId) || null; } + /** + * The authoritative state for a server record: the live process state when + * one exists, otherwise the persisted one. + * + * Guards written as `proc && proc.state` silently pass when there is no + * ServerProcess — and processes are created lazily, only on start or on a + * WebSocket subscribe, so a server being provisioned in the background + * usually has none. That is exactly the case those guards most need to + * catch, so read the state through here instead. + * @param {{ id: string, state: string }} server + * @returns {string} + */ + getState(server) { + const proc = this.getProcess(server.id); + return proc ? proc.state : server.state; + } + /** * Lazily create a ServerProcess shell for subscription purposes. * Unlike _ensureProcess, this NEVER rebuilds an existing proc (it is safe @@ -39,6 +56,29 @@ class ServerManager { * state so that restored or edited config values (memory, javaArgs, * version, serverType, etc.) take effect on the next start. */ + /** + * True while a server is between the exit of its old process and the start + * of its replacement during a restart. + * + * A restart routes through `stopped`, so for roughly two seconds every + * state check says the server is stopped and every power action looks + * legal. Acting in that window rebuilds the process out from under the + * pending respawn, which then starts a second JVM on the same port and in + * the same directory. Treat it as a state conflict instead. + * @param {string} serverId + */ + isRestarting(serverId) { + return this.getProcess(serverId)?._restarting === true; + } + + /** Throw a 409-tagged error if a restart is mid-flight. */ + _assertNotRestarting(serverId) { + if (!this.isRestarting(serverId)) return; + const err = new Error('Server is restarting. Wait for it to come back up.'); + err.status = 409; + throw err; + } + async _ensureProcess(serverId) { let proc = this.processes.get(serverId); @@ -78,6 +118,9 @@ class ServerManager { * @param {{ initiatedBy?: string }} [opts] */ async startServer(serverId, opts = {}) { + // Before _ensureProcess: a restarting server reads as `stopped`, so the + // rebuild branch would fire and orphan the pending respawn timer. + this._assertNotRestarting(serverId); const proc = await this._ensureProcess(serverId); if (!canPerformAction(proc.state, 'start')) { @@ -94,6 +137,7 @@ class ServerManager { * @param {{ initiatedBy?: string }} [opts] */ async stopServer(serverId, opts = {}) { + this._assertNotRestarting(serverId); const proc = this.getProcess(serverId); if (!proc) throw new Error('Server is not running.'); @@ -111,6 +155,7 @@ class ServerManager { * @param {{ initiatedBy?: string }} [opts] */ async restartServer(serverId, opts = {}) { + this._assertNotRestarting(serverId); const proc = this.getProcess(serverId); if (!proc) throw new Error('Server is not running.'); @@ -128,6 +173,7 @@ class ServerManager { * @param {{ initiatedBy?: string }} [opts] */ async killServer(serverId, opts = {}) { + this._assertNotRestarting(serverId); const proc = this.getProcess(serverId); if (!proc) throw new Error('Server is not running.'); @@ -165,6 +211,33 @@ class ServerManager { const server = await serversDb.get(`server_${serverId}`); if (!server) throw new Error('Server not found.'); + // A restarting server reads as `stopped` for a couple of seconds, which + // would otherwise let a backup or jar upgrade claim it — and then be + // overwritten when the respawn lands. Crash reporting still gets through, + // since a restart that dies on the way back up must be recordable. + if (this.isRestarting(serverId) && newState !== STATES.CRASHED) { + const err = new Error('Server is restarting. Wait for it to come back up.'); + err.status = 409; + throw err; + } + + // Honour the transition table. This used to write any allowed target + // state unconditionally, which meant every backup/restore/jar-upgrade + // flow bypassed the state machine entirely — a provisioning server + // could be moved straight to backing_up and then reported as stopped + // while its directory was still being assembled. + // Re-asserting the current state stays a no-op: several failure paths + // set stopped defensively without knowing whether it is already set. + const current = this.getState(server); + if (current !== newState && !canTransition(current, newState)) { + // Tagged 409 so routes report a state conflict rather than a 500 — + // this fires when two operations race, which is the caller's + // problem to retry, not a server fault. + const err = new Error(`Cannot move server from ${current} to ${newState}.`); + err.status = 409; + throw err; + } + server.state = newState; if (newState === STATES.CRASHED && opts.crashReason) { server.crashReason = opts.crashReason; diff --git a/src/mc/ServerProcess.js b/src/mc/ServerProcess.js index fbbeb1c..11feac7 100644 --- a/src/mc/ServerProcess.js +++ b/src/mc/ServerProcess.js @@ -33,6 +33,13 @@ class ServerProcess extends EventEmitter { this._stopRequested = false; this._restartPending = false; this._restartStarting = false; // Suppress "started" event after restart + // True from the moment a restarting process exits until it is running + // again. A restart passes through `stopped` on its way back up, and that + // state is broadcast — without this flag the panel and the API would + // both treat that instant as "the server is stopped, you may start it", + // which races the respawn. Carried on every state broadcast. + this._restarting = false; + this._restartTimer = null; // Pending respawn; cleared if the process is destroyed this._crashDetected = false; // Set when crash report is detected in logs this._oomKillInProgress = false; // Guards against multiple OOM kill attempts this._initiatedBy = null; // Who triggered the current action (username or system label) @@ -88,6 +95,14 @@ class ServerProcess extends EventEmitter { if (this._restartStarting && newState === STATES.RUNNING) { this._restartStarting = false; } + // The guard exists only to cover the `stopped` gap before the respawn. + // Once the server leaves that state the process exists again and the + // normal rules apply — keeping it raised through `starting` would leave + // Stop and Kill disabled for the whole boot, which on a large modpack + // is minutes. + if (this._restarting && newState !== STATES.STOPPED) { + this._restarting = false; + } const eventTypes = { [STATES.RUNNING]: 'started', [STATES.STOPPED]: 'stopped', @@ -99,16 +114,9 @@ class ServerProcess extends EventEmitter { ? { initiatedBy: this._initiatedBy } : {}; const eventMessage = `Server ${eventTypes[newState]}`; + // logEvent broadcasts the event itself (see utils/eventLogger). logEvent(this.id, eventTypes[newState], eventMessage, extra).catch(() => {}); pruneEvents(this.id, 500).catch(() => {}); - - this.broadcast({ - type: 'event', - serverId: this.id, - eventType: eventTypes[newState], - message: eventMessage, - createdAt: new Date().toISOString() - }); } // Broadcast state change to all WebSocket subscribers @@ -116,6 +124,7 @@ class ServerProcess extends EventEmitter { type: 'state', serverId: this.id, state: newState, + restarting: this._restarting, lastStarted: this.config.lastStarted || null, exitCode: this.config.exitCode ?? null, crashReason: this.config.crashReason ?? null @@ -476,6 +485,7 @@ class ServerProcess extends EventEmitter { type: 'subscribed', serverId: this.id, state: this.state, + restarting: this._restarting, lastStarted: this.config.lastStarted || null, history: this.lastLines.slice(-200), players: sortedPlayers, @@ -669,6 +679,10 @@ class ServerProcess extends EventEmitter { // Clean shutdown — user requested stop this.config.exitCode = code; this.config.crashReason = null; + // Raise before the transition so the `stopped` broadcast below + // already carries restarting:true — clients must never see a bare + // `stopped` for a server that is on its way back up. + this._restarting = this._restartPending; await this._setStateRobust(STATES.STOPPED); // Update DB @@ -691,15 +705,19 @@ class ServerProcess extends EventEmitter { log('info', `[${this.config.name}] Restarting server...`); this._appendLine('[Craftbox] Restarting server...'); const extra = this._initiatedBy ? { initiatedBy: this._initiatedBy } : {}; + // logEvent broadcasts the event itself. logEvent(this.id, 'restarted', 'Server restarted', extra).catch(() => {}); - this.broadcast({ - type: 'event', - serverId: this.id, - eventType: 'restarted', - message: 'Server restarted', - createdAt: new Date().toISOString() - }); - setTimeout(() => this.start(), 2000); + this._restartTimer = setTimeout(() => { + this._restartTimer = null; + this.start().catch((err) => { + // The respawn is fire-and-forget; if it fails, drop the + // restart guard so the server isn't stuck refusing every + // power action. + this._restarting = false; + log('error', `[${this.config.name}] Restart failed: ${err.message}`); + this._appendLine(`[Craftbox] Restart failed: ${err.message}`); + }); + }, 2000); } } else if (isCrash) { // Crash or unexpected exit @@ -807,15 +825,8 @@ class ServerProcess extends EventEmitter { ? { initiatedBy: this._initiatedBy } : {}; const eventMessage = `Server ${eventTypes[targetState]} (forced from ${oldState})`; + // logEvent broadcasts the event itself. logEvent(this.id, eventTypes[targetState], eventMessage, extra).catch(() => {}); - - this.broadcast({ - type: 'event', - serverId: this.id, - eventType: eventTypes[targetState], - message: eventMessage, - createdAt: new Date().toISOString() - }); } // Broadcast state change @@ -862,6 +873,15 @@ class ServerProcess extends EventEmitter { * Clean up resources. */ destroy() { + // Cancel a pending restart respawn. Without this the timer survives the + // process object and starts a JVM in a directory that may since have + // been deleted, or alongside a replacement process. + if (this._restartTimer) { + clearTimeout(this._restartTimer); + this._restartTimer = null; + } + this._restarting = false; + this._restartPending = false; if (this.child) { this._killTree(); } diff --git a/src/mc/serverTypes/_channels.js b/src/mc/serverTypes/_channels.js index b79ef9e..ade34c3 100644 --- a/src/mc/serverTypes/_channels.js +++ b/src/mc/serverTypes/_channels.js @@ -34,4 +34,41 @@ function pickPreferredBuild(builds) { return stable || builds[0]; } -module.exports = { classifyMcId, pickPreferredBuild }; +/** + * Compare two build identifiers. Returns >0 when `a` is newer than `b`, <0 when + * older, 0 when equivalent or not comparable. + * + * Providers use two different shapes for `build`: Paper/Purpur/Folia report an + * integer build number, while Forge/NeoForge/Fabric report a dotted version + * string ("21.1.95", "0.16.9"). Comparing those with `>` compares them as text, + * so "21.1.100" > "21.1.95" is false and a genuine upgrade goes undetected — + * which is why an upgrade check appeared to work for some builds but not others. + * @param {number|string|null} a + * @param {number|string|null} b + */ +function compareBuilds(a, b) { + if (a == null || b == null) return 0; + + const aNum = Number(a); + const bNum = Number(b); + if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum; + + // Dotted versions: compare segment by segment, numerically where both + // segments are numeric. A missing segment counts as 0, so "21.1" < "21.1.1". + const aParts = String(a).split(/[.\-+]/); + const bParts = String(b).split(/[.\-+]/); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const ap = aParts[i] ?? '0'; + const bp = bParts[i] ?? '0'; + const an = Number(ap); + const bn = Number(bp); + if (Number.isFinite(an) && Number.isFinite(bn)) { + if (an !== bn) return an - bn; + } else if (ap !== bp) { + return ap < bp ? -1 : 1; + } + } + return 0; +} + +module.exports = { classifyMcId, pickPreferredBuild, compareBuilds }; diff --git a/src/mc/serverTypes/fabric.js b/src/mc/serverTypes/fabric.js index 64ef9c0..30801cb 100644 --- a/src/mc/serverTypes/fabric.js +++ b/src/mc/serverTypes/fabric.js @@ -36,6 +36,21 @@ module.exports = { return null; }, + // Fabric records the loader version as its build (see downloadJar below, and + // modpacks which pin fabric-loader exactly). getBuilds stays null so the + // create/edit version picker keeps auto-selecting, but the upgrade check + // needs to know what the newest loader is — otherwise a Fabric server can + // never be told an update exists. + async getLatestBuild() { + const res = await fetch(`${BASE}/versions/loader`); + if (!res.ok) throw new Error(`Failed to fetch Fabric loader versions: HTTP ${res.status}`); + const loaders = await res.json(); + + const stable = loaders.find(l => l.stable) || loaders[0]; + if (!stable) return null; + return { build: stable.version, channel: stable.stable ? 'stable' : 'beta' }; + }, + async downloadJar(version, build, destPath) { // Honor a pinned loader version (modpacks pin fabric-loader exactly); // otherwise use the latest stable loader. diff --git a/src/middleware/blockWhileProvisioning.js b/src/middleware/blockWhileProvisioning.js new file mode 100644 index 0000000..274e02d --- /dev/null +++ b/src/middleware/blockWhileProvisioning.js @@ -0,0 +1,30 @@ +const { serversDb } = require('../db'); +const { STATES } = require('../mc/stateMachine'); + +// Close the management pages while a server is still being provisioned. +// +// Provisioning means the panel is mid-way through assembling the server +// directory — downloading a jar, extracting a modpack, unpacking a transfer +// archive. Settings, Properties, Plugins, Files and Backups all read or write +// those files, so acting on them races the provisioning job and reports state +// that isn't true yet. Console and Events stay open so the user can watch it +// finish. +// +// Mount after ensureAuth on routes carrying a :id param. +module.exports = async function blockWhileProvisioning(req, res, next) { + const id = req.params.id; + if (!id) return next(); + + const server = await serversDb.get(`server_${id}`); + // Unknown server — let the route render its own 404. + if (!server) return next(); + + const proc = req.app.get('serverManager')?.getProcess(id); + const state = proc ? proc.state : server.state; + if (state !== STATES.PROVISIONING) return next(); + + req.session.flash = { + info: 'This server is still being set up. Management pages open once it finishes.' + }; + return res.redirect(`/servers/${id}`); +}; diff --git a/src/routes/api-v1/backups.js b/src/routes/api-v1/backups.js index 582bc15..cf7f2b6 100644 --- a/src/routes/api-v1/backups.js +++ b/src/routes/api-v1/backups.js @@ -1,4 +1,6 @@ const express = require('express'); +const fs = require('fs'); +const contentDisposition = require('content-disposition'); const router = express.Router(); const { serversDb, backupsDb } = require('../../db'); const { log } = require('../../utils/log'); @@ -11,7 +13,8 @@ const { applyRetention, formatSize, tryAcquireBackupLock, - releaseBackupLock + releaseBackupLock, + resolveBackupPath } = require('../../mc/BackupManager'); const { STATES } = require('../../mc/stateMachine'); const { syncServerConfig } = require('../../mc/syncServerConfig'); @@ -45,6 +48,46 @@ router.get('/servers/:id/backups', async (req, res) => { res.json({ backups: backupsFormatted }); }); +// GET /servers/:id/backups/:backupId/download — Stream a backup archive. +router.get('/servers/:id/backups/:backupId/download', async (req, res) => { + if (!UUID_RE.test(req.params.backupId)) { + return res.status(400).json({ error: 'Invalid backup ID.' }); + } + const server = await getServerWithState(req); + if (!server) return res.status(404).json({ error: 'Server not found.' }); + + // Check ownership, not just existence — a backup id from another server + // must not be readable through this server's route. + const backup = await backupsDb.get(`backup_${req.params.backupId}`); + if (!backup || backup.serverId !== server.id) { + return res.status(404).json({ error: 'Backup not found.' }); + } + + let zipPath; + try { + zipPath = resolveBackupPath(server.id, backup.filename); + } catch { + return res.status(403).json({ error: 'Access denied.' }); + } + if (!fs.existsSync(zipPath)) { + return res.status(404).json({ error: 'Backup file not found on disk.' }); + } + + const safeName = server.name.replace(/[^a-zA-Z0-9_-]/g, '_'); + const safeFilename = backup.filename.replace(/[^a-zA-Z0-9._-]/g, '_'); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', contentDisposition(`${safeName}_backup_${safeFilename}`)); + res.setHeader('Content-Length', backup.size); + + const stream = fs.createReadStream(zipPath); + stream.on('error', (err) => { + log('error', `Backup download error: ${err.message}`); + if (!res.headersSent) res.status(500).json({ error: 'Download failed.' }); + }); + stream.pipe(res); +}); + // POST /servers/:id/backups — Kick off a manual backup. Returns 202 immediately; // completion (or failure) is reported via the per-server WebSocket as // { type: 'operation', operation: 'backup', status: 'complete'|'failed', ... }. @@ -62,7 +105,16 @@ router.post('/servers/:id/backups', async (req, res) => { backupName = 'Manual Backup'; } - if (proc && ![STATES.STOPPED, STATES.CRASHED].includes(proc.state) && !stopFirst) { + // getServerWithState already overlaid the live state, so read it from the + // record rather than from `proc` — a provisioning server normally has no + // process yet, and a `proc &&` guard would wave it straight through. + if (server.state === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + + // stopFirst deliberately does not bypass the provisioning check above: it + // means "stop a running server for me", not "interrupt whatever is going on". + if (![STATES.STOPPED, STATES.CRASHED].includes(server.state) && !stopFirst) { return res.status(409).json({ error: 'Server must be stopped to create a backup.' }); } @@ -73,7 +125,7 @@ router.post('/servers/:id/backups', async (req, res) => { let lockOwnedByRoute = true; try { - if (proc && (proc.state === STATES.RUNNING || proc.state === STATES.STARTING)) { + if (server.state === STATES.RUNNING || server.state === STATES.STARTING) { await serverManager.stopServer(server.id, { initiatedBy }); await proc.waitForState(STATES.STOPPED, 60000); } @@ -122,6 +174,8 @@ router.post('/servers/:id/backups', async (req, res) => { if (lockOwnedByRoute) releaseBackupLock(server.id); log('error', `Backup setup failed for ${server.name}: ${err.message}`); if (!res.headersSent) { + // A rejected state transition is a conflict, not a server fault. + if (err.status === 409) return res.status(409).json({ error: err.message }); res.status(500).json({ error: `Backup failed: ${err.message}` }); } } @@ -143,8 +197,14 @@ router.post('/servers/:id/backups/:backupId/restore', async (req, res) => { const initiatedBy = req.user.username; const backupId = req.params.backupId; + // Restoring over a directory that is still being assembled would race the + // provisioning job and leave a half-built server behind. + if (server.state === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + try { - if (proc && (proc.state === STATES.RUNNING || proc.state === STATES.STARTING)) { + if (server.state === STATES.RUNNING || server.state === STATES.STARTING) { await serverManager.stopServer(server.id, { initiatedBy }); await proc.waitForState(STATES.STOPPED, 60000); } @@ -189,6 +249,7 @@ router.post('/servers/:id/backups/:backupId/restore', async (req, res) => { } catch (err) { log('error', `Restore setup failed for ${server.name}: ${err.message}`); if (!res.headersSent) { + if (err.status === 409) return res.status(409).json({ error: err.message }); res.status(500).json({ error: `Restore failed: ${err.message}` }); } } diff --git a/src/routes/api-v1/plugins.js b/src/routes/api-v1/plugins.js index 5011595..35b530b 100644 --- a/src/routes/api-v1/plugins.js +++ b/src/routes/api-v1/plugins.js @@ -12,9 +12,12 @@ const { DISABLED_SUFFIX, setModEnv, clearModEnv, - clearAllModEnv + clearAllModEnv, + listModFiles, + getModEnvMap } = require('../../utils/modEnvironment'); const { isPathInside } = require('../../utils/pathSafety'); +const { formatSize } = require('../../utils/resourceStats'); const { cleanupTempFiles, isZipFile } = require('../../utils/uploadSafety'); const { createDgupRouter, multerShim } = require('../../middleware/dgup'); @@ -76,6 +79,7 @@ const uploadPluginsHandler = async (req, res) => { const uploaded = []; const rejected = []; + let replaced = 0; try { for (const file of req.files) { const safeName = path.basename(file.originalname).replace(/[/\\]/g, ''); @@ -95,7 +99,25 @@ const uploadPluginsHandler = async (req, res) => { continue; } + // One mod, one file. A disabled twin left on disk would make the + // upload land beside the copy it was meant to replace: the list + // would show the mod twice, deleting it would remove only one of + // the pair, and the environment dropdown would silently no-op + // (enableOnDisk/disableOnDisk skip a rename when both exist). + const disabledTwin = destPath + DISABLED_SUFFIX; + const hadDisabledTwin = fs.existsSync(disabledTwin); + const hadEnabled = fs.existsSync(destPath); + fs.copyFileSync(file.path, destPath); + if (hadDisabledTwin) { + fs.unlinkSync(disabledTwin); + // Uploading is an explicit "put this on the server", so the + // mod comes back as Client and Server rather than staying + // tagged client-only from its previous life. + if (contentType.label === 'Mods') await clearModEnv(server.id, safeName); + } + + if (hadEnabled || hadDisabledTwin) replaced++; uploaded.push(safeName); } } finally { @@ -108,9 +130,71 @@ const uploadPluginsHandler = async (req, res) => { if (rejected.length > 0) { log('warn', `Rejected ${rejected.length} upload(s) to server ${server.name} (${server.id}): ${rejected.map(r => `${r.name} (${r.reason})`).join(', ')}`); } - res.json({ success: true, count: uploaded.length, uploaded, rejected }); + res.json({ success: true, count: uploaded.length, uploaded, replaced, rejected }); }; +// GET /servers/:id/plugins — List installed plugins/mods. +// Unlike the mutating routes below this does not require the server to be +// stopped, and does not create the content directory: a read should not have +// side effects on disk. +router.get('/servers/:id/plugins', async (req, res) => { + try { + const server = await getServerWithState(req); + if (!server) return res.status(404).json({ error: 'Server not found.' }); + + const contentType = getContentType(server.serverType); + if (!contentType) { + return res.status(404).json({ error: 'This server type does not support plugins or mods.' }); + } + + const contentDir = path.join(path.resolve(SERVERS_DIR, server.id), contentType.folder); + if (!fs.existsSync(contentDir)) { + return res.json({ contentType: { label: contentType.label, folder: contentType.folder }, files: [] }); + } + + // 'both' is stored as the absence of a key, and a disabled jar is how a + // client-only mod is represented on disk — same derivation the plugins + // page uses, so the API and the UI never disagree. + const isMods = contentType.label === 'Mods'; + const envMap = isMods ? await getModEnvMap(server.id) : {}; + + const files = listModFiles(contentDir).map(entry => ({ + name: entry.displayName, + size: entry.size, + sizeFormatted: formatSize(entry.size), + modifiedISO: entry.modified.toISOString(), + environment: isMods + ? (entry.isDisabled ? 'client' : (envMap[entry.displayName] || 'both')) + : 'both' + })); + + res.json({ contentType: { label: contentType.label, folder: contentType.folder }, files }); + } catch (err) { + log('error', `Failed to list plugins for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to list plugins.' }); + } +}); + +// GET /servers/:id/plugins/environment — Read the mod environment map. +// Only the non-default entries are stored, so a mod missing from the map is +// 'both'. Mods-type servers only; plugin loaders have no environment concept. +router.get('/servers/:id/plugins/environment', async (req, res) => { + try { + const server = await getServerWithState(req); + if (!server) return res.status(404).json({ error: 'Server not found.' }); + + const contentType = getContentType(server.serverType); + if (!contentType || contentType.label !== 'Mods') { + return res.status(400).json({ error: 'This server type does not support mod environments.' }); + } + + res.json({ environment: await getModEnvMap(server.id) }); + } catch (err) { + log('error', `Failed to read mod environment for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to read mod environment.' }); + } +}); + // POST /servers/:id/plugins/upload — Upload JAR file(s) (single multipart request) router.post('/servers/:id/plugins/upload', multerShim(upload.any()), uploadPluginsHandler); @@ -163,17 +247,20 @@ router.post('/servers/:id/plugins/delete', async (req, res) => { return res.status(403).json({ error: 'Access denied.' }); } + // One row can stand for both forms on disk, so delete every one of them — + // removing just the enabled half would leave the disabled twin behind and + // the mod would reappear on the next load. const disabledPath = targetPath + DISABLED_SUFFIX; - const existingPath = fs.existsSync(targetPath) && !fs.statSync(targetPath).isDirectory() - ? targetPath - : (fs.existsSync(disabledPath) && !fs.statSync(disabledPath).isDirectory() ? disabledPath : null); + const existingPaths = [targetPath, disabledPath].filter(p => { + try { return !fs.statSync(p).isDirectory(); } catch { return false; } + }); - if (!existingPath) { + if (existingPaths.length === 0) { return res.status(404).json({ error: 'File not found.' }); } try { - fs.unlinkSync(existingPath); + for (const p of existingPaths) fs.unlinkSync(p); if (contentType.label === 'Mods') { await clearModEnv(server.id, safeName); } diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index 0265628..9e070e0 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -4,10 +4,18 @@ const fs = require('fs'); const path = require('path'); const multer = require('multer'); const StreamZip = require('node-stream-zip'); +const archiver = require('archiver'); +const contentDisposition = require('content-disposition'); const { v4: uuidv4 } = require('uuid'); const router = express.Router(); const { serversDb, backupsDb, eventsDb, SERVERS_DIR } = require('../../db'); -const { ensureBackupDir, resolveBackupPath } = require('../../mc/BackupManager'); +const { + ensureBackupDir, + resolveBackupPath, + listBackups, + tryAcquireBackupLock, + releaseBackupLock +} = require('../../mc/BackupManager'); const { getProvider, listProviders } = require('../../mc/serverTypes'); const { downloadServerJar } = require('../../mc/downloader'); const { log } = require('../../utils/log'); @@ -19,15 +27,20 @@ const { setServerIcon, resetServerIcon, removeServerIcon, getIconPath, copyDefau const { writeServerProperties, writeEula, parseServerProperties, updateServerProperties } = require('../../mc/serverProperties'); const { PROPERTY_META } = require('../../mc/propertyMeta'); const { getContentType } = require('../../utils/contentType'); -const { copyModEnvMap, setModEnvMap } = require('../../utils/modEnvironment'); -const { isZipFile } = require('../../utils/uploadSafety'); +const { copyModEnvMap, setModEnvMap, getModEnvMap } = require('../../utils/modEnvironment'); +const { isZipFile, cleanupTempFiles } = require('../../utils/uploadSafety'); const { createDgupRouter, multerShim } = require('../../middleware/dgup'); const { syncServerConfig } = require('../../mc/syncServerConfig'); const { STATES } = require('../../mc/stateMachine'); const { isPathInside } = require('../../utils/pathSafety'); const { normalizeGroupName, getGroupColor, pruneGroupMetaIfEmpty, GROUP_NAME_ERROR } = require('../../utils/serverGroups'); const { MC_VERSION_RE, isReleaseVersion } = require('../../utils/mcVersion'); -const { pickPreferredBuild } = require('../../mc/serverTypes/_channels'); +const { pickPreferredBuild, compareBuilds } = require('../../mc/serverTypes/_channels'); +const { + isEditableFile, listDirectory, safeEntryName, newNameError, + readTextWindow, parseReadWindow, MAX_TEXT_BYTES +} = require('../../utils/fileBrowser'); +const { readConsoleTail } = require('../../utils/consoleLog'); const { cleanupServerData } = require('../../utils/serverCleanup'); const { installModpack, parseMrpack, resolveLoader, pickLoaderFromArray } = require('../../mc/modpackInstaller'); const { assertWhitelistedUrl } = require('../../utils/httpDownload'); @@ -144,6 +157,10 @@ async function runWithRestorePoint({ req, res, server, label, operation, apply } const initiatedBy = req.user.username; const { runBackupJob, tryAcquireBackupLock, releaseBackupLock, formatSize } = require('../../mc/BackupManager'); + if (serverManager.getState(server) === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!tryAcquireBackupLock(id)) { return res.status(409).json({ error: 'A backup is already in progress for this server.' }); } @@ -210,7 +227,7 @@ async function runWithRestorePoint({ req, res, server, label, operation, apply } if (lockOwnedByRoute) releaseBackupLock(id); log('error', `Restore-point setup failed for ${id}: ${err.message}`); if (!res.headersSent) { - res.status(500).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 500).json({ error: err.message }); } } } @@ -246,15 +263,6 @@ function notifyDashboard(req) { }); } -const TEXT_EXTENSIONS = new Set([ - '.txt', '.log', '.properties', '.json', '.yml', '.yaml', '.xml', - '.cfg', '.conf', '.ini', '.toml', '.csv', '.md', '.sh', '.bat', - '.cmd', '.ps1', '.js', '.ts', '.py', '.java', '.html', '.css', - '.mcmeta', '.lang', '.sk', '.nbt' -]); -function isTextFile(filename) { - return TEXT_EXTENSIONS.has(path.extname(filename).toLowerCase()); -} const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -483,31 +491,45 @@ router.get('/servers/:id/check-upgrade', async (req, res) => { const provider = getProvider(type); if (!provider) return res.json({ upgradeAvailable: false }); - if (!provider.getBuilds || type === 'custom') { + if (type === 'custom' || (!provider.getBuilds && !provider.getLatestBuild)) { return res.json({ upgradeAvailable: false, reason: 'No build tracking for this server type.' }); } - const builds = await provider.getBuilds(server.version); - if (!builds || builds.length === 0) { - return res.json({ upgradeAvailable: false }); + // Providers with no user-facing build picker (Fabric) expose the newest + // build directly instead of a list. + let preferred = null; + if (provider.getLatestBuild) { + preferred = await provider.getLatestBuild(server.version); + } else { + const builds = await provider.getBuilds(server.version); + // getBuilds includes non-stable channels — prefer the newest stable + // build so stable servers aren't offered ALPHA/BETA builds. + if (builds && builds.length > 0) preferred = pickPreferredBuild(builds); + } + if (!preferred) { + return res.json({ upgradeAvailable: false, reason: 'No builds published for this version.' }); } - // getBuilds now includes non-stable channels — prefer the newest - // stable build so stable servers aren't offered ALPHA/BETA builds. - const preferred = pickPreferredBuild(builds); const latestBuild = preferred.build; const currentBuild = server.build; + // A server imported from a .cbx, duplicated, or provisioned before build + // tracking existed can have no recorded build. Reporting "no upgrade + // available" left it permanently stuck, because upgrading is the only + // thing that records a build: upgrade-jar passes a null build to the + // provider, which installs the newest and writes it back. So offer the + // upgrade rather than refusing it. if (currentBuild == null) { return res.json({ - upgradeAvailable: false, - latestBuild, + upgradeAvailable: true, currentBuild: null, - reason: 'No build number recorded for this server.' + latestBuild, + channel: preferred.channel || null, + reason: `No build recorded for this server. Upgrading installs build ${latestBuild} and records it.` }); } - const upgradeAvailable = latestBuild !== currentBuild && latestBuild > currentBuild; + const upgradeAvailable = compareBuilds(latestBuild, currentBuild) > 0; res.json({ upgradeAvailable, currentBuild, @@ -528,8 +550,11 @@ router.post('/servers/:id/upgrade-jar', async (req, res) => { if (!server) return; const serverManager = req.app.get('serverManager'); - const proc = serverManager?.getProcess(server.id); - if (proc && !['stopped', 'crashed'].includes(proc.state)) { + const liveState = serverManager.getState(server); + if (liveState === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!['stopped', 'crashed'].includes(liveState)) { return res.status(409).json({ error: 'Stop the server before upgrading the jar.' }); } @@ -641,6 +666,7 @@ router.post('/servers/:id/upgrade-jar', async (req, res) => { if (lockOwnedByRoute) releaseBackupLock(server.id); log('error', `Jar upgrade setup failed for ${req.params.id}: ${err.message}`); if (!res.headersSent) { + if (err.status === 409) return res.status(409).json({ error: err.message }); res.status(500).json({ error: `Failed to upgrade jar: ${err.message}` }); } } @@ -738,7 +764,11 @@ router.get('/servers/:id/events', async (req, res) => { const server = await loadServerOr404(req, res); if (!server) return; - const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200); + // Same clamp shape as /console: only a missing/unparseable value takes + // the default. There was no lower bound here at all, so limit=-5 reached + // getEvents and slice(0, -5) quietly dropped the five newest events. + const rawLimit = parseInt(req.query.limit, 10); + const limit = Math.min(Math.max(Number.isNaN(rawLimit) ? 50 : rawLimit, 1), 200); const types = req.query.types ? req.query.types.split(',') : null; const events = await getEvents(server.id, { limit, types }); @@ -1534,7 +1564,7 @@ router.post('/servers/:id/start', async (req, res) => { logEvent(req.params.id, 'action', 'Server start requested', { initiatedBy: req.user.username }).catch(() => {}); res.json({ success: true, message: 'Server is starting...' }); } catch (err) { - res.status(400).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 400).json({ error: err.message }); } }); @@ -1548,18 +1578,23 @@ router.post('/servers/:id/stop', async (req, res) => { logEvent(req.params.id, 'action', 'Server stop requested', { initiatedBy: req.user.username }).catch(() => {}); res.json({ success: true, message: 'Server is stopping...' }); } catch (err) { - res.status(400).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 400).json({ error: err.message }); } }); // POST /servers/:id/restart router.post('/servers/:id/restart', async (req, res) => { - if (!await loadServerOr404(req, res)) return; + const server = await loadServerOr404(req, res); + if (!server) return; const serverManager = req.app.get('serverManager'); const id = req.params.id; const initiatedBy = req.user.username; const createBackupFirst = req.body?.backup === 'true' || req.body?.backup === true; + if (serverManager.getState(server) === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!createBackupFirst) { try { await clearStatsHistory(id); @@ -1567,7 +1602,7 @@ router.post('/servers/:id/restart', async (req, res) => { logEvent(id, 'action', 'Server restart requested', { initiatedBy }).catch(() => {}); return res.json({ success: true, message: 'Server is restarting...' }); } catch (err) { - return res.status(400).json({ error: err.message }); + return res.status(err.status === 409 ? 409 : 400).json({ error: err.message }); } } @@ -1617,7 +1652,7 @@ router.post('/servers/:id/restart', async (req, res) => { if (lockOwnedByRoute) releaseBackupLock(id); log('error', `Restart-with-backup setup failed for ${id}: ${err.message}`); if (!res.headersSent) { - res.status(500).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 500).json({ error: err.message }); } } }); @@ -1655,7 +1690,7 @@ router.post('/servers/:id/kill', async (req, res) => { logEvent(req.params.id, 'action', 'Server force-killed', { initiatedBy: req.user.username }).catch(() => {}); res.json({ success: true, message: 'Server force-killed.' }); } catch (err) { - res.status(400).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 400).json({ error: err.message }); } }); @@ -2092,6 +2127,11 @@ router.delete('/servers/:id', async (req, res) => { const serverManager = req.app.get('serverManager'); const proc = serverManager?.getProcess(id); const liveState = proc ? proc.state : server.state; + // A restarting server is momentarily `stopped`; deleting in that window + // tears down the directory while a respawn is already queued. + if (serverManager?.isRestarting(id)) { + return res.status(409).json({ error: 'Server is restarting. Wait for it to come back up.' }); + } if (!['stopped', 'crashed'].includes(liveState)) { return res.status(409).json({ error: 'Stop the server before deleting it.' }); } @@ -2335,6 +2375,693 @@ router.post('/servers/:id/properties', async (req, res) => { res.json({ success: true }); }); +// Where ServerProcess writes the console log for a server. +function consolePathFor(server) { + return path.join(path.resolve(SERVERS_DIR, server.id), 'logs', 'craftbox-console.log'); +} + +// Resolve a caller-supplied path against a server's directory. +// Returns null once a response has been sent — the caller must `return`. +function resolveServerPath(req, res, server, rawPath) { + const serverDir = path.resolve(SERVERS_DIR, server.id); + const targetPath = path.resolve(serverDir, rawPath || ''); + if (!isPathInside(serverDir, targetPath)) { + res.status(403).json({ error: 'Access denied.' }); + return null; + } + return { serverDir, targetPath }; +} + +// GET /servers/:id/files?path= — List a directory inside the server. +router.get('/servers/:id/files', async (req, res) => { + try { + const server = await loadServerOr404(req, res); + if (!server) return; + + const resolved = resolveServerPath(req, res, server, req.query.path); + if (!resolved) return; + const { targetPath } = resolved; + + if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) { + return res.status(404).json({ error: 'Directory not found.' }); + } + + res.json({ path: String(req.query.path || ''), files: listDirectory(targetPath) }); + } catch (err) { + log('error', `Failed to list files for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to list files.' }); + } +}); + +// GET /servers/:id/file?path=[&offset=&limit=|&tail=] — Read a text file. +// +// Binary files are refused here and must be fetched from /download instead; +// this mirrors what the file editor will open (see utils/fileBrowser). +// +// Unlike /download this works while the server is running, which makes it the +// way to read a log or an append-only feed a plugin is still writing to. Such +// a file has no bound worth trusting, so a whole-file read is capped and +// callers past the cap ask for a byte window instead. +router.get('/servers/:id/file', async (req, res) => { + try { + const server = await loadServerOr404(req, res); + if (!server) return; + + if (!req.query.path) return res.status(400).json({ error: 'No path specified.' }); + + const resolved = resolveServerPath(req, res, server, req.query.path); + if (!resolved) return; + const { targetPath } = resolved; + + if (!fs.existsSync(targetPath) || fs.statSync(targetPath).isDirectory()) { + return res.status(404).json({ error: 'File not found.' }); + } + if (!isEditableFile(targetPath)) { + return res.status(400).json({ error: 'This file is not text and cannot be read as text. Use /download instead.' }); + } + + const readWindow = parseReadWindow(req.query); + if (readWindow.error) return res.status(400).json({ error: readWindow.error }); + + const stat = fs.statSync(targetPath); + if (!readWindow.windowed && stat.size > MAX_TEXT_BYTES) { + return res.status(413).json({ + error: `File is ${formatSize(stat.size)}, over the ${formatSize(MAX_TEXT_BYTES)} whole-file limit. ` + + 'Read part of it with ?tail= or ?offset=&limit= (bytes).', + size: stat.size, + maxBytes: MAX_TEXT_BYTES + }); + } + + const read = readTextWindow(targetPath, readWindow); + res.json({ + file: { + name: path.basename(targetPath), + path: String(req.query.path), + size: read.size, + modifiedISO: stat.mtime.toISOString(), + offset: read.offset, + length: read.length, + truncated: read.truncated, + content: read.content + } + }); + } catch (err) { + log('error', `Failed to read file for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to read file.' }); + } +}); + +// GET /servers/:id/download?path= — Stream any single file, text or binary. +// Requires the server stopped: a running server holds handles on world data and +// jars, and on Windows reading them fails with EBUSY mid-stream. +router.get('/servers/:id/download', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + + const liveState = req.app.get('serverManager').getState(server); + if (liveState === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!['stopped', 'crashed'].includes(liveState)) { + return res.status(409).json({ error: 'Stop the server before downloading files.' }); + } + + if (!req.query.path) return res.status(400).json({ error: 'No path specified.' }); + + const resolved = resolveServerPath(req, res, server, req.query.path); + if (!resolved) return; + const { targetPath } = resolved; + + if (!fs.existsSync(targetPath) || fs.statSync(targetPath).isDirectory()) { + return res.status(404).json({ error: 'File not found.' }); + } + + res.setHeader('Content-Disposition', contentDisposition(path.basename(targetPath))); + res.setHeader('Content-Type', 'application/octet-stream'); + + const stream = fs.createReadStream(targetPath); + stream.on('error', (err) => { + if (res.headersSent) return; + if (err.code === 'EBUSY') { + res.status(409).json({ error: 'File is currently in use by the server. Try again later or stop the server first.' }); + } else { + res.status(500).json({ error: 'Failed to download file.' }); + } + }); + stream.pipe(res); +}); + +// ── File management ───────────────────────────────────────────────────────── +// Creating things (upload, mkdir) works in any state, matching /edit-file, +// which already writes into a running server's directory. Delete and rename +// are gated on a stopped server: they are the destructive pair, and a running +// server holds open handles on world data and jars. + +// No extension filter and no size cap. This is a general file manager, and an +// authenticated user can already edit any text file and download the whole +// directory — an allowlist here would be theatre rather than a boundary. +// Files stream to disk, so size is bounded by disk space, not memory. +const fileUpload = multer({ dest: os.tmpdir() }); + +// Returns false once a response has been sent — the caller must `return`. +function requireStoppedForFiles(req, res, server, verb) { + const liveState = req.app.get('serverManager').getState(server); + if (liveState === STATES.PROVISIONING) { + res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + return false; + } + if (!['stopped', 'crashed'].includes(liveState)) { + res.status(409).json({ error: `Stop the server before ${verb} files.` }); + return false; + } + return true; +} + +// The files a running server holds open: its jar, its world folders, its logs, +// and the mods/plugins the JVM has loaded. Overwriting one of these mid-session +// is what corrupts a live server — and on Linux the write SUCCEEDS silently +// rather than failing with EBUSY the way it does on Windows, so the state has +// to be checked up front instead of waiting for copyFileSync to throw. +// +// Reading server.properties per request is cheap next to the upload itself, and +// level-name can change under us, so it is resolved fresh rather than cached. +function heldOpenTargets(server, serverDir) { + const level = parseServerProperties(serverDir)['level-name'] || 'world'; + const dirs = ['logs', level, `${level}_nether`, `${level}_the_end`]; + const content = getContentType(server.serverType); + if (content) dirs.push(content.folder); + + return { + files: [path.resolve(serverDir, server.jarFile || 'server.jar')], + dirs: dirs.map(d => path.join(serverDir, d)).filter(d => fs.existsSync(d)) + }; +} + +function isHeldOpen(targets, destPath) { + const resolved = path.resolve(destPath); + return targets.files.includes(resolved) + || targets.dirs.some(dir => isPathInside(dir, resolved)); +} + +// Craftbox mirrors a few server.properties / eula.txt values in the database, +// so touching either from the file manager has to re-sync them exactly as +// /edit-file does, or the panel keeps reporting the old port and EULA state. +async function syncIfConfigFile(serverId, serverDir, ...targets) { + const touched = targets.some(p => p + && path.dirname(p) === serverDir + && ['server.properties', 'eula.txt'].includes(path.basename(p))); + if (touched) await syncServerConfig(serverId); +} + +// Shared by the multipart and DGUP paths, as uploadPluginsHandler is. +const uploadFilesHandler = async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) { + cleanupTempFiles(req.files); + return; + } + + const resolved = resolveServerPath(req, res, server, req.body.path); + if (!resolved) { + cleanupTempFiles(req.files); + return; + } + const { serverDir, targetPath: targetDir } = resolved; + + if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) { + cleanupTempFiles(req.files); + return res.status(404).json({ error: 'Directory not found.' }); + } + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded.' }); + } + + // Uploading stays allowed while a server runs (see the note above + // requireStoppedForFiles) — but replacing a file it currently holds open + // does not. New files are unaffected: nothing can be holding a handle on a + // name that isn't there yet. + const liveState = req.app.get('serverManager').getState(server); + const heldOpen = ['stopped', 'crashed'].includes(liveState) + ? null + : heldOpenTargets(server, serverDir); + + const uploaded = []; + const rejected = []; + let replaced = 0; + + try { + for (const file of req.files) { + const safeName = safeEntryName(file.originalname); + if (!safeName) { + rejected.push({ name: file.originalname, reason: 'invalid filename' }); + continue; + } + + const destPath = path.join(targetDir, safeName); + // Catches a name that survived sanitising but resolves outside the + // directory anyway — most plausibly an existing symlink at destPath. + if (!isPathInside(targetDir, destPath)) { + rejected.push({ name: safeName, reason: 'invalid path' }); + continue; + } + + let existing = null; + try { existing = fs.statSync(destPath); } catch { /* nothing there yet */ } + if (existing && existing.isDirectory()) { + rejected.push({ name: safeName, reason: 'a folder with that name already exists' }); + continue; + } + + if (existing && heldOpen && isHeldOpen(heldOpen, destPath)) { + rejected.push({ name: safeName, reason: 'file is in use by the server' }); + continue; + } + + try { + fs.copyFileSync(file.path, destPath); + } catch (err) { + // Backstop for anything the check above doesn't know to expect: + // on Windows a held-open file fails here rather than silently + // winning. One bad file shouldn't sink the batch, so report it + // like any other rejection. + rejected.push({ + name: safeName, + reason: ['EBUSY', 'EPERM', 'EACCES'].includes(err.code) + ? 'file is in use by the server' + : 'could not be written' + }); + continue; + } + + if (existing) replaced++; + uploaded.push(safeName); + } + } finally { + cleanupTempFiles(req.files); + } + + if (uploaded.length > 0) { + log('info', `Uploaded ${uploaded.length} file(s) to "${req.body.path || '/'}" ` + + `on server ${server.name} (${server.id})`); + await syncIfConfigFile(server.id, serverDir, ...uploaded.map(n => path.join(targetDir, n))); + } + + res.json({ success: true, count: uploaded.length, uploaded, replaced, rejected }); +}; + +// POST /servers/:id/files/upload — Upload file(s) into a directory. +// The `path` body field picks the destination (omitted = server root). On the +// multipart path it must precede the files in the stream, or multer will not +// have parsed it by the time the handler runs. +router.post('/servers/:id/files/upload', multerShim(fileUpload.any()), uploadFilesHandler); + +// POST /servers/:id/files/upload/{init,chunk,complete,cancel} — DGUP chunked +// upload for files too large for a single request (e.g. behind Cloudflare +// Tunnel's 100 MB body cap). complete() runs uploadFilesHandler unchanged. +router.use('/servers/:id/files/upload', createDgupRouter({ + routeKey: 'files', + field: 'files', + fileMode: 'array', + maxBytes: Infinity, + ext: null, // any file type, as above + mimetype: 'application/octet-stream', + // The destination directory rides on `complete`, not `init` (see the + // `fields` option in public/js/dgup.js), so the server's existence is the + // only thing there is to preflight here. + validate: async (req) => { + const server = await serversDb.get(`server_${req.params.id}`); + if (!server) return { status: 404, error: 'Server not found.' }; + return null; + } +}, uploadFilesHandler)); + +// POST /servers/:id/files/delete — Delete a file, or a directory and its contents. +router.post('/servers/:id/files/delete', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + if (!requireStoppedForFiles(req, res, server, 'deleting')) return; + + if (!req.body.path) return res.status(400).json({ error: 'No path specified.' }); + + const resolved = resolveServerPath(req, res, server, req.body.path); + if (!resolved) return; + const { serverDir, targetPath } = resolved; + + if (targetPath === serverDir) { + return res.status(400).json({ error: 'The server directory itself cannot be deleted.' }); + } + + // lstat, not stat: a symlink should be unlinked, not followed and emptied. + let stat; + try { + stat = fs.lstatSync(targetPath); + } catch { + return res.status(404).json({ error: 'File not found.' }); + } + + try { + if (stat.isDirectory()) { + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(targetPath); + } + } catch (err) { + if (['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(err.code)) { + return res.status(409).json({ error: 'File is currently in use by the server. Try again later or stop the server first.' }); + } + log('error', `Failed to delete ${req.body.path}: ${err.message}`); + return res.status(500).json({ error: 'Failed to delete file.' }); + } + + log('info', `Deleted ${stat.isDirectory() ? 'folder' : 'file'} "${req.body.path}" ` + + `from server ${server.name} (${server.id})`); + await syncIfConfigFile(server.id, serverDir, targetPath); + res.json({ success: true }); +}); + +// POST /servers/:id/files/rename — Rename a file or directory in place. +router.post('/servers/:id/files/rename', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + if (!requireStoppedForFiles(req, res, server, 'renaming')) return; + + if (!req.body.path) return res.status(400).json({ error: 'No path specified.' }); + + const nameError = newNameError(req.body.newName); + if (nameError) return res.status(400).json({ error: nameError }); + const newName = safeEntryName(req.body.newName); + if (!newName) return res.status(400).json({ error: 'Invalid name.' }); + + const resolved = resolveServerPath(req, res, server, req.body.path); + if (!resolved) return; + const { serverDir, targetPath } = resolved; + + if (targetPath === serverDir) { + return res.status(400).json({ error: 'The server directory itself cannot be renamed.' }); + } + if (!fs.existsSync(targetPath)) return res.status(404).json({ error: 'File not found.' }); + + const destPath = path.join(path.dirname(targetPath), newName); + if (!isPathInside(serverDir, destPath)) { + return res.status(403).json({ error: 'Access denied.' }); + } + + // Changing only the case is a legitimate rename, but on a case-insensitive + // filesystem the destination "already exists" — it is the source. + const caseOnly = destPath.toLowerCase() === targetPath.toLowerCase(); + if (!caseOnly && fs.existsSync(destPath)) { + return res.status(409).json({ error: 'Something with that name already exists here.' }); + } + + try { + fs.renameSync(targetPath, destPath); + } catch (err) { + if (['EBUSY', 'EPERM', 'EACCES'].includes(err.code)) { + return res.status(409).json({ error: 'File is currently in use by the server. Try again later or stop the server first.' }); + } + log('error', `Failed to rename ${req.body.path}: ${err.message}`); + return res.status(500).json({ error: 'Failed to rename file.' }); + } + + log('info', `Renamed "${req.body.path}" to "${newName}" on server ${server.name} (${server.id})`); + await syncIfConfigFile(server.id, serverDir, targetPath, destPath); + res.json({ success: true, name: newName }); +}); + +// POST /servers/:id/files/mkdir — Create a directory. +router.post('/servers/:id/files/mkdir', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + + const nameError = newNameError(req.body.name); + if (nameError) return res.status(400).json({ error: nameError }); + const name = safeEntryName(req.body.name); + if (!name) return res.status(400).json({ error: 'Invalid folder name.' }); + + const resolved = resolveServerPath(req, res, server, req.body.path); + if (!resolved) return; + const { targetPath: parentDir } = resolved; + + if (!fs.existsSync(parentDir) || !fs.statSync(parentDir).isDirectory()) { + return res.status(404).json({ error: 'Directory not found.' }); + } + + const destPath = path.join(parentDir, name); + if (!isPathInside(parentDir, destPath)) { + return res.status(403).json({ error: 'Access denied.' }); + } + if (fs.existsSync(destPath)) { + return res.status(409).json({ error: 'Something with that name already exists here.' }); + } + + try { + fs.mkdirSync(destPath); + } catch (err) { + log('error', `Failed to create folder ${name}: ${err.message}`); + return res.status(500).json({ error: 'Failed to create folder.' }); + } + + log('info', `Created folder "${name}" in "${req.body.path || '/'}" ` + + `on server ${server.name} (${server.id})`); + res.json({ success: true, name }); +}); + +// POST /servers/:id/files/mkfile — Create an empty file. +// +// Ungated like mkdir: a name that isn't on disk yet cannot be one the running +// server is holding open. No extension check either — the file manager already +// takes any file by upload, and the panel decides editable-vs-downloadable when +// it lists the directory. +router.post('/servers/:id/files/mkfile', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + + const nameError = newNameError(req.body.name); + if (nameError) return res.status(400).json({ error: nameError }); + const name = safeEntryName(req.body.name); + if (!name) return res.status(400).json({ error: 'Invalid file name.' }); + + const resolved = resolveServerPath(req, res, server, req.body.path); + if (!resolved) return; + const { serverDir, targetPath: parentDir } = resolved; + + if (!fs.existsSync(parentDir) || !fs.statSync(parentDir).isDirectory()) { + return res.status(404).json({ error: 'Directory not found.' }); + } + + const destPath = path.join(parentDir, name); + if (!isPathInside(parentDir, destPath)) { + return res.status(403).json({ error: 'Access denied.' }); + } + if (fs.existsSync(destPath)) { + return res.status(409).json({ error: 'Something with that name already exists here.' }); + } + + try { + // 'wx' rather than a plain write: creating a file must never truncate + // an existing one, including one that appeared since the check above. + fs.writeFileSync(destPath, '', { flag: 'wx' }); + } catch (err) { + if (err.code === 'EEXIST') { + return res.status(409).json({ error: 'Something with that name already exists here.' }); + } + log('error', `Failed to create file ${name}: ${err.message}`); + return res.status(500).json({ error: 'Failed to create file.' }); + } + + log('info', `Created file "${name}" in "${req.body.path || '/'}" ` + + `on server ${server.name} (${server.id})`); + // An empty server.properties / eula.txt created in the root has to re-sync + // the mirrored database fields, exactly as uploading or deleting one does. + await syncIfConfigFile(server.id, serverDir, destPath); + res.json({ success: true, name }); +}); + +// GET /servers/:id/console?limit=&source= — Read recent console output. +// +// The WebSocket is the live feed but rejects bearer tokens, so this is how an +// API-key client reads the console. Two sources, because they differ: +// file — /logs/craftbox-console.log. Durable and timestamped; +// survives a panel restart. The default. +// memory — the live process buffer. Shorter, untimestamped, lost whenever the +// process object is rebuilt, but it holds the few `[Craftbox] ...` +// lines emitted after the log stream closes on exit. +// `auto` (the default) prefers the file and falls back to memory when a server +// has never been started on this install. +router.get('/servers/:id/console', async (req, res) => { + try { + const server = await loadServerOr404(req, res); + if (!server) return; + + // `|| 200` here would have turned an explicit limit=0 into the default + // instead of clamping it to 1, so only a missing/unparseable value + // falls back — every parsed number goes through the clamp. + const rawLimit = parseInt(req.query.limit, 10); + const limit = Math.min(Math.max(Number.isNaN(rawLimit) ? 200 : rawLimit, 1), 1000); + const source = String(req.query.source || 'auto').toLowerCase(); + if (!['auto', 'file', 'memory'].includes(source)) { + return res.status(400).json({ error: 'source must be one of: auto, file, memory.' }); + } + + const proc = req.app.get('serverManager')?.getProcess(server.id); + const logPath = consolePathFor(server); + const hasLog = fs.existsSync(logPath); + + // Memory when asked for it, or when auto has nothing on disk to read. + if (source === 'memory' || (source === 'auto' && !hasLog)) { + const buffered = proc?.lastLines || []; + return res.json({ + source: 'memory', + truncated: buffered.length > limit, + lines: buffered.slice(-limit).map(line => ({ timestamp: null, line })) + }); + } + + // source === 'file' with nothing written yet is an empty log, not an error. + if (!hasLog) return res.json({ source: 'file', truncated: false, lines: [] }); + + const { lines, truncated } = readConsoleTail(logPath, limit); + res.json({ source: 'file', truncated, lines }); + } catch (err) { + log('error', `Failed to read console for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to read console output.' }); + } +}); + +// GET /servers/:id/export — Server transfer archive (.cbx): server files and +// Craftbox settings always, backups and event history when requested. +// Importable on another instance via POST /servers/import. +router.get('/servers/:id/export', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + + const serverManager = req.app.get('serverManager'); + const liveState = serverManager.getState(server); + if (liveState === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!['stopped', 'crashed'].includes(liveState)) { + return res.status(409).json({ error: 'Stop the server before exporting.' }); + } + + const serverDir = path.join(SERVERS_DIR, server.id); + if (!fs.existsSync(serverDir)) return res.status(404).json({ error: 'Server directory not found.' }); + + const includeBackups = req.query.backups === 'true'; + const includeEvents = req.query.events === 'true'; + const startAfter = req.query.start === 'true'; + const initiatedBy = req.user?.username; + + // Hold the backup lock while streaming so a scheduled backup can't write a + // partial zip into the archive mid-export. + let lockHeld = false; + if (includeBackups) { + if (!tryAcquireBackupLock(server.id)) { + return res.status(409).json({ error: 'A backup is currently in progress. Try again when it completes.' }); + } + lockHeld = true; + } + const releaseLock = () => { + if (lockHeld) { + releaseBackupLock(server.id); + lockHeld = false; + } + }; + + // Optional restart once the archive has fully streamed — by then every + // server file has been read, so starting the server can no longer corrupt + // the export. + let startRequested = false; + const startServerAfterExport = () => { + if (!startAfter || startRequested || !serverManager) return; + startRequested = true; + serverManager.startServer(server.id, { initiatedBy }).catch((err) => { + log('error', `Failed to start server after export: ${err.message}`); + }); + }; + + try { + const backups = includeBackups ? await listBackups(server.id) : []; + let events = []; + if (includeEvents) { + const allEvents = await eventsDb.all(); + events = allEvents + .map(row => row.value) + .filter(e => e.serverId === server.id) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + const modEnv = await getModEnvMap(server.id); + + const manifest = { + format: 'craftbox-server-export', + formatVersion: 1, + exportedAt: new Date().toISOString(), + craftboxVersion: require('../../../package.json').version, + server, + includes: { backups: includeBackups, events: includeEvents }, + backupCount: backups.length, + eventCount: events.length + }; + + const safeName = server.name.replace(/[^a-zA-Z0-9_-]/g, '_'); + + // A .cbx transfer archive is a zip container with a Craftbox manifest at + // its root. The dedicated media type stops browsers from "correcting" + // the extension back to .zip on download. + res.setHeader('Content-Type', 'application/x-craftbox-export+zip'); + res.setHeader('Content-Disposition', contentDisposition(`${safeName}.cbx`)); + + const archive = archiver('zip', { zlib: { level: 5 } }); + archive.on('error', (err) => { + log('error', `Export archive error for ${server.name}: ${err.message}`); + releaseLock(); + if (!res.headersSent) res.status(500).json({ error: 'Archive failed.' }); + }); + res.on('close', releaseLock); + archive.on('end', releaseLock); + res.on('finish', startServerAfterExport); + // 'finish' = the full archive reached the client; 'close' without it + // means the download was abandoned mid-stream. + res.on('finish', () => { + log('info', `Export of "${server.name}" (${server.id}) completed — ${formatSize(archive.pointer())} sent`); + }); + res.on('close', () => { + if (!res.writableFinished) { + log('warn', `Export of "${server.name}" (${server.id}) aborted by client after ${formatSize(archive.pointer())}`); + } + }); + + archive.pipe(res); + archive.append(JSON.stringify(manifest, null, 2), { name: 'craftbox-manifest.json' }); + archive.append(JSON.stringify(modEnv, null, 2), { name: 'modenv.json' }); + archive.directory(serverDir, 'server'); + + if (includeBackups) { + archive.append(JSON.stringify(backups, null, 2), { name: 'backups.json' }); + for (const b of backups) { + try { + const zipPath = resolveBackupPath(server.id, b.filename); + if (fs.existsSync(zipPath)) { + archive.file(zipPath, { name: `backups/${b.filename}` }); + } + } catch { /* skip backups with invalid filenames */ } + } + } + if (includeEvents) { + archive.append(JSON.stringify(events, null, 2), { name: 'events.json' }); + } + + log('info', `Exporting server "${server.name}" (${server.id}) — backups: ${includeBackups} (${backups.length}), events: ${includeEvents} (${events.length}), startAfter: ${startAfter}`); + archive.finalize(); + } catch (err) { + releaseLock(); + log('error', `Export failed for ${server.name}: ${err.message}`); + if (!res.headersSent) res.status(500).json({ error: 'Export failed.' }); + } +}); + // POST /servers/:id/edit-file — Save a text file in the server directory router.post('/servers/:id/edit-file', async (req, res) => { const id = req.params.id; @@ -2353,8 +3080,8 @@ router.post('/servers/:id/edit-file', async (req, res) => { return res.status(403).json({ error: 'Access denied.' }); } - if (!isTextFile(path.basename(targetPath))) { - return res.status(400).json({ error: 'This file type cannot be edited.' }); + if (!isEditableFile(targetPath)) { + return res.status(400).json({ error: 'This file is not text and cannot be edited.' }); } try { diff --git a/src/routes/backups.js b/src/routes/backups.js index 5d12b33..3d3edf5 100644 --- a/src/routes/backups.js +++ b/src/routes/backups.js @@ -1,15 +1,9 @@ const express = require('express'); -const fs = require('fs'); -const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); -const { serversDb, backupsDb } = require('../db'); -const { log } = require('../utils/log'); -const { - listBackups, - formatSize, - resolveBackupPath -} = require('../mc/BackupManager'); +const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); +const { serversDb } = require('../db'); +const { listBackups, formatSize } = require('../mc/BackupManager'); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -27,7 +21,7 @@ async function getServerWithState(req) { } // GET /servers/:id/backups — Backups page (view only; mutations live on /api/v1) -router.get('/servers/:id/backups', ensureAuth, async (req, res) => { +router.get('/servers/:id/backups', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -67,42 +61,4 @@ router.get('/servers/:id/backups', ensureAuth, async (req, res) => { delete req.session.flash; }); -// GET /servers/:id/backups/:backupId/download — Download a backup ZIP (binary) -router.get('/servers/:id/backups/:backupId/download', ensureAuth, async (req, res) => { - if (!UUID_RE.test(req.params.backupId)) { - return res.status(400).json({ error: 'Invalid backup ID.' }); - } - const server = await getServerWithState(req); - if (!server) return res.status(404).json({ error: 'Server not found.' }); - - const backup = await backupsDb.get(`backup_${req.params.backupId}`); - if (!backup || backup.serverId !== server.id) { - req.session.flash = { error: 'Backup not found.' }; - return res.redirect(`/servers/${server.id}/backups`); - } - - const zipPath = resolveBackupPath(server.id, backup.filename); - if (!fs.existsSync(zipPath)) { - req.session.flash = { error: 'Backup file not found on disk.' }; - return res.redirect(`/servers/${server.id}/backups`); - } - - const safeName = server.name.replace(/[^a-zA-Z0-9_-]/g, '_'); - const safeFilename = backup.filename.replace(/[^a-zA-Z0-9._-]/g, '_'); - const downloadName = `${safeName}_backup_${safeFilename}`; - - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Disposition', contentDisposition(downloadName)); - res.setHeader('Content-Length', backup.size); - - const stream = fs.createReadStream(zipPath); - stream.on('error', (err) => { - log('error', `Backup download error: ${err.message}`); - if (!res.headersSent) { - res.status(500).json({ error: 'Download failed.' }); - } - }); - stream.pipe(res); -}); - module.exports = router; diff --git a/src/routes/plugins.js b/src/routes/plugins.js index 69de6bb..ba77f66 100644 --- a/src/routes/plugins.js +++ b/src/routes/plugins.js @@ -4,6 +4,7 @@ const path = require('path'); const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); +const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); const { serversDb, SERVERS_DIR } = require('../db'); const { log } = require('../utils/log'); const { getContentType } = require('../utils/contentType'); @@ -35,7 +36,7 @@ function formatSize(bytes) { } // GET /servers/:id/plugins — Plugins/Mods page (view only; mutations live on /api/v1) -router.get('/servers/:id/plugins', ensureAuth, async (req, res) => { +router.get('/servers/:id/plugins', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -97,7 +98,7 @@ router.get('/servers/:id/plugins', ensureAuth, async (req, res) => { }); // GET /servers/:id/plugins/download — Download a single plugin/mod JAR (binary) -router.get('/servers/:id/plugins/download', ensureAuth, async (req, res) => { +router.get('/servers/:id/plugins/download', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) return res.status(404).json({ error: 'Server not found.' }); @@ -147,7 +148,7 @@ router.get('/servers/:id/plugins/download', ensureAuth, async (req, res) => { }); // GET /servers/:id/plugins/download-all — Download all plugins/mods as ZIP (binary) -router.get('/servers/:id/plugins/download-all', ensureAuth, async (req, res) => { +router.get('/servers/:id/plugins/download-all', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) return res.status(404).json({ error: 'Server not found.' }); diff --git a/src/routes/servers.js b/src/routes/servers.js index d6896fc..cd7cbc7 100644 --- a/src/routes/servers.js +++ b/src/routes/servers.js @@ -4,9 +4,10 @@ const path = require('path'); const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); -const { serversDb, eventsDb, SERVERS_DIR } = require('../db'); -const { listBackups, resolveBackupPath, tryAcquireBackupLock, releaseBackupLock } = require('../mc/BackupManager'); -const { getModEnvMap } = require('../utils/modEnvironment'); +const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); +const { isEditableFile, listDirectory, MAX_TEXT_BYTES } = require('../utils/fileBrowser'); +const { formatSize } = require('../utils/resourceStats'); +const { serversDb, SERVERS_DIR } = require('../db'); const { parseServerProperties } = require('../mc/serverProperties'); const { PROPERTY_META, GROUPS } = require('../mc/propertyMeta'); const { log } = require('../utils/log'); @@ -80,19 +81,11 @@ async function getServerWithState(req) { return server; } -// ── Helper: format file size ── -function formatSize(bytes) { - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return parseFloat((bytes / Math.pow(1024, i)).toFixed(1)) + ' ' + units[i]; -} - // ═══════════════════════════════════════════ // Edit Server Settings (view only — mutations in /api/v1) // ═══════════════════════════════════════════ -router.get('/servers/:id/edit', ensureAuth, async (req, res) => { +router.get('/servers/:id/edit', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -122,7 +115,7 @@ router.get('/servers/:id/edit', ensureAuth, async (req, res) => { // Server Properties Editor (view only — mutations in /api/v1) // ═══════════════════════════════════════════ -router.get('/servers/:id/properties', ensureAuth, async (req, res) => { +router.get('/servers/:id/properties', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -151,17 +144,6 @@ router.get('/servers/:id/properties', ensureAuth, async (req, res) => { // File Browser & Editor (views + binary downloads — mutations in /api/v1) // ═══════════════════════════════════════════ -const TEXT_EXTENSIONS = new Set([ - '.txt', '.log', '.properties', '.json', '.yml', '.yaml', '.xml', - '.cfg', '.conf', '.ini', '.toml', '.csv', '.md', '.sh', '.bat', - '.cmd', '.ps1', '.js', '.ts', '.py', '.java', '.html', '.css', - '.mcmeta', '.lang', '.sk', '.nbt' -]); - -function isTextFile(filename) { - return TEXT_EXTENSIONS.has(path.extname(filename).toLowerCase()); -} - async function handleFiles(req, res, subpath) { const server = await getServerWithState(req); if (!server) { @@ -185,24 +167,7 @@ async function handleFiles(req, res, subpath) { }); } - const entries = fs.readdirSync(targetPath, { withFileTypes: true }); - const files = entries.map(entry => { - const entryPath = path.join(targetPath, entry.name); - let stat; - try { stat = fs.statSync(entryPath); } catch { return null; } - return { - name: entry.name, - isDirectory: entry.isDirectory(), - size: stat.size, - sizeFormatted: formatSize(stat.size), - modified: stat.mtime, - modifiedISO: stat.mtime.toISOString(), - editable: !entry.isDirectory() && isTextFile(entry.name) - }; - }).filter(Boolean).sort((a, b) => { - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; - return a.name.localeCompare(b.name); - }); + const files = listDirectory(targetPath); const breadcrumbs = subpath ? subpath.split('/').filter(Boolean) : []; const parentPath = breadcrumbs.length > 1 ? breadcrumbs.slice(0, -1).join('/') : ''; @@ -222,14 +187,14 @@ async function handleFiles(req, res, subpath) { delete req.session.flash; } -router.get('/servers/:id/files', ensureAuth, (req, res) => handleFiles(req, res, '')); -router.get('/servers/:id/files/*subpath', ensureAuth, (req, res) => { +router.get('/servers/:id/files', ensureAuth, blockWhileProvisioning, (req, res) => handleFiles(req, res, '')); +router.get('/servers/:id/files/*subpath', ensureAuth, blockWhileProvisioning, (req, res) => { const sub = Array.isArray(req.params.subpath) ? req.params.subpath.join('/') : req.params.subpath; handleFiles(req, res, sub); }); // Individual file download (binary — stays here, browser-driven) -router.get('/servers/:id/download', ensureAuth, async (req, res) => { +router.get('/servers/:id/download', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await serversDb.get(`server_${req.params.id}`); if (!server) return res.status(404).json({ error: 'Not found' }); @@ -269,7 +234,7 @@ router.get('/servers/:id/download', ensureAuth, async (req, res) => { }); // Full server directory download as .zip (binary — stays here) -router.get('/servers/:id/download-zip', ensureAuth, async (req, res) => { +router.get('/servers/:id/download-zip', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await serversDb.get(`server_${req.params.id}`); if (!server) return res.status(404).json({ error: 'Not found' }); @@ -299,139 +264,7 @@ router.get('/servers/:id/download-zip', ensureAuth, async (req, res) => { archive.finalize(); }); -// Server transfer export — server files + Craftbox settings always, backups and -// event history when requested. Importable on another Craftbox instance via -// POST /api/v1/servers/import. (binary download — stays here) -router.get('/servers/:id/export', ensureAuth, async (req, res) => { - const server = await serversDb.get(`server_${req.params.id}`); - if (!server) return res.status(404).json({ error: 'Not found' }); - - const serverManager = req.app.get('serverManager'); - const proc = serverManager?.getProcess(server.id); - if (proc && !['stopped', 'crashed'].includes(proc.state)) { - req.session.flash = { error: 'Stop the server before exporting.' }; - return res.redirect(`/servers/${server.id}/edit`); - } - - const serverDir = path.join(SERVERS_DIR, server.id); - if (!fs.existsSync(serverDir)) return res.status(404).json({ error: 'Directory not found' }); - - const includeBackups = req.query.backups === 'true'; - const includeEvents = req.query.events === 'true'; - const startAfter = req.query.start === 'true'; - const initiatedBy = req.user?.username; - - // Hold the backup lock while streaming so a scheduled backup can't write a - // partial zip into the archive mid-export. - let lockHeld = false; - if (includeBackups) { - if (!tryAcquireBackupLock(server.id)) { - req.session.flash = { error: 'A backup is currently in progress. Try again when it completes.' }; - return res.redirect(`/servers/${server.id}/edit`); - } - lockHeld = true; - } - const releaseLock = () => { - if (lockHeld) { - releaseBackupLock(server.id); - lockHeld = false; - } - }; - - // Optional restart once the archive has fully streamed — by then every - // server file has been read, so starting the server can no longer corrupt - // the export. - let startRequested = false; - const startServerAfterExport = () => { - if (!startAfter || startRequested || !serverManager) return; - startRequested = true; - serverManager.startServer(server.id, { initiatedBy }).catch((err) => { - log('error', `Failed to start server after export: ${err.message}`); - }); - }; - - try { - const backups = includeBackups ? await listBackups(server.id) : []; - let events = []; - if (includeEvents) { - const allEvents = await eventsDb.all(); - events = allEvents - .map(row => row.value) - .filter(e => e.serverId === server.id) - .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - } - const modEnv = await getModEnvMap(server.id); - - const manifest = { - format: 'craftbox-server-export', - formatVersion: 1, - exportedAt: new Date().toISOString(), - craftboxVersion: require('../../package.json').version, - server, - includes: { backups: includeBackups, events: includeEvents }, - backupCount: backups.length, - eventCount: events.length - }; - - const archiver = require('archiver'); - const safeName = server.name.replace(/[^a-zA-Z0-9_-]/g, '_'); - - // A .cbx transfer archive is a zip container with a Craftbox manifest at - // its root. The dedicated media type stops browsers from "correcting" - // the extension back to .zip on download. - res.setHeader('Content-Type', 'application/x-craftbox-export+zip'); - res.setHeader('Content-Disposition', contentDisposition(`${safeName}.cbx`)); - - const archive = archiver('zip', { zlib: { level: 5 } }); - archive.on('error', (err) => { - log('error', `Export archive error for ${server.name}: ${err.message}`); - releaseLock(); - if (!res.headersSent) res.status(500).json({ error: 'Archive failed' }); - }); - res.on('close', releaseLock); - archive.on('end', releaseLock); - res.on('finish', startServerAfterExport); - // 'finish' = the full archive reached the client; 'close' without it - // means the download was abandoned mid-stream. - res.on('finish', () => { - log('info', `Export of "${server.name}" (${server.id}) completed — ${formatSize(archive.pointer())} sent`); - }); - res.on('close', () => { - if (!res.writableFinished) { - log('warn', `Export of "${server.name}" (${server.id}) aborted by client after ${formatSize(archive.pointer())}`); - } - }); - - archive.pipe(res); - archive.append(JSON.stringify(manifest, null, 2), { name: 'craftbox-manifest.json' }); - archive.append(JSON.stringify(modEnv, null, 2), { name: 'modenv.json' }); - archive.directory(serverDir, 'server'); - - if (includeBackups) { - archive.append(JSON.stringify(backups, null, 2), { name: 'backups.json' }); - for (const b of backups) { - try { - const zipPath = resolveBackupPath(server.id, b.filename); - if (fs.existsSync(zipPath)) { - archive.file(zipPath, { name: `backups/${b.filename}` }); - } - } catch { /* skip backups with invalid filenames */ } - } - } - if (includeEvents) { - archive.append(JSON.stringify(events, null, 2), { name: 'events.json' }); - } - - log('info', `Exporting server "${server.name}" (${server.id}) — backups: ${includeBackups} (${backups.length}), events: ${includeEvents} (${events.length}), startAfter: ${startAfter}`); - archive.finalize(); - } catch (err) { - releaseLock(); - log('error', `Export failed for ${server.name}: ${err.message}`); - if (!res.headersSent) res.status(500).json({ error: 'Export failed' }); - } -}); - -router.get('/servers/:id/edit-file', ensureAuth, async (req, res) => { +router.get('/servers/:id/edit-file', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -457,9 +290,22 @@ router.get('/servers/:id/edit-file', ensureAuth, async (req, res) => { }); } - if (!isTextFile(path.basename(targetPath))) { + if (!isEditableFile(targetPath)) { return res.status(400).render('errors/404', { - title: 'Not Editable', navbar: true, user: req.user, message: 'This file type cannot be edited.' + title: 'Not Editable', navbar: true, user: req.user, message: 'This file is not text and cannot be edited.' + }); + } + + // The editor posts back the whole textarea, so it must never open a partial + // file — saving one would truncate the rest away. Oversized files are + // refused here and read in windows through the API instead. + const size = fs.statSync(targetPath).size; + if (size > MAX_TEXT_BYTES) { + return res.status(413).render('errors/404', { + title: 'Too Large', + navbar: true, + user: req.user, + message: `This file is ${formatSize(size)}, over the ${formatSize(MAX_TEXT_BYTES)} editor limit. Download it to read the whole thing.` }); } diff --git a/src/security.js b/src/security.js index cadf8ba..0e4ebb4 100644 --- a/src/security.js +++ b/src/security.js @@ -54,6 +54,16 @@ function csrfValidate(req, res, next) { const isApi = req.path.startsWith('/api/'); const fail = (message) => { if (isApi) return res.status(403).json({ error: 'forbidden', message }); + + // A page POST from a tab whose session lapsed carries the old session's + // token, so it lands here rather than on ensureAuth's redirect. "Invalid + // or missing CSRF token" is a misleading dead end for what is really an + // expired login — send them to sign in and back to where they were. + if (!req.isAuthenticated?.()) { + if (req.session) req.session.returnTo = req.originalUrl; + return res.redirect('/login'); + } + return res.status(403).render('errors/403', { title: 'Forbidden', message, diff --git a/src/server.js b/src/server.js index 876451b..90dc46d 100644 --- a/src/server.js +++ b/src/server.js @@ -16,6 +16,7 @@ const { securityHeaders, csrfToken, csrfValidate } = require('./security'); const serverStateMeta = require('./utils/serverStateMeta'); const { initWebSocket } = require('./websocket'); const ServerManager = require('./mc/ServerManager'); +const { setEventBroadcaster } = require('./utils/eventLogger'); const BackupScheduler = require('./mc/BackupScheduler'); const StatsCollector = require('./utils/StatsCollector'); const mountRoutes = require('./routes'); @@ -63,6 +64,22 @@ log('info', `NODE_ENV: ${NODE_ENV}`); const serverManager = new ServerManager(); app.set('serverManager', serverManager); + // Push every logged event to that server's WebSocket subscribers, so the + // event log updates live. Wired here rather than imported inside + // eventLogger, which ServerProcess already depends on. + setEventBroadcaster((serverId, event) => { + serverManager.getProcess(serverId)?.broadcast({ + type: 'event', + serverId, + eventId: event.id, + eventType: event.type, + message: event.message, + createdAt: event.createdAt, + initiatedBy: event.initiatedBy || null, + playerName: event.playerName || null + }); + }); + const backupScheduler = new BackupScheduler(serverManager); app.set('backupScheduler', backupScheduler); diff --git a/src/utils/consoleLog.js b/src/utils/consoleLog.js new file mode 100644 index 0000000..e859f3d --- /dev/null +++ b/src/utils/consoleLog.js @@ -0,0 +1,60 @@ +const fs = require('fs'); + +// Craftbox writes every console line to /logs/craftbox-console.log as +// `[] `. That file is append-only and never rotated, so it +// can be large — reads are always tailed from the end rather than loaded whole. +const LINE_RE = /^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\]\s?([\s\S]*)$/; + +// How far back to read when tailing. Generous enough that a `limit` of 1000 +// almost always lands inside it, small enough never to load a multi-GB log. +const TAIL_BYTES = 1024 * 1024; + +/** + * Read the last `limit` lines of a console log. + * + * @param {string} logPath + * @param {number} limit + * @returns {{lines: Array<{timestamp: string|null, line: string}>, truncated: boolean}} + * `truncated` is true when older lines exist beyond what was read — either + * because the tail window was reached or because more lines were found than + * were asked for. + */ +function readConsoleTail(logPath, limit) { + const size = fs.statSync(logPath).size; + const start = Math.max(0, size - TAIL_BYTES); + + const fd = fs.openSync(logPath, 'r'); + let raw; + try { + const length = size - start; + const buf = Buffer.alloc(length); + fs.readSync(fd, buf, 0, length, start); + raw = buf.toString('utf8'); + } finally { + fs.closeSync(fd); + } + + // A non-zero start almost certainly lands mid-line; drop that fragment. + if (start > 0) { + const nl = raw.indexOf('\n'); + raw = nl === -1 ? '' : raw.slice(nl + 1); + } + + const all = raw.split('\n'); + if (all.length && all[all.length - 1] === '') all.pop(); + + const selected = limit >= all.length ? all : all.slice(-limit); + const truncated = start > 0 || selected.length < all.length; + + return { + lines: selected.map((entry) => { + const m = LINE_RE.exec(entry); + // Lines written before timestamping, or a torn write, still come + // back — just without a timestamp. + return m ? { timestamp: m[1], line: m[2] } : { timestamp: null, line: entry }; + }), + truncated + }; +} + +module.exports = { readConsoleTail }; diff --git a/src/utils/eventLogger.js b/src/utils/eventLogger.js index ddab21e..c2200af 100644 --- a/src/utils/eventLogger.js +++ b/src/utils/eventLogger.js @@ -2,8 +2,26 @@ const { v4: uuidv4 } = require('uuid'); const { eventsDb } = require('../db'); const { log } = require('./log'); +// Set once at boot by src/server.js. Kept as an injected function rather than a +// direct ServerManager import so this module stays free of the process layer +// (which requires this one — importing both ways would be circular). +let broadcaster = null; + +/** + * Register the sink that pushes newly logged events to WebSocket subscribers. + * @param {(serverId: string, event: object) => void} fn + */ +function setEventBroadcaster(fn) { + broadcaster = fn; +} + /** - * Log a structured event for a server. + * Log a structured event for a server, then push it to anyone watching. + * + * Broadcasting from here rather than from each call site is what makes the + * event log live for *every* event type — backups, jar upgrades and user + * actions are all logged through this function, and previously none of them + * reached a socket. */ async function logEvent(serverId, type, message, extra = {}) { const event = { @@ -19,6 +37,15 @@ async function logEvent(serverId, type, message, extra = {}) { } catch (err) { log('error', `Failed to log event: ${err.message}`); } + + // After the write, so a client that reacts by refetching sees this event. + if (broadcaster) { + try { + broadcaster(serverId, event); + } catch (err) { + log('warn', `Failed to broadcast event: ${err.message}`); + } + } return event; } @@ -76,4 +103,4 @@ async function deleteServerEvents(serverId) { } } -module.exports = { logEvent, getEvents, pruneEvents, deleteServerEvents }; +module.exports = { logEvent, setEventBroadcaster, getEvents, pruneEvents, deleteServerEvents }; diff --git a/src/utils/fileBrowser.js b/src/utils/fileBrowser.js new file mode 100644 index 0000000..1e70643 --- /dev/null +++ b/src/utils/fileBrowser.js @@ -0,0 +1,336 @@ +const fs = require('fs'); +const path = require('path'); +const { formatSize } = require('./resourceStats'); + +// Extensions we treat as text when we cannot read the file to find out — it +// does not exist yet, or the running server has it locked. Contents decide +// every case where contents are available, so this list is a fallback, not the +// rule: an unlisted but perfectly textual file (a mod's own config extension, a +// dotfile, a name with no extension at all) still opens on the strength of its +// bytes rather than being refused for the sole crime of being unlisted. +const TEXT_EXTENSIONS = new Set([ + // Plain text and docs + '.txt', '.log', '.md', '.markdown', '.rst', '.adoc', '.nfo', + // Config + '.properties', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf', + '.env', '.list', '.rules', '.editorconfig', + // JSON and friends — jsonl/ndjson are line-delimited, json5/jsonc allow comments + '.json', '.jsonl', '.ndjson', '.json5', '.jsonc', + // Markup and tabular + '.xml', '.xsd', '.xsl', '.svg', '.html', '.htm', '.css', '.scss', '.less', + '.csv', '.tsv', '.sql', + // Scripts and source + '.sh', '.bash', '.zsh', '.bat', '.cmd', '.ps1', '.psm1', + '.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx', + '.py', '.rb', '.pl', '.lua', '.php', '.go', '.rs', '.c', '.h', + '.cpp', '.hpp', '.cs', '.java', '.kt', '.kts', '.groovy', '.gradle', + // Minecraft-specific text formats + '.mcmeta', '.mcfunction', '.snbt', '.lang', '.sk', + // Patches + '.diff', '.patch' +]); + +// Extensions we refuse without reading the file. This is the fast path that +// keeps a directory listing cheap: a mods folder is hundreds of jars and a +// world is thousands of region files, and none of them are worth opening to +// confirm what the name already says. +const BINARY_EXTENSIONS = new Set([ + // Archives and packaged content + '.jar', '.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.zst', '.7z', '.rar', + '.mrpack', '.war', '.ear', + // Minecraft binary data — NBT is gzipped, region files are chunk blobs. + // These used to be editable via '.nbt'; opening one in a text editor and + // saving re-encodes its bytes as UTF-8 and corrupts the world or player. + '.dat', '.dat_old', '.nbt', '.mca', '.mcr', '.mclevel', '.schematic', '.litematic', + // Images, media, fonts + '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico', '.tiff', + '.mp3', '.ogg', '.wav', '.mp4', '.webm', '.ttf', '.otf', '.woff', '.woff2', + // Compiled output and databases + '.exe', '.dll', '.so', '.dylib', '.class', '.bin', '.o', '.a', '.pdf', + '.db', '.sqlite', '.sqlite3' +]); + +// How much of a file we look at. Enough to catch a binary header and any stray +// control bytes just past it, small enough that doing it for every candidate +// entry in a directory listing is cheap. +const SNIFF_BYTES = 8192; + +// Control bytes that are ordinary in a text file: tab, newline, form feed, +// carriage return, and ESC — a Minecraft console log is full of ANSI colour +// codes, and a short one would otherwise fail on ratio alone. +const ALLOWED_CONTROL_BYTES = new Set([0x09, 0x0a, 0x0c, 0x0d, 0x1b]); + +// Largest file the editor and the text API will load in one piece. Past this, +// callers take a byte window (see readTextWindow) instead — an append-only log +// on a running server has no upper bound worth trusting. +const MAX_TEXT_BYTES = 5 * 1024 * 1024; + +/** + * Decide whether a sample of bytes reads as UTF-8 text. + * + * @param {Buffer} buf - the first bytes of a file, possibly cut mid-character + * @returns {boolean} + */ +function looksLikeText(buf) { + if (buf.length === 0) return true; + + // UTF-16/UTF-32 are text, but the editor reads and writes UTF-8 — saving + // one back through it would rewrite every byte in the file, so refuse. + if ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff)) return false; + + // A NUL byte is the single most reliable binary tell. + if (buf.includes(0)) return false; + + try { + // stream: true so a multi-byte character straddling the end of the + // sample is held back rather than reported as corruption. + new TextDecoder('utf-8', { fatal: true }).decode(buf, { stream: true }); + } catch { + return false; + } + + // The rest of the C0 range and DEL are not ordinary: a light sprinkling is + // tolerable, a heavy one is binary that happened to survive the NUL and + // UTF-8 checks above. + let control = 0; + for (const byte of buf) { + if ((byte < 0x20 || byte === 0x7f) && !ALLOWED_CONTROL_BYTES.has(byte)) control++; + } + return control / buf.length <= 0.1; +} + +/** + * Read the head of a file and decide whether it is text. + * + * @param {string} filePath - absolute path + * @returns {boolean|null} null when the file could not be read at all — it is + * absent, or the running server holds it open — leaving nothing to judge + */ +function sniffFile(filePath) { + let fd; + try { + fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(SNIFF_BYTES); + const read = fs.readSync(fd, buf, 0, SNIFF_BYTES, 0); + return looksLikeText(buf.subarray(0, read)); + } catch { + return null; + } finally { + if (fd !== undefined) { + try { fs.closeSync(fd); } catch { /* already gone */ } + } + } +} + +/** + * Whether the panel will open this file as text. + * + * Contents decide it. The name only gets a say twice: to skip the read for + * formats that are always binary, and to stand in when there is nothing to + * read. Judging by bytes is what lets an unlisted extension through, and it is + * also what catches the reverse — a UTF-16 or latin-1 file wearing a .txt on + * the end, which the editor would show as mojibake and mangle on save, since + * it reads and writes UTF-8 throughout. + * + * @param {string} filePath - absolute path + * @returns {boolean} + */ +function isEditableFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (BINARY_EXTENSIONS.has(ext)) return false; + + const sniffed = sniffFile(filePath); + if (sniffed !== null) return sniffed; + + // Nothing to go on but the name. A path that does not exist yet is a file + // the caller is about to write text into, so let any non-binary extension + // through; one that exists but will not open falls back to the list. + return !fs.existsSync(filePath) || TEXT_EXTENSIONS.has(ext); +} + +/** + * A byte window can land in the middle of a multi-byte character at either + * end. Drop the partial pieces rather than emitting U+FFFD into the response. + * + * @returns {{buf: Buffer, leading: number}} leading = bytes dropped at the front + */ +function trimPartialUtf8(buf, cutStart, cutEnd) { + let lead = 0; + if (cutStart) { + while (lead < buf.length && (buf[lead] & 0xc0) === 0x80) lead++; + } + let end = buf.length; + if (cutEnd) { + let i = end - 1; + while (i >= lead && (buf[i] & 0xc0) === 0x80) i--; + if (i >= lead) { + const b = buf[i]; + const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1; + if (end - i < need) end = i; + } + } + return { buf: buf.subarray(lead, end), leading: lead }; +} + +/** + * Read all or part of a file as UTF-8 text. + * + * Offsets are in bytes, not characters, so a window is cheap to ask for + * against a file that is still being appended to. The window is clamped to + * MAX_TEXT_BYTES and to the file's actual length, so an over-large request + * comes back short rather than failing. + * + * @param {string} filePath - absolute path + * @param {{offset?: number, limit?: number|null, tail?: number|null}} window + * @returns {{content: string, size: number, offset: number, length: number, truncated: boolean}} + */ +function readTextWindow(filePath, { offset = 0, limit = null, tail = null } = {}) { + const size = fs.statSync(filePath).size; + + let start, want; + if (tail !== null) { + want = Math.min(tail, MAX_TEXT_BYTES, size); + start = size - want; + } else { + start = Math.min(offset, size); + want = Math.min(limit === null ? size - start : limit, MAX_TEXT_BYTES, size - start); + } + + let read = 0; + const buf = Buffer.alloc(want); + if (want > 0) { + const fd = fs.openSync(filePath, 'r'); + try { read = fs.readSync(fd, buf, 0, want, start); } finally { fs.closeSync(fd); } + } + + const cutEnd = start + read < size; + const trimmed = trimPartialUtf8(buf.subarray(0, read), start > 0, cutEnd); + return { + content: trimmed.buf.toString('utf8'), + size, + offset: start + trimmed.leading, + length: trimmed.buf.length, + truncated: start > 0 || cutEnd + }; +} + +/** + * Validate the offset/limit/tail trio off a query string. + * + * @param {object} query - req.query + * @returns {{error: string}|{offset: number, limit: number|null, tail: number|null, windowed: boolean}} + */ +function parseReadWindow(query) { + const parsed = {}; + for (const name of ['offset', 'limit', 'tail']) { + const raw = query[name]; + if (raw === undefined || raw === '') { parsed[name] = null; continue; } + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + return { error: `"${name}" must be a whole number of bytes, zero or more.` }; + } + parsed[name] = n; + } + if (parsed.tail !== null && (parsed.offset !== null || parsed.limit !== null)) { + return { error: 'Use either "tail" or "offset"/"limit", not both.' }; + } + return { + offset: parsed.offset === null ? 0 : parsed.offset, + limit: parsed.limit, + tail: parsed.tail, + windowed: parsed.tail !== null || parsed.offset !== null || parsed.limit !== null + }; +} + +// Windows refuses these outright; creating one on Linux would produce a file +// that breaks the moment the server directory is exported and restored there. +const RESERVED_DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i; + +/** + * Reduce a caller-supplied name to a single safe path segment. + * Mirrors the checks DGUP already applies to an uploaded filename + * (see middleware/dgup.js) so both upload paths agree on what a name is. + * + * @param {*} name + * @returns {string|null} the segment, or null when it cannot be made safe + */ +function safeEntryName(name) { + if (typeof name !== 'string') return null; + const base = path.basename(name).replace(/[/\\]/g, '').trim(); + // eslint-disable-next-line no-control-regex + if (!base || base.length > 255 || /[\x00-\x1f]/.test(base)) return null; + if (base === '.' || base === '..') return null; + return base; +} + +/** + * Stricter check for names the user types (rename, new folder, new file), as + * opposed to names that arrive attached to an upload. Rejecting here produces a + * clear message instead of a bare EINVAL/ENOENT from the filesystem later. + * + * Takes the name as typed, before safeEntryName has been near it. That order + * matters for the separator check: safeEntryName reduces a name to its last + * segment, which is right for an upload — a browser sends a whole relative path + * as the filename — but wrong for a name somebody typed, where "sub/notes.txt" + * would quietly become "notes.txt" in the current folder instead of saying that + * a name is not a path. The client refuses a slash the same way + * (public/js/files.js), so this is the server half of a check the UI already + * makes rather than a new restriction. + * + * @param {*} name - the raw name from the request body + * @returns {string|null} an error message, or null when the name is fine + */ +function newNameError(name) { + if (typeof name !== 'string') return 'Enter a name.'; + const trimmed = name.trim(); + if (!trimmed) return 'Enter a name.'; + if (trimmed.length > 255) return 'A name cannot be longer than 255 characters.'; + if (trimmed === '.' || trimmed === '..') return 'That name cannot be used.'; + if (/[/\\]/.test(trimmed)) return 'A name cannot contain a slash.'; + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(trimmed)) return 'A name cannot contain control characters.'; + if (/[<>:"|?*]/.test(trimmed)) return 'A name cannot contain any of: < > : " | ? *'; + if (/\.$/.test(trimmed)) return 'A name cannot end with a dot.'; + if (RESERVED_DEVICE_NAMES.test(trimmed)) return `"${trimmed}" is a reserved name and cannot be used.`; + return null; +} + +/** + * List one directory, newest metadata first resolved per entry. + * Directories sort ahead of files, then by name. + * + * Shared by the Files page and the file API so both describe a directory + * identically. Entries whose stat fails (deleted mid-listing, permission + * denied) are dropped rather than failing the whole listing. `editable` also + * accounts for size: a file past MAX_TEXT_BYTES is text the editor still will + * not open, so the Edit button stays off rather than leading to a 413. + * @param {string} dir - absolute path, already validated with isPathInside + */ +function listDirectory(dir) { + return fs.readdirSync(dir, { withFileTypes: true }) + .map(entry => { + const entryPath = path.join(dir, entry.name); + let stat; + try { stat = fs.statSync(entryPath); } catch { return null; } + return { + name: entry.name, + isDirectory: entry.isDirectory(), + size: stat.size, + sizeFormatted: formatSize(stat.size), + modified: stat.mtime, + modifiedISO: stat.mtime.toISOString(), + editable: !entry.isDirectory() && stat.size <= MAX_TEXT_BYTES && isEditableFile(entryPath) + }; + }) + .filter(Boolean) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} + +module.exports = { + TEXT_EXTENSIONS, BINARY_EXTENSIONS, MAX_TEXT_BYTES, + looksLikeText, isEditableFile, readTextWindow, parseReadWindow, + listDirectory, safeEntryName, newNameError +}; diff --git a/src/utils/modEnvironment.js b/src/utils/modEnvironment.js index 2d11284..3654c33 100644 --- a/src/utils/modEnvironment.js +++ b/src/utils/modEnvironment.js @@ -93,7 +93,11 @@ function listModFiles(contentDir) { return []; } - const results = []; + // Keyed by display name, because a jar and its `.disabled` twin can both + // be on disk — from a hand-managed mods folder, or a file dropped in + // through the file manager. That is one mod, not two, so collapse the pair + // onto the enabled file rather than listing the same name twice. + const byDisplayName = new Map(); for (const entry of entries) { if (entry.isDirectory()) continue; const name = entry.name; @@ -111,10 +115,14 @@ function listModFiles(contentDir) { continue; } + // Keep what we already have unless this is the enabled half of a pair. + const existing = byDisplayName.get(displayName); + if (existing && !(existing.isDisabled && !isDisabled)) continue; + let stat; try { stat = fs.statSync(path.join(contentDir, name)); } catch { continue; } - results.push({ + byDisplayName.set(displayName, { displayName, onDiskName: name, isDisabled, @@ -122,7 +130,8 @@ function listModFiles(contentDir) { modified: stat.mtime }); } - return results.sort((a, b) => a.displayName.localeCompare(b.displayName)); + return Array.from(byDisplayName.values()) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); } async function reconcileModFiles(serverId, serverDir, serverType) { diff --git a/views/groups/view.ejs b/views/groups/view.ejs index 1fac0ec..0b34da0 100644 --- a/views/groups/view.ejs +++ b/views/groups/view.ejs @@ -44,9 +44,9 @@
- <% if (_serverStopped) { %> - - <% } else { %> - - <% } %> + <%# Single button; edit.js branches on live state at submit time. %> + @@ -574,7 +570,7 @@ - <% } %> -<% if (events.length === 0) { %> -
+<%# Empty state and table are both always present so live events can swap them + without rebuilding the page. %> +
history

No events recorded<%= typeFilter ? ' for this filter' : '' %>.

-<% } else { %> -
+
@@ -71,10 +74,16 @@ const eventLabels = Object.fromEntries( - + - + <%# events.js builds live rows from this map, keeping the badge/icon + vocabulary defined once, here. %> + <% events.forEach(function(event) { %> <% const meta = metaFor(event.type); %> @@ -113,7 +122,6 @@ const eventLabels = Object.fromEntries(
Event Details Initiated ByTimeTime
-<% } %>