diff --git a/CHANGELOG.md b/CHANGELOG.md index b22ae7530..e330db4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,27 +2,10 @@ All notable changes to Bambuddy will be documented in this file. - - ## [1.2.6b1] - Unreleased -### Fixed -- **The Settings page no longer reverts settings changed from anywhere else (#2716, reporter @jmoore-skild)** — While the Settings page was open it held its own copy of every setting and only ever took one from the server, on first load. A background effect then compared that copy against the server's and saved the whole thing back on any difference — with no way to tell "the user edited this field" from "this field changed on the server". So anything written while the page sat open was silently undone: a change made in a second tab, another user's change on a shared install, a restore from a backup. It needed no click to trigger. The page's data goes stale after a minute and refreshes when the window regains focus, and around thirty other places in the app read the same settings, so a refresh from any of them was enough — after which the page wrote its page-load copy back over all 77 settings it manages, and showed **Settings saved** while doing it. The page now keeps track of the last server state it reconciled with. A field still matching that state has not been touched, so a newer value from the server is adopted and displayed; a field the user has edited keeps their value and is saved over the top, so the newer of the two writes wins either way. Typing into a text field while a refresh lands is still safe, which is what the old behaviour was protecting. Covered by frontend tests. -- **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests. -- **The K-profile flow type is a real choice again** — On most printers the calibration table comes back with no nozzle identity at all, and Bambuddy had started showing "Not reported by printer" in the Flow Type field as a result. That is not a value you can save, and it isn't what the slicer does: BambuStudio treats a missing nozzle identity as **Standard** and leaves the choice editable. Bambuddy now does the same. The field is hidden only on models sold with a single nozzle variant — the A1, A1 Mini and A2L — using the same rule the slicer applies. This is not the single-versus-dual-nozzle split: the P1P, P1S, P2S, X1, X1 Carbon, X1E and H2S are all single-nozzle and all offer both flows. Editing a profile also no longer strips the nozzle identity from what it writes back. -- **Dialogs no longer act after they have closed** — The AMS slot configuration and K-Profile dialogs hold their success state briefly and then close themselves, between 1.5 and 4 seconds after the command is sent so the printer has time to process it. That timer ran whether or not the dialog was still open, so dismissing it — or the printer card refreshing underneath it — within that window left a pending close that fired later, dismissing whatever dialog happened to be open by then. The deferred close is now cancelled when the dialog goes away. Covered by frontend tests. -- **A printer with no K-profiles can now be given its first one (#2719, reporter @jmoore-skild)** — **Add K-Profile** built its Filament dropdown out of the profiles already on the printer, so on a printer with none the field was empty, required, and impossible to satisfy — the modal even said so, telling you to go and create the profile in Bambu Studio instead. The filament picker is now populated the way every other one in Bambuddy is, in the same order: **Imported** presets first, then **Orca Cloud**, then **Bambu Cloud**, then Bambuddy's built-in Bambu filament table. That last tier is compiled in, so the list is never empty — a brand-new printer with no cloud account and nothing imported still gets you a profile. The per-printer-model copies a cloud account carries ("Bambu PLA Basic" once for the X1C, once for the P1S, once for the A1) are collapsed into a single row, and the built-in table — a static copy of the same Bambu catalogue — no longer echoes back filaments the groups above already list. Your imported and Orca Cloud libraries are both shown in full even where they overlap by name, because they are usually the same profiles reached two ways and each group is worth seeing under its own heading. The picker is a searchable list with the source heading shown as a real, legible group header — a native dropdown can't do that, since browsers render the group label of a `` filters on the runs dashboard with themed dropdowns and fixes a `react-hooks/exhaustive-deps` warning. - ## [0.2.4.8] - 2026-06-28 ### Added @@ -409,7 +361,6 @@ All notable changes to Bambuddy will be documented in this file. - **Frontend dependency bumps** (`000af683`) - **Printer secrets restricted to update-authority callers** (`8283b175`) - ## [0.2.4.7] - 2026-06-14 ### Added @@ -492,13 +443,11 @@ All notable changes to Bambuddy will be documented in this file. - **A1 / A1 Mini internal-code map was swapped in `PRINTER_MODEL_ID_MAP` (surfaced while scoping A2L support, #1684)** — `backend/app/utils/printer_models.py` mapped `N1 → "A1"` and `N2S → "A1 Mini"`, but every other registry that names these codes — `firmware_check.py` (`N2S → "a1"`), `virtual_printer/manager.py` (both the model map and the serial-prefix map: `N2S → "03900A"` is the A1's `039` prefix, `N1 → "03000A"` is the A1 Mini's `030`), `printer_manager.py` `A1_MODELS` — consistently uses the opposite (correct) direction. Any path that resolved an A1-family printer by internal code rather than serial prefix would silently misclassify. **Fix:** swap `PRINTER_MODEL_ID_MAP` to `N1 → "A1 Mini"`, `N2S → "A1"`; the matching comment in `LINEAR_RAIL_MODELS` was also wrong and got the same swap (the frozenset's contents don't change — both codes were already in it — so this is cosmetic, but kept the file self-consistent). New regression test class `TestA1SeriesModelIds` pins both directions so a future re-flip fails loudly. Functional impact in practice is small (most A1 detection runs off the serial prefix), but the inconsistency was a footgun for any future caller that trusted `normalize_printer_model_id`. Backend printer-model suite 46 / 46 green; ruff clean. - - **Print Queue filament-override panel showed raw 3MF base material instead of Bambu Studio's sub-brand colour name (#1718, reported by @SamNuttall)** — The Print Queue's filament-override panel rendered every "Original" row as `{type} ({colorName})` — just the raw 3MF `` attribute, which is always the base material ("PLA", "PETG-HF") — plus the generic color-bucket name from `getColorName(hex)`. A model sliced with "Bambu PLA Matte Charcoal" therefore showed up as "PLA (Black)" in the dropdown's original-filament option, and the schedule dialog gave no way to confirm the user was actually overriding what they thought they were. The 3MF DOES carry the Bambu SKU (`tray_info_idx`, e.g. `GFA01`) on each `` element — `backend/app/api/routes/archives.py:3634/3665` already returns it in the `/archives/{id}/filament-requirements` response — but `FilamentReqsData` at `frontend/src/components/PrintModal/types.ts:178` didn't carry the field, so `FilamentOverride.tsx` couldn't see it. The resolution path was also already in place: `_BUILTIN_FILAMENT_NAMES` at `backend/app/api/routes/cloud.py:568` maps Bambu factory SKUs (`GFA01` → "Bambu PLA Matte"), exposed as `/cloud/builtin-filaments`; `/cloud/filament-id-map` returns the same shape for user custom presets (`P*` prefix). `KProfilesView.tsx:791` already merges those two for its own labels. **Fix:** add `tray_info_idx?: string` to the `FilamentReqsData.filaments` type. `FilamentOverride` now loads both maps via `useQuery(['builtin-filaments'])` + `useQuery(['filament-id-map'])` (both shared caches the rest of the app already populates, `staleTime: 5 min`) and merges them into a single `idx → name` lookup — user cloud preset names win over the builtin entries for the same id (the user-authored label is more specific). Both the dropdown's "original" placeholder option AND the swatch tooltip use the resolved name; the raw `req.type` stays as the fallback when the SKU is unknown to both sources so unknown ids degrade to today's behaviour instead of rendering blank. Color side note: Bambu Studio's specific color names ("Charcoal") live in their cloud catalog, not in the 3MF — the file carries only the hex — so Bambuddy still renders the color from `getColorName(hex)`. "Bambu PLA Matte (Black)" is the realistic best we can do; user-readable sub-brand IS now exposed. **Color disambiguation (round 2):** the sub-brand half above is necessary but not sufficient — `getColorName(hex)` resolved through `/api/inventory/colors/map`, which collapses every catalog entry sharing a hex to a single name via "Bambu Lab > is_default > first" priority. Hex `#000000` has 9 Bambu Lab catalog entries (Black for 8 materials, Charcoal for PLA Matte) all at the same priority, so "Black" — first encountered — wins the race and "Charcoal" is dropped before the frontend ever sees it. A new endpoint `GET /api/inventory/colors/by-material?hex=X&material=Y` (`backend/app/api/routes/inventory.py:get_color_by_material`) preserves the material context: same case-insensitive hex match as `/colors/map`, then a `material` filter on top. When no entry matches the requested material it falls back to the same priority order as `/colors/map`, so callers without a material hint (or with an unknown one) get exactly the existing answer — no regression for the flat-map consumers (PrintersPage, InventoryPage). `FilamentOverride.tsx` derives a material hint from the resolved sub-brand by stripping the leading brand token ("Bambu PLA Matte" → "PLA Matte", "PolyLite ABS" → "ABS"), dispatches one `useQuery` per slot via `useQueries` keyed on `(hex, material)`, and uses `data.color_name || getColorName(hex)` so a slow query never blanks out the placeholder. Five new tests in `test_color_catalog_extras.py` pin: same hex + different material returns the correctly-paired name; unknown material falls back to priority order; missing hex returns `color_name=null` (no 404); mixed-case input on both sides matches; invalid hex (<6 chars) returns null without crashing. Three new vitest cases pin: PLA Matte Charcoal scenario lands "Bambu PLA Matte (Charcoal)", per-slot disambiguation (regression guard so a Matte slot doesn't adopt a Basic slot's answer when both share a hex), null lookup falls back to `getColorName(hex)`. **Tests overall:** 20 `FilamentOverride.test.tsx` cases green; 12 `test_color_catalog_extras.py` integration cases green; combined PrintModal + FilamentOverride + FilamentMapping suite 79/79 green. **Same fix applies to printer-mode FilamentMapping (round 3):** the schedule modal's "Specific Printer" branch renders `FilamentMapping` instead of `FilamentOverride` and was reading the same raw fields (`item.type` + generic `getColorName(item.color)`) for the required-side row and the colour swatch tooltip — so a Charcoal slice opened against a specific printer still showed "Required: PLA - Black" while the model-mode branch already read "Bambu PLA Matte - Charcoal" against the same 3MF (caught when Sam's Specific-Printer screenshot still showed the old text after round 2 shipped). Extracted the three-query resolution machinery from `FilamentOverride.tsx` into a shared hook `useFilamentLabels` in `frontend/src/components/PrintModal/useFilamentLabels.ts` so the two panels can't drift on label content; `FilamentOverride` and `FilamentMapping` now both call `useFilamentLabels(filamentReqs?.filaments)` and read positional `{ resolvedName, colorLabel }` per slot. The hook also exports the `extractMaterialHint` helper so backend material-hint test parity is mechanical (one source of truth for "strip the leading brand token"). FilamentMapping's required-side type label now reads `{resolvedName}` instead of raw `{item.type}`, and the colour swatch tooltip reads `Required: {resolvedName} - {colorLabel}` instead of `Required: {item.type} - getColorName(item.color)`. New vitest case `renders sub-brand + material-disambiguated colour on the required side (#1718)` mirrors the FilamentOverride Charcoal scenario against FilamentMapping (msw stubs for builtin-filaments + by-material). Existing FTS dropdown-filter / force-color-match cases stay green. Hook itself gets direct unit coverage in a new `useFilamentLabels.test.tsx` (11 cases — extractMaterialHint corner cases, SKU resolution, cloud-over-builtin precedence, fallbacks, positional alignment across slots with same hex but different materials, and the `enabled: !!color` query gate). The earlier "case-insensitive on both inputs" backend test (in `test_color_catalog_extras.py`) is rewritten to actually seed an upper-case stored hex and query it with lower-case input — the original version only checked invalid-hex returns null, which is the wrong assertion for the test name. Combined PrintModal + FilamentOverride + FilamentMapping + useFilamentLabels + useFilamentMapping suite 144/144 green; eslint clean, build clean. **What this fix can NOT recover:** for hexes the catalog has no entry for (third-party filament manually loaded, etc.), the color label degrades to the existing HSL-bucket name from `getColorName(hex)` — still strictly better than blank, but Bambu's specific color names only live in the seeded catalog. Frontend + backend; no migration, no new i18n keys; ruff clean, eslint clean, frontend build clean, i18n parity unchanged. ### Removed - **Slicer Bundle (.bbscfg) import (#1712, reported by @IndividualGhost1905)** — Bundle import never delivered what users expected. BambuStudio's "Export Preset Bundle" only includes user-customised presets; system processes / filaments are deliberately excluded by BS. So a fresh-install user who only used stock processes (the common case) got back a bundle containing their printer + maybe four custom filaments + zero processes. Importing that bundle into Bambuddy and then opening the SliceModal flipped into bundle mode — which constrained the dropdowns to bundle contents only — and surfaced "no presets" for process, blocking slicing on STL (3MF still worked because the embedded process JSON bypasses the dropdown). The first round of #1712 (`d459b6ea`, 2026-05-XX) addressed cross-tier visibility / dedup / banner behaviour but didn't touch the bundle-mode dropdown trap. Investigating the second round made it clear the bundle import wasn't unlocking anything the existing tiers don't already cover — custom presets reach Bambuddy through Bambu Cloud sync, Orca Cloud sync, or Single Preset Import; standard presets come from the sidecar's `/profiles/bundled` route automatically — so bundle mode was a fourth code path delivering no unique value while gating users on a slot they couldn't populate. **What was removed.** Backend: `POST/GET/DELETE /slicer/bundles*` routes, `SliceRequest.bundle` field + `SliceBundleSpec` schema, the bundle-dispatch fork in `library.py::_run_slicer_with_fallback` (cross-class slice-all loop, normal slice branch, `_resolve_target_printer_model` short-circuit), the bundle-context query params on `GET /library/files/{id}/filament-requirements` and `GET /archives/{id}/filament-requirements`, the bundle-fingerprint key in `slice_preview.py`'s LRU cache (back to `(kind, source_id, plate_id, content_hash)`), `SlicerApiService.import_bundle/list_bundles/get_bundle/delete_bundle/slice_with_bundle`, the `BundleSummary` / `BundleNotFoundError` types. Frontend: `BundlePicker` + `BundleStringDropdown` components, `isBundleMode` state and every branch on it in `SliceModal.tsx`, `selectedBundleId` / `bundleProcessName` / `bundleFilamentNames` state, the bundle-mode auto-pick effect, the bundle dispatch shape in `buildSliceBody`, the `bundlesQuery` itself, `SlicerBundle` / `SliceBundleSpec` types, `listSlicerBundles` / `importSlicerBundle` / `deleteSlicerBundle` API methods. The bundle-derived path in `buildCompatibilityIndex` is also gone — the function now only takes the printer-model registry and returns `{bambuModelByShortCode}`. `presetCompatibility` keeps its two remaining paths: the slicer's own `compatible_printers` list on local-imported presets (authoritative when set) and the `@BBL ` name-based fallback against the printer-model registry. Tests: `TestBundleRoutes` / `TestBundleClientMethods` / `TestSliceWithBundle` / `TestBundleAwarePreview` / `TestBundleDispatchShape` classes deleted across `test_slicer_presets.py` / `test_slicer_api.py` / `test_slice_preview.py` / `test_slice_request_schema.py` / `test_library_slice_api.py`; the SliceModal's "Bundle tier" describe block and the bundle-only assertions in `slicerPrinterMatch.test.ts` deleted; `SlicerBundlesPanel.test.tsx` removed; `TestNozzleClassGuard` simplified (no more bundle vs preset request distinction). **What replaces the Settings panel.** `SlicerBundlesPanel` is kept under the same name and slot in `SettingsPage` but now renders a static notice (title: "Slicer Bundles (removed)") explaining the removal and pointing users at Single Preset Import / Bambu Cloud / Orca Cloud, with the slicer sidecar covering stock presets automatically. The notice is permanent and can be removed in a future cleanup. **i18n.** `settings.slicerBundles.*` block replaced with `settings.slicerBundlesRemoved.{title,description,alternatives}` translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) per `feedback_translate_dont_fallback`. `slice.bundle` / `slice.bundleNone` / `slice.bundleAllRequired` keys removed across all locales. Parity check 5106 leaves × 11 locales green. **Migration.** Hard cutover, no automatic preset migration. Users who previously imported bundles will see them disappear from Settings → Slicer Bundles after this drops; their printer preset still lives on the sidecar bundle store but is no longer surfaced. Standard presets from the sidecar's BBL tree cover stock slicing; users who need their customs re-upload them via Single Preset Import or sync via Bambu Cloud / Orca Cloud. **Why this resolves #1712.** shaddowlink's failing path was: import bundle for H2D → bundle has 0 processes (BS-side limitation) → SliceModal flips into bundle mode → process dropdown empty → can't slice STL. Post-removal: same import isn't possible, but the cross-tier preset picker shows H2D processes from the sidecar's standard tier (which always had them — bundle mode was the thing hiding them), filtered by `@BBL H2D` compatibility. STL slicing works without any user action. **Tests:** full backend suite 5907/5907 green; ruff clean; frontend ESLint clean; `npm run build` clean; vitest 158 files / 2118 tests green; i18n parity 5106 leaves × 11 locales green. - ## [0.2.4.6] - 2026-06-09 ### Added @@ -627,7 +576,6 @@ All notable changes to Bambuddy will be documented in this file. - **VP MQTT client session errors elevated from DEBUG to WARNING** — The outer ``except Exception`` in ``SimpleMQTTServer._handle_client`` was logging at DEBUG, which production deployments default to suppressing. Users reporting "slicer disconnects randomly" then had no signal to pass us. WARNING surfaces it. Inner handlers' expected parser/IO failures stay at DEBUG — only unexpected errors that would otherwise reach the outer catch get visibility. - **VP MQTT periodic status push now logs a one-line per-minute counter per active slicer connection (#1548 follow-up)** — ``_periodic_status_push`` emits ``1Hz status push: N pushes/min to `` at INFO level once per minute per connected slicer (silent when no slicer is attached). The 1 Hz status push was previously silent at INFO; when a reporter sent a support bundle showing an idle disconnect, there was no way to tell whether the push task was actually pushing to that connection or being eaten silently. The counter both confirms the task is healthy for a given client and gives us a concrete data point (N < 60 means pushes were dropped) when triaging future "slicer disconnects on idle" reports. No behaviour change to the push itself. - ### Security - **PyJWT bumped to >=2.13.0 to pick up upstream advisory fixes** — `pip-audit` flagged four advisories against 2.12.1 (all fixed in 2.13.0). Pre-bump audit confirmed Bambuddy's usage is unaffected by the five behavioural changes in 2.13.0: (a) HMAC empty-key reject — `_get_jwt_secret()` already guards against `""` at every priority (env-var falsy check, file `len >= 32` gate, generated `secrets.token_urlsafe(64)`); (b) PyJWK header-`alg` must match JWK's algorithm — OIDC decode in `mfa.py:1846` uses `signing_key.key` (raw-key path), not the `PyJWK` wrapper, so this branch doesn't apply; (c) `PyJWKClient` rejects non-HTTP(S) URIs at construction — `mfa.py:1839` constructs from OIDC discovery `jwks_uri` which is HTTPS, and `fetch_data` is overridden so the URI is never fetched anyway; (d) `b64=false` RFC 7515/7797 strictness — no detached-payload usage anywhere in the codebase; (e) per-call `enforce_minimum_key_length` now actually enforces — option not passed anywhere, and the generated 64-byte secret is well over HS256's 32-byte minimum regardless. 229 auth/MFA/OIDC integration tests + 78 auth-related unit tests pass on 2.13.0; runtime encode/decode roundtrip with the real `SECRET_KEY` verified; `pip-audit --strict` reports no remaining vulnerabilities. Pins bumped in `requirements.txt` (PyJWT>=2.13.0) and `pyproject.toml` dev group (pyjwt>=2.13.0). - **WebSocket auth gate + audit-driven hardening sweep — A proactive auth-surface audit run surfaced one critical (`/api/v1/ws` broadcast every printer-status / archive / inventory event to anyone reachable on the HTTP port. All fixed in the same PR. @@ -639,7 +587,6 @@ All notable changes to Bambuddy will be documented in this file. - **VP FTP upload capped at 4 GiB (DoS guard)** — `cmd_STOR` now rejects an upload that crosses `MAX_UPLOAD_BYTES = 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`. Same code path adds the streaming rewrite (see Changed section for details). - **Path-traversal hardening across the upload / import / file-write surface (routes + services); fifth CI backstop ships alongside** — A private path-traversal report against `POST /api/v1/projects/import/file` traced two attacker-controlled strings being joined to `library_dir` with no resolve + containment check: (a) `linked_folders[*].name` from the request's `project.json` ("Vector A" — an absolute path in this field collapsed `library_dir / "/anywhere"` to `Path("/anywhere")` because pathlib discards the left side when the right is absolute, letting the next `write_bytes` land anywhere the backend could write), and (b) per-entry `zf.namelist()` paths from the ZIP itself ("Vector B" — ZIP filenames carry `..` segments by spec and the join `library_dir / folder_name / relative_path` had no per-component check). Concrete escalation: drop a `.pth` file into the venv's `site-packages` directory for code execution on next service restart; overwrite the JWT signing-secret file to forge an admin token; overwrite `~/.ssh/authorized_keys` or `~/.bashrc` on native installs. **Fix is structural, not just patch the diff** (per [[feedback_dont_dismiss_preexisting]]). New `backend/app/utils/safe_path.py::safe_join_under(parent, *parts)` helper joins under a trusted parent, resolves both sides, asserts `is_relative_to(parent.resolve())`, and rejects up-front empty / null-byte / absolute path components. Wired into `import_project_file` at both vectors. **Adjacent fix from the routes audit**: `GET /api/v1/archives/{id}/photos/{filename}` had NO validation on `filename` and FileResponse-served arbitrary paths — the existing DELETE endpoint at least had a membership check against `archive.photos` (which is UUID-generated on upload), but GET shared neither the check nor any traversal guard. Both GET and DELETE now route through `safe_join_under` for defence-in-depth on top of the membership check. **Second adjacent fix from the services audit**: `ArchiveService.attach_timelapse(archive_id, data, filename)` in `backend/app/services/archive.py:1456` wrote `archive_dir / filename` where `filename` ultimately comes from either a printer's FTP listing (compromised-printer threat model — the printer is part of the trust surface) or the `?filename=...` query param on `POST /api/v1/archives/{id}/timelapse/select`. A malicious printer that returns a directory listing entry with `..` segments could write the timelapse bytes outside the archive directory; the `f.get("name") == filename` gate in the route did not prevent it because the gate is satisfied by whatever the printer claims is on disk. `attach_timelapse` now routes through `safe_join_under(..., http=False)` and returns `False` (logging the rejection) when the join would escape — matching the existing not-found contract of the function rather than raising 400 from inside a background task. **Audit sweep methodology**: AST-walked every Python file under `backend/app/api/routes/` AND `backend/app/services/` for `Path / Name` shapes (the exact shape that produced the original report). 25 additional route-layer sites and 8 additional service-layer sites confirmed safe case-by-case (UUID-generated filenames written by Bambuddy itself, `_safe_filename(...)` / `Path(arg).name` basename-stripped inputs, `os.walk`-discovered names, denylist + format-validated backup names, hardcoded constants iterated through a tuple, DB-stored paths whose write origin already goes through a resolved-and-containment-checked helper). Each safe site got a `# SEC-PATH-OK: ` marker so future audits can trust the inline guard at a glance. Six pre-existing safe-with-marker sites (`library.py` external upload, `archives.py` timelapse output, `projects.py` attachment download/delete, `settings.py` backup extractall) carry the same marker shape. **Fifth CI backstop** `test_route_path_arithmetic_is_safe_joined_or_marked` (`backend/tests/unit/test_no_unsafe_path_joins.py`) AST-walks every Python file in `backend/app/api/routes/` AND `backend/app/services/` and fails the build on any ` / ` join that doesn't either route through `safe_join_under` or carry the marker on the join line. Joins matching the higher-structure shapes (Attribute access, Subscript, f-string, `str(...)` call) are categorically different and out of scope — those are caught by the broader audit sweep, not the regression backstop. The services layer is in scope because it receives values from the routes verbatim AND from external sources Bambuddy has no control over (the printer FTP-listing case above). **Tests**: 17 unit tests for `safe_join_under` covering every escape vector (absolute path, Windows abs path, `..` segments, embedded `..`, null byte, empty string, no parts, non-str, plus legitimate nested-path round-trip); 4 integration tests against `POST /api/v1/projects/import/file` exercising the full FastAPI stack with the verbatim shape from the report (absolute path in `folder_name` → 400 + filesystem assertion that the target file doesn't exist; `..` in `folder_name` → 400; `..` in `relative_path` → 400; legitimate nested ZIP still imports cleanly to guard against the fix being over-strict); 3 unit tests against `ArchiveService.attach_timelapse` exercising the compromised-printer threat model (filename with `..` segments → returns False + no file at the escape target; absolute filename → returns False + no file at `/tmp`; legitimate `timelapse_YYYY-MM-DD_HH-MM-SS.mp4` → returns True + file lands inside archive_dir, guarding against the fix being over-strict). **SECURITY.md** gains a fifth rule + a fifth row in the CI-test mapping table; the rule explicitly names the printer FTP-listing case as in-scope to set the expectation for future services-layer audits. Full 5500+ test backend suite green; ruff clean. - ### Fixed - **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 and confirmed by @IndividualGhost1905 with a UTC+3 reproduction)** — Reporter saw print-run completion timestamps show UTC clock values (e.g. `07:50` instead of the correct local `10:50` for Berlin / `10:50` instead of `13:50` for a UTC+3 host). Same shape as the #504 timezone-offset bug from Feb 2026 — frontend display helpers calling `new Date(isoString)` directly on backend timestamps without timezone indicators. Per ECMAScript, a bare `"2026-06-02T07:50:00"` is parsed as **local time**, so a UTC-stored value gets displayed as if its numeric components were already local — visually identical to UTC. The #504 fix patched 13 sites but missed four: PrintLogTable and SpoolUsageHistory hadn't been written yet; CameraTokensPage and SpoolBuddySettingsPage existed but were overlooked. **Fix** — replaced the bare `new Date(iso)` calls in `frontend/src/components/PrintLogTable.tsx::formatDate` (per-archive Runs list — the reporter's literal symptom), `frontend/src/components/SpoolUsageHistory.tsx::formatDate` (spool usage records), `frontend/src/pages/CameraTokensPage.tsx::formatDate` + `isExpired` (long-lived camera token created / expires / last-used columns), and `frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx::formatDateTime` (SpoolBuddy device "last calibrated") with calls to the shared `parseUTCDate()` helper from `utils/date.ts`, which appends `Z` to naive ISO strings and parses TZ-tagged strings as-is — already used by every other date formatter in the codebase and well-tested (`parseUTCDate` has 4 dedicated test cases covering null/empty/tagged/naive inputs). `isExpired` in `CameraTokensPage.tsx` got the same treatment because comparing a misparsed Date against `Date.now()` would have produced false "not expired" / "expired" results around the TZ-offset boundary. **What this does NOT fix** — the printer-card ETA reporter #1 described (10:50 + 57m showing 09:48). That comes from `formatETA(status.remaining_time)` 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. That's a browser / OS config issue, not Bambuddy's display. If reporter #1 was actually looking at log timestamps (the same surface reporter #2 explicitly called out), this fix covers it; otherwise their ETA complaint stays a config matter. **Audit confirmed no other regressions** — grepped every `new Date(` call in `frontend/src/` for backend-supplied string arguments. Remaining call sites either pass a number (epoch ms from chart data — `AMSHistoryModal.tsx:327,349`), construct from a numeric date string only with no time component (chart axis labels — `FilamentTrends.tsx:57,129`), use the result only for `.getTime()` arithmetic where the same TZ offset cancels out (sort comparators in `StatsPage.tsx:920`, `ForecastPanel.tsx:101,111,112,141`, `FilamentTrends.tsx:70`), or already wrap in `parseUTCDate(...) || new Date(...)` as defensive fallback (`StatsPage.tsx:607,895`). `FailureDetectionSettings.tsx:359` uses `new Date(ev.timestamp)` directly but the backend (`obico_detection.py:293`) emits `datetime.now(timezone.utc).isoformat()` which includes a `+00:00` indicator so ECMAScript parses it as UTC correctly — unchanged. **No new tests added** — the four `formatDate` / `formatDateTime` helpers are local to their files and the bug is mechanical "use the existing helper"; `parseUTCDate` itself has full coverage in `__tests__/utils/date.test.ts`. Frontend build clean, ESLint zero output, full date-utils + impacted-component vitest suites (103 tests) green, i18n parity green at 5007 leaves × 9 locales. - **Archive card's Print Time + accuracy badge are now consistent for multi-run / multi-plate archives (#1608, reported via an AI-assisted diagnosis that included the failing SQL, file line numbers, and a worked example for archive #65)** — Reporter's case: 3-plate `.gcode.3mf` printed plate-by-plate over 9 runs. Card showed `1h 46m +188%` next to the now-correct `156.7g` / `$9.81`. The 1h 46m = 6364 s = one run's `completed_at − started_at`; the +188% = `print_time_seconds / 6364` − 100 % where `print_time_seconds` is 18354 s (the whole-file estimate the #1593 parser fix correctly stores). The two halves describe different scopes — apples-to-oranges. **Root cause** — `backend/app/api/routes/archives.py::compute_time_accuracy` (line 152) only inspects the archive row's own `started_at` / `completed_at`, which reflect the latest run, while `archive.print_time_seconds` is the sum across plates post-#1593. The existing 5-500 % sanity band catches truly broken values but lets the deterministic N×100% shape through (300% for a 3-plate file). `archive_to_response` calls `compute_time_accuracy` on every list / detail / search / project-archive / patch render (line 275), so the bad number reaches the frontend on every card surface. The stats endpoint (`/api/v1/archives/stats`, line 940-988) has its OWN per-run accuracy loop with a tighter 50-200 % band filter shipped with #1593 — that's untouched and stays correct. **Fix** — `compute_time_accuracy(archive, run_aggregate=None)` gains an optional `run_aggregate` argument. When `run_aggregate["run_count"] > 1`, both `actual_time_seconds` and `time_accuracy` are returned as `None`. The frontend already falls through to `archive.print_time_seconds` for the Time display (`archive.actual_time_seconds || archive.print_time_seconds`) and conditionally renders the badge only when `archive.time_accuracy` is truthy, so multi-run archives now show "Estimated 5h 6m" with no badge instead of "Actual 1h 46m +188%". Single-run archives — the case the badge was designed for, and the only case where one-run actual versus whole-file estimate is a meaningful ratio — keep the original behaviour verbatim. **Audit-wide** — `archive_to_response` now passes `run_aggregate` through to `compute_time_accuracy` at the response-conversion call site. The 3 endpoints that did NOT previously load run aggregates (`backend/app/api/routes/archives.py` search endpoint's pre-FTS fast path at line 583 and FTS path at line 610, the single-archive PATCH endpoint at line 1419, and `backend/app/api/routes/projects.py::list_project_archives` at line 706) now batch-load `_load_run_aggregates` and pass it through, so the badge-suppression applies on every card surface — not just the main list and detail endpoints. One extra `SELECT … GROUP BY archive_id` per endpoint (the helper is already batched), cheap. Per the [[feedback_pr_reviews_thorough]] HARD RULE the fix is shipped across every call site that renders an archive card. **What this does NOT change** — the stats endpoint's per-run accuracy aggregation at line 940-988, its 50-200% band filter, the `archive.started_at` / `completed_at` source-of-truth for the latest-run timestamps, the frontend `ArchivesPage.tsx:1004-1022` rendering logic, or the time-accuracy computation for single-run archives. Reprint scope is unchanged (the reporter's option A — comparing summed run durations against the whole-file estimate — was not pursued because it produces a different but equally misleading number for the reprints-of-a-single-plate-file shape, where sum-of-runs = N × estimate). **Tests** — `backend/tests/unit/test_archive_run_aggregation.py::TestComputeTimeAccuracyMultiRun`: 4 new direct unit tests for the function — single-run archive keeps original badge, no `run_aggregate` argument keeps original badge (defends the legacy caller pattern), multi-run archive (reporter's exact 9-run case) clears both fields, and `run_count: 0` edge case keeps original behaviour. Two new integration tests against the live archives list endpoint: `test_archive_list_suppresses_time_accuracy_for_multi_run_archives` is the #1608 regression (3-plate plate-by-plate fixture, asserts both `actual_time_seconds is None` and `time_accuracy is None` AND that the estimate `print_time_seconds` survives so the card has something to render), and `test_archive_list_keeps_time_accuracy_for_single_run_archives` is the sanity check that the badge still shows for the happy path. Full backend pytest 3680 passed under `-n 30`; ruff clean. **No frontend change required** — the existing rendering logic in `ArchivesPage.tsx:1004-1026` (`formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)` for the time + `{archive.time_accuracy && …}` conditional badge) naturally produces the desired "show estimate, no badge" presentation when the backend returns null for both fields. @@ -1312,7 +1259,6 @@ All notable changes to Bambuddy will be documented in this file. ### Security - **postcss bumped to 8.5.12 to clear GHSA-qx2v-qp2m-jg93** — moderate-severity advisory: PostCSS < 8.5.10 has an XSS via an unescaped `` sequence in its CSS Stringify output. The caret range in `frontend/package.json` already accepted 8.5.12, so this is a lockfile-only bump; vite, autoprefixer, and `@tailwindcss/postcss` all dedupe onto the same 8.5.12 with no nested copies left in `node_modules`. PostCSS runs at build time only and Bambuddy doesn't pass user-controlled CSS through it at runtime, so the practical impact even on the older version was nil — this is hygiene + clearing the `npm audit` warning. - ## [0.2.3.2] - 2026-04-22 ### Improved @@ -1338,7 +1284,6 @@ All notable changes to Bambuddy will be documented in this file. - **AMS: Configure / Assign Spool Hidden on Reset Slots, and Assign Spool Missing Matching-Material Inventory** ([#1047](https://github.com/maziggy/bambuddy/issues/1047)) — Two separate symptoms from the same report. (1) After resetting an AMS slot from the printer UI, the Bambuddy printer card showed "Empty Slot" with no Configure or Assign Spool actions on hover, while the same slot in SpoolBuddy's AMS page still let the user re-configure it. Root cause: commit `c9efa4b8` (#784) added a `tray?.state === 10` gate to the `EmptySlotHoverCard` actions, intended to show the buttons only when a spool was physically present but not loaded (state=10) and hide them on truly empty slots (state=9). In practice, firmware often reports `state=9` (or no `state` field at all) after a user-initiated reset — even when a spool is still physically in the slot — so the actions disappeared exactly when the user needed them. The gate is redundant anyway (`EmptySlotHoverCard` is only rendered when the slot has no `tray_type`, so it's definitionally empty from Bambuddy's perspective), and configuring an empty slot is a valid "tell the printer what will be loaded here" operation. The gate is now removed at both the standard-AMS and AMS-HT render paths. (2) After configuring a slot with a Generic profile (e.g. "Devil Design PLA Basic Red"), the Assign Spool modal didn't list the matching inventory spool unless the user enabled the "Show all spools" toggle. Root cause: the filter at `AssignSpoolModal.tsx:144` required `normalizeValue(spool.slicer_filament_name) === normalizeValue(trayInfo.profile)` — manually-added inventory spools typically don't have `slicer_filament_name` populated, so they failed the exact-profile check even when the material matched. The filter now prefers an exact slicer-profile match when both sides advertise one, and falls back to partial material match in either direction (so e.g. a spool with `material="PLA"` is selectable for a slot reporting `"PLA Basic"`) when profile info is missing. (3) Once the matching spool was assignable, a "profile mismatch" confirmation dialog still warned on every assignment because Bambu Studio / OrcaSlicer slicer-profile names carry a printer/nozzle/variant qualifier after `@` (e.g. `"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"`) while the tray stores only the bare base name (`"Devil Design PLA Basic"`), and `checkProfileMatch` compared the full strings. Both the filter and the mismatch check now strip the `@…` qualifier before comparing, so identical base profiles are treated as a match. Regression test covers a spool with no slicer profile being surfaced for a slot whose profile + material are both set. Thanks to @TravisWilder for the report. - **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-pr - ### Added - **Spoolman Unified Inventory UI** — Replaced the Spoolman iframe with a native inventory UI that matches the local spool experience exactly. The Filament Inventory page auto-detects the active backend (local DB or Spoolman) and renders spools, filters, deep-links, and NFC write flows identically regardless of source. Spoolman spools are fully editable — material, weight, colour, storage location, cost — via a PATCH proxy that re-links the Spoolman filament on metadata changes. Bulk-create, archive, restore, and delete are all supported. A 207 Multi-Status response on partial bulk-create includes `requested_count` and `failed_count` so the UI can surface a useful "Created N of M" message. - **Storage Location field** — Spoolman's `location` field is now exposed as `storage_location` in the unified inventory schema and editable from the spool detail panel. @@ -1419,7 +1364,6 @@ All notable changes to Bambuddy will be documented in this file. - **Archive Reprints Show Wrong Duration in Third-Party MQTT Monitors** ([#1011](https://github.com/maziggy/bambuddy/issues/1011)) — Re-printing a file from Bambuddy's archive caused external MQTT observers like OctoEverywhere to report wildly wrong durations: a 40 min job first reprint would show ~1 h 40 min, and a second reprint of the same file would compound further (~4 h for a ~45 min print), with the excess roughly matching the wall-clock gap since the previous archive replay. The same file printed via BambuStudio → Bambuddy proxy → printer reported correct durations every time. Root cause: the archive-reprint path built the MQTT `project_file` command with hardcoded `project_id="0"`, `subtask_id="0"`, `task_id="0"`, and `md5=""`, while BambuStudio mints unique identity fields per submission. The printer uses those IDs to key per-job state (including `gcode_start_time`), so when every reprint arrived under the same `task_id=0`, the printer reused the prior job's start timestamp instead of emitting a fresh state-transition event — third-party tools that derive duration from that timestamp latched onto a stale value, and successive replays compounded the error. `bambu_mqtt.start_print()` now generates a per-submission millisecond timestamp for `project_id`/`subtask_id`/`task_id` and a unique `md5` derived from the filename + timestamp, matching BambuStudio's per-submission-unique-ID behavior. Covers both archive reprints and direct prints from the Library. Thanks to @PurseChicken for the controlled A/B reproducer (Studio vs archive reprint) that pinpointed the divergence to the print-start command payload. - **CSP Blocked Sidebar Iframes, Service-Worker Registration, and Google Fonts** — The strict `Content-Security-Policy` header added in 0.2.3b4 broke three things at once: (1) custom sidebar links pointing at external HTTPS URLs (e.g. a Grafana/telemetry dashboard) rendered in `ExternalLinkPage` were blocked because no `frame-src` was declared and iframes fell back to `default-src 'self'`; (2) the inline service-worker registration `