Commit Graph
2789 Commits
Author SHA1 Message Date
maziggy ccdbdabdb7 docs(docker): warn bridge-mode users about FTP passive port × docker-proxy RAM footprint (#1646)
Reporter @TheFou (on Docker bridge mode with the default userland-proxy:
  true) saw ~2000 docker-proxy host processes spawn from the commented
  "50000-51000:50000-51000" line, pinning ~3.5 GB of host RAM before they
  had even logged in for the first time. The processes are host-level so
  they don't appear in `docker stats`, which makes the leak invisible.

  Linux's host-mode default in the same compose file sidesteps this
  entirely (zero docker-proxy cost) - the issue only fires when a user
  forces bridge mode (typically Docker Desktop on macOS / Windows).

  The 1001-port FTP passive range is load-bearing on the VP server side
  (virtual_printer/ftp_server.py:567-574 documents the widening from 100
  ports as multi-VP collision-avoidance headroom against birthday-style
  collisions when bind_ip=0.0.0.0). Reverting it would regress multi-VP
  installs to solve a problem that only exists for bridge-mode users.

  Fix is documentation, not code. Added a warning block above the
  commented FTP-passive line pointing bridge-mode users at
  { "userland-proxy": false } in /etc/docker/daemon.json. Reporter
  confirmed this clears the issue on their setup - the kernel does NAT
  directly via iptables/nftables in that mode, no per-port host process
  needed. Only side-effect is that connections originating from
  127.0.0.1 on the host itself can't reach the container, which doesn't
  matter for nearly every Bambuddy install.
2026-06-05 09:36:30 +02:00
maziggy 54389a54aa fix(library): MakerWorld URL import honours external folder destinations (#1645)
Reported and root-caused by @needo37. Importing a model via the MakerWorld
  URL-download feature into a writable external folder (e.g. SMB/NFS-mounted
  NAS) saved the 3MF into Bambuddy's internal managed library dir, not the
  external mount. The file card showed in the File Manager under the
  external folder, but the bytes never landed on the NAS, and the on-disk
  copy was UUID-renamed so a find by the original basename matched nothing.

  Root cause was save_3mf_bytes_to_library at backend/app/api/routes/library.py:422:
  it accepted folder_id but never loaded the folder, never inspected
  is_external / external_path, hardcoded the destination to
  get_library_files_dir() with a UUID name, and left the LibraryFile row
  with is_external=False. So the row's folder_id pointed at the external
  folder while its bytes and is_external flag both said "managed/internal".
  Same class of bug as #1112, which had been fixed for the multipart-upload
  and move paths but never applied to this byte-import path.

  Fix mirrors the multipart-upload path directly:
  - Load target_folder from folder_id when non-None.
  - Feed it to _resolve_upload_destination(target_folder, filename), which
    already returns (dest, is_external) and enforces the 403-read-only /
    400-unwritable-or-missing / 409-collision rejections.
  - Write bytes to dest (real filename for external, UUID for managed).
  - Persist the row with file_path=_stored_file_path(dest, is_external)
    and is_external=is_external.

  The route-layer read-only guard at makerworld.py:256-260 is preserved -
  it returns the friendlier error before the upstream download burns
  bandwidth - and _resolve_upload_destination's identical check stays as
  defence-in-depth for any future caller that skips the route gate.
  Thumbnails continue to live under the managed get_library_thumbnails_dir()
  regardless of the 3MF's location, matching the upload path.
2026-06-05 09:29:53 +02:00
maziggy 9554ebd05d refactor(inventory): rename /reset-usage to /reset-consumed-counter to match what it actually does (issue #1644)
The old endpoint name implied that calling it would drop weight_used to
  0. In practice it only stamps weight_used_baseline = weight_used so the
  Inventory page's "Total Consumed" widget (weight_used - baseline) reads
  0 going forward, while remaining (label_weight - weight_used) is
  preserved. Calling the endpoint via curl and seeing weight_used
  unchanged in the JSON response is confusing.

  New paths:
  - internal: /api/v1/inventory/spools/{id}/reset-consumed-counter
             /api/v1/inventory/spools/reset-consumed-counter-bulk
  - spoolman: /api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter
             /api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk

  Behaviour is unchanged in both modes; internal stamps the baseline
  directly, Spoolman-mode PATCHes upstream used_weight=0 and the
  _map_spoolman_spool read mapping reconstructs the same "displayed
  consumed = 0, remaining unchanged" Bambuddy-visible shape. Parity
  between modes was already in place and is preserved.

  The Spoolman-client method reset_spool_usage keeps its name because it
  describes what is sent upstream to Spoolman, not what Bambuddy's
  endpoint promises to callers.

  Frontend:
  - api.resetSpoolUsage / bulkResetSpoolUsage (and Spoolman variants)
    renamed to resetSpoolConsumedCounter / bulkResetSpoolConsumedCounter.
  - Button labels: "Reset usage to 0" -> "Reset counter" / "Reset all
    counters" (short, unambiguous); tooltips and confirm-modal bodies
    still spell out the full semantics.
2026-06-05 09:22:05 +02:00
maziggy e802bfc806 fix(ftp): cap TLS to v1.2 for X2D FTPS to dodge WRONG_VERSION_NUMBER on firmware 01.01.00.00 (#1638)
Reporter @vasmarfas saw X2D archive cards land almost empty - only print
  time, no filament weight / layers / MakerWorld link / thumbnail - and
  Spoolman filament-usage tracking went silent on the same printer.

  Support bundle traces the end-to-end: at print start
  backend/app/main.py::on_print_start tries the usual FTP-download dance
  for the 3MF, every implicit-FTPS connect attempt to the X2D fails with
  `[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)`, ~2
  minutes later "Could not find 3MF file for print" -> "Created fallback
  archive". Fallback path writes file_path="", file_size=0,
  content_hash=NULL, no layers / filament / model-link fields. Spoolman
  tracking degrades from the same root cause - both depend on the 3MF
  metadata parser.

  Proximate cause: Python 3.13's default ssl.create_default_context()
  negotiates TLS 1.3, the X2D's implicit-FTPS server on port 990 rejects
  the ClientHello. Same family as the P2S 01.02.00.00 bug from #1401
  (post-Python-3.13 TLS-1.3 breakage), different wire-level failure mode
  (P2S completes the handshake and truncates with 426; X2D fails the
  handshake outright).

  Same fix shape: add X2D to backend/app/services/ftp_profiles.py with
  cap_tls_v1_2=True, plus N6 -> X2D SSDP alias. Every other model stays
  on negotiated TLS 1.3.

  Honest caveat: hypothesis-driven trial, not a confirmed root-cause fix.
  WRONG_VERSION_NUMBER could equally describe the X2D switching to
  explicit FTPS (AUTH TLS on plaintext greeting) or moving FTPS to a
  different port - either would need a different code path. Reporter has
  been asked to test this build; if the cap doesn't clear it the registry
  slot stays useful and the next diagnostic round goes to openssl
  s_client from a network-adjacent host.
2026-06-05 08:30:19 +02:00
maziggy 18d534c945 feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account
  (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing
  Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub /
  email+password); password defaults. Paste-flow PKCE because Orca's
  Supabase project only allowlists localhost redirect_to — open feature
  request at OrcaSlicer/OrcaSlicer#14028.

  Surfaces:
  - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same
    rich layout (search + 5 filter dropdowns + 3-column grouped grid +
    read-only detail modal)
  - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud >
    standard); separate status banner per cloud; metadata-aware pre-pick
    scores Orca filaments above local (Orca's sync_pull returns full
    content inline so filament_type / filament_colour come for free, no
    per-setting fetch rate-limit dance)
  - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed
    orca_<UUID> to match local_/builtin_); generic Bambu filament-ID
    derivation from parsed material (printer firmware can't grok Orca
    UUIDs); slot mapping persists preset_source='orca_cloud'
  - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the
    cloud preset list via Promise.allSettled (OrcaProfileMeta is
    structurally identical to SlicerSetting)

  Backend:
  - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange /
    single-use refresh rotation / get_user_info / list_profiles via the
    bare /sync/pull bootstrap path
  - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish,
    auth/password, status, logout, profiles, profiles/{id}); router-level
    _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers
    (SpoolBuddy kiosk) properly resolve their owner User; just-in-time
    refresh with atomic persist-before-API-call
  - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu
    Cloud fetcher (status vocabulary, 5min cache, permission shortcut);
    _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains
    orca_cloud + orca_cloud_status
  - services/preset_resolver.py: PresetRef.source extended with
    "orca_cloud"; _resolve_orca_cloud walks list + filters
  - 8 columns on users table for tokens (5 persistent) + transient PKCE
    handshake state with 10-min TTL (3); dialect-branched DATETIME /
    TIMESTAMP; auth-disabled mode falls back to Settings table
  - orca_cloud:auth permission folded into can_access_cloud API-key scope
    (same trust dimension)
2026-06-04 15:50:44 +02:00
maziggy 764eb58540 Updated BACKERS 2026-06-04 11:52:46 +02:00
MartinNYHC 01c402eae9 Merge pull request #1626 from maziggy/dependabot/npm_and_yarn/frontend/npm_and_yarn-813bc8c1b2
chore(deps): bump react-router from 7.13.0 to 7.16.0 in /frontend in the npm_and_yarn group across 1 directory
2026-06-04 11:43:44 +02:00
maziggy 51730a7bf1 feat(ams): gate humidity/temperature alarms on AMS-has-filament (#1619)
The hourly AMS sensor recorder dispatched humidity and temperature alarms
  for every unit above threshold without checking whether the unit was
  actually loaded. Empty AMS units still report ambient readings, so users
  with one loaded + one empty AMS got useful alarms for the loaded one and
  hourly noise for the empty one. Disabling the whole alarm category killed
  both — not a real choice.

  New _ams_has_filament helper inspects tray_exist_bits (hex bitmap, "0" =
  empty) with fallback to the tray array's tray_type strings for shapes
  where the bitmap is missing. The recorder gates the alarm dispatch on
  this check per-AMS-unit, so a multi-AMS printer with one loaded + one
  empty still alarms on the loaded one.

  Sensor history still records regardless of the gate so the System page
  humidity charts stay continuous — only the outbound notification is
  suppressed. 9 unit tests cover the bitmap-zero case, bitmap-missing
  fallback, garbage/blank/int bitmap edges, and defensive malformed tray.
2026-06-04 11:30:26 +02:00
maziggy db1c664fce feat(vp): surface why MQTT bridge IP encoding didn't arm (#1429 defensive)
_refresh_ip_encoding had 4 silent early-returns. When the rewrite
  silently no-op'd on a user's setup, the only signal was the absence of
  the "armed" INFO line, and diagnosing which path was firing meant
  grepping the source.

  Each path now emits one INFO line naming the specific reason. A
  _not_armed_reason dedup field throttles to one line per state change,
  so an idle unarmed bridge doesn't spam every 30s refresh tick. Cleared
  on successful arm so regressions re-emit.

  Not a fix for #1429 itself — the bridge logic is unchanged; this just
  turns the silent failure into visible signal so the next "fix didn't
  work for me" report can be triaged in one round-trip.
2026-06-04 11:24:20 +02:00
maziggy 24ce250176 fix(labels): single PDF per click (#1628)
window.open(url, '_blank', 'noopener,noreferrer') returns null even on
  success per the WindowFeatures spec — `noopener` deliberately suppresses
  the return reference. The label-print modal treated null as "popup
  blocked → fall back to <a download> click", so the fallback fired on
  every click. Result: window.open opened the blob tab (downloading a
  random-named PDF on systems without an inline viewer) AND the fallback
  downloaded bambuddy-labels.pdf — two identical PDFs per click.

  Drop noopener,noreferrer. The blob is same-origin, the destination is a
  passive PDF preview tab with no script context, and noreferrer is no-op
  for blob URLs. window.open now returns a real window reference on
  success and the if (!win) fallback only fires on genuine popup-block.
2026-06-04 11:04:57 +02:00
maziggy 51abc4b7a8 feat(drying): enable AMS drying for H2C at firmware 01.02.00.00+ (issue #1624)
H2C was in _DRYING_UNSUPPORTED_MODELS. Move to _DRYING_MIN_FIRMWARE
  with the same 01.02.00.00 floor as H2S / P2S. Both SSDP model codes
  the H2C advertises (O1C single-nozzle, O1C2 dual-nozzle) get the
  same gate so supports_drying() fires correctly regardless of which
  form is stored on the printer record.
2026-06-04 10:57:46 +02:00
maziggy c571ad86dd feat(diagnostic): printer_publishing check + countdown UI (#1622)
The existing connection diagnostic proved TCP + TLS + auth + SUBSCRIBE but
  not that the printer was actually publishing reports. A wrong-cased serial
  passes mqtt_auth because the broker accepts the subscription regardless;
  the user-visible symptom is empty AMS / no K-profiles / no custom filaments
  in the slicer Device tab because the VP cached state is empty. Bambuddy
  already logged the actionable hint at bambu_mqtt.py:498 but only to
  container logs.

  New printer_publishing check turns that warning into a structured
  diagnostic result. Pass = bridge has seen at least one report since the
  last (re)connect; fail = zero reports across the wait window with fix-text
  pointing at the case-sensitive serial. Bounded 10s poll on the on-demand
  UI route, no wait on the support-package gathering path so bundling stays
  fast. Exits the moment a message arrives — typical wall-clock is 1-2s.

  Frontend renders an elapsed-seconds counter plus a "Listening for status
  report — up to 10s" hint during the pending state so the wait doesn't look
  hung. PUBLISH_WAIT_DEFAULT_SECONDS pinned on both sides.

  report_messages_since_connect exposed as a public property on
  BambuMQTTClient so the diagnostic doesn't reach into private state.
2026-06-04 10:54:04 +02:00
maziggy a1cb5d5b4d fix(backup): interpret scheduled-backup HH:MM as local time, not UTC (#1602 follow-up)
The Scheduled Local Backups time-of-day picker was interpreted as UTC by
  _calculate_next_run, so a UTC+3 user had to enter 18:00 to get a 21:00
  local backup. The UI labeled the field "UTC" but it was still surprising.

  Picker is now interpreted in the container's local timezone, resolved
  from the TZ env var via zoneinfo.ZoneInfo (same source the Support page's
  environment.timezone shows). UTC fallback when TZ is unset or
  unrecognised. The /local-backup/status endpoint exposes the resolved
  zone, and the UI renders it next to the field via a new
  backup.localTimeHint i18n key with real translations in all 10
  non-English locales.

  One-time behaviour change for users who entered a UTC time as a
  workaround: the first scheduled cycle after upgrade will run at their
  local TZ offset earlier than expected. Re-enter the time as local once
  and it is correct from then on. No migration is shipped; migrating
  around a DST boundary would be ambiguous.
2026-06-04 10:17:10 +02:00
maziggy 526ef462fd Updated CHANGELOG 2026-06-03 15:21:11 +02:00
maziggy bd26050fad fix(i18n): strip unnecessary quote escapes in ko.ts and tr.ts
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.
2026-06-03 15:19:51 +02:00
maziggy c36c8cea77 chore(deps): bump PyJWT floor to >=2.13.0 for upstream advisories
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.
2026-06-03 14:02:54 +02:00
maziggy fee16f4e34 Updated README 2026-06-03 11:32:12 +02:00
maziggy 2b378a429e Updated README 2026-06-03 11:31:04 +02:00
maziggy f68eda4da5 Post work pr #1571 2026-06-03 11:01:16 +02:00
Samed Yüksel d736611ded feat(i18n): add Turkish (tr) translation (#1571) 2026-06-03 10:59:38 +02:00
Hijae Song 77655d8dfa feat(i18n): add Korean (ko) translation (#1587) 2026-06-03 10:51:28 +02:00
maziggy 0471e43b8a Housekeeping 2026-06-03 10:50:48 +02:00
maziggy e38267065c fix(frontend): #1602 render print-run / spool-usage / camera-token / SpoolBuddy timestamps in local time
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).
2026-06-03 10:39:07 +02:00
maziggy cc25cbe774 fix(archives): #1608 suppress card time-accuracy badge for multi-run archives
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.
2026-06-03 10:14:57 +02:00
maziggy 9cc4b6aa60 fix(virtual-printer): #1610 add Bambu cipher pin to every slicer-facing TLS context
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.
2026-06-03 10:04:37 +02:00
maziggy fee7c722f5 fix(usage-tracker): #1607 filter empty AMS slots from position-based fallback
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.
2026-06-03 09:45:10 +02:00
maziggy cdc27eb517 fix(virtual-printer): #1429 follow-up — auto-resolve VP IP when bind_address is 0.0.0.0
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.
2026-06-03 09:07:57 +02:00
maziggy dd746c1818 Updated spoolbuddy/install/install.sh 2026-06-02 17:13:25 +02:00
maziggy 2a520a1ec7 chore(slicer-api): bump BAMBU_VERSION default to 02.07.01.57
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.
2026-06-02 16:01:25 +02:00
maziggy 148eab4de6 Updated BACKERS 2026-06-02 15:51:42 +02:00
maziggy bd19a8ccb4 docs(install): polish Windows section + Service/Update/Troubleshooting
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.
2026-06-02 15:25:31 +02:00
Marko@VMHOMELAB 873c78a6ff Feature/windows installer (#1529)
Add feature: Added a powershells script to easily install bambuddy on windows
2026-06-02 15:17:25 +02:00
maziggy 673001e3cd fix(maintenance): #1596 persist wiki_url on Custom Type create + correct
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.
2026-06-02 14:58:10 +02:00
maziggy a837a3acbd ● fix(library): #1600 thumbnail extraction for external-folder .gcode.3mf
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.
2026-06-02 14:51:08 +02:00
maziggy 5d6d928b3f fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#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.
2026-06-02 14:33:20 +02:00
maziggy 1e08c25a9f fix(stats): #1593 multi-plate parser + per-run project rollup + carry-over system totals + accuracy band
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.
2026-06-02 13:35:06 +02:00
maziggy c9bc5eb4e9 fix(webhook): #1584 PrinterState dataclass attribute access in status / stop / cancel
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.
2026-06-02 12:57:51 +02:00
maziggy 171848a0fc fix(slicer-presets): #1581 SliceModal refresh + invalidate on local-profile delete/import
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.
2026-06-02 12:42:16 +02:00
maziggy 396e9aa09e security: harden path-traversal class across routes + services; fifth CI backstop
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.
2026-06-02 12:12:54 +02:00
maziggy db9b20631c fix(cloud): #1575 surface actionable error when Bambu Cloud Cloudflare challenge swallows the JSON response
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.
2026-06-02 10:55:25 +02:00
maziggy be15a375a6 fix(oidc): #1569 populate User.email from standard 'email' claim when email_claim is preferred_username
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.
2026-06-02 10:23:21 +02:00
maziggy b7d7c82501 fix(security): WebSocket auth gate + audit-driven hardening sweep 2026-06-02 10:02:17 +02:00
maziggy 9c8df1744d chore(deps): bump vitest 3.2.4 → 4.1.8 (GHSA-5xrq-8626-4rwp, CVSS 9.8)
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.
2026-06-02 08:38:00 +02:00
maziggy ec51394196 fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)
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.
2026-06-02 08:28:24 +02:00
MartinNYHC 5a93b33fd1 Housekeeping 2026-06-01 15:34:06 +02:00
maziggy 4ffefa60f4 chore(vp): per-minute MQTT status-push counter for idle-disconnect triage (#1548 follow-up)
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.
2026-05-30 14:47:06 +02:00
maziggy f6f6f92e38 test(pytest): silence upstream starlette httpx2 deprecation noise 2026-05-30 14:19:19 +02:00
maziggy e10462678f fix(security): GHSA-6mf4-q26m-47pv — fail-closed on auth-probe DB errors (CVSS 9.8)
is_auth_enabled() and auth_middleware both caught every exception during
  the auth-state probe and returned the "allow" answer instead of denying
  the request. Reporter's PoC floods /api/v1/auth/login to exhaust file
  descriptors, forcing the next SQLite connect to raise, then hits a
  protected endpoint during the fail-open window with no token — granting
  unauthenticated access to admin-account creation, API-key creation, DB
  backup download, and printer control. CWE-636 / CWE-755. Affects >= 0.1.6.

  Fix:
  - is_auth_enabled (backend/app/core/auth.py): only returns False for the
    legitimate "settings row absent" case; any actual exception propagates
    so the caller can deny the request.
  - auth_middleware (backend/app/main.py): returns 503 on any probe failure
    instead of await call_next(request).

  4 new regression tests in test_auth_fail_closed.py pin the contract
  (propagates DB exceptions, returns False for no-row, True for "true",
  False for "false"). 1 existing security test renamed and updated to
  accept either 500 or 503 (both fail-closed) and to verify the
  SQLAlchemy detail does not leak in the body.

  Codebase grep confirmed no other auth-decision predicate has the same
  fail-open shape: _validate_api_key returns None on catch (→ 401 fail-
  closed downstream), is_advanced_auth_enabled propagates correctly,
  permissions.py has no catch-alls.

  Reported by @wondercrash via private advisory.
2026-05-30 13:49:09 +02:00
maziggy 597762685c fix(virtual-printer): #1558 Send pre-flight + slicer-surface audit bundle
#1558: cached-as-base push_status only forced gcode_state=IDLE while letting
  the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...)
  leak through. Bambu Studio's Send pre-flight read them as busy and refused.
  The cached branch now overrides the activity-field set the same way it
  already overrode storage indicators (#1228) and protocol fields.

  Same bundle ships a multi-round VP audit that found adjacent bugs in the
  same family:

  - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error
  - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log
  - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload)
  - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status
  - _pending_files cleanup in finally for archive / queue / dispatch handlers
  - _add_to_print_queue position uses MAX+1 (was hardcoded 1)
  - DELETE VP removes orphan PendingUpload rows + upload_dir from disk
  - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match)
  - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs
  - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map)
  - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind
  - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation)
  - FTP passive port range widened 50000-51000; docker-compose + wiki updated
  - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening
2026-05-30 13:34:10 +02:00
maziggy 40cd45e2d5 fix(library): render 3D preview for sliced .gcode.3mf files (#1543)
Reporter exported a multi-plate .gcode.3mf from Bambu Studio to the
  shared folder Bambuddy watches and the 3D preview tab came up empty;
  if he re-uploaded the same file via the file manager, the preview
  worked. Root cause: two paths classify file_type differently. The
  shared-folder scan at backend/app/api/routes/library.py:1343-1348
  does a compound-extension check and tags the file `gcode.3mf`; the
  upload path at the same file's :1588 does a single `ext[1:]` and tags
  it `3mf`. Then frontend/src/components/ModelViewerModal.tsx:71-73 had

    hasModel = normalizedType === '3mf' || 'stl'
    hasGcode = normalizedType === 'gcode' || '3mf'

  Neither matched `gcode.3mf`, so the capabilities object landed with
  both flags false and the modal rendered an empty bed.
  FileManagerPage.tsx:858 also gated the Preview-3D context action on
  `file_type === '3mf' || 'gcode' || 'stl'`, so for shared-folder files
  the menu entry didn't even appear, and the type pill at :765-770 had
  no colour case for `gcode.3mf` so it fell through to the generic
  gray.

  Fix (frontend-only, no backend churn):

  - ModelViewerModal.tsx introduces an
    `isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'`
    predicate used in two branches — the capabilities check
    (`hasModel = isThreeMfFamily || 'stl'`, `hasGcode = isThreeMfFamily
    || 'gcode'`) and the plates-loading branch that previously hard-
    gated on `!== '3mf'` and would have returned setPlatesData(null)
    for the shared-folder file.

  - FileManagerPage.tsx adds `gcode.3mf` to the Preview-3D action gate
    and shares the gcode blue type-pill colour so sliced-output files
    are visually distinguishable from source 3MFs.

  The compound `gcode.3mf` classification on the backend is intentionally
  preserved — it carries useful "this is a sliced output" semantics that
  other UI surfaces could use later. The `canOpenInSlicer` and
  `sliceableType` checks at ModelViewerModal.tsx:269, 277-280 are
  deliberately left alone — a sliced output isn't openable in the slicer,
  and `sliceableType` already explicitly excludes `.gcode` and
  `.gcode.3mf` per the comment.

  Out of scope (separate Bambu-Studio format limitation, not a Bambuddy
  bug): Vlado's secondary observation that the upload-path 3D preview
  "shows only one plate" even though his project has 5 plates — Bambu
  Studio's .gcode.3mf export contains the model data and g-code for the
  active plate only, not the entire multi-plate project. The print
  picker enumerates plates via gcode_*.gcode entries inside the zip
  (a separate code path), which is why the user can still pick the
  plate at print time. The empty-bed fix is the data point that closes
  the user-visible bug.
2026-05-29 11:29:40 +02:00