Skip to content

1.2.0 - #62

Draft
WillTDA wants to merge 48 commits into
masterfrom
1.2.0
Draft

1.2.0#62
WillTDA wants to merge 48 commits into
masterfrom
1.2.0

Conversation

@WillTDA

@WillTDA WillTDA commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #50, which GitHub closed automatically when the 1.1.1 branch was renamed to 1.2.0.

Promoted from 1.1.1 to a 1.2.0 minor because this adds new API surface rather than only fixing things. Currently versioned 1.2.0-beta.5.

Five prereleases have been cut from this branch, each published to Docker Hub as willtda/craftbox:<version>: beta.1, beta.2, beta.3, beta.4, beta.5.

New

  • File management — the Files tab can upload, rename, delete, create folders and create text files. Drag files anywhere on the page to drop them into the folder being browsed, or use the picker; anything large uploads in chunks so it survives a reverse proxy's request size cap. Getting a file into a server directory previously meant shell access to the host, which is not an option in Docker. Backed by POST /servers/:id/files/upload, /mkdir, /mkfile, /rename and /delete: creating is ungated (matching /edit-file, which has always written into a running server's directory), renaming and deleting require the server stopped.
  • Console APIGET /servers/:id/console reads console output over REST. Serves the durable on-disk log by default, so it works for servers that aren't running and survives a panel restart. The WebSocket rejects bearer keys, so this was the missing half of the existing command endpoint.
  • File APIGET /servers/:id/files, /file and /download. Server export and backup downloads moved under /api/v1, so an API key can reach every file on a server; they previously redirected API clients to the login page. (API endpoint to read/download files #57)
  • Mod/plugin API — list installed mods and read the environment map without downloading the status-page mods zip. (API Endpoint to view mod/plugin list/environment settings #51)
  • Live event log — the event log updates over the WebSocket, capped at 500 rows. Events are now broadcast from logEvent, so backups, jar upgrades and user actions are live for the first time; previously only start/stop/crash reached a socket, and only the public status page listened.
  • Live state gating — controls that require a stopped server now track live state instead of freezing at render time.

Fixed

  • Uploading over a file a running server holds open silently replaced it. The jar, and anything already under the world folders, logs/ or mods//plugins/, are refused while the server runs — per file, so the rest of the batch still lands. Windows refused that write on its own; Linux, which is every Docker install, did not, and overwriting a live jar or world is enough to corrupt a running server.
  • A scheduled backup deferred by re-saving the schedule ran anyway, on its old timing, the first time Craftbox restarted. The new due time was the only record the deferral existed, and shutdown deleted it; the missed-backup check then measured from the last backup, which knows nothing about deferrals. Worst exactly where it is least welcome: deferring during the countdown, then restarting.
  • A mod appeared twice on the Mods page after uploading a jar over a Client Only copy of itself. A client-only mod is stored disabled on disk, so the upload landed beside it instead of replacing it — after which the environment dropdown silently did nothing and deleting removed only one of the pair.
  • State-dependent text and tooltips rendered wrongly across the panel, from one escaping EJS tag emitting class=&#34;d-none&#34; in twelve places: both halves of the running/stopped hints on Settings and Properties showed at once, the Events Clear button stayed on an empty log, and the disabled-state tooltips on Files, Mods and Plugins came through cut off after their first word.
  • Backing up a server mid-provision archived a half-downloaded jar and could start it before setup finished. Provisioning now blocks backups, restores, jar upgrades and restarts, and its management pages stay closed. Root cause was setOperationalState never consulting the transition table. (Server can be backed up while still provisioning #56)
  • Update Jar was unreachable on modpack and imported servers: a server with no recorded build was refused the one operation that records a build, and builds were compared as strings, so 21.1.100 read as older than 21.1.95. (Update Jar feature not working if server was imported or created from a modpack #60)
  • A restart briefly passed through stopped with the power buttons live. Starting in that window rebuilt the process while the old respawn timer was still armed, launching a second JVM in the same directory on the same port.
  • GET /servers/:id/console?limit=0 returned the default 200 lines instead of clamping to 1. GET /servers/:id/events?limit= had the same shape with no lower bound at all, where a negative limit silently dropped the newest events.
  • The rename and new-folder dialogs let you submit a name the server was always going to reject; their confirm button now gates on the same rules the API applies. Required fields missing the red asterisk were marked with it — Version and JAR URL on Settings, and the name field in the rename, new-folder and group dialogs.
  • The Files tab did not point NeoForge servers at their Mods tab when browsing mods/, which every other mod loader already did.
  • Session expiry surfaced as a bare unauthorized toast with no recovery. (Better unauthenticated error handling #52)
  • The .mrpack / .cbx upload dialogs stayed editable during upload, silently discarding edits. (Input elements do not lock/disable while .mrpack or .cbx is uploading. #59)
  • Event log timestamps now show the absolute date and time alongside the relative age.
  • Documented unlisted status page access as intentional rather than a vulnerability. (Remediate security vulnerability false positive #58)

Navigation and layout

  • A server's back arrow returns to its group page rather than always the dashboard, so working through the servers in a group no longer means clicking back into it each time. Servers without a group are unchanged.
  • Assign Group is centred on Settings, as it already was on Create. Both forms now call one centerLoneRowItems in app.js — the create form on every validation pass, since its columns come and go with modpack mode; the settings form once at init.
  • Both create dialogs on the Files tab quote the folder they are creating into, so a folder name containing spaces stops running into the sentence around it.
  • Row action icons align right rather than centre.

Verification

No test suite exists in the repo. Everything through beta.4 was verified with throwaway harnesses against an isolated copy of the app and then click-tested in a browser against a live Docker instance: the restart guard, view rendering in both server states, the event log contract and broadcast pipeline, the console endpoint (path traversal, auth, limit clamping), and the beta.2 file manager surface end to end. That pass found three defects — the busy-file upload, the Clear button, and limit=0 — all fixed in beta.3 and listed above.

Still outstanding: the beta.3 fixes have not been re-tested in a browser, and nothing in beta.5 has been through one at all. A fresh install, an upgrade over a 1.1.x data directory, and a Docker build from source have not been exercised; neither has anything needing a real Minecraft client. TODO.txt is the checklist and is rewritten each beta, so what is unticked in it is always what is outstanding for the build in hand.

docs/API.md tracks the shipped behaviour of every endpoint added here, including the busy-file rejection rules and the ungated-create/gated-destroy split.

WillTDA and others added 26 commits July 26, 2026 18:44
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](WiseLibs/better-sqlite3@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] <support@github.com>
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](express-rate-limit/express-rate-limit@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] <support@github.com>
Bumps [sharp](https://github.com/lovell/sharp) from 0.34.5 to 0.35.3.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](lovell/sharp@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] <support@github.com>
…/express-rate-limit-8.6.1

Bump express-rate-limit from 8.6.0 to 8.6.1
…/better-sqlite3-13.0.2

Bump better-sqlite3 from 13.0.1 to 13.0.2
…/sharp-0.35.3

Bump sharp from 0.34.5 to 0.35.3
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.
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.
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.
…toast (#52)

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.
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.
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.
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.
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 <a href> 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.
Bump package.json, package-lock.json and the README badge.
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.
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.
The event log was a static render — new events only appeared on reload, even
though the page already holds an open socket. The public status page has done
this for a while; the panel's own log had not.

Events were only broadcast from three hand-written call sites in ServerProcess,
covering started/stopped/crashed/restarted. Everything else — backups, jar
upgrades, player joins, user actions — was written to the database and never
reached a socket at all. Broadcast from logEvent instead, so every event type is
live by construction, and drop the three now-duplicate broadcasts. The sink is
injected at boot rather than imported, since ServerProcess already depends on
eventLogger and the reverse would be circular.

The payload now carries initiatedBy and playerName so a live row can render the
same "Initiated By" cell as a server-rendered one, and the event id so clients
can identify rows. Public sockets are unaffected: broadcast() still filters them
down to the started/stopped/crashed/restarted allowlist.

On the page, rows are built from the same badge/icon map the view uses, handed
to the client as JSON so the vocabulary stays defined once. New rows go on top
and the oldest is dropped past 500, matching the server-side prune. An active
type filter suppresses events that don't match it, rather than silently
contradicting the dropdown. The table and the empty state are now both always
rendered so the first live event can swap between them, and the Clear button
appears with it.
Console output was reachable only over the WebSocket, which rejects bearer
keys. An API-key client could send a command via POST /servers/:id/command but
had no way to see the reply — the only workaround was reading
logs/craftbox-console.log through the file endpoint, unbounded and unparsed.

GET /servers/:id/console returns recent output oldest-first as
{timestamp, line} pairs, with limit 1-1000 (default 200) and a truncated flag.

Two sources, because they genuinely differ. The log file is durable,
timestamped and survives a panel restart, so it is the default; it is
append-only and never rotated, so reads tail a bounded window from the end
rather than loading the file. The in-memory buffer is shorter and untimestamped
and is discarded whenever the process object is rebuilt, but it holds the few
[Craftbox] lines emitted after the log stream closes on exit, which never reach
disk. `auto` prefers the file and falls back to memory for a server that has
never been started on this install.
This release adds new API surface — file read/download, mod and plugin
listing, and console read — plus live state and event-log updates in the panel.
That is new functionality rather than a patch, so it moves to a 1.2.0 minor and
enters beta rather than shipping as 1.1.1.
The Files tab could browse, download and edit text, but nothing could be put
into a server directory or taken out of one. Anything that is not a .jar for a
mod loader — a datapack, a world, a plugin config, a resource pack — had to be
placed by hand on the host filesystem, which is not an option for anyone
running the panel in Docker or on a box they do not have a shell on.

Four endpoints under /api/v1, grouped like the plugins routes rather than
following the older edit-file shape:

  POST /servers/:id/files/upload   multipart + `path`, or DGUP for large files
  POST /servers/:id/files/mkdir
  POST /servers/:id/files/rename
  POST /servers/:id/files/delete

Creating is ungated and destroying is not. Upload and mkdir work in any state,
matching edit-file, which has always written into a running server's directory;
requiring a full stop to drop in a datapack would make the feature useless for
the case people actually want it for. Rename and delete require the server
stopped, since those are the destructive pair and a running server holds open
handles — a write that loses to one of those handles comes back as a 409 with
the same wording /download already uses, rather than a 500.

Uploads overwrite an existing file and report how many they replaced, so the
outcome is visible instead of silent. A name already taken by a folder is
rejected per-file, leaving the rest of the batch alone.

There is no extension allowlist. An authenticated caller can already edit any
text file and download the whole directory, so filtering here would be theatre;
names are still reduced to a single path segment, and rename/mkdir additionally
refuse characters and reserved device names that would fail confusingly at the
filesystem layer — on Windows now, or after an export and import later.

The page itself follows the Mods tab: picker plus full-page drag and drop,
progress in the shared overlay, confirm modals, flash toast and reload. Rename
and delete carry data-enable-when so they track live state over the WebSocket
without any bespoke JavaScript; upload does not, since it only needs a
selection. Folders get rename and delete too, with sterner confirm copy given
the delete is recursive.

Also adds neoforge to the content-type map in files.ejs, which serverNav has
had all along.
A client-only mod lives on disk as <name>.jar.disabled. Uploading that same jar
again wrote <name>.jar beside it and left the disabled copy in place, so one mod
became two files — and listModFiles emits a row per file, so the Mods page
listed it twice under the same name.

From there it got worse rather than settling. enableOnDisk and disableOnDisk
both skip their rename when the other form already exists, so the environment
dropdown silently did nothing for that mod. Delete removed only whichever form
it found first, so the mod reappeared on the next load. reconcileModFiles heals
a pair on the next start, but only for mods carrying a client/server tag — a
mod on the default `both` has no map entry, so its pair was never repaired.

Fix the cause: an upload removes a disabled twin and clears the mod's
environment tag, so the file it just replaced does not linger. Uploading is an
explicit "put this on the server", so the mod comes back as Client and Server
rather than staying tagged client-only from its previous life.

Then make the two consumers robust to a pair arriving some other way — a
hand-managed mods folder, or, now, a file dropped in through the file manager.
listModFiles collapses both forms onto the enabled one, and delete unlinks
every form it finds.

Uploads also report how many files they replaced, which the response never
carried, so re-uploading a mod said "1 mod uploaded" and gave no hint that
anything had been overwritten. The toast now reads "1 mod uploaded, 1 replaced."
The backend of a change can be exercised over HTTP, but the things that
actually broke in this cycle — a control that fails to re-enable when the
server stops, an event row that renders differently live than it does from a
page render, a restart window where Start is briefly clickable — only show up
in a browser with a real server behind it.

Collects those into TODO.txt, covering both betas: the file manager and mods
fixes from beta.2, and live state gating, the live event log, the restart race
and the new read endpoints from beta.1.
Adds file management to the Files page — upload, rename, delete and new folder
— which was the last thing in the panel that still needed shell access to the
host. Also fixes a mod being listed twice, and silently duplicated on disk,
when a jar was uploaded over a disabled copy of itself.

Still a beta: the file manager is new surface that writes to and deletes from
server directories, and wants a run against real servers before it ships as
1.2.0. TODO.txt has the checklist.
@WillTDA WillTDA changed the title 1.2.0-beta.1 1.2.0 Aug 4, 2026
WillTDA added 3 commits August 4, 2026 22:07
The rename and new-folder modals added in beta.2 shipped with their confirm
button always enabled, unlike every other modal in the panel. Both now start
disabled and enable only once the name would actually be accepted — the check
mirrors safeEntryName/newNameError, so the button no longer lights up for a
name the API is about to refuse.

Also marks the required fields that were missing the asterisk: Version and the
JAR URL on Settings (both already starred on Create), and the name field in the
rename, new-folder and group modals. Neither Settings field takes a `required`
attribute — they render prefilled and go disabled while the server runs, and an
existing server with a blank one would leave the shared submit gate wedging Save
with no way out.
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=&#34;d-none&#34;,
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.
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.
@WillTDA
WillTDA marked this pull request as draft August 4, 2026 22:55
WillTDA added 18 commits August 5, 2026 00:15
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.
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.
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.
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.
Every server page's back arrow went to the dashboard, even when the server had
been opened from a group page — leaving the group and having to click back into
it to reach the next server in it.

It now points at /dashboard/groups/<group> whenever the server has one, and at
the dashboard otherwise. A group only exists while a server is in it, so the
destination is always a page that renders.
Assign Group sits alone on the last row of Advanced Options, so a half-width
field pinned to the left edge read as though a second field was missing beside
it. Centring the column puts it under the gap between Memory and JVM Arguments
instead. No effect below the md breakpoint, where the field is full width.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant