diff --git a/README.md b/README.md index 77f2f6f..b263062 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.11-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) @@ -157,25 +157,7 @@ This project is licensed under the [GNU Affero General Public License v3.0](./LI ### AI Disclosure -This project uses AI tools to aid development. - -AI is used to: -- Plan significant changes -- Implement initial passes of new features -- Perform security audits (alongside human review) -- Fix bugs and patch security vulnerabilities -- Review pull requests (alongside human review) - -AI is NOT used to: -- Design UI/UX -- Design visual assets (such as bitmap and vector graphics) -- Triage issues -- Decide project direction -- Create release information - -AI has a tendency to hallucinate/produce plausible but suboptimal, inaccurate or misleading solutions to delegated tasks. - -Every commit is manually reviewed and approved by a member of Diamond Digital Development, and testing is carried out to ensure changes work as intended, do not introduce regressions, and meet reliability and security expectations before being merged into the `master` branch. +This project uses AI tools to aid development. Read our [AI Transparency & Quality Commitment](https://diamonddigital.dev/ai-transparency) statement for more information. ## Contact Us diff --git a/docs/API.md b/docs/API.md index 69585ee..480e9b5 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,49 @@ 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`, with an exact `Content-Length`. 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 client can send a whole relative path as the filename, and only the basename is meaningful to an endpoint that writes into one directory. +> +> **Directory trees cannot be uploaded.** An upload flattens what it is given into the destination folder; it never recreates a hierarchy under it. The panel refuses a dropped folder before anything is sent, because a browser does not expand one — it hands over a single zero-byte entry standing for the directory itself, which fails the moment it is read. Create the folders with `mkdir` and upload into them. ### Restore-point backups @@ -161,9 +215,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 build published for that Minecraft version, whatever channel it carries — the same build a fresh install or an upgrade downloads, so the check and the download can never disagree. Forge in particular tracks the `latest` promotion, not the older `recommended` one. 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"`, and a pre-release suffix sorts below the release it precedes (`"21.9.16-beta"` is older than `"21.9.16"`). `vanilla` and `custom` servers have no build at all. + ## Backups @@ -175,8 +231,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`, with an exact `Content-Length` read off the file rather than the record. `404` if the backup does not belong to this server | ## Server transfer @@ -185,14 +240,16 @@ 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` sends the download as `.cbx` with `Content-Type: application/x-craftbox-export+zip` and an exact `Content-Length`. 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) — an abandoned download leaves the server stopped. Requesting `backups` holds the backup lock while the archive is packed; `409` if a backup is already running. `507` if the staging area cannot hold the archive. + +> **The archive is packed before the response begins.** Nothing is sent until the whole `.cbx` exists, which is what makes the size knowable — a zip's length is not known until its last entry is written, and a browser given no `Content-Length` shows an indefinite "Resuming…" for the entire transfer with no size, percentage or ETA. Expect a pause on a large server before the first byte, proportional to the amount being packed. Packing failures therefore land **before** any header is sent and come back as ordinary JSON errors; only a fault while streaming an already-packed archive drops the connection mid-body. The same applies to `/servers/:id/download-zip`, `/servers/:id/plugins/download-all` and `/status/:id/mods`. > **`.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. Archive layout (`formatVersion` 1): ``` -craftbox-manifest.json manifest + full server config (always) +craftbox-manifest.json manifest + full server config + group color (always) modenv.json mod enable/disable environment map (always) server/ full server directory (always) backups.json backup metadata records (optional) @@ -209,7 +266,8 @@ Returns `201 {"success": true, "server": {...}, "warnings": [...]}`; extraction Import behavior: - The source server UUID is kept when free on the target instance, otherwise a new UUID is generated. Backup and event records always get fresh IDs. -- All settings (including `autoStart` and the dashboard group) are preserved; runtime state is reset (`exitCode`, `crashReason`, timestamps) and `advertisedIp` is cleared (host-specific). The server stays stopped after import until started. +- All settings are preserved, `advertisedIp` included — the archive is a snapshot of the server as it was, so an address that does not apply on the new host is an edit away rather than something to remember. Only runtime state is reset (`exitCode`, `crashReason`, timestamps); the server stays stopped after import until started. +- The dashboard group comes across by name, and its color travels in the manifest alongside it. A group that already exists on the target instance keeps the color chosen there — an import never restyles servers that were already in it. - A port collision with an existing server does not block the import; a warning is returned instead. @@ -224,11 +282,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,15 +344,19 @@ 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 | +> **Downloads.** The panel's download links live outside `/api/v1` and are listed here for completeness: `GET /servers/:id/plugins/download?file=` (one jar), `GET /servers/:id/plugins/download-all` (the whole folder as a zip), and `GET /servers/:id/download-zip` (the whole server directory). All three carry an exact `Content-Length` and report their outcome over the WebSocket as `operation: "download"`; the two zips are packed before the response begins, as [Export](#export) describes. + ## Modrinth @@ -354,23 +417,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 | +| GET | `/status/:id/mods` | Zip of client-facing mods, packed before the response begins so it carries an exact `Content-Length`; `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. @@ -395,10 +460,14 @@ The server pings every 30 seconds and drops sockets that miss a pong. | `state` | `{serverId, state, lastStarted, exitCode, crashReason}` | Lifecycle change | | `players` | `{serverId, players, count}` | Join/leave updates | | `event` | `{serverId, eventType, message, createdAt}` | Public sockets only receive started/stopped/crashed/restarted | -| `operation` | `{serverId, operation, status, payload?, error?}` | Progress/completion of async REST calls. `operation` ∈ `backup`, `restore`, `jar-upgrade`, `settings-save`, `create`, `duplicate`, `import`, `modpack-install`; `status` ∈ `complete`, `failed`, `progress`. `progress` is currently emitted by `modpack-install` only, with `payload {phase, done?, total?}` (see [Modrinth](#modrinth)). A restore-point save emits `backup` first, then `settings-save` | +| `operation` | `{serverId, operation, status, payload?, error?}` | Progress/completion of async REST calls. `operation` ∈ `backup`, `restore`, `jar-upgrade`, `settings-save`, `create`, `duplicate`, `import`, `modpack-install`, `download`; `status` ∈ `complete`, `failed`, `progress`, `cancelled`. `progress` is emitted by `modpack-install` with `payload {phase, done?, total?}` (see [Modrinth](#modrinth)) and by `download` (see below). A restore-point save emits `backup` first, then `settings-save` | | `events_cleared` | `{serverId}` | Event log was cleared | | `pong` / `error` | — | Heartbeat reply / protocol errors | +> **`operation: "download"` reports how a download went.** A browser download is invisible to the page that started it, so any download endpoint under a server reports its own outcome here — including the ones that are plain links rather than API calls. Add `?dl=` (any opaque string, up to 64 characters) to the download URL and the token comes back in every message about it, which is how a client matches an outcome to the request it made. Without the token nothing is emitted; API clients read the HTTP status instead. +> +> `progress` carries `{token, label, phase, done, total}` where `phase` is `packing` (bytes read so far, out of the estimated source size) or `sending` (`total` is the finished archive's size), throttled to one message a second. `complete` and `cancelled` carry `{token, label, bytes, sizeFormatted}` — `cancelled` means the client hung up before the last byte, whether during packing or mid-transfer. `failed` carries the reason in `error` and covers everything a download can be refused for, including the guard failures (`409` server running, `404` missing file, `507` no staging space) whose response body the browser never shows. + ## Rate limiting diff --git a/package-lock.json b/package-lock.json index f7de3f5..467a85f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { "name": "craftbox", - "version": "1.1.0", + "version": "1.2.0-beta.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftbox", - "version": "1.1.0", + "version": "1.2.0-beta.11", "license": "AGPL-3.0-only", "dependencies": { "archiver": "^7.0.1", "bcrypt": "^6.0.0", - "better-sqlite3": "^13.0.1", + "better-sqlite3": "^13.0.3", "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.2", "express-session": "^1.19.0", "material-icons": "^1.13.14", "multer": "^2.1.1", @@ -25,9 +25,9 @@ "passport": "^0.7.0", "passport-local": "^1.0.0", "quick.db": "^9.1.7", - "sharp": "^0.34.5", - "uuid": "^14.0.1", - "ws": "^8.21.1" + "sharp": "^0.35.4", + "uuid": "^14.0.2", + "ws": "^8.21.3" }, "funding": { "type": "buymeacoffee", @@ -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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "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.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "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.3" } }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "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.4" }, "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "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.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "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.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "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.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "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.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "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.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -2754,9 +2793,9 @@ } }, "node_modules/uuid": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", - "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -2888,9 +2927,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 77f4589..3d2e59d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbox", - "version": "1.1.0", + "version": "1.2.0-beta.11", "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.3", "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.2", "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", - "uuid": "^14.0.1", - "ws": "^8.21.1" + "sharp": "^0.35.4", + "uuid": "^14.0.2", + "ws": "^8.21.3" }, "allowScripts": { "bcrypt@6.0.0": true, - "better-sqlite3@13.0.1": true, - "sharp@0.34.5": true + "better-sqlite3@13.0.3": true, + "sharp@0.35.4": 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..26fb68c 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); @@ -176,6 +221,144 @@ function showToast(message, type) { toast.show(); } +// ── Stray file drops ── +// A file dropped somewhere the page does not handle is a navigation: the browser +// leaves for that file's own URL, and a half-filled form goes with it. The four +// pages that own an upload each cancelled that for themselves; every other page +// never did — including Create, which has the most to lose. +// +// Cancel it once here, for every page. Only for drags that carry files: dragging +// selected text into a textarea is a drop too, and has to keep working. Chrome, +// Firefox and Safari all put "Files" in `types` for an OS drag, a dragged folder +// included, and `types` is readable during the drag where the data itself is not. +function _dragCarriesFiles(e) { + var types = e.dataTransfer && e.dataTransfer.types; + return !!types && Array.prototype.indexOf.call(types, 'Files') !== -1; +} + +document.addEventListener('dragover', function (e) { + if (_dragCarriesFiles(e)) e.preventDefault(); +}); +document.addEventListener('drop', function (e) { + if (_dragCarriesFiles(e)) e.preventDefault(); +}); + +// ── Dropped folders ── +// A folder dragged onto the page does not arrive as the files inside it. The +// browser puts a single entry in dataTransfer.files standing for the directory +// itself — a File with size 0 and an empty type, which looks like an ordinary +// (if odd) file right up until something tries to read it. Handing that to +// FormData builds a request the browser then fails to send, and apiFetch has +// nothing to report but a bare `network_error`, which is what the user saw. +// +// Nothing here can upload a directory tree: every upload endpoint takes flat +// files and reduces a name to its basename (safeEntryName, src/utils/ +// fileBrowser.js). So the fix is to recognise a folder before it is queued and +// say so, rather than to send it and mistranslate the failure. +// +// dataTransfer.items is the part that actually knows: webkitGetAsEntry() must +// be called synchronously inside the drop handler (the item list is emptied +// once it returns), and its entry.isDirectory is definitive. Every browser +// Craftbox targets has it; the size/type shape check below only stands in if +// it is missing, where it costs an empty extension-less file being called a +// folder — rarer, and less confusing, than the failed request it replaces. +function _looksLikeFolder(file) { + return file.size === 0 && !file.type && !/\.[^.]+$/.test(file.name); +} + +// Call synchronously from a `drop` handler. Returns the droppable files and the +// names of anything that was a folder, for the caller to phrase its own message +// around — what to do instead differs per page. +function readDroppedItems(dataTransfer) { + var files = dataTransfer && dataTransfer.files + ? Array.prototype.slice.call(dataTransfer.files) + : []; + + // items carries dragged strings (links, selected text) as well as files; + // the file-kind ones line up with dataTransfer.files in order. + var fileItems = []; + if (dataTransfer && dataTransfer.items) { + Array.prototype.forEach.call(dataTransfer.items, function (item) { + if (item.kind === 'file') fileItems.push(item); + }); + } + var aligned = fileItems.length === files.length; + + var kept = []; + var folders = []; + files.forEach(function (file, i) { + var isDirectory = null; + if (aligned && typeof fileItems[i].webkitGetAsEntry === 'function') { + try { + var entry = fileItems[i].webkitGetAsEntry(); + if (entry) isDirectory = entry.isDirectory; + } catch (_) { /* fall through to the shape check */ } + } + if (isDirectory === null) isDirectory = _looksLikeFolder(file); + if (isDirectory) folders.push(file.name); else kept.push(file); + }); + + return { files: kept, folders: folders }; +} + +// Shared opening for those messages, so every page words the refusal the same +// way and only differs in the advice that follows. +function folderDropMessage(folders, advice) { + var lead = folders.length === 1 + ? '"' + folders[0] + '" is a folder, and folders cannot be uploaded.' + : 'Folders cannot be uploaded, and ' + folders.length + ' of the dropped items are folders.'; + // `advice` has to read for one folder and for several, so keep it plural + // where the caller can — the lead already names the single case. + return lead + ' ' + advice; +} + +// Lets an be a drop target in its own right. +// +// The page-wide guard above cancels the browser's default handling of a file +// drop, and that default is what put a dropped file into an input. Pages with a +// drop zone of their own already re-implement it; an input that IS the drop zone +// needs it back. It is also the only route by which a folder reaches an input, +// and the `change` event that follows cannot help — by then all it has is a File, +// which is exactly what a directory entry looks like. +function acceptFileDrops(input, advice) { + if (!input) return; + + // Both halves are needed: without dragover's preventDefault the drop never + // fires at all, and stopPropagation keeps a page-level handler from taking + // the same drop a second time. + input.addEventListener('dragover', function (e) { + e.preventDefault(); + e.stopPropagation(); + }); + + input.addEventListener('drop', function (e) { + e.preventDefault(); + e.stopPropagation(); + + var dropped = readDroppedItems(e.dataTransfer); + if (dropped.folders.length > 0) { + showToast(folderDropMessage(dropped.folders, advice), + dropped.files.length > 0 ? 'warning' : 'danger'); + } + + var keep = input.multiple ? dropped.files : dropped.files.slice(0, 1); + // Nothing droppable: leave whatever was already chosen alone rather than + // clearing it because the drop missed. + if (keep.length === 0) return; + + try { + var dt = new DataTransfer(); + keep.forEach(function (f) { dt.items.add(f); }); + input.files = dt.files; + } catch (_) { + return; // no DataTransfer support — leave the input as it was + } + // Whatever the page hangs off `change` (guardFileInput, form validation) + // now runs exactly as it would for a file chosen from the dialog. + input.dispatchEvent(new Event('change', { bubbles: true })); + }); +} + // ── File input extension guard ── // The `accept` attribute only filters the OS file dialog — the user can switch it // to "All files" and pick anything — so check the extension the moment a file is @@ -209,6 +392,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 +506,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..98d8eeb 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; @@ -107,6 +95,12 @@ form.addEventListener('change', validateCreateForm); // form listener above, so the Create button goes back to disabled. guardFileInput(mrpackFileInput, ['.mrpack'], 'Only .mrpack modpack files can be used here.'); +// This page has no drop zone of its own, so the picker is the drop target. A +// folder dropped on it used to be handed straight to the upload: one named +// something.mrpack even cleared the extension guard, and the request then failed +// as a bare network_error. +acceptFileDrops(mrpackFileInput, 'Drop the .mrpack file itself.'); + // ── Form submit — create via /api/v1/servers (or the modpack routes) ── form.addEventListener('submit', async (e) => { e.preventDefault(); @@ -147,12 +141,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 +194,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 +231,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 +298,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/download.js b/public/js/download.js new file mode 100644 index 0000000..e57d84a --- /dev/null +++ b/public/js/download.js @@ -0,0 +1,236 @@ +// ── Download tracking ── +// Browser downloads are opaque to the page that starts them: a plain link hands +// the transfer to the browser and never says whether it finished, was cancelled, +// or was refused with a 409. So Craftbox starts every panel download itself and +// lets the request report its own outcome back over the server WebSocket, keyed +// by a token minted here and put on the URL as `dl`. +// +// Any anchor tagged `data-download="
- <% if (_serverStopped) { %> - - <% } else { %> - - <% } %> + <%# Single button; edit.js branches on live state at submit time. %> + @@ -559,7 +555,9 @@

Download this server as a .cbx transfer archive that can be imported on another Craftbox instance. Server files and Craftbox settings are - always included. + always included. The archive is packed before the download starts, so + the browser can show its size and progress — expect a wait on a large + server.

@@ -574,7 +572,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
-<% } %>