From dce9c8bc0673638eb529458642d81ae28f91094a Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 16:08:49 +0100 Subject: [PATCH 01/11] Fix filename matching for files with spaces (Issue #218) Bambu Studio converts spaces to underscores when saving files to the printer, but MQTT reports the original name with spaces. This caused FTP downloads to fail with "550 Failed to open file" because we were searching for "Battery Storage_giesela.gcode.3mf" but the actual file was "Battery_Storage_giesela.gcode.3mf". Changes: - Add underscore variants to direct download path attempts - Normalize spaces/underscores in fallback directory search - Apply fix to archive download, cover extraction, and objects reload Closes #218 --- backend/app/api/routes/printers.py | 18 ++++++++++++++++++ backend/app/main.py | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 3f924859f..fa890f1d2 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -595,6 +595,15 @@ async def get_printer_cover( possible_filenames.append(f"{subtask_name}.gcode.3mf") possible_filenames.append(f"{subtask_name}.3mf") + # Also try with spaces converted to underscores (Bambu Studio may normalize filenames) + if " " in subtask_name: + normalized = subtask_name.replace(" ", "_") + if normalized.endswith(".3mf"): + possible_filenames.append(normalized) + else: + possible_filenames.append(f"{normalized}.gcode.3mf") + possible_filenames.append(f"{normalized}.3mf") + # Build list of all remote paths to try remote_paths = [] for filename in possible_filenames: @@ -1574,6 +1583,15 @@ async def get_printable_objects( possible_filenames.append(f"{subtask_name}.gcode.3mf") possible_filenames.append(f"{subtask_name}.3mf") + # Also try with spaces converted to underscores (Bambu Studio may normalize filenames) + if " " in subtask_name: + normalized = subtask_name.replace(" ", "_") + if normalized.endswith(".3mf"): + possible_filenames.append(normalized) + else: + possible_filenames.append(f"{normalized}.gcode.3mf") + possible_filenames.append(f"{normalized}.3mf") + # Download 3MF from printer temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}" temp_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/main.py b/backend/app/main.py index 5f534411e..e36d5cc89 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1002,6 +1002,13 @@ async def on_print_start(printer_id: int, data: dict): possible_names.append(f"{fname}.gcode.3mf") possible_names.append(f"{fname}.3mf") + # Also try with spaces converted to underscores (Bambu Studio may normalize filenames) + space_variants = [] + for name in possible_names: + if " " in name: + space_variants.append(name.replace(" ", "_")) + possible_names.extend(space_variants) + # Remove duplicates while preserving order seen = set() possible_names = [x for x in possible_names if not (x in seen or seen.add(x))] @@ -1085,7 +1092,10 @@ async def on_print_start(printer_id: int, data: dict): if f.get("is_directory"): continue fname = f.get("name", "") - if fname.endswith(".3mf") and search_term in fname.lower(): + # Normalize both for comparison (spaces and underscores are equivalent) + fname_normalized = fname.lower().replace(" ", "_") + search_normalized = search_term.replace(" ", "_") + if fname.endswith(".3mf") and search_normalized in fname_normalized: logger.info(f"Found matching file in {search_dir}: {fname}") temp_path = app_settings.archive_dir / "temp" / fname temp_path.parent.mkdir(parents=True, exist_ok=True) From 4dad18a331690accfea595b08f7368e7b425c403 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 16:20:07 +0100 Subject: [PATCH 02/11] Fixed CodeQL Alert #68: Stack trace exposure in archives.py --- backend/app/api/routes/archives.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 5bf950fa6..4d34a0508 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -901,7 +901,8 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)): updated += 1 except Exception as e: - errors.append({"id": archive.id, "error": str(e)}) + logger.exception(f"Failed to rescan archive {archive.id}: {e}") + errors.append({"id": archive.id, "error": "Failed to parse 3MF file"}) await db.commit() return {"updated": updated, "errors": errors} @@ -944,7 +945,8 @@ async def backfill_content_hashes(db: AsyncSession = Depends(get_db)): archive.content_hash = ArchiveService.compute_file_hash(file_path) updated += 1 except Exception as e: - errors.append({"id": archive.id, "error": str(e)}) + logger.exception(f"Failed to compute hash for archive {archive.id}: {e}") + errors.append({"id": archive.id, "error": "Failed to compute hash"}) await db.commit() return {"updated": updated, "errors": errors} @@ -2134,7 +2136,8 @@ async def upload_archives_bulk( else: errors.append({"filename": file.filename, "error": "Failed to process"}) except Exception as e: - errors.append({"filename": file.filename, "error": str(e)}) + logger.exception(f"Failed to upload archive {file.filename}: {e}") + errors.append({"filename": file.filename, "error": "Failed to process file"}) finally: if temp_path.exists(): temp_path.unlink() From 234693a306b9434b4e4294c32ae3c04665f490e6 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 16:26:55 +0100 Subject: [PATCH 03/11] Fix external spool ams_mapping2 slot_id (Issue #213) The ams_mapping2 format was incorrectly using the tray_id (254/255) as the slot_id for external spools. The printer expects slot_id to be the actual slot index (0 for main nozzle, 1 for deputy nozzle), not the tray_id value. Before: {"ams_id": 255, "slot_id": 254} <- invalid slot index After: {"ams_id": 255, "slot_id": 0} <- correct slot index This caused prints using external spool to fail immediately with error code 07FF_8007. Closes #213 --- backend/app/services/bambu_mqtt.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index ece44bb81..ec27a3dc4 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -2038,8 +2038,9 @@ class BambuMQTTClient: ams_mapping2.append({"ams_id": 255, "slot_id": 255}) elif tray_id >= 254: # External spool: 254 = main nozzle, 255 = deputy nozzle - # External spools use ams_id=255 with slot_id matching tray_id - ams_mapping2.append({"ams_id": 255, "slot_id": tray_id}) + # For ams_mapping2, slot_id is 0 (main) or 1 (deputy), not the tray_id + external_slot = 0 if tray_id == 254 else 1 + ams_mapping2.append({"ams_id": 255, "slot_id": external_slot}) else: # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id ams_id = tray_id // 4 From 57db7932e764ee7e2a8223986d2de2a11fe149b3 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 16:29:37 +0100 Subject: [PATCH 04/11] Fix external spool ams_mapping2 slot_id (Issue #213) The ams_mapping2 format was incorrectly using the tray_id (254/255) as the slot_id for external spools. The printer expects slot_id to be the actual slot index (0 for main nozzle, 1 for deputy nozzle), not the tray_id value. Before: {"ams_id": 255, "slot_id": 254} <- invalid slot index After: {"ams_id": 255, "slot_id": 0} <- correct slot index This caused prints using external spool to fail immediately with error code 07FF_8007. Closes #213 --- frontend/src/__tests__/mocks/handlers.ts | 2 +- frontend/src/__tests__/setup.ts | 13 ++++--------- frontend/vitest.config.ts | 5 +++++ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index ac6ac79ea..8d0158981 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -273,7 +273,7 @@ export const handlers = [ // Auth // ======================================================================== - http.get('/api/v1/auth/status', () => { + http.get('*/api/v1/auth/status', () => { return HttpResponse.json({ auth_enabled: false, requires_setup: false, diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 2a46de464..83ab24145 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -11,17 +11,12 @@ import { server } from './mocks/server'; // Initialize i18n for tests (suppresses react-i18next warnings) import '../i18n'; -// Setup MSW server - bypass WebSocket requests so our mock handles them +// Setup MSW server beforeAll(() => server.listen({ - onUnhandledRequest: (request, _print) => { - // Allow WebSocket requests to pass through to our mock - if (request.url.includes('/ws')) { - return; - } - // Silently ignore unhandled requests in tests to reduce noise - // Remove 'warn' to completely silence, or use print.warning() to show warnings - }, + // Bypass unhandled requests silently (don't warn, just let them through) + // Handlers use wildcard (*) prefix to match any origin + onUnhandledRequest: 'bypass', }) ); afterEach(() => { diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 520c0ebe5..84fc7261a 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -7,6 +7,11 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost:3000', + }, + }, setupFiles: ['./src/__tests__/setup.ts'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], exclude: ['node_modules', 'dist'], From f330f1c1af75a1d119a167ade8c43c770bf1d77b Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 2 Feb 2026 06:36:03 +0100 Subject: [PATCH 05/11] Housekeeping --- docker-publish.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-publish.sh b/docker-publish.sh index 4132621e5..446ef8d5a 100755 --- a/docker-publish.sh +++ b/docker-publish.sh @@ -84,7 +84,7 @@ fi # Determine if this is a release version (includes betas for now) IS_RELEASE=false -if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(b[0-9]+)?$ ]]; then +if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?(b[0-9]+)?$ ]]; then IS_RELEASE=true fi From c31f2968889c855f1ffacb700c2c9970deb2a6fb Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 2 Feb 2026 06:51:55 +0100 Subject: [PATCH 06/11] Fix critical security vulnerabilities (GHSA-gc24-px2r-5qmf) ## Summary Address two critical security issues reported via GitHub Security Advisory: 1. Hardcoded JWT secret key allowing token forgery 2. Missing authentication on 77+ API endpoints ## Changes ### JWT Secret Key (backend/app/core/auth.py) - Remove hardcoded secret "bambuddy-secret-key-change-in-production" - Load secret from JWT_SECRET_KEY environment variable (recommended) - Fall back to .jwt_secret file in data directory (auto-generated) - Generate cryptographically secure 64-byte random secret if neither exists - File is created with 0600 permissions for security ### API Authentication Middleware (backend/app/main.py) - Add HTTP middleware that enforces auth on ALL /api/ routes - When auth is enabled, every API request requires valid JWT or API key - Only exempt routes that must be public: - /api/v1/auth/status (check if auth enabled) - /api/v1/auth/login (login endpoint) - /api/v1/updates/version (version check) - /api/v1/ws/* (WebSockets handle own auth) ### Test Updates - backend/tests/conftest.py: Patch middleware's async_session for tests - backend/tests/integration/test_ownership_permissions.py: Add missing auth headers to requests that now require authentication ## Migration Notes - Existing JWT tokens will be invalidated (users must re-login) - Set JWT_SECRET_KEY env var in production for token persistence across restarts - No database changes required Fixes: GHSA-gc24-px2r-5qmf Security: CWE-306 (Missing Authentication), CWE-321 (Hardcoded Crypto Key) Closes GHSA-gc24-px2r-5qmf --- backend/app/core/auth.py | 59 ++++++++- backend/app/main.py | 115 ++++++++++++++++++ backend/tests/conftest.py | 3 +- .../integration/test_ownership_permissions.py | 10 +- 4 files changed, 183 insertions(+), 4 deletions(-) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index d72b65091..4716b5c11 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -1,7 +1,10 @@ from __future__ import annotations +import logging +import os import secrets from datetime import datetime, timedelta +from pathlib import Path from typing import Annotated import jwt @@ -19,13 +22,67 @@ from backend.app.models.api_key import APIKey from backend.app.models.settings import Settings from backend.app.models.user import User +logger = logging.getLogger(__name__) + # Password hashing # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +def _get_jwt_secret() -> str: + """Get the JWT secret key from environment, file, or generate a new one. + + Priority: + 1. JWT_SECRET_KEY environment variable + 2. .jwt_secret file in data directory + 3. Generate new random secret and save to file + + Returns: + The JWT secret key + """ + # 1. Check environment variable first + env_secret = os.environ.get("JWT_SECRET_KEY") + if env_secret: + logger.info("Using JWT secret from JWT_SECRET_KEY environment variable") + return env_secret + + # 2. Check for secret file in data directory + data_dir = Path(os.environ.get("BAMBUDDY_DATA_DIR", "/app/data")) + secret_file = data_dir / ".jwt_secret" + + if secret_file.exists(): + try: + secret = secret_file.read_text().strip() + if secret and len(secret) >= 32: + logger.info("Using JWT secret from %s", secret_file) + return secret + except Exception as e: + logger.warning("Failed to read JWT secret file: %s", e) + + # 3. Generate new random secret + new_secret = secrets.token_urlsafe(64) + + # Try to save it + try: + data_dir.mkdir(parents=True, exist_ok=True) + secret_file.write_text(new_secret) + # Restrict permissions (owner read/write only) + secret_file.chmod(0o600) + logger.info("Generated new JWT secret and saved to %s", secret_file) + except Exception as e: + logger.warning( + "Could not save JWT secret to file (%s). " + "Secret will be regenerated on restart, invalidating existing tokens. " + "Set JWT_SECRET_KEY environment variable for persistence.", + e, + ) + + return new_secret + + # JWT settings -SECRET_KEY = "bambuddy-secret-key-change-in-production" # TODO: Move to settings/env +SECRET_KEY = _get_jwt_secret() ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days diff --git a/backend/app/main.py b/backend/app/main.py index e36d5cc89..9c7bb34ae 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2537,6 +2537,121 @@ app = FastAPI( lifespan=lifespan, ) + +# ============================================================================= +# Authentication Middleware - Secures ALL API routes by default +# ============================================================================= +# Public routes that don't require authentication even when auth is enabled +PUBLIC_API_ROUTES = { + # Auth routes needed before login + "/api/v1/auth/status", + "/api/v1/auth/login", + # Version check for updates (no sensitive data) + "/api/v1/updates/version", +} + +# Route prefixes that are public (for routes with dynamic segments) +PUBLIC_API_PREFIXES = [ + # WebSocket connections handle their own auth + "/api/v1/ws", +] + + +@app.middleware("http") +async def auth_middleware(request, call_next): + """Enforce authentication on all API routes when auth is enabled. + + This middleware provides defense-in-depth by checking auth at the API gateway level, + regardless of whether individual routes have auth dependencies. + """ + from starlette.responses import JSONResponse + + path = request.url.path + + # Only apply to API routes + if not path.startswith("/api/"): + return await call_next(request) + + # Allow public routes + if path in PUBLIC_API_ROUTES: + return await call_next(request) + + # Allow public prefixes + for prefix in PUBLIC_API_PREFIXES: + if path.startswith(prefix): + return await call_next(request) + + # Check if auth is enabled + try: + async with async_session() as db: + from backend.app.core.auth import is_auth_enabled + + auth_enabled = await is_auth_enabled(db) + + if not auth_enabled: + # Auth disabled, allow all requests + return await call_next(request) + except Exception: + # If we can't check auth status, allow request (fail open for DB issues) + return await call_next(request) + + # Auth is enabled - require valid token + auth_header = request.headers.get("Authorization") + x_api_key = request.headers.get("X-API-Key") + + # Check for API key auth first + if x_api_key or (auth_header and auth_header.startswith("Bearer bb_")): + # API key authentication - let the request through to be validated by route handler + # API keys are validated per-route since they have different permission levels + return await call_next(request) + + # Check for JWT auth + if not auth_header or not auth_header.startswith("Bearer "): + return JSONResponse( + status_code=401, + content={"detail": "Authentication required"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Validate JWT token + try: + import jwt + + from backend.app.core.auth import ALGORITHM, SECRET_KEY + + token = auth_header.replace("Bearer ", "") + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + if not username: + raise ValueError("No username in token") + + # Verify user exists and is active + async with async_session() as db: + from backend.app.core.auth import get_user_by_username + + user = await get_user_by_username(db, username) + if not user or not user.is_active: + return JSONResponse( + status_code=401, + content={"detail": "User not found or inactive"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + except jwt.ExpiredSignatureError: + return JSONResponse( + status_code=401, + content={"detail": "Token has expired"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + except (jwt.InvalidTokenError, ValueError, Exception): + return JSONResponse( + status_code=401, + content={"detail": "Invalid token"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + return await call_next(request) + + # API routes app.include_router(auth.router, prefix=app_settings.api_prefix) app.include_router(users.router, prefix=app_settings.api_prefix) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e56ee875b..40f487405 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -117,10 +117,11 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N async def mock_init_printer_connections(db): pass # No-op - don't connect to real printers - # Also patch the module-level async_session used by services and auth + # Also patch the module-level async_session used by services, auth, and middleware with ( patch("backend.app.core.database.async_session", test_async_session), patch("backend.app.core.auth.async_session", test_async_session), + patch("backend.app.main.async_session", test_async_session), patch("backend.app.main.init_printer_connections", mock_init_printer_connections), ): # Seed default groups for tests that need them diff --git a/backend/tests/integration/test_ownership_permissions.py b/backend/tests/integration/test_ownership_permissions.py index 2d0063f76..9b01a0d57 100644 --- a/backend/tests/integration/test_ownership_permissions.py +++ b/backend/tests/integration/test_ownership_permissions.py @@ -700,7 +700,10 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup): assert response.status_code == 204 # Verify archive still exists but is now ownerless - archive_response = await async_client.get(f"/api/v1/archives/{archive_id}") + archive_response = await async_client.get( + f"/api/v1/archives/{archive_id}", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + ) assert archive_response.status_code == 200 assert archive_response.json()["created_by_id"] is None @@ -736,5 +739,8 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup): assert response.status_code == 204 # Verify archive was deleted - archive_response = await async_client.get(f"/api/v1/archives/{archive_id}") + archive_response = await async_client.get( + f"/api/v1/archives/{archive_id}", + headers={"Authorization": f"Bearer {auth_setup['admin_token']}"}, + ) assert archive_response.status_code == 404 From 018a74447576d48c47c838c89db70e784fd6b80f Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 2 Feb 2026 07:39:58 +0100 Subject: [PATCH 07/11] Location filter for queue and auth fixes (Issue #220) Features: - Add location filter for "Any {Model}" queue assignments - Queue items can target a specific location (e.g., "Any X1C in Workshop") - Location dropdown filter on Queue page to view jobs by location - Scheduler considers location when assigning model-based jobs Closes #220 --- .gitignore | 3 + CHANGELOG.md | 7 + README.md | 2 +- backend/app/api/routes/print_queue.py | 2 + backend/app/core/auth.py | 8 +- backend/app/core/database.py | 6 + backend/app/main.py | 29 ++- backend/app/models/print_queue.py | 3 + backend/app/schemas/print_queue.py | 3 + backend/app/services/print_scheduler.py | 17 +- backend/tests/integration/test_auth_api.py | 85 +++++++ .../tests/integration/test_print_queue_api.py | 213 +++++++++++++++++ frontend/src/api/client.ts | 3 + .../components/PrintModal/PrinterSelector.tsx | 74 +++++- frontend/src/components/PrintModal/index.tsx | 13 ++ frontend/src/components/PrintModal/types.ts | 4 + frontend/src/pages/PrintersPage.tsx | 14 +- frontend/src/pages/QueuePage.tsx | 69 +++++- frontend/vitest.config.ts | 7 + icons/5f21bc794a4e4521b72c6564029ed5d9.svg | 1 - .../{index-1q7Yxq-H.js => index-D-vJDFzo.js} | 218 +++++++++--------- static/index.html | 2 +- 22 files changed, 652 insertions(+), 131 deletions(-) delete mode 100644 icons/5f21bc794a4e4521b72c6564029ed5d9.svg rename static/assets/{index-1q7Yxq-H.js => index-D-vJDFzo.js} (56%) diff --git a/.gitignore b/.gitignore index 57914d1aa..8a840e6be 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ firmware/ node_modules/ data/ + +# JWT secret file (should be in data dir, but protect project root too) +.jwt_secret diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d4847ae..ca9659c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.7b] - Not released ### Enhancements +- **Location Filter for Queue** (Issue #220): + - Filter queue jobs by printer location in the Queue page + - "Any {Model}" queue assignments can now specify a target location (e.g., "Any X1C in Workshop") + - Location filter dropdown shows all unique locations from printers and queue items + - Location is saved with queue items and displayed in the queue list - **Ownership-Based Permissions** (Issue #205): - Users can now only update/delete their own items unless they have elevated permissions - Update/delete permissions split into `*_own` and `*_all` variants: @@ -51,6 +56,8 @@ All notable changes to Bambuddy will be documented in this file. - Removed ~2000 lines of legacy JSON-based backup/restore code ### Fixes +- **JWT secret key not persistent across restarts** - Fixed JWT secret key generation to properly use data directory, ensuring tokens remain valid across container restarts +- **Images/thumbnails returning 401 when auth enabled** - Fixed auth middleware to allow public access to image/media endpoints (thumbnails, photos, QR codes, timelapses, camera streams) since browser elements like `` don't send Authorization headers - **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems: - Library now stores relative paths in database for portability - Automatic migration converts existing absolute paths to relative on startup diff --git a/README.md b/README.md index f305c2971..912ea921c 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ ### ⏰ Scheduling & Automation - Print queue with drag-and-drop - Multi-printer selection (send to multiple printers at once) -- Model-based queue assignment (send to "any X1C" for load balancing) +- Model-based queue assignment (send to "any X1C" for load balancing) with location filtering - Filament validation (only assign to printers with required filaments) - Per-printer AMS mapping (individual slot configuration for print farms) - Scheduled prints (date/time) diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index d4109a5db..ff8f8c4c1 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -121,6 +121,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: "id": item.id, "printer_id": item.printer_id, "target_model": item.target_model, + "target_location": item.target_location, "required_filament_types": required_filament_types_parsed, "waiting_reason": item.waiting_reason, "archive_id": item.archive_id, @@ -289,6 +290,7 @@ async def add_to_queue( item = PrintQueueItem( printer_id=data.printer_id, target_model=target_model_norm, + target_location=data.target_location, required_filament_types=required_filament_types, archive_id=data.archive_id, library_file_id=data.library_file_id, diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 4716b5c11..2dc5ec4e9 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -48,7 +48,13 @@ def _get_jwt_secret() -> str: return env_secret # 2. Check for secret file in data directory - data_dir = Path(os.environ.get("BAMBUDDY_DATA_DIR", "/app/data")) + # Use DATA_DIR env var (same as rest of app), fallback to data/ subdirectory + data_dir_env = os.environ.get("DATA_DIR") + if data_dir_env: + data_dir = Path(data_dir_env) + else: + # Fallback to data/ subdirectory under project root (not project root itself!) + data_dir = Path(__file__).parent.parent.parent.parent / "data" secret_file = data_dir / ".jwt_secret" if secret_file.exists(): diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 8635bf2f5..6c97bf120 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1044,6 +1044,12 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add target_location column to print_queue for location-based filtering (Issue #220) + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)")) + except Exception: + pass + # Migration: Convert absolute paths to relative paths in library_files table # This ensures backup/restore portability across different installations try: diff --git a/backend/app/main.py b/backend/app/main.py index 9c7bb34ae..32d3279e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2543,11 +2543,14 @@ app = FastAPI( # ============================================================================= # Public routes that don't require authentication even when auth is enabled PUBLIC_API_ROUTES = { - # Auth routes needed before login + # Auth routes needed before/during login "/api/v1/auth/status", "/api/v1/auth/login", + "/api/v1/auth/setup", # Needed for initial setup and recovery # Version check for updates (no sensitive data) "/api/v1/updates/version", + # Metrics endpoint handles its own prometheus_token authentication + "/api/v1/metrics", } # Route prefixes that are public (for routes with dynamic segments) @@ -2556,6 +2559,25 @@ PUBLIC_API_PREFIXES = [ "/api/v1/ws", ] +# Route patterns that are public (read-only display data) +# These are checked with "in path" - needed because browsers load images/videos +# via and