Skip to content

1.2.0 - #62

Draft
WillTDA wants to merge 63 commits into
masterfrom
1.2.0
Draft

1.2.0#62
WillTDA wants to merge 63 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.10.

Ten 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, beta.6, beta.7, beta.8, beta.9, beta.10.

Closes #51, closes #52, closes #56, closes #57, closes #58, closes #59, closes #60, closes #63, closes #64.

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.
  • Any text file opens in the editor — editability is decided by content instead of a fixed extension list: a file is text if its first 8 KB decode as UTF-8, hold no NUL byte and are not mostly control characters, so .jsonl, .json5, a mod's own invented config extension, or a name with no extension at all now open without anyone adding them anywhere. Reads over 5 MB take a byte window (?tail= or ?offset=&limit=) instead of the whole file, so a growing log can still be read while the server writing it stays up. (Support more text file types #63)
  • 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)
  • Typing a name containing a path separator into rename, new-folder or new-file (e.g. sub/notes.txt) silently created just the last segment in the current directory instead of being rejected, on all three of mkfile, mkdir and rename. The browser already refused a slash before sending the request, and the API docs already said a name must be a single path segment — the server was the one place not checking. It now runs the same validation, so all three descriptions match.
  • Loaders tracked the newest stable-channel build rather than the newest build outright: Forge servers could sit over a dozen builds behind their own up-to-date check, and Paper/Folia auto-selected the oldest stable build on a version instead of the newest, because an upstream build list assumed to be oldest-first was actually newest-first. Every loader now targets the newest build published for the chosen Minecraft version.
  • Downloads (server export, backups, file/mod archives) reported no size or progress, so a large export just looked hung. Archives are now packed to a staging file first and streamed with a real Content-Length, with packing, completion, cancellation and failure reported live over the WebSocket; a doomed export now answers with a JSON error instead of a truncated file. Starting a second download no longer cancels the first — each gets its own transfer frame instead of sharing one.
  • The Docker build's npm ci could fail outright: an npm 9 lockfile rewrite had pruned sharp's non-Windows platform binaries, so a Linux container had nothing to resolve its native dependency against. Lockfile regenerated with npm 11, which keeps every platform; no dependency versions changed.
  • Dragging a folder onto any of the panel's drop targets ended in a bare network_error toast. A folder does not arrive as the files inside it — the browser hands over a single zero-byte entry standing for the directory itself, which passes for an ordinary file right up until something reads it. It was queued like any other upload, the send then failed inside the browser rather than at the panel, and the catch-all error handler was left showing the user its own error code. Every upload surface now recognises a folder before anything is sent and names what to drop instead — including the Create page's modpack picker, which is a drop target by way of the browser's default and where a directory named something.mrpack cleared the extension guard and failed the same way — and a folder dropped alongside real files no longer takes those files down with it. Uploading a directory tree stays unsupported by design — an upload flattens into one destination folder rather than recreating a hierarchy under it — and docs/API.md now says so. (Attempting to upload a folder throws "network_error". #64)
  • A file dropped anywhere the page does not handle navigated the tab to that file's own URL. Only the four pages owning an upload ever cancelled the browser's default, so on every other page — Create, groups, backups, templates, properties, events, the file editor — a stray or mis-aimed drop threw away whatever was on screen, a half-filled Create form included. Cancelled once for the whole panel now, gated on the drag carrying files so dragging selected text into a text box still works; a blanket cancel would have broken that on the file editor. The four per-page copies are gone with it.

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.
  • The Files toolbar's New Folder and New Text File buttons wrap to their own row below the md breakpoint instead of crowding out the upload picker on narrow screens.

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.

Since beta.5, TODO.txt has recorded two further full passes against a live Docker instance: beta.6 (52 of 55 checks, turned up the mkfile/mkdir/rename path-segment gap fixed above) and beta.8 (turned up the shared-download-frame cancellation bug fixed above). Neither beta.9 nor beta.10 has been through a pass of its own. 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. There is none in the tree for beta.10, whose change is the panel's drop handling and a docs correction.

