diff --git a/CHANGELOG.md b/CHANGELOG.md index 365e2cf65..bf79cd7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +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. - **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 new file mode 100644 index 000000000..669b74f4a --- /dev/null +++ b/backend/app/cli.py @@ -0,0 +1,117 @@ +"""Bambuddy administrative CLI. + +Invoked via ``python -m backend.app.cli ``. + +Currently provides ``kiosk-bootstrap`` for creating the SpoolBuddy kiosk +API key during install (see ``spoolbuddy/install/install.sh``). +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys + +from sqlalchemy import select +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.models.api_key import APIKey + +DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk" + + +class KioskBootstrapError(RuntimeError): + """Raised when an existing kiosk key would be silently overwritten.""" + + +async def kiosk_bootstrap( + name: str, + *, + force: bool, + session_maker: async_sessionmaker | None = None, + ensure_schema: bool = True, +) -> str: + """Create (or rotate) an API key for the SpoolBuddy kiosk and return it. + + The returned value is the one-time full key string; callers are responsible + for writing it somewhere secure — it cannot be retrieved again. + """ + if ensure_schema and session_maker is None: + await init_db() + + maker = session_maker or default_session_maker + + async with maker() as db: + existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none() + + if existing and not force: + raise KioskBootstrapError( + f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate." + ) + + if existing: + await db.delete(existing) + await db.flush() + + full_key, key_hash, key_prefix = generate_api_key() + row = APIKey( + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + can_queue=False, + can_control_printer=False, + can_read_status=True, + printer_ids=None, + enabled=True, + expires_at=None, + ) + db.add(row) + await db.commit() + return full_key + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python -m backend.app.cli", + description="Bambuddy administrative commands", + ) + sub = parser.add_subparsers(dest="command", required=True) + + kiosk = sub.add_parser( + "kiosk-bootstrap", + help="Create an API key for the SpoolBuddy kiosk", + description=( + "Create (or rotate with --force) an API key scoped for the SpoolBuddy " + "kiosk. The full key is printed to stdout — capture it into " + "spoolbuddy/.env as SPOOLBUDDY_API_KEY." + ), + ) + kiosk.add_argument( + "--name", + default=DEFAULT_KIOSK_KEY_NAME, + help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})", + ) + kiosk.add_argument( + "--force", + action="store_true", + help="Rotate an existing key with the same name (deletes the old one)", + ) + + args = parser.parse_args(argv) + + if args.command == "kiosk-bootstrap": + try: + key = asyncio.run(kiosk_bootstrap(args.name, force=args.force)) + except KioskBootstrapError as exc: + print(str(exc), file=sys.stderr) + return 1 + print(key) + return 0 + + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/unit/test_cli.py b/backend/tests/unit/test_cli.py new file mode 100644 index 000000000..90030c57d --- /dev/null +++ b/backend/tests/unit/test_cli.py @@ -0,0 +1,131 @@ +"""Unit tests for the ``backend.app.cli`` kiosk-bootstrap subcommand.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from backend.app.cli import DEFAULT_KIOSK_KEY_NAME, KioskBootstrapError, kiosk_bootstrap +from backend.app.core.auth import _validate_api_key +from backend.app.core.database import Base +from backend.app.models.api_key import APIKey + + +@pytest_asyncio.fixture +async def session_maker() -> AsyncGenerator[async_sessionmaker, None]: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + try: + yield maker + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_bootstrap_creates_key_when_none_exists(session_maker): + key = await kiosk_bootstrap( + DEFAULT_KIOSK_KEY_NAME, + force=False, + session_maker=session_maker, + ensure_schema=False, + ) + + assert key.startswith("bb_") + assert len(key) > 20 + + async with session_maker() as db: + rows = (await db.execute(select(APIKey))).scalars().all() + assert len(rows) == 1 + row = rows[0] + assert row.name == DEFAULT_KIOSK_KEY_NAME + assert row.enabled is True + assert row.can_queue is False + assert row.can_control_printer is False + assert row.can_read_status is True + assert row.printer_ids is None + assert row.expires_at is None + assert row.key_prefix.startswith("bb_") + assert row.key_hash != key # stored value is a hash, not the plaintext + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_bootstrap_refuses_to_overwrite_without_force(session_maker): + first = await kiosk_bootstrap( + DEFAULT_KIOSK_KEY_NAME, + force=False, + session_maker=session_maker, + ensure_schema=False, + ) + + with pytest.raises(KioskBootstrapError) as exc_info: + await kiosk_bootstrap( + DEFAULT_KIOSK_KEY_NAME, + force=False, + session_maker=session_maker, + ensure_schema=False, + ) + + assert "already exists" in str(exc_info.value) + assert "--force" in str(exc_info.value) + + # First key survives unchanged and still validates + async with session_maker() as db: + row = (await db.execute(select(APIKey))).scalar_one() + validated = await _validate_api_key(db, first) + assert validated is not None + assert validated.id == row.id + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_bootstrap_force_rotates_existing_key(session_maker): + first = await kiosk_bootstrap( + DEFAULT_KIOSK_KEY_NAME, + force=False, + session_maker=session_maker, + ensure_schema=False, + ) + second = await kiosk_bootstrap( + DEFAULT_KIOSK_KEY_NAME, + force=True, + session_maker=session_maker, + ensure_schema=False, + ) + + assert first != second + + async with session_maker() as db: + rows = (await db.execute(select(APIKey))).scalars().all() + assert len(rows) == 1 # old row was deleted, not duplicated + + # Old key no longer validates, new key does + assert await _validate_api_key(db, first) is None + validated = await _validate_api_key(db, second) + assert validated is not None + assert validated.name == DEFAULT_KIOSK_KEY_NAME + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_bootstrap_custom_name(session_maker): + key = await kiosk_bootstrap( + "custom-kiosk-name", + force=False, + session_maker=session_maker, + ensure_schema=False, + ) + + async with session_maker() as db: + row = (await db.execute(select(APIKey))).scalar_one() + assert row.name == "custom-kiosk-name" + validated = await _validate_api_key(db, key) + assert validated is not None + assert validated.name == "custom-kiosk-name" diff --git a/spoolbuddy/install/install.sh b/spoolbuddy/install/install.sh index 03faa0bee..2c795715a 100755 --- a/spoolbuddy/install/install.sh +++ b/spoolbuddy/install/install.sh @@ -783,6 +783,43 @@ EOF success "Bambuddy service created and enabled" } +bootstrap_spoolbuddy_kiosk_key() { + # Provision an API key for the local SpoolBuddy kiosk and write it into + # spoolbuddy/.env. Runs against the Bambuddy DB directly (via the CLI), + # so the bambuddy service does not need to be running yet. + info "Provisioning SpoolBuddy kiosk API key..." + + local env_file="$INSTALL_PATH/spoolbuddy/.env" + if [[ ! -f "$env_file" ]]; then + warn "SpoolBuddy env file not found at $env_file — skipping kiosk key bootstrap" + return + fi + + # CWD must be $INSTALL_PATH so `python -m backend.app.cli` finds the backend + # package on sys.path (matches the systemd unit's WorkingDirectory). + local kiosk_key + if ! kiosk_key="$(cd "$INSTALL_PATH" && sudo -u "$BAMBUDDY_SERVICE_USER" \ + env DATA_DIR="$INSTALL_PATH/data" LOG_DIR="$INSTALL_PATH/logs" \ + "$INSTALL_PATH/venv/bin/python" -m backend.app.cli kiosk-bootstrap --force)"; then + error "Failed to bootstrap SpoolBuddy kiosk API key" + fi + + if [[ -z "$kiosk_key" || "$kiosk_key" != bb_* ]]; then + error "CLI returned an invalid API key (got: ${kiosk_key:0:8}...)" + fi + + if ! grep -q '^SPOOLBUDDY_API_KEY=' "$env_file"; then + error "Sentinel 'SPOOLBUDDY_API_KEY=' line missing in $env_file" + fi + + # Escape for sed replacement (the key is base64url-safe, no slashes, but be defensive) + local escaped_key + escaped_key=$(printf '%s\n' "$kiosk_key" | sed -e 's/[\/&]/\\&/g') + sed -i "s/^SPOOLBUDDY_API_KEY=.*/SPOOLBUDDY_API_KEY=${escaped_key}/" "$env_file" + + success "SpoolBuddy kiosk API key provisioned" +} + # ───────────────────────────────────────────────────────────────────────────── # System Strip-Down (dedicated appliance — remove unnecessary services/packages) # ───────────────────────────────────────────────────────────────────────────── @@ -1504,6 +1541,7 @@ main() { create_bambuddy_directories create_bambuddy_env create_bambuddy_service + bootstrap_spoolbuddy_kiosk_key echo "" fi @@ -1532,10 +1570,7 @@ main() { echo -e " ${BOLD}Next steps:${NC}" echo -e " 1. Reboot (required for kiosk, Plymouth splash, and hardware changes)" echo -e " 2. The touchscreen kiosk will start automatically after reboot" - echo -e " 3. On another device, open ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC}" - echo -e " 4. Go to Settings -> API Keys and create an API key" - echo -e " 5. Update the API key in: ${CYAN}$INSTALL_PATH/spoolbuddy/.env${NC}" - echo -e " 6. Restart SpoolBuddy: ${CYAN}sudo systemctl restart spoolbuddy${NC}" + echo -e " 3. On another device, open ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC} to complete first-run admin setup" fi echo ""