ESLint no-useless-escape flagged 156 errors (153 in ko.ts, 3 in tr.ts):
\" inside single-quoted Korean strings and \' inside double-quoted
Turkish strings. The escapes weren't needed because the surrounding
quote style differs from the escaped quote. Visible-text unchanged;
i18n parity green at 5007 leaves × 10 locales.
# Bambuddy 0.2.4.5
**⚠ Upgrade Notes — Read Before Updating**
0.2.4.5 is a substantial patch release on the same 0.2.4 code base — no schema breaks beyond the documented column additions for the new API-key scopes (auto-migrated on first start, dialect-branched for SQLite and Postgres), no Docker entrypoint changes, no Vite/proxy quirks. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on 0.2.4.1 or later.
**Four behavior-change callouts to know about before you upgrade:**
- **API-key permission model switched from denylist to allowlist** (security fix). Two new scope flags were added — `can_manage_library` and `can_manage_inventory` — and existing keys that had `can_queue` set are auto-backfilled with both new scopes on first start so library upload and inventory write keep working for keys you'd previously created as "queue-only". A hardened "read-only" key (no `can_queue`) does **not** silently gain new writes on upgrade. Review the Settings → API Keys page after upgrade if you run integrations against specific keys.
- **External folder mounting is now disabled by default and requires explicit operator opt-in via `BAMBUDDY_EXTERNAL_ROOTS`** (security fix). If you currently use the File Manager → external folder feature (mounting host paths like a NAS share, USB drive, or `/mnt/library`), you **must** set `BAMBUDDY_EXTERNAL_ROOTS=/path/one:/path/two` in your env / `docker-compose.yml` / systemd unit before upgrading, or all mounted external folders will be rejected on next start. The variable takes a colon-separated allowlist of absolute paths; the mount route is also now gated on the admin `SETTINGS_UPDATE` scope rather than `LIBRARY_UPLOAD` because mounting host paths crosses user boundaries. Bambuddy-owned dirs (data dir, log dir, static dir, archive dir) are hardcode-rejected even if added to the allowlist. See [wiki → Docker → External library folders](https://wiki.bambuddy.cool/getting-started/docker/#external-library-folders-bambuddy_external_roots) for examples.
- **Slicer-API sidecar users: bump `BAMBU_VERSION` in `slicer-api/.env` to `02.07.01.57` and rebuild.** If you run the optional OrcaSlicer API sidecar for server-side slicing, the upstream `bambu-studio-api` image switched from the no-longer-published Fedora AppImage to the Ubuntu 22.04 AppImage. The new default in `slicer-api/.env.example` / `slicer-api/docker-compose.yml` matches the new build path. After pulling this release:
```
cd slicer-api
docker compose --profile bambu build --no-cache bambu-studio-api
docker compose --profile bambu up -d
```
Bambuddy users who don't run the slicer-API sidecar are unaffected.
- **Virtual-printer FTP passive port range widened from 50000–50100 (101 ports) to 50000–51000 (1001 ports)** for the non-proxy path. Docker bridge-mode users mapping the old range need to update to `50000-51000:50000-51000`. Docker host-mode and bare-metal users are unaffected. Proxy-mode VPs keep the old range (they pre-bind the printer-side range exactly).
Make a backup before upgrading via Settings → Backup → Create Backup. Native install with `update.sh` snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.
### Docker
```
docker compose pull
docker compose up -d
```
`docker-compose.yml` doesn't need refreshing unless you map the VP passive FTP port range (see above) — no other entrypoint, volume, or env-var conventions changed since
0.2.4.
### Native install — recommended path
```
sudo BRANCH=main /opt/bambuddy/install/update.sh
```
Snapshots the database first and rolls back on failure.
### Native install — manual path
```
sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch --prune --tags --force origin
sudo -u bambuddy git checkout main
sudo -u bambuddy git reset --hard origin/main
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
sudo systemctl start bambuddy
```
### Windows install
0.2.4.5 ships Windows installer (#1529). Existing installs upgrade in place via the Service / Update entries on the installer; fresh installs use the new GUI installer end-to-end.
---
## Highlights
0.2.4.5 is a security-led release. The big-ticket security work — API-key permission allowlist, path-traversal hardening across the upload / import / file-write surface with a fifth CI backstop, a WebSocket auth gate and audit-driven sweep, a vitest dev-dep bump, and a PyJWT floor bump for four upstream advisories — sits alongside a Windows-first installer (#1529), Turkish (#1571) and Korean (#1587) locales, OS-aware system theme detection (#1418), and the second wave of virtual-printer hardening (cipher-pin parity on every slicer-facing TLS context #1610, multi-slicer response routing, MQTT brute-force rate-limit, sticky-keys for seven more fields).
On the fix side, the biggest reports closed in this cycle: multi-plate archives now report filament / time / cost honestly (#1593), card timestamps render in the browser's local timezone instead of UTC (#1602), the multi-run accuracy badge is suppressed when it would be apples-to-oranges (#1608), the cross-distro TLS handshake regression on hardened-policy hosts is fixed at every VP TLS context (#1610), inventory and AMS handling tightened across transparent filament (#1545), profile-only mismatch popups (#1552), per-spool weight sync (#1530 / #1459), the SpoolBuddy tare banner (#1536), and the OIDC `email` claim auto-provisioning gap (#1569). VP `_pending_files` / temp-file leaks, queue-position assignment, DELETE orphan cleanup, sync-from-db race, and slicer-options cache bounds all landed as one slicer-surface audit bundle (#1558) plus follow-ups.
---
## Security
- **API-key permission model rewritten as an allowlist (critical).** The three documented API-key scopes ("Read Status", "Manage Queue", "Control Printer") were enforced only inside the legacy `/api/v1/webhook/*` router; every other route fell through to a 17-entry admin denylist that granted any valid key access to every non-admin endpoint regardless of which scope flags were ticked. Fix: a new explicit mapping pins every non-admin permission to exactly one scope; unmapped permissions return 403 ("administrative operations") regardless of scope. Two new scope flags ship in the same release — `can_manage_library` (gates library upload / update / delete and MakerWorld import) and `can_manage_inventory` (gates inventory create / update / delete and SpoolBuddy kiosk writes). Existing keys with `can_queue` set are auto-backfilled with both new scopes; "read-only" keys don't gain new writes. A new CI test fails the build on any future permission added without a scope classification, so the surface can't silently grow again. See **Upgrade Notes** above for the migration story.
- **WebSocket auth gate.** The proactive WebSocket audit caught that `/api/v1/ws` was broadcasting every printer-status / archive / inventory event to anyone who could reach the HTTP port. The endpoint is now auth-gated like the REST surface; events broadcast only to authenticated subscribers.
- **Path-traversal hardening across the upload / import / file-write surface.** A private report against `POST /api/v1/projects/import/file` traced two attacker-controlled strings being joined to the library directory with no resolve + containment check (`linked_folders[*].name` from the request `project.json` and per-entry `zf.namelist()` paths from the ZIP itself). Concrete escalation: drop a `.pth` into the venv's `site-packages` for code execution on next restart, overwrite the JWT signing-secret file to forge an admin token, or overwrite `~/.ssh/authorized_keys` on native installs. Fix is structural: a new `safe_join_under(parent, *parts)` helper resolves both sides and asserts containment; both vectors now route through it. Adjacent fixes in the same audit: `GET /api/v1/archives/{id}/photos/{filename}` had no traversal guard and was serving arbitrary paths; `ArchiveService.attach_timelapse` accepted printer-FTP-listing-supplied filenames with `..` segments under the compromised-printer threat model. The audit sweep marked every Path-arithmetic site under `backend/app/api/routes/` AND `backend/app/services/` with an explicit `# SEC-PATH-OK` annotation or routed it through the helper; a new CI test (the fifth security backstop) AST-walks both directories and fails the build on any future unsafe-shape join that's neither helper-routed nor marker-annotated.
- **External folder mounting restructured to an opt-in allowlist + admin-only scope.** External folder mounting (host paths like a NAS share or USB drive surfaced into Bambuddy's File Manager) was previously gated on `LIBRARY_UPLOAD` (any non-admin user with that scope) and validated against a small denylist of system directories — meaning anything not on the denylist could be mounted, including the Bambuddy data directory containing other users' archives, the log directory, or arbitrary NFS / SMB mounts. The route now requires the admin `SETTINGS_UPDATE` scope and accepts only paths inside the new `BAMBUDDY_EXTERNAL_ROOTS` env-var allowlist. The default empty allowlist disables the feature outright. Bambuddy-owned directories are hardcode-rejected even if the operator adds them to the allowlist. **This is a breaking change for installs that currently use external folder mounting** — see Upgrade Notes above for the restoration path.
- **VP MQTT brute-force rate-limit.** Bambuddy's virtual printer exposes an 8-character access code on the slicer-facing MQTT port; without rate-limiting it was brute-forceable by anyone who could reach the VP's bind IP. A new per-IP sliding-window limiter (5 failures / 60 s) now blocks further CONNECTs from a failing IP for the rest of the window; successful auth clears the IP's failure history.
- **VP access codes now compared in constant time.** Both the FTP `PASS` handler and the MQTT CONNECT handler used Python's `==` operator on the 8-character access code. Closes the timing side-channel without changing the protocol surface.
- **VP FTP uploads capped at 4 GiB.** `cmd_STOR` now rejects uploads exceeding 4 GiB, deletes the partial file, and replies 426. Without the cap a runaway or malicious client could drive RSS or disk to exhaustion; 4 GiB is well above any realistic multi-plate `.gcode.3mf`.
- **vitest bumped 3.2.4 → 4.1.8** (development dependency) to pick up an upstream CVSS 9.8 advisory. No production-runtime impact, but the floor moves so fresh installs and CI pick up the fix.
- **PyJWT bumped to >=2.13.0** to pick up four upstream advisories. Audit confirmed Bambuddy's usage is unaffected by every behavioural change in 2.13.0 (HMAC empty-key reject can't trigger, OIDC decode uses raw-key path not PyJWK, `jwks_uri` is HTTPS from discovery, no `b64=false` usage, `enforce_minimum_key_length` not opted into); 229 auth/MFA/OIDC integration tests + 78 auth unit tests pass on 2.13.0; `pip-audit --strict` is clean.
- **Trivy DS-0026 silenced on `Dockerfile.test` via `HEALTHCHECK NONE`.** The test image runs `pytest` and exits — no service to probe. Adding a healthcheck would have b
een cargo-cult noise.
---
## New Features
- **Windows installer (#1529, contributed by @vmhomelab).** Installer for Windows hosts — fresh install, service install / uninstall, in-place update, and a Troubleshooting entry. Existing native Windows users can upgrade via the Update entry on the installer.
- **Turkish (`tr`) locale (#1571, contributed by @samedyuksel).** Full Turkish translation across all keys, joining the existing 9-locale set.
- **Korean (`ko`) locale (#1587, contributed by @hijae).** Full Korean translation across all keys.
- **System theme detection — sidebar toggle and Settings selector follow the OS dark/light preference (#1418, contributed by @TempleClause via PR #1501).** `ThemeMode` gains a third value `system` alongside `dark` and `light`. The provider listens to `window.matchMedia('(prefers-color-scheme: dark)')` and tracks the OS preference in real time. The sidebar toggle now cycles `dark → light → system → dark` with the icon hinting at the next stop; Settings → Appearance gained a 3-button Dark / Light / System selector. Existing users' persisted preference is untouched — anyone on `dark` or `light` stays there and simply gains an extra stop in the cycle.
- **MQTT auth rate-limit on the virtual printer.** Per-IP sliding-window limiter (5 failures / 60 s) on VP MQTT CONNECT, brute-force-resistant. See Security section above for the full description.
- **Per-slicer MQTT response routing for multi-slicer VP setups.** Pre-fix: when slicer A sent a bridge-forwarded command to a non-proxy VP bound to a target printer, the printer's response was fanned out to every connected slicer. Now responses are routed to the originating slicer only, falling back to broadcast for printer-initiated unsolicited pushes (push_status etc.) and for sequence_ids the routing map never saw. Bounded at 256 entries with FIFO eviction.
- **VP child-service readiness barrier.** Each VP child sub-service (FTP, MQTT, Bind, SSDP) now exposes a `ready` event that fires after the socket actually binds; `start_server` awaits all of them with a bounded 5 s timeout. Closes a race where `is_running` reported true while the child sockets were still binding, which a quick poll from the diagnostic route or the VP card could observe.
---
## Changes
- **Bug-report template tightened + new Area dropdown.** 170 issues have been closed `invalid` (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The Connection Diagnostic checkbox, Support Package attachment, and a new "Troubleshooting steps already taken" textarea are now required. The old `Bambuddy / SpoolBuddy / Both` dropdown is replaced with two required dropdowns — `Product` and `Area` (15 options covering the actual feature surface) — and an auto-label workflow applies the matching `area:*` label on every issue open/edit.
- **VP virtual-printer FTP server streams uploads straight to disk instead of buffering.** Pre-fix: `cmd_STOR` accumulated every chunk in a list and called `write_bytes` at the end. Peak RSS for a multi-GB `.gcode.3mf` was ~2× the file size and could OOM-kill a low-memory host. The streaming rewrite writes each 64 KiB chunk inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Same change adds the 4 GiB hard cap described above.
- **VP virtual-printer FTP passive port range widened from 50000–50100 to 50000–51000.** Docker bridge-mode users mapping the old range need to update — see Upgrade Notes above.
- **VP MQTT bridge sticky-keys: 7 more fields preserved across incremental pushes.** Pre-fix: a single 1 Hz incremental push (which only carries changed temps / fan / wifi_signal) wiped any field not in the sticky-keys allowlist. The cached state lost `upgrade_state`, `xcam`, `hw_switch_state`, `nozzle_diameter`, `nozzle_type`, `online`, and `ams_status` after a single tick — BambuStudio's Send pre-flight reads several of these and could refuse Send because the cached push said "unknown firmware state". Same shape as #1228 (storage indicators) and #1558 (live-progress fields). Sticky-keys carry-forward is now also a `copy.deepcopy` (was reference) so a future merge can't corrupt both copies.
- **VP target-printer DHCP IP / serial refresh now restarts proxy VPs.** Pre-fix: when a target printer's IP changed (DHCP renewal, network reconfiguration), the running proxy VP kept forwarding to the stale IP forever; the user had to manually toggle the VP to refresh. `sync_from_db` now re-evaluates the proxy target each cycle and restarts the VP when the IP or serial actually changes.
- **VP `queue_force_color_match` setting takes effect immediately.** Pre-fix: toggling the per-VP "Force exact color match" setting via the UI silently no-op'd because `sync_from_db`'s "changed" predicate didn't include the field. Now restarts the running instance on toggle.
- **VP MQTT client session errors elevated from DEBUG to WARNING.** Production never sees the message at DEBUG; legitimate slicer-side errors (TLS handshake failure, protocol violation) deserve to be visible in the default log level.
- **VP MQTT periodic status push: one-line per-minute counter per active slicer connection (#1548 follow-up).** Replaces the noisy per-tick log line with a single per-minute summary, while still surfacing connection-drop events at WARNING.
---
## Fixed
**UI / rendering**
- Print-run log, spool usage history, camera-token list, and SpoolBuddy device "last calibrated" timestamps now render in the browser's local timezone instead of UTC (#1602, reported by @maziggy, confirmed by @IndividualGhost1905 with a UTC+3 reproduction). Same shape as the #504 timezone-offset bug — four sites the #504 sweep didn't catch because they were added afterwards or were missed.
- Archive card's Print Time + accuracy badge are now consistent for multi-run / multi-plate archives (#1608). The card was showing one run's actual duration next to a whole-file estimate, producing badges like "+188%". Multi-run archives now show "Estimated 5h 6m" with no badge; single-run archives keep the badge.
- Cancelled prints have their own stats bucket and no longer drag down the Success Rate gauge (#1390 follow-up).
- Cancelled bucket icon now uses the semantic warning token (orange) instead of an unrelated palette colour (#1390 follow-up).
- Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by @IndividualGhost1905).
**Slicer / library**
- Sliced `.gcode.3mf` files now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by @Vlado-Tarakan).
- External-folder `.gcode.3mf` files now show thumbnails, and every ingest path stores the same canonical `file_type` for sliced outputs (#1600, reported by @maziggy).
- Deleted local profiles no longer linger in the SliceModal preset dropdown; new manual "Refresh" button surfaces cloud-side deletions without waiting for the 5-minute cache (#1581, reported by @lloydcat).
- STL thumbnail noise on first generation: matplotlib cache + font_manager scan no longer log three WARNING lines on first STL upload (reported by @maziggy).
- Bulk-upload ZIPs of stub / empty STL files no longer spam the log with thousands of warnings (reported by @maziggy).
- Print filenames with FAT32-illegal characters now rejected at rename / upload / queue time instead of failing at FTP (#1540, reported by @anthonyma94).
**Archive / stats**
- Multi-plate `.gcode.3mf` archives and reprints no longer under-report filament, time, and cost — project stats and parser both fixed (#1593, reported by @needo37). 3-plate file printed plate-by-plate over 9 runs was showing 1/9th of the real material consumption.
- Source-3MF upload on "fallback" archives no longer crashes with HTTP 500 (and stops orphaning files outside the data volume) (#1531, reported by @d3nn3s08).
- Fallback archives now carry MQTT-derived filament type + colour when the 3MF can't be downloaded (#1533, reported by @JmanB52D), plus a follow-up fix for real prints (#1533 follow-up).
**Inventory / Spoolman**
- Transparent / clear filament now selectable and rendered as transparent end-to-end in the built-in inventory (#1545, reported by @Synec5, confirmed by @CMW-ISS).
- Assigning a spool no longer shows a profile-mismatch warning when only the slicer profile differs; the warning now states the AMS slot will be reconfigured (#1552, reported by @anthonyma94).
- External-spool usage is now tracked when the AMS has empty slots in between loaded ones (#1607, reported by @ahmtcnby).
- SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by @chesterakl).
- SpoolBuddy Tare status banner no longer sits at "Waiting for device..." forever (#1536, reported by @flom89).
**Virtual printer**
- Queue / Review / Archive virtual-printer modes now complete the TLS handshake on hardened-distro hosts (#1610). Real Bambu printers offer only the plain-RSA AES-GCM suites `AES256-GCM-SHA384` / `AES128-GCM-SHA256`; a system crypto policy that strips them left the slicer's ClientHello with no overlap. The #620 cipher-suite fix only patched the printer-facing context; this release patches every slicer-facing VP TLS context (bind / MQTT / proxy-server / FTP).
- VP "Send file" IP rewrite now also fires for VPs without a dedicated bind IP (#1429 follow-up, residual case confirmed by @Mape6).
- VP "Send file" no longer redirects from Bambuddy to the physical printer's SD card once the printer powers on, and the mode button labels now match the wire values stored in the DB (#1429).
- VP queue mode no longer blocks BambuStudio Send while the target printer is mid-print (#1558, reported by @phieb).
- VP `_pending_files` / temp-file leak fixed on every error path across the three file handlers.
- VP queue position now picks `MAX(position)+1` instead of hardcoded `1`; duplicate position=1 entries no longer pile up on non-empty queues.
- VP DELETE route now cleans orphan `PendingUpload` rows and the on-disk `upload_dir`; previously these accumulated.
- VP `MQTTBridge._refresh_loop` no longer leaks the raw_message_handler on exceptions in the IP-encoding branch.
- VP `sync_from_db` serialised by `asyncio.Lock`; concurrent PUT calls (browser racing the auto-save trigger) no longer race the inner start/stop.
- VP `_slicer_print_options` cache bounded at 128 entries with FIFO eviction.
- VP MQTT bridge sticky-key carry-forward now uses `copy.deepcopy`; a sticky key carried over from the previous cache no longer shares nested dicts with the next push.
- VP MQTT no longer drops idle slicer connections at exactly 60 s (#1548, reported by @hollajandro); honours client-negotiated keepalive instead of the hardcoded 60 s.
- VP diagnostic now probes both bind ports 3000 (plain) and 3002 (TLS).
- VP FTP `stop()` now awaits cancelled sessions instead of `sleep(0.1)`; a session mid-write or mid-TLS-handshake can no longer outlive the stop call.
- VP per-VP TLS certificate auto-regenerates when the shared CA is rotated.
- VP `tailscale.py::get_status` now catches `asyncio.TimeoutError`; a stuck Tailscale subprocess no longer surfaces an uncaught exception.
- VP `certificate.py` CA save uses correct parent directory; previously the CA write could fail because only the per-VP subdirectory had been created.
- VP `_extract_plate_id` failures now log at debug instead of swallowing silently; a malformed `Metadata/slice_info.config` is now traceable.
**Dispatch / prints**
- A1 no longer auto-replays the previous print after a power cycle when the library row's filename has a doubled `.gcode.3mf` (#1542). The dispatch SD-cleanup path now aligns with the upload path; doubled-extension library rows no longer leave ghost prints.
- Connected-edge reconciliation closes the missed-PRINT-COMPLETE loop that produced ghost replays on smart-plug power cycles (#1542 follow-up, reported by @vixussrl-ui).
- Paused prints no longer inflate maintenance hours (#1521, reported by @TempleClause). `track_printer_runtime` was counting both RUNNING and PAUSE states.
- Webhook printer-status / stop / cancel routes 500'd on every connected printer because the route treated the `PrinterState` dataclass as a dict (#1584).
**Cloud / auth**
- Bambu Cloud sign-in failures caused by an upstream Cloudflare challenge now surface an actionable message instead of "Invalid response from Bambu Cloud" (#1575, reported by @cliveflint).
- OIDC auto-provisioning now reads the standard `email` claim for `User.email` when `Email Claim` is set to a non-email identity claim (#1569, reported by @anderl1969).
**Notifications**
- ntfy notifications: honest User-Agent + actionable error when the server is behind a Cloudflare challenge (#1534, reported by @apizz). User-Agent is now `Bambuddy/<version>` instead of the bare httpx default.
**Maintenance**
- Custom maintenance type "documentation URL" now persists on create (#1596, reported by @BurntOutHylian — with the exact root cause pre-triaged in the issue body).
**Internal / CI**
- Path-traversal CI backstop now recognises markers on the closing-paren line (project-wide convention).
- Backend test sharded 4-way + `-n auto` for ~3.5× wall-clock speedup in CI; pip cache mount in the test image.
- Unit tests no longer re-run inside the test image (duplicated the parent CI job).
- Trivy DS-0026 silenced on `Dockerfile.test` via `HEALTHCHECK NONE` (see Security).
---
## Credits
External contributors this release: @TempleClause (#1418 / PR #1501 system theme detection), @vmhomelab (PR #1529 Windows installer), @samedyuksel (PR #1571 Turkish locale), and @hijae (PR #1587 Korean locale).
Thank you!
The hardcoded /tmp paths and 0.0.0.0 bind in
test_archives_api.py / test_attach_timelapse_safe_path.py /
test_vp_mqtt_bridge.py are deliberate adversarial-input fixtures for
the path-traversal containment tests and the #1429 bind_address=0.0.0.0
auto-resolve path — not insecure temp-file usage by the tests. Same
nosec-without-comment pattern as the existing test_virtual_printer.py
sites.
pip-audit flagged four advisories against 2.12.1, all fixed in 2.13.0.
Audited the five behavioural changes in 2.13.0 against our usage; none
apply (HMAC empty-key reject can't trigger, OIDC decode uses raw-key
path not PyJWK, jwks_uri is HTTPS from discovery, no b64=false usage,
enforce_minimum_key_length not opted into). 229 auth/MFA/OIDC
integration tests + 78 auth unit tests green on 2.13.0; runtime
encode/decode roundtrip verified with the real SECRET_KEY; pip-audit
--strict now clean.
Four frontend formatDate / formatDateTime helpers called new Date(iso)
directly on backend timestamps that have no timezone indicator. Per
ECMAScript, a bare "2026-06-02T07:50:00" is parsed as local time, so
a UTC-stored value got displayed as if its numeric components were
already local — visually identical to UTC. Same shape as the #504
fix from Feb 2026, which patched 13 sites but missed these four:
PrintLogTable and SpoolUsageHistory hadn't been written yet;
CameraTokensPage and SpoolBuddySettingsPage existed but were
overlooked.
Reporter #2 (@IndividualGhost1905) confirmed with a UTC+3 host: print
log shows UTC clock value, system date shows local. PrintLogTable is
the "logs/completion time" they called out; the other three are the
same pattern in nearby surfaces.
Replaced the bare new Date(iso) calls with parseUTCDate(iso) from
utils/date.ts — the same helper every other date formatter in the
codebase already uses. It appends "Z" to naive ISO strings and
parses TZ-tagged strings as-is. CameraTokensPage.isExpired got the
same fix because comparing a misparsed Date against Date.now() would
produce false "not expired" / "expired" results around the TZ-offset
boundary.
Reporter #1's printer-card ETA complaint (10:50 + 57m showing 09:48)
is NOT addressed by this fix. That ETA comes from
formatETA(remainingMinutes) which is purely client-side (new Date()
plus minutes from the WebSocket payload, then toLocaleTimeString)
— for it to render UTC the browser timezone itself would need to
be UTC, which is a browser / OS config issue.
Audit confirmed no other regressions: grepped every new Date( call
in frontend/src/. Remaining sites either pass an epoch-ms number,
use the result only for .getTime() arithmetic where the offset
cancels, already wrap in parseUTCDate fallback, or consume a
backend timestamp that includes "+00:00" (FailureDetectionSettings
reads obico_detection.py's tz-aware isoformat).
compute_time_accuracy in routes/archives.py compares the archive row's
own started_at / completed_at (which reflect the latest run only)
against archive.print_time_seconds (which the #1593 parser fix
correctly stores as the sum across plates). For a 3-plate file printed
plate-by-plate the ratio is ~300%, producing a "+188%" card badge that
means nothing — apples to oranges. The 5-500% sanity band catches
truly broken values but lets this deterministic N×100% shape through.
Reporter's archive #65 was 3 plates over 9 runs.
compute_time_accuracy gains an optional run_aggregate argument and
returns both actual_time_seconds and time_accuracy as null when the
aggregate reports more than one logged run. The frontend already falls
through to print_time_seconds for the time display
(actual_time_seconds || print_time_seconds) and gates the badge on
time_accuracy being truthy, so multi-run archives now show the slicer
estimate with no badge. Single-run archives keep the original
behaviour verbatim.
The fix is applied at every call site that renders an archive card:
archive_to_response now threads run_aggregate through, and the three
endpoints that previously didn't load the aggregate (archives.py
search fast-path and FTS path, single-archive PATCH, and
projects.list_project_archives) now batch-load it via the existing
_load_run_aggregates helper.
The stats endpoint's per-run accuracy aggregation at archives.py:940
already uses PrintLogEntry.duration_seconds with its own 50-200% band
filter and is untouched.
The #620 patch fixed the OpenSSL-3.x-strips-plain-RSA-AES-GCM cipher
mismatch on the printer-facing TLSProxy client context. The same fix
was never applied to the four other slicer-facing TLS contexts. On
hardened distros (Fedora / RHEL with update-crypto-policies, hardened
Alpine builds) where the system narrows DEFAULT to forward-secrecy
only, the slicer's ClientHello finds no overlap with what Bambuddy
offers and the handshake aborts with the slicer reporting code=-1
before any application data flows. The reporter pinpointed the missing
set_ciphers call in bind_server.py against the #620 lineage; the
audit-wide sweep here extends the same fix to mqtt_server.py,
tcp_proxy._create_server_ssl_context (the missing other half of #620),
and ftp_server.py.
For the three new contexts (bind / mqtt / proxy-server) the cipher
string is DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256 — verbatim match
with the #620 client-side fix. For FTPS the original HIGH baseline is
kept (HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4) so the
cipher set stays a strict superset of what shipped before — HIGH
offers ~58 suites DEFAULT doesn't (CCM / ARIA / CAMELLIA / DSS) that
no Bambu slicer is known to pick, but narrowing a compat surface
without proof would violate the existing don't-remove-compat-pinning
rule. TLS version pins (TLSv1_2 minimum across all four, TLSv1_2 max
on FTPS for the BambuStudio PSK-reuse compat) and verify-mode settings
are unchanged — only the cipher list is widened.
When no explicit slot-to-tray mapping is captured (path 5 of 6 in
_track_from_3mf — fires before the request-topic subscription that catches
ams_mapping is accepted), the tracker builds available_trays from
build_ams_tray_lookup and uses position to map the slicer's Nth filament
to the Nth available tray. The helper enumerated every AMS tray by id
regardless of whether a spool was loaded, so AMS slots 0-2 loaded + slot 3
empty + external yielded available_trays = [0, 1, 2, 3, 254]. The slicer
compacts its filament UI to hide empty AMS slots, so its 4th filament is
the external — but position mapping routed it to AMS0-T3 (the empty slot)
instead of 254 (external). No spool assigned there → usage silently
skipped → external never decremented.
Filter the fallback to slots with a non-empty tray_type. build_ams_tray_lookup
stays unchanged for its other callers (spoolman_tracking.store_print_data,
routes/printers, spool_assignment_notifications); the filter is applied at
the usage-tracker call site only. Mirrors the existing vt_tray filter in
build_ams_tray_lookup line 174.
The original #1429 fix's _refresh_ip_encoding early-returned when
mqtt_server.bind_address was "0.0.0.0" or empty (the default for VPs created
without a dedicated bind IP). On a flat-LAN install that's the typical case,
so the encoding never armed, _rewrite_net_info_ips was a no-op on every push,
and the slicer kept following the real printer IP to its SD card. @Mape6
reported this on the 2026-06-02 daily that supposedly fixed the bug.
New helper _resolve_host_interface_for_target() consults the existing
network_utils.find_interface_for_ip() to pick the host interface in the
printer's subnet. _refresh_ip_encoding falls back to it when bind_address
is unspecified; an explicit bind IP still wins. INFO log line distinguishes
the two paths ("armed: ... (bind_address)" vs "(auto-resolved)") so future
bundles directly answer which IP the rewrite picked.
Tests: 4 new under TestBindAddressAutoResolve — rewrite arms via auto-resolved
IP at bind_address=0.0.0.0; stays disabled if no interface matches (no crash);
explicit bind_ip still takes precedence; helper returns None defensively when
find_interface_for_ip does.
Tracks the orca-slicer-api Dockerfile change that switched the sidecar
from the (no-longer-published) Fedora AppImage to the Ubuntu 22.04
AppImage. The default in .env.example and slicer-api/docker-compose.yml
now matches the new build path; users running the sidecar should
`docker compose --profile bambu build --no-cache bambu-studio-api &&
docker compose --profile bambu up -d` after pulling this change.
entries
PR #1529 added the Windows installer but the rendered README sections
had a stray blank line inside the install one-liner's code fence and
the cross-platform Service Management / Updating / Troubleshooting
sections still only covered Linux + macOS + Docker. Fold in Windows
entries (Start-Service, Get-NetTCPConnection, NSSM runtime log path)
and link the README description to the Windows Installer wiki page so
users know where the parameter reference and unattended examples live.
entries
PR #1529 added the Windows installer but the rendered README sections
had a stray blank line inside the install one-liner's code fence and
the cross-platform Service Management / Updating / Troubleshooting
sections still only covered Linux + macOS + Docker. Fold in Windows
entries (Start-Service, Get-NetTCPConnection, NSSM runtime log path)
and link the README description to the Windows Installer wiki page so
users know where the parameter reference and unattended examples live.
inline mutation type
POST /api/v1/maintenance/types hard-coded the MaintenanceType
constructor and silently dropped `wiki_url`, so the Documentation URL
field disappeared after save. PATCH worked because it uses
`data.model_dump(exclude_unset=True) + setattr`, which is why editing
a freshly-created type DID save the URL — masking the bug under any
"save then immediately fix it" retest. Reporter @BurntOutHylian
pre-triaged the issue to the exact constructor call at
routes/maintenance.py:206-213; fix is the missing `wiki_url=data.wiki_url`
argument.
Frontend nit from the same report: MaintenancePage.tsx:1131's
`updateTypeMutation` declared `data: Partial<{ name; default_interval_hours;
interval_type; icon }>` — omitting `wiki_url`. The value reached the
API correctly at runtime because `api.updateMaintenanceType` accepts
`Partial<MaintenanceTypeCreate>` (which has wiki_url), but the inline
type lied about the payload shape. Extended the inline `Partial<{...}>`
to include `wiki_url?: string | null`. Pure type fix — no runtime change.
files + unify file_type classification across ingest paths
#1600: external-folder sliced outputs landed
with no thumbnail. Cause: four backend ingest paths classified
LibraryFile.file_type differently for the same .gcode.3mf family.
upload / ZIP-extract / in-process used os.path.splitext()[1] which
returns .3mf for foo.gcode.3mf and stored file_type="3mf", matching
the thumbnail-extraction gate at library.py:1467 (file_type == "3mf").
External-folder scan explicitly detected the compound and stored
file_type="gcode.3mf" — preserving "sliced output" identity — but
then skipped both the "3mf" gate and the "gcode" gate, so the file
landed with thumbnail_path = None. Same compound-extension drift that
bit #1543 in the 3D preview, in a surface that audit didn't trace
back to.
Unified fix:
- New classify_file_type(filename) helper in routes/library.py is the
single source of truth. Returns "gcode.3mf" for sliced outputs and
ext[1:] otherwise.
- Applied to every ingest path: upload (line 1704), ZIP-extract
(1998), external-folder scan (the bug site — the manual compound
check is replaced), and in-process save_3mf_from_bytes (471, used
by MakerWorld import).
- External-scan thumbnail gate widened to
`if file_type in ("3mf", "gcode.3mf"):` — a .gcode.3mf IS a 3MF zip
with Metadata/plate_1.png; ThreeMFParser doesn't care about the
trailing extension.
- gcode-download endpoint at GET /library/files/{id}/gcode had the
same drift in reverse: gate was `elif file.file_type == "3mf":` so
a row stored with file_type="gcode.3mf" (the external-scan path's
pre-unification behaviour, and the canonical going forward) got
rejected with HTTP 400. Widened to the same compound-aware tuple.
One-shot DB migration in core/database.py::run_migrations backfills
existing legacy rows:
UPDATE library_files
SET file_type = 'gcode.3mf'
WHERE file_type = '3mf'
AND LOWER(filename) LIKE '%.gcode.3mf'
Idempotent (post-update rows no longer match the file_type='3mf'
predicate, so re-runs at every boot are no-ops) and dialect-neutral
(LOWER + LIKE are identical under SQLite and Postgres). Without the
backfill, users would have a permanent split state: old uploads at
'3mf', new uploads at 'gcode.3mf' — which would double-bucket sliced
outputs in the dashboard stats query at line 4615 and show two
entries in the file-manager filter dropdown for the same conceptual
type.
Frontend untouched. FileManagerPage.tsx and ProjectDetailPage.tsx
already accept both '3mf' and 'gcode.3mf' per the #1543 fix. After
the migration the DB only contains canonical values, so the legacy
'3mf' branches in the frontend become dead code for sliced files —
they stay as defence-in-depth in case any future ingest path I
missed reverts to the legacy classifier.
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
that rules out subnet / mDNS-reflector theories): with the physical printer
off the slicer's "Send" landed in Bambuddy's archive; once the printer
powered on every subsequent "Send" went straight to the printer's SD card
and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
once the printer was online.
Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
_vp_ip_uint32_le ONLY on client-identity change and early-returned on
every refresh tick when the same client object was still bound. If
target_client.ip_address was empty at first bind (DB row stale, or client
constructed before SSDP refresh filled it in), the encoding stayed None,
the net.info[*].ip rewrite block was skipped, the cache filled with the
real printer IP, sticky-key preservation kept the poisoned net value
alive across every subsequent incremental push, and the slicer followed
the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
workaround both reporters independently arrived at. Same shape on
multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
whose ip equalled _target_ip_uint32_le, so a secondary interface IP
Bambuddy never saw would leak through unchanged.
Bridge fix:
- _resolve_client calls a new _refresh_ip_encoding() on every refresh
tick, even when client identity is unchanged; self-heals once
ip_address becomes valid.
- _refresh_ip_encoding() sweeps the existing _latest_print_state when
encoding becomes valid for the first time. Without the sweep,
sticky-key preservation keeps the pre-arm poisoned cache alive
forever — incremental pushes that don't include net carry the bad
value forward.
- _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
that doesn't already equal the VP IP, not just entries matching
_target_ip_uint32_le. Multi-NIC printers stop leaking secondary
interfaces. Zero-IP placeholders are left alone so "active interface"
detection still works.
- INFO logging on encoding arm/update and on cache sweep so future
bundles directly answer "did the rewrite fire?".
Mode wire-value rename (#1429 follow-up, separate confusion source):
- Both reporters' support bundles showed mode: immediate while the UI
said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
immediate in the support-info.json file. In the webui the printer is
set to archive". UI button "Archive" had always saved immediate, and
"Queue" had always saved print_queue. Canonical wire values are now
archive / review / queue / proxy, matching the button labels 1:1.
- New normalize_vp_mode() + VP_MODE_* constants in
models/virtual_printer.py; manager.py normalises on construction so
a legacy row read pre-migration still dispatches correctly.
- core/database.py::run_migrations rewrites existing virtual_printers
and settings rows; idempotent (re-runs are no-ops); identical SQL
under SQLite and Postgres.
- API routes accept both legacy and canonical on input, normalise
before storage. GET /settings/virtual-printer normalises on read so
the frontend's mode-button highlight works for stale legacy values.
- Three frontend VP components (VirtualPrinterSettings,
VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
and type aliases to canonical; each got its own normalizeMode()
helper so a stale-cached settings payload still highlights the right
button. Two pre-existing `printer.mode === 'queue' ? 'review'`
legacy mappings in VirtualPrinterCard were the source of a test
failure caught mid-implementation where the new canonical 'queue'
was being mis-aliased back to 'review' and hiding the auto-dispatch
+ force-color-match toggles.
mode handler is NOT the dispatch bug: manager.py::_archive_file (the
handler for archive mode) doesn't dispatch to the physical printer.
The "files end up on the printer's SD card" symptom was the IP-leak
from the bridge cache. The mode rename is purely clarity / support-
bundle accuracy.
Two stacked causes under-reported multi-plate prints in the project
rollup and the archive card.
Root cause 1 - parser only read plate 1.
ThreeMFParser._parse_slice_info used root.find(".//plate") and pulled
prediction / weight from that one element. Any multi-plate file's
archive-level print_time_seconds / filament_used_grams reflected
plate 1 alone. The /plates endpoint already looped findall and was
correct, which is why the plate carousel showed the right numbers
while the archive card was wrong.
Fix: loop every <plate> and sum prediction + weight. Per-plate
concepts (plate_number, _plate_index, printable_objects) only set
when there's exactly one plate - for multi-plate exports the
archive represents all plates and a single index doesn't apply at
the file level. bed_type keeps the first plate's value as a
best-effort default. Malformed prediction / weight on individual
plates skip cleanly rather than poison the sum.
Root cause 2 - project rollup aggregated PrintArchive, not the
per-run log.
compute_project_stats and list_projects quick-stats summed
PrintArchive.print_time_seconds / filament_used_grams / cost /
energy_* WHERE project_id. A reprint reuses the source archive row
and writes a new PrintLogEntry, so 3 sequential runs collapsed to 1
archive - and that archive's numbers were already plate-1-only from
cause 1. The Archive Print Log path was already correct because it
drove off print_log_entries (archives.py:420 comment).
Fix: both compute_project_stats and the list_projects quick-stats
block inner-join print_log_entries -> print_archives WHERE
archives.project_id. total_archives becomes COUNT(PrintLogEntry.id),
failed_prints counts runs in failed/aborted/cancelled/stopped,
completed_items is SUM(PrintArchive.quantity) for runs where
status='completed', time/filament/cost/energy from PrintLogEntry.
Orphan log rows (archive_id IS NULL post archive deletion) are
excluded by the inner join.
Same-shape fixes carried forward (no follow-ups per project rule):
system.py system-info totals: total_print_time / total_filament
had the same bug shape - summed PrintArchive directly so reprints
collapsed to one row. Now sums PrintLogEntry.duration_seconds /
filament_used_grams. The semantic shift is also a correctness
improvement: the field now reflects time the printer actually spent
printing, not slicer-estimated time.
archives.py time-accuracy metric: estimate / actual per run where
estimate = PrintArchive.print_time_seconds. Post-parser-fix
multi-plate archives have file-level estimate but per-run actual =
one plate, so ratio = N x 100% for an N-plate file. The calc now
clamps each row to the [50%, 200%] plausibility band before
contributing to the printer-level average; single-plate accuracy
(the case the metric is designed for) stays fully included.
Backfill: users with AMS spool tracking - the reporter's case - have
per-run filament_used_grams from the tracked spool delta, so stats
become correct immediately. Users without tracking fall back to the
archive estimate and undercount until they reprint. Archive card
still reads PrintArchive.filament_used_grams directly so old
multi-plate archives keep plate-1-only numbers until reslice -
forward-only as the reporter accepted.
webhook.py treated printer_manager.get_status() return as a dict and
called .get(...) on it. The return is a PrinterState dataclass
(backend/app/services/bambu_mqtt.py), so the call raised AttributeError
and Starlette surfaced it as a generic 500 for every printer with a
status row. Non-existent printers correctly returned 404 because the
early "Printer not found" branch fired before the crash.
Reporter's repro matched exactly: id 1 (existing printer) returned 500,
id 2 and id 3 (no row) returned 404. Verified end-to-end against a live
PG-backed instance with the reporter's key shape — same 500 before the
patch, 200 with the correct payload after.
8 crash sites across 3 routes:
- webhook_get_printer_status GET /printer/{id}/status 5 sites
- webhook_stop_print POST /printer/{id}/stop 2 sites
- webhook_cancel_print POST /printer/{id}/cancel 2 sites
Every status.get("X", default) replaced with status.X if status else
default. Pydantic response schema unchanged; PrinterState's dataclass
defaults cleanly cover the "registered but never connected" branch so
the status route now returns 200 with connected=false, state=null
rather than crashing.
Two-part fix for the reporter's "removed profiles still show on the slice
menu" symptom.
Local half (real bug). LocalProfilesView's import and delete mutations
invalidated ['localPresets'] (the management view's own query) but not
['slicerPresets'] (the SliceModal's unified preset query, staleTime 60s).
A freshly-deleted preset kept rendering in the slice dropdown until that
staleTime elapsed plus a refocus/remount. Both mutations now also call
queryClient.invalidateQueries({queryKey: ['slicerPresets']}).
Cloud half (opt-in cache bypass). _fetch_cloud_presets keeps a 5-minute
per-(user, token) in-process cache (slicer_presets.py:69, balances
"users see freshly-saved presets quickly" against "busy install doesn't
hit Bambu Cloud once per modal open"). Users delete cloud presets in
Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook
to invalidate on. Rather than shorten the TTL globally, the listing
endpoint gains an opt-in ?refresh=true query param that bypasses both
the cloud cache AND the 1-hour bundled-preset cache for that one call;
the fresh result is still written back so subsequent normal callers
keep hitting the cache.
New SliceModal "Refresh" button. Lives in the preset section header
next to the cloud-status banner. Calls getSlicerPresets({refresh: true})
and writes the fresh slots into the ['slicerPresets'] cache via
queryClient.setQueryData so the spinner stops immediately rather than
triggering a second refetch. RefreshCw icon spins while in-flight;
disabled during slice enqueue to prevent double-fire.
Two attacker-controlled strings were being joined to library_dir with no
resolve + containment check in the project ZIP import endpoint:
- linked_folders[*].name from the request's project.json
- per-entry zf.namelist() paths from the ZIP itself
An absolute path in either field collapsed the join (Path("/lib") / "/etc"
becomes Path("/etc") because pathlib discards the left side when the right
is absolute) and the next write_bytes landed wherever the attacker chose.
Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
had NO validation on filename and FileResponse-served arbitrary paths -
the DELETE counterpart at least gated on the photos membership check.
Adjacent finding from the services audit: ArchiveService.attach_timelapse
wrote archive_dir / filename where filename ultimately came from a printer's
FTP listing (compromised-printer threat model) or the /timelapse/select
query param. A malicious printer that exposes a directory entry with ..
segments could write the timelapse outside the archive directory.
New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
single source of truth: rejects empty / null-byte / absolute parts up-front,
joins under parent, resolves both sides, asserts is_relative_to. Returns the
resolved canonical path on success, raises HTTPException(400) on escape, or
PathTraversalError when http=False (for service-layer callers that need to
match a non-HTTP return contract).
Wired into the import vectors, both archive photo handlers, and the
attach_timelapse service. The full audit sweep inspected every Path/Name
join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
sites + 8 service-layer sites confirmed safe and tagged with
# SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.
Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
AST-walks both layers and fails the build on any <dir-like>/<bare variable>
join that doesn't either route through safe_join_under or carry the marker.
The services layer is in scope because it receives values verbatim from the
routes AND from external sources Bambuddy has no control over (the printer
FTP-listing case above).
SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
the rule now names the printer FTP-listing case explicitly so future
services-layer audits set the right expectation.
--------------
fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files
Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
"solid test\nendsolid test" shape) produced one WARNING per file in
stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
correct - trimesh returns a valid Mesh with zero vertices, the safeguard
matches, and the function returns None so the library entry is still
created without a thumbnail - but the volume turned a successful upload
into thousands of WARNING lines in the journal.
Two changes:
1. The per-file "Failed to load STL or empty mesh" message in
stl_thumbnail.py is now logger.debug instead of logger.warning. It's
a per-file content observation, not an actionable error; the caller
already handles None correctly. The branch now catches the rare
"large enough but trimesh still can't parse it" case, visible in
debug logs without spamming production.
2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
below any real STL). The three thumbnail call sites in library.py
(extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
pre-skip files below this size before calling generate_stl_thumbnail.
Stubs never enter the trimesh pipeline at all.
Behavior is unchanged for real STLs: any file >=200 bytes runs through
the existing pipeline, MAX_VERTICES still triggers simplification at
100k vertices for the 256x256 thumbnail render, large files still get
thumbnails.
------------
fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)
On first STL upload, three matplotlib-internal log lines surfaced:
WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
INFO [matplotlib.font_manager] generated new fontManager
The writable-dir warning fired because Bambuddy's $HOME isn't writable for
matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
which lost the font cache on every host reboot, so font_manager rebuilt it
each cold start - producing another batch of INFO lines.
Fix is two small additions in stl_thumbnail.py before the matplotlib import:
1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
persists across container restarts and the writable-dir warning never
fires. Respects an externally-set MPLCONFIGDIR so operators who chose
their own path aren't overridden. Best-effort with a debug fallback if
settings can't be imported or the mkdir fails.
2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
import demotes the per-font INFO scan that fires when font_manager
builds its cache cold. Real font warnings (>= WARNING) still surface.
3 new tests: font_manager logger at WARNING after module import;
_configure_matplotlib_cache creates the directory under base_dir and sets
MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
5516 backend tests green, frontend gates clean.
When Cloudflare in front of bambulab.com returns a "Just a moment..." interstitial
instead of the JSON the API normally produces, the parse error in
verify_totp / verify_code / login_request used to surface as the opaque "Invalid
response from Bambu Cloud" or a generic 401 from BambuCloudAuthError. Reporter
hit this with three back-to-back TOTP attempts; a curl from a different network
with the same honest Bambuddy UA returns clean JSON, so the trigger is CF-side
(per-IP / TLS-fingerprint / rate / transient mitigation), not our code.
Add a small _detect_cloudflare_challenge() helper that inspects the response for
four CF markers (body "Just a moment...", body "challenges.cloudflare.com", 403
with cf-mitigated header, 503 with cf-ray header) and returns a message that
attributes the block to Bambu Lab's Cloudflare protection, suggests waiting a few
minutes, and points the user at a same-network browser sign-in as the standard
workaround. Wired into all three JSON-parse sites; verify_totp previously had a
defensive catch, login_request and verify_code now do too.
No header changes, no impersonation, no retry loop - pure diagnostics. Stays
clearly on the right side of Bambu Lab's "no falsified client identity" line.
When an operator configures `Email Claim = preferred_username` (e.g. Authentik) the
primary `_resolve_provider_email` correctly rejects the identity value as non-email
shaped and returns None, leaving auto-provisioned users with `email=None` even though
the same token carries a valid standard `email` claim.
Add a narrow fallback in the auto-create-users branch only: when
`provider.email_claim != "email"` and the primary returned None, resolve the standard
`email` claim with the same Fall A/B shape + email_verified enforcement and use it for
`User.email` and `UserOIDCLink.provider_email`.
The auto-link-existing-accounts gate is left on the primary `provider_email`, so the
GHSA Fall-B / Fall-C guards remain intact - the fallback never feeds account matching.
The Vitest UI server's /__vitest_attachment__ handler bypasses
isFileServingAllowed via a path-traversal payload, allowing arbitrary file
read/execute on the host. Dev-scope only and not exploitable in
Bambuddy's CI/CLI usage (we don't start the Vitest UI server and
@vitest/ui is not installed), but bumping clears the Dependabot alert
and brings us onto the supported 4.x line.
Bumped:
vitest 3.2.4 → 4.1.8
@vitest/coverage-v8 3.2.4 → 4.1.8
Migration-required fix:
StreamOverlayPage.test.tsx mocked `WebSocket` via
vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({...})))
and the page does `new WebSocket(url)`. Vitest 4 dropped support for
arrow-function constructor mocks ("is not a constructor"). Rewrote
with a plain `function` so `new` resolves correctly.
All 2043 frontend tests pass; npm run build clean; npm audit shows 0
vulnerabilities.
API-key permission gates went from a 17-entry admin denylist with the three
documented scope flags (can_read_status / can_queue / can_control_printer)
enforced only inside /api/v1/webhook/* to an explicit per-Permission
allowlist consulted by every dependency:
- core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
Permission to one scope flag on APIKey; unmapped = 403.
_check_apikey_permissions now takes the api_key and checks the flag.
- require_any_permission_if_auth_enabled + require_ownership_permission
were returning None for any valid key with zero scope check; both now
invoke _check_apikey_permissions and fail closed.
- Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
(INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
"queue-only" keys keep working and hardened "read-only" keys do not
silently gain writes.
- CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
alongside the existing _cloud_api_key_gate.
- Migration column-existence check (_api_keys_column_exists) gates the
backfill so user-edited values are never overwritten on restart.
Structural drift backstop: test_every_permission_has_a_classification fails
CI on any new Permission added without an explicit scope mapping —
prevents the denylist-shape regression that grew the prior surface.
Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
table + allowlist callout + upgrade notes updated.
The 1 Hz status push was silent at INFO, so support bundles couldn't show
whether the push task was actually reaching a specific slicer connection.
Now ``_periodic_status_push`` emits one line per minute per connected
slicer ("1Hz status push: N pushes/min to <client>") and stays silent when
no slicer is attached. No behaviour change to the push itself — counters
are local to the task and reset every 60 ticks.
Motivated by the open follow-up on #1548: keepalive parser shipped (b6636053)
and the symptom moved 60 s → 90 s, but OrcaSlicer still disconnects on idle.
This adds the missing observability so the reporter's next support bundle
shows whether our outbound 1 Hz push is alive for the disconnecting
connection.