Beta.10's two fixes were verified directly rather than by checklist: real Chrome driven over the DevTools Protocol, dragging real directories off disk into the actual page scripts — a synthesised DataTransfer cannot produce a directory entry that behaves like one. The folder-only, folder-alongside-a-real-file and unchanged-files cases were each run against the file manager, the mods/plugins page, the dashboard import and the server icon zone. That reproduced the reported network_error before the change and confirmed nothing reaches the network for a folder after it, with an empty extension-less file still treated as a file. An input-level round followed after the first fix proved too narrowly scoped: the Create picker, and pages with no upload at all, where dropped files are confirmed cancelled while a text/plain drag still passes through untouched. The four pages that lost their local drop guards were re-run in full against the global one. It is targeted at the fix, not a pass over the build as a whole.

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

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.
WillTDA added 30 commits August 7, 2026 00:20
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.
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").
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.
better-sqlite3 13.0.2 → 13.0.3, express-rate-limit 8.6.1 → 8.6.2, sharp 0.35.3
→ 0.35.4, uuid 14.0.1 → 14.0.2 and ws 8.21.1 → 8.21.3. All patch releases; the
native modules load and both download suites pass against them. `allowScripts`
carries the two new native versions, or the Docker build's strict mode would
refuse to run their install scripts. The lockfile still carries all 26 of
sharp's platform binaries, so `npm ci` resolves the same on linux/amd64.

archiver 8.0.0 and content-disposition 3.0.0 are held back. Both are ESM-only
releases, and Craftbox is CommonJS throughout — taking either means converting
the whole application, which is not a small breaking change. content-disposition
also dropped its default export in 2.0.0 and stopped reducing a filename to its
basename, both of which this codebase relies on.
The pause before a large server's download appears is new and unexplained
otherwise — the request looks like it has hung when it is compressing a few
gigabytes into the staging area. Name the trade so the wait reads as the price
of the size and progress the browser can now show.
Three changes ship in this build.

Every loader now installs and tracks the newest build upstream publishes for
the chosen Minecraft version. Forge was pinned to the "recommended" promotion —
MC 1.20.1 provisioned 47.4.10 while Forge was on 47.4.23, and the upgrade check
read back the same stale value, so a server on the recommended build was
reported up to date indefinitely. Paper and Folia were further out: their
provider reversed an already newest-first list, so they auto-selected the
OLDEST stable build on a version.

Downloads get an exact size and a reported outcome. Archives are packed before
the response starts, which is the only way their length can be known, so the
browser shows MB of MB and an ETA rather than an open-ended "Resuming...". The
panel now reports packing progress, completion, cancellation and refusal as
toasts — a 409 for a running server was previously a flash message nobody saw.

Five dependencies move up a patch release; archiver 8 and content-disposition 3
are held back as ESM-only.

The download work is covered by a 14-case unit suite over the delivery util and
a 12-case suite driving every download endpoint against a real instance, both
green. The browser pass is still outstanding.
The upload row put the file picker and three buttons on one line. Below the md
breakpoint the buttons took most of the width and the picker was squeezed into
what was left, which on a phone is barely enough to read the selected filename.

New Folder and New Text File now sit in their own group that wraps onto a second
row under md, using the same flex-column/flex-md-row pattern the dashboard and
the server controls already use. The 400px cap moves from the input to the
picker-and-Upload group, so the input grows into whatever that row does not need
rather than being sized against buttons that are no longer beside it. Nothing
changes at md and above.
Starting a second download cancelled the first. ensureFrame() handed back one
shared iframe and start() overwrote its src, and pointing a frame that is still
streaming at a new URL aborts the transfer at the browser level. The panel
reported it accurately, which is what made it visible: the first download's
toast read "cancelled" the moment the second was clicked.

Each in-flight download now gets its own frame. Frames come from a pool and
rejoin it only once their download has terminally settled - complete, cancelled
or failed - so a frame is never reused while it might still be carrying bytes,
and a page that downloads repeatedly does not accumulate them without bound.

The silence timeout deliberately does not release its frame. Sixty seconds of
quiet from the socket does not mean the transfer ended; it means we stopped
hearing about it, and on a page with no server WebSocket that is the normal
outcome. Returning that frame to the pool would let a later download abort
exactly the transfer already lost track of, so it is retired instead.

Found by B7 of the beta.8 checklist, on a dev instance and again inside the
built beta.8 image.
Closed at 77/77 against a live instance driven through a browser, with real
provisioning from the Forge, Paper and Fabric APIs, and section B re-run against
a container built from the repo's own Dockerfile.

One genuine failure, written up as NOTE 1 and fixed in d4850e9: concurrent
downloads shared a single hidden iframe, so starting a second one aborted the
first mid-transfer. It reproduced identically inside the built image.

