diff --git a/CHANGELOG.md b/CHANGELOG.md index bf79cd7c5..713742286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ All notable changes to Bambuddy will be documented in this file. - **Dependency Updates for Published Advisories** — Bumped two dependencies flagged by vulnerability scanners. `python-multipart` 0.0.22 → 0.0.26 closes CVE-2026-40347 (GHSA-mj87-hwqh-73pj), a denial-of-service triggered by large preamble or epilogue data around a multipart boundary — the 0.0.26 release now skips the preamble before the first boundary and silently discards the epilogue after the closing one. Bambuddy uses `python-multipart` transitively through FastAPI/Starlette for form and file-upload parsing, so any authenticated endpoint accepting `multipart/form-data` (e.g. backup restore, project thumbnail upload) was exposed. `dompurify` 3.3.3 → 3.4.0 picks up the fix for GHSA-39q2-94rc-95cp (the function-form `ADD_TAGS` could bypass `FORBID_TAGS`); Bambuddy's two call sites (`ProjectDetailPage`, `ProjectPageModal`) only use array-form `ALLOWED_TAGS`/`ALLOWED_ATTR`, so the specific bypass was not reachable, but the bump still hardens the sanitizer against future misconfiguration and clears the audit warning. ### Fixed -- **SpoolBuddy Kiosk Shows Login Page in Full-Mode Install** — Installing SpoolBuddy via the `spoolbuddy/install/install.sh` script in `full` mode (both Bambuddy and SpoolBuddy on the same host) left the kiosk launching at `http://localhost:/spoolbuddy?token=CHANGE_ME_AFTER_SETUP` — a placeholder the script wrote into `spoolbuddy/.env` because the install runs before Bambuddy has an admin to create a real API key. The frontend's `AuthContext` validated that placeholder against the server, got rejected, and redirected to the login page; disabling authentication let the kiosk through (only symptom users noticed), so the bug only surfaced with auth enabled. Standalone mode was unaffected because the user supplies a valid key from an existing Bambuddy server before install proceeds. Added `backend/app/cli.py` with a `kiosk-bootstrap` subcommand that creates an API key row directly in the DB (scoped read-only: `can_read_status=True`, `can_queue=False`, `can_control_printer=False`) and prints it to stdout. `install.sh` full-mode now runs the CLI as the bambuddy service user right after `create_bambuddy_service`, captures the key, and `sed`-replaces the placeholder in `spoolbuddy/.env`. On first reboot the kiosk picks up a real token and logs in automatically — no manual "create an API key and edit the env" step is required anymore. The CLI is idempotent with `--force` to rotate on re-install. +- **SpoolBuddy Kiosk Unusable After Full-Mode Install** — A bundled Bambuddy + SpoolBuddy install via `spoolbuddy/install/install.sh --mode full` produced an unusable kiosk on first boot: Chromium raced ahead of uvicorn and showed "can't connect to localhost"; after a manual reload the kiosk URL `/spoolbuddy?token=…` was hijacked by Bambuddy's first-run wizard (`AuthContext` force-redirects to `/setup` whenever `requires_setup=true`, regardless of the target path); the wizard asks for admin credentials, but a touch-only Pi has no on-screen keyboard; if the user skipped auth the browser landed at `/` instead of the kiosk, and if they tried to enable auth they were stranded. Standalone mode was unaffected because it runs against an already-configured remote Bambuddy. Fixed in three parts: (a) new `backend/app/cli.py` with a `kiosk-bootstrap` subcommand that in a single DB transaction creates a scoped API key (`can_read_status=True`, `can_queue=False`, `can_control_printer=False`) and upserts `setup_completed=true`, so the first-run wizard never triggers and the kiosk URL loads the SpoolBuddy page directly; users can still enable authentication later from the admin UI and the pre-provisioned key keeps working. (b) `install.sh` full-mode now runs the CLI as the bambuddy service user immediately after `create_bambuddy_service` and `sed`-replaces the `CHANGE_ME_AFTER_SETUP` placeholder in `spoolbuddy/.env`. (c) The generated `spoolbuddy-kiosk-launch` now polls `${backend_url}/health` with a 60 s timeout before exec'ing Chromium, so cold boots wait for uvicorn instead of flashing the connection-refused error. The CLI is idempotent with `--force` for re-installs. - **Bambu Lab X2D Support** ([#988](https://github.com/maziggy/bambuddy/issues/988)) — Added X2D to the Add Printer and Edit Printer model dropdowns (both were missing the new model, so manual printer setup had no X2D option — auto-discovery via SSDP was unaffected). The newly released X2D (dual-nozzle, enclosed, hardened steel rod gantry, AMS 2 Pro compatible) identifies itself as internal model code `N6` via SSDP/MQTT, and serials begin with `20P9`. Because neither the code nor the prefix existed in any of Bambuddy's model tables, multiple paths silently fell back to wrong defaults: the camera service routed to the chamber-image protocol on port 6000 (which the X2D doesn't speak) instead of RTSP on port 322 — the reporter saw `Chamber image: data is not a valid JPEG` spam and no stream; the K-profile edit/delete path conditioned its in-place `cali_idx` write on the H2D serial prefix `094` and would therefore have treated X2D as a single-nozzle printer even though its dual-extruder layout matches H2D; the firmware-update check logged `Unknown printer model: N6`; and the virtual-printer model registry had no way to emulate X2D. Added the `N6 → X2D` mapping across every registry (`PRINTER_MODEL_ID_MAP`, `PRINTER_MODEL_MAP`, `ETHERNET_MODELS`, `STEEL_ROD_MODELS`, `CHAMBER_TEMP_SUPPORTED_MODELS`, firmware-check API keys and wiki path, virtual-printer SSDP product names and serial prefix, DB migration `vp_model_fixes`), extended `supports_rtsp()` to match `X2` display names and the `N6` internal code (camera now goes to port 322), expanded the dual-nozzle serial prefix check in `kprofiles.py` and the K-profile delete command in `bambu_mqtt.py` to also accept `20P9` so the H2D-style `cali_idx` in-place edit path runs on X2D, added X2D to the `is_h2d` model-family gate that selects the integer-format `timelapse`/`bed_leveling`/`flow_cali`/`vibration_cali`/`layer_inspect` fields in the MQTT print command, and added X2D to the frontend's door-badge and airduct-mode whitelists, `mapModelCode` lookups on both the Printers page and Spoolbuddy AMS page, and the MaintenancePage wiki-URL resolver (X2D inherits P2S's steel-rod lubrication, belt-tension, nozzle cold-pull and PTFE wiki pages, since its hardware is closer to P2S than to H2). Credit to @krautech for the report and the debug bundle, and to @legend813 for the initial PR (#989) that seeded most of the registry changes — the classification was corrected (X2D uses hardened steel rods like P2S, not carbon rods) and the dual-nozzle/K-profile gaps were added on top. - **Print Speed Icon Not Updating Live When Changed on Printer** ([#993](https://github.com/maziggy/bambuddy/issues/993)) — Changing the print speed mode from the printer's own panel (instead of from Bambuddy) did not update the speed icon on the Printers page card; the new value only appeared after a full page reload. The MQTT parser was already tracking `spd_lvl` and updating `state.speed_level` correctly, but the WebSocket serializer (`printer_state_to_dict`) was missing the field — so live status pushes never carried `speed_level`, and the frontend's merge-over-old-cache update left the icon stuck on its previous value. The REST `/status` endpoint used on initial page load already included it, which is why reloads worked. Added `speed_level` to the WebSocket payload. Thanks to @chesterakl for reporting. - **Camera Popup Shows "Valid camera stream token required" With Auth Enabled** ([#979](https://github.com/maziggy/bambuddy/issues/979)) — When Camera View Mode was set to "Window" and authentication was enabled, clicking the camera button opened a popup that immediately failed with `"Valid camera stream token required"`, while the embedded overlay kept working. Two root causes: (1) `window.open(...)` passed `noopener` in the popup features, which severed the opener link and prevented the browser from copying sessionStorage (where the auth token lives) into the popup — so the new window booted unauthenticated and the `POST /printers/camera/stream-token` fetch returned 401, leaving the `` src without the required `?token=` query param; (2) even once the token arrived, `CameraPage` computed its URL from the module-level stream-token cache on render and never re-rendered when the cache was updated in a `useEffect`, so the first paint locked in a tokenless URL that the backend kept rejecting. Fixed by dropping `noopener` from the camera popup features (same-origin, trusted window) so sessionStorage is inherited, subscribing `CameraPage` to the `camera-stream-token` React Query so it re-renders the moment the token resolves, and appending the token directly from the reactive query value instead of the effect-synced module cache — the `` src stays empty until the token is ready, so no tokenless request ever leaves the popup. Embedded-overlay mode was unaffected. Thanks to @VREmma for the reproducer. diff --git a/backend/app/cli.py b/backend/app/cli.py index 669b74f4a..25c35cc76 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -17,7 +17,9 @@ from sqlalchemy.ext.asyncio import async_sessionmaker from backend.app.core.auth import generate_api_key from backend.app.core.database import async_session as default_session_maker, init_db +from backend.app.core.db_dialect import upsert_setting from backend.app.models.api_key import APIKey +from backend.app.models.settings import Settings DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk" @@ -68,6 +70,15 @@ async def kiosk_bootstrap( expires_at=None, ) db.add(row) + + # Mark first-run setup as completed so the kiosk URL loads directly + # instead of being force-redirected to /setup by AuthContext. Without + # this, a bundled SpoolBuddy/Bambuddy install boots into the Bambuddy + # first-run wizard (touch-only Pi has no keyboard to complete it). + # Users who want authentication enable it later from the admin UI; the + # API key we just created is already valid so the kiosk keeps working. + await upsert_setting(db, Settings, "setup_completed", "true") + await db.commit() return full_key diff --git a/backend/tests/unit/test_cli.py b/backend/tests/unit/test_cli.py index 90030c57d..3d13df852 100644 --- a/backend/tests/unit/test_cli.py +++ b/backend/tests/unit/test_cli.py @@ -13,6 +13,7 @@ from backend.app.cli import DEFAULT_KIOSK_KEY_NAME, KioskBootstrapError, kiosk_b from backend.app.core.auth import _validate_api_key from backend.app.core.database import Base from backend.app.models.api_key import APIKey +from backend.app.models.settings import Settings @pytest_asyncio.fixture @@ -113,6 +114,45 @@ async def test_bootstrap_force_rotates_existing_key(session_maker): assert validated.name == DEFAULT_KIOSK_KEY_NAME +@pytest.mark.asyncio +@pytest.mark.unit +async def test_bootstrap_marks_setup_completed(session_maker): + """Bootstrap must set setup_completed=true so AuthContext doesn't redirect the kiosk to /setup.""" + await kiosk_bootstrap( + DEFAULT_KIOSK_KEY_NAME, + force=False, + session_maker=session_maker, + ensure_schema=False, + ) + + async with session_maker() as db: + setting = (await db.execute(select(Settings).where(Settings.key == "setup_completed"))).scalar_one() + assert setting.value == "true" + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_bootstrap_setup_idempotent_on_rotate(session_maker): + """Re-running with --force must not duplicate the setup_completed row.""" + await kiosk_bootstrap( + DEFAULT_KIOSK_KEY_NAME, + force=False, + session_maker=session_maker, + ensure_schema=False, + ) + await kiosk_bootstrap( + DEFAULT_KIOSK_KEY_NAME, + force=True, + session_maker=session_maker, + ensure_schema=False, + ) + + async with session_maker() as db: + rows = (await db.execute(select(Settings).where(Settings.key == "setup_completed"))).scalars().all() + assert len(rows) == 1 + assert rows[0].value == "true" + + @pytest.mark.asyncio @pytest.mark.unit async def test_bootstrap_custom_name(session_maker): diff --git a/spoolbuddy/install/install.sh b/spoolbuddy/install/install.sh index 2c795715a..c0e5d30d3 100755 --- a/spoolbuddy/install/install.sh +++ b/spoolbuddy/install/install.sh @@ -984,7 +984,7 @@ setup_kiosk() { dpkg-divert --local --rename --add /usr/sbin/update-initramfs >/dev/null 2>&1 || true ln -sf /bin/true /usr/sbin/update-initramfs fi - run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr swayidle wlopm jq + run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr swayidle wlopm jq curl # Restore real update-initramfs if dpkg-divert --list /usr/sbin/update-initramfs 2>/dev/null | grep -q local; then rm -f /usr/sbin/update-initramfs @@ -1198,6 +1198,18 @@ else kiosk_url="\$FALLBACK_URL" fi +# Wait for the Bambuddy backend to be reachable before launching Chromium. +# Without this the browser opens before uvicorn has bound to the port on a +# cold boot and the user sees an ERR_CONNECTION_REFUSED splash until they +# manually reload. Probe /health (no auth, no body) with a short timeout. +probe_url="\${backend_url:-http://localhost}/health" +for _i in \$(seq 1 60); do + if curl -sf --max-time 2 "\$probe_url" >/dev/null 2>&1; then + break + fi + sleep 1 +done + exec chromium --kiosk --no-first-run --disable-infobars \ --disable-session-crashed-bubble --disable-features=TranslateUI \ --noerrdialogs --disable-component-update \