On bare-metal Raspberry Pi OS bookworm / armv7l / Python 3.11, 3MF
files larger than a few megabytes arrived complete via the
virtual-printer FTP server but the copy into data/archives/ was
silently truncated. The archive row was still written, the printer
card looked fine, and the problem only surfaced later when opening
the archive — the subsequent zipfile.ZipFile() in
GET /archives/{id}/plates raised BadZipFile and the UI came up blank
with no thumbnail, plate list, or filament data.
Two things conspired:
1. archive_print() used shutil.copy2, which takes Python's sendfile()
fast path on Linux. On the reporter's kernel/fs combination
sendfile returned a short count on the first call for the upload
sizes hit in practice and the destination ended up truncated.
Small files completed in one syscall and were fine.
2. ThreeMFParser.parse() caught the resulting BadZipFile in a bare
`except Exception: pass`, so the archive pipeline kept going with
empty metadata and left the bad file on disk — nothing in the
logs hinted anything had gone wrong until a support bundle came
in and the "Failed to parse plates" warning fired much later.
The archive copy is now an explicit chunked read/write with fsync —
sendfile is not in the path. After the copy, if the source was a
valid ZIP but the destination isn't, we refuse to create the archive
row, remove only the truncated file (and the archive directory if
empty — archive_dir is created with exist_ok=True so rmtree would be
unsafe if a same-second same-filename collision happened), and log
both sizes at ERROR so the condition is obvious in future support
bundles. The parser's silent catch now logs at WARNING for the same
reason.
All nine archive_print() call sites already check `if archive:` or
`if not archive:`, so returning None for corrupted ZIPs propagates
cleanly without behaviour changes elsewhere.
Regression tests cover single-chunk and multi-chunk copies, mtime
preservation via copystat, overwrite of an existing destination, a
ZIP roundtrip through a multi-megabyte 3MF, the new parser WARNING,
and a truncation sentinel verifying that zipfile.is_zipfile() flips
to False on a half-written ZIP — the exact post-condition
archive_print now trusts.
On auth-enabled instances, logging out and back in left the File Manager
(and occasionally the Archives page) full of broken thumbnails until a
manual page reload. Thumbnail URLs are gated by a short-lived camera
stream token that <img> tags cannot send via Authorization headers, so
the token is appended as ?token=… at render time.
Two races broke this after sign-in:
1. The token query was keyed on ['camera-stream-token'] alone and fired
while the user was still on the login page. It 401'd, React Query
cached the failure with a 50-minute staleTime, and nothing invalidated
it after login — the token never arrived.
2. Even when the token did arrive, the module-level variable holding it
was not reactive, so pages that had already rendered kept serving
image URLs with no token in them.
Fixes:
- Include user.id in the query key and gate with
`enabled: authEnabled ? !!user : true`. A new sign-in produces a new
key and triggers a fresh fetch; no anonymous fetch is cached.
- When the token transitions from null to a value, walk the DOM once
and update src on every <img>/<video> pointing at /api/v1/ without
the current token so already-rendered pages reload in place.
- Mirror the query key/gate in CameraPage so it shares the cache entry.
The DOM-rewrite logic is extracted into rewriteMediaSrcWithToken() with
unit tests covering: appending to a query-less URL, & separator with an
existing query, skipping URLs that already carry the current token,
replacing a stale token (trailing and middle positions), leaving
non-/api/v1/ URLs alone, updating <video>, and URL-encoding tokens with
special characters.
The wiki scraper silently returned no versions for P2S and X2D, causing
Bambuddy to fall back to the Bambu Lab download page, which still listed
01.01.01.00 as "latest" even though 01.02.00.00 shipped on 2026-04-09.
Two regex mismatches in _fetch_all_versions_from_wiki():
1. Heading anchor ids require an optional dash between version bytes and
date. H2D/X1/H2C/H2S use "h-01020000-20260409"; P2S and X2D publish
"h-0102000020260409" (no dash).
2. The text fallback only matched ASCII parens around release dates, but
P2S, X2D, A1 and A1-mini render dates in full-width parens (YYYYMMDD)
(U+FF08/U+FF09).
Anchor regex now accepts an optional dash; fallback accepts both paren
styles. Added regression tests for both shapes.
Both fields have existed on the model and been shown in the File
Manager for some time, but nothing ever wrote to them — every file in
every library appeared to have never been printed.
Now on_print_complete's queue-status update path calls a small
_bump_library_file_usage_if_completed() helper that increments
print_count and stamps last_printed_at on the source library file
whenever a queued print completes successfully. Failed, cancelled and
user-aborted prints are intentionally skipped so the fields represent
successful usage rather than attempt count.
Unblocks sorting the File Manager by last-printed date and is a
prerequisite for the scheduled-purge feature requested in #1008,
which is held until we see whether manual sort+bulk-delete covers the
use case.
Add CVE-2026-6385, CVE-2026-30997 and CVE-2026-6192 to .trivyignore.
All three are marked "vulnerable / postponed" in both bookworm and
trixie by the Debian Security Tracker with no upstream fix yet, so
the Trivy container scan will keep re-raising them on every run.
None of the vulnerable code paths are reachable in Bambuddy:
* CVE-2026-6385 (ffmpeg DVD subtitle heap OOB write) — ffmpeg here
only ingests printer-camera RTSP and MJPEG/H.264/H.265 streams,
never DVD/VOB files with subtitle tracks.
* CVE-2026-30997 (ffmpeg AV1 decoder OOB read → DoS) — Bambu
printer cameras emit H.264/H.265/MJPEG, not AV1.
* CVE-2026-6192 (openjpeg JPEG 2000 integer overflow) —
libopenjp2-7 is pulled in transitively by ffmpeg but Bambuddy
never decodes JPEG 2000 files.
Not caused by the recent bookworm → trixie runtime image switch;
both releases carry the same "postponed" status. Rationale captured
inline next to each CVE for future auditors.
Add a "Collapse" toggle in the File Manager sidebar header next to
"Wrap". When enabled, the folder tree opens with only top-level
folders visible on every page load; disabled restores the previous
fully-expanded default. Toggling the preference also immediately
re-collapses or re-expands the current tree via a key-remount trick
on each top-level FolderTreeItem, so the change takes effect without
a page reload. Preference persists to localStorage under
library-collapse-folders, matching the existing library-* convention.
Backwards-compatible: FolderTreeItem gains an optional
defaultExpanded prop defaulting to true, so no callers see a
behavior change. Missing localStorage key coerces to false, so
existing users keep the old expanded-by-default behavior until they
flip the toggle.
New strings added to all 8 locales under fileManager.*. Wiki
"File Manager" page gains a "Folder sidebar preferences" section
that documents both Wrap and Collapse toggles. Four vitest cases
cover default, preloaded-collapsed, click-to-collapse, and
click-to-expand paths.
Picks up ffmpeg 5 → 7 (HEVC/AV1 improvements), OpenSSL 3.0 → 3.3, and
two more years of APT package freshness. Frontend-builder stays on
Bookworm until the Node.js image team publishes Trixie variants.
- Bandit B108: mark 3 dummy /tmp paths in test fixtures as nosec
- CodeQL py/ldap-injection: already RFC 4515 escaped via _ldap_escape()
- CodeQL py/incomplete-url-substring-sanitization: test-only assertions
- GitGuardian: replace sample passwords with <placeholder> strings in
notification-template preview data
Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
report informationally until their drift is caught up. ja notably has 27 real
placeholder bugs worth fixing before promotion to strict.
Facebook and some other OAuth providers issue authorization codes that
exceed 512 characters. Pydantic rejected these with 422 string_too_long.
The OAuth spec defines no maximum code length; 2048 aligns with common
provider limits.
Also adds three integration tests to verify 512-char and 2048-char codes
are accepted while 2049-char codes are correctly rejected.
OrcaSlicer's Linux BBLNetworkPlugin publishes MQTT payloads with the
C-string null terminator included in the length, so decoded messages
arrived as `{…}\x00`. The strict json.loads() raised JSONDecodeError
and the publish handler silently returned — pushall, get_version, and
project_file were never answered, and the slicer hit its 60 s sync
timeout. Print_queue mode only (proxy mode tunnels MQTT). The b069b521
serial-adaptation fix was correct but ran past this earlier silent
failure.
_handle_publish now strips trailing \x00/whitespace before parsing and
logs the raw payload on any remaining decode failure so future silent
variants are visible in support bundles.
feat(cloud): support China region for token-based login
The /cloud/token endpoint always used the global Bambu API endpoint,
so users with China-region access tokens could not validate their
token. The password login flow already exposes a region selector; this
brings the token flow to parity.
Archive reprints and library-file prints built the MQTT project_file
command with hardcoded project_id="0", subtask_id="0", task_id="0".
Printers key per-job state (including gcode_start_time) on those IDs,
so reprints looked like continuations of the same job and third-party
MQTT observers (OctoEverywhere) reported compounding durations across
repeat replays — a 40 min job reprinted from archive showed ~1h40m,
and a second reprint of the same file showed ~4h. BambuStudio mints
fresh IDs per submission; bambu_mqtt.start_print() now does the same
using an epoch-millisecond timestamp for all three fields. md5 is
deliberately left empty to avoid activating firmware md5-validation
against a digest we can't compute without re-reading the upload.
Added 6 regression tests in TestStartPrintUniqueIdentityFields
covering non-zero IDs, md5 stays empty, uniqueness across successive
submissions, numeric-string format, and blast-radius guard on
unrelated payload fields. Updated CHANGELOG.
The 0.2.3b4 #1003 "fix" POSTed JPEG bytes as multipart form data,
but Obico's /p/ endpoint is declared methods=['GET'] upstream and
reads ?img=URL from the query string. Every POST was 405'd by
Flask's router before any handler ran, which is why the Obico
container logs were silent while Bambuddy kept reporting
"ML API call failed for printer N:" with a blank suffix —
raise_for_status() on the 405 produced an exception whose str()
rendered empty.
Restored the pre-#1003 nonce-URL approach (commit 3e434458):
capture locally with a 20s timeout we control, stash the JPEG
under a single-use 32-byte nonce, hand Obico a
GET /api/v1/obico/cached-frame/{nonce} URL that resolves in
<50ms so its hardcoded 5s read timeout never races RTSP.
Also guards against future silent exceptions: the error format
now falls back to type(exc).__name__ when str(exc) is empty.
Detection also early-returns with an explicit error if
external_url is unset instead of handing Obico a URL it can't
resolve.
The #1003 reverse-proxy scenario (Authelia/Authentik/CF Access
in front of Bambuddy) is addressed by documenting that the
/api/v1/obico/cached-frame/ path must be whitelisted from
external auth at the proxy layer — it is already public on
Bambuddy's side.
Backend: services/obico_detection.py, api/routes/obico.py,
main.py (PUBLIC_API_PATTERNS).
Frontend: FailureDetectionSettings banner + client.ts type +
all 7 locales restored.
Tests: 15 unit + 5 integration tests pass.
Full-mode install booted into an unusable kiosk:
- Chromium opened before uvicorn → "can't connect to localhost"
- After reload, requires_setup=true hijacked /spoolbuddy → /setup
- Touch-only Pi has no keyboard to complete the setup wizard
- Declining auth left the user at / instead of the kiosk
Fixes, bundled:
1. backend/app/cli.py kiosk-bootstrap now, in one DB transaction:
- creates a scoped API key (can_read_status=True, rest false)
- upserts setup_completed=true
so AuthContext never redirects and the kiosk URL loads directly. Users
who want auth can still enable it from the admin UI; the provisioned
key keeps working.
2. install.sh full-mode runs the CLI as the bambuddy service user after
create_bambuddy_service and sed-replaces the CHANGE_ME_AFTER_SETUP
placeholder in spoolbuddy/.env.
3. The generated spoolbuddy-kiosk-launch polls ${backend_url}/health for
up to 60s before exec'ing chromium, so cold boots wait for uvicorn
instead of flashing ERR_CONNECTION_REFUSED.
Standalone mode was unaffected — users supply a real key from their
existing Bambuddy before install.
Full-mode install wrote CHANGE_ME_AFTER_SETUP as SPOOLBUDDY_API_KEY because
no admin exists yet to create a real one. On reboot the kiosk launched with
that placeholder, AuthContext rejected it, and the user hit the Bambuddy
login page instead of the kiosk. Standalone mode was unaffected — users
paste a real key from their existing Bambuddy before install.
Adds backend/app/cli.py with a kiosk-bootstrap subcommand that creates a
scoped APIKey row directly in the DB (can_read_status=True, everything else
false) and prints the full key to stdout. install.sh full-mode runs it as
the bambuddy service user after create_bambuddy_service, captures the key,
and sed-replaces the placeholder in spoolbuddy/.env. Idempotent with
--force for re-installs.
Drops the outdated "create an API key and edit .env" next-step block since
the kiosk is now provisioned automatically.
After hours idle the MQTT connection can degrade so telemetry still
flows but published commands never reach the printer. The existing
dev-mode probe only ran on first connect; this adds tracking for
user-initiated ams_filament_setting commands — two consecutive
unanswered commands (10 s timeout each) trigger force_reconnect.
The ML API previously called back into Bambuddy to fetch snapshots,
which failed behind reverse proxies with external auth (Authelia, etc.).
Now the detection loop captures the JPEG locally and POSTs it directly
as multipart form data — no callback URL, no nonce cache, no
external_url dependency.
Both the Add Printer and Edit Printer modals had hardcoded model lists
missing the X2D — manual printer setup had no way to select the new
model. Auto-discovery via SSDP and virtual printer model selection
(dynamic from backend) were unaffected.
The periodic camera cleanup task scans /proc for ffmpeg processes and
kills any not in the active-streams registry. The Obico detection
service's capture_camera_frame_bytes() spawns short-lived ffmpeg for
snapshots but never registered the PID — so cleanup killed it as
"orphaned" mid-capture (SIGKILL, exit -9), producing false errors and
missed detection frames.
Track capture PIDs in _active_capture_pids and exclude them from the
cleanup kill list.
Obico's ML API has a hardcoded 5s read timeout on the URL it fetches, which
our /camera/snapshot regularly exceeds on cold calls (TLS proxy + ffmpeg +
RTSP keyframe wait). The detection loop now captures the JPEG locally with
a 20s timeout we control, stashes the bytes under a single-use 32-byte
nonce, and hands Obico a new /api/v1/obico/cached-frame/{nonce} URL that
returns the cached bytes instantly. The 5s ceiling is no longer a factor.
The nonce is the credential (URL-safe, 256 bits of entropy, single-use,
30s TTL) so the endpoint can be unauthenticated without widening the
camera access surface. Replaces the previous camera-stream-token snapshot
URL approach, which remained vulnerable to the upstream 5s timeout even
when auth was disabled.
Thanks to @fblix for the detailed reproducer with timeout numbers.
The Bambu Lab X2D (launched April 2026, dual-nozzle, enclosed, hardened
steel rod gantry, AMS 2 Pro compatible) identifies itself as internal
model code N6 via SSDP/MQTT, and real serials begin with 20P9. None of
these identifiers existed in Bambuddy's registries, so the camera
service fell back to the chamber-image protocol on port 6000 (X2D
doesn't speak it), firmware-check logged "Unknown printer model: N6",
and the dual-nozzle K-profile paths — gated on the H2D serial prefix
"094" — would have treated X2D as single-nozzle.
Backend:
- Register N6 → X2D across every registry (PRINTER_MODEL_ID_MAP,
PRINTER_MODEL_MAP, STEEL_ROD_MODELS, ETHERNET_MODELS,
CHAMBER_TEMP_SUPPORTED_MODELS, firmware-check API keys + wiki path,
virtual-printer SSDP/product/serial tables, DB vp_model_fixes).
- supports_rtsp(): match the X2 display-name prefix and the N6 internal
code; camera now routes to RTSP on port 322.
- Dual-nozzle serial prefix check in bambu_mqtt.delete_kprofile and
kprofiles.set_kprofile broadened to ("094", "20P9") — X2D now takes
the H2D-style cali_idx in-place edit path.
- is_h2d model gate in bambu_mqtt.start_print extended with "X2D" so
timelapse / bed_leveling / flow_cali / vibration_cali / layer_inspect
are sent as integers and external-spool ams_id 254/255 routing is
preserved (H2D-style deputy-nozzle addressing).
X2D uses hardened steel rods like P2S — it is intentionally placed in
STEEL_ROD_MODELS, not CARBON_ROD_MODELS. A regression-guard test pins
the classification.
Frontend:
- mapModelCode in PrintersPage and SpoolBuddyAmsPage handle N6 and X2D.
- Enclosure-door badge and airduct-mode whitelists include X2D.
- MaintenancePage.getMaintenanceWikiUrl routes X2D to P2S wiki URLs for
steel-rod lubrication, belt tension, cold-pull, and PTFE tube
(exported to enable direct unit testing).
Tests:
- test_printer_models.py: TestX2DModel (10 assertions).
- test_bambu_mqtt.py: X2D in start_print ams_mapping and is_h2d gate;
TestDeleteKProfileDualNozzleDetection across H2D, X2D, P2S, X1C.
- MaintenancePageWikiUrls.test.tsx: 15 assertions covering X2D, P2S
regression, X1C/H2D/A1Mini regression, and model-name normalisation.
Docs:
- README: added X2 series to the supported printers table.
- CHANGELOG: new entry under 0.2.3b4 Fixed.
Credit to @krautech for the report and debug bundle, and to @legend813
for PR #989 which seeded most of the registry changes — rod-type
classification was corrected (steel, not carbon) and the dual-nozzle /
K-profile / is_h2d gaps were added on top.
PyJWT compares the iss claim against discovery_issuer with an exact string
match. Authentik (and similar providers) include a trailing slash in the JWT
iss claim while the discovery document issuer may omit it, or vice-versa.
Disable PyJWT built-in issuer validation and compare both sides after
rstrip('/') to make the check slash-agnostic.
Adds a regression test that verifies a login succeeds when the provider is
configured without a trailing slash but the JWT iss claim carries one.
Second wave of #972 — reproducer on a 37.5 MB BambuStudio print to an A1
showed three stacking root causes when Bambuddy restarts mid-print.
1. Archive start_time lost on container restart. The name-based dedup
cancelled any "printing" archive older than 4h and recreated it with
started_at=now(), so a 13h print that saw a restart 10h in ended up
showing ~1.5h duration. Persist MQTT subtask_id on every archive and
match on that first, regardless of age — same id means same print,
resume in place. Also revives Stale-cancelled rows for users
upgrading mid-print.
2. 3MF FTP search tried non-existent paths for ~48 min. Order was
/cache → /model → /data → /data/Metadata → / with 11×30s retries
each; BambuStudio actually pushes to / on A1, so the real path was
tested last. Reorder to / first, and raise a new FileNotOnPrinterError
sentinel from download_to_file on 550 so with_ftp_retry short-circuits
via non_retry_exceptions. 425 / SSL EOF / connection resets still
retry as before.
3. Cover endpoint and archive flow downloaded the same 36 MB twice and
competed for the printer's single FTP socket, producing 425 errors
that fed cause-2's retry storm. Add an in-memory _threemf_path_cache
keyed on (printer_id, normalized filename); whichever flow fetches
first populates it, the other reuses the file read-only. Eviction
runs on on_print_complete and deletes the temp file.
Backend: 14 new tests across test_bambu_ftp.py and a new
test_subtask_archive_resume.py. Existing suite: 2737 pass. ruff clean,
frontend build clean.
An async handler (e.g. LoginPage's catch-handler on a failed login
request) can call showToast AFTER Vitest's afterEach has unmounted the
ToastProvider. The existing unmount effect clears timers it knows about,
but a showToast scheduled POST-unmount lands a fresh 3s setTimeout that
the cleanup never saw. The callback then runs against a torn-down jsdom
— setToasts → React scheduler → accesses window → ReferenceError as an
uncaught exception in test output ("Vitest caught 1 unhandled error").
Add an isMountedRef flipped to false in the unmount cleanup; every
setToasts call (showToast, showPersistentToast, dismissToast, both
dispatch-toast auto-dismiss timers) short-circuits when the provider is
gone. The timer callback re-checks the ref as a belt-and-braces guard
for timers scheduled after cleanup already ran.
In production this only hardens the code; the provider lives at app
root and does not unmount during normal navigation.
Tests: 4 new regression cases in __tests__/contexts/ToastContext.test.tsx
covering post-unmount showToast / showPersistentToast / dismissToast
no-oping, and the auto-dismiss timer firing post-unmount without
throwing. Full suite: 1381 pass, 0 unhandled errors (was 1377 + 1
uncaught exception). TypeScript check clean.
python-multipart 0.0.26 closes CVE-2026-40347 (GHSA-mj87-hwqh-73pj), a
DoS triggered by large preamble/epilogue data around a multipart
boundary. Bambuddy consumes python-multipart transitively through
FastAPI/Starlette for form and file-upload parsing, so multipart routes
(backup restore, project thumbnail upload, etc.) were exposed.
dompurify 3.4.0 picks up the fix for GHSA-39q2-94rc-95cp (function-form
ADD_TAGS could bypass FORBID_TAGS). Bambuddy's two call sites use only
array-form ALLOWED_TAGS/ALLOWED_ATTR, so the specific bypass was not
reachable, but the bump still hardens the sanitizer and clears the
audit warning.
requirements.txt floor raised to python-multipart>=0.0.26;
frontend/package.json caret pinned to ^3.4.0; npm audit and pip audit
both report zero outstanding advisories after the bumps.
The MQTT parser already tracked spd_lvl and updated state.speed_level,
but printer_state_to_dict omitted the field, so live WebSocket pushes
never carried it. The frontend's merge-over-old-cache update kept the
icon stuck on its previous value; only a full page reload (which hits
the REST /status endpoint — that field was included) would refresh it.
Changing the print speed from the printer's own panel now updates the
Bambuddy icon live, without a reload.