
-
+

[](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
diff --git a/views/partials/serverNav.ejs b/views/partials/serverNav.ejs
index be4d0ce..a3196a8 100644
--- a/views/partials/serverNav.ejs
+++ b/views/partials/serverNav.ejs
@@ -4,9 +4,22 @@
const _displayVersion = (server.serverType || 'vanilla') === 'custom'
? '(Unknown Version)'
: (server.version || '(Unknown Version)');
+// While the server directory is still being assembled, every page except
+// Console and Events is closed (blockWhileProvisioning redirects them). Show
+// that in the nav rather than letting the click bounce.
+const _provisioning = server.state === 'provisioning';
+const _gated = _provisioning ? ' disabled' : '';
+const _gatedTitle = _provisioning ? ' title="Available once provisioning finishes" aria-disabled="true"' : '';
+// Back goes to the page the server was reached from: its group page when it
+// belongs to a group, the dashboard otherwise. A group is implicit — it exists
+// only while a server is in it — so a group with this server in it is always
+// a page that exists.
+const _backHref = server.group
+ ? `/dashboard/groups/${encodeURIComponent(server.group)}`
+ : '/dashboard';
%>
+ <%# stopFirst / startAfter are derived from the live state at
+ submit time (see backups.js) rather than baked in here,
+ which used to go stale the moment the server changed state. %>
diff --git a/views/servers/edit.ejs b/views/servers/edit.ejs
index cda2951..ea470b3 100644
--- a/views/servers/edit.ejs
+++ b/views/servers/edit.ejs
@@ -3,7 +3,9 @@
<%- include('../partials/serverNav', { server, active: 'settings' }) %>
<% const _displayVersion=(server.serverType || 'vanilla' )==='custom' ? '(Unknown Version)' : (server.version
- || '(Unknown Version)' ); const _canChangeJar=['stopped', 'crashed' ].includes(server.state); %>
+ || '(Unknown Version)' ); const _canChangeJar=['stopped', 'crashed' ].includes(server.state);
+// Gate list for data-enable-when / data-show-when — see applyStateGates in app.js.
+const _gate = 'stopped crashed'; %>
+ <%# The jar URL and version are starred like the create page — both are
+ mandatory server settings. Neither carries `required`: they arrive
+ prefilled and can be disabled while the server runs, and an existing
+ server with a blank one (a modpack pinned to no version, an older
+ import) would leave data-validate-required wedging Save for good. %>
<% if ((server.serverType || 'vanilla' )==='custom' ) { %>
-
+
+ data-enable-when="<%= _gate %>" data-enabled-title=""
+ data-disabled-title="Stop the server to change the JAR URL."
title="<%= _canChangeJar ? '' : 'Stop the server to change the JAR URL.' %>">
- <%= _canChangeJar ? 'Change the URL to replace the server jar.'
- : 'Stop the server to change the JAR URL.' %>
+ >Change the URL to replace the server jar.
+ >Stop the server to change the JAR URL.
+ <%# Alone on its row, so edit.js centres this column through the
+ shared centerLoneRowItems — the same call the create form
+ makes, rather than a class hardcoded on one of the two. %>
@@ -167,9 +182,7 @@
Takes a restore point before the changes are applied, so restoring it
undoes them.
- <% if (!_canChangeJar) { %>
- The server will be stopped for the backup and restarted afterwards.
- <% } %>
+ >The server will be stopped for the backup and restarted afterwards.
@@ -464,22 +477,14 @@
Uncheck to copy only configuration and
mods/plugins (no world data).
- <% if (_serverStopped) { %>
-
- <% } else { %>
-
- <% } %>
+ <%# One button either way — edit.js decides at submit time whether to
+ duplicate directly or open the stop-first modal, based on live state. %>
+
@@ -528,22 +533,13 @@