The other three notes are the checklist being wrong rather than the code. Vanilla
answers "No builds published for this version." because its getBuilds() returns
null rather than being absent, so check-upgrade takes the generic branch - that
predates beta.8 and is left alone here. The specific backup-lock export message
is effectively unreachable, since the export's own state check wins the race. And
groups are keyed by name by design, which the uuid bump did not change.
Closes out the checklist at 77/77 and drops it. This is beta.8's feature set with
its test pass finished: the one failure it found is fixed, and the three other
notes were the checklist being wrong rather than the code.

The concurrency bug is the reason this build exists. Every download shared one
hidden iframe, so starting a second aborted the first mid-transfer - a real
cancelled download, not a mislabelled toast. It reproduced on a dev instance and
again inside an image built from the Dockerfile. Each in-flight download now
holds its own frame, released back to the pool only when its transfer has
terminally settled; d4850e9 has the reasoning on why the silence timeout retires
its frame instead.

Also in this build: the file manager's New Folder and New Text File buttons wrap
to a second row below the md breakpoint, so the upload picker is usable on a
phone.

Nothing in beta.8's build-targeting or download-size work changed here, and no
dependency moved - the versions this lockfile pins are the ones beta.8 declared.
The lockfile also picks up the hasInstallScript flag npm writes for
better-sqlite3, which was missing from the entry beta.8 shipped.

TODO.txt goes with this commit as usual. It is rewritten each beta, there is
nothing left unticked in it, and the history keeps every version.
Dragging a folder into any of the panel's drop targets ended in a red
"network_error" toast. A folder does not arrive as the files inside it: the
browser puts a single entry in dataTransfer.files standing for the directory,
size 0 and no type, which looks like an ordinary file right up until something
reads it. uploadFiles queued it like any other, FormData took it, and the send
then failed inside fetch - so apiFetch's catch-all was the only thing left to
answer with, and it showed the user its own error code.

Nothing here could have uploaded it anyway. Every upload endpoint writes flat
files into one directory and reduces a name to its basename (safeEntryName), so
a directory tree has nowhere to go. Recreating one is a feature rather than a
fix, and issue #64 offers refusing clearly as the alternative.

readDroppedItems (app.js) now splits a drop into files and folders before
anything is queued, using webkitGetAsEntry - which has to be called
synchronously inside the drop handler, before the item list is emptied, and
whose entry.isDirectory is definitive. The size/type shape check only stands in
where that API is missing. All four drop targets go through it: the file manager
warns and uploads the rest of the drop, plugins and import name what to drop
instead, and the icon zone answers in its own status line. A folder alongside a
real file no longer takes that file down with it - the icon zone read files[0]
before this, so a PNG dropped second was rejected as "not a PNG".

Verified in Chrome over the DevTools Protocol, dragging real directories off
disk into the real page scripts. Before: dataTransfer.files holds
{name:"world", size:0, type:""} and the POST fails with "Failed to fetch".
After: the folder is named in the toast, no request is made at all, and real
files in the same drop still upload. An empty extension-less file (LICENSE)
stays a file, which is the one false positive the shape check could cause and
webkitGetAsEntry rules out.

The API doc's note on upload naming claimed a browser sends a folder's relative
path as the filename. It does not - that is a webkitdirectory input or an API
client, and the drop case never got that far. Corrected, with the flattening
rule and the refusal written down beside it.
A single-fix build on top of beta.9. Dropping a folder into any of the panel's
drop targets answered with a red "network_error" toast rather than saying that
folders cannot be uploaded; 909d158 has the detail on where the request actually
failed and why apiFetch had nothing better to report.

Verified by driving real Chrome over the DevTools Protocol and dragging real
directories off disk into the page scripts themselves, rather than by
synthesising a DataTransfer - a folder's entry only behaves like a folder when
the browser made it. The folder-only, folder-alongside-a-real-file and
unchanged-files cases were each run against the file manager, the plugins page,
the dashboard import and the server icon zone. Nothing reaches the network for a
folder now, and real files dropped beside one still upload.

No dependency moved. The lockfile pins exactly what beta.9 declared and the only
lines that changed in it are the two version strings, so beta.9's feature set is
otherwise untouched.

