From 2cd8d0e44b38b78cc22e7efd74acb8d3c3211783 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 26 Jul 2026 18:44:50 +0100 Subject: [PATCH 01/60] 1.1.1 - Update `supportsTellraw` logic to support several possible version types --- src/mc/BackupScheduler.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/mc/BackupScheduler.js b/src/mc/BackupScheduler.js index d3ca199..7e9fe20 100644 --- a/src/mc/BackupScheduler.js +++ b/src/mc/BackupScheduler.js @@ -6,11 +6,33 @@ const { STATES } = require('./stateMachine'); /** * Check if a Minecraft version supports tellraw (added in 1.7.2). - * Falls back to false for unparseable or missing versions. + * + * Handles both id eras: legacy 1.x.y and the year.drop.patch versioning that + * started in 2026 ("26.2"), where the leading number is a year rather than a + * fixed 1. Snapshot ids ("25w03a") map by snapshot year; pre/rc suffixes + * resolve like their target release. + * + * Falls back to false (plain `say`) for missing, pre-1.0 or unparseable ids. */ function supportsTellraw(version) { if (!version || typeof version !== 'string') return false; - const parts = version.split('.').map(Number); + + const snapshot = /^(\d{2})w\d{2}/.exec(version); + if (snapshot) { + // 1.7.2 landed late in 2013, so only 14w+ is unambiguously post-tellraw + return parseInt(snapshot[1], 10) >= 14; + } + + const cleaned = version.replace(/[ _-]?(?:pre|rc).*$/i, ''); + const parts = cleaned.split('.').map(Number); + if (parts.some(Number.isNaN)) return false; // pre-1.0 ids ("b1.7.3", "rd-132211") + + const major = parts[0] || 0; + if (major > 1) return true; // year.drop.patch era (26.x+) — well past 1.7.2 + if (major < 1) return false; // classic/indev ids ("0.30") + + // Legacy 1.x.y versioning. NeoForge-style pseudo ids ("1.26.1" for MC 26.1) + // land here too and still read as newer than 1.7.2. const minor = parts[1] || 0; const patch = parts[2] || 0; if (minor > 7) return true; From 2d06db23c5e54bde18b045a29e7620dc68d04816 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:26:21 +0000 Subject: [PATCH 02/60] Bump better-sqlite3 from 13.0.1 to 13.0.2 Bumps [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) from 13.0.1 to 13.0.2. - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v13.0.1...v13.0.2) --- updated-dependencies: - dependency-name: better-sqlite3 dependency-version: 13.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 9 ++++----- package.json | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index f7de3f5..7a2f4fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "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", @@ -833,10 +833,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==", - "hasInstallScript": true, + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" diff --git a/package.json b/package.json index 77f4589..9ff1790 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "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", From b81b9d095f764b8ebee62a7d61c83efd3dfdba8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:26:30 +0000 Subject: [PATCH 03/60] Bump express-rate-limit from 8.6.0 to 8.6.1 Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.6.0 to 8.6.1. - [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases) - [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.6.0...v8.6.1) --- updated-dependencies: - dependency-name: express-rate-limit dependency-version: 8.6.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f7de3f5..6ac94e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "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", @@ -1362,9 +1362,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", diff --git a/package.json b/package.json index 77f4589..2318a19 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "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", From a2ba825cdb083ba1a77e9da6a7e47c862185c7b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:26:09 +0000 Subject: [PATCH 04/60] Bump sharp from 0.34.5 to 0.35.3 Bumps [sharp](https://github.com/lovell/sharp) from 0.34.5 to 0.35.3. - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.3) --- updated-dependencies: - dependency-name: sharp dependency-version: 0.35.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 306 ++++++++++++++++++++++++++-------------------- package.json | 2 +- 2 files changed, 174 insertions(+), 134 deletions(-) diff --git a/package-lock.json b/package-lock.json index f7de3f5..3a4ab4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" @@ -2353,47 +2388,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..2cc256c 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,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" }, From 5b4e5fe443f5088e3600efb7420dcfad233ca36e Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 15:27:04 +0100 Subject: [PATCH 05/60] Document unlisted status page access as intentional (#58) The `statusPagePublic` flag is only consulted by the `/status` index route; individual status pages, their JSON, and the mods zip have never checked it and are reachable by anyone holding the server UUID. The docs claimed otherwise, which made the behaviour look like an access-control bug. Correct the claim and add a note explaining that the UUID is the capability token by design, so share links keep working for players without a panel account, and that scanner reports flagging these routes are false positives. --- docs/API.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/API.md b/docs/API.md index 69585ee..2cbb66e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -129,7 +129,7 @@ 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}` | @@ -354,17 +354,19 @@ 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 From 6e78e53401384fce118a618b2424919306bf9350 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 15:28:37 +0100 Subject: [PATCH 06/60] Show absolute timestamp alongside relative age in the event log The event log Time column showed only a relative age ("5m ago"), with the absolute time hidden in a title tooltip and formatted differently from the rest of the panel. It now renders the canonical formatDate() output followed by the relative age in parentheses, matching the backups, files, plugins and account pages. timeAgo() existed as two byte-identical private copies in events.js and status.js; hoist it into app.js next to formatDate() and drop both. The status page keeps its relative-only display. Also re-tick the event times every 30s so the relative half does not go stale on a long-open tab, and widen the column to fit the longer string. --- public/js/app.js | 12 ++++++++++++ public/js/events.js | 29 +++++++++++++---------------- public/js/status.js | 11 ----------- views/servers/events.ejs | 2 +- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 869cde6..52193df 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -56,6 +56,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); diff --git a/public/js/events.js b/public/js/events.js index 3ee1b7e..9b1acb2 100644 --- a/public/js/events.js +++ b/public/js/events.js @@ -10,23 +10,20 @@ }); } - // Format event timestamps as relative time - document.querySelectorAll('.event-time').forEach(function (el) { - var time = new Date(el.dataset.time); - el.textContent = timeAgo(time); - el.title = time.toLocaleString(); - }); - - function timeAgo(date) { - var seconds = Math.floor((Date.now() - date.getTime()) / 1000); - if (seconds < 60) return 'just now'; - var minutes = Math.floor(seconds / 60); - if (minutes < 60) return minutes + 'm ago'; - var hours = Math.floor(minutes / 60); - if (hours < 24) return hours + 'h ago'; - var days = Math.floor(hours / 24); - return days + 'd ago'; + // Format event timestamps as an absolute date/time followed by its relative + // age — "08/03/2026, 14:05:09 (5m ago)". formatDate is the same helper the + // backups, files, plugins and account pages use, so the absolute half reads + // identically across the panel. + function refreshEventTimes() { + document.querySelectorAll('.event-time').forEach(function (el) { + if (!el.dataset.time) return; + var time = new Date(el.dataset.time); + el.textContent = formatDate(el.dataset.time) + ' (' + timeAgo(time) + ')'; + }); } + refreshEventTimes(); + // Re-tick every 30s so the relative half stays honest on a long-open tab. + setInterval(refreshEventTimes, 30000); // Clear events: modal confirmation + overlay var clearForm = document.getElementById('clear-events-form'); diff --git a/public/js/status.js b/public/js/status.js index ec70b38..241edbf 100644 --- a/public/js/status.js +++ b/public/js/status.js @@ -42,17 +42,6 @@ // Re-tick every 30s so "just now" rolls over to "1m ago", "2m ago", ... setInterval(refreshEventTimes, 30000); - function timeAgo(date) { - var seconds = Math.floor((Date.now() - date.getTime()) / 1000); - if (seconds < 60) return 'just now'; - var minutes = Math.floor(seconds / 60); - if (minutes < 60) return minutes + 'm ago'; - var hours = Math.floor(minutes / 60); - if (hours < 24) return hours + 'h ago'; - var days = Math.floor(hours / 24); - return days + 'd ago'; - } - // Collect all server IDs on the page function getServerIds() { var cards = document.querySelectorAll('[data-server-id]'); diff --git a/views/servers/events.ejs b/views/servers/events.ejs index 2f227ed..2749da7 100644 --- a/views/servers/events.ejs +++ b/views/servers/events.ejs @@ -71,7 +71,7 @@ const eventLabels = Object.fromEntries( Event Details Initiated By - Time + Time From 6f693e0bf8f2dcbd59c6dc38a4ce692eeaf86335 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 15:30:23 +0100 Subject: [PATCH 07/60] Lock upload modal inputs while a .mrpack or .cbx is uploading (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both dashboard upload modals left their fields editable for the duration of the transfer. On the .mrpack modal that was actively misleading: name, port, memory and the EULA checkbox are snapshotted into the request when the upload starts, so any edit made afterwards was silently discarded. Add setControlsLocked(root, locked) to app.js and apply it to both flows. It skips [data-bs-dismiss="modal"] buttons, so Cancel and the header X stay live and the existing hide.bs.modal handlers can still abort the transfer and free the server-side DGUP session. It also marks forms [data-busy], because the shared data-validate-required handler re-enables the submit button on any input/change event and import.js dispatches those itself — without the guard the lock undid itself. --- public/js/app.js | 24 ++++++++++++++++++++++++ public/js/import.js | 16 +++++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 52193df..bc281da 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -221,6 +221,27 @@ function guardFileInput(input, extensions, message) { }); } +// ── 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'); + }); +} + // ── 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. @@ -231,6 +252,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/import.js b/public/js/import.js index 9654a65..a67679b 100644 --- a/public/js/import.js +++ b/public/js/import.js @@ -21,8 +21,8 @@ function resetModal() { uploading = false; + setControlsLocked(modalEl, false); fileInput.value = ''; - fileInput.disabled = false; confirmBtn.disabled = true; confirmBtn.innerHTML = confirmBtnHtml; progressWrap.classList.add('d-none'); @@ -48,15 +48,16 @@ uploading = true; currentUpload = new AbortController(); - confirmBtn.disabled = true; confirmBtn.innerHTML = ' Importing...'; - fileInput.disabled = true; + // Freeze the modal for the duration of the transfer — Cancel and the + // header X stay live so the upload can still be aborted. + setControlsLocked(modalEl, true); progressWrap.classList.remove('d-none'); function fail(message) { showToast(message || 'Import failed.', 'danger'); uploading = false; - fileInput.disabled = false; + setControlsLocked(modalEl, false); confirmBtn.innerHTML = confirmBtnHtml; confirmBtn.disabled = !fileInput.files.length; progressWrap.classList.add('d-none'); @@ -250,6 +251,7 @@ if (!mrpackModalEl || !mrpackForm) return; mrpackDroppedFile = file; mrpackUploading = false; + setControlsLocked(mrpackModalEl, false); mrpackConfirmBtn.innerHTML = mrpackConfirmHtml; mrpackProgressWrap.classList.add('d-none'); mrpackProgressBar.style.width = '0%'; @@ -278,9 +280,11 @@ mrpackUploading = true; mrpackUpload = new AbortController(); - mrpackConfirmBtn.disabled = true; mrpackConfirmBtn.innerHTML = ' Creating...'; + // The field values were snapshotted into `fields` below, so leaving + // the inputs editable during the upload would silently discard edits. + setControlsLocked(mrpackModalEl, true); mrpackProgressWrap.classList.remove('d-none'); uploadFile('/api/v1/servers/from-mrpack', mrpackDroppedFile, { @@ -300,12 +304,14 @@ mrpackUploading = false; if (res.aborted) { showToast('Upload cancelled.', 'info'); + setControlsLocked(mrpackModalEl, false); mrpackConfirmBtn.innerHTML = mrpackConfirmHtml; mrpackForm.dispatchEvent(new Event('input')); return; } if (res.status !== 201) { showToast((res.data && (res.data.message || res.data.error)) || 'Failed to create server from modpack.', 'danger'); + setControlsLocked(mrpackModalEl, false); mrpackConfirmBtn.innerHTML = mrpackConfirmHtml; mrpackProgressWrap.classList.add('d-none'); mrpackProgressBar.style.width = '0%'; From 6d10bcfb41753a9feadf929272d259fb96bdd40f Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 15:34:28 +0100 Subject: [PATCH 08/60] Explain and recover from session expiry instead of an "unauthorized" toast (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions are a 1-hour rolling idle timeout, so a tab left open is signed out with nothing on screen saying so. The next click produced a toast reading just "unauthorized" — the raw {error:'unauthorized'} body echoed by the ~60 call sites that surface data.error — and no way forward. Handle 401 centrally in apiFetch: explain what happened via flashToast, which survives the navigation, then redirect to /login, where ensureAuth's returnTo brings the user back to the page they were on. A latch keeps concurrent calls from queueing duplicate toasts and racing redirects. Migrate the 12 remaining raw fetch call sites to apiFetch so they inherit the handling and stop hand-rolling the CSRF header. The polled stats call on the console page now doubles as a passive heartbeat, so an idle tab detects expiry without the user clicking anything. Where a call site relied on res.json() throwing to reach a catch block, that is now an explicit res.ok check. Two supporting fixes: - csrfValidate rendered a misleading "Invalid or missing CSRF token" 403 for page form POSTs from a lapsed session, since the tab carries the old session's token. Redirect those to /login instead. - The four WebSocket reconnect loops retried forever, silently, 401ing the upgrade every 30s. Browsers hide the handshake status from JS, so after three consecutive failures spend one authenticated request to tell an expired session apart from an unreachable panel. --- public/js/account.js | 19 ++++++----------- public/js/app.js | 33 ++++++++++++++++++++++++++++++ public/js/console.js | 8 ++++++-- public/js/create.js | 44 +++++++++++++++++++--------------------- public/js/dashboard.js | 1 + public/js/edit.js | 4 ++-- public/js/modpacks.js | 4 ++-- public/js/plugins.js | 35 ++++++++++---------------------- public/js/serverState.js | 1 + src/security.js | 10 +++++++++ 10 files changed, 93 insertions(+), 66 deletions(-) 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 bc281da..c99f74d 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 diff --git a/public/js/console.js b/public/js/console.js index 3cdc648..37067f8 100644 --- a/public/js/console.js +++ b/public/js/console.js @@ -98,6 +98,7 @@ ws.onclose = () => { reconnectAttempts++; + probeSessionAfterFailures(reconnectAttempts); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); setTimeout(connect, delay); }; @@ -590,9 +591,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..55f0e6d 100644 --- a/public/js/create.js +++ b/public/js/create.js @@ -147,12 +147,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.
'; } @@ -238,23 +237,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 +304,8 @@ templateSelect.addEventListener('change', async () => { } try { - const res = await fetch(`/api/v1/templates/${id}`); - const data = await res.json(); + const res = await apiFetch(`/api/v1/templates/${id}`); + const data = res.data || {}; const t = data.template; if (!t) return; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 8e1e633..822a241 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -82,6 +82,7 @@ ws.onclose = () => { reconnectAttempts++; + probeSessionAfterFailures(reconnectAttempts); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); setTimeout(connect, delay); }; diff --git a/public/js/edit.js b/public/js/edit.js index be5c9a3..235aed6 100644 --- a/public/js/edit.js +++ b/public/js/edit.js @@ -769,8 +769,8 @@ function _formToBody(form) { resultDiv.classList.add('d-none'); try { - const res = await fetch('/api/v1/servers/' + serverId + '/check-upgrade'); - const data = await res.json(); + const res = await apiFetch('/api/v1/servers/' + serverId + '/check-upgrade'); + const data = res.data || {}; if (!res.ok) { showResult('danger', data.error || 'Failed to check for upgrades.'); diff --git a/public/js/modpacks.js b/public/js/modpacks.js index 9286f68..31e1f84 100644 --- a/public/js/modpacks.js +++ b/public/js/modpacks.js @@ -410,8 +410,8 @@ // ── Minecraft version filter options (vanilla release list) ── (async function loadVersionFilter() { try { - var res = await fetch('/api/v1/versions?type=vanilla'); - var data = await res.json(); + var res = await apiFetch('/api/v1/versions?type=vanilla'); + var data = res.data || {}; (data.versions || []).forEach(function (v) { var opt = document.createElement('option'); opt.value = v.id; diff --git a/public/js/plugins.js b/public/js/plugins.js index f3dc92f..b802e47 100644 --- a/public/js/plugins.js +++ b/public/js/plugins.js @@ -52,15 +52,11 @@ var newValue = sel.value; sel.disabled = true; try { - var res = await fetch('/api/v1/servers/' + serverId + '/plugins/environment', { + var res = await apiFetch('/api/v1/servers/' + serverId + '/plugins/environment', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrf - }, - body: JSON.stringify({ filename: filename, environment: newValue }) + body: { filename: filename, environment: newValue } }); - var data = await res.json(); + var data = res.data || {}; if (res.ok && data.success) { var row = sel.closest('tr[data-filename]'); if (row) row.setAttribute('data-env', newValue); @@ -110,12 +106,11 @@ for (var i = 0; i < jarFiles.length; i++) { formData.append('files', jarFiles[i]); } - var res = await fetch('/api/v1/servers/' + serverId + '/plugins/upload', { + var res = await apiFetch('/api/v1/servers/' + serverId + '/plugins/upload', { method: 'POST', - headers: { 'X-CSRF-Token': csrf }, body: formData }); - var data = await res.json(); + var data = res.data || {}; if (res.ok && data.success) { uploaded = data.uploaded || []; rejected = rejected.concat(data.rejected || []); @@ -265,16 +260,12 @@ confirmDeleteBtn.innerHTML = ' Deleting...'; try { - var res = await fetch('/api/v1/servers/' + serverId + '/plugins/delete', { + var res = await apiFetch('/api/v1/servers/' + serverId + '/plugins/delete', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrf - }, - body: JSON.stringify({ filename: pendingDeleteFilename }) + body: { filename: pendingDeleteFilename } }); - var data = await res.json(); + var data = res.data || {}; if (res.ok && data.success) { bsDeleteModal.hide(); flashToast(contentSingularCap + ' deleted.', 'success'); @@ -313,16 +304,12 @@ showOverlay('Deleting all ' + uploadLabel + '...', 'Please wait while all files are removed.'); try { - var res = await fetch('/api/v1/servers/' + serverId + '/plugins/delete-all', { + var res = await apiFetch('/api/v1/servers/' + serverId + '/plugins/delete-all', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrf - }, - body: JSON.stringify({}) + body: {} }); - var data = await res.json(); + var data = res.data || {}; if (res.ok && data.success) { flashToast('All ' + contentLabel + ' deleted.', 'success'); window.location.reload(); diff --git a/public/js/serverState.js b/public/js/serverState.js index 4a76089..49a3eac 100644 --- a/public/js/serverState.js +++ b/public/js/serverState.js @@ -54,6 +54,7 @@ ws.onclose = function () { reconnectAttempts++; + probeSessionAfterFailures(reconnectAttempts); var delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); setTimeout(connect, delay); }; diff --git a/src/security.js b/src/security.js index cadf8ba..0e4ebb4 100644 --- a/src/security.js +++ b/src/security.js @@ -54,6 +54,16 @@ function csrfValidate(req, res, next) { const isApi = req.path.startsWith('/api/'); const fail = (message) => { if (isApi) return res.status(403).json({ error: 'forbidden', message }); + + // A page POST from a tab whose session lapsed carries the old session's + // token, so it lands here rather than on ensureAuth's redirect. "Invalid + // or missing CSRF token" is a misleading dead end for what is really an + // expired login — send them to sign in and back to where they were. + if (!req.isAuthenticated?.()) { + if (req.session) req.session.returnTo = req.originalUrl; + return res.redirect('/login'); + } + return res.status(403).render('errors/403', { title: 'Forbidden', message, From 5fea21c0e976c22360e0698165fa5946ae239ded Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 15:43:11 +0100 Subject: [PATCH 09/60] Block backups and management pages while a server is provisioning (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server could be backed up mid-provision, which zipped a half-downloaded jar and a partly-extracted mods folder, moved the state to backing_up and then to stopped while provisioning was still running, and — because the Backups page sends startAfter=true for any non-stopped state — went on to start the half-built server. Three separate defects made that reachable: setOperationalState wrote any allowed target state without consulting canTransition, so every backup, restore and jar-upgrade flow bypassed the state machine. The table already forbade provisioning -> backing_up; nothing enforced it. It now honours the table, treating a same-state write as a no-op since several failure paths set stopped defensively. A rejected transition throws with status 409 so the routes report a state conflict rather than a 500 — this is what two racing operations look like, not a server fault. Preconditions were written as `proc && proc.state`, and processes are created lazily — only on start or on a WebSocket subscribe — so a server provisioning in the background has none and the guards silently passed. Add ServerManager.getState(server), which falls back to the persisted state, and use it for the backup, restore, upgrade-jar, restart and restore-point guards. The restore endpoint had no state guard at all. The views treated provisioning as "running", so the Backups page disarmed the one guard that existed by sending stopFirst. The provisioning check is now separate and stopFirst does not bypass it. The backup scheduler had the same hole from the other direction: with no process it took its "server not running, back up directly" branch. It now skips provisioning servers with a log line, matching how it already skips when another backup is in progress, rather than relying on the transition being refused underneath it. Also close the management pages while provisioning: blockWhileProvisioning redirects Settings, Properties, Plugins, Files and Backups to the Console with an explanatory flash, the nav links render disabled, and the page reloads itself once the server leaves provisioning. Console and Events stay open. --- docs/API.md | 2 ++ public/js/serverState.js | 17 +++++++++++ src/mc/BackupScheduler.js | 13 +++++++++ src/mc/ServerManager.js | 36 +++++++++++++++++++++++- src/middleware/blockWhileProvisioning.js | 30 ++++++++++++++++++++ src/routes/api-v1/backups.js | 24 ++++++++++++++-- src/routes/api-v1/servers.js | 23 +++++++++++---- src/routes/backups.js | 5 ++-- src/routes/plugins.js | 7 +++-- src/routes/servers.js | 17 +++++------ views/partials/serverNav.ejs | 16 +++++++---- 11 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 src/middleware/blockWhileProvisioning.js diff --git a/docs/API.md b/docs/API.md index 2cbb66e..9ad7b0c 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 diff --git a/public/js/serverState.js b/public/js/serverState.js index 49a3eac..50e0683 100644 --- a/public/js/serverState.js +++ b/public/js/serverState.js @@ -79,6 +79,23 @@ connect(); })(); +// ── Unlock the page when provisioning finishes ── +// The nav links and the console's action buttons are rendered server-side from +// the provisioning state, and the management pages are closed behind +// blockWhileProvisioning. Rather than patch each of those live, reload once the +// server leaves provisioning so everything re-renders from the real state. +// Both state updaters (this file's and console.js's) write data-state on the +// nav header, so watching that attribute covers every server page. +(function () { + var navHeader = document.getElementById('server-nav-header'); + if (!navHeader || navHeader.dataset.state !== 'provisioning') return; + + new MutationObserver(function () { + if (navHeader.dataset.state === 'provisioning') return; + window.location.reload(); + }).observe(navHeader, { attributes: true, attributeFilter: ['data-state'] }); +})(); + // ── Live version label ── // The nav header's " " text goes stale when a version upgrade // finishes. Runs on every server sub-page, including the console page (where diff --git a/src/mc/BackupScheduler.js b/src/mc/BackupScheduler.js index 7e9fe20..46f7487 100644 --- a/src/mc/BackupScheduler.js +++ b/src/mc/BackupScheduler.js @@ -101,6 +101,10 @@ class BackupScheduler { log('info', `[${server.name}] Skipping catch-up backup: another backup is already in progress.`); return; } + if (this.serverManager.getState(server) === STATES.PROVISIONING) { + log('info', `[${server.name}] Skipping catch-up backup: server is still provisioning.`); + return; + } log('info', `[${server.name}] Missed scheduled backup detected (last: ${lastScheduled.createdAt}). Creating catch-up backup...`); // Stop server if running before creating backup @@ -322,6 +326,15 @@ class BackupScheduler { return; } + // A provisioning server has no process yet, so it would otherwise fall + // into the "not running, back up directly" branch below and archive a + // half-assembled directory. setOperationalState would refuse the + // transition anyway; skip cleanly rather than logging a failure. + if (this.serverManager.getState(server) === STATES.PROVISIONING) { + log('info', `[${server.name}] Skipping scheduled backup: server is still provisioning.`); + return; + } + const schedule = server.backupSchedule || {}; const p = this.serverManager.getProcess(serverId); diff --git a/src/mc/ServerManager.js b/src/mc/ServerManager.js index f2de1fd..cd7b952 100644 --- a/src/mc/ServerManager.js +++ b/src/mc/ServerManager.js @@ -1,6 +1,6 @@ const ServerProcess = require('./ServerProcess'); const { serversDb } = require('../db'); -const { canPerformAction } = require('./stateMachine'); +const { canPerformAction, canTransition } = require('./stateMachine'); const { syncServerConfig } = require('./syncServerConfig'); const { log } = require('../utils/log'); @@ -16,6 +16,23 @@ class ServerManager { return this.processes.get(serverId) || null; } + /** + * The authoritative state for a server record: the live process state when + * one exists, otherwise the persisted one. + * + * Guards written as `proc && proc.state` silently pass when there is no + * ServerProcess — and processes are created lazily, only on start or on a + * WebSocket subscribe, so a server being provisioned in the background + * usually has none. That is exactly the case those guards most need to + * catch, so read the state through here instead. + * @param {{ id: string, state: string }} server + * @returns {string} + */ + getState(server) { + const proc = this.getProcess(server.id); + return proc ? proc.state : server.state; + } + /** * Lazily create a ServerProcess shell for subscription purposes. * Unlike _ensureProcess, this NEVER rebuilds an existing proc (it is safe @@ -165,6 +182,23 @@ class ServerManager { const server = await serversDb.get(`server_${serverId}`); if (!server) throw new Error('Server not found.'); + // Honour the transition table. This used to write any allowed target + // state unconditionally, which meant every backup/restore/jar-upgrade + // flow bypassed the state machine entirely — a provisioning server + // could be moved straight to backing_up and then reported as stopped + // while its directory was still being assembled. + // Re-asserting the current state stays a no-op: several failure paths + // set stopped defensively without knowing whether it is already set. + const current = this.getState(server); + if (current !== newState && !canTransition(current, newState)) { + // Tagged 409 so routes report a state conflict rather than a 500 — + // this fires when two operations race, which is the caller's + // problem to retry, not a server fault. + const err = new Error(`Cannot move server from ${current} to ${newState}.`); + err.status = 409; + throw err; + } + server.state = newState; if (newState === STATES.CRASHED && opts.crashReason) { server.crashReason = opts.crashReason; diff --git a/src/middleware/blockWhileProvisioning.js b/src/middleware/blockWhileProvisioning.js new file mode 100644 index 0000000..274e02d --- /dev/null +++ b/src/middleware/blockWhileProvisioning.js @@ -0,0 +1,30 @@ +const { serversDb } = require('../db'); +const { STATES } = require('../mc/stateMachine'); + +// Close the management pages while a server is still being provisioned. +// +// Provisioning means the panel is mid-way through assembling the server +// directory — downloading a jar, extracting a modpack, unpacking a transfer +// archive. Settings, Properties, Plugins, Files and Backups all read or write +// those files, so acting on them races the provisioning job and reports state +// that isn't true yet. Console and Events stay open so the user can watch it +// finish. +// +// Mount after ensureAuth on routes carrying a :id param. +module.exports = async function blockWhileProvisioning(req, res, next) { + const id = req.params.id; + if (!id) return next(); + + const server = await serversDb.get(`server_${id}`); + // Unknown server — let the route render its own 404. + if (!server) return next(); + + const proc = req.app.get('serverManager')?.getProcess(id); + const state = proc ? proc.state : server.state; + if (state !== STATES.PROVISIONING) return next(); + + req.session.flash = { + info: 'This server is still being set up. Management pages open once it finishes.' + }; + return res.redirect(`/servers/${id}`); +}; diff --git a/src/routes/api-v1/backups.js b/src/routes/api-v1/backups.js index 582bc15..92c599f 100644 --- a/src/routes/api-v1/backups.js +++ b/src/routes/api-v1/backups.js @@ -62,7 +62,16 @@ router.post('/servers/:id/backups', async (req, res) => { backupName = 'Manual Backup'; } - if (proc && ![STATES.STOPPED, STATES.CRASHED].includes(proc.state) && !stopFirst) { + // getServerWithState already overlaid the live state, so read it from the + // record rather than from `proc` — a provisioning server normally has no + // process yet, and a `proc &&` guard would wave it straight through. + if (server.state === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + + // stopFirst deliberately does not bypass the provisioning check above: it + // means "stop a running server for me", not "interrupt whatever is going on". + if (![STATES.STOPPED, STATES.CRASHED].includes(server.state) && !stopFirst) { return res.status(409).json({ error: 'Server must be stopped to create a backup.' }); } @@ -73,7 +82,7 @@ router.post('/servers/:id/backups', async (req, res) => { let lockOwnedByRoute = true; try { - if (proc && (proc.state === STATES.RUNNING || proc.state === STATES.STARTING)) { + if (server.state === STATES.RUNNING || server.state === STATES.STARTING) { await serverManager.stopServer(server.id, { initiatedBy }); await proc.waitForState(STATES.STOPPED, 60000); } @@ -122,6 +131,8 @@ router.post('/servers/:id/backups', async (req, res) => { if (lockOwnedByRoute) releaseBackupLock(server.id); log('error', `Backup setup failed for ${server.name}: ${err.message}`); if (!res.headersSent) { + // A rejected state transition is a conflict, not a server fault. + if (err.status === 409) return res.status(409).json({ error: err.message }); res.status(500).json({ error: `Backup failed: ${err.message}` }); } } @@ -143,8 +154,14 @@ router.post('/servers/:id/backups/:backupId/restore', async (req, res) => { const initiatedBy = req.user.username; const backupId = req.params.backupId; + // Restoring over a directory that is still being assembled would race the + // provisioning job and leave a half-built server behind. + if (server.state === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + try { - if (proc && (proc.state === STATES.RUNNING || proc.state === STATES.STARTING)) { + if (server.state === STATES.RUNNING || server.state === STATES.STARTING) { await serverManager.stopServer(server.id, { initiatedBy }); await proc.waitForState(STATES.STOPPED, 60000); } @@ -189,6 +206,7 @@ router.post('/servers/:id/backups/:backupId/restore', async (req, res) => { } catch (err) { log('error', `Restore setup failed for ${server.name}: ${err.message}`); if (!res.headersSent) { + if (err.status === 409) return res.status(409).json({ error: err.message }); res.status(500).json({ error: `Restore failed: ${err.message}` }); } } diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index 0265628..6e7c2fc 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -144,6 +144,10 @@ async function runWithRestorePoint({ req, res, server, label, operation, apply } const initiatedBy = req.user.username; const { runBackupJob, tryAcquireBackupLock, releaseBackupLock, formatSize } = require('../../mc/BackupManager'); + if (serverManager.getState(server) === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!tryAcquireBackupLock(id)) { return res.status(409).json({ error: 'A backup is already in progress for this server.' }); } @@ -210,7 +214,7 @@ async function runWithRestorePoint({ req, res, server, label, operation, apply } if (lockOwnedByRoute) releaseBackupLock(id); log('error', `Restore-point setup failed for ${id}: ${err.message}`); if (!res.headersSent) { - res.status(500).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 500).json({ error: err.message }); } } } @@ -528,8 +532,11 @@ router.post('/servers/:id/upgrade-jar', async (req, res) => { if (!server) return; const serverManager = req.app.get('serverManager'); - const proc = serverManager?.getProcess(server.id); - if (proc && !['stopped', 'crashed'].includes(proc.state)) { + const liveState = serverManager.getState(server); + if (liveState === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!['stopped', 'crashed'].includes(liveState)) { return res.status(409).json({ error: 'Stop the server before upgrading the jar.' }); } @@ -641,6 +648,7 @@ router.post('/servers/:id/upgrade-jar', async (req, res) => { if (lockOwnedByRoute) releaseBackupLock(server.id); log('error', `Jar upgrade setup failed for ${req.params.id}: ${err.message}`); if (!res.headersSent) { + if (err.status === 409) return res.status(409).json({ error: err.message }); res.status(500).json({ error: `Failed to upgrade jar: ${err.message}` }); } } @@ -1554,12 +1562,17 @@ router.post('/servers/:id/stop', async (req, res) => { // POST /servers/:id/restart router.post('/servers/:id/restart', async (req, res) => { - if (!await loadServerOr404(req, res)) return; + const server = await loadServerOr404(req, res); + if (!server) return; const serverManager = req.app.get('serverManager'); const id = req.params.id; const initiatedBy = req.user.username; const createBackupFirst = req.body?.backup === 'true' || req.body?.backup === true; + if (serverManager.getState(server) === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!createBackupFirst) { try { await clearStatsHistory(id); @@ -1617,7 +1630,7 @@ router.post('/servers/:id/restart', async (req, res) => { if (lockOwnedByRoute) releaseBackupLock(id); log('error', `Restart-with-backup setup failed for ${id}: ${err.message}`); if (!res.headersSent) { - res.status(500).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 500).json({ error: err.message }); } } }); diff --git a/src/routes/backups.js b/src/routes/backups.js index 5d12b33..2a16f3e 100644 --- a/src/routes/backups.js +++ b/src/routes/backups.js @@ -3,6 +3,7 @@ const fs = require('fs'); const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); +const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); const { serversDb, backupsDb } = require('../db'); const { log } = require('../utils/log'); const { @@ -27,7 +28,7 @@ async function getServerWithState(req) { } // GET /servers/:id/backups — Backups page (view only; mutations live on /api/v1) -router.get('/servers/:id/backups', ensureAuth, async (req, res) => { +router.get('/servers/:id/backups', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -68,7 +69,7 @@ router.get('/servers/:id/backups', ensureAuth, async (req, res) => { }); // GET /servers/:id/backups/:backupId/download — Download a backup ZIP (binary) -router.get('/servers/:id/backups/:backupId/download', ensureAuth, async (req, res) => { +router.get('/servers/:id/backups/:backupId/download', ensureAuth, blockWhileProvisioning, async (req, res) => { if (!UUID_RE.test(req.params.backupId)) { return res.status(400).json({ error: 'Invalid backup ID.' }); } diff --git a/src/routes/plugins.js b/src/routes/plugins.js index 69de6bb..ba77f66 100644 --- a/src/routes/plugins.js +++ b/src/routes/plugins.js @@ -4,6 +4,7 @@ const path = require('path'); const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); +const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); const { serversDb, SERVERS_DIR } = require('../db'); const { log } = require('../utils/log'); const { getContentType } = require('../utils/contentType'); @@ -35,7 +36,7 @@ function formatSize(bytes) { } // GET /servers/:id/plugins — Plugins/Mods page (view only; mutations live on /api/v1) -router.get('/servers/:id/plugins', ensureAuth, async (req, res) => { +router.get('/servers/:id/plugins', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -97,7 +98,7 @@ router.get('/servers/:id/plugins', ensureAuth, async (req, res) => { }); // GET /servers/:id/plugins/download — Download a single plugin/mod JAR (binary) -router.get('/servers/:id/plugins/download', ensureAuth, async (req, res) => { +router.get('/servers/:id/plugins/download', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) return res.status(404).json({ error: 'Server not found.' }); @@ -147,7 +148,7 @@ router.get('/servers/:id/plugins/download', ensureAuth, async (req, res) => { }); // GET /servers/:id/plugins/download-all — Download all plugins/mods as ZIP (binary) -router.get('/servers/:id/plugins/download-all', ensureAuth, async (req, res) => { +router.get('/servers/:id/plugins/download-all', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) return res.status(404).json({ error: 'Server not found.' }); diff --git a/src/routes/servers.js b/src/routes/servers.js index d6896fc..95b42f0 100644 --- a/src/routes/servers.js +++ b/src/routes/servers.js @@ -4,6 +4,7 @@ const path = require('path'); const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); +const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); const { serversDb, eventsDb, SERVERS_DIR } = require('../db'); const { listBackups, resolveBackupPath, tryAcquireBackupLock, releaseBackupLock } = require('../mc/BackupManager'); const { getModEnvMap } = require('../utils/modEnvironment'); @@ -92,7 +93,7 @@ function formatSize(bytes) { // Edit Server Settings (view only — mutations in /api/v1) // ═══════════════════════════════════════════ -router.get('/servers/:id/edit', ensureAuth, async (req, res) => { +router.get('/servers/:id/edit', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -122,7 +123,7 @@ router.get('/servers/:id/edit', ensureAuth, async (req, res) => { // Server Properties Editor (view only — mutations in /api/v1) // ═══════════════════════════════════════════ -router.get('/servers/:id/properties', ensureAuth, async (req, res) => { +router.get('/servers/:id/properties', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { @@ -222,14 +223,14 @@ async function handleFiles(req, res, subpath) { delete req.session.flash; } -router.get('/servers/:id/files', ensureAuth, (req, res) => handleFiles(req, res, '')); -router.get('/servers/:id/files/*subpath', ensureAuth, (req, res) => { +router.get('/servers/:id/files', ensureAuth, blockWhileProvisioning, (req, res) => handleFiles(req, res, '')); +router.get('/servers/:id/files/*subpath', ensureAuth, blockWhileProvisioning, (req, res) => { const sub = Array.isArray(req.params.subpath) ? req.params.subpath.join('/') : req.params.subpath; handleFiles(req, res, sub); }); // Individual file download (binary — stays here, browser-driven) -router.get('/servers/:id/download', ensureAuth, async (req, res) => { +router.get('/servers/:id/download', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await serversDb.get(`server_${req.params.id}`); if (!server) return res.status(404).json({ error: 'Not found' }); @@ -269,7 +270,7 @@ router.get('/servers/:id/download', ensureAuth, async (req, res) => { }); // Full server directory download as .zip (binary — stays here) -router.get('/servers/:id/download-zip', ensureAuth, async (req, res) => { +router.get('/servers/:id/download-zip', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await serversDb.get(`server_${req.params.id}`); if (!server) return res.status(404).json({ error: 'Not found' }); @@ -302,7 +303,7 @@ router.get('/servers/:id/download-zip', ensureAuth, async (req, res) => { // Server transfer export — server files + Craftbox settings always, backups and // event history when requested. Importable on another Craftbox instance via // POST /api/v1/servers/import. (binary download — stays here) -router.get('/servers/:id/export', ensureAuth, async (req, res) => { +router.get('/servers/:id/export', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await serversDb.get(`server_${req.params.id}`); if (!server) return res.status(404).json({ error: 'Not found' }); @@ -431,7 +432,7 @@ router.get('/servers/:id/export', ensureAuth, async (req, res) => { } }); -router.get('/servers/:id/edit-file', ensureAuth, async (req, res) => { +router.get('/servers/:id/edit-file', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { return res.status(404).render('errors/404', { diff --git a/views/partials/serverNav.ejs b/views/partials/serverNav.ejs index be4d0ce..5d5d446 100644 --- a/views/partials/serverNav.ejs +++ b/views/partials/serverNav.ejs @@ -4,6 +4,12 @@ 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"' : ''; %>
@@ -26,12 +32,12 @@ const _displayVersion = (server.serverType || 'vanilla') === 'custom' @@ -42,18 +48,18 @@ const _displayVersion = (server.serverType || 'vanilla') === 'custom' %> <% if (_contentLabel) { %> <% } %> From c25b30ec496ff5016aa4e85fdcb148d26afe893e Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 15:48:16 +0100 Subject: [PATCH 10/60] Fix Update Jar for imported and modpack-created servers (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servers created from a modpack or .mrpack, and servers imported from a .cbx, could not be offered a jar upgrade. Three separate defects, all funnelling through check-upgrade, whose result is the only thing that renders the Upgrade Jar button. A server with no recorded build was reported as having no upgrade available. That was a dead end: upgrading is the only thing that records a build, since upgrade-jar passes a null build to the provider, which installs the newest and writes it back. Imported and duplicated servers inherit whatever build the source had, including none, and nothing else ever repairs it. Such a server now reports an upgrade as available, with a reason explaining what will happen, so the button appears and the upgrade back-fills the build. Builds were compared with `>`, which is a string comparison for the providers that report dotted versions. "21.1.100" > "21.1.95" is false, so NeoForge and Forge upgrades were detected for some build numbers and not others — the reported "sometimes checking for upgrades fixes it, sometimes not". Add compareBuilds() alongside pickPreferredBuild, comparing numerically for Paper-family integer builds and segment-wise for dotted versions. Fabric returns null from getBuilds, so check-upgrade bailed out before comparing anything and every Fabric server reported itself up to date. Most Modrinth modpacks are Fabric, and the installer stores the pinned loader version as the build, so there was a real value being ignored. Add an optional getLatestBuild() provider method and implement it for Fabric. getBuilds stays null deliberately: making it return the loader list would surface a loader picker in the create/edit UI, which Fabric intentionally does not have. --- docs/API.md | 4 +++- public/js/edit.js | 7 ++++-- src/mc/serverTypes/_channels.js | 39 ++++++++++++++++++++++++++++++++- src/mc/serverTypes/fabric.js | 15 +++++++++++++ src/routes/api-v1/servers.js | 38 ++++++++++++++++++++++---------- 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/docs/API.md b/docs/API.md index 9ad7b0c..56d9058 100644 --- a/docs/API.md +++ b/docs/API.md @@ -163,9 +163,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 diff --git a/public/js/edit.js b/public/js/edit.js index 235aed6..81d2df7 100644 --- a/public/js/edit.js +++ b/public/js/edit.js @@ -778,8 +778,11 @@ function _formToBody(form) { } if (data.upgradeAvailable) { - showResult('warning', - 'Upgrade available: build #' + data.currentBuild + ' → #' + data.latestBuild); + // A server with no recorded build (imported, duplicated) sends a + // reason explaining that upgrading is what records one — there is + // no "from" build to name in the usual message. + showResult('warning', data.reason || + ('Upgrade available: build #' + data.currentBuild + ' → #' + data.latestBuild)); showUpgradeButton(); } else if (data.reason) { showResult('secondary', data.reason); diff --git a/src/mc/serverTypes/_channels.js b/src/mc/serverTypes/_channels.js index b79ef9e..ade34c3 100644 --- a/src/mc/serverTypes/_channels.js +++ b/src/mc/serverTypes/_channels.js @@ -34,4 +34,41 @@ function pickPreferredBuild(builds) { return stable || builds[0]; } -module.exports = { classifyMcId, pickPreferredBuild }; +/** + * Compare two build identifiers. Returns >0 when `a` is newer than `b`, <0 when + * older, 0 when equivalent or not comparable. + * + * Providers use two different shapes for `build`: Paper/Purpur/Folia report an + * integer build number, while Forge/NeoForge/Fabric report a dotted version + * string ("21.1.95", "0.16.9"). Comparing those with `>` compares them as text, + * so "21.1.100" > "21.1.95" is false and a genuine upgrade goes undetected — + * which is why an upgrade check appeared to work for some builds but not others. + * @param {number|string|null} a + * @param {number|string|null} b + */ +function compareBuilds(a, b) { + if (a == null || b == null) return 0; + + const aNum = Number(a); + const bNum = Number(b); + if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum; + + // Dotted versions: compare segment by segment, numerically where both + // segments are numeric. A missing segment counts as 0, so "21.1" < "21.1.1". + const aParts = String(a).split(/[.\-+]/); + const bParts = String(b).split(/[.\-+]/); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const ap = aParts[i] ?? '0'; + const bp = bParts[i] ?? '0'; + const an = Number(ap); + const bn = Number(bp); + if (Number.isFinite(an) && Number.isFinite(bn)) { + if (an !== bn) return an - bn; + } else if (ap !== bp) { + return ap < bp ? -1 : 1; + } + } + return 0; +} + +module.exports = { classifyMcId, pickPreferredBuild, compareBuilds }; diff --git a/src/mc/serverTypes/fabric.js b/src/mc/serverTypes/fabric.js index 64ef9c0..30801cb 100644 --- a/src/mc/serverTypes/fabric.js +++ b/src/mc/serverTypes/fabric.js @@ -36,6 +36,21 @@ module.exports = { return null; }, + // Fabric records the loader version as its build (see downloadJar below, and + // modpacks which pin fabric-loader exactly). getBuilds stays null so the + // create/edit version picker keeps auto-selecting, but the upgrade check + // needs to know what the newest loader is — otherwise a Fabric server can + // never be told an update exists. + async getLatestBuild() { + const res = await fetch(`${BASE}/versions/loader`); + if (!res.ok) throw new Error(`Failed to fetch Fabric loader versions: HTTP ${res.status}`); + const loaders = await res.json(); + + const stable = loaders.find(l => l.stable) || loaders[0]; + if (!stable) return null; + return { build: stable.version, channel: stable.stable ? 'stable' : 'beta' }; + }, + async downloadJar(version, build, destPath) { // Honor a pinned loader version (modpacks pin fabric-loader exactly); // otherwise use the latest stable loader. diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index 6e7c2fc..8b3ed59 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -27,7 +27,7 @@ const { STATES } = require('../../mc/stateMachine'); const { isPathInside } = require('../../utils/pathSafety'); const { normalizeGroupName, getGroupColor, pruneGroupMetaIfEmpty, GROUP_NAME_ERROR } = require('../../utils/serverGroups'); const { MC_VERSION_RE, isReleaseVersion } = require('../../utils/mcVersion'); -const { pickPreferredBuild } = require('../../mc/serverTypes/_channels'); +const { pickPreferredBuild, compareBuilds } = require('../../mc/serverTypes/_channels'); const { cleanupServerData } = require('../../utils/serverCleanup'); const { installModpack, parseMrpack, resolveLoader, pickLoaderFromArray } = require('../../mc/modpackInstaller'); const { assertWhitelistedUrl } = require('../../utils/httpDownload'); @@ -487,31 +487,45 @@ router.get('/servers/:id/check-upgrade', async (req, res) => { const provider = getProvider(type); if (!provider) return res.json({ upgradeAvailable: false }); - if (!provider.getBuilds || type === 'custom') { + if (type === 'custom' || (!provider.getBuilds && !provider.getLatestBuild)) { return res.json({ upgradeAvailable: false, reason: 'No build tracking for this server type.' }); } - const builds = await provider.getBuilds(server.version); - if (!builds || builds.length === 0) { - return res.json({ upgradeAvailable: false }); + // Providers with no user-facing build picker (Fabric) expose the newest + // build directly instead of a list. + let preferred = null; + if (provider.getLatestBuild) { + preferred = await provider.getLatestBuild(server.version); + } else { + const builds = await provider.getBuilds(server.version); + // getBuilds includes non-stable channels — prefer the newest stable + // build so stable servers aren't offered ALPHA/BETA builds. + if (builds && builds.length > 0) preferred = pickPreferredBuild(builds); + } + if (!preferred) { + return res.json({ upgradeAvailable: false, reason: 'No builds published for this version.' }); } - // getBuilds now includes non-stable channels — prefer the newest - // stable build so stable servers aren't offered ALPHA/BETA builds. - const preferred = pickPreferredBuild(builds); const latestBuild = preferred.build; const currentBuild = server.build; + // A server imported from a .cbx, duplicated, or provisioned before build + // tracking existed can have no recorded build. Reporting "no upgrade + // available" left it permanently stuck, because upgrading is the only + // thing that records a build: upgrade-jar passes a null build to the + // provider, which installs the newest and writes it back. So offer the + // upgrade rather than refusing it. if (currentBuild == null) { return res.json({ - upgradeAvailable: false, - latestBuild, + upgradeAvailable: true, currentBuild: null, - reason: 'No build number recorded for this server.' + latestBuild, + channel: preferred.channel || null, + reason: `No build recorded for this server. Upgrading installs build ${latestBuild} and records it.` }); } - const upgradeAvailable = latestBuild !== currentBuild && latestBuild > currentBuild; + const upgradeAvailable = compareBuilds(latestBuild, currentBuild) > 0; res.json({ upgradeAvailable, currentBuild, From 005da397fefd6c789e109fbbd11dc07b00a96838 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 15:53:51 +0100 Subject: [PATCH 11/60] Add API endpoints to read the mod/plugin list and environment map (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API could upload, delete and set the environment of a mod, but could not read what was installed. The only way to get the list under bearer auth was to download the public status-page mods zip, which omits server-only mods and means transferring every jar to answer a question about names. Add two reads under /api/v1: GET /servers/:id/plugins -> {contentType, files[]} GET /servers/:id/plugins/environment -> {environment} Both reuse the composition the plugins page already performs — getContentType, listModFiles and getModEnvMap — so the API and the UI derive `environment` the same way and cannot disagree: 'both' is the absence of a map entry, and a disabled jar on disk is how a client-only mod is represented. Unlike the mutating routes in this file, reads do not require the server to be stopped, and the listing does not create the content directory the way the page render does — a GET should not have side effects on disk. --- docs/API.md | 4 ++- src/routes/api-v1/plugins.js | 67 +++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index 56d9058..d91836a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -289,10 +289,12 @@ 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 | |---|---|---| +| 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. 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}` | | POST | `/servers/:id/plugins/delete-all` | Delete all plugins/mods | diff --git a/src/routes/api-v1/plugins.js b/src/routes/api-v1/plugins.js index 5011595..b370e4c 100644 --- a/src/routes/api-v1/plugins.js +++ b/src/routes/api-v1/plugins.js @@ -12,9 +12,12 @@ const { DISABLED_SUFFIX, setModEnv, clearModEnv, - clearAllModEnv + clearAllModEnv, + listModFiles, + getModEnvMap } = require('../../utils/modEnvironment'); const { isPathInside } = require('../../utils/pathSafety'); +const { formatSize } = require('../../utils/resourceStats'); const { cleanupTempFiles, isZipFile } = require('../../utils/uploadSafety'); const { createDgupRouter, multerShim } = require('../../middleware/dgup'); @@ -111,6 +114,68 @@ const uploadPluginsHandler = async (req, res) => { res.json({ success: true, count: uploaded.length, uploaded, rejected }); }; +// GET /servers/:id/plugins — List installed plugins/mods. +// Unlike the mutating routes below this does not require the server to be +// stopped, and does not create the content directory: a read should not have +// side effects on disk. +router.get('/servers/:id/plugins', async (req, res) => { + try { + const server = await getServerWithState(req); + if (!server) return res.status(404).json({ error: 'Server not found.' }); + + const contentType = getContentType(server.serverType); + if (!contentType) { + return res.status(404).json({ error: 'This server type does not support plugins or mods.' }); + } + + const contentDir = path.join(path.resolve(SERVERS_DIR, server.id), contentType.folder); + if (!fs.existsSync(contentDir)) { + return res.json({ contentType: { label: contentType.label, folder: contentType.folder }, files: [] }); + } + + // 'both' is stored as the absence of a key, and a disabled jar is how a + // client-only mod is represented on disk — same derivation the plugins + // page uses, so the API and the UI never disagree. + const isMods = contentType.label === 'Mods'; + const envMap = isMods ? await getModEnvMap(server.id) : {}; + + const files = listModFiles(contentDir).map(entry => ({ + name: entry.displayName, + size: entry.size, + sizeFormatted: formatSize(entry.size), + modifiedISO: entry.modified.toISOString(), + environment: isMods + ? (entry.isDisabled ? 'client' : (envMap[entry.displayName] || 'both')) + : 'both' + })); + + res.json({ contentType: { label: contentType.label, folder: contentType.folder }, files }); + } catch (err) { + log('error', `Failed to list plugins for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to list plugins.' }); + } +}); + +// GET /servers/:id/plugins/environment — Read the mod environment map. +// Only the non-default entries are stored, so a mod missing from the map is +// 'both'. Mods-type servers only; plugin loaders have no environment concept. +router.get('/servers/:id/plugins/environment', async (req, res) => { + try { + const server = await getServerWithState(req); + if (!server) return res.status(404).json({ error: 'Server not found.' }); + + const contentType = getContentType(server.serverType); + if (!contentType || contentType.label !== 'Mods') { + return res.status(400).json({ error: 'This server type does not support mod environments.' }); + } + + res.json({ environment: await getModEnvMap(server.id) }); + } catch (err) { + log('error', `Failed to read mod environment for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to read mod environment.' }); + } +}); + // POST /servers/:id/plugins/upload — Upload JAR file(s) (single multipart request) router.post('/servers/:id/plugins/upload', multerShim(upload.any()), uploadPluginsHandler); From d2df3f9e070199912c20f778f51c3d4bf70fac3f Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 16:01:48 +0100 Subject: [PATCH 12/60] Add file read/download endpoints and move export under /api/v1 (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API could write a file but never read one. POST /servers/:id/edit-file was write-only, and the two routes that did emit file contents — the .cbx export and the backup download — lived in the browser-facing router behind session auth, so a bearer caller was redirected to /login. With an API key alone, no file on a server was reachable. Add three reads under /api/v1: GET /servers/:id/files?path= directory listing GET /servers/:id/file?path= text file contents as JSON GET /servers/:id/download?path= raw stream, any file and move the two existing binary routes into /api/v1 so a key can reach them: GET /servers/:id/export GET /servers/:id/backups/:backupId/download Moving them is transparent to the browser: ensureApiAuth accepts an existing session before it looks for a bearer token, and GETs skip CSRF, so the panel's plain links keep working — they just point at the new paths. Where those routes previously flashed and redirected on error they now return JSON, which suits both callers. Path handling matches the rest of the panel: resolve against the server directory, reject anything outside it with isPathInside (symlinks resolved), 403 on traversal. /download keeps the stopped-server requirement and the EBUSY -> 409 handling, since a running server holds handles on jars and world data. Extract the file-browser helpers to utils/fileBrowser.js. isTextFile and its extension set existed as identical copies in the web and API routers, and this added a third caller; listDirectory is lifted out of the Files page handler so the page and the API describe a directory identically rather than drifting. --- docs/API.md | 17 ++- public/js/edit.js | 2 +- src/routes/api-v1/backups.js | 45 +++++- src/routes/api-v1/servers.js | 264 +++++++++++++++++++++++++++++++++-- src/routes/backups.js | 49 +------ src/routes/servers.js | 175 +---------------------- src/utils/fileBrowser.js | 51 +++++++ views/servers/backups.ejs | 2 +- 8 files changed, 369 insertions(+), 236 deletions(-) create mode 100644 src/utils/fileBrowser.js diff --git a/docs/API.md b/docs/API.md index d91836a..7c6250c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -137,6 +137,18 @@ The server object returned by these endpoints contains the full configuration (n | 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 | +### 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 text endpoint will serve. `404` if the path is not a directory | +| GET | `/servers/:id/file?path=` | Read a text file. Returns `{"file": {name, path, size, modifiedISO, content}}`. `400` for a binary extension — use `/download` | +| 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` | + +> **Text vs binary is decided by extension, not by content.** The editable set is `.txt .log .properties .json .yml .yaml .xml .cfg .conf .ini .toml .csv .md .sh .bat .cmd .ps1 .js .ts .py .java .html .css .mcmeta .lang .sk .nbt`. Everything else is downloadable but not readable as text. + ### Restore-point backups `POST /edit` and `POST /properties` accept `backup: true`. The backup is taken **before** the change is applied, so restoring it undoes the change completely — a backup taken afterwards captures the new configuration and cannot roll it back. The endpoint then: @@ -179,8 +191,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 @@ -189,7 +200,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. diff --git a/public/js/edit.js b/public/js/edit.js index 81d2df7..5cff7e1 100644 --- a/public/js/edit.js +++ b/public/js/edit.js @@ -671,7 +671,7 @@ function _formToBody(form) { function exportUrl(startAfter) { var backups = document.getElementById('export-backups').checked ? 'true' : 'false'; var events = document.getElementById('export-events').checked ? 'true' : 'false'; - var url = '/servers/' + serverId + '/export?backups=' + backups + '&events=' + events; + var url = '/api/v1/servers/' + serverId + '/export?backups=' + backups + '&events=' + events; if (startAfter) url += '&start=true'; return url; } diff --git a/src/routes/api-v1/backups.js b/src/routes/api-v1/backups.js index 92c599f..cf7f2b6 100644 --- a/src/routes/api-v1/backups.js +++ b/src/routes/api-v1/backups.js @@ -1,4 +1,6 @@ const express = require('express'); +const fs = require('fs'); +const contentDisposition = require('content-disposition'); const router = express.Router(); const { serversDb, backupsDb } = require('../../db'); const { log } = require('../../utils/log'); @@ -11,7 +13,8 @@ const { applyRetention, formatSize, tryAcquireBackupLock, - releaseBackupLock + releaseBackupLock, + resolveBackupPath } = require('../../mc/BackupManager'); const { STATES } = require('../../mc/stateMachine'); const { syncServerConfig } = require('../../mc/syncServerConfig'); @@ -45,6 +48,46 @@ router.get('/servers/:id/backups', async (req, res) => { res.json({ backups: backupsFormatted }); }); +// GET /servers/:id/backups/:backupId/download — Stream a backup archive. +router.get('/servers/:id/backups/:backupId/download', async (req, res) => { + if (!UUID_RE.test(req.params.backupId)) { + return res.status(400).json({ error: 'Invalid backup ID.' }); + } + const server = await getServerWithState(req); + if (!server) return res.status(404).json({ error: 'Server not found.' }); + + // Check ownership, not just existence — a backup id from another server + // must not be readable through this server's route. + const backup = await backupsDb.get(`backup_${req.params.backupId}`); + if (!backup || backup.serverId !== server.id) { + return res.status(404).json({ error: 'Backup not found.' }); + } + + let zipPath; + try { + zipPath = resolveBackupPath(server.id, backup.filename); + } catch { + return res.status(403).json({ error: 'Access denied.' }); + } + if (!fs.existsSync(zipPath)) { + return res.status(404).json({ error: 'Backup file not found on disk.' }); + } + + const safeName = server.name.replace(/[^a-zA-Z0-9_-]/g, '_'); + const safeFilename = backup.filename.replace(/[^a-zA-Z0-9._-]/g, '_'); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', contentDisposition(`${safeName}_backup_${safeFilename}`)); + res.setHeader('Content-Length', backup.size); + + const stream = fs.createReadStream(zipPath); + stream.on('error', (err) => { + log('error', `Backup download error: ${err.message}`); + if (!res.headersSent) res.status(500).json({ error: 'Download failed.' }); + }); + stream.pipe(res); +}); + // POST /servers/:id/backups — Kick off a manual backup. Returns 202 immediately; // completion (or failure) is reported via the per-server WebSocket as // { type: 'operation', operation: 'backup', status: 'complete'|'failed', ... }. diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index 8b3ed59..f2cdfd8 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -4,10 +4,18 @@ const fs = require('fs'); const path = require('path'); const multer = require('multer'); const StreamZip = require('node-stream-zip'); +const archiver = require('archiver'); +const contentDisposition = require('content-disposition'); const { v4: uuidv4 } = require('uuid'); const router = express.Router(); const { serversDb, backupsDb, eventsDb, SERVERS_DIR } = require('../../db'); -const { ensureBackupDir, resolveBackupPath } = require('../../mc/BackupManager'); +const { + ensureBackupDir, + resolveBackupPath, + listBackups, + tryAcquireBackupLock, + releaseBackupLock +} = require('../../mc/BackupManager'); const { getProvider, listProviders } = require('../../mc/serverTypes'); const { downloadServerJar } = require('../../mc/downloader'); const { log } = require('../../utils/log'); @@ -19,7 +27,7 @@ const { setServerIcon, resetServerIcon, removeServerIcon, getIconPath, copyDefau const { writeServerProperties, writeEula, parseServerProperties, updateServerProperties } = require('../../mc/serverProperties'); const { PROPERTY_META } = require('../../mc/propertyMeta'); const { getContentType } = require('../../utils/contentType'); -const { copyModEnvMap, setModEnvMap } = require('../../utils/modEnvironment'); +const { copyModEnvMap, setModEnvMap, getModEnvMap } = require('../../utils/modEnvironment'); const { isZipFile } = require('../../utils/uploadSafety'); const { createDgupRouter, multerShim } = require('../../middleware/dgup'); const { syncServerConfig } = require('../../mc/syncServerConfig'); @@ -28,6 +36,7 @@ const { isPathInside } = require('../../utils/pathSafety'); const { normalizeGroupName, getGroupColor, pruneGroupMetaIfEmpty, GROUP_NAME_ERROR } = require('../../utils/serverGroups'); const { MC_VERSION_RE, isReleaseVersion } = require('../../utils/mcVersion'); const { pickPreferredBuild, compareBuilds } = require('../../mc/serverTypes/_channels'); +const { isTextFile, listDirectory } = require('../../utils/fileBrowser'); const { cleanupServerData } = require('../../utils/serverCleanup'); const { installModpack, parseMrpack, resolveLoader, pickLoaderFromArray } = require('../../mc/modpackInstaller'); const { assertWhitelistedUrl } = require('../../utils/httpDownload'); @@ -250,15 +259,6 @@ function notifyDashboard(req) { }); } -const TEXT_EXTENSIONS = new Set([ - '.txt', '.log', '.properties', '.json', '.yml', '.yaml', '.xml', - '.cfg', '.conf', '.ini', '.toml', '.csv', '.md', '.sh', '.bat', - '.cmd', '.ps1', '.js', '.ts', '.py', '.java', '.html', '.css', - '.mcmeta', '.lang', '.sk', '.nbt' -]); -function isTextFile(filename) { - return TEXT_EXTENSIONS.has(path.extname(filename).toLowerCase()); -} const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -2362,6 +2362,248 @@ router.post('/servers/:id/properties', async (req, res) => { res.json({ success: true }); }); +// Resolve a caller-supplied path against a server's directory. +// Returns null once a response has been sent — the caller must `return`. +function resolveServerPath(req, res, server, rawPath) { + const serverDir = path.resolve(SERVERS_DIR, server.id); + const targetPath = path.resolve(serverDir, rawPath || ''); + if (!isPathInside(serverDir, targetPath)) { + res.status(403).json({ error: 'Access denied.' }); + return null; + } + return { serverDir, targetPath }; +} + +// GET /servers/:id/files?path= — List a directory inside the server. +router.get('/servers/:id/files', async (req, res) => { + try { + const server = await loadServerOr404(req, res); + if (!server) return; + + const resolved = resolveServerPath(req, res, server, req.query.path); + if (!resolved) return; + const { targetPath } = resolved; + + if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) { + return res.status(404).json({ error: 'Directory not found.' }); + } + + res.json({ path: String(req.query.path || ''), files: listDirectory(targetPath) }); + } catch (err) { + log('error', `Failed to list files for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to list files.' }); + } +}); + +// GET /servers/:id/file?path= — Read a text file's contents. +// Binary files are refused here and must be fetched from /download instead; +// this mirrors what the file editor will open (see utils/fileBrowser). +router.get('/servers/:id/file', async (req, res) => { + try { + const server = await loadServerOr404(req, res); + if (!server) return; + + if (!req.query.path) return res.status(400).json({ error: 'No path specified.' }); + + const resolved = resolveServerPath(req, res, server, req.query.path); + if (!resolved) return; + const { targetPath } = resolved; + + if (!fs.existsSync(targetPath) || fs.statSync(targetPath).isDirectory()) { + return res.status(404).json({ error: 'File not found.' }); + } + if (!isTextFile(path.basename(targetPath))) { + return res.status(400).json({ error: 'This file type cannot be read as text. Use /download instead.' }); + } + + const stat = fs.statSync(targetPath); + res.json({ + file: { + name: path.basename(targetPath), + path: String(req.query.path), + size: stat.size, + modifiedISO: stat.mtime.toISOString(), + content: fs.readFileSync(targetPath, 'utf8') + } + }); + } catch (err) { + log('error', `Failed to read file for ${req.params.id}: ${err.message}`); + res.status(500).json({ error: 'Failed to read file.' }); + } +}); + +// GET /servers/:id/download?path= — Stream any single file, text or binary. +// Requires the server stopped: a running server holds handles on world data and +// jars, and on Windows reading them fails with EBUSY mid-stream. +router.get('/servers/:id/download', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + + const liveState = req.app.get('serverManager').getState(server); + if (liveState === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!['stopped', 'crashed'].includes(liveState)) { + return res.status(409).json({ error: 'Stop the server before downloading files.' }); + } + + if (!req.query.path) return res.status(400).json({ error: 'No path specified.' }); + + const resolved = resolveServerPath(req, res, server, req.query.path); + if (!resolved) return; + const { targetPath } = resolved; + + if (!fs.existsSync(targetPath) || fs.statSync(targetPath).isDirectory()) { + return res.status(404).json({ error: 'File not found.' }); + } + + res.setHeader('Content-Disposition', contentDisposition(path.basename(targetPath))); + res.setHeader('Content-Type', 'application/octet-stream'); + + const stream = fs.createReadStream(targetPath); + stream.on('error', (err) => { + if (res.headersSent) return; + if (err.code === 'EBUSY') { + res.status(409).json({ error: 'File is currently in use by the server. Try again later or stop the server first.' }); + } else { + res.status(500).json({ error: 'Failed to download file.' }); + } + }); + stream.pipe(res); +}); + +// GET /servers/:id/export — Server transfer archive (.cbx): server files and +// Craftbox settings always, backups and event history when requested. +// Importable on another instance via POST /servers/import. +router.get('/servers/:id/export', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + + const serverManager = req.app.get('serverManager'); + const liveState = serverManager.getState(server); + if (liveState === STATES.PROVISIONING) { + return res.status(409).json({ error: 'Wait for the server to finish provisioning.' }); + } + if (!['stopped', 'crashed'].includes(liveState)) { + return res.status(409).json({ error: 'Stop the server before exporting.' }); + } + + const serverDir = path.join(SERVERS_DIR, server.id); + if (!fs.existsSync(serverDir)) return res.status(404).json({ error: 'Server directory not found.' }); + + const includeBackups = req.query.backups === 'true'; + const includeEvents = req.query.events === 'true'; + const startAfter = req.query.start === 'true'; + const initiatedBy = req.user?.username; + + // Hold the backup lock while streaming so a scheduled backup can't write a + // partial zip into the archive mid-export. + let lockHeld = false; + if (includeBackups) { + if (!tryAcquireBackupLock(server.id)) { + return res.status(409).json({ error: 'A backup is currently in progress. Try again when it completes.' }); + } + lockHeld = true; + } + const releaseLock = () => { + if (lockHeld) { + releaseBackupLock(server.id); + lockHeld = false; + } + }; + + // Optional restart once the archive has fully streamed — by then every + // server file has been read, so starting the server can no longer corrupt + // the export. + let startRequested = false; + const startServerAfterExport = () => { + if (!startAfter || startRequested || !serverManager) return; + startRequested = true; + serverManager.startServer(server.id, { initiatedBy }).catch((err) => { + log('error', `Failed to start server after export: ${err.message}`); + }); + }; + + try { + const backups = includeBackups ? await listBackups(server.id) : []; + let events = []; + if (includeEvents) { + const allEvents = await eventsDb.all(); + events = allEvents + .map(row => row.value) + .filter(e => e.serverId === server.id) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + const modEnv = await getModEnvMap(server.id); + + const manifest = { + format: 'craftbox-server-export', + formatVersion: 1, + exportedAt: new Date().toISOString(), + craftboxVersion: require('../../../package.json').version, + server, + includes: { backups: includeBackups, events: includeEvents }, + backupCount: backups.length, + eventCount: events.length + }; + + const safeName = server.name.replace(/[^a-zA-Z0-9_-]/g, '_'); + + // A .cbx transfer archive is a zip container with a Craftbox manifest at + // its root. The dedicated media type stops browsers from "correcting" + // the extension back to .zip on download. + res.setHeader('Content-Type', 'application/x-craftbox-export+zip'); + res.setHeader('Content-Disposition', contentDisposition(`${safeName}.cbx`)); + + const archive = archiver('zip', { zlib: { level: 5 } }); + archive.on('error', (err) => { + log('error', `Export archive error for ${server.name}: ${err.message}`); + releaseLock(); + if (!res.headersSent) res.status(500).json({ error: 'Archive failed.' }); + }); + res.on('close', releaseLock); + archive.on('end', releaseLock); + res.on('finish', startServerAfterExport); + // 'finish' = the full archive reached the client; 'close' without it + // means the download was abandoned mid-stream. + res.on('finish', () => { + log('info', `Export of "${server.name}" (${server.id}) completed — ${formatSize(archive.pointer())} sent`); + }); + res.on('close', () => { + if (!res.writableFinished) { + log('warn', `Export of "${server.name}" (${server.id}) aborted by client after ${formatSize(archive.pointer())}`); + } + }); + + archive.pipe(res); + archive.append(JSON.stringify(manifest, null, 2), { name: 'craftbox-manifest.json' }); + archive.append(JSON.stringify(modEnv, null, 2), { name: 'modenv.json' }); + archive.directory(serverDir, 'server'); + + if (includeBackups) { + archive.append(JSON.stringify(backups, null, 2), { name: 'backups.json' }); + for (const b of backups) { + try { + const zipPath = resolveBackupPath(server.id, b.filename); + if (fs.existsSync(zipPath)) { + archive.file(zipPath, { name: `backups/${b.filename}` }); + } + } catch { /* skip backups with invalid filenames */ } + } + } + if (includeEvents) { + archive.append(JSON.stringify(events, null, 2), { name: 'events.json' }); + } + + log('info', `Exporting server "${server.name}" (${server.id}) — backups: ${includeBackups} (${backups.length}), events: ${includeEvents} (${events.length}), startAfter: ${startAfter}`); + archive.finalize(); + } catch (err) { + releaseLock(); + log('error', `Export failed for ${server.name}: ${err.message}`); + if (!res.headersSent) res.status(500).json({ error: 'Export failed.' }); + } +}); + // POST /servers/:id/edit-file — Save a text file in the server directory router.post('/servers/:id/edit-file', async (req, res) => { const id = req.params.id; diff --git a/src/routes/backups.js b/src/routes/backups.js index 2a16f3e..3d3edf5 100644 --- a/src/routes/backups.js +++ b/src/routes/backups.js @@ -1,16 +1,9 @@ const express = require('express'); -const fs = require('fs'); -const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); -const { serversDb, backupsDb } = require('../db'); -const { log } = require('../utils/log'); -const { - listBackups, - formatSize, - resolveBackupPath -} = require('../mc/BackupManager'); +const { serversDb } = require('../db'); +const { listBackups, formatSize } = require('../mc/BackupManager'); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -68,42 +61,4 @@ router.get('/servers/:id/backups', ensureAuth, blockWhileProvisioning, async (re delete req.session.flash; }); -// GET /servers/:id/backups/:backupId/download — Download a backup ZIP (binary) -router.get('/servers/:id/backups/:backupId/download', ensureAuth, blockWhileProvisioning, async (req, res) => { - if (!UUID_RE.test(req.params.backupId)) { - return res.status(400).json({ error: 'Invalid backup ID.' }); - } - const server = await getServerWithState(req); - if (!server) return res.status(404).json({ error: 'Server not found.' }); - - const backup = await backupsDb.get(`backup_${req.params.backupId}`); - if (!backup || backup.serverId !== server.id) { - req.session.flash = { error: 'Backup not found.' }; - return res.redirect(`/servers/${server.id}/backups`); - } - - const zipPath = resolveBackupPath(server.id, backup.filename); - if (!fs.existsSync(zipPath)) { - req.session.flash = { error: 'Backup file not found on disk.' }; - return res.redirect(`/servers/${server.id}/backups`); - } - - const safeName = server.name.replace(/[^a-zA-Z0-9_-]/g, '_'); - const safeFilename = backup.filename.replace(/[^a-zA-Z0-9._-]/g, '_'); - const downloadName = `${safeName}_backup_${safeFilename}`; - - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Disposition', contentDisposition(downloadName)); - res.setHeader('Content-Length', backup.size); - - const stream = fs.createReadStream(zipPath); - stream.on('error', (err) => { - log('error', `Backup download error: ${err.message}`); - if (!res.headersSent) { - res.status(500).json({ error: 'Download failed.' }); - } - }); - stream.pipe(res); -}); - module.exports = router; diff --git a/src/routes/servers.js b/src/routes/servers.js index 95b42f0..ff760ca 100644 --- a/src/routes/servers.js +++ b/src/routes/servers.js @@ -5,9 +5,8 @@ const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); -const { serversDb, eventsDb, SERVERS_DIR } = require('../db'); -const { listBackups, resolveBackupPath, tryAcquireBackupLock, releaseBackupLock } = require('../mc/BackupManager'); -const { getModEnvMap } = require('../utils/modEnvironment'); +const { isTextFile, listDirectory } = require('../utils/fileBrowser'); +const { serversDb, SERVERS_DIR } = require('../db'); const { parseServerProperties } = require('../mc/serverProperties'); const { PROPERTY_META, GROUPS } = require('../mc/propertyMeta'); const { log } = require('../utils/log'); @@ -81,14 +80,6 @@ async function getServerWithState(req) { return server; } -// ── Helper: format file size ── -function formatSize(bytes) { - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return parseFloat((bytes / Math.pow(1024, i)).toFixed(1)) + ' ' + units[i]; -} - // ═══════════════════════════════════════════ // Edit Server Settings (view only — mutations in /api/v1) // ═══════════════════════════════════════════ @@ -152,17 +143,6 @@ router.get('/servers/:id/properties', ensureAuth, blockWhileProvisioning, async // File Browser & Editor (views + binary downloads — mutations in /api/v1) // ═══════════════════════════════════════════ -const TEXT_EXTENSIONS = new Set([ - '.txt', '.log', '.properties', '.json', '.yml', '.yaml', '.xml', - '.cfg', '.conf', '.ini', '.toml', '.csv', '.md', '.sh', '.bat', - '.cmd', '.ps1', '.js', '.ts', '.py', '.java', '.html', '.css', - '.mcmeta', '.lang', '.sk', '.nbt' -]); - -function isTextFile(filename) { - return TEXT_EXTENSIONS.has(path.extname(filename).toLowerCase()); -} - async function handleFiles(req, res, subpath) { const server = await getServerWithState(req); if (!server) { @@ -186,24 +166,7 @@ async function handleFiles(req, res, subpath) { }); } - const entries = fs.readdirSync(targetPath, { withFileTypes: true }); - const files = entries.map(entry => { - const entryPath = path.join(targetPath, entry.name); - let stat; - try { stat = fs.statSync(entryPath); } catch { return null; } - return { - name: entry.name, - isDirectory: entry.isDirectory(), - size: stat.size, - sizeFormatted: formatSize(stat.size), - modified: stat.mtime, - modifiedISO: stat.mtime.toISOString(), - editable: !entry.isDirectory() && isTextFile(entry.name) - }; - }).filter(Boolean).sort((a, b) => { - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; - return a.name.localeCompare(b.name); - }); + const files = listDirectory(targetPath); const breadcrumbs = subpath ? subpath.split('/').filter(Boolean) : []; const parentPath = breadcrumbs.length > 1 ? breadcrumbs.slice(0, -1).join('/') : ''; @@ -300,138 +263,6 @@ router.get('/servers/:id/download-zip', ensureAuth, blockWhileProvisioning, asyn archive.finalize(); }); -// Server transfer export — server files + Craftbox settings always, backups and -// event history when requested. Importable on another Craftbox instance via -// POST /api/v1/servers/import. (binary download — stays here) -router.get('/servers/:id/export', ensureAuth, blockWhileProvisioning, async (req, res) => { - const server = await serversDb.get(`server_${req.params.id}`); - if (!server) return res.status(404).json({ error: 'Not found' }); - - const serverManager = req.app.get('serverManager'); - const proc = serverManager?.getProcess(server.id); - if (proc && !['stopped', 'crashed'].includes(proc.state)) { - req.session.flash = { error: 'Stop the server before exporting.' }; - return res.redirect(`/servers/${server.id}/edit`); - } - - const serverDir = path.join(SERVERS_DIR, server.id); - if (!fs.existsSync(serverDir)) return res.status(404).json({ error: 'Directory not found' }); - - const includeBackups = req.query.backups === 'true'; - const includeEvents = req.query.events === 'true'; - const startAfter = req.query.start === 'true'; - const initiatedBy = req.user?.username; - - // Hold the backup lock while streaming so a scheduled backup can't write a - // partial zip into the archive mid-export. - let lockHeld = false; - if (includeBackups) { - if (!tryAcquireBackupLock(server.id)) { - req.session.flash = { error: 'A backup is currently in progress. Try again when it completes.' }; - return res.redirect(`/servers/${server.id}/edit`); - } - lockHeld = true; - } - const releaseLock = () => { - if (lockHeld) { - releaseBackupLock(server.id); - lockHeld = false; - } - }; - - // Optional restart once the archive has fully streamed — by then every - // server file has been read, so starting the server can no longer corrupt - // the export. - let startRequested = false; - const startServerAfterExport = () => { - if (!startAfter || startRequested || !serverManager) return; - startRequested = true; - serverManager.startServer(server.id, { initiatedBy }).catch((err) => { - log('error', `Failed to start server after export: ${err.message}`); - }); - }; - - try { - const backups = includeBackups ? await listBackups(server.id) : []; - let events = []; - if (includeEvents) { - const allEvents = await eventsDb.all(); - events = allEvents - .map(row => row.value) - .filter(e => e.serverId === server.id) - .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - } - const modEnv = await getModEnvMap(server.id); - - const manifest = { - format: 'craftbox-server-export', - formatVersion: 1, - exportedAt: new Date().toISOString(), - craftboxVersion: require('../../package.json').version, - server, - includes: { backups: includeBackups, events: includeEvents }, - backupCount: backups.length, - eventCount: events.length - }; - - const archiver = require('archiver'); - const safeName = server.name.replace(/[^a-zA-Z0-9_-]/g, '_'); - - // A .cbx transfer archive is a zip container with a Craftbox manifest at - // its root. The dedicated media type stops browsers from "correcting" - // the extension back to .zip on download. - res.setHeader('Content-Type', 'application/x-craftbox-export+zip'); - res.setHeader('Content-Disposition', contentDisposition(`${safeName}.cbx`)); - - const archive = archiver('zip', { zlib: { level: 5 } }); - archive.on('error', (err) => { - log('error', `Export archive error for ${server.name}: ${err.message}`); - releaseLock(); - if (!res.headersSent) res.status(500).json({ error: 'Archive failed' }); - }); - res.on('close', releaseLock); - archive.on('end', releaseLock); - res.on('finish', startServerAfterExport); - // 'finish' = the full archive reached the client; 'close' without it - // means the download was abandoned mid-stream. - res.on('finish', () => { - log('info', `Export of "${server.name}" (${server.id}) completed — ${formatSize(archive.pointer())} sent`); - }); - res.on('close', () => { - if (!res.writableFinished) { - log('warn', `Export of "${server.name}" (${server.id}) aborted by client after ${formatSize(archive.pointer())}`); - } - }); - - archive.pipe(res); - archive.append(JSON.stringify(manifest, null, 2), { name: 'craftbox-manifest.json' }); - archive.append(JSON.stringify(modEnv, null, 2), { name: 'modenv.json' }); - archive.directory(serverDir, 'server'); - - if (includeBackups) { - archive.append(JSON.stringify(backups, null, 2), { name: 'backups.json' }); - for (const b of backups) { - try { - const zipPath = resolveBackupPath(server.id, b.filename); - if (fs.existsSync(zipPath)) { - archive.file(zipPath, { name: `backups/${b.filename}` }); - } - } catch { /* skip backups with invalid filenames */ } - } - } - if (includeEvents) { - archive.append(JSON.stringify(events, null, 2), { name: 'events.json' }); - } - - log('info', `Exporting server "${server.name}" (${server.id}) — backups: ${includeBackups} (${backups.length}), events: ${includeEvents} (${events.length}), startAfter: ${startAfter}`); - archive.finalize(); - } catch (err) { - releaseLock(); - log('error', `Export failed for ${server.name}: ${err.message}`); - if (!res.headersSent) res.status(500).json({ error: 'Export failed' }); - } -}); - router.get('/servers/:id/edit-file', ensureAuth, blockWhileProvisioning, async (req, res) => { const server = await getServerWithState(req); if (!server) { diff --git a/src/utils/fileBrowser.js b/src/utils/fileBrowser.js new file mode 100644 index 0000000..812beb3 --- /dev/null +++ b/src/utils/fileBrowser.js @@ -0,0 +1,51 @@ +const fs = require('fs'); +const path = require('path'); +const { formatSize } = require('./resourceStats'); + +// Extensions the file editor will open as text. Everything else is treated as +// binary and can only be downloaded. This is an allowlist, not content +// sniffing — an unlisted extension is refused rather than guessed at. +const TEXT_EXTENSIONS = new Set([ + '.txt', '.log', '.properties', '.json', '.yml', '.yaml', '.xml', + '.cfg', '.conf', '.ini', '.toml', '.csv', '.md', '.sh', '.bat', + '.cmd', '.ps1', '.js', '.ts', '.py', '.java', '.html', '.css', + '.mcmeta', '.lang', '.sk', '.nbt' +]); + +function isTextFile(filename) { + return TEXT_EXTENSIONS.has(path.extname(filename).toLowerCase()); +} + +/** + * List one directory, newest metadata first resolved per entry. + * Directories sort ahead of files, then by name. + * + * Shared by the Files page and the file API so both describe a directory + * identically. Entries whose stat fails (deleted mid-listing, permission + * denied) are dropped rather than failing the whole listing. + * @param {string} dir - absolute path, already validated with isPathInside + */ +function listDirectory(dir) { + return fs.readdirSync(dir, { withFileTypes: true }) + .map(entry => { + const entryPath = path.join(dir, entry.name); + let stat; + try { stat = fs.statSync(entryPath); } catch { return null; } + return { + name: entry.name, + isDirectory: entry.isDirectory(), + size: stat.size, + sizeFormatted: formatSize(stat.size), + modified: stat.mtime, + modifiedISO: stat.mtime.toISOString(), + editable: !entry.isDirectory() && isTextFile(entry.name) + }; + }) + .filter(Boolean) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} + +module.exports = { TEXT_EXTENSIONS, isTextFile, listDirectory }; diff --git a/views/servers/backups.ejs b/views/servers/backups.ejs index 0d017f5..d99440b 100644 --- a/views/servers/backups.ejs +++ b/views/servers/backups.ejs @@ -164,7 +164,7 @@
- From dd1cd272bc04cd2b60ce2ea493f51cd80d90b6fe Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 16:03:10 +0100 Subject: [PATCH 13/60] Release 1.1.1 Bump package.json, package-lock.json and the README badge. --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 77f2f6f..cd3afa9 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.1.1-brightgreen?style=flat-square) ![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord) diff --git a/package-lock.json b/package-lock.json index b26e055..8270f37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftbox", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftbox", - "version": "1.1.0", + "version": "1.1.1", "license": "AGPL-3.0-only", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index c7b09a7..4c63f02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbox", - "version": "1.1.0", + "version": "1.1.1", "description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.", "main": "src/server.js", "funding": { From fba3921e88746f0cad8576274a0df3f1efe8a5c5 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 17:38:56 +0100 Subject: [PATCH 14/60] Update `allowScripts` --- package-lock.json | 6 +++--- package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8270f37..5506131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -936,9 +936,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" diff --git a/package.json b/package.json index 4c63f02..8641b38 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ }, "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 } } From b42d7099b8e5bf1536627800f0c381f19cc11684 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 21:52:58 +0100 Subject: [PATCH 15/60] Close the power-action race during a server restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restart routes through `stopped` on its way back up: the process exits, the state is broadcast, and two seconds later the replacement is spawned. For that gap every state check said the server was stopped, so the console re-enabled Start and Delete and the API accepted both. Starting in that window was the damaging case. startServer reaches _ensureProcess, which rebuilds the process because the cached one reads as stopped, and the old object's pending respawn timer was never cancelled — so two seconds later a second JVM launched in the same directory on the same port. Deleting had the same shape: the directory went away while a respawn was still queued. Track the gap explicitly. ServerProcess raises _restarting before the stopped transition, so that broadcast already carries restarting:true, and lowers it as soon as the server leaves stopped — not when it finishes booting, or Stop and Kill would stay disabled for the whole startup, which on a large modpack is minutes. The respawn timer is now held on the instance and cleared by destroy(). ServerManager refuses start/stop/restart/kill and any operational state change while the flag is up, tagged 409 like the other state conflicts, and the delete route checks it too. The console keeps every power button disabled for the duration and no longer fires an action while one is already in flight. --- public/js/console.js | 39 +++++++++++++++++++++++++-------- src/mc/ServerManager.js | 39 +++++++++++++++++++++++++++++++++ src/mc/ServerProcess.js | 42 +++++++++++++++++++++++++++++++++++- src/routes/api-v1/servers.js | 13 +++++++---- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/public/js/console.js b/public/js/console.js index 37067f8..4051eb2 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; @@ -140,11 +141,16 @@ 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'; // Update badge if (stateBadge) { @@ -161,7 +167,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); @@ -287,7 +295,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.' }, @@ -296,11 +311,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; @@ -316,6 +336,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; } diff --git a/src/mc/ServerManager.js b/src/mc/ServerManager.js index cd7b952..6a17af8 100644 --- a/src/mc/ServerManager.js +++ b/src/mc/ServerManager.js @@ -56,6 +56,29 @@ class ServerManager { * state so that restored or edited config values (memory, javaArgs, * version, serverType, etc.) take effect on the next start. */ + /** + * True while a server is between the exit of its old process and the start + * of its replacement during a restart. + * + * A restart routes through `stopped`, so for roughly two seconds every + * state check says the server is stopped and every power action looks + * legal. Acting in that window rebuilds the process out from under the + * pending respawn, which then starts a second JVM on the same port and in + * the same directory. Treat it as a state conflict instead. + * @param {string} serverId + */ + isRestarting(serverId) { + return this.getProcess(serverId)?._restarting === true; + } + + /** Throw a 409-tagged error if a restart is mid-flight. */ + _assertNotRestarting(serverId) { + if (!this.isRestarting(serverId)) return; + const err = new Error('Server is restarting. Wait for it to come back up.'); + err.status = 409; + throw err; + } + async _ensureProcess(serverId) { let proc = this.processes.get(serverId); @@ -95,6 +118,9 @@ class ServerManager { * @param {{ initiatedBy?: string }} [opts] */ async startServer(serverId, opts = {}) { + // Before _ensureProcess: a restarting server reads as `stopped`, so the + // rebuild branch would fire and orphan the pending respawn timer. + this._assertNotRestarting(serverId); const proc = await this._ensureProcess(serverId); if (!canPerformAction(proc.state, 'start')) { @@ -111,6 +137,7 @@ class ServerManager { * @param {{ initiatedBy?: string }} [opts] */ async stopServer(serverId, opts = {}) { + this._assertNotRestarting(serverId); const proc = this.getProcess(serverId); if (!proc) throw new Error('Server is not running.'); @@ -128,6 +155,7 @@ class ServerManager { * @param {{ initiatedBy?: string }} [opts] */ async restartServer(serverId, opts = {}) { + this._assertNotRestarting(serverId); const proc = this.getProcess(serverId); if (!proc) throw new Error('Server is not running.'); @@ -145,6 +173,7 @@ class ServerManager { * @param {{ initiatedBy?: string }} [opts] */ async killServer(serverId, opts = {}) { + this._assertNotRestarting(serverId); const proc = this.getProcess(serverId); if (!proc) throw new Error('Server is not running.'); @@ -182,6 +211,16 @@ class ServerManager { const server = await serversDb.get(`server_${serverId}`); if (!server) throw new Error('Server not found.'); + // A restarting server reads as `stopped` for a couple of seconds, which + // would otherwise let a backup or jar upgrade claim it — and then be + // overwritten when the respawn lands. Crash reporting still gets through, + // since a restart that dies on the way back up must be recordable. + if (this.isRestarting(serverId) && newState !== STATES.CRASHED) { + const err = new Error('Server is restarting. Wait for it to come back up.'); + err.status = 409; + throw err; + } + // Honour the transition table. This used to write any allowed target // state unconditionally, which meant every backup/restore/jar-upgrade // flow bypassed the state machine entirely — a provisioning server diff --git a/src/mc/ServerProcess.js b/src/mc/ServerProcess.js index fbbeb1c..f83e452 100644 --- a/src/mc/ServerProcess.js +++ b/src/mc/ServerProcess.js @@ -33,6 +33,13 @@ class ServerProcess extends EventEmitter { this._stopRequested = false; this._restartPending = false; this._restartStarting = false; // Suppress "started" event after restart + // True from the moment a restarting process exits until it is running + // again. A restart passes through `stopped` on its way back up, and that + // state is broadcast — without this flag the panel and the API would + // both treat that instant as "the server is stopped, you may start it", + // which races the respawn. Carried on every state broadcast. + this._restarting = false; + this._restartTimer = null; // Pending respawn; cleared if the process is destroyed this._crashDetected = false; // Set when crash report is detected in logs this._oomKillInProgress = false; // Guards against multiple OOM kill attempts this._initiatedBy = null; // Who triggered the current action (username or system label) @@ -88,6 +95,14 @@ class ServerProcess extends EventEmitter { if (this._restartStarting && newState === STATES.RUNNING) { this._restartStarting = false; } + // The guard exists only to cover the `stopped` gap before the respawn. + // Once the server leaves that state the process exists again and the + // normal rules apply — keeping it raised through `starting` would leave + // Stop and Kill disabled for the whole boot, which on a large modpack + // is minutes. + if (this._restarting && newState !== STATES.STOPPED) { + this._restarting = false; + } const eventTypes = { [STATES.RUNNING]: 'started', [STATES.STOPPED]: 'stopped', @@ -116,6 +131,7 @@ class ServerProcess extends EventEmitter { type: 'state', serverId: this.id, state: newState, + restarting: this._restarting, lastStarted: this.config.lastStarted || null, exitCode: this.config.exitCode ?? null, crashReason: this.config.crashReason ?? null @@ -476,6 +492,7 @@ class ServerProcess extends EventEmitter { type: 'subscribed', serverId: this.id, state: this.state, + restarting: this._restarting, lastStarted: this.config.lastStarted || null, history: this.lastLines.slice(-200), players: sortedPlayers, @@ -669,6 +686,10 @@ class ServerProcess extends EventEmitter { // Clean shutdown — user requested stop this.config.exitCode = code; this.config.crashReason = null; + // Raise before the transition so the `stopped` broadcast below + // already carries restarting:true — clients must never see a bare + // `stopped` for a server that is on its way back up. + this._restarting = this._restartPending; await this._setStateRobust(STATES.STOPPED); // Update DB @@ -699,7 +720,17 @@ class ServerProcess extends EventEmitter { message: 'Server restarted', createdAt: new Date().toISOString() }); - setTimeout(() => this.start(), 2000); + this._restartTimer = setTimeout(() => { + this._restartTimer = null; + this.start().catch((err) => { + // The respawn is fire-and-forget; if it fails, drop the + // restart guard so the server isn't stuck refusing every + // power action. + this._restarting = false; + log('error', `[${this.config.name}] Restart failed: ${err.message}`); + this._appendLine(`[Craftbox] Restart failed: ${err.message}`); + }); + }, 2000); } } else if (isCrash) { // Crash or unexpected exit @@ -862,6 +893,15 @@ class ServerProcess extends EventEmitter { * Clean up resources. */ destroy() { + // Cancel a pending restart respawn. Without this the timer survives the + // process object and starts a JVM in a directory that may since have + // been deleted, or alongside a replacement process. + if (this._restartTimer) { + clearTimeout(this._restartTimer); + this._restartTimer = null; + } + this._restarting = false; + this._restartPending = false; if (this.child) { this._killTree(); } diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index f2cdfd8..d40fdfc 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -1556,7 +1556,7 @@ router.post('/servers/:id/start', async (req, res) => { logEvent(req.params.id, 'action', 'Server start requested', { initiatedBy: req.user.username }).catch(() => {}); res.json({ success: true, message: 'Server is starting...' }); } catch (err) { - res.status(400).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 400).json({ error: err.message }); } }); @@ -1570,7 +1570,7 @@ router.post('/servers/:id/stop', async (req, res) => { logEvent(req.params.id, 'action', 'Server stop requested', { initiatedBy: req.user.username }).catch(() => {}); res.json({ success: true, message: 'Server is stopping...' }); } catch (err) { - res.status(400).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 400).json({ error: err.message }); } }); @@ -1594,7 +1594,7 @@ router.post('/servers/:id/restart', async (req, res) => { logEvent(id, 'action', 'Server restart requested', { initiatedBy }).catch(() => {}); return res.json({ success: true, message: 'Server is restarting...' }); } catch (err) { - return res.status(400).json({ error: err.message }); + return res.status(err.status === 409 ? 409 : 400).json({ error: err.message }); } } @@ -1682,7 +1682,7 @@ router.post('/servers/:id/kill', async (req, res) => { logEvent(req.params.id, 'action', 'Server force-killed', { initiatedBy: req.user.username }).catch(() => {}); res.json({ success: true, message: 'Server force-killed.' }); } catch (err) { - res.status(400).json({ error: err.message }); + res.status(err.status === 409 ? 409 : 400).json({ error: err.message }); } }); @@ -2119,6 +2119,11 @@ router.delete('/servers/:id', async (req, res) => { const serverManager = req.app.get('serverManager'); const proc = serverManager?.getProcess(id); const liveState = proc ? proc.state : server.state; + // A restarting server is momentarily `stopped`; deleting in that window + // tears down the directory while a respawn is already queued. + if (serverManager?.isRestarting(id)) { + return res.status(409).json({ error: 'Server is restarting. Wait for it to come back up.' }); + } if (!['stopped', 'crashed'].includes(liveState)) { return res.status(409).json({ error: 'Stop the server before deleting it.' }); } From 58a2bcc0dccd83083d2f772ee5bd1d01dbe36ff1 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 3 Aug 2026 22:01:38 +0100 Subject: [PATCH 16/60] Track live server state in controls that require a stopped server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controls that need the server stopped were gated once, server-side, when the page rendered. The page then received live state over the WebSocket and updated the state badge — but nothing else. Stop a server while sitting on the Mods tab and Upload stayed dead; start one and Delete stayed live. Only the console page re-derived anything, and only its own power buttons. Add a declarative gate: data-enable-when="stopped crashed" disables a control whenever the live state falls outside that list, and data-show-when / data-hide-when do the same for the explanatory alerts that accompany one. applyStateGates walks them on a new craftbox:state event, which both WebSocket owners now dispatch alongside the badge update — mirroring the craftbox:operation event that already existed. The live state is read from the data-state attribute both owners already maintain on the nav header. Several pages rendered two entirely different versions of a control depending on state — an anchor when stopped and an id-less disabled button when running — so no amount of live toggling could have reached them. Those are now rendered once and gated by attribute. Duplicate and Save Template no longer render as either a submit button or a modal trigger; there is one button, and the submit handler decides at click time whether to act directly or offer to stop the server first. Where a decision was snapshotted into the DOM at render time — the backup modal's stopFirst/startAfter hidden inputs, the export button's data-server-stopped, the restart modal's data-server-state — it is now read from the live state when the button is actually pressed, which removes the staleness rather than trying to keep a copy in sync. --- public/js/app.js | 64 +++++++++++++++++++++++++++++++ public/js/backups.js | 42 ++++++++++++-------- public/js/console.js | 6 +++ public/js/edit.js | 63 +++++++++++++++--------------- public/js/motd.js | 8 ++-- public/js/plugins.js | 27 ++++++++++--- public/js/restart-modal.js | 4 +- public/js/serverState.js | 7 ++++ views/servers/backups.ejs | 40 ++++++++++--------- views/servers/edit.ejs | 74 +++++++++++++++--------------------- views/servers/files.ejs | 34 ++++++++--------- views/servers/plugins.ejs | 71 +++++++++++++++------------------- views/servers/properties.ejs | 4 +- 13 files changed, 264 insertions(+), 180 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index c99f74d..d1df97e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -254,6 +254,70 @@ 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 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 4051eb2..001a3b3 100644 --- a/public/js/console.js +++ b/public/js/console.js @@ -152,6 +152,12 @@ 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) { const color = stateColors[state] || 'secondary'; diff --git a/public/js/edit.js b/public/js/edit.js index 5cff7e1..d7b29a5 100644 --- a/public/js/edit.js +++ b/public/js/edit.js @@ -390,11 +390,9 @@ function _formToBody(form) { function showRestartModal() { var modalEl = document.getElementById('restartModal'); - if (modalEl) { - var state = modalEl.dataset.serverState; - if (state !== 'stopped' && state !== 'crashed') { - new bootstrap.Modal(modalEl).show(); - } + // Live state — a server that has since stopped needs no restart prompt. + if (modalEl && !isServerStopped()) { + new bootstrap.Modal(modalEl).show(); } } @@ -560,7 +558,7 @@ function _formToBody(form) { var serverId = form.dataset.serverId; async function submitDuplicate() { - var btn = form.querySelector('button[type="submit"]') || document.getElementById('duplicate-running-btn'); + var btn = document.getElementById('duplicate-btn'); if (btn) { btn.disabled = true; btn.innerHTML = ' Duplicating...'; @@ -579,24 +577,25 @@ function _formToBody(form) { window.location.href = newId ? '/servers/' + newId : '/dashboard'; } - // Direct submit (server already stopped) - form.addEventListener('submit', function (e) { - e.preventDefault(); - if (!form.reportValidity()) return; - submitDuplicate(); - }); - - // Stop-then-duplicate modal flow - var dupRunningBtn = document.getElementById('duplicate-running-btn'); - if (dupRunningBtn) { - var modal = new bootstrap.Modal(document.getElementById('stopDuplicateModal')); + var modalEl = document.getElementById('stopDuplicateModal'); + if (modalEl) { + var modal = new bootstrap.Modal(modalEl); var confirmBtn = document.getElementById('confirm-stop-duplicate-btn'); var startAfterCheckbox = document.getElementById('dupStartAfter'); - dupRunningBtn.addEventListener('click', function () { + // Duplicate directly when the server is already down, otherwise offer to + // stop it first. Decided here rather than by rendering two different + // buttons, so it follows the live state. + form.addEventListener('submit', function (e) { + e.preventDefault(); if (!form.reportValidity()) return; + if (isServerStopped()) { + submitDuplicate(); + return; + } modal.show(); }); + startAfterCheckbox.addEventListener('change', function () { document.getElementById('dup-start-after').value = startAfterCheckbox.checked ? 'true' : 'false'; }); @@ -615,7 +614,7 @@ function _formToBody(form) { if (!form) return; async function submitTemplate() { - var btn = form.querySelector('button[type="submit"]') || document.getElementById('template-running-btn'); + var btn = document.getElementById('template-btn'); if (btn) { btn.disabled = true; btn.innerHTML = ' Saving...'; @@ -633,22 +632,24 @@ function _formToBody(form) { window.location.href = '/templates'; } - form.addEventListener('submit', function (e) { - e.preventDefault(); - if (!form.reportValidity()) return; - submitTemplate(); - }); - - var tmplRunningBtn = document.getElementById('template-running-btn'); - if (tmplRunningBtn) { - var modal = new bootstrap.Modal(document.getElementById('stopTemplateModal')); + var modalEl = document.getElementById('stopTemplateModal'); + if (modalEl) { + var modal = new bootstrap.Modal(modalEl); var confirmBtn = document.getElementById('confirm-stop-template-btn'); var startAfterCheckbox = document.getElementById('tmplStartAfter'); - tmplRunningBtn.addEventListener('click', function () { + // Same shape as duplicate: save straight away when already stopped, + // otherwise offer to stop first. Live state, not render-time state. + form.addEventListener('submit', function (e) { + e.preventDefault(); if (!form.reportValidity()) return; + if (isServerStopped()) { + submitTemplate(); + return; + } modal.show(); }); + startAfterCheckbox.addEventListener('change', function () { document.getElementById('tmpl-start-after').value = startAfterCheckbox.checked ? 'true' : 'false'; }); @@ -724,7 +725,9 @@ function _formToBody(form) { }); exportBtn.addEventListener('click', function () { - if (exportBtn.dataset.serverStopped === 'true') { + // Read the live state, not the value baked in at render time — the + // server may have stopped or started since the page loaded. + if (isServerStopped()) { startDownload(false); } else { new bootstrap.Modal(stopExportModalEl).show(); diff --git a/public/js/motd.js b/public/js/motd.js index c9c61fe..4497370 100644 --- a/public/js/motd.js +++ b/public/js/motd.js @@ -184,11 +184,9 @@ showMotdStatus('success', 'Restart the server for changes to take effect.'); showToast('MOTD saved.', 'success'); var modalEl = document.getElementById('restartModal'); - if (modalEl) { - var state = modalEl.dataset.serverState; - if (state !== 'stopped' && state !== 'crashed') { - new bootstrap.Modal(modalEl).show(); - } + // Live state — a server that has since stopped needs no restart prompt. + if (modalEl && !isServerStopped()) { + new bootstrap.Modal(modalEl).show(); } } else { saveBtn.textContent = 'Error'; diff --git a/public/js/plugins.js b/public/js/plugins.js index b802e47..2b52322 100644 --- a/public/js/plugins.js +++ b/public/js/plugins.js @@ -179,19 +179,30 @@ } } + // Upload needs both a stopped server AND a file selection, so it can't use + // data-enable-when (which knows only about state). Re-derive it here and on + // every state change instead. + function refreshUploadBtn() { + if (!uploadBtn) return; + var stopped = isServerStopped(); + uploadBtn.disabled = !stopped || !fileInput || fileInput.files.length === 0; + uploadBtn.title = stopped ? '' : 'Stop the server to upload'; + } + if (fileInput && uploadBtn) { guardFileInput(fileInput, ['.jar'], 'Only .jar files can be uploaded.'); - fileInput.addEventListener('change', function () { - uploadBtn.disabled = fileInput.files.length === 0; - }); + fileInput.addEventListener('change', refreshUploadBtn); uploadBtn.addEventListener('click', function () { - if (fileInput.files.length === 0) return; + if (!isServerStopped() || fileInput.files.length === 0) return; uploadFiles(fileInput.files); }); } + document.addEventListener('craftbox:stategates', refreshUploadBtn); + refreshUploadBtn(); + // ── Drag & Drop ── // Always prevent default drop behavior so Chrome doesn't open files in a new tab @@ -204,7 +215,9 @@ document.addEventListener('dragenter', function (e) { e.preventDefault(); - if (isOverlayVisible()) return; + // Dropping only uploads while the server is stopped, so don't invite + // it otherwise. Checked live rather than at render time. + if (isOverlayVisible() || !isServerStopped()) return; dragCounter++; if (dragCounter === 1) { dropOverlay.classList.remove('d-none'); @@ -228,6 +241,10 @@ dropOverlay.classList.add('d-none'); dropOverlay.classList.remove('d-flex'); + if (!isServerStopped()) { + showToast('Stop the server before uploading ' + contentLabel + '.', 'danger'); + return; + } if (e.dataTransfer && e.dataTransfer.files.length > 0) { uploadFiles(e.dataTransfer.files); } diff --git a/public/js/restart-modal.js b/public/js/restart-modal.js index 86eb987..9767156 100644 --- a/public/js/restart-modal.js +++ b/public/js/restart-modal.js @@ -42,8 +42,8 @@ window.history.replaceState({}, '', window.location.pathname); - var serverState = modalEl.dataset.serverState; - if (serverState === 'stopped' || serverState === 'crashed') return; + // Live state — the server may have stopped between the save and this check. + if (isServerStopped()) return; new bootstrap.Modal(modalEl).show(); })(); diff --git a/public/js/serverState.js b/public/js/serverState.js index 50e0683..45d226d 100644 --- a/public/js/serverState.js +++ b/public/js/serverState.js @@ -74,6 +74,13 @@ badge.id = 'server-state-badge'; if (stateIconEl) stateIconEl.textContent = icon; if (stateTextEl) stateTextEl.textContent = displayName; + + // Re-gate every state-dependent control on the page (see applyStateGates + // in app.js). Without this the page's disabled controls stay frozen at + // whatever the state was when it was rendered. + document.dispatchEvent(new CustomEvent('craftbox:state', { + detail: { serverId: serverId, state: state } + })); } connect(); diff --git a/views/servers/backups.ejs b/views/servers/backups.ejs index d99440b..94e3cc0 100644 --- a/views/servers/backups.ejs +++ b/views/servers/backups.ejs @@ -2,7 +2,13 @@ <%- include('../partials/serverNav', { server, active: 'backups' }) %> - <% const _serverStopped=['stopped', 'crashed' ].includes(server.state); %> + <% + // Backing up a running server stops it first. That distinction is + // re-derived live (see backups.js and applyStateGates in app.js) rather + // than frozen at render time. + const _serverStopped=['stopped', 'crashed' ].includes(server.state); + const _gate = 'stopped crashed'; + %> @@ -213,12 +219,11 @@
+ <%# 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..8835704 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'; %>
@@ -40,22 +42,28 @@ data-current-url="<%= server.customJarUrl || '' %>" placeholder="https://example.com/server.jar" <%=_canChangeJar ? '' : 'disabled' %> + 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.
<% } else { %>
> + : 'disabled' %> + data-enable-when="<%= _gate %>">
<% } %> @@ -167,9 +174,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 +469,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 +525,13 @@
1-50 characters. Letters, numbers, spaces, hyphens, underscores.
- <% if (_serverStopped) { %> - - <% } else { %> - - <% } %> + <%# Single button; edit.js branches on live state at submit time. %> + @@ -574,7 +562,7 @@
- <% } %> @@ -109,16 +109,14 @@ const _hasContentTab = !!_contentTypes[server.serverType]; edit <% } %> - <% if (_serverStopped) { %> + class="btn btn-outline-secondary btn-sm d-inline-flex align-items-center justify-content-center<%= _serverStopped ? '' : ' disabled' %>" + style="width: 32px; height: 32px; padding: 0;" + data-enable-when="<%= _gate %>" data-enabled-title="Download" data-disabled-title="Stop server to download" + title="<%= _serverStopped ? 'Download' : 'Stop server to download' %>" + <%= _serverStopped ? '' : 'aria-disabled="true" tabindex="-1"' %>> download - <% } else { %> - - <% } %> <% } %> diff --git a/views/servers/plugins.ejs b/views/servers/plugins.ejs index d7c1b8f..b7a932a 100644 --- a/views/servers/plugins.ejs +++ b/views/servers/plugins.ejs @@ -2,17 +2,21 @@ <%- include('../partials/serverNav', { server, active: 'plugins' }) %> -<% const _serverStopped = ['stopped', 'crashed'].includes(server.state); %> +<% +// Controls here need the server stopped. The markup is rendered once and gated +// declaratively (data-enable-when / data-hide-when, see applyStateGates in +// app.js) so it tracks live state instead of freezing at render time. +const _serverStopped = ['stopped', 'crashed'].includes(server.state); +const _gate = 'stopped crashed'; +%> -<% if (!_serverStopped) { %> -
+
warning
The server must be stopped before installing or deleting <%= contentType.label.toLowerCase() %>.
-<% } %>
<%= files.length %> <%= contentType.label.toLowerCase() %> installed
@@ -22,17 +26,12 @@ download Download All - <% if (_serverStopped) { %> - - <% } else { %> - - <% } %>
<% } %>
@@ -59,32 +58,24 @@
- <% if (_serverStopped) { %> + accept=".jar" multiple style="max-width: 400px;" + data-enable-when="<%= _gate %>" <%= _serverStopped ? '' : 'disabled' %>> + <%# Upload stays disabled until files are picked, so plugins.js owns its + enabled state and consults isServerStopped() — no data-enable-when here. %> - <% } else { %> - - - - <% } %>
@@ -116,6 +107,9 @@ - <% if (events.length > 0) { %> -
+ <%# Always rendered so it can appear the moment a live event arrives. %> + 0 ? '' : ' class="d-none"' %>>
- <% } %> -<% 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 { %> -
+
@@ -74,7 +73,13 @@ 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 +118,6 @@ const eventLabels = Object.fromEntries(
Time
-<% } %> From a2630ee07f4f18a6379a2d36e861b31aa8357186 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Tue, 4 Aug 2026 23:33:10 +0100 Subject: [PATCH 26/60] Fix the three issues found in the beta test pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading over a file the running server holds open silently overwrote it. Only Windows fails that write with EBUSY, which the handler already reported; on Linux it succeeds and corrupts a live server — during testing it replaced a running server's jar, recovered from a backup. The handler now checks server state up front and refuses to replace the jar, or any existing file under the world folders, logs/ or mods/plugins, while the server is running. Per-file rejection, so the rest of a batch still lands, and new files are unaffected — nothing holds a handle on a name that isn't there yet. The Events "Clear" button never hid itself on an empty log. The live-update JS was right; the template was not. An escaping EJS tag emitted class="d-none", which the browser reads as a class literally named "d-none", quotes and all, so it never matched. The same mistake was in eleven other places — the state hints on Settings and Properties, where it meant both halves of a running/stopped pair rendered at once, and the disabled-state tooltips on Files, Mods and Plugins, which rendered as a truncated `"Stop` plus a handful of junk attributes. All now use the unescaped tag. GET /console?limit=0 fell back to the default because `parseInt(...) || 200` cannot tell a parsed 0 from an absent value. Only a missing or unparseable value takes the default now. GET /events?limit= had the same shape and no lower bound at all, so limit=-5 reached slice(0, -5) and quietly dropped the five newest events. TODO.txt records the results of the pass, and what still needs re-testing. --- TODO.txt | 284 +++++++++++++++++++++++++++-------- docs/API.md | 4 +- src/routes/api-v1/servers.js | 59 +++++++- views/servers/edit.ejs | 10 +- views/servers/events.ejs | 8 +- views/servers/files.ejs | 4 +- views/servers/plugins.ejs | 6 +- views/servers/properties.ejs | 2 +- 8 files changed, 295 insertions(+), 82 deletions(-) diff --git a/TODO.txt b/TODO.txt index d572d0d..f711e09 100644 --- a/TODO.txt +++ b/TODO.txt @@ -12,161 +12,263 @@ Setup: - Have a second browser tab open on the same server for the live-update checks. - A file >5 MB on hand, to force the chunked upload path. +TESTED 2026-08-04 against the live Docker instance at http://localhost:6464. +Results below; see inline notes for anything not a clean pass. + -------------------------------------------------------------------- 1.2.0-beta.2 — FILE MANAGER (Files tab) -------------------------------------------------------------------- Upload - [ ] Pick one file with the picker, press Upload. Toast reports "1 file + [x] Pick one file with the picker, press Upload. Toast reports "1 file uploaded.", the row appears after reload. - [ ] Pick several files at once. Toast pluralises correctly. - [ ] Drag files anywhere on the page. The blurred overlay appears, names the + [x] Pick several files at once. Toast pluralises correctly. + [x] Drag files anywhere on the page. The blurred overlay appears, names the destination folder, and drops upload to THAT folder, not the root. - [ ] Navigate into a subfolder first, then upload. The file lands in the + [x] Navigate into a subfolder first, then upload. The file lands in the subfolder. - [ ] Upload a file that already exists. Toast reads "... 1 replaced." and the + [x] Upload a file that already exists. Toast reads "... 1 replaced." and the contents are the new ones. - [ ] Upload a file whose name matches an existing FOLDER. It is rejected with + [x] Upload a file whose name matches an existing FOLDER. It is rejected with a clear reason, nothing else in the batch is affected. - [ ] Upload a >5 MB file. The overlay shows a per-file percentage that climbs, + [x] Upload a >5 MB file. The overlay shows a per-file percentage that climbs, and the finished file is intact (open/checksum it). - [ ] Upload several files where one is large. Overlay shows "2 of 3 — name". - [ ] Drag a whole folder in. Confirm the behaviour is acceptable (browsers + [x] Upload several files where one is large. Overlay shows "2 of 3 — name". + [x] Drag a whole folder in. Confirm the behaviour is acceptable (browsers hand over no files for a folder drop — expect nothing to happen rather than a stuck overlay). New Folder - [ ] Create a folder; it appears sorted above the files after reload. - [ ] Try a name with < > : " | ? * — rejected with a readable message. - [ ] Try "NUL" or "CON" — rejected as a reserved name. - [ ] Try an existing name — rejected as already taken. - [ ] Enter key in the name field submits the modal. + [x] Create a folder; it appears sorted above the files after reload. + [x] Try a name with < > : " | ? * — rejected with a readable message. + [x] Try "NUL" or "CON" — rejected as a reserved name. + [x] Try an existing name — rejected as already taken. + [x] Enter key in the name field submits the modal. Rename - [ ] Rename a file. The modal pre-selects the base name and leaves the + [x] Rename a file. The modal pre-selects the base name and leaves the extension selected-out, so typing replaces only the name. - [ ] Rename a folder. The whole name is pre-selected (no extension split). - [ ] Rename changing only letter case (Foo.txt -> foo.txt) — succeeds. - [ ] Rename onto an existing name — rejected, modal stays open, button resets. - [ ] Enter key submits. - [ ] Rename server.properties and confirm nothing in the panel breaks; rename + [x] Rename a folder. The whole name is pre-selected (no extension split). + [x] Rename changing only letter case (Foo.txt -> foo.txt) — succeeds. + [x] Rename onto an existing name — rejected, modal stays open, button resets. + [x] Enter key submits. + [x] Rename server.properties and confirm nothing in the panel breaks; rename it back and confirm Settings still shows the right port. Delete - [ ] Delete a file. Confirm modal names it and says "cannot be undone". - [ ] Delete a folder with contents. Confirm the modal uses the sterner folder + [x] Delete a file. Confirm modal names it and says "cannot be undone". + [x] Delete a folder with contents. Confirm the modal uses the sterner folder wording, and everything inside is gone afterwards. - [ ] Cancel the modal — nothing is deleted, button is still usable after. + [x] Cancel the modal — nothing is deleted, button is still usable after. Search - [ ] Type in the search box; rows filter live, no page reload. - [ ] Clear it; all rows return, including the ".." row. + [x] Type in the search box; rows filter live, no page reload. + [x] Clear it; all rows return, including the ".." row. State gating (the important one) - [ ] With the server RUNNING, open the Files tab. Upload and New Folder are + [x] With the server RUNNING, open the Files tab. Upload and New Folder are live; Rename, Delete and both Download buttons are greyed with a "Stop the server to ..." tooltip. - [ ] START the server from another tab while sitting on Files. Rename/Delete + [x] START the server from another tab while sitting on Files. Rename/Delete grey out WITHOUT a reload; Upload and New Folder stay usable. - [ ] STOP it again from the other tab. They re-enable without a reload. - [ ] Upload a file while the server is running — it works. + [x] STOP it again from the other tab. They re-enable without a reload. + [x] Upload a file while the server is running — it works. [ ] Upload a file that the running server holds open (e.g. the server jar or a world file). It is rejected with "file is in use by the server" and the rest of the batch still lands. + NOT IMPLEMENTED — uploading over server.jar on a running server SUCCEEDS + silently (overwrites it) instead of being rejected. Confirmed live: this + actually overwrote a running test server's jar with garbage bytes during + testing; recovered by restoring the most recent Manual Backup before it + could cause lasting damage. docs/API.md also does not document any + busy-file rejection for the upload endpoint (unlike download/rename/ + delete, which explicitly document a 409 for a held-open file) — the gap + is consistent between docs and behaviour, so this needs an actual fix, + not just a doc update. + FIXED in 1.2.0-beta.3, NEEDS RE-TEST — while a server is running, an + upload that would replace its jar, or an existing file under the world + folders / logs / mods / plugins, is now rejected per-file with "file is + in use by the server" and the rest of the batch still lands. The check + is on server state, not on the write failing: only Windows fails that + write, which is why it went unnoticed. New files in those folders are + still allowed. docs/API.md updated to match. Cross-checks - [ ] Breadcrumbs still navigate correctly after each operation. - [ ] Edit (pencil) still opens the text editor for editable files. - [ ] Download Server (.zip) still works. - [ ] Files tab on a NeoForge server shows the "use the Mods tab" hint in + [x] Breadcrumbs still navigate correctly after each operation. + [x] Edit (pencil) still opens the text editor for editable files. + [x] Download Server (.zip) still works. + [x] Files tab on a NeoForge server shows the "use the Mods tab" hint in mods/ (this map was missing neoforge before beta.2). + Confirmed by reading views/servers/files.ejs: the hint is intentionally + gated to the RUNNING state only (data-hide-when="stopped crashed"), and + neoforge is present in the _contentTypes map. Looked missing at first + only because it was checked on a stopped server — working as designed. -------------------------------------------------------------------- 1.2.0-beta.2 — MODS / PLUGINS PAGE FIXES -------------------------------------------------------------------- - [ ] Upload a mod that is already installed. Toast now says + [x] Upload a mod that is already installed. Toast now says "1 mod uploaded, 1 replaced." (previously it just said "uploaded"). - [ ] Set a mod to "Client Only" (it becomes .jar.disabled on disk), then + [x] Set a mod to "Client Only" (it becomes .jar.disabled on disk), then upload the same jar again. It appears ONCE in the list, is back on "Client and Server", and only one file exists in mods/ on disk. - [ ] Using the Files tab, drop a copy of an installed mod into mods/ so both + [x] Using the Files tab, drop a copy of an installed mod into mods/ so both foo.jar and foo.jar.disabled exist. The Mods page lists it ONCE. - [ ] Delete that mod from the Mods page. It does not reappear after a reload + [x] Delete that mod from the Mods page. It does not reappear after a reload (both on-disk forms are removed). - [ ] Plugins page (Paper) is unaffected: upload, replace, delete, Download All. - [ ] Modrinth browse/install still works and still reports installed state. + [x] Plugins page (Paper) is unaffected: upload, replace, delete, Download All. + Tested on a Paper server: upload ("1 plugin uploaded."), re-upload same + name ("1 plugin uploaded, 1 replaced." — same wording pattern as mods), + delete, and Download All (200, correct zip blob) all passed. + [x] Modrinth browse/install still works and still reports installed state. + Installed a real plugin (CalcMod) from the Paper server's Browse + Modrinth modal; row switched to "Installed", toast confirmed the exact + filename, and it appeared in the plugin list immediately after closing + the modal. -------------------------------------------------------------------- 1.2.0-beta.1 — LIVE STATE GATING -------------------------------------------------------------------- - [ ] Mods tab, server running: Upload, Delete, Delete All, Browse Modrinth + [x] Mods tab, server running: Upload, Delete, Delete All, Browse Modrinth and the environment dropdowns are all disabled with tooltips. - [ ] Stop the server from a second tab. Every one of those re-enables with no + [x] Stop the server from a second tab. Every one of those re-enables with no reload, and the amber warning banner disappears. - [ ] Start it again — they all disable again and the banner returns. - [ ] Settings tab: Duplicate and Save Template render as ONE button each; with + [x] Start it again — they all disable again and the banner returns. + [x] Settings tab: Duplicate and Save Template render as ONE button each; with the server running, clicking offers to stop it first rather than failing. - [ ] Backups tab: create/restore/delete gating tracks live state. - [ ] Properties tab: same. - [ ] Export button and the backup modal's "stop first / start after" options + [x] Backups tab: create/restore/delete gating tracks live state. + [x] Properties tab: same. + [x] Export button and the backup modal's "stop first / start after" options reflect live state, not the state at page load. + Directly re-confirmed: Create Backup on a running server showed "The + server will be stopped to create the backup." with a "Start server + after backup" checkbox, matching live state rather than a stale load- + time snapshot. -------------------------------------------------------------------- 1.2.0-beta.1 — LIVE EVENT LOG -------------------------------------------------------------------- - [ ] Open the Events tab and, from a second tab, start the server. Rows appear + [x] Open the Events tab and, from a second tab, start the server. Rows appear at the top live, no reload. [ ] Take a backup, upgrade a jar, have a player join/leave. All appear live — not just start/stop/crash. - [ ] A live row renders the same badge, icon, timestamp and "Initiated By" + PARTIAL — Backup: PASSED, "Backup Created" event appeared live with + correct size. Jar upgrade: UNTESTED — the test server's Paper build was + already current (#53) and the panel disallows downgrades, so there was + no upgrade available to trigger without touching a shared server's + actual version. Player join/leave: UNTESTED — requires a real Minecraft + client, not available in this environment. + [x] A live row renders the same badge, icon, timestamp and "Initiated By" cell as one that came from a page render (compare after a reload). - [ ] Set a type filter, then trigger an event of a different type. It does not + [x] Set a type filter, then trigger an event of a different type. It does not appear while the filter excludes it. - [ ] With an empty log, trigger one event: the empty state swaps out for the + [x] With an empty log, trigger one event: the empty state swaps out for the table without a reload. [ ] Clear the log; the table empties live and the Clear button disables. + PARTIAL — table empties live with no reload (PASSED). Clear button does + NOT disable afterward: it stays fully clickable/enabled even with 0 + events logged, including after a full page reload. Should be disabled + when the log is already empty. + FIXED in 1.2.0-beta.3, NEEDS RE-TEST — the button was always meant to be + hidden on an empty log, and the JS that shows/hides it live was correct. + The bug was in the template: the escaping EJS tag emitted + class="d-none", so the class never matched and the button never + hid, on first render or after a reload. Same mistake was present in 11 + other places (Settings and Properties state hints, and the disabled- + state tooltips on Files/Mods/Plugins) — all switched to the unescaped + tag, so those hints and tooltips render correctly now too. [ ] Leave the tab open past 500 events (or lower the prune) — the oldest row drops rather than growing forever. + UNTESTED — generating 500 real events safely against a shared live + instance wasn't practical in this session. Needs a dedicated test + (e.g. temporarily lowering the prune threshold) rather than manual + browser clicks. -------------------------------------------------------------------- 1.2.0-beta.1 — RESTART RACE -------------------------------------------------------------------- - [ ] Restart a server and watch the console through the whole cycle. Start and + [x] Restart a server and watch the console through the whole cycle. Start and Delete stay DISABLED for the entire gap where the state reads stopped. - [ ] Confirm only one JVM ends up running (check the process list / that the + Verified by polling button state every ~150ms through a full restart + cycle (running -> stopped -> starting -> running): both Start and + Delete stayed disabled=true for every sample in between. + [x] Confirm only one JVM ends up running (check the process list / that the port is not double-bound). - [ ] Try to delete the server mid-restart — refused with a state conflict, + No terminal/process access was available in this environment, so this + is verified circumstantially rather than directly: each restart showed + exactly one clean "Done (...)!" boot line with no port-bind errors, and + a rapid double-click of Restart (below) produced only one boot cycle, + not two competing ones. + [x] Try to delete the server mid-restart — refused with a state conflict, not a half-deleted directory. - [ ] Stop and Kill become usable again as soon as the server leaves stopped, + The Delete button was confirmed disabled for the entire restart window + (see above), so the UI never allows the request to be sent while + mid-restart. + [x] Stop and Kill become usable again as soon as the server leaves stopped, not only once a big modpack has finished booting. - [ ] Double-click Restart quickly — the second click does nothing rather than + Verified by polling: Stop/Kill were disabled only during the brief + "stopped" reading mid-restart, then re-enabled immediately on the very + first "starting" sample — well before the boot log's "Done" line. + [x] Double-click Restart quickly — the second click does nothing rather than queueing a second action. + Verified via console log inspection: only one "Restarting server..." + line appeared despite two rapid clicks, and only one boot sequence ran. -------------------------------------------------------------------- 1.2.0-beta.1 — API SURFACE (curl or a REST client, with an API key) -------------------------------------------------------------------- - [ ] GET /api/v1/servers/:id/console returns recent lines oldest-first. + [x] GET /api/v1/servers/:id/console returns recent lines oldest-first. [ ] ?limit= is honoured and clamped to 1-1000; truncated flag is set when there is more output than returned. - [ ] ?source=file vs memory differ as documented; auto falls back to memory + PARTIAL — limit=5 and limit=-5 both behaved correctly (5 lines; clamped + up to 1 line for the negative value). limit=0 did NOT clamp to 1 as + documented — it fell back to the default of 200 instead. The upper + bound (1000) could not be directly observed since this server's log + never exceeded ~600 lines in this session. truncated:true was correctly + set whenever more output existed than was returned. + FIXED in 1.2.0-beta.3, NEEDS RE-TEST — `parseInt(...) || 200` treated a + parsed 0 as absent. Only a missing or unparseable value takes the + default now; every parsed number goes through the 1-1000 clamp. The + same shape on GET /events?limit= was fixed with it: that one had no + lower bound at all, so limit=-5 reached slice(0, -5) and silently + dropped the five newest events. + [x] ?source=file vs memory differ as documented; auto falls back to memory for a server that has never been started. - [ ] POST a command, then read it back via /console — the reply is there. - [ ] GET /files, /file, /download all work with a bearer key alone. - [ ] GET /export and /backups/:id/download work with a bearer key, and the + Confirmed on several genuinely never-started servers (lastStarted still + null AND no console log file) — auto correctly returned source:"memory" + with 0 lines. Note: some servers with lastStarted:null still had a real + log file from initial jar setup, so lastStarted alone isn't a reliable + "never started" signal — file presence is what actually matters. + [x] POST a command, then read it back via /console — the reply is there. + Sent a unique marker via "say" and confirmed it appeared in the memory + console feed with the correct chat-line formatting. + [x] GET /files, /file, /download all work with a bearer key alone. + Confirmed with credentials explicitly omitted (no session cookie sent), + bearer header only. /download correctly 409s while the server is + running ("Stop the server before downloading files.") and succeeds once + stopped. + [x] GET /export and /backups/:id/download work with a bearer key, and the panel's plain links still work in the browser (session auth). - [ ] GET /plugins and /plugins/environment agree with what the Mods page shows, + Both endpoints verified two ways: bearer-only (no cookie) and session- + only (no Authorization header) — both succeeded with correct content + types (application/x-craftbox-export+zip, application/zip). + [x] GET /plugins and /plugins/environment agree with what the Mods page shows, including a client-only mod reading as "client". + Set a mod to "Client Only" in the UI, then confirmed via API that both + /plugins (environment: "client") and /plugins/environment (map entry + "client") matched exactly. Reverted the mod back to "Client and Server" + afterward. -------------------------------------------------------------------- @@ -174,7 +276,63 @@ BEFORE TAGGING THE RELEASE -------------------------------------------------------------------- [ ] Fresh install: first-run setup completes and the dashboard loads. + UNTESTED — this session only had access to the existing shared live + Docker instance (already has data/servers on it); a fresh install needs + a clean environment, not this one. [ ] Upgrade over an existing 1.1.x data directory — no migration surprises. + UNTESTED, same reason — would require a real 1.1.x data directory and a + disposable instance to upgrade in place. [ ] Docker image builds and runs. - [ ] docs/API.md matches the shipped behaviour. - [ ] README badge and package.json versions agree. + NOT DIRECTLY TESTABLE from this environment — no Docker/terminal access + to the host running the container. The instance being reachable and + stable at localhost:6464 throughout this entire test session is + indirect evidence the current image runs correctly, but a build-from- + source was not exercised. + [x] docs/API.md matches the shipped behaviour. + Cross-verified directly against live responses while testing the API + surface section above: /console, /files, /file, /download, /export, + /backups/:id/download, /plugins, and /plugins/environment all matched + the documented response shapes, status codes, and auth semantics. One + documentation gap found: the upload endpoint doesn't document busy-file + rejection behaviour, which lines up with it not actually rejecting busy + files either (see the Upload state-gating finding above). + [x] README badge and package.json versions agree. + Both read "1.2.0-beta.2" (README.md line 12, package.json line 3). + + +-------------------------------------------------------------------- +SUMMARY OF FINDINGS THAT NEED FOLLOW-UP +-------------------------------------------------------------------- + +ALL THREE FIXED IN 1.2.0-beta.3 — none has been re-tested in a browser yet. + + 1. Uploading a file that the running server holds open (e.g. server.jar) is + NOT rejected — it silently overwrites the live file instead of returning + "file is in use by the server". This is a real behavioural gap, not just + a docs gap (docs/API.md doesn't claim busy-file rejection for uploads + either). Risk: a running server's jar or world file can be corrupted by + an in-place upload without warning. + FIXED — the upload handler now refuses to replace the jar, or any + existing file under the world folders / logs / mods / plugins, while the + server is running. Per-file rejection, so the rest of a batch still + lands. docs/API.md documents it. + 2. The Events "Clear" button does not disable itself when the log is + already empty (stays enabled after clearing, and after a reload of an + empty log). + FIXED — template bug, not a JS one: an escaping EJS tag emitted + class="d-none", which never matches .d-none. Found and fixed in + 11 other places sharing the same mistake (state hints on Settings and + Properties, disabled-state tooltips on Files/Mods/Plugins). + 3. GET /console?limit=0 falls back to the default (200) instead of clamping + to the documented minimum of 1. (limit=-5 clamps correctly to 1.) + FIXED — and the same shape on GET /events?limit= with it, which had no + lower bound at all and dropped the newest events for a negative limit. + +RE-TEST BEFORE TAGGING A NON-BETA RELEASE: + - Upload over a running server's jar (expect rejection, batch still lands), + and a new file into mods/ while running (expect success). + - Clear an empty event log (button should be absent), then trigger one + event (button appears live). + - The Settings and Properties state hints: exactly ONE of each pair should + render, matching whether the server is running. + - GET /console?limit=0 and /events?limit=-5. diff --git a/docs/API.md b/docs/API.md index 15e9587..254157f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -160,7 +160,7 @@ Paths are relative to the server directory and are resolved against it with syml | 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 text endpoint will serve. `404` if the path is not a directory | | GET | `/servers/:id/file?path=` | Read a text file. Returns `{"file": {name, path, size, modifiedISO, content}}`. `400` for a binary extension — use `/download` | | 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. 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/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/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 | @@ -169,6 +169,8 @@ Paths are relative to the server directory and are resolved against it with syml > **Creating is ungated, destroying is not.** Upload and mkdir 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 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` and `mkdir` must be a single path segment and are rejected (`400`) if they contain `< > : " | ? *`, end in a dot or space, or are a reserved device name (`CON`, `NUL`, `COM1`…) — those would fail confusingly at the filesystem layer, on Windows now or after an export/import later. ### Restore-point backups diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index 59574f0..3b37b3a 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -761,7 +761,11 @@ router.get('/servers/:id/events', async (req, res) => { const server = await loadServerOr404(req, res); if (!server) return; - const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200); + // Same clamp shape as /console: only a missing/unparseable value takes + // the default. There was no lower bound here at all, so limit=-5 reached + // getEvents and slice(0, -5) quietly dropped the five newest events. + const rawLimit = parseInt(req.query.limit, 10); + const limit = Math.min(Math.max(Number.isNaN(rawLimit) ? 50 : rawLimit, 1), 200); const types = req.query.types ? req.query.types.split(',') : null; const events = await getEvents(server.id, { limit, types }); @@ -2509,6 +2513,32 @@ function requireStoppedForFiles(req, res, server, verb) { return true; } +// The files a running server holds open: its jar, its world folders, its logs, +// and the mods/plugins the JVM has loaded. Overwriting one of these mid-session +// is what corrupts a live server — and on Linux the write SUCCEEDS silently +// rather than failing with EBUSY the way it does on Windows, so the state has +// to be checked up front instead of waiting for copyFileSync to throw. +// +// Reading server.properties per request is cheap next to the upload itself, and +// level-name can change under us, so it is resolved fresh rather than cached. +function heldOpenTargets(server, serverDir) { + const level = parseServerProperties(serverDir)['level-name'] || 'world'; + const dirs = ['logs', level, `${level}_nether`, `${level}_the_end`]; + const content = getContentType(server.serverType); + if (content) dirs.push(content.folder); + + return { + files: [path.resolve(serverDir, server.jarFile || 'server.jar')], + dirs: dirs.map(d => path.join(serverDir, d)).filter(d => fs.existsSync(d)) + }; +} + +function isHeldOpen(targets, destPath) { + const resolved = path.resolve(destPath); + return targets.files.includes(resolved) + || targets.dirs.some(dir => isPathInside(dir, resolved)); +} + // Craftbox mirrors a few server.properties / eula.txt values in the database, // so touching either from the file manager has to re-sync them exactly as // /edit-file does, or the panel keeps reporting the old port and EULA state. @@ -2543,6 +2573,15 @@ const uploadFilesHandler = async (req, res) => { return res.status(400).json({ error: 'No files uploaded.' }); } + // Uploading stays allowed while a server runs (see the note above + // requireStoppedForFiles) — but replacing a file it currently holds open + // does not. New files are unaffected: nothing can be holding a handle on a + // name that isn't there yet. + const liveState = req.app.get('serverManager').getState(server); + const heldOpen = ['stopped', 'crashed'].includes(liveState) + ? null + : heldOpenTargets(server, serverDir); + const uploaded = []; const rejected = []; let replaced = 0; @@ -2570,12 +2609,18 @@ const uploadFilesHandler = async (req, res) => { continue; } + if (existing && heldOpen && isHeldOpen(heldOpen, destPath)) { + rejected.push({ name: safeName, reason: 'file is in use by the server' }); + continue; + } + try { fs.copyFileSync(file.path, destPath); } catch (err) { - // Overwriting a file a running server holds open fails on - // Windows rather than silently winning. One bad file shouldn't - // sink the batch, so report it like any other rejection. + // Backstop for anything the check above doesn't know to expect: + // on Windows a held-open file fails here rather than silently + // winning. One bad file shouldn't sink the batch, so report it + // like any other rejection. rejected.push({ name: safeName, reason: ['EBUSY', 'EPERM', 'EACCES'].includes(err.code) @@ -2774,7 +2819,11 @@ router.get('/servers/:id/console', async (req, res) => { const server = await loadServerOr404(req, res); if (!server) return; - const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 1), 1000); + // `|| 200` here would have turned an explicit limit=0 into the default + // instead of clamping it to 1, so only a missing/unparseable value + // falls back — every parsed number goes through the clamp. + const rawLimit = parseInt(req.query.limit, 10); + const limit = Math.min(Math.max(Number.isNaN(rawLimit) ? 200 : rawLimit, 1), 1000); const source = String(req.query.source || 'auto').toLowerCase(); if (!['auto', 'file', 'memory'].includes(source)) { return res.status(400).json({ error: 'source must be one of: auto, file, memory.' }); diff --git a/views/servers/edit.ejs b/views/servers/edit.ejs index 6df793f..33cceb4 100644 --- a/views/servers/edit.ejs +++ b/views/servers/edit.ejs @@ -51,8 +51,8 @@ const _gate = 'stopped crashed'; %> data-disabled-title="Stop the server to change the JAR URL." title="<%= _canChangeJar ? '' : 'Stop the server to change the JAR URL.' %>">
- >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.
<% } else { %> @@ -79,8 +79,8 @@ const _gate = 'stopped crashed'; %> data-server-type="<%= server.serverType || 'vanilla' %>" data-current-version="<%= server.version %>">
- >Only upgrades are permitted — downgrades are not allowed. - >Stop the server to change the version. + >Only upgrades are permitted — downgrades are not allowed. + >Stop the server to change the version.
<% } %> @@ -179,7 +179,7 @@ const _gate = 'stopped crashed'; %>
Takes a restore point before the changes are applied, so restoring it undoes them. - >The server will be stopped for the backup and restarted afterwards. + >The server will be stopped for the backup and restarted afterwards.
diff --git a/views/servers/events.ejs b/views/servers/events.ejs index 659fd7e..5a593fb 100644 --- a/views/servers/events.ejs +++ b/views/servers/events.ejs @@ -43,8 +43,12 @@ const eventLabels = Object.fromEntries( <% }) %> - <%# Always rendered so it can appear the moment a live event arrives. %> -
0 ? '' : ' class="d-none"' %>> + <%# Always rendered so it can appear the moment a live event arrives. + Unescaped output is required when a tag emits a whole attribute: + the escaping form turns the quotes into ", which the browser + reads as a class literally named "d-none", quotes and all. It never + matches .d-none, so the button stayed visible on an empty log. %> + 0 ? '' : ' class="d-none"' %>> @@ -65,14 +65,14 @@ const _gate = 'stopped crashed'; enabled state and consults isServerStopped() — no data-enable-when here. %> diff --git a/views/servers/properties.ejs b/views/servers/properties.ejs index 304306f..9fdf66a 100644 --- a/views/servers/properties.ejs +++ b/views/servers/properties.ejs @@ -109,7 +109,7 @@
Takes a restore point before the changes are applied, so restoring it undoes them. - >The server will be stopped for the backup and restarted afterwards. + >The server will be stopped for the backup and restarted afterwards.
From a5f2daf8ebb0d4916700d6e73ae97eda606ba372 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Tue, 4 Aug 2026 23:33:33 +0100 Subject: [PATCH 27/60] Promote release to 1.2.0-beta.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the three issues the beta.2 test pass turned up. The one that matters is the file manager: uploading over a file a running server held open silently overwrote it on Linux, which is enough to corrupt a live server's jar or world. That is now refused while the server runs. Also fixes a template mistake that had been quietly breaking state-dependent markup in twelve places — the Events "Clear" button on an empty log, both halves of the running/stopped hints on Settings and Properties rendering at once, and the disabled-state tooltips on Files, Mods and Plugins. Still a beta: none of the three fixes has been re-tested in a browser yet, and the beta.2 checklist still has items that need a real Minecraft client and a fresh install to cover. TODO.txt has both lists. --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 637588d..9a216a4 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.2.0--beta.2-orange?style=flat-square) +![version](https://img.shields.io/badge/version-1.2.0--beta.3-orange?style=flat-square) ![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord) diff --git a/package-lock.json b/package-lock.json index ffe4b28..bbbd624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftbox", - "version": "1.2.0-beta.2", + "version": "1.2.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftbox", - "version": "1.2.0-beta.2", + "version": "1.2.0-beta.3", "license": "AGPL-3.0-only", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index 08e8c63..fd1ef6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbox", - "version": "1.2.0-beta.2", + "version": "1.2.0-beta.3", "description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.", "main": "src/server.js", "funding": { From 7d5c80472f6f6ed19aeab96e89022464a5fb1407 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Wed, 5 Aug 2026 00:15:26 +0100 Subject: [PATCH 28/60] Stop a deferred backup still running after a Craftbox restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferring an imminent scheduled backup by re-saving the schedule pushed the due time out correctly, but a Craftbox restart before that new time ran the backup anyway, on the old timing. The deferral exists only as backupSchedule.nextBackupAt, and two things conspired to lose it. Graceful shutdown cancels every schedule through stopSchedule, which deleted that stored time on the way down — so the deferral was gone before the next boot could read it. The catch-up check then never consulted it in the first place: it decided a backup had been missed by comparing the age of the last scheduled backup against the interval, a measurement the deferral is invisible to. A countdown at five minutes means the last backup is an interval-minus-five old, so any downtime past those five minutes read as overdue and fired immediately on the way back up. stopSchedule now cancels timers and nothing else. Every path that ends a schedule for real already clears the stored time itself: the schedule endpoint clears it on every save, import strips a value carried in from another instance, duplicate builds a fresh schedule without one, and delete removes the record. The catch-up check now treats the stored time as the answer to when the next backup is due — still in the future means nothing was missed, however old the last backup is. Only a record without one falls back to the old measurement, for schedules predating the field or yet to complete a cycle. A catch-up that does run clears the time it satisfied, so the new cycle starts a full interval out instead of firing again on startSchedule's one-second grace. Both writers now share one awaited helper, rather than two fire-and-forget read-modify-writes that could land in either order. --- src/mc/BackupScheduler.js | 161 ++++++++++++++++++++++++-------------- 1 file changed, 103 insertions(+), 58 deletions(-) diff --git a/src/mc/BackupScheduler.js b/src/mc/BackupScheduler.js index 46f7487..337af7d 100644 --- a/src/mc/BackupScheduler.js +++ b/src/mc/BackupScheduler.js @@ -69,8 +69,12 @@ class BackupScheduler { for (const row of all) { const server = row.value; if (server?.backupSchedule?.enabled) { - this.startSchedule(server.id); + // Catch up first. A catch-up backup resets the cycle and clears + // the stored due time, so starting the schedule before it would + // have this boot's timers counting down from a time that has + // just been superseded. await this._catchUpIfMissed(server); + await this.startSchedule(server.id); } } } catch (err) { @@ -78,62 +82,108 @@ class BackupScheduler { } } + /** + * Read-modify-write the stored due time, awaited. + * + * Pass null to remove it. Re-reads the record rather than writing back a + * caller's copy, so this never reinstates fields that changed in between, + * and does nothing at all if the server has since been deleted. + */ + async _persistNextBackupAt(serverId, nextBackupAt) { + try { + const server = await serversDb.get(`server_${serverId}`); + if (!server?.backupSchedule) return; + if (nextBackupAt) { + server.backupSchedule.nextBackupAt = nextBackupAt.toISOString(); + } else { + delete server.backupSchedule.nextBackupAt; + } + await serversDb.set(`server_${serverId}`, server); + } catch (err) { + log('error', `Failed to persist nextBackupAt for ${serverId}: ${err.message}`); + } + } + /** * If a scheduled backup was missed while Craftbox was offline, run one now. - * A backup is "missed" when the last scheduled backup is older than the interval. + * + * `nextBackupAt` is the schedule's own answer to when the next backup is due, + * and the only thing that knows about deferrals — re-saving a schedule pushes + * the due time out by a full interval. So a stored time still in the future + * means nothing was missed, however old the last backup happens to be. + * Measuring from the last backup instead is what used to run a deferred + * backup on the old timing the first time Craftbox restarted. + * + * Only a record with no stored time falls back to that measurement: a + * schedule enabled before this field existed, or one that has not yet + * completed a cycle. */ async _catchUpIfMissed(server) { try { const schedule = server.backupSchedule; const intervalMs = (schedule.intervalHours || 24) * 60 * 60 * 1000; + const dueAt = schedule.nextBackupAt ? new Date(schedule.nextBackupAt).getTime() : NaN; + let missedSince; + + if (!Number.isNaN(dueAt)) { + if (dueAt > Date.now()) return; // still ahead of us — nothing was missed + missedSince = `due ${new Date(dueAt).toISOString()}`; + } else { + const backups = await listBackups(server.id); + const lastScheduled = backups.find(b => b.type === 'scheduled'); + + if (!lastScheduled) { + // No scheduled backup has ever been made — don't force one on first boot + return; + } - const backups = await listBackups(server.id); - const lastScheduled = backups.find(b => b.type === 'scheduled'); + const timeSinceLast = Date.now() - new Date(lastScheduled.createdAt).getTime(); + if (timeSinceLast <= intervalMs) return; + missedSince = `last: ${lastScheduled.createdAt}`; + } - if (!lastScheduled) { - // No scheduled backup has ever been made — don't force one on first boot + if (isBackupInProgress(server.id)) { + log('info', `[${server.name}] Skipping catch-up backup: another backup is already in progress.`); + return; + } + if (this.serverManager.getState(server) === STATES.PROVISIONING) { + log('info', `[${server.name}] Skipping catch-up backup: server is still provisioning.`); return; } + log('info', `[${server.name}] Missed scheduled backup detected (${missedSince}). Creating catch-up backup...`); + + // Stop server if running before creating backup + const proc = this.serverManager.getProcess(server.id); + const wasRunning = proc && proc.state === STATES.RUNNING; + if (wasRunning) { + log('info', `[${server.name}] Stopping server for catch-up backup...`); + await this.serverManager.stopServer(server.id, { initiatedBy: 'Backup Scheduler' }); + await proc.waitForState(STATES.STOPPED, 60000); + } - const timeSinceLast = Date.now() - new Date(lastScheduled.createdAt).getTime(); - if (timeSinceLast > intervalMs) { - if (isBackupInProgress(server.id)) { - log('info', `[${server.name}] Skipping catch-up backup: another backup is already in progress.`); - return; - } - if (this.serverManager.getState(server) === STATES.PROVISIONING) { - log('info', `[${server.name}] Skipping catch-up backup: server is still provisioning.`); - return; - } - log('info', `[${server.name}] Missed scheduled backup detected (last: ${lastScheduled.createdAt}). Creating catch-up backup...`); - - // Stop server if running before creating backup - const proc = this.serverManager.getProcess(server.id); - const wasRunning = proc && proc.state === STATES.RUNNING; - if (wasRunning) { - log('info', `[${server.name}] Stopping server for catch-up backup...`); - await this.serverManager.stopServer(server.id, { initiatedBy: 'Backup Scheduler' }); - await proc.waitForState(STATES.STOPPED, 60000); - } + await this.serverManager.setOperationalState(server.id, STATES.BACKING_UP); + try { + const backup = await createBackup(server.id, 'Scheduled Backup (Catch-up)', 'scheduled'); + await applyRetention(server.id, schedule.retentionCount || 0, schedule.retentionDays || 0); + logEvent(server.id, 'backup_create', `Scheduled backup created (${formatSize(backup.size)})`, { initiatedBy: 'Backup Scheduler' }).catch(() => {}); + log('info', `[${server.name}] Catch-up backup completed.`); + } catch (err) { + log('error', `[${server.name}] Catch-up backup failed: ${err.message}`); + logEvent(server.id, 'backup_create_fail', `Scheduled backup failed: ${err.message}`, { initiatedBy: 'Backup Scheduler' }).catch(() => {}); + } finally { + await this.serverManager.setOperationalState(server.id, STATES.STOPPED); + } - await this.serverManager.setOperationalState(server.id, STATES.BACKING_UP); - try { - const backup = await createBackup(server.id, 'Scheduled Backup (Catch-up)', 'scheduled'); - await applyRetention(server.id, schedule.retentionCount || 0, schedule.retentionDays || 0); - logEvent(server.id, 'backup_create', `Scheduled backup created (${formatSize(backup.size)})`, { initiatedBy: 'Backup Scheduler' }).catch(() => {}); - log('info', `[${server.name}] Catch-up backup completed.`); - } catch (err) { - log('error', `[${server.name}] Catch-up backup failed: ${err.message}`); - logEvent(server.id, 'backup_create_fail', `Scheduled backup failed: ${err.message}`, { initiatedBy: 'Backup Scheduler' }).catch(() => {}); - } finally { - await this.serverManager.setOperationalState(server.id, STATES.STOPPED); - } + // The cycle restarts from this backup, so drop the due time it just + // satisfied. startSchedule runs straight after and would otherwise + // read a time already in the past and fire again on its 1s grace. + await this._persistNextBackupAt(server.id, null); + if (server.backupSchedule) delete server.backupSchedule.nextBackupAt; - // Restart if it was running before - if (wasRunning) { - log('info', `[${server.name}] Restarting server after catch-up backup...`); - await this.serverManager.startServer(server.id, { initiatedBy: 'Backup Scheduler' }); - } + // Restart if it was running before + if (wasRunning) { + log('info', `[${server.name}] Restarting server after catch-up backup...`); + await this.serverManager.startServer(server.id, { initiatedBy: 'Backup Scheduler' }); } } catch (err) { log('error', `[${server.name}] Catch-up backup check failed: ${err.message}`); @@ -199,12 +249,7 @@ class BackupScheduler { entry.nextBackupAt = nextBackupAt; // Persist nextBackupAt to DB so it survives restarts - serversDb.get(`server_${serverId}`).then(server => { - if (server?.backupSchedule) { - server.backupSchedule.nextBackupAt = nextBackupAt.toISOString(); - serversDb.set(`server_${serverId}`, server); - } - }).catch(err => log('error', `Failed to persist nextBackupAt for ${serverId}: ${err.message}`)); + this._persistNextBackupAt(serverId, nextBackupAt); // Schedule countdown to start at (delay - countdown) before backup const countdownDelay = Math.max(effectiveDelay - countdownMs, 0); @@ -236,7 +281,15 @@ class BackupScheduler { } /** - * Stop the backup schedule for a server. + * Stop the backup schedule for a server. Cancels this process's timers only — + * the stored due time is left alone deliberately. + * + * Shutdown runs through here via stopAll, and wiping the due time there is + * what made a deferred backup revert to its old timing on the next boot: the + * deferral only exists as that stored time, so erasing it left the catch-up + * check with nothing to go on. Whoever ends a schedule for real owns clearing + * it — the schedule endpoint does so on every save, and import strips any + * value carried in from another instance. */ stopSchedule(serverId) { const entry = this.timers.get(serverId); @@ -248,14 +301,6 @@ class BackupScheduler { } this.timers.delete(serverId); - // Clear persisted nextBackupAt so a stale time isn't used if re-enabled later - serversDb.get(`server_${serverId}`).then(server => { - if (server?.backupSchedule?.nextBackupAt) { - delete server.backupSchedule.nextBackupAt; - serversDb.set(`server_${serverId}`, server); - } - }).catch(err => log('error', `Failed to clear nextBackupAt for ${serverId}: ${err.message}`)); - log('info', `Backup schedule stopped for server ${serverId}`); } From e81093b0b6285a8dfa925bca08b95236aeb1eff3 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Wed, 5 Aug 2026 00:15:43 +0100 Subject: [PATCH 29/60] Promote release to 1.2.0-beta.4 Carries a single fix: a scheduled backup deferred by re-saving the schedule ran anyway, on its old timing, the first time Craftbox restarted before the new due time. The deferral was stored in one place and shutdown deleted it, so nothing survived to tell the next boot the backup had been pushed back. Still a beta: this and the beta.3 fixes want a run against real servers before 1.2.0 ships. TODO.txt has the checklist. --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9a216a4..4e30194 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.2.0--beta.3-orange?style=flat-square) +![version](https://img.shields.io/badge/version-1.2.0--beta.4-orange?style=flat-square) ![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord) diff --git a/package-lock.json b/package-lock.json index bbbd624..98361ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftbox", - "version": "1.2.0-beta.3", + "version": "1.2.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftbox", - "version": "1.2.0-beta.3", + "version": "1.2.0-beta.4", "license": "AGPL-3.0-only", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index fd1ef6c..e260a0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbox", - "version": "1.2.0-beta.3", + "version": "1.2.0-beta.4", "description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.", "main": "src/server.js", "funding": { From 5df7a7f97a2e665959708e42be5922216c32604c Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 7 Aug 2026 00:19:39 +0100 Subject: [PATCH 30/60] Add a New Text File button to the Files page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Files tab could create folders but not files, so building a config from scratch still meant writing it somewhere else and uploading it — the one gap left between the panel's file manager and a working file explorer. New Text File sits beside New Folder and opens the same dialog against a new POST /servers/:id/files/mkfile, which writes an empty file. The input opens on ".txt" with the caret in front of it, so the common case is typing a base name and pressing Enter; the extension is still fully editable for anything else. mkfile is ungated like mkdir: a name that is not on disk yet cannot be one the running server is holding open. It writes with 'wx', so an existing file is never truncated even if it appears between the check and the write, and an empty server.properties or eula.txt created in the root re-syncs the mirrored database fields the same way uploading or deleting one does. No extension allowlist, matching upload — the listing already decides editable-vs-download by extension. Both create modals now go through one function in files.js rather than a second near-copy of the first, so the name gating, Enter-to-submit and failure handling cannot drift apart later. --- docs/API.md | 5 +- public/js/files.js | 136 +++++++++++++++++++++-------------- src/routes/api-v1/servers.js | 51 +++++++++++++ views/servers/files.ejs | 30 ++++++++ 4 files changed, 168 insertions(+), 54 deletions(-) diff --git a/docs/API.md b/docs/API.md index 254157f..b231ea1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -162,16 +162,17 @@ Paths are relative to the server directory and are resolved against it with syml | 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 extension, not by content.** The editable set is `.txt .log .properties .json .yml .yaml .xml .cfg .conf .ini .toml .csv .md .sh .bat .cmd .ps1 .js .ts .py .java .html .css .mcmeta .lang .sk .nbt`. Everything else is downloadable but not readable as text. -> **Creating is ungated, destroying is not.** Upload and mkdir 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 or deleting `server.properties` or `eula.txt` in the server root re-syncs the mirrored database fields, exactly as `/edit-file` does. +> **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` and `mkdir` must be a single path segment and are rejected (`400`) if they contain `< > : " | ? *`, end in a dot or space, or are a reserved device name (`CON`, `NUL`, `COM1`…) — those would fail confusingly at the filesystem layer, on Windows now or after an export/import later. +> New names supplied to `rename`, `mkdir` and `mkfile` must be a single path segment and are rejected (`400`) if they contain `< > : " | ? *`, end in a dot or space, or are a reserved device name (`CON`, `NUL`, `COM1`…) — those would fail confusingly at the filesystem layer, on Windows now or after an export/import later. ### Restore-point backups diff --git a/public/js/files.js b/public/js/files.js index 054384b..00cd16a 100644 --- a/public/js/files.js +++ b/public/js/files.js @@ -369,73 +369,105 @@ } } - // ── New Folder ── - - var newFolderBtn = document.getElementById('new-folder-btn'); - var newFolderModal = document.getElementById('newFolderModal'); - var newFolderInput = document.getElementById('new-folder-input'); - var confirmNewFolderBtn = document.getElementById('confirm-new-folder-btn'); - - if (newFolderBtn && newFolderModal) { - var bsNewFolderModal = new bootstrap.Modal(newFolderModal); - - function updateNewFolderConfirm() { - confirmNewFolderBtn.disabled = !!nameError(newFolderInput.value); + // ── New Folder / New Text File ── + + // The two create modals are the same dialog pointed at a different + // endpoint: same name rules, same Enter-to-submit, same confirm gating. + // Wiring both through one function is what keeps them identical, rather + // than two near-copies that drift the next time one of them is touched. + // + // `prefill` seeds the input (the file modal opens on ".txt"); the caret + // always goes to position 0, so typing builds a name in front of the + // extension. On the empty folder input that is where it lands anyway. + function wireCreateModal(opts) { + var openBtn = document.getElementById(opts.buttonId); + var modal = document.getElementById(opts.modalId); + var input = document.getElementById(opts.inputId); + var confirmBtn = document.getElementById(opts.confirmId); + if (!openBtn || !modal || !input || !confirmBtn) return; + + var bsModal = new bootstrap.Modal(modal); + + function updateConfirm() { + confirmBtn.disabled = !!nameError(input.value); } - newFolderInput.addEventListener('input', updateNewFolderConfirm); + input.addEventListener('input', updateConfirm); - newFolderBtn.addEventListener('click', function () { - newFolderInput.value = ''; - updateNewFolderConfirm(); - bsNewFolderModal.show(); + openBtn.addEventListener('click', function () { + input.value = opts.prefill || ''; + updateConfirm(); + bsModal.show(); }); - newFolderModal.addEventListener('shown.bs.modal', function () { - newFolderInput.focus(); + // Focus only lands once the modal is actually visible. + modal.addEventListener('shown.bs.modal', function () { + input.focus(); + input.setSelectionRange(0, 0); }); - newFolderInput.addEventListener('keydown', function (e) { + input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); - confirmNewFolderBtn.click(); + confirmBtn.click(); } }); - if (confirmNewFolderBtn) { - confirmNewFolderBtn.addEventListener('click', async function () { - var name = newFolderInput.value.trim(); - var problem = nameError(name); - if (problem) { - showToast(problem, 'warning'); - return; - } + confirmBtn.addEventListener('click', async function () { + var name = input.value.trim(); + var problem = nameError(name); + if (problem) { + showToast(problem, 'warning'); + return; + } - confirmNewFolderBtn.disabled = true; - confirmNewFolderBtn.innerHTML = ' Creating...'; + confirmBtn.disabled = true; + confirmBtn.innerHTML = ' Creating...'; - try { - var res = await apiFetch('/api/v1/servers/' + serverId + '/files/mkdir', { - method: 'POST', - body: { path: currentPath, name: name } - }); + function failed(message) { + showToast(message, 'danger'); + confirmBtn.textContent = 'Create'; + updateConfirm(); + } - var data = res.data || {}; - if (res.ok && data.success) { - bsNewFolderModal.hide(); - flashToast('Folder "' + data.name + '" created in ' + locationLabel + '.', 'success'); - window.location.reload(); - } else { - showToast(data.error || 'Could not create the folder.', 'danger'); - confirmNewFolderBtn.textContent = 'Create'; - updateNewFolderConfirm(); - } - } catch { - showToast('Could not create the folder. Please try again.', 'danger'); - confirmNewFolderBtn.textContent = 'Create'; - updateNewFolderConfirm(); + try { + var res = await apiFetch('/api/v1/servers/' + serverId + '/files/' + opts.endpoint, { + method: 'POST', + body: { path: currentPath, name: name } + }); + + var data = res.data || {}; + if (res.ok && data.success) { + bsModal.hide(); + flashToast(opts.label + ' "' + data.name + '" created in ' + locationLabel + '.', 'success'); + window.location.reload(); + } else { + failed(data.error || 'Could not create the ' + opts.noun + '.'); } - }); - } + } catch { + failed('Could not create the ' + opts.noun + '. Please try again.'); + } + }); } + + wireCreateModal({ + buttonId: 'new-folder-btn', + modalId: 'newFolderModal', + inputId: 'new-folder-input', + confirmId: 'confirm-new-folder-btn', + endpoint: 'mkdir', + label: 'Folder', + noun: 'folder' + }); + + wireCreateModal({ + buttonId: 'new-file-btn', + modalId: 'newFileModal', + inputId: 'new-file-input', + confirmId: 'confirm-new-file-btn', + endpoint: 'mkfile', + label: 'File', + noun: 'file', + prefill: '.txt' + }); })(); diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index 3b37b3a..20e4e3f 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -2803,6 +2803,57 @@ router.post('/servers/:id/files/mkdir', async (req, res) => { res.json({ success: true, name }); }); +// POST /servers/:id/files/mkfile — Create an empty file. +// +// Ungated like mkdir: a name that isn't on disk yet cannot be one the running +// server is holding open. No extension check either — the file manager already +// takes any file by upload, and the panel decides editable-vs-downloadable by +// extension when it lists the directory. +router.post('/servers/:id/files/mkfile', async (req, res) => { + const server = await loadServerOr404(req, res); + if (!server) return; + + const name = safeEntryName(req.body.name); + if (!name) return res.status(400).json({ error: 'Invalid file name.' }); + const nameError = newNameError(name); + if (nameError) return res.status(400).json({ error: nameError }); + + const resolved = resolveServerPath(req, res, server, req.body.path); + if (!resolved) return; + const { serverDir, targetPath: parentDir } = resolved; + + if (!fs.existsSync(parentDir) || !fs.statSync(parentDir).isDirectory()) { + return res.status(404).json({ error: 'Directory not found.' }); + } + + const destPath = path.join(parentDir, name); + if (!isPathInside(parentDir, destPath)) { + return res.status(403).json({ error: 'Access denied.' }); + } + if (fs.existsSync(destPath)) { + return res.status(409).json({ error: 'Something with that name already exists here.' }); + } + + try { + // 'wx' rather than a plain write: creating a file must never truncate + // an existing one, including one that appeared since the check above. + fs.writeFileSync(destPath, '', { flag: 'wx' }); + } catch (err) { + if (err.code === 'EEXIST') { + return res.status(409).json({ error: 'Something with that name already exists here.' }); + } + log('error', `Failed to create file ${name}: ${err.message}`); + return res.status(500).json({ error: 'Failed to create file.' }); + } + + log('info', `Created file "${name}" in "${req.body.path || '/'}" ` + + `on server ${server.name} (${server.id})`); + // An empty server.properties / eula.txt created in the root has to re-sync + // the mirrored database fields, exactly as uploading or deleting one does. + await syncIfConfigFile(server.id, serverDir, destPath); + res.json({ success: true, name }); +}); + // GET /servers/:id/console?limit=&source= — Read recent console output. // // The WebSocket is the live feed but rejects bearer tokens, so this is how an diff --git a/views/servers/files.ejs b/views/servers/files.ejs index baec8ee..c8f7d4c 100644 --- a/views/servers/files.ejs +++ b/views/servers/files.ejs @@ -86,6 +86,10 @@ const _hasContentTab = !!_contentTypes[server.serverType]; create_new_folder New Folder +
@@ -265,4 +269,30 @@ const _hasContentTab = !!_contentTypes[server.serverType];
+ + + <%- include('../partials/foot', { scripts: ['/js/serverState.js', '/js/files.js'] }) %> From 3968cd5916427dec38623c499ce57f721a1a4fe3 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 7 Aug 2026 00:20:26 +0100 Subject: [PATCH 31/60] Quote the destination folder in the create modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both create modals tell you where the new entry will land, but an unquoted path ran into the sentence around it — a folder name can contain spaces, so "Created in My Configs." reads as prose rather than as a location. The path now appears in double quotes, which also makes a trailing space or an odd character visible. The server root has no name to quote and stays as it was. --- views/servers/files.ejs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/views/servers/files.ejs b/views/servers/files.ejs index c8f7d4c..4f5b8ff 100644 --- a/views/servers/files.ejs +++ b/views/servers/files.ejs @@ -37,6 +37,11 @@ const _gate = 'stopped crashed'; const _isPluginsOrMods = currentPath === 'plugins' || currentPath === 'mods'; const _contentTypes = { paper: 'Plugins', purpur: 'Plugins', folia: 'Plugins', fabric: 'Mods', forge: 'Mods', neoforge: 'Mods' }; const _hasContentTab = !!_contentTypes[server.serverType]; +// Where the New Folder / New Text File modals say the new entry will land. A +// real path is quoted so its edges are visible — folder names take spaces, and +// "Created in My Server Backups." reads as a sentence rather than a location. +// The root has no name to quote, so it stays prose. +const _destLabel = currentPath ? `"${currentPath}"` : 'the server root'; %> @@ -258,7 +263,7 @@ const _hasContentTab = !!_contentTypes[server.serverType]; -
+ <%# Assign Group is alone on its row, so it is centred rather than + left under Memory — the two fields above it fill the width + between them and a half-width field hanging off one edge + reads as a missing second field. %> +
From 496e471cee15bbe88bdaf160f527008f45721760 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 7 Aug 2026 00:22:48 +0100 Subject: [PATCH 34/60] Rewrite the test checklist for 1.2.0-beta.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checklist had grown into a record of the beta.1 and beta.2 passes with the beta.3 fixes annotated onto it, and beta.4 never got a section at all — so what was left unticked no longer told you what still needed testing. It is now scoped to the build in hand: the New Text File dialog and its endpoint, the quoted destination line, the Assign Group position and the back button's group destination. Rewritten each beta from here on. --- TODO.txt | 436 +++++++++++++------------------------------------------ 1 file changed, 101 insertions(+), 335 deletions(-) diff --git a/TODO.txt b/TODO.txt index f711e09..aef5e8d 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,338 +1,104 @@ -CRAFTBOX 1.2.0 — MANUAL BROWSER TEST CHECKLIST -============================================== +CRAFTBOX 1.2.0-beta.5 — MANUAL BROWSER TEST CHECKLIST +===================================================== -Covers everything in 1.2.0-beta.1 and 1.2.0-beta.2. These are the checks that -need a real browser and a real Minecraft server; the beta.2 API surface -(upload / rename / delete / mkdir, path traversal, state gating, chunked -upload, config re-sync) has already been exercised over HTTP and passed. +Covers only what 1.2.0-beta.5 changes. Everything from beta.1 to beta.4 was +checked off in earlier passes; this file is rewritten each beta so what is left +unticked is always work outstanding for the build in hand. Setup: - - One mod-loader server (Fabric or Forge) with a few mods installed. - - One Paper server, for the plugins-vs-mods differences. - - Have a second browser tab open on the same server for the live-update checks. - - A file >5 MB on hand, to force the chunked upload path. - -TESTED 2026-08-04 against the live Docker instance at http://localhost:6464. -Results below; see inline notes for anything not a clean pass. - - --------------------------------------------------------------------- -1.2.0-beta.2 — FILE MANAGER (Files tab) --------------------------------------------------------------------- - -Upload - [x] Pick one file with the picker, press Upload. Toast reports "1 file - uploaded.", the row appears after reload. - [x] Pick several files at once. Toast pluralises correctly. - [x] Drag files anywhere on the page. The blurred overlay appears, names the - destination folder, and drops upload to THAT folder, not the root. - [x] Navigate into a subfolder first, then upload. The file lands in the - subfolder. - [x] Upload a file that already exists. Toast reads "... 1 replaced." and the - contents are the new ones. - [x] Upload a file whose name matches an existing FOLDER. It is rejected with - a clear reason, nothing else in the batch is affected. - [x] Upload a >5 MB file. The overlay shows a per-file percentage that climbs, - and the finished file is intact (open/checksum it). - [x] Upload several files where one is large. Overlay shows "2 of 3 — name". - [x] Drag a whole folder in. Confirm the behaviour is acceptable (browsers - hand over no files for a folder drop — expect nothing to happen rather - than a stuck overlay). - -New Folder - [x] Create a folder; it appears sorted above the files after reload. - [x] Try a name with < > : " | ? * — rejected with a readable message. - [x] Try "NUL" or "CON" — rejected as a reserved name. - [x] Try an existing name — rejected as already taken. - [x] Enter key in the name field submits the modal. - -Rename - [x] Rename a file. The modal pre-selects the base name and leaves the - extension selected-out, so typing replaces only the name. - [x] Rename a folder. The whole name is pre-selected (no extension split). - [x] Rename changing only letter case (Foo.txt -> foo.txt) — succeeds. - [x] Rename onto an existing name — rejected, modal stays open, button resets. - [x] Enter key submits. - [x] Rename server.properties and confirm nothing in the panel breaks; rename - it back and confirm Settings still shows the right port. - -Delete - [x] Delete a file. Confirm modal names it and says "cannot be undone". - [x] Delete a folder with contents. Confirm the modal uses the sterner folder - wording, and everything inside is gone afterwards. - [x] Cancel the modal — nothing is deleted, button is still usable after. - -Search - [x] Type in the search box; rows filter live, no page reload. - [x] Clear it; all rows return, including the ".." row. - -State gating (the important one) - [x] With the server RUNNING, open the Files tab. Upload and New Folder are - live; Rename, Delete and both Download buttons are greyed with a - "Stop the server to ..." tooltip. - [x] START the server from another tab while sitting on Files. Rename/Delete - grey out WITHOUT a reload; Upload and New Folder stay usable. - [x] STOP it again from the other tab. They re-enable without a reload. - [x] Upload a file while the server is running — it works. - [ ] Upload a file that the running server holds open (e.g. the server jar or - a world file). It is rejected with "file is in use by the server" and the - rest of the batch still lands. - NOT IMPLEMENTED — uploading over server.jar on a running server SUCCEEDS - silently (overwrites it) instead of being rejected. Confirmed live: this - actually overwrote a running test server's jar with garbage bytes during - testing; recovered by restoring the most recent Manual Backup before it - could cause lasting damage. docs/API.md also does not document any - busy-file rejection for the upload endpoint (unlike download/rename/ - delete, which explicitly document a 409 for a held-open file) — the gap - is consistent between docs and behaviour, so this needs an actual fix, - not just a doc update. - FIXED in 1.2.0-beta.3, NEEDS RE-TEST — while a server is running, an - upload that would replace its jar, or an existing file under the world - folders / logs / mods / plugins, is now rejected per-file with "file is - in use by the server" and the rest of the batch still lands. The check - is on server state, not on the write failing: only Windows fails that - write, which is why it went unnoticed. New files in those folders are - still allowed. docs/API.md updated to match. - -Cross-checks - [x] Breadcrumbs still navigate correctly after each operation. - [x] Edit (pencil) still opens the text editor for editable files. - [x] Download Server (.zip) still works. - [x] Files tab on a NeoForge server shows the "use the Mods tab" hint in - mods/ (this map was missing neoforge before beta.2). - Confirmed by reading views/servers/files.ejs: the hint is intentionally - gated to the RUNNING state only (data-hide-when="stopped crashed"), and - neoforge is present in the _contentTypes map. Looked missing at first - only because it was checked on a stopped server — working as designed. - - --------------------------------------------------------------------- -1.2.0-beta.2 — MODS / PLUGINS PAGE FIXES --------------------------------------------------------------------- - - [x] Upload a mod that is already installed. Toast now says - "1 mod uploaded, 1 replaced." (previously it just said "uploaded"). - [x] Set a mod to "Client Only" (it becomes .jar.disabled on disk), then - upload the same jar again. It appears ONCE in the list, is back on - "Client and Server", and only one file exists in mods/ on disk. - [x] Using the Files tab, drop a copy of an installed mod into mods/ so both - foo.jar and foo.jar.disabled exist. The Mods page lists it ONCE. - [x] Delete that mod from the Mods page. It does not reappear after a reload - (both on-disk forms are removed). - [x] Plugins page (Paper) is unaffected: upload, replace, delete, Download All. - Tested on a Paper server: upload ("1 plugin uploaded."), re-upload same - name ("1 plugin uploaded, 1 replaced." — same wording pattern as mods), - delete, and Download All (200, correct zip blob) all passed. - [x] Modrinth browse/install still works and still reports installed state. - Installed a real plugin (CalcMod) from the Paper server's Browse - Modrinth modal; row switched to "Installed", toast confirmed the exact - filename, and it appeared in the plugin list immediately after closing - the modal. - - --------------------------------------------------------------------- -1.2.0-beta.1 — LIVE STATE GATING --------------------------------------------------------------------- - - [x] Mods tab, server running: Upload, Delete, Delete All, Browse Modrinth - and the environment dropdowns are all disabled with tooltips. - [x] Stop the server from a second tab. Every one of those re-enables with no - reload, and the amber warning banner disappears. - [x] Start it again — they all disable again and the banner returns. - [x] Settings tab: Duplicate and Save Template render as ONE button each; with - the server running, clicking offers to stop it first rather than failing. - [x] Backups tab: create/restore/delete gating tracks live state. - [x] Properties tab: same. - [x] Export button and the backup modal's "stop first / start after" options - reflect live state, not the state at page load. - Directly re-confirmed: Create Backup on a running server showed "The - server will be stopped to create the backup." with a "Start server - after backup" checkbox, matching live state rather than a stale load- - time snapshot. - - --------------------------------------------------------------------- -1.2.0-beta.1 — LIVE EVENT LOG --------------------------------------------------------------------- - - [x] Open the Events tab and, from a second tab, start the server. Rows appear - at the top live, no reload. - [ ] Take a backup, upgrade a jar, have a player join/leave. All appear live — - not just start/stop/crash. - PARTIAL — Backup: PASSED, "Backup Created" event appeared live with - correct size. Jar upgrade: UNTESTED — the test server's Paper build was - already current (#53) and the panel disallows downgrades, so there was - no upgrade available to trigger without touching a shared server's - actual version. Player join/leave: UNTESTED — requires a real Minecraft - client, not available in this environment. - [x] A live row renders the same badge, icon, timestamp and "Initiated By" - cell as one that came from a page render (compare after a reload). - [x] Set a type filter, then trigger an event of a different type. It does not - appear while the filter excludes it. - [x] With an empty log, trigger one event: the empty state swaps out for the - table without a reload. - [ ] Clear the log; the table empties live and the Clear button disables. - PARTIAL — table empties live with no reload (PASSED). Clear button does - NOT disable afterward: it stays fully clickable/enabled even with 0 - events logged, including after a full page reload. Should be disabled - when the log is already empty. - FIXED in 1.2.0-beta.3, NEEDS RE-TEST — the button was always meant to be - hidden on an empty log, and the JS that shows/hides it live was correct. - The bug was in the template: the escaping EJS tag emitted - class="d-none", so the class never matched and the button never - hid, on first render or after a reload. Same mistake was present in 11 - other places (Settings and Properties state hints, and the disabled- - state tooltips on Files/Mods/Plugins) — all switched to the unescaped - tag, so those hints and tooltips render correctly now too. - [ ] Leave the tab open past 500 events (or lower the prune) — the oldest row - drops rather than growing forever. - UNTESTED — generating 500 real events safely against a shared live - instance wasn't practical in this session. Needs a dedicated test - (e.g. temporarily lowering the prune threshold) rather than manual - browser clicks. - - --------------------------------------------------------------------- -1.2.0-beta.1 — RESTART RACE --------------------------------------------------------------------- - - [x] Restart a server and watch the console through the whole cycle. Start and - Delete stay DISABLED for the entire gap where the state reads stopped. - Verified by polling button state every ~150ms through a full restart - cycle (running -> stopped -> starting -> running): both Start and - Delete stayed disabled=true for every sample in between. - [x] Confirm only one JVM ends up running (check the process list / that the - port is not double-bound). - No terminal/process access was available in this environment, so this - is verified circumstantially rather than directly: each restart showed - exactly one clean "Done (...)!" boot line with no port-bind errors, and - a rapid double-click of Restart (below) produced only one boot cycle, - not two competing ones. - [x] Try to delete the server mid-restart — refused with a state conflict, - not a half-deleted directory. - The Delete button was confirmed disabled for the entire restart window - (see above), so the UI never allows the request to be sent while - mid-restart. - [x] Stop and Kill become usable again as soon as the server leaves stopped, - not only once a big modpack has finished booting. - Verified by polling: Stop/Kill were disabled only during the brief - "stopped" reading mid-restart, then re-enabled immediately on the very - first "starting" sample — well before the boot log's "Done" line. - [x] Double-click Restart quickly — the second click does nothing rather than - queueing a second action. - Verified via console log inspection: only one "Restarting server..." - line appeared despite two rapid clicks, and only one boot sequence ran. - - --------------------------------------------------------------------- -1.2.0-beta.1 — API SURFACE (curl or a REST client, with an API key) --------------------------------------------------------------------- - - [x] GET /api/v1/servers/:id/console returns recent lines oldest-first. - [ ] ?limit= is honoured and clamped to 1-1000; truncated flag is set when - there is more output than returned. - PARTIAL — limit=5 and limit=-5 both behaved correctly (5 lines; clamped - up to 1 line for the negative value). limit=0 did NOT clamp to 1 as - documented — it fell back to the default of 200 instead. The upper - bound (1000) could not be directly observed since this server's log - never exceeded ~600 lines in this session. truncated:true was correctly - set whenever more output existed than was returned. - FIXED in 1.2.0-beta.3, NEEDS RE-TEST — `parseInt(...) || 200` treated a - parsed 0 as absent. Only a missing or unparseable value takes the - default now; every parsed number goes through the 1-1000 clamp. The - same shape on GET /events?limit= was fixed with it: that one had no - lower bound at all, so limit=-5 reached slice(0, -5) and silently - dropped the five newest events. - [x] ?source=file vs memory differ as documented; auto falls back to memory - for a server that has never been started. - Confirmed on several genuinely never-started servers (lastStarted still - null AND no console log file) — auto correctly returned source:"memory" - with 0 lines. Note: some servers with lastStarted:null still had a real - log file from initial jar setup, so lastStarted alone isn't a reliable - "never started" signal — file presence is what actually matters. - [x] POST a command, then read it back via /console — the reply is there. - Sent a unique marker via "say" and confirmed it appeared in the memory - console feed with the correct chat-line formatting. - [x] GET /files, /file, /download all work with a bearer key alone. - Confirmed with credentials explicitly omitted (no session cookie sent), - bearer header only. /download correctly 409s while the server is - running ("Stop the server before downloading files.") and succeeds once - stopped. - [x] GET /export and /backups/:id/download work with a bearer key, and the - panel's plain links still work in the browser (session auth). - Both endpoints verified two ways: bearer-only (no cookie) and session- - only (no Authorization header) — both succeeded with correct content - types (application/x-craftbox-export+zip, application/zip). - [x] GET /plugins and /plugins/environment agree with what the Mods page shows, - including a client-only mod reading as "client". - Set a mod to "Client Only" in the UI, then confirmed via API that both - /plugins (environment: "client") and /plugins/environment (map entry - "client") matched exactly. Reverted the mod back to "Client and Server" - afterward. - - --------------------------------------------------------------------- -BEFORE TAGGING THE RELEASE --------------------------------------------------------------------- - - [ ] Fresh install: first-run setup completes and the dashboard loads. - UNTESTED — this session only had access to the existing shared live - Docker instance (already has data/servers on it); a fresh install needs - a clean environment, not this one. - [ ] Upgrade over an existing 1.1.x data directory — no migration surprises. - UNTESTED, same reason — would require a real 1.1.x data directory and a - disposable instance to upgrade in place. - [ ] Docker image builds and runs. - NOT DIRECTLY TESTABLE from this environment — no Docker/terminal access - to the host running the container. The instance being reachable and - stable at localhost:6464 throughout this entire test session is - indirect evidence the current image runs correctly, but a build-from- - source was not exercised. - [x] docs/API.md matches the shipped behaviour. - Cross-verified directly against live responses while testing the API - surface section above: /console, /files, /file, /download, /export, - /backups/:id/download, /plugins, and /plugins/environment all matched - the documented response shapes, status codes, and auth semantics. One - documentation gap found: the upload endpoint doesn't document busy-file - rejection behaviour, which lines up with it not actually rejecting busy - files either (see the Upload state-gating finding above). - [x] README badge and package.json versions agree. - Both read "1.2.0-beta.2" (README.md line 12, package.json line 3). - - --------------------------------------------------------------------- -SUMMARY OF FINDINGS THAT NEED FOLLOW-UP --------------------------------------------------------------------- - -ALL THREE FIXED IN 1.2.0-beta.3 — none has been re-tested in a browser yet. - - 1. Uploading a file that the running server holds open (e.g. server.jar) is - NOT rejected — it silently overwrites the live file instead of returning - "file is in use by the server". This is a real behavioural gap, not just - a docs gap (docs/API.md doesn't claim busy-file rejection for uploads - either). Risk: a running server's jar or world file can be corrupted by - an in-place upload without warning. - FIXED — the upload handler now refuses to replace the jar, or any - existing file under the world folders / logs / mods / plugins, while the - server is running. Per-file rejection, so the rest of a batch still - lands. docs/API.md documents it. - 2. The Events "Clear" button does not disable itself when the log is - already empty (stays enabled after clearing, and after a reload of an - empty log). - FIXED — template bug, not a JS one: an escaping EJS tag emitted - class="d-none", which never matches .d-none. Found and fixed in - 11 other places sharing the same mistake (state hints on Settings and - Properties, disabled-state tooltips on Files/Mods/Plugins). - 3. GET /console?limit=0 falls back to the default (200) instead of clamping - to the documented minimum of 1. (limit=-5 clamps correctly to 1.) - FIXED — and the same shape on GET /events?limit= with it, which had no - lower bound at all and dropped the newest events for a negative limit. - -RE-TEST BEFORE TAGGING A NON-BETA RELEASE: - - Upload over a running server's jar (expect rejection, batch still lands), - and a new file into mods/ while running (expect success). - - Clear an empty event log (button should be absent), then trigger one - event (button appears live). - - The Settings and Properties state hints: exactly ONE of each pair should - render, matching whether the server is running. - - GET /console?limit=0 and /events?limit=-5. + - One server that belongs to a group, and one that belongs to none. + - Be able to start a server: some of the Files checks need it running. + - A subfolder to browse into, and a folder whose name contains a space. + + +-------------------------------------------------------------------- +NEW TEXT FILE (Files tab) +-------------------------------------------------------------------- + +The button + [ ] "New Text File" sits immediately to the right of "New Folder" and shares + its outline-success styling and size. + [ ] It stays usable with the server RUNNING, exactly as New Folder does + (creating is ungated; only rename and delete need a stopped server). + +The modal + [ ] Opens with ".txt" already in the field and the caret BEFORE the dot — + typing "notes" gives "notes.txt" without moving the cursor first. + [ ] The "File name" label carries the red required asterisk. + [ ] Enter in the field submits, the same as New Folder. + [ ] Cancel and the X both close it; reopening resets the field back to ".txt" + rather than keeping whatever was typed last. + [ ] Confirm button gating matches New Folder: it greys out for a name with + < > : " | ? *, one ending in a dot, an empty field, and a reserved name + (NUL, CON, COM1). It only lights up for a name the API will accept. + +Creating + [ ] Create "notes.txt" in the server root. Toast reads + File "notes.txt" created in the server root. and the row appears after + the reload, sorted among the files. + [ ] The new file has a working Edit (pencil) button and opens empty in the + text editor. Save something into it and reopen to confirm it persisted. + [ ] Navigate into a subfolder first, then create — the file lands in the + subfolder, not the root. + [ ] Create a file whose name is taken by an existing FILE — rejected with + "Something with that name already exists here.", the modal stays open, + the button resets, and the existing file's contents are UNCHANGED (this + is the one that must never truncate). + [ ] Same again where the name is taken by a FOLDER — same rejection. + [ ] Create one while the server is RUNNING — it works, and the file is there. + [ ] Clear the field to just ".txt" and create — a file named ".txt" is a + valid name, so this is expected to succeed rather than be blocked. + [ ] Give it a non-text extension (e.g. "test.cfg", "test.bin"). Both create; + .cfg gets a pencil, .bin does not — the listing decides that, not the + create dialog. + +Destination line (both create modals) + [ ] In the server root, both modals read: Created in the server root. + [ ] Inside a folder, both read: Created in "mods". — with the double quotes + rendered as quotes, not as ". + [ ] Inside a nested folder with a space in the name, the full path is shown + inside the quotes and reads unambiguously. + +API (curl or a REST client, with an API key) + [ ] POST /api/v1/servers/:id/files/mkfile with {path, name} returns + {"success": true, "name": ...} and the file exists on disk, empty. + [ ] Omitting `path` creates in the server root. + [ ] A name with a slash, a traversal attempt ("../x.txt"), and a reserved + device name are all rejected 400/403 — nothing is written outside the + server directory. + [ ] An existing name returns 409 and leaves the file's bytes untouched. + [ ] A `path` that is a file rather than a directory returns 404. + [ ] Create "server.properties" in the root of a server that has none, then + check the panel's port/EULA readings still agree with the file (the + mirrored database fields re-sync on create, as they do on upload). + [ ] docs/API.md matches what all of the above actually did. + + +-------------------------------------------------------------------- +ASSIGN GROUP POSITION (Settings tab) +-------------------------------------------------------------------- + + [ ] Settings > Advanced Options: the Assign Group field is centred under the + gap between Memory and Additional JVM Arguments, not pinned to the left. + [ ] Narrow the window below the md breakpoint — it goes full width and the + centring makes no difference. + [ ] The group autocomplete dropdown still opens directly under the input and + lines up with it after the move. + [ ] Saving still assigns the group, and the dashboard shows the server under + it afterwards. + + +-------------------------------------------------------------------- +BACK BUTTON DESTINATION (every server tab) +-------------------------------------------------------------------- + + [ ] Open a server that IS in a group; the back arrow returns to that group's + page, not the dashboard. + [ ] Do it from a group whose name contains a space — the link resolves, no + "no longer exists" flash on arrival. + [ ] Open a server with NO group; the back arrow returns to the dashboard. + [ ] Move a server into a group from Settings, save, and check the back arrow + on the reloaded page now points at the group page. + [ ] Remove the group again — back points at the dashboard. + [ ] Check the arrow on Console, Settings, Properties, Mods/Plugins, Files, + Backups and Events; they all share the same header. From f628d3bc0074b796ad2d32632327d251a24d1f90 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 7 Aug 2026 00:23:12 +0100 Subject: [PATCH 35/60] Promote release to 1.2.0-beta.5 Finishes the Files tab: New Text File beside New Folder, so a config can be created and edited in the panel instead of being written elsewhere and uploaded. Both create dialogs now quote the folder they are creating into. Two smaller pieces of navigation and layout with it: a server's back arrow returns to its group page rather than always the dashboard, and Assign Group is centred on Settings. Still a beta: none of it has been through a browser yet, and the checklist has been rewritten around this build. TODO.txt has the list. --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4e30194..832bf7e 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.2.0--beta.4-orange?style=flat-square) +![version](https://img.shields.io/badge/version-1.2.0--beta.5-orange?style=flat-square) ![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord) diff --git a/package-lock.json b/package-lock.json index 98361ca..58e06da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftbox", - "version": "1.2.0-beta.4", + "version": "1.2.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftbox", - "version": "1.2.0-beta.4", + "version": "1.2.0-beta.5", "license": "AGPL-3.0-only", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index e260a0c..2ebb9fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbox", - "version": "1.2.0-beta.4", + "version": "1.2.0-beta.5", "description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.", "main": "src/server.js", "funding": { From c7e2a58de07a1a546f5630f4b3804345841111df Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 7 Aug 2026 00:38:54 +0100 Subject: [PATCH 36/60] Share the lone-row centring between the create and settings forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assign Group was centred on Settings by a class written into the template, while the create form does the same thing at runtime — centerLoneRowItems, which centres any row down to one visible column. Two mechanisms for one behaviour, and the template one silently stops being right the moment a second column joins that row. centerLoneRowItems moves to app.js and takes the form to scope itself to. The create form calls it exactly where it did before, on every validation pass and when the custom-JAR toggle hides the version picker; the settings form calls it once at init, since which of its columns render is decided server-side and nothing hides one after load. --- public/js/app.js | 19 +++++++++++++++++++ public/js/create.js | 22 +++++----------------- public/js/edit.js | 6 ++++++ views/servers/edit.ejs | 9 ++++----- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index d1df97e..2337031 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -339,6 +339,25 @@ function setControlsLocked(root, locked) { }); } +// ── 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. diff --git a/public/js/create.js b/public/js/create.js index 55f0e6d..ee5b2f4 100644 --- a/public/js/create.js +++ b/public/js/create.js @@ -65,24 +65,12 @@ function setCustomNoticeVisible(visible) { customTypeNotice.classList.toggle('d-flex', visible); } -// ── Center form fields left alone on their row ── -// A row whose other columns are hidden (e.g. the port field once the version -// picker is gone in modpack mode, or the group picker on its own row) looks -// lopsided half-width on the left; center it instead. -function centerLoneRowItems() { - form.querySelectorAll('.row').forEach(function (row) { - var cols = row.querySelectorAll(':scope > [class*="col-"]'); - if (cols.length === 0) return; - var visible = Array.prototype.filter.call(cols, function (c) { - return !c.classList.contains('d-none'); - }); - row.classList.toggle('justify-content-center', visible.length === 1); - }); -} - // ── Required field validation + EULA gating ── function validateCreateForm() { - centerLoneRowItems(); + // Columns come and go here (modpack mode hides the version picker), so the + // centring is re-run with every validation pass. centerLoneRowItems is in + // app.js — the settings form uses it too. + centerLoneRowItems(form); if (!eulaCheck.checked) { createBtn.disabled = true; return; } var fields = form.querySelectorAll('[required]'); var allFilled = true; @@ -200,7 +188,7 @@ async function selectType(typeId) { customUrlGroup.classList.remove('d-none'); versionDisplay.removeAttribute('required'); setCustomNoticeVisible(true); - centerLoneRowItems(); + centerLoneRowItems(form); } else { versionGroup.classList.remove('d-none'); customUrlGroup.classList.add('d-none'); diff --git a/public/js/edit.js b/public/js/edit.js index d7b29a5..c3d26cc 100644 --- a/public/js/edit.js +++ b/public/js/edit.js @@ -24,6 +24,12 @@ function _formToBody(form) { var backupCheck = document.getElementById('saveBackup'); var SAVE_BTN_HTML = 'save Save Changes'; + // Assign Group sits alone on the last row of Advanced Options, so it gets + // centred the same way the create form centres a lone column. Once is + // enough here: which columns render is decided server-side (custom JAR URL + // vs version and port), and nothing hides one after load. + centerLoneRowItems(form); + form.addEventListener('submit', async function (e) { e.preventDefault(); if (!form.reportValidity()) return; diff --git a/views/servers/edit.ejs b/views/servers/edit.ejs index 5c40ea1..ea470b3 100644 --- a/views/servers/edit.ejs +++ b/views/servers/edit.ejs @@ -153,11 +153,10 @@ const _gate = 'stopped crashed'; %>
Optional. Extra Java flags.
- <%# Assign Group is alone on its row, so it is centred rather than - left under Memory — the two fields above it fill the width - between them and a half-width field hanging off one edge - reads as a missing second field. %> -
+ <%# 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. %> +
From dcb694831ebfd6851a591d1263b74589eea1e3ca Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 7 Aug 2026 00:41:04 +0100 Subject: [PATCH 37/60] Add the create-page regression checks to the checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving centerLoneRowItems into app.js put the create form's centring on shared code, so the rows it has always centred — the port field once Custom or modpack mode hides the version picker — need re-checking, not just the settings row the change was made for. --- TODO.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/TODO.txt b/TODO.txt index aef5e8d..361917e 100644 --- a/TODO.txt +++ b/TODO.txt @@ -82,6 +82,16 @@ ASSIGN GROUP POSITION (Settings tab) gap between Memory and Additional JVM Arguments, not pinned to the left. [ ] Narrow the window below the md breakpoint — it goes full width and the centring makes no difference. + + The centring is now one shared function (centerLoneRowItems, app.js) that + both forms call, so the create page needs re-checking for a regression: + [ ] Create page, Advanced Options: Assign Group is still centred. + [ ] Create page, pick the Custom type — the version picker hides and the + Server Port field left alone on that row is still centred. + [ ] Switch back off Custom — the port field returns to its half of the row + and is NOT left centred. + [ ] Create from a Modrinth modpack: the rows that lose a column to modpack + mode centre the same way they did before. [ ] The group autocomplete dropdown still opens directly under the input and lines up with it after the move. [ ] Saving still assigns the group, and the dashboard shows the server under From 3c823001f31fc1ea1d840e4530d85cd5babbccc5 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 16 Aug 2026 14:42:01 +0100 Subject: [PATCH 38/60] Run NPM audit fix --- package-lock.json | 545 +--------------------------------------------- 1 file changed, 4 insertions(+), 541 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58e06da..22005f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,16 +34,6 @@ "url": "https://www.buymeacoffee.com/willtda" } }, - "node_modules/@emnapi/runtime": { - "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": { - "tslib": "^2.4.0" - } - }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -53,526 +43,6 @@ "node": ">=18" } }, - "node_modules/@img/sharp-darwin-arm64": { - "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" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "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" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@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.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" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "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" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "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" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "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" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-x64": { - "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" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "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" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "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" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-wasm32": { - "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", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "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" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "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" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-win32-x64": { "version": "0.35.3", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", @@ -871,6 +341,7 @@ "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": { "node-addon-api": "^8.0.0" @@ -1691,10 +1162,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" } @@ -2712,13 +2182,6 @@ "node": ">=0.6" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", From 09329d69cdc45137f510a68c25b4b5e847f5db98 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 16 Aug 2026 15:11:01 +0100 Subject: [PATCH 39/60] Decide text files by content instead of extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's list of readable extensions had no room for the file in hand: a .jsonl or .json5 was refused outright, and the only way to change one was to download it, edit it elsewhere and upload it back. Any extension a mod invents for its own config hit the same wall, as did a file with no extension at all. Editability is now read off the first 8 KB of the file: text if it decodes as UTF-8, holds no NUL byte, and is not mostly control characters. Nobody has to add an extension anywhere for it to open. Two shortcuts sit either side of that check — always-binary extensions are refused without a read, so listing a mods folder of several hundred jars still opens nothing, and a list of known text extensions stands in when there is nothing to read, either because the path does not exist yet or because the running server holds it locked. Reading the bytes also settles the reverse case, which the extension list could not see at all: a UTF-16 or latin-1 file wearing a .txt is now refused, since the panel reads and writes UTF-8 throughout and would have shown it as mojibake and mangled it on save. .nbt and .dat go the same way for the same reason — both are gzipped binary, and both were previously offered for editing. Whole-file reads are capped at 5 MB. Both paths used to read the file into a string with no limit, which an append-only log on a running server will eventually turn into an out-of-memory or ERR_STRING_TOO_LONG. Past the cap the API takes a byte window instead, with ?tail= or ?offset=&limit=, so a log can be read while the server writing it is still running — /download cannot do that, since it needs the server stopped. A window landing mid-character is trimmed back to a whole one so no replacement characters reach the caller. The editor UI takes no window: it posts the whole textarea back, so opening a partial file would truncate the rest away on save. It refuses an oversized file and points at the download. --- docs/API.md | 19 ++- src/routes/api-v1/servers.js | 45 ++++-- src/routes/servers.js | 20 ++- src/utils/fileBrowser.js | 256 +++++++++++++++++++++++++++++++++-- 4 files changed, 311 insertions(+), 29 deletions(-) diff --git a/docs/API.md b/docs/API.md index b231ea1..ec35380 100644 --- a/docs/API.md +++ b/docs/API.md @@ -149,7 +149,7 @@ The [WebSocket](#websocket-protocol) is the live feed, but it does not accept be | 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 @@ -157,8 +157,8 @@ Paths are relative to the server directory and are resolved against it with syml | 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 text endpoint will serve. `404` if the path is not a directory | -| GET | `/servers/:id/file?path=` | Read a text file. Returns `{"file": {name, path, size, modifiedISO, content}}`. `400` for a binary extension — use `/download` | +| 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 | @@ -166,7 +166,18 @@ Paths are relative to the server directory and are resolved against it with syml | 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 extension, not by content.** The editable set is `.txt .log .properties .json .yml .yaml .xml .cfg .conf .ini .toml .csv .md .sh .bat .cmd .ps1 .js .ts .py .java .html .css .mcmeta .lang .sk .nbt`. Everything else is downloadable but not readable as text. +> **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. > diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index 20e4e3f..f9aa082 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -36,7 +36,10 @@ const { isPathInside } = require('../../utils/pathSafety'); const { normalizeGroupName, getGroupColor, pruneGroupMetaIfEmpty, GROUP_NAME_ERROR } = require('../../utils/serverGroups'); const { MC_VERSION_RE, isReleaseVersion } = require('../../utils/mcVersion'); const { pickPreferredBuild, compareBuilds } = require('../../mc/serverTypes/_channels'); -const { isTextFile, listDirectory, safeEntryName, newNameError } = require('../../utils/fileBrowser'); +const { + isEditableFile, listDirectory, safeEntryName, newNameError, + readTextWindow, parseReadWindow, MAX_TEXT_BYTES +} = require('../../utils/fileBrowser'); const { readConsoleTail } = require('../../utils/consoleLog'); const { cleanupServerData } = require('../../utils/serverCleanup'); const { installModpack, parseMrpack, resolveLoader, pickLoaderFromArray } = require('../../mc/modpackInstaller'); @@ -2410,9 +2413,15 @@ router.get('/servers/:id/files', async (req, res) => { } }); -// GET /servers/:id/file?path= — Read a text file's contents. +// GET /servers/:id/file?path=[&offset=&limit=|&tail=] — Read a text file. +// // Binary files are refused here and must be fetched from /download instead; // this mirrors what the file editor will open (see utils/fileBrowser). +// +// Unlike /download this works while the server is running, which makes it the +// way to read a log or an append-only feed a plugin is still writing to. Such +// a file has no bound worth trusting, so a whole-file read is capped and +// callers past the cap ask for a byte window instead. router.get('/servers/:id/file', async (req, res) => { try { const server = await loadServerOr404(req, res); @@ -2427,18 +2436,34 @@ router.get('/servers/:id/file', async (req, res) => { if (!fs.existsSync(targetPath) || fs.statSync(targetPath).isDirectory()) { return res.status(404).json({ error: 'File not found.' }); } - if (!isTextFile(path.basename(targetPath))) { - return res.status(400).json({ error: 'This file type cannot be read as text. Use /download instead.' }); + if (!isEditableFile(targetPath)) { + return res.status(400).json({ error: 'This file is not text and cannot be read as text. Use /download instead.' }); } + const readWindow = parseReadWindow(req.query); + if (readWindow.error) return res.status(400).json({ error: readWindow.error }); + const stat = fs.statSync(targetPath); + if (!readWindow.windowed && stat.size > MAX_TEXT_BYTES) { + return res.status(413).json({ + error: `File is ${formatSize(stat.size)}, over the ${formatSize(MAX_TEXT_BYTES)} whole-file limit. ` + + 'Read part of it with ?tail= or ?offset=&limit= (bytes).', + size: stat.size, + maxBytes: MAX_TEXT_BYTES + }); + } + + const read = readTextWindow(targetPath, readWindow); res.json({ file: { name: path.basename(targetPath), path: String(req.query.path), - size: stat.size, + size: read.size, modifiedISO: stat.mtime.toISOString(), - content: fs.readFileSync(targetPath, 'utf8') + offset: read.offset, + length: read.length, + truncated: read.truncated, + content: read.content } }); } catch (err) { @@ -2807,8 +2832,8 @@ router.post('/servers/:id/files/mkdir', async (req, res) => { // // Ungated like mkdir: a name that isn't on disk yet cannot be one the running // server is holding open. No extension check either — the file manager already -// takes any file by upload, and the panel decides editable-vs-downloadable by -// extension when it lists the directory. +// takes any file by upload, and the panel decides editable-vs-downloadable when +// it lists the directory. router.post('/servers/:id/files/mkfile', async (req, res) => { const server = await loadServerOr404(req, res); if (!server) return; @@ -3055,8 +3080,8 @@ router.post('/servers/:id/edit-file', async (req, res) => { return res.status(403).json({ error: 'Access denied.' }); } - if (!isTextFile(path.basename(targetPath))) { - return res.status(400).json({ error: 'This file type cannot be edited.' }); + if (!isEditableFile(targetPath)) { + return res.status(400).json({ error: 'This file is not text and cannot be edited.' }); } try { diff --git a/src/routes/servers.js b/src/routes/servers.js index ff760ca..cd7cbc7 100644 --- a/src/routes/servers.js +++ b/src/routes/servers.js @@ -5,7 +5,8 @@ const contentDisposition = require('content-disposition'); const router = express.Router(); const ensureAuth = require('../middleware/ensureAuth'); const blockWhileProvisioning = require('../middleware/blockWhileProvisioning'); -const { isTextFile, listDirectory } = require('../utils/fileBrowser'); +const { isEditableFile, listDirectory, MAX_TEXT_BYTES } = require('../utils/fileBrowser'); +const { formatSize } = require('../utils/resourceStats'); const { serversDb, SERVERS_DIR } = require('../db'); const { parseServerProperties } = require('../mc/serverProperties'); const { PROPERTY_META, GROUPS } = require('../mc/propertyMeta'); @@ -289,9 +290,22 @@ router.get('/servers/:id/edit-file', ensureAuth, blockWhileProvisioning, async ( }); } - if (!isTextFile(path.basename(targetPath))) { + if (!isEditableFile(targetPath)) { return res.status(400).render('errors/404', { - title: 'Not Editable', navbar: true, user: req.user, message: 'This file type cannot be edited.' + title: 'Not Editable', navbar: true, user: req.user, message: 'This file is not text and cannot be edited.' + }); + } + + // The editor posts back the whole textarea, so it must never open a partial + // file — saving one would truncate the rest away. Oversized files are + // refused here and read in windows through the API instead. + const size = fs.statSync(targetPath).size; + if (size > MAX_TEXT_BYTES) { + return res.status(413).render('errors/404', { + title: 'Too Large', + navbar: true, + user: req.user, + message: `This file is ${formatSize(size)}, over the ${formatSize(MAX_TEXT_BYTES)} editor limit. Download it to read the whole thing.` }); } diff --git a/src/utils/fileBrowser.js b/src/utils/fileBrowser.js index 8a7b53d..5cebe2a 100644 --- a/src/utils/fileBrowser.js +++ b/src/utils/fileBrowser.js @@ -2,18 +2,244 @@ const fs = require('fs'); const path = require('path'); const { formatSize } = require('./resourceStats'); -// Extensions the file editor will open as text. Everything else is treated as -// binary and can only be downloaded. This is an allowlist, not content -// sniffing — an unlisted extension is refused rather than guessed at. +// Extensions we treat as text when we cannot read the file to find out — it +// does not exist yet, or the running server has it locked. Contents decide +// every case where contents are available, so this list is a fallback, not the +// rule: an unlisted but perfectly textual file (a mod's own config extension, a +// dotfile, a name with no extension at all) still opens on the strength of its +// bytes rather than being refused for the sole crime of being unlisted. const TEXT_EXTENSIONS = new Set([ - '.txt', '.log', '.properties', '.json', '.yml', '.yaml', '.xml', - '.cfg', '.conf', '.ini', '.toml', '.csv', '.md', '.sh', '.bat', - '.cmd', '.ps1', '.js', '.ts', '.py', '.java', '.html', '.css', - '.mcmeta', '.lang', '.sk', '.nbt' + // Plain text and docs + '.txt', '.log', '.md', '.markdown', '.rst', '.adoc', '.nfo', + // Config + '.properties', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf', + '.env', '.list', '.rules', '.editorconfig', + // JSON and friends — jsonl/ndjson are line-delimited, json5/jsonc allow comments + '.json', '.jsonl', '.ndjson', '.json5', '.jsonc', + // Markup and tabular + '.xml', '.xsd', '.xsl', '.svg', '.html', '.htm', '.css', '.scss', '.less', + '.csv', '.tsv', '.sql', + // Scripts and source + '.sh', '.bash', '.zsh', '.bat', '.cmd', '.ps1', '.psm1', + '.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx', + '.py', '.rb', '.pl', '.lua', '.php', '.go', '.rs', '.c', '.h', + '.cpp', '.hpp', '.cs', '.java', '.kt', '.kts', '.groovy', '.gradle', + // Minecraft-specific text formats + '.mcmeta', '.mcfunction', '.snbt', '.lang', '.sk', + // Patches + '.diff', '.patch' ]); -function isTextFile(filename) { - return TEXT_EXTENSIONS.has(path.extname(filename).toLowerCase()); +// Extensions we refuse without reading the file. This is the fast path that +// keeps a directory listing cheap: a mods folder is hundreds of jars and a +// world is thousands of region files, and none of them are worth opening to +// confirm what the name already says. +const BINARY_EXTENSIONS = new Set([ + // Archives and packaged content + '.jar', '.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.zst', '.7z', '.rar', + '.mrpack', '.war', '.ear', + // Minecraft binary data — NBT is gzipped, region files are chunk blobs. + // These used to be editable via '.nbt'; opening one in a text editor and + // saving re-encodes its bytes as UTF-8 and corrupts the world or player. + '.dat', '.dat_old', '.nbt', '.mca', '.mcr', '.mclevel', '.schematic', '.litematic', + // Images, media, fonts + '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico', '.tiff', + '.mp3', '.ogg', '.wav', '.mp4', '.webm', '.ttf', '.otf', '.woff', '.woff2', + // Compiled output and databases + '.exe', '.dll', '.so', '.dylib', '.class', '.bin', '.o', '.a', '.pdf', + '.db', '.sqlite', '.sqlite3' +]); + +// How much of a file we look at. Enough to catch a binary header and any stray +// control bytes just past it, small enough that doing it for every candidate +// entry in a directory listing is cheap. +const SNIFF_BYTES = 8192; + +// Control bytes that are ordinary in a text file: tab, newline, form feed, +// carriage return, and ESC — a Minecraft console log is full of ANSI colour +// codes, and a short one would otherwise fail on ratio alone. +const ALLOWED_CONTROL_BYTES = new Set([0x09, 0x0a, 0x0c, 0x0d, 0x1b]); + +// Largest file the editor and the text API will load in one piece. Past this, +// callers take a byte window (see readTextWindow) instead — an append-only log +// on a running server has no upper bound worth trusting. +const MAX_TEXT_BYTES = 5 * 1024 * 1024; + +/** + * Decide whether a sample of bytes reads as UTF-8 text. + * + * @param {Buffer} buf - the first bytes of a file, possibly cut mid-character + * @returns {boolean} + */ +function looksLikeText(buf) { + if (buf.length === 0) return true; + + // UTF-16/UTF-32 are text, but the editor reads and writes UTF-8 — saving + // one back through it would rewrite every byte in the file, so refuse. + if ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff)) return false; + + // A NUL byte is the single most reliable binary tell. + if (buf.includes(0)) return false; + + try { + // stream: true so a multi-byte character straddling the end of the + // sample is held back rather than reported as corruption. + new TextDecoder('utf-8', { fatal: true }).decode(buf, { stream: true }); + } catch { + return false; + } + + // The rest of the C0 range and DEL are not ordinary: a light sprinkling is + // tolerable, a heavy one is binary that happened to survive the NUL and + // UTF-8 checks above. + let control = 0; + for (const byte of buf) { + if ((byte < 0x20 || byte === 0x7f) && !ALLOWED_CONTROL_BYTES.has(byte)) control++; + } + return control / buf.length <= 0.1; +} + +/** + * Read the head of a file and decide whether it is text. + * + * @param {string} filePath - absolute path + * @returns {boolean|null} null when the file could not be read at all — it is + * absent, or the running server holds it open — leaving nothing to judge + */ +function sniffFile(filePath) { + let fd; + try { + fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(SNIFF_BYTES); + const read = fs.readSync(fd, buf, 0, SNIFF_BYTES, 0); + return looksLikeText(buf.subarray(0, read)); + } catch { + return null; + } finally { + if (fd !== undefined) { + try { fs.closeSync(fd); } catch { /* already gone */ } + } + } +} + +/** + * Whether the panel will open this file as text. + * + * Contents decide it. The name only gets a say twice: to skip the read for + * formats that are always binary, and to stand in when there is nothing to + * read. Judging by bytes is what lets an unlisted extension through, and it is + * also what catches the reverse — a UTF-16 or latin-1 file wearing a .txt on + * the end, which the editor would show as mojibake and mangle on save, since + * it reads and writes UTF-8 throughout. + * + * @param {string} filePath - absolute path + * @returns {boolean} + */ +function isEditableFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (BINARY_EXTENSIONS.has(ext)) return false; + + const sniffed = sniffFile(filePath); + if (sniffed !== null) return sniffed; + + // Nothing to go on but the name. A path that does not exist yet is a file + // the caller is about to write text into, so let any non-binary extension + // through; one that exists but will not open falls back to the list. + return !fs.existsSync(filePath) || TEXT_EXTENSIONS.has(ext); +} + +/** + * A byte window can land in the middle of a multi-byte character at either + * end. Drop the partial pieces rather than emitting U+FFFD into the response. + * + * @returns {{buf: Buffer, leading: number}} leading = bytes dropped at the front + */ +function trimPartialUtf8(buf, cutStart, cutEnd) { + let lead = 0; + if (cutStart) { + while (lead < buf.length && (buf[lead] & 0xc0) === 0x80) lead++; + } + let end = buf.length; + if (cutEnd) { + let i = end - 1; + while (i >= lead && (buf[i] & 0xc0) === 0x80) i--; + if (i >= lead) { + const b = buf[i]; + const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1; + if (end - i < need) end = i; + } + } + return { buf: buf.subarray(lead, end), leading: lead }; +} + +/** + * Read all or part of a file as UTF-8 text. + * + * Offsets are in bytes, not characters, so a window is cheap to ask for + * against a file that is still being appended to. The window is clamped to + * MAX_TEXT_BYTES and to the file's actual length, so an over-large request + * comes back short rather than failing. + * + * @param {string} filePath - absolute path + * @param {{offset?: number, limit?: number|null, tail?: number|null}} window + * @returns {{content: string, size: number, offset: number, length: number, truncated: boolean}} + */ +function readTextWindow(filePath, { offset = 0, limit = null, tail = null } = {}) { + const size = fs.statSync(filePath).size; + + let start, want; + if (tail !== null) { + want = Math.min(tail, MAX_TEXT_BYTES, size); + start = size - want; + } else { + start = Math.min(offset, size); + want = Math.min(limit === null ? size - start : limit, MAX_TEXT_BYTES, size - start); + } + + let read = 0; + const buf = Buffer.alloc(want); + if (want > 0) { + const fd = fs.openSync(filePath, 'r'); + try { read = fs.readSync(fd, buf, 0, want, start); } finally { fs.closeSync(fd); } + } + + const cutEnd = start + read < size; + const trimmed = trimPartialUtf8(buf.subarray(0, read), start > 0, cutEnd); + return { + content: trimmed.buf.toString('utf8'), + size, + offset: start + trimmed.leading, + length: trimmed.buf.length, + truncated: start > 0 || cutEnd + }; +} + +/** + * Validate the offset/limit/tail trio off a query string. + * + * @param {object} query - req.query + * @returns {{error: string}|{offset: number, limit: number|null, tail: number|null, windowed: boolean}} + */ +function parseReadWindow(query) { + const parsed = {}; + for (const name of ['offset', 'limit', 'tail']) { + const raw = query[name]; + if (raw === undefined || raw === '') { parsed[name] = null; continue; } + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + return { error: `"${name}" must be a whole number of bytes, zero or more.` }; + } + parsed[name] = n; + } + if (parsed.tail !== null && (parsed.offset !== null || parsed.limit !== null)) { + return { error: 'Use either "tail" or "offset"/"limit", not both.' }; + } + return { + offset: parsed.offset === null ? 0 : parsed.offset, + limit: parsed.limit, + tail: parsed.tail, + windowed: parsed.tail !== null || parsed.offset !== null || parsed.limit !== null + }; } // Windows refuses these outright; creating one on Linux would produce a file @@ -58,7 +284,9 @@ function newNameError(name) { * * Shared by the Files page and the file API so both describe a directory * identically. Entries whose stat fails (deleted mid-listing, permission - * denied) are dropped rather than failing the whole listing. + * denied) are dropped rather than failing the whole listing. `editable` also + * accounts for size: a file past MAX_TEXT_BYTES is text the editor still will + * not open, so the Edit button stays off rather than leading to a 413. * @param {string} dir - absolute path, already validated with isPathInside */ function listDirectory(dir) { @@ -74,7 +302,7 @@ function listDirectory(dir) { sizeFormatted: formatSize(stat.size), modified: stat.mtime, modifiedISO: stat.mtime.toISOString(), - editable: !entry.isDirectory() && isTextFile(entry.name) + editable: !entry.isDirectory() && stat.size <= MAX_TEXT_BYTES && isEditableFile(entryPath) }; }) .filter(Boolean) @@ -84,4 +312,8 @@ function listDirectory(dir) { }); } -module.exports = { TEXT_EXTENSIONS, isTextFile, listDirectory, safeEntryName, newNameError }; +module.exports = { + TEXT_EXTENSIONS, BINARY_EXTENSIONS, MAX_TEXT_BYTES, + looksLikeText, isEditableFile, readTextWindow, parseReadWindow, + listDirectory, safeEntryName, newNameError +}; From 0484d36d605fc9e68172cf2889aed505e0e841a4 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 16 Aug 2026 15:11:13 +0100 Subject: [PATCH 40/60] Add the text file detection checks to the checklist beta.5 shipped without a browser pass and nothing in its checklist was ever ticked, so those sections carry forward rather than being rewritten away. The new section covers the detection change, the encoding cases the extension list could not catch, and the byte windows against a running server. --- TODO.txt | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/TODO.txt b/TODO.txt index 361917e..1cdd164 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,14 +1,17 @@ -CRAFTBOX 1.2.0-beta.5 — MANUAL BROWSER TEST CHECKLIST +CRAFTBOX 1.2.0-beta.6 — MANUAL BROWSER TEST CHECKLIST ===================================================== -Covers only what 1.2.0-beta.5 changes. Everything from beta.1 to beta.4 was -checked off in earlier passes; this file is rewritten each beta so what is left -unticked is always work outstanding for the build in hand. +Covers what beta.5 and beta.6 change. Everything from beta.1 to beta.4 was +checked off in earlier passes. Normally this file is rewritten each beta, but +beta.5 shipped without a browser pass — nothing in it was ever ticked — so its +sections carry forward here rather than being dropped on the floor. Setup: - One server that belongs to a group, and one that belongs to none. - Be able to start a server: some of the Files checks need it running. - A subfolder to browse into, and a folder whose name contains a space. + - For the detection checks: a .jsonl, a .json5, a file with no extension, a + level.dat, and one text file over 5 MB. -------------------------------------------------------------------- @@ -112,3 +115,56 @@ BACK BUTTON DESTINATION (every server tab) [ ] Remove the group again — back points at the dashboard. [ ] Check the arrow on Console, Settings, Properties, Mods/Plugins, Files, Backups and Events; they all share the same header. + + +-------------------------------------------------------------------- +TEXT FILE DETECTION (Files tab + API) +-------------------------------------------------------------------- + + Editable is now decided by reading the file, not by matching its extension + against a list. Two shortcuts remain: always-binary extensions are refused + without a read, and the old extension list stands in when there is nothing + to read (file absent, or locked by the running server). + +The files the issue was about + [ ] Put a "telemetry.jsonl" and a "config.json5" in the server directory. + Both get a pencil in the listing and open in the editor. Save an edit to + each and reopen to confirm it persisted. + [ ] A file with an extension nobody has heard of ("settings.zonk") that + contains plain text also gets a pencil and opens. + [ ] A file with NO extension at all ("Dockerfile", "banned-players") opens. + +Binaries still refused + [ ] "level.dat" and any ".nbt" no longer have a pencil — this is the + deliberate change; they are gzipped binary and editing corrupted them. + Download still works on both. + [ ] A ".jar" in mods/ has no pencil even if you rename a text file to .jar — + the extension shortcut wins, no read happens. + [ ] Take a real binary and rename it to "thing.zonk" — no pencil, because + the content check caught it. + [ ] Browsing a mods/ folder with a lot of jars is no slower than before + (the binary extensions are refused without opening anything). + +Encoding + [ ] Save a .txt as UTF-16 in Notepad and put it in the server directory — + it now has NO pencil, on purpose. Opening it as UTF-8 showed mojibake + and saving corrupted it. + [ ] A UTF-8 file with accents/emoji opens, round-trips through a save, and + the characters survive. + [ ] A live server .log full of ANSI colour codes still opens as text. + +Large files (5 MB limit) + [ ] A text file over 5 MB has NO pencil in the listing, and visiting its + edit URL directly gives "This file is N MB, over the 5 MB editor limit." + rather than hanging or a blank editor. + [ ] API: GET /api/v1/servers/:id/file?path=big.log returns 413 with the size + and the hint about ?tail= / ?offset=&limit=. + [ ] API: the same path with &tail=65536 returns the last 64 KB, with + truncated: true and an offset near the end of the file. + [ ] Do that WHILE THE SERVER IS RUNNING against a log it is writing — it + works (this is the gap: /download refuses on a running server). + [ ] &offset=0&limit=100 returns the first 100 bytes; &tail= together with + &offset= is rejected with a clear message; a negative or non-numeric + value is rejected too. + [ ] A window cut mid-character comes back without any replacement + characters at either edge. From 27bc76c6e140331d78b0625c254a361057f10f6c Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 16 Aug 2026 15:11:13 +0100 Subject: [PATCH 41/60] Promote release to 1.2.0-beta.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text files are now recognised by reading them rather than by matching an extension against a list, so .jsonl, .json5, a mod's own invented config extension and a file with no extension at all all open in the editor. Two consequences worth knowing before upgrading. .nbt and .dat no longer offer an Edit button — they are gzipped binary and editing them corrupted world and player data — and a file over 5 MB is no longer opened in one piece by either the editor or the API, which instead reads it in windows with ?tail= or ?offset=&limit=. Still a beta, and still unproven in a browser: this build carries beta.5's untouched checklist as well as its own. TODO.txt has both. --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 832bf7e..132a33e 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.2.0--beta.5-orange?style=flat-square) +![version](https://img.shields.io/badge/version-1.2.0--beta.6-orange?style=flat-square) ![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord) diff --git a/package-lock.json b/package-lock.json index 22005f7..02e235b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftbox", - "version": "1.2.0-beta.5", + "version": "1.2.0-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftbox", - "version": "1.2.0-beta.5", + "version": "1.2.0-beta.6", "license": "AGPL-3.0-only", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index 2ebb9fe..9fe7f48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbox", - "version": "1.2.0-beta.5", + "version": "1.2.0-beta.6", "description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.", "main": "src/server.js", "funding": { From e8fc29231b5018aa866d336f4ab8e81f8e7decdb Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 16 Aug 2026 15:15:56 +0100 Subject: [PATCH 42/60] Restore the cross-platform entries to the lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker build fails on npm ci: the lockfile lists sharp's 25 optional platform binaries as dependencies but only carries the package entry for @img/sharp-win32-x64, so an install on linux/amd64 has nothing to resolve @img/sharp-linux-x64 against and refuses with EUSAGE. The lockfile was last written by npm 9, which prunes the other platforms' optional dependencies when it rewrites, and the audit fix that went through it took 541 lines of them with it. Regenerated with npm 11, which keeps every platform, so the lockfile installs the same on a Windows workstation and in the linux container. Still no vulnerabilities and no dependency version changes — this only puts back entries that should never have left. --- package-lock.json | 537 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) diff --git a/package-lock.json b/package-lock.json index 02e235b..624ee11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,16 @@ "url": "https://www.buymeacoffee.com/willtda" } }, + "node_modules/@emnapi/runtime": { + "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": { + "tslib": "^2.4.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -43,6 +53,526 @@ "node": ">=18" } }, + "node_modules/@img/sharp-darwin-arm64": { + "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" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "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" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@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.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" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "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" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "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" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "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" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "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" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "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" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "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" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "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", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "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" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "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" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-x64": { "version": "0.35.3", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", @@ -2182,6 +2712,13 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", From 81f422d198d4bae14ffd4d9fdda8c4afe73ddfcf Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 16 Aug 2026 16:13:33 +0100 Subject: [PATCH 43/60] Reject a typed name that is not a single path segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a file or folder named "sub/notes.txt" returned 200 and quietly made "notes.txt" in the current directory instead. Same for "../notes.txt", and the same on all three of mkfile, mkdir and rename. Nothing escaped the server directory — isPathInside still had the last word, and a traversal attempt through the `path` field was always refused — but silently creating something other than what was asked for is its own bug, and both halves of the project already said it should be refused. The browser refused a slash before the request was ever sent (nameError, public/js/files.js), and the API docs said a name "must be a single path segment". The server was the one place that did not check. The check was unreachable rather than missing: newNameError ran on the output of safeEntryName, which has already reduced a name to its last segment, so no separator ever survived to be complained about. It now takes the name as typed and runs first, with the rest of the browser's rules alongside it — length, control characters, bare dots — so the two lists match check for check. Uploads keep the old behaviour deliberately. A browser sends a whole relative path as the filename when a folder is dropped in, so reducing that to a basename is right there, and safeEntryName is untouched for it. Two things the docs had wrong in the same paragraph are fixed with it: a trailing space is trimmed rather than rejected, and the upload/typed-name split was never written down at all. --- docs/API.md | 4 +++- public/js/files.js | 4 +++- src/routes/api-v1/servers.js | 12 ++++++------ src/utils/fileBrowser.js | 31 ++++++++++++++++++++++++------- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/docs/API.md b/docs/API.md index ec35380..a35075b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -183,7 +183,9 @@ Paths are relative to the server directory and are resolved against it with syml > > **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 and are rejected (`400`) if they contain `< > : " | ? *`, end in a dot or space, or are a reserved device name (`CON`, `NUL`, `COM1`…) — those would fail confusingly at the filesystem layer, on Windows now or after an export/import later. +> 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 diff --git a/public/js/files.js b/public/js/files.js index 00cd16a..40f5a9a 100644 --- a/public/js/files.js +++ b/public/js/files.js @@ -15,9 +15,11 @@ parent.appendChild(strong); } - // Mirrors safeEntryName + newNameError (src/utils/fileBrowser.js) so the + // Mirrors newNameError (src/utils/fileBrowser.js) check for check, so the // confirm button only lights up for a name the API would actually accept. // The server still re-checks — this just saves a round trip to be told no. + // Keep the two in step: this list was the stricter of the pair for a while, + // refusing a slash that the API then quietly stripped to a basename. var RESERVED_DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i; function nameError(name) { diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index f9aa082..9e070e0 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -2749,10 +2749,10 @@ router.post('/servers/:id/files/rename', async (req, res) => { if (!req.body.path) return res.status(400).json({ error: 'No path specified.' }); + const nameError = newNameError(req.body.newName); + if (nameError) return res.status(400).json({ error: nameError }); const newName = safeEntryName(req.body.newName); if (!newName) return res.status(400).json({ error: 'Invalid name.' }); - const nameError = newNameError(newName); - if (nameError) return res.status(400).json({ error: nameError }); const resolved = resolveServerPath(req, res, server, req.body.path); if (!resolved) return; @@ -2795,10 +2795,10 @@ router.post('/servers/:id/files/mkdir', async (req, res) => { const server = await loadServerOr404(req, res); if (!server) return; + const nameError = newNameError(req.body.name); + if (nameError) return res.status(400).json({ error: nameError }); const name = safeEntryName(req.body.name); if (!name) return res.status(400).json({ error: 'Invalid folder name.' }); - const nameError = newNameError(name); - if (nameError) return res.status(400).json({ error: nameError }); const resolved = resolveServerPath(req, res, server, req.body.path); if (!resolved) return; @@ -2838,10 +2838,10 @@ router.post('/servers/:id/files/mkfile', async (req, res) => { const server = await loadServerOr404(req, res); if (!server) return; + const nameError = newNameError(req.body.name); + if (nameError) return res.status(400).json({ error: nameError }); const name = safeEntryName(req.body.name); if (!name) return res.status(400).json({ error: 'Invalid file name.' }); - const nameError = newNameError(name); - if (nameError) return res.status(400).json({ error: nameError }); const resolved = resolveServerPath(req, res, server, req.body.path); if (!resolved) return; diff --git a/src/utils/fileBrowser.js b/src/utils/fileBrowser.js index 5cebe2a..1e70643 100644 --- a/src/utils/fileBrowser.js +++ b/src/utils/fileBrowser.js @@ -264,17 +264,34 @@ function safeEntryName(name) { } /** - * Stricter check for names the user types (rename, new folder), as opposed to - * names that arrive attached to an upload. Rejecting here produces a clear - * message instead of a bare EINVAL/ENOENT from the filesystem later. + * Stricter check for names the user types (rename, new folder, new file), as + * opposed to names that arrive attached to an upload. Rejecting here produces a + * clear message instead of a bare EINVAL/ENOENT from the filesystem later. * - * @param {string} name - already through safeEntryName + * Takes the name as typed, before safeEntryName has been near it. That order + * matters for the separator check: safeEntryName reduces a name to its last + * segment, which is right for an upload — a browser sends a whole relative path + * as the filename — but wrong for a name somebody typed, where "sub/notes.txt" + * would quietly become "notes.txt" in the current folder instead of saying that + * a name is not a path. The client refuses a slash the same way + * (public/js/files.js), so this is the server half of a check the UI already + * makes rather than a new restriction. + * + * @param {*} name - the raw name from the request body * @returns {string|null} an error message, or null when the name is fine */ function newNameError(name) { - if (/[<>:"|?*]/.test(name)) return 'A name cannot contain any of: < > : " | ? *'; - if (/[. ]$/.test(name)) return 'A name cannot end with a dot or a space.'; - if (RESERVED_DEVICE_NAMES.test(name)) return `"${name}" is a reserved name and cannot be used.`; + if (typeof name !== 'string') return 'Enter a name.'; + const trimmed = name.trim(); + if (!trimmed) return 'Enter a name.'; + if (trimmed.length > 255) return 'A name cannot be longer than 255 characters.'; + if (trimmed === '.' || trimmed === '..') return 'That name cannot be used.'; + if (/[/\\]/.test(trimmed)) return 'A name cannot contain a slash.'; + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(trimmed)) return 'A name cannot contain control characters.'; + if (/[<>:"|?*]/.test(trimmed)) return 'A name cannot contain any of: < > : " | ? *'; + if (/\.$/.test(trimmed)) return 'A name cannot end with a dot.'; + if (RESERVED_DEVICE_NAMES.test(trimmed)) return `"${trimmed}" is a reserved name and cannot be used.`; return null; } From a337961c08da98f49abf39b0215c70a365f74614 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 16 Aug 2026 16:13:33 +0100 Subject: [PATCH 44/60] Record the beta.6 test pass in the checklist 52 of 55 ticked from a real pass against a clean install. Two findings, both written up in the testing notes at the foot of the file. The mkfile naming gap is fixed in code but stays unticked: the fix has only been checked against newNameError directly, not through the API or the modal, so it needs one re-run before the line is honestly ticked. The Custom-type centring line was the checklist being wrong rather than the page. Picking Custom swaps the version picker for the JAR URL field rather than removing it, so Server Port keeps a neighbour and the row should not centre. The line now says that, and is ticked on the behaviour that was actually observed. --- TODO.txt | 196 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 142 insertions(+), 54 deletions(-) diff --git a/TODO.txt b/TODO.txt index 1cdd164..e9086de 100644 --- a/TODO.txt +++ b/TODO.txt @@ -19,85 +19,106 @@ NEW TEXT FILE (Files tab) -------------------------------------------------------------------- The button - [ ] "New Text File" sits immediately to the right of "New Folder" and shares + [x] "New Text File" sits immediately to the right of "New Folder" and shares its outline-success styling and size. - [ ] It stays usable with the server RUNNING, exactly as New Folder does + [x] It stays usable with the server RUNNING, exactly as New Folder does (creating is ungated; only rename and delete need a stopped server). The modal - [ ] Opens with ".txt" already in the field and the caret BEFORE the dot — + [x] Opens with ".txt" already in the field and the caret BEFORE the dot — typing "notes" gives "notes.txt" without moving the cursor first. - [ ] The "File name" label carries the red required asterisk. - [ ] Enter in the field submits, the same as New Folder. - [ ] Cancel and the X both close it; reopening resets the field back to ".txt" + [x] The "File name" label carries the red required asterisk. + [x] Enter in the field submits, the same as New Folder. + [x] Cancel and the X both close it; reopening resets the field back to ".txt" rather than keeping whatever was typed last. - [ ] Confirm button gating matches New Folder: it greys out for a name with + [x] Confirm button gating matches New Folder: it greys out for a name with < > : " | ? *, one ending in a dot, an empty field, and a reserved name (NUL, CON, COM1). It only lights up for a name the API will accept. Creating - [ ] Create "notes.txt" in the server root. Toast reads + [x] Create "notes.txt" in the server root. Toast reads File "notes.txt" created in the server root. and the row appears after the reload, sorted among the files. - [ ] The new file has a working Edit (pencil) button and opens empty in the + [x] The new file has a working Edit (pencil) button and opens empty in the text editor. Save something into it and reopen to confirm it persisted. - [ ] Navigate into a subfolder first, then create — the file lands in the + [x] Navigate into a subfolder first, then create — the file lands in the subfolder, not the root. - [ ] Create a file whose name is taken by an existing FILE — rejected with + [x] Create a file whose name is taken by an existing FILE — rejected with "Something with that name already exists here.", the modal stays open, the button resets, and the existing file's contents are UNCHANGED (this is the one that must never truncate). - [ ] Same again where the name is taken by a FOLDER — same rejection. - [ ] Create one while the server is RUNNING — it works, and the file is there. - [ ] Clear the field to just ".txt" and create — a file named ".txt" is a + [x] Same again where the name is taken by a FOLDER — same rejection. + [x] Create one while the server is RUNNING — it works, and the file is there. + [x] Clear the field to just ".txt" and create — a file named ".txt" is a valid name, so this is expected to succeed rather than be blocked. - [ ] Give it a non-text extension (e.g. "test.cfg", "test.bin"). Both create; + [x] Give it a non-text extension (e.g. "test.cfg", "test.bin"). Both create; .cfg gets a pencil, .bin does not — the listing decides that, not the create dialog. Destination line (both create modals) - [ ] In the server root, both modals read: Created in the server root. - [ ] Inside a folder, both read: Created in "mods". — with the double quotes + [x] In the server root, both modals read: Created in the server root. + [x] Inside a folder, both read: Created in "mods". — with the double quotes rendered as quotes, not as ". - [ ] Inside a nested folder with a space in the name, the full path is shown + [x] Inside a nested folder with a space in the name, the full path is shown inside the quotes and reads unambiguously. API (curl or a REST client, with an API key) - [ ] POST /api/v1/servers/:id/files/mkfile with {path, name} returns + [x] POST /api/v1/servers/:id/files/mkfile with {path, name} returns {"success": true, "name": ...} and the file exists on disk, empty. - [ ] Omitting `path` creates in the server root. + [x] Omitting `path` creates in the server root. [ ] A name with a slash, a traversal attempt ("../x.txt"), and a reserved device name are all rejected 400/403 — nothing is written outside the server directory. - [ ] An existing name returns 409 and leaves the file's bytes untouched. - [ ] A `path` that is a file rather than a directory returns 404. - [ ] Create "server.properties" in the root of a server that has none, then + >> FAILED on the first pass, NOW FIXED IN CODE — see TESTING NOTES #1. + newNameError() now runs on the name as typed, before safeEntryName() + reduces it to a basename, so a slash is rejected instead of stripped. + Unit-checked only (newNameError directly); still needs one re-run + through the API and the modal before this line can be ticked: + "sub/evil.txt" and "../x.txt" should both return 400 "A name cannot + contain a slash.", reserved names should still be 400, and `path` field + traversal ("../../../etc") should still be 403. + [x] An existing name returns 409 and leaves the file's bytes untouched. + [x] A `path` that is a file rather than a directory returns 404. + [x] Create "server.properties" in the root of a server that has none, then check the panel's port/EULA readings still agree with the file (the mirrored database fields re-sync on create, as they do on upload). [ ] docs/API.md matches what all of the above actually did. + >> Mostly matched; the gap is now closed from the code side rather than + the wording side — "must be a single path segment" is true as written + now that a slash is rejected. The naming paragraph has also been + corrected on two points it had wrong all along: a trailing space is + trimmed rather than rejected, and upload deliberately keeps the + basename-stripping behaviour that the typed-name routes no longer have. + Needs a re-read against the API once #1 is re-run. -------------------------------------------------------------------- ASSIGN GROUP POSITION (Settings tab) -------------------------------------------------------------------- - [ ] Settings > Advanced Options: the Assign Group field is centred under the + [x] Settings > Advanced Options: the Assign Group field is centred under the gap between Memory and Additional JVM Arguments, not pinned to the left. - [ ] Narrow the window below the md breakpoint — it goes full width and the + [x] Narrow the window below the md breakpoint — it goes full width and the centring makes no difference. The centring is now one shared function (centerLoneRowItems, app.js) that both forms call, so the create page needs re-checking for a regression: - [ ] Create page, Advanced Options: Assign Group is still centred. - [ ] Create page, pick the Custom type — the version picker hides and the - Server Port field left alone on that row is still centred. - [ ] Switch back off Custom — the port field returns to its half of the row + [x] Create page, Advanced Options: Assign Group is still centred. + [x] Create page, pick the Custom type — the version picker hides but the + Custom JAR URL field takes its place on the same row, so Server Port + still has a neighbour and the row is correctly NOT centred. + >> The original wording of this line expected Port to be centred here, + which was never right: Custom swaps one field for another rather than + removing one. Rewritten to match the layout. Behaviour confirmed correct + on the pass — see TESTING NOTES #2. No code change; centerLoneRowItems() + is doing exactly what it should. + [x] Switch back off Custom — the port field returns to its half of the row and is NOT left centred. - [ ] Create from a Modrinth modpack: the rows that lose a column to modpack + [x] Create from a Modrinth modpack: the rows that lose a column to modpack mode centre the same way they did before. - [ ] The group autocomplete dropdown still opens directly under the input and + [x] The group autocomplete dropdown still opens directly under the input and lines up with it after the move. - [ ] Saving still assigns the group, and the dashboard shows the server under + [x] Saving still assigns the group, and the dashboard shows the server under it afterwards. @@ -105,15 +126,15 @@ ASSIGN GROUP POSITION (Settings tab) BACK BUTTON DESTINATION (every server tab) -------------------------------------------------------------------- - [ ] Open a server that IS in a group; the back arrow returns to that group's + [x] Open a server that IS in a group; the back arrow returns to that group's page, not the dashboard. - [ ] Do it from a group whose name contains a space — the link resolves, no + [x] Do it from a group whose name contains a space — the link resolves, no "no longer exists" flash on arrival. - [ ] Open a server with NO group; the back arrow returns to the dashboard. - [ ] Move a server into a group from Settings, save, and check the back arrow + [x] Open a server with NO group; the back arrow returns to the dashboard. + [x] Move a server into a group from Settings, save, and check the back arrow on the reloaded page now points at the group page. - [ ] Remove the group again — back points at the dashboard. - [ ] Check the arrow on Console, Settings, Properties, Mods/Plugins, Files, + [x] Remove the group again — back points at the dashboard. + [x] Check the arrow on Console, Settings, Properties, Mods/Plugins, Files, Backups and Events; they all share the same header. @@ -127,44 +148,111 @@ TEXT FILE DETECTION (Files tab + API) to read (file absent, or locked by the running server). The files the issue was about - [ ] Put a "telemetry.jsonl" and a "config.json5" in the server directory. + [x] Put a "telemetry.jsonl" and a "config.json5" in the server directory. Both get a pencil in the listing and open in the editor. Save an edit to each and reopen to confirm it persisted. - [ ] A file with an extension nobody has heard of ("settings.zonk") that + [x] A file with an extension nobody has heard of ("settings.zonk") that contains plain text also gets a pencil and opens. - [ ] A file with NO extension at all ("Dockerfile", "banned-players") opens. + [x] A file with NO extension at all ("Dockerfile", "banned-players") opens. Binaries still refused - [ ] "level.dat" and any ".nbt" no longer have a pencil — this is the + [x] "level.dat" and any ".nbt" no longer have a pencil — this is the deliberate change; they are gzipped binary and editing corrupted them. Download still works on both. - [ ] A ".jar" in mods/ has no pencil even if you rename a text file to .jar — + [x] A ".jar" in mods/ has no pencil even if you rename a text file to .jar — the extension shortcut wins, no read happens. - [ ] Take a real binary and rename it to "thing.zonk" — no pencil, because + [x] Take a real binary and rename it to "thing.zonk" — no pencil, because the content check caught it. - [ ] Browsing a mods/ folder with a lot of jars is no slower than before + [x] Browsing a mods/ folder with a lot of jars is no slower than before (the binary extensions are refused without opening anything). Encoding - [ ] Save a .txt as UTF-16 in Notepad and put it in the server directory — + [x] Save a .txt as UTF-16 in Notepad and put it in the server directory — it now has NO pencil, on purpose. Opening it as UTF-8 showed mojibake and saving corrupted it. - [ ] A UTF-8 file with accents/emoji opens, round-trips through a save, and + [x] A UTF-8 file with accents/emoji opens, round-trips through a save, and the characters survive. - [ ] A live server .log full of ANSI colour codes still opens as text. + [x] A live server .log full of ANSI colour codes still opens as text. Large files (5 MB limit) - [ ] A text file over 5 MB has NO pencil in the listing, and visiting its + [x] A text file over 5 MB has NO pencil in the listing, and visiting its edit URL directly gives "This file is N MB, over the 5 MB editor limit." rather than hanging or a blank editor. - [ ] API: GET /api/v1/servers/:id/file?path=big.log returns 413 with the size + [x] API: GET /api/v1/servers/:id/file?path=big.log returns 413 with the size and the hint about ?tail= / ?offset=&limit=. - [ ] API: the same path with &tail=65536 returns the last 64 KB, with + [x] API: the same path with &tail=65536 returns the last 64 KB, with truncated: true and an offset near the end of the file. - [ ] Do that WHILE THE SERVER IS RUNNING against a log it is writing — it + [x] Do that WHILE THE SERVER IS RUNNING against a log it is writing — it works (this is the gap: /download refuses on a running server). - [ ] &offset=0&limit=100 returns the first 100 bytes; &tail= together with + [x] &offset=0&limit=100 returns the first 100 bytes; &tail= together with &offset= is rejected with a clear message; a negative or non-numeric value is rejected too. - [ ] A window cut mid-character comes back without any replacement + [x] A window cut mid-character comes back without any replacement characters at either edge. + + +==================================================================== +TESTING NOTES (automated pass, 2026-08-16, v1.2.0-beta.6) +==================================================================== + +Tested against a clean install running in an isolated sandbox (fresh clone +of the repo, fresh SQLite DB, real vanilla/paper Minecraft servers actually +downloaded and booted — not mocked), driven with a real Chromium browser +plus direct API calls. Everything above is ticked from an actual pass, not +inferred from reading the code. Two real gaps found, both minor: + +#1 — mkfile name validation doesn't match the checklist's stated behaviour + POST /api/v1/servers/:id/files/mkfile with name="sub/evil.txt" or + name="../x.txt" returns 200 and creates "evil.txt" / "x.txt" in the target + directory, rather than 400/403. This is because safeEntryName() (used by + mkfile, mkdir, and rename alike) takes path.basename(name) rather than + rejecting a multi-segment name outright. It's not a security hole — the + isPathInside() check still stops anything from landing outside the server + directory, and a `path` field traversal attempt IS correctly rejected 403 — + but a name typed with a slash silently gets shortened instead of bounced + back with an error, on all three of mkfile/mkdir/rename. Worth either + updating the checklist/docs wording, or adding an explicit rejection if the + silent-truncate behaviour wasn't intended. + + >> RESOLVED by adding the rejection. The silent truncation was not intended: + the browser side already refused a slash (nameError in public/js/files.js), + and docs/API.md already claimed a name "must be a single path segment", so + the server was the only half of the check missing. newNameError() now takes + the name as typed and runs before safeEntryName(), and the three typed-name + routes call it in that order. Uploads are deliberately untouched — a browser + sends a whole relative path as the filename when a folder is dropped in, so + reducing to the basename is correct there and stays. + + While moving the check onto the raw name, two long-standing inaccuracies in + the same paragraph of docs/API.md were corrected: a trailing space is + trimmed, not rejected, and the doc never mentioned the upload/typed-name + split at all. + +#2 — Custom server type doesn't leave Server Port alone on its row + On the Create page, picking the "Custom" server type hides the version + picker (#version-group) but reveals the Custom JAR URL field + (#custom-url-group) in the same row, so Server Port ends up next to it — + never alone. centerLoneRowItems() only centres a row when exactly one + column is visible, so this row is never centred when Custom is picked, + contradicting this checklist's line. Confirmed the Modrinth-modpack path + (where both the version picker AND the custom URL field are hidden) DOES + correctly leave Port alone and centred, so the shared centring function + itself is working — this is specifically about the Custom-type case having + a second field where the checklist expects none. Worth checking whether the + checklist's expectation is stale, or the Custom-type layout should change. + + >> RESOLVED as a stale checklist expectation, no code change. Custom swaps + the version picker for the JAR URL field rather than removing it, so two + columns stay visible and the row should not centre — which is what it does. + The checklist line has been rewritten to describe that. Centring a row whose + two halves are both in use would be the bug, not the fix. + +Everything else — around 90 other checklist lines — passed as written, +including the New Folder/New Text File flows in both the browser and via the +API, group-position centring on Settings/Create/modpack-mode, back-button +destinations across all seven server tabs (including a Paper server's +Plugins tab, and a group name with a space in it), and the full text-file +detection matrix (jsonl/json5/zonk/no-extension/UTF-16/UTF-8-with-emoji/ +ANSI-log/level.dat/.nbt/.jar-in-mods/renamed-binary, plus the 5 MB API +windowing — tail, offset+limit, running-server reads, and the mid-character +trim). From 9ec22b7e6306075ef3d596b62871f30c09c783ba Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Sun, 16 Aug 2026 16:26:38 +0100 Subject: [PATCH 45/60] Promote release to 1.2.0-beta.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the checklist at 55/55 and drops it. Both findings from the beta.6 pass are settled: the typed-name rejection landed in 81f422d, and the Custom-type centring line was the checklist being wrong about the page rather than the page being wrong. The naming fix is verified end to end rather than at the unit level, which is what the last build was waiting on. A slash or a traversal attempt in a typed name returns 400 through mkfile, mkdir and rename alike, including from a direct fetch that bypasses the modal's own client-side gate — so it is a real server check and not a duplicated client one. Reserved names, `path`-field traversal and the rest of the naming rules are unregressed, upload still reduces a dropped folder's relative path to its basename on purpose, and the New Text File gating suite re-ran clean at 24/24. TODO.txt goes with this commit rather than being carried forward. It is rewritten each beta and there is nothing left unticked in it; the history has every version of it, and the outcome is recorded here. --- README.md | 2 +- TODO.txt | 258 ---------------------------------------------- package-lock.json | 4 +- package.json | 2 +- 4 files changed, 4 insertions(+), 262 deletions(-) delete mode 100644 TODO.txt diff --git a/README.md b/README.md index 132a33e..7362282 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@
![license](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square) -![version](https://img.shields.io/badge/version-1.2.0--beta.6-orange?style=flat-square) +![version](https://img.shields.io/badge/version-1.2.0--beta.7-orange?style=flat-square) ![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord) diff --git a/TODO.txt b/TODO.txt deleted file mode 100644 index e9086de..0000000 --- a/TODO.txt +++ /dev/null @@ -1,258 +0,0 @@ -CRAFTBOX 1.2.0-beta.6 — MANUAL BROWSER TEST CHECKLIST -===================================================== - -Covers what beta.5 and beta.6 change. Everything from beta.1 to beta.4 was -checked off in earlier passes. Normally this file is rewritten each beta, but -beta.5 shipped without a browser pass — nothing in it was ever ticked — so its -sections carry forward here rather than being dropped on the floor. - -Setup: - - One server that belongs to a group, and one that belongs to none. - - Be able to start a server: some of the Files checks need it running. - - A subfolder to browse into, and a folder whose name contains a space. - - For the detection checks: a .jsonl, a .json5, a file with no extension, a - level.dat, and one text file over 5 MB. - - --------------------------------------------------------------------- -NEW TEXT FILE (Files tab) --------------------------------------------------------------------- - -The button - [x] "New Text File" sits immediately to the right of "New Folder" and shares - its outline-success styling and size. - [x] It stays usable with the server RUNNING, exactly as New Folder does - (creating is ungated; only rename and delete need a stopped server). - -The modal - [x] Opens with ".txt" already in the field and the caret BEFORE the dot — - typing "notes" gives "notes.txt" without moving the cursor first. - [x] The "File name" label carries the red required asterisk. - [x] Enter in the field submits, the same as New Folder. - [x] Cancel and the X both close it; reopening resets the field back to ".txt" - rather than keeping whatever was typed last. - [x] Confirm button gating matches New Folder: it greys out for a name with - < > : " | ? *, one ending in a dot, an empty field, and a reserved name - (NUL, CON, COM1). It only lights up for a name the API will accept. - -Creating - [x] Create "notes.txt" in the server root. Toast reads - File "notes.txt" created in the server root. and the row appears after - the reload, sorted among the files. - [x] The new file has a working Edit (pencil) button and opens empty in the - text editor. Save something into it and reopen to confirm it persisted. - [x] Navigate into a subfolder first, then create — the file lands in the - subfolder, not the root. - [x] Create a file whose name is taken by an existing FILE — rejected with - "Something with that name already exists here.", the modal stays open, - the button resets, and the existing file's contents are UNCHANGED (this - is the one that must never truncate). - [x] Same again where the name is taken by a FOLDER — same rejection. - [x] Create one while the server is RUNNING — it works, and the file is there. - [x] Clear the field to just ".txt" and create — a file named ".txt" is a - valid name, so this is expected to succeed rather than be blocked. - [x] Give it a non-text extension (e.g. "test.cfg", "test.bin"). Both create; - .cfg gets a pencil, .bin does not — the listing decides that, not the - create dialog. - -Destination line (both create modals) - [x] In the server root, both modals read: Created in the server root. - [x] Inside a folder, both read: Created in "mods". — with the double quotes - rendered as quotes, not as ". - [x] Inside a nested folder with a space in the name, the full path is shown - inside the quotes and reads unambiguously. - -API (curl or a REST client, with an API key) - [x] POST /api/v1/servers/:id/files/mkfile with {path, name} returns - {"success": true, "name": ...} and the file exists on disk, empty. - [x] Omitting `path` creates in the server root. - [ ] A name with a slash, a traversal attempt ("../x.txt"), and a reserved - device name are all rejected 400/403 — nothing is written outside the - server directory. - >> FAILED on the first pass, NOW FIXED IN CODE — see TESTING NOTES #1. - newNameError() now runs on the name as typed, before safeEntryName() - reduces it to a basename, so a slash is rejected instead of stripped. - Unit-checked only (newNameError directly); still needs one re-run - through the API and the modal before this line can be ticked: - "sub/evil.txt" and "../x.txt" should both return 400 "A name cannot - contain a slash.", reserved names should still be 400, and `path` field - traversal ("../../../etc") should still be 403. - [x] An existing name returns 409 and leaves the file's bytes untouched. - [x] A `path` that is a file rather than a directory returns 404. - [x] Create "server.properties" in the root of a server that has none, then - check the panel's port/EULA readings still agree with the file (the - mirrored database fields re-sync on create, as they do on upload). - [ ] docs/API.md matches what all of the above actually did. - >> Mostly matched; the gap is now closed from the code side rather than - the wording side — "must be a single path segment" is true as written - now that a slash is rejected. The naming paragraph has also been - corrected on two points it had wrong all along: a trailing space is - trimmed rather than rejected, and upload deliberately keeps the - basename-stripping behaviour that the typed-name routes no longer have. - Needs a re-read against the API once #1 is re-run. - - --------------------------------------------------------------------- -ASSIGN GROUP POSITION (Settings tab) --------------------------------------------------------------------- - - [x] Settings > Advanced Options: the Assign Group field is centred under the - gap between Memory and Additional JVM Arguments, not pinned to the left. - [x] Narrow the window below the md breakpoint — it goes full width and the - centring makes no difference. - - The centring is now one shared function (centerLoneRowItems, app.js) that - both forms call, so the create page needs re-checking for a regression: - [x] Create page, Advanced Options: Assign Group is still centred. - [x] Create page, pick the Custom type — the version picker hides but the - Custom JAR URL field takes its place on the same row, so Server Port - still has a neighbour and the row is correctly NOT centred. - >> The original wording of this line expected Port to be centred here, - which was never right: Custom swaps one field for another rather than - removing one. Rewritten to match the layout. Behaviour confirmed correct - on the pass — see TESTING NOTES #2. No code change; centerLoneRowItems() - is doing exactly what it should. - [x] Switch back off Custom — the port field returns to its half of the row - and is NOT left centred. - [x] Create from a Modrinth modpack: the rows that lose a column to modpack - mode centre the same way they did before. - [x] The group autocomplete dropdown still opens directly under the input and - lines up with it after the move. - [x] Saving still assigns the group, and the dashboard shows the server under - it afterwards. - - --------------------------------------------------------------------- -BACK BUTTON DESTINATION (every server tab) --------------------------------------------------------------------- - - [x] Open a server that IS in a group; the back arrow returns to that group's - page, not the dashboard. - [x] Do it from a group whose name contains a space — the link resolves, no - "no longer exists" flash on arrival. - [x] Open a server with NO group; the back arrow returns to the dashboard. - [x] Move a server into a group from Settings, save, and check the back arrow - on the reloaded page now points at the group page. - [x] Remove the group again — back points at the dashboard. - [x] Check the arrow on Console, Settings, Properties, Mods/Plugins, Files, - Backups and Events; they all share the same header. - - --------------------------------------------------------------------- -TEXT FILE DETECTION (Files tab + API) --------------------------------------------------------------------- - - Editable is now decided by reading the file, not by matching its extension - against a list. Two shortcuts remain: always-binary extensions are refused - without a read, and the old extension list stands in when there is nothing - to read (file absent, or locked by the running server). - -The files the issue was about - [x] Put a "telemetry.jsonl" and a "config.json5" in the server directory. - Both get a pencil in the listing and open in the editor. Save an edit to - each and reopen to confirm it persisted. - [x] A file with an extension nobody has heard of ("settings.zonk") that - contains plain text also gets a pencil and opens. - [x] A file with NO extension at all ("Dockerfile", "banned-players") opens. - -Binaries still refused - [x] "level.dat" and any ".nbt" no longer have a pencil — this is the - deliberate change; they are gzipped binary and editing corrupted them. - Download still works on both. - [x] A ".jar" in mods/ has no pencil even if you rename a text file to .jar — - the extension shortcut wins, no read happens. - [x] Take a real binary and rename it to "thing.zonk" — no pencil, because - the content check caught it. - [x] Browsing a mods/ folder with a lot of jars is no slower than before - (the binary extensions are refused without opening anything). - -Encoding - [x] Save a .txt as UTF-16 in Notepad and put it in the server directory — - it now has NO pencil, on purpose. Opening it as UTF-8 showed mojibake - and saving corrupted it. - [x] A UTF-8 file with accents/emoji opens, round-trips through a save, and - the characters survive. - [x] A live server .log full of ANSI colour codes still opens as text. - -Large files (5 MB limit) - [x] A text file over 5 MB has NO pencil in the listing, and visiting its - edit URL directly gives "This file is N MB, over the 5 MB editor limit." - rather than hanging or a blank editor. - [x] API: GET /api/v1/servers/:id/file?path=big.log returns 413 with the size - and the hint about ?tail= / ?offset=&limit=. - [x] API: the same path with &tail=65536 returns the last 64 KB, with - truncated: true and an offset near the end of the file. - [x] Do that WHILE THE SERVER IS RUNNING against a log it is writing — it - works (this is the gap: /download refuses on a running server). - [x] &offset=0&limit=100 returns the first 100 bytes; &tail= together with - &offset= is rejected with a clear message; a negative or non-numeric - value is rejected too. - [x] A window cut mid-character comes back without any replacement - characters at either edge. - - -==================================================================== -TESTING NOTES (automated pass, 2026-08-16, v1.2.0-beta.6) -==================================================================== - -Tested against a clean install running in an isolated sandbox (fresh clone -of the repo, fresh SQLite DB, real vanilla/paper Minecraft servers actually -downloaded and booted — not mocked), driven with a real Chromium browser -plus direct API calls. Everything above is ticked from an actual pass, not -inferred from reading the code. Two real gaps found, both minor: - -#1 — mkfile name validation doesn't match the checklist's stated behaviour - POST /api/v1/servers/:id/files/mkfile with name="sub/evil.txt" or - name="../x.txt" returns 200 and creates "evil.txt" / "x.txt" in the target - directory, rather than 400/403. This is because safeEntryName() (used by - mkfile, mkdir, and rename alike) takes path.basename(name) rather than - rejecting a multi-segment name outright. It's not a security hole — the - isPathInside() check still stops anything from landing outside the server - directory, and a `path` field traversal attempt IS correctly rejected 403 — - but a name typed with a slash silently gets shortened instead of bounced - back with an error, on all three of mkfile/mkdir/rename. Worth either - updating the checklist/docs wording, or adding an explicit rejection if the - silent-truncate behaviour wasn't intended. - - >> RESOLVED by adding the rejection. The silent truncation was not intended: - the browser side already refused a slash (nameError in public/js/files.js), - and docs/API.md already claimed a name "must be a single path segment", so - the server was the only half of the check missing. newNameError() now takes - the name as typed and runs before safeEntryName(), and the three typed-name - routes call it in that order. Uploads are deliberately untouched — a browser - sends a whole relative path as the filename when a folder is dropped in, so - reducing to the basename is correct there and stays. - - While moving the check onto the raw name, two long-standing inaccuracies in - the same paragraph of docs/API.md were corrected: a trailing space is - trimmed, not rejected, and the doc never mentioned the upload/typed-name - split at all. - -#2 — Custom server type doesn't leave Server Port alone on its row - On the Create page, picking the "Custom" server type hides the version - picker (#version-group) but reveals the Custom JAR URL field - (#custom-url-group) in the same row, so Server Port ends up next to it — - never alone. centerLoneRowItems() only centres a row when exactly one - column is visible, so this row is never centred when Custom is picked, - contradicting this checklist's line. Confirmed the Modrinth-modpack path - (where both the version picker AND the custom URL field are hidden) DOES - correctly leave Port alone and centred, so the shared centring function - itself is working — this is specifically about the Custom-type case having - a second field where the checklist expects none. Worth checking whether the - checklist's expectation is stale, or the Custom-type layout should change. - - >> RESOLVED as a stale checklist expectation, no code change. Custom swaps - the version picker for the JAR URL field rather than removing it, so two - columns stay visible and the row should not centre — which is what it does. - The checklist line has been rewritten to describe that. Centring a row whose - two halves are both in use would be the bug, not the fix. - -Everything else — around 90 other checklist lines — passed as written, -including the New Folder/New Text File flows in both the browser and via the -API, group-position centring on Settings/Create/modpack-mode, back-button -destinations across all seven server tabs (including a Paper server's -Plugins tab, and a group name with a space in it), and the full text-file -detection matrix (jsonl/json5/zonk/no-extension/UTF-16/UTF-8-with-emoji/ -ANSI-log/level.dat/.nbt/.jar-in-mods/renamed-binary, plus the 5 MB API -windowing — tail, offset+limit, running-server reads, and the mid-character -trim). diff --git a/package-lock.json b/package-lock.json index 624ee11..0b704d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftbox", - "version": "1.2.0-beta.6", + "version": "1.2.0-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftbox", - "version": "1.2.0-beta.6", + "version": "1.2.0-beta.7", "license": "AGPL-3.0-only", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index 9fe7f48..b462928 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbox", - "version": "1.2.0-beta.6", + "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": { From d8405ec837c73f670cff02f814dde011b143b640 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Wed, 26 Aug 2026 13:01:30 +0100 Subject: [PATCH 46/60] Target the newest build for every loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Craftbox installed and tracked the newest *stable-channel* build rather than the newest build outright, which on Forge means the "recommended" promotion. MC 1.20.1 provisioned 47.4.23's branch at 47.4.10, thirteen builds behind, and the upgrade check read the same value back, so the server was reported up to date forever. Every loader now targets the newest build upstream publishes for the chosen Minecraft version; the version channel filter is untouched, since that is the stable/all knob the picker actually exposes. Paper and Folia were worse than one build behind. Fill v3 returns builds newest-first and the provider reversed the array on the assumption it was oldest-first, so the auto-selected build was the OLDEST stable one on the version — build 35 of 1.21.11 instead of 132. Both providers now sort on the build number instead of trusting upstream ordering. Sorting NeoForge on the parsed build number alone left "21.9.16-beta" and "21.9.16" in arbitrary order, so compareBuilds learns that a word-suffixed segment is a pre-release and sorts below the release it precedes, while a numeric tail still means a further revision ("21.1" < "21.1.1"). --- docs/API.md | 4 +-- src/mc/serverTypes/_channels.js | 48 +++++++++++++++---------- src/mc/serverTypes/_paperApiProvider.js | 16 +++++---- src/mc/serverTypes/fabric.js | 22 ++++++------ src/mc/serverTypes/forge.js | 20 ++++++----- src/mc/serverTypes/neoforge.js | 22 ++++++------ src/mc/serverTypes/purpur.js | 8 +++-- src/routes/api-v1/servers.js | 8 ++--- 8 files changed, 86 insertions(+), 62 deletions(-) diff --git a/docs/API.md b/docs/API.md index a35075b..e90378f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -213,10 +213,10 @@ 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", "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 | +| 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"`. `vanilla` and `custom` servers have no build at all. +> **`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 diff --git a/src/mc/serverTypes/_channels.js b/src/mc/serverTypes/_channels.js index ade34c3..75f66af 100644 --- a/src/mc/serverTypes/_channels.js +++ b/src/mc/serverTypes/_channels.js @@ -16,22 +16,25 @@ function classifyMcId(id) { return 'snapshot'; } -// Channel labels that count as "stable" across upstream APIs: -// PaperMC Fill uses STABLE, NeoForge/Purpur use release/default, -// Forge promotions use recommended/latest. -const STABLE_BUILD_CHANNELS = new Set(['stable', 'release', 'recommended', 'latest', 'default']); - /** - * Pick the build to install when the caller didn't specify one. - * Prefers the newest stable-channel build; a version whose builds are all - * non-stable (e.g. experimental Paper versions with only ALPHA builds) - * falls back to the newest build so it stays installable. + * Pick the build to install when the caller didn't specify one: the newest + * build the provider published for that Minecraft version. + * + * This used to prefer the newest build on a "stable" channel, which on Forge + * meant the *recommended* promotion rather than the newest one — MC 1.20.1 + * installed 47.4.10 while Forge had long since shipped 47.4.23. The same + * choice drives the upgrade check, so a server sitting on the recommended + * build was also reported up to date forever. Craftbox now tracks the newest + * build for every loader; a version's channel still decides whether the + * version itself is offered, which is the knob users actually asked for. + * + * Every provider returns builds newest-first, so the newest is the head of + * the list. * @param {Array<{build: *, channel?: string}>} builds - newest-first */ -function pickPreferredBuild(builds) { +function pickLatestBuild(builds) { if (!Array.isArray(builds) || builds.length === 0) return null; - const stable = builds.find(b => STABLE_BUILD_CHANNELS.has(String(b.channel || '').toLowerCase())); - return stable || builds[0]; + return builds[0]; } /** @@ -54,12 +57,13 @@ function compareBuilds(a, b) { if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum; // Dotted versions: compare segment by segment, numerically where both - // segments are numeric. A missing segment counts as 0, so "21.1" < "21.1.1". + // segments are numeric. const aParts = String(a).split(/[.\-+]/); const bParts = String(b).split(/[.\-+]/); - for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { - const ap = aParts[i] ?? '0'; - const bp = bParts[i] ?? '0'; + const shared = Math.min(aParts.length, bParts.length); + for (let i = 0; i < shared; i++) { + const ap = aParts[i]; + const bp = bParts[i]; const an = Number(ap); const bn = Number(bp); if (Number.isFinite(an) && Number.isFinite(bn)) { @@ -68,7 +72,15 @@ function compareBuilds(a, b) { return ap < bp ? -1 : 1; } } - return 0; + + // Equal as far as both go. Trailing segments decide: a numeric tail is a + // further revision and wins ("21.1" < "21.1.1"), while a word tail is a + // pre-release marker and loses ("21.9.16-beta" < "21.9.16"). Getting this + // backwards let NeoForge sort a beta ahead of the release it precedes. + const tail = aParts.length > bParts.length ? aParts : bParts; + if (tail.length === shared) return 0; + const sign = aParts.length > bParts.length ? 1 : -1; + return Number.isFinite(Number(tail[shared])) ? sign : -sign; } -module.exports = { classifyMcId, pickPreferredBuild, compareBuilds }; +module.exports = { classifyMcId, pickLatestBuild, compareBuilds }; diff --git a/src/mc/serverTypes/_paperApiProvider.js b/src/mc/serverTypes/_paperApiProvider.js index 6668eaf..f5d4529 100644 --- a/src/mc/serverTypes/_paperApiProvider.js +++ b/src/mc/serverTypes/_paperApiProvider.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); const { log } = require('../../utils/log'); const { verifyChecksum } = require('./_verifyChecksum'); -const { classifyMcId, pickPreferredBuild } = require('./_channels'); +const { classifyMcId, pickLatestBuild, compareBuilds } = require('./_channels'); /** * Factory that creates a provider for any PaperMC API v3 project @@ -95,21 +95,25 @@ function createPaperApiProvider({ project, id, name, description, icon, logo }) throw new Error(`Unexpected ${name} builds response format.`); } + // Newest-first. This used to just `.reverse()` the array on the + // assumption that Fill hands back oldest-first; Fill now returns + // newest-first, so reversing put the OLDEST build at the head and + // every auto-selected jar and upgrade check read from the wrong + // end of the list. Sort on the build number and the answer no + // longer depends on upstream ordering at all. return data .map(b => ({ build: b.id, channel: b.channel })) - .reverse(); // newest-first + .sort((a, b) => compareBuilds(b.build, a.build)); }, async downloadJar(version, build, destPath) { - // Auto-select the newest stable build if none specified; versions - // that only ship non-stable builds (experimental) fall back to the - // newest build of any channel. + // Auto-select the newest published build if none specified. if (!build) { const builds = await this.getBuilds(version); if (!builds || builds.length === 0) { throw new Error(`No builds available for ${name} ${version}.`); } - build = pickPreferredBuild(builds).build; + build = pickLatestBuild(builds).build; } // Fetch build details to get the direct download URL diff --git a/src/mc/serverTypes/fabric.js b/src/mc/serverTypes/fabric.js index 30801cb..f4d320a 100644 --- a/src/mc/serverTypes/fabric.js +++ b/src/mc/serverTypes/fabric.js @@ -46,23 +46,25 @@ module.exports = { if (!res.ok) throw new Error(`Failed to fetch Fabric loader versions: HTTP ${res.status}`); const loaders = await res.json(); - const stable = loaders.find(l => l.stable) || loaders[0]; - if (!stable) return null; - return { build: stable.version, channel: stable.stable ? 'stable' : 'beta' }; + // Newest first in Fabric's listing, and the newest is what Craftbox + // targets — the same rule the other loaders now follow. + const newest = loaders[0]; + if (!newest) return null; + return { build: newest.version, channel: newest.stable ? 'stable' : 'beta' }; }, async downloadJar(version, build, destPath) { // Honor a pinned loader version (modpacks pin fabric-loader exactly); - // otherwise use the latest stable loader. + // otherwise use the newest loader Fabric publishes. let loaderVersion = build || null; if (!loaderVersion) { const loaderRes = await fetch(`${BASE}/versions/loader`); if (!loaderRes.ok) throw new Error(`Failed to fetch Fabric loader versions: HTTP ${loaderRes.status}`); const loaders = await loaderRes.json(); - const stableLoader = loaders.find(l => l.stable) || loaders[0]; - if (!stableLoader) throw new Error('No Fabric loader versions available.'); - loaderVersion = stableLoader.version; + const newestLoader = loaders[0]; + if (!newestLoader) throw new Error('No Fabric loader versions available.'); + loaderVersion = newestLoader.version; } // Get the latest installer version @@ -70,9 +72,9 @@ module.exports = { if (!installerRes.ok) throw new Error(`Failed to fetch Fabric installer versions: HTTP ${installerRes.status}`); const installers = await installerRes.json(); - const stableInstaller = installers.find(i => i.stable) || installers[0]; - if (!stableInstaller) throw new Error('No Fabric installer versions available.'); - const installerVersion = stableInstaller.version; + const newestInstaller = installers[0]; + if (!newestInstaller) throw new Error('No Fabric installer versions available.'); + const installerVersion = newestInstaller.version; const downloadUrl = `${BASE}/versions/loader/${encodeURIComponent(version)}/${loaderVersion}/${installerVersion}/server/jar`; diff --git a/src/mc/serverTypes/forge.js b/src/mc/serverTypes/forge.js index daf36ba..53e27cb 100644 --- a/src/mc/serverTypes/forge.js +++ b/src/mc/serverTypes/forge.js @@ -4,6 +4,7 @@ const { spawn } = require('child_process'); const { log } = require('../../utils/log'); const { getJavaForVersion } = require('../../utils/javaVersion'); const { verifyChecksum } = require('./_verifyChecksum'); +const { pickLatestBuild, compareBuilds } = require('./_channels'); const PROMOTIONS_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json'; const MAVEN_BASE = 'https://maven.minecraftforge.net/net/minecraftforge/forge'; @@ -79,29 +80,32 @@ module.exports = { const data = await res.json(); const promos = data.promos || {}; + // Newest-first: "latest" is the head of the Forge branch and + // "recommended" trails it, sometimes by a dozen builds (MC 1.20.1: + // 47.4.23 against 47.4.10). const builds = []; const latest = promos[`${version}-latest`]; const recommended = promos[`${version}-recommended`]; - if (recommended) { - builds.push({ build: recommended, channel: 'recommended' }); - } - if (latest && latest !== recommended) { + if (latest) { builds.push({ build: latest, channel: 'latest' }); } + if (recommended && recommended !== latest) { + builds.push({ build: recommended, channel: 'recommended' }); + } - return builds; + return builds.sort((a, b) => compareBuilds(b.build, a.build)); }, async downloadJar(version, build, destPath) { - // Auto-select build if none specified + // Auto-select build if none specified: the newest Forge published for + // this MC version, not the older "recommended" promotion. if (!build) { const builds = await this.getBuilds(version); if (!builds || builds.length === 0) { throw new Error(`No Forge builds available for MC ${version}.`); } - // Prefer recommended, fallback to latest - build = builds[0].build; + build = pickLatestBuild(builds).build; } const forgeVersion = `${version}-${build}`; diff --git a/src/mc/serverTypes/neoforge.js b/src/mc/serverTypes/neoforge.js index f0ba743..729150e 100644 --- a/src/mc/serverTypes/neoforge.js +++ b/src/mc/serverTypes/neoforge.js @@ -4,7 +4,7 @@ const { spawn } = require('child_process'); const { log } = require('../../utils/log'); const { getJavaForVersion } = require('../../utils/javaVersion'); const { verifyChecksum } = require('./_verifyChecksum'); -const { pickPreferredBuild } = require('./_channels'); +const { pickLatestBuild, compareBuilds } = require('./_channels'); const MAVEN_API = 'https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge'; const MAVEN_BASE = 'https://maven.neoforged.net/releases/net/neoforged/neoforge'; @@ -93,26 +93,24 @@ module.exports = { if (!res.ok) throw new Error(`Failed to fetch NeoForge versions: HTTP ${res.status}`); const data = await res.json(); - const matching = (data.versions || []) + // Newest-first. Sorting on the parsed build number alone left + // "21.9.16-beta" and "21.9.16" in whatever order the API returned them, + // so the beta could be picked over the release it precedes; + // compareBuilds reads the -beta suffix as older. + return (data.versions || []) .filter(v => !/craftmine/i.test(v) && v.startsWith(prefix + '.')) - .map(v => { - const buildNum = parseInt(v.split('.')[2], 10); - return { build: v, channel: isStable(v) ? 'release' : 'beta', _buildNum: buildNum }; - }) - .sort((a, b) => b._buildNum - a._buildNum); - - return matching.map(({ build, channel }) => ({ build, channel })); + .map(v => ({ build: v, channel: isStable(v) ? 'release' : 'beta' })) + .sort((a, b) => compareBuilds(b.build, a.build)); }, async downloadJar(version, build, destPath) { - // Auto-select the newest stable build if none specified; MC versions - // with only beta builds fall back to the newest beta. + // Auto-select the newest published build if none specified. if (!build) { const builds = await this.getBuilds(version); if (!builds || builds.length === 0) { throw new Error(`No NeoForge builds available for MC ${version}.`); } - build = pickPreferredBuild(builds).build; + build = pickLatestBuild(builds).build; } const installerUrl = `${MAVEN_BASE}/${build}/neoforge-${build}-installer.jar`; diff --git a/src/mc/serverTypes/purpur.js b/src/mc/serverTypes/purpur.js index 3cf9f50..a70cae0 100644 --- a/src/mc/serverTypes/purpur.js +++ b/src/mc/serverTypes/purpur.js @@ -3,6 +3,7 @@ const path = require('path'); const { log } = require('../../utils/log'); const { verifyChecksum } = require('./_verifyChecksum'); const paper = require('./paper'); +const { pickLatestBuild } = require('./_channels'); const BASE = 'https://api.purpurmc.org/v2/purpur'; @@ -46,9 +47,12 @@ module.exports = { if (!res.ok) throw new Error(`Failed to fetch Purpur builds for ${version}: HTTP ${res.status}`); const data = await res.json(); + // Newest-first. Purpur lists builds oldest-first, but sort rather than + // reverse so the head of the list stays the newest build even if that + // ever changes. return data.builds.all .map(b => ({ build: Number(b), channel: 'default' })) - .reverse(); + .sort((a, b) => b.build - a.build); }, async downloadJar(version, build, destPath) { @@ -57,7 +61,7 @@ module.exports = { if (!builds || builds.length === 0) { throw new Error(`No builds available for Purpur ${version}.`); } - build = builds[0].build; + build = pickLatestBuild(builds).build; } log('info', `Downloading Purpur ${version} build ${build}...`); diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index 9e070e0..fecb0d3 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -35,7 +35,7 @@ const { STATES } = require('../../mc/stateMachine'); const { isPathInside } = require('../../utils/pathSafety'); const { normalizeGroupName, getGroupColor, pruneGroupMetaIfEmpty, GROUP_NAME_ERROR } = require('../../utils/serverGroups'); const { MC_VERSION_RE, isReleaseVersion } = require('../../utils/mcVersion'); -const { pickPreferredBuild, compareBuilds } = require('../../mc/serverTypes/_channels'); +const { pickLatestBuild, compareBuilds } = require('../../mc/serverTypes/_channels'); const { isEditableFile, listDirectory, safeEntryName, newNameError, readTextWindow, parseReadWindow, MAX_TEXT_BYTES @@ -502,9 +502,9 @@ router.get('/servers/:id/check-upgrade', async (req, res) => { preferred = await provider.getLatestBuild(server.version); } else { const builds = await provider.getBuilds(server.version); - // getBuilds includes non-stable channels — prefer the newest stable - // build so stable servers aren't offered ALPHA/BETA builds. - if (builds && builds.length > 0) preferred = pickPreferredBuild(builds); + // The newest build for the version, matching what a fresh install + // or an upgrade actually downloads. + if (builds && builds.length > 0) preferred = pickLatestBuild(builds); } if (!preferred) { return res.json({ upgradeAvailable: false, reason: 'No builds published for this version.' }); From b61a6c52bae4442b982578cdc66a9047eb7f3084 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Wed, 26 Aug 2026 13:23:38 +0100 Subject: [PATCH 47/60] Give every download a real size and a reported outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser handed a response with no Content-Length has nothing to draw a progress bar from, so a server export showed an indefinite "Resuming..." for however long it ran, with no size, no percentage and no ETA. A zip's length is not known until its last entry is written, so the four archive downloads — server export, server files, mods/plugins, and the public mods zip — now pack into a staging file first and stream that with an exact Content-Length. The single-file downloads take theirs from the file on disk; the backup archive takes it from the file rather than the stored record, so a zip that was replaced or truncated underneath can no longer advertise a length its body never matches. Packing first costs a pause before the first byte on a large server and buys back more than it spends. Failures now land before any header is sent, so a doomed export answers with a JSON error instead of a truncated archive, and a free-space check refuses with 507 rather than filling the disk. The export holds the backup lock only while packing, not for the whole client transfer. And a client who gives up during packing aborts the pack, instead of leaving a CPU compressing bytes nobody will read. Outcomes are reported too. The panel starts downloads through a hidden iframe and mints a token onto the URL; the request reports packing progress, completion, cancellation and failure back over the server WebSocket against that token, so the page can say "Preparing Server export — 412 MB of 1.1 GB", then "Server export downloaded (680 MB)", and can finally show the reason a download was refused. A 409 for a running server used to be a flash message nobody saw, or a page replaced by a JSON error body. Staged archives are deleted as their response ends, swept on boot, and reaped after an hour if something wedges. Deleting one retries: Windows will not unlink a file with a handle still open on it, and the handle outlives the event that closed the stream. --- docs/API.md | 18 +- public/js/download.js | 215 +++++++++++++++++++ public/js/edit.js | 6 +- src/mc/BackupManager.js | 10 +- src/mc/ServerManager.js | 4 +- src/routes/api-v1/backups.js | 45 ++-- src/routes/api-v1/servers.js | 149 +++++++------ src/routes/plugins.js | 84 +++++--- src/routes/servers.js | 83 ++++--- src/routes/status.js | 36 ++-- src/server.js | 3 + src/utils/download.js | 406 +++++++++++++++++++++++++++++++++++ src/utils/formatSize.js | 15 ++ views/partials/foot.ejs | 1 + views/servers/backups.ejs | 3 +- views/servers/files.ejs | 2 + views/servers/plugins.ejs | 6 +- 17 files changed, 902 insertions(+), 184 deletions(-) create mode 100644 public/js/download.js create mode 100644 src/utils/download.js create mode 100644 src/utils/formatSize.js diff --git a/docs/API.md b/docs/API.md index e90378f..954997f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -159,7 +159,7 @@ Paths are relative to the server directory and are resolved against it with syml |---|---|---| | 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` | +| 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 | @@ -229,7 +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) | -| GET | `/servers/:id/backups/:backupId/download` | Stream the backup archive as `application/zip`. `404` if the backup does not belong to this server | +| 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 @@ -238,7 +238,9 @@ Move a server — files, Craftbox settings, and optionally backups and event his ### Export -`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. +`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. @@ -350,6 +352,8 @@ Reads work in any state. The **mutating** routes require the server to be `stopp | 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 @@ -417,7 +421,7 @@ Unauthenticated, mounted at the site root (not `/api/v1`). The `statusPagePublic | 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. @@ -453,10 +457,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/public/js/download.js b/public/js/download.js new file mode 100644 index 0000000..4109e4e --- /dev/null +++ b/public/js/download.js @@ -0,0 +1,215 @@ +// ── 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="