github maziggy/bambuddy v0.2.4b1-daily.20260423
Daily Beta Build v0.2.4b1-daily.20260423

pre-release6 hours ago

Note

This is a daily beta build (2026-04-23). It contains the latest fixes and improvements but may have undiscovered issues.

Docker users: Update by pulling the new image:

docker pull ghcr.io/maziggy/bambuddy:daily

or

docker pull maziggy/bambuddy:daily


**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.

Added

  • Library Trash Bin + Admin Bulk Purge + Auto-Purge (#1008) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than N days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new Auto-purge setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New library:purge permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable deleted_at column on library_files with an index (dialect-aware migration: DATETIME on SQLite, TIMESTAMP on PostgreSQL, since raw DATETIME is SQLite-only syntax); every LibraryFile query site now routes through a new LibraryFile.active() classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to cadtoolbox for the proposal and the follow-up answers that tightened the spec.
  • Archive Auto-Purge (#1008 follow-up) — Settings → Archives now has an auto-purge toggle plus a Purge archives now action on the Archives page header (next to Upload 3MF, mirroring File Manager's placement) that hard-deletes print archives not printed within a configurable window (default 365 days, min 7, max 10 years) with the same live-preview modal as the library purge. Reprinting an archive reuses the row and updates its completed_at, so the purge honours the most recent print completion — a two-year-old archive you reprinted yesterday is not eligible for deletion. Unlike the library trash, archives are hard-deleted: print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checked ArchiveService.delete_archive path so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Gated by a new dedicated archives:purge permission (Administrators group by default, backfilled on upgrade); 9 new backend integration tests; localised across all 8 UI languages.
  • MakerWorld Integration — Paste any makerworld.com/models/… URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click Save or Save & Slice in Bambu Studio / OrcaSlicer per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. LibraryFile now tracks source_type + source_url, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links).
    Endpoint route (non-obvious, ~1 day of reverse engineering)Pr0zak/YASTL#51 documented that makerworld.com-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at api.bambulab.com. The working path turned out to be GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId} with Authorization: Bearer <cloud_token> — a different service (iot-service, not design-service) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…). The modelId query param is the alphanumeric identifier (e.g. US2bb73b106683e5) that only appears in the design response body, not the integer designId from the /models/{N} URL — so the import flow fetches design metadata first, reads modelId, then calls iot-service. S3 presigned URLs must be fetched with urllib.request (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with SignatureDoesNotMatch 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path.
    UI and UX features — per-plate picker with inline Save / Save & Slice in Bambu Studio / OrcaSlicer buttons, Import all to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (View in File Manager / Open in Bambu Studio / Open in OrcaSlicer / Remove from library), per-plate delete via the standard Bambuddy confirm modal (no browser confirm()), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (/library/files/{id}/dl/{token}/{filename}) so the handoff works even with auth enabled. Localised across all eight UI languages.
    Security hardening — the MakerWorld description HTML is user-authored and goes through DOMPurify.sanitize() before dangerouslySetInnerHTML. <img> tags inside summaries are rewritten to route through Bambuddy's /makerworld/thumbnail proxy so the SPA's img-src 'self' data: blob: CSP stays unwidened. Thumbnail proxy now uses follow_redirects=False (the host-allowlist guarantee is only meaningful on the initial URL — a 302 to 169.254.169.254 would otherwise bypass it). The 3MF CDN fetch sends only User-Agent — the Bambu Cloud bearer is never forwarded to the CDN. S3 presigned-URL fetch uses a urllib.request opener with a no-op HTTPRedirectHandler for the same reason. Filenames from MakerWorld responses are os.path.basename'd before persisting, so a malicious name: "../../evil.3mf" cannot surface a path-traversal string into the DB / UI (on-disk storage uses a UUID filename regardless). New routes respect the MAKERWORLD_VIEW (resolve / recent-imports / status) and MAKERWORLD_IMPORT (import) permissions. SSRF guard on downloads rejects any host that isn't makerworld.bblmw.com, public-cdn.bblmw.com, or a .amazonaws.com subdomain.
    Test coverage — 46 unit tests for services/makerworld.py (header shape, API base, get_design/get_design_instances/get_profile, get_profile_download 200/401/403/404/no-token, download_3mf SSRF rejection of 4 hostile hosts, S3 path delegation, CDN path with minimal headers, size-cap, _download_s3_urllib happy/redirect/size/network paths, fetch_thumbnail with follow_redirects=False); 19 route tests (/resolve, /import with folder autocreation + explicit folder + dedupe + filename basename + profile_id response, /recent-imports with empty-list / ordering / pydantic shape / limit clamping, _canonical_url unit); 12 frontend tests (button labels, slicer-name interpolation, URL-change detection, inline post-import actions, Recent imports rendering, DOMPurify <script> strip).

Changed

  • Settings page: permission-gated instead of admin-only — the Settings sidebar entry has always been visible to any user holding settings:read, but the route guard required admin role, so a non-admin with settings:read would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with settings:read can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (users:read, groups:update, oidc:*, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (groups:create for /groups/new, groups:update for /groups/:id/edit), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.

Fixed

  • Spoolman iframe silently blank on HTTPS Bambuddy with HTTP Spoolman (#1096) — Users behind an HTTPS reverse proxy (Traefik / Nginx / Caddy) pointing the Spoolman URL at plain HTTP saw the Filament tab render as a blank page with only a console-side Mixed Content warning. CSP was fine (the #1054 fix already allowed frame-src http:), but browsers enforce mixed-content blocking independently of CSP — an HTTP iframe inside an HTTPS parent is always blocked. Bambuddy can't technically fix this (the browser is correct to refuse), so instead of the silent blank frame the Filament page now detects the protocol mismatch (window.location.protocol === 'https:' plus Spoolman URL starting with http://) and renders an inline warning card explaining the root cause, pointing users at the right fix (put Spoolman behind the same HTTPS reverse proxy and update the Spoolman URL in Settings), and offering an "Open Spoolman in a new tab" button as an immediate workaround — a standalone tab isn't subject to mixed-content rules. Localised across all 8 UI languages. Thanks to jsapede for the report.
  • Reprint-from-Archive left created_by_id as NULL (#730 follow-up) — 0.2.4b1 fixed user attribution for Direct Print / File Manager / Library prints, but the reprint path was still unattributed on the archive row. Reprint intentionally reuses the source archive (to avoid duplicate rows — see register_expected_print), so an archive auto-created from a printer-initiated print with no known user stayed created_by_id=NULL forever, even after multiple reprints by authenticated Bambuddy users. Print Log got the reprinter's username correctly (via _print_user_info), but the Statistics per-user filter — which reads archive.created_by_id — kept showing the archive as unassigned. Fix in main.py's print-complete handler: when the archive has no created_by_id and a print-session user is set (which reprint always sets via set_current_print_user), back-fill the archive's attribution. Never overwrites an existing attribution — the original uploader keeps ownership; NULL archives are the only ones touched. Thanks to 3823u44238 for the detailed retest that caught this.
  • Settings: failed-save toast looped forever when the user lacked settings:update — the Settings page runs a debounced auto-save effect that fires PATCH /settings whenever localSettings diverges from the last server snapshot. When a delegated user with settings:read but not settings:update toggled a control, the effect fired PATCH, got 403, and kept re-firing every ~500 ms producing an endless stream of identical "Failed to save" toasts. Gated at three points so the mutation is never attempted without permission: (1) the updateSetting callback — every onChange path — shows one settings.toast.noPermissionUpdate toast and short-circuits before diverging localSettings; (2) the debounced-save effect safety-nets the same check in case any call site bypassed updateSetting; (3) the language <select> was a fire-and-forget direct api.updateSettings call that always flashed a success toast regardless of outcome — it now goes through updateMutation with the same permission guard. New settings.toast.noPermissionUpdate key added across all 8 locales with full translations (not English-fallback).
  • Groups: edits to custom-group permissions appeared lost on reopen (#1083) — creating a custom group and reopening the editor showed the correct permissions, but after editing that group's permissions and saving, reopening the editor within ~1 minute displayed the pre-edit snapshot as if the save had failed. The backend PATCH /api/v1/groups/{id} was persisting correctly (now covered by four new integration tests in test_groups_api.py, including a direct DB read after update); the issue was purely in the frontend React Query cache — GroupEditPage.onSuccess invalidated ['groups'] (the list) but left the ['group', id] detail cache stale, and with the app-wide 60 s staleTime the next mount served the cached pre-update body instead of refetching. onSuccess now primes the ['group', id] detail cache with the PATCH response body so the next mount hits fresh data immediately without a round-trip. Create-path invalidates ['group'] for symmetry. Regression test in GroupEditPage.test.tsx verifies the detail cache contains the updated permissions after save.
  • Setup: re-enabling auth could 422 on a password the form no longer needs — after disabling authentication and re-enabling it (common when switching between local auth and LDAP, or recovering from a bad config), the setup form still sends admin_password in the body even though the backend route ignores it when an admin user already exists. The SetupRequest Pydantic schema enforced password complexity (uppercase + lowercase + digit + special char) unconditionally, so any existing password that predated the complexity rule — or a legitimate LDAP-mode placeholder — triggered 422 Value error, Password must contain at least one special character before the route body could decide to ignore the field. Complexity validation has moved out of the schema and into the route body, scoped to the branch that actually creates a new local admin. Re-enabling auth with an existing admin (or any LDAP user) now accepts whatever the form sends; fresh first-time setup still rejects weak passwords with a clear 400. Two regression tests added in test_auth_api.py: weak password rejected at setup when creating the first admin, weak/placeholder password accepted when an admin already exists.
  • Queue: batch (quantity>1) double-dispatched onto the same printer — scheduling an ASAP print with quantity > 1 could end up with two queue items in 'printing' status for the same printer, surfaced in the logs as BUG: Multiple queue items in 'printing' status for printer N. The scheduler's in-memory busy_printers set was seeded empty each tick and only populated after _start_print succeeded in the current iteration, so on the next tick (30 s later) _is_printer_idle() read the printer's live MQTT state — which on H2D / P1 series lags several seconds behind the print command and still reported IDLE / FINISH — and dispatched the second batch item onto the already-running printer. check_queue() now queries PrintQueueItem for status='printing' rows and seeds busy_printers with their printer IDs before iterating pending items, so any printer with an outstanding dispatched job is excluded regardless of what MQTT currently reports. Regression covered in test_phantom_print_hardening.py (TestBusyPrinterSeedingFromPrintingItems): seeding query returns printers with 'printing' rows only, returns empty when none exist, and end-to-end check_queue() does not call _start_print for a pending item whose printer already has a 'printing' row even when _is_printer_idle() is forced True.
  • Queue: active-item progress bar flashed 100% before dropping to 0% — immediately after a queue item was dispatched, the per-item progress bar on the Queue page showed 100% (or whatever the prior print's final mc_percent was) for the few seconds between dispatch and the printer's MQTT state transitioning to RUNNING. Frontend QueuePage.tsx read status.progress directly from the printer's live MQTT snapshot, which carries over the last reported value from the previous print until the new one starts ticking. The progress bar, remaining time, ETA, and layer counter are now gated on status.state being RUNNING or PAUSE; in any other state (including FINISH from the prior print, IDLE, or PREPARE while heating) the bar renders at 0% with no stale ETA/layer values.
  • "Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters (#1059) — clicking "Open in Slicer" from the File Manager or Archives page produced one of three symptoms depending on the file: .3mf files opened Bambu Studio / OrcaSlicer but the app showed "Importing to Bambu Studio failed. Please download the file and open it manually" (the file on disk was 0 bytes); .stl files greyed the button out; .step couldn't be previewed at all. The protocol-handler URL emitted by frontend/src/utils/slicer.ts for OrcaSlicer (orcaslicer://open?file=<URL>) and Windows/Linux Bambu Studio (bambustudio://open?file=<URL>) was built by plain string concatenation with no encodeURIComponent() — the macOS bambustudioopen://<URL> branch was already encoding correctly, which is why macOS users didn't see this. A stale comment block in the file claimed the browser preserves the URL in the query string so no encoding is needed; that's true for the browser-to-OS handoff but ignores that the slicer itself calls url_decode() on the received query (BS post_init() calls url_decode then split_str; OrcaSlicer's Downloader regex-extracts then url_decode). Any already-percent-encoded character in the download URL — most commonly %20 from filenames with spaces, which Bambuddy's archive paths produce naturally — decoded to a literal space and the slicer's subsequent HTTP GET came back 0 bytes or 404. All three URL forms now encodeURIComponent() the file URL, so the slicer sees the correctly-encoded URL after its own url_decode. The comment block is corrected to document the actual invariant. Regression test in slicer.test.ts feeds the exact issue reproduction URL (Toothpick%20Launcher%20Print-in-Place.3mf) and asserts %2520 appears in the generated orcaslicer:// href — so any future refactor that drops the encoding fails CI. Thanks to jsapede for the double-encoding diagnosis and AllanonBrooks and lunaticds for the original reports.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.