No TODO.txt this time. The checklist is written for builds that move a feature
surface; this one changes four drop handlers, and the browser-driven run above
covers what would have been on it.
The folder fix covered the four pages that handle drops themselves and stopped
there. That was the wrong boundary. Only 4 of the 26 frontend scripts ever
cancelled the browser's default handling of a dropped file, so on every other
page - Create, groups, backups, templates, properties, events, the file editor -
dropping a file still navigated the tab to that file's own URL, taking any
half-filled form with it.

The create page had both halves of the problem. It has no drop zone, so its
.mrpack picker is the drop target by way of the browser's default, and a folder
dropped on it went straight into the input. guardFileInput's extension check hid
that for most folder names, but a directory named something.mrpack cleared it and
the upload then failed as the same bare network_error issue #64 reported.
Reproduced in Chrome before changing anything.

app.js now cancels the default once, for every page. It is gated on the drag
carrying files - `types` includes "Files", which is readable during a drag where
the data itself is not - so dragging selected text into a textarea still works;
a blanket cancel would have broken that on the file editor. The four per-page
copies are gone, since a weaker duplicate of a global guard only invites doubt
about which one is in force.

Cancelling the default also takes away what put a dropped file into an input, so
acceptFileDrops gives that back to any input that is itself the drop zone. It
classifies with readDroppedItems while the item list is still alive, refuses
folders with the same wording as everywhere else, honours `multiple`, and leaves
an existing choice alone when a drop turns out to hold nothing droppable. Only
the create picker needs it; the other four inputs sit on pages whose own drop
zone already covers them.

Verified in Chrome over the DevTools Protocol, dragging real directories off
disk. Create: a plain folder, a directory named Pack.mrpack, a real .mrpack, and
a folder and a .mrpack together - the folder is named and refused, the .mrpack
still uploads. Pages with no upload: dropped files are cancelled
(defaultPrevented true, no navigation) while a text/plain drag passes through
untouched, which is the regression the types gate exists to prevent. The file
manager, mods/plugins, dashboard import and icon zone were re-run in full and
behave exactly as before losing their local guards.
A .cbx is a snapshot of a server, but two settings were not arriving with it.

The advertised IP was written into the manifest and then thrown away on the way
in, on the reasoning that the address is host-specific. In practice that reads
as data loss: the value is sitting in the archive, the panel never says it was
dropped, and the only clue is a status page that has quietly gone back to
showing the Craftbox hostname. Moving to a host that answers on a different name
is one field edit; being silently reset is not something you can act on until
someone notices. It now comes across with everything else, trimmed and capped at
the same 253 characters the settings field accepts, since a manifest is
untrusted input and this value renders on the public status page.

A group's color was never in the archive at all. It lives in its own record
keyed by the group name rather than on the server, so an imported server kept
its group and lost the color the group was given, arriving on the far side
wearing the default green. The export now carries it in the manifest, and the
import applies it only when the destination has no color of its own for that
name - a group is implicit and shared by every server in it, so an incoming
archive must never restyle servers that were already there.

Both changes stay within formatVersion 1. An older Craftbox ignores manifest
keys it does not know, and only unexpected *files* are rejected on import, so
beta.11 archives still import on beta.10 and earlier - a new groups.json entry
would have failed them outright, which is why the color travels in the manifest.

Verified end to end against a real instance: exported a grouped server with a
custom color and an advertised IP, deleted it so the group record was pruned as
it would be on a fresh host, and imported the archive back - both came home.
Re-importing with the group already colored differently left the existing color
alone. Hand-edited manifests were checked too: a 300-character address truncates
to 253, a non-string one becomes null, and a junk color is ignored rather than
stored.
A single-fix build on top of beta.10. A server transfer archive was leaving two
settings behind - the advertised IP and the group color - and a416314 has the
detail on why one was deliberate and the other was never carried at all.

Verified against a running instance rather than by inspection: a grouped server
with a custom group color and an advertised IP was exported, deleted so its
group record was pruned exactly as it would be on a fresh host, and imported
back. Both settings returned. The case that matters for a shared instance - an
import landing in a group that already exists and is already colored - leaves
the existing color untouched. Hand-edited manifests were run through the same
path to confirm the new field is treated as untrusted input.

No dependency moved. The lockfile pins exactly what beta.10 declared and the
only lines that changed in it are the two version strings, so beta.10's feature
set is otherwise untouched.

No TODO.txt this time. The checklist is written for builds that move a feature
surface; this one changes what two fields do on import, and the round-trip run
above covers what would have been on it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment