Browse Source

Merge pull request #225 from maziggy/0.1.6.2

v0.1.6.2
MartinNYHC 3 months ago
parent
commit
5423530672

+ 3 - 0
.gitignore

@@ -58,3 +58,6 @@ firmware/
 node_modules/
 
 data/
+
+# JWT secret file (should be in data dir, but protect project root too)
+.jwt_secret

+ 37 - 0
CHANGELOG.md

@@ -4,7 +4,27 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.1.7b] - Not released
 
+## [0.1.6.2] - 2026-02-02
+
+> **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.
+
+### Security
+- **Critical: Hardcoded JWT Secret Key** (GHSA-gc24-px2r-5qmf, CWE-321) - Fixed hardcoded JWT secret key that could allow attackers to forge authentication tokens:
+  - JWT secret now loaded from `JWT_SECRET_KEY` environment variable (recommended for production)
+  - Falls back to auto-generated `.jwt_secret` file in data directory with secure permissions (0600)
+  - Generates cryptographically secure 64-byte random secret if neither exists
+  - **Action Required**: Existing users will need to re-login after upgrading
+- **Critical: Missing API Authentication** (GHSA-gc24-px2r-5qmf, CWE-306) - Fixed 77+ API endpoints that lacked authentication checks:
+  - Added HTTP middleware enforcing authentication on ALL `/api/` routes when auth is enabled
+  - Only essential public endpoints are exempt (login, auth status, version check, WebSocket)
+  - All other API calls now require valid JWT token or API key
+
 ### 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,11 +71,28 @@ All notable changes to Bambuddy will be documented in this file.
   - Removed ~2000 lines of legacy JSON-based backup/restore code
 
 ### Fixes
+- **File Manager permissions not enforced** (Issue #224) - Fixed backend not checking `library:read` permission for File Manager endpoints:
+  - Added `library:read` permission check to all list/view endpoints (files, folders, stats)
+  - Added `library:upload` permission check to upload and folder creation endpoints
+  - Added `queue:create` permission check to add-to-queue endpoint
+  - Added `printers:control` permission check to direct print endpoint
+  - Added ownership-based permission checks to file move operation
+  - Users without `library:read` permission can no longer view files in the File Manager
+  - Users can now only delete/update their own files unless they have `*_all` permissions
+- **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 `<img>` 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
   - Thumbnails and files now display correctly after restoring backups
 - **File uploads failing with authentication enabled** - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled
+- **External spool AMS mapping causing "Failed to get AMS mapping table"** (Issue #213) - Fixed external spool `ams_mapping2` slot_id handling that caused AMS mapping failures
+- **Filename matching for files with spaces** (Issue #218) - Fixed file detection when filenames contain spaces
+- **P2S FTP upload failure** (Issue #218) - Fixed FTP uploads to P2S printers by passing `skip_session_reuse` to ImplicitFTP_TLS
+- **Printer deletion freeze** (Issue #214) - Fixed UI freeze when deleting printers, and now allows multiple smart plugs per printer
+- **Stack trace exposure in error responses** (CodeQL Alert #68) - Fixed stack traces being exposed in API error responses in archives.py
+- **Printer serial numbers exposed in support bundle** (Issue #216) - Sanitized printer serial numbers in support bundle logs for privacy
+- **Missing sliced_for_model migration** (Issue #211) - Fixed database migration for `sliced_for_model` column that was missing in some upgrade paths
 
 ## [0.1.6-final] - 2026-01-31
 

+ 1 - 1
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)

+ 88 - 17
backend/app/api/routes/library.py

@@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.auth import (
-    require_auth_if_enabled,
     require_ownership_permission,
     require_permission_if_auth_enabled,
 )
@@ -237,7 +236,11 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", "
 
 @router.get("/folders", response_model=list[FolderTreeItem])
 @router.get("/folders/", response_model=list[FolderTreeItem])
-async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
+async def list_folders(
+    response: Response,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get all folders as a tree structure."""
     # Prevent browser caching of folder list
     response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -289,7 +292,11 @@ async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/folders/by-project/{project_id}", response_model=list[FolderResponse])
-async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get_db)):
+async def get_folders_by_project(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get all folders linked to a specific project."""
     result = await db.execute(
         select(LibraryFolder, Project.name)
@@ -326,7 +333,11 @@ async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get
 
 
 @router.get("/folders/by-archive/{archive_id}", response_model=list[FolderResponse])
-async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_folders_by_archive(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get all folders linked to a specific archive."""
     result = await db.execute(
         select(LibraryFolder, PrintArchive.print_name)
@@ -364,7 +375,11 @@ async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get
 
 @router.post("/folders", response_model=FolderResponse)
 @router.post("/folders/", response_model=FolderResponse)
-async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)):
+async def create_folder(
+    data: FolderCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+):
     """Create a new folder."""
     # Verify parent exists if specified
     if data.parent_id is not None:
@@ -415,7 +430,11 @@ async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/folders/{folder_id}", response_model=FolderResponse)
-async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
+async def get_folder(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get a folder by ID."""
     result = await db.execute(
         select(LibraryFolder, Project.name, PrintArchive.print_name)
@@ -449,8 +468,17 @@ async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.put("/folders/{folder_id}", response_model=FolderResponse)
-async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = Depends(get_db)):
-    """Update a folder."""
+async def update_folder(
+    folder_id: int,
+    data: FolderUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+):
+    """Update a folder.
+
+    Note: Folders require library:update_all permission since they don't have
+    ownership tracking.
+    """
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     folder = result.scalar_one_or_none()
 
@@ -595,6 +623,7 @@ async def list_files(
     folder_id: int | None = None,
     include_root: bool = True,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """List files, optionally filtered by folder.
 
@@ -669,7 +698,7 @@ async def upload_file(
     folder_id: int | None = None,
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
 ):
     """Upload a file to the library."""
     try:
@@ -805,7 +834,7 @@ async def extract_zip_file(
     create_folder_from_zip: bool = Query(default=False),
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
 ):
     """Upload and extract a ZIP file to the library.
 
@@ -1064,9 +1093,13 @@ async def extract_zip_file(
 async def batch_generate_stl_thumbnails(
     request: BatchThumbnailRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ):
     """Generate thumbnails for STL files in batch.
 
+    Note: Requires library:update_all permission since this is a batch operation
+    that may affect files owned by different users.
+
     Can generate thumbnails for:
     - Specific file IDs (file_ids)
     - All STL files in a folder (folder_id)
@@ -1188,6 +1221,7 @@ def is_sliced_file(filename: str) -> bool:
 async def add_files_to_queue(
     request: AddToQueueRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
 ):
     """Add library files to the print queue.
 
@@ -1266,6 +1300,7 @@ async def add_files_to_queue(
 async def get_library_file_plates(
     file_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """Get available plates from a multi-plate 3MF library file.
 
@@ -1477,6 +1512,7 @@ async def get_library_file_filament_requirements(
     file_id: int,
     plate_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """Get filament requirements from a library file.
 
@@ -1599,6 +1635,7 @@ async def print_library_file(
     printer_id: int,
     body: FilePrintRequest | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
 ):
     """Print a library file directly.
 
@@ -1786,7 +1823,11 @@ async def print_library_file(
 
 
 @router.get("/files/{file_id}", response_model=FileResponseSchema)
-async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
+async def get_file(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get a file by ID with full details."""
     result = await db.execute(
         select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
@@ -1961,7 +2002,11 @@ async def delete_file(
 
 
 @router.get("/files/{file_id}/download")
-async def download_file(file_id: int, db: AsyncSession = Depends(get_db)):
+async def download_file(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Download a file."""
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
@@ -2008,7 +2053,11 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/files/{file_id}/gcode")
-async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
+async def get_gcode(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get gcode for a file (for preview)."""
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
@@ -2046,8 +2095,22 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/files/move")
-async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
-    """Move multiple files to a folder."""
+async def move_files(
+    data: FileMoveRequest,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+):
+    """Move multiple files to a folder.
+
+    Files not owned by the user are skipped (unless user has *_all permission).
+    """
+    user, can_modify_all = auth_result
+
     # Verify folder exists if specified
     if data.folder_id is not None:
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
@@ -2056,14 +2119,19 @@ async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
 
     # Update files
     moved = 0
+    skipped = 0
     for file_id in data.file_ids:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
         file = result.scalar_one_or_none()
         if file:
+            # Ownership check
+            if not can_modify_all and file.created_by_id != user.id:
+                skipped += 1
+                continue
             file.folder_id = data.folder_id
             moved += 1
 
-    return {"status": "success", "moved": moved}
+    return {"status": "success", "moved": moved, "skipped": skipped}
 
 
 @router.post("/bulk-delete", response_model=BulkDeleteResponse)
@@ -2133,7 +2201,10 @@ async def bulk_delete(
 
 
 @router.get("/stats")
-async def get_library_stats(db: AsyncSession = Depends(get_db)):
+async def get_library_stats(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get library statistics."""
     # Total files
     total_files_result = await db.execute(select(func.count(LibraryFile.id)))

+ 2 - 0
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,

+ 18 - 0
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)

+ 67 - 1
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,76 @@ 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
+    # 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():
+        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)
+        # Note: CodeQL flags this as "clear-text storage of sensitive information" but this is
+        # intentional and secure - JWT secrets must be readable by the app, we set 0600 permissions,
+        # and this is standard practice for self-hosted applications (same as .env files).
+        secret_file.write_text(new_secret)  # nosec B105 - intentional secure storage
+        # 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
 

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.1.7b"
+APP_VERSION = "0.1.6.2"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

+ 6 - 0
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:

+ 153 - 1
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)
@@ -2527,6 +2537,148 @@ 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/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)
+PUBLIC_API_PREFIXES = [
+    # WebSocket connections handle their own auth
+    "/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 <img src> and <video src> which don't include Authorization headers
+PUBLIC_API_PATTERNS = [
+    # Thumbnails
+    "/thumbnail",  # /archives/{id}/thumbnail, /library/files/{id}/thumbnail
+    "/plate-thumbnail/",  # /archives/{id}/plate-thumbnail/{plate_id}
+    # Images and media
+    "/photos/",  # /archives/{id}/photos/{filename}
+    "/project-image/",  # /archives/{id}/project-image/{path}
+    "/qrcode",  # /archives/{id}/qrcode
+    "/timelapse",  # /archives/{id}/timelapse (video)
+    "/cover",  # /printers/{id}/cover
+    "/icon",  # /external-links/{id}/icon
+    # Camera (streams loaded via <img> tag)
+    "/camera/stream",  # /printers/{id}/camera/stream
+    "/camera/snapshot",  # /printers/{id}/camera/snapshot
+]
+
+
+@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)
+
+    # Allow public patterns (read-only display data like thumbnails)
+    for pattern in PUBLIC_API_PATTERNS:
+        if pattern in path:
+            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)

+ 3 - 0
backend/app/models/print_queue.py

@@ -18,6 +18,9 @@ class PrintQueueItem(Base):
     # Target printer model for model-based assignment (mutually exclusive with printer_id)
     # When set, scheduler assigns to any idle printer of matching model
     target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    # Target location filter for model-based assignment (only used with target_model)
+    # When set, only printers in this location are considered
+    target_location: Mapped[str | None] = mapped_column(String(100), nullable=True)
     # Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
     # Used by scheduler to validate printer has compatible filaments loaded
     required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 3 - 0
backend/app/schemas/print_queue.py

@@ -18,6 +18,7 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    target_location: str | None = None  # Target location filter (only used with target_model)
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
@@ -43,6 +44,7 @@ class PrintQueueItemCreate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    target_location: str | None = None  # Target location filter (only used with target_model)
     position: int | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
@@ -63,6 +65,7 @@ class PrintQueueItemResponse(BaseModel):
     id: int
     printer_id: int | None  # None = unassigned
     target_model: str | None = None  # Target printer model for model-based assignment
+    target_location: str | None = None  # Target location filter for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)

+ 13 - 4
backend/app/services/print_scheduler.py

@@ -148,7 +148,7 @@ class PrintScheduler:
                             pass
 
                     printer_id, waiting_reason = await self._find_idle_printer_for_model(
-                        db, item.target_model, busy_printers, required_types
+                        db, item.target_model, busy_printers, required_types, item.target_location
                     )
 
                     # Update waiting_reason if changed and send notification when first waiting
@@ -225,6 +225,7 @@ class PrintScheduler:
         model: str,
         exclude_ids: set[int],
         required_filament_types: list[str] | None = None,
+        target_location: str | None = None,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -234,6 +235,7 @@ class PrintScheduler:
             exclude_ids: Printer IDs to exclude (already busy)
             required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
                                      If provided, only printers with all required types loaded will match.
+            target_location: Optional location filter. If provided, only printers in this location are considered.
 
         Returns:
             Tuple of (printer_id, waiting_reason):
@@ -242,15 +244,22 @@ class PrintScheduler:
         """
         # Normalize model name and use case-insensitive matching
         normalized_model = normalize_printer_model(model) or model
-        result = await db.execute(
+        query = (
             select(Printer)
             .where(func.lower(Printer.model) == normalized_model.lower())
             .where(Printer.is_active == True)  # noqa: E712
         )
+
+        # Add location filter if specified
+        if target_location:
+            query = query.where(Printer.location == target_location)
+
+        result = await db.execute(query)
         printers = list(result.scalars().all())
 
+        location_suffix = f" in {target_location}" if target_location else ""
         if not printers:
-            return None, f"No active {normalized_model} printers configured"
+            return None, f"No active {normalized_model} printers{location_suffix} configured"
 
         # Track reasons for skipping printers
         printers_busy = []
@@ -295,7 +304,7 @@ class PrintScheduler:
         if printers_offline:
             reasons.append(f"Offline: {', '.join(printers_offline)}")
 
-        return None, " | ".join(reasons) if reasons else f"No available {model} printers"
+        return None, " | ".join(reasons) if reasons else f"No available {model} printers{location_suffix}"
 
     def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
         """Get the list of required filament types that are not loaded on the printer.

+ 2 - 1
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

+ 85 - 0
backend/tests/integration/test_auth_api.py

@@ -689,3 +689,88 @@ class TestChangePasswordAPI:
         )
 
         assert response.status_code == 401
+
+
+class TestAuthMiddlewarePublicRoutes:
+    """Tests for auth middleware public route configuration.
+
+    These routes must be accessible without authentication, even when auth is enabled,
+    because browser elements like <img src> and <video src> don't send Authorization headers.
+    """
+
+    @pytest.fixture
+    async def enabled_auth(self, async_client: AsyncClient):
+        """Enable auth for testing middleware behavior."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "middlewareadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/auth/status is accessible without auth."""
+        response = await async_client.get("/api/v1/auth/status")
+        assert response.status_code == 200
+        assert "auth_enabled" in response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/auth/login is accessible without auth."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "middlewareadmin", "password": "adminpassword123"},
+        )
+        # Should not return 401 (unauthorized) - it should either succeed or return
+        # a different error (like 400 for wrong credentials)
+        assert response.status_code != 401 or "token" in response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_setup_is_public(self, async_client: AsyncClient):
+        """Verify /api/v1/auth/setup is accessible without auth (needed for setup/recovery)."""
+        # Don't enable auth first - test that setup endpoint itself is accessible
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": False},
+        )
+        # Should not be 401
+        assert response.status_code != 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_updates_version_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/updates/version is accessible without auth."""
+        response = await async_client.get("/api/v1/updates/version")
+        # Should not be 401
+        assert response.status_code != 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_protected_route_requires_auth(self, async_client: AsyncClient, enabled_auth):
+        """Verify non-public routes return 401 without token."""
+        response = await async_client.get("/api/v1/printers/")
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_protected_route_works_with_token(self, async_client: AsyncClient, enabled_auth):
+        """Verify non-public routes work with valid token."""
+        # Login to get token
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "middlewareadmin", "password": "adminpassword123"},
+        )
+        token = login_response.json()["access_token"]
+
+        # Access protected route
+        response = await async_client.get(
+            "/api/v1/printers/",
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        assert response.status_code == 200

+ 207 - 0
backend/tests/integration/test_library_api.py

@@ -848,3 +848,210 @@ class TestLibraryPathHelpers:
 
         assert to_absolute_path(None) is None
         assert to_absolute_path("") is None
+
+
+class TestLibraryPermissions:
+    """Tests for library permission enforcement."""
+
+    @pytest.fixture
+    async def auth_setup(self, db_session):
+        """Set up auth with users of different permission levels."""
+        from backend.app.core.auth import create_access_token, get_password_hash
+        from backend.app.models.group import Group
+        from backend.app.models.settings import Settings
+        from backend.app.models.user import User
+
+        # Enable auth
+        settings = Settings(key="auth_enabled", value="true")
+        db_session.add(settings)
+        await db_session.commit()
+
+        # Groups are auto-seeded during db init, but we need to commit them
+        await db_session.commit()
+
+        # Get groups
+        from sqlalchemy import select
+
+        admin_group = (await db_session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+        operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
+        viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
+
+        password_hash = get_password_hash("password")
+
+        # Create users
+        admin_user = User(username="admin_lib", password_hash=password_hash, role="admin", is_active=True)
+        admin_user.groups.append(admin_group)
+
+        operator_user = User(username="operator_lib", password_hash=password_hash, is_active=True)
+        operator_user.groups.append(operator_group)
+
+        viewer_user = User(username="viewer_lib", password_hash=password_hash, is_active=True)
+        viewer_user.groups.append(viewer_group)
+
+        db_session.add_all([admin_user, operator_user, viewer_user])
+        await db_session.commit()
+
+        # Create tokens
+        admin_token = create_access_token(data={"sub": admin_user.username})
+        operator_token = create_access_token(data={"sub": operator_user.username})
+        viewer_token = create_access_token(data={"sub": viewer_user.username})
+
+        return {
+            "admin_user": admin_user,
+            "operator_user": operator_user,
+            "viewer_user": viewer_user,
+            "admin_token": admin_token,
+            "operator_token": operator_token,
+            "viewer_token": viewer_token,
+        }
+
+    @pytest.fixture
+    async def test_file(self, db_session, auth_setup):
+        """Create a test file owned by the operator user."""
+        from backend.app.models.library import LibraryFile
+
+        operator_user = auth_setup["operator_user"]
+        lib_file = LibraryFile(
+            filename="test.txt",
+            file_path="data/archive/library/files/test.txt",
+            file_type="txt",
+            file_size=100,
+            created_by_id=operator_user.id,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+        return lib_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):
+        """Verify list_files requires library:read permission."""
+        viewer_token = auth_setup["viewer_token"]
+
+        # Viewers have library:read, should succeed
+        response = await async_client.get("/api/v1/library/files", headers={"Authorization": f"Bearer {viewer_token}"})
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):
+        """Verify list_files denied without auth when auth is enabled."""
+        from backend.app.models.settings import Settings
+
+        # Enable auth
+        settings = Settings(key="auth_enabled", value="true")
+        db_session.add(settings)
+        await db_session.commit()
+
+        # Request without token should fail
+        response = await async_client.get("/api/v1/library/files")
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):
+        """Verify operator can delete their own files."""
+        from pathlib import Path
+
+        # Create actual file on disk so delete doesn't fail
+        from backend.app.core.config import settings as app_settings
+
+        file_path = Path(app_settings.base_dir) / test_file.file_path
+        file_path.parent.mkdir(parents=True, exist_ok=True)
+        file_path.write_text("test content")
+
+        operator_token = auth_setup["operator_token"]
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):
+        """Verify operator cannot delete files owned by others."""
+        # Create another operator user with a file
+        from sqlalchemy import select
+
+        from backend.app.core.auth import create_access_token
+        from backend.app.models.group import Group
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.user import User
+
+        operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
+
+        from backend.app.core.auth import get_password_hash as get_pw_hash
+
+        other_user = User(username="other_op", password_hash=get_pw_hash("password"), is_active=True)
+        other_user.groups.append(operator_group)
+        db_session.add(other_user)
+        await db_session.commit()
+        await db_session.refresh(other_user)
+
+        # Create file owned by other user
+        other_file = LibraryFile(
+            filename="other.txt",
+            file_path="data/archive/library/files/other.txt",
+            file_type="txt",
+            file_size=100,
+            created_by_id=other_user.id,
+        )
+        db_session.add(other_file)
+        await db_session.commit()
+        await db_session.refresh(other_file)
+
+        # Original operator should not be able to delete it
+        operator_token = auth_setup["operator_token"]
+        response = await async_client.delete(
+            f"/api/v1/library/files/{other_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
+        )
+        assert response.status_code == 403
+        assert "your own files" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):
+        """Verify admin can delete any file."""
+        from pathlib import Path
+
+        from backend.app.core.config import settings as app_settings
+        from backend.app.models.library import LibraryFile
+
+        # Create file owned by operator
+        operator_user = auth_setup["operator_user"]
+        lib_file = LibraryFile(
+            filename="admin_can_delete.txt",
+            file_path="data/archive/library/files/admin_can_delete.txt",
+            file_type="txt",
+            file_size=100,
+            created_by_id=operator_user.id,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        # Create actual file on disk
+        file_path = Path(app_settings.base_dir) / lib_file.file_path
+        file_path.parent.mkdir(parents=True, exist_ok=True)
+        file_path.write_text("test content")
+
+        # Admin should be able to delete it
+        admin_token = auth_setup["admin_token"]
+        response = await async_client.delete(
+            f"/api/v1/library/files/{lib_file.id}", headers={"Authorization": f"Bearer {admin_token}"}
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):
+        """Verify viewer cannot delete any files."""
+        viewer_token = auth_setup["viewer_token"]
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {viewer_token}"}
+        )
+        # Viewers don't have delete_own or delete_all permissions
+        assert response.status_code == 403

+ 8 - 2
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

+ 213 - 0
backend/tests/integration/test_print_queue_api.py

@@ -963,3 +963,216 @@ class TestBulkUpdateEndpoint:
         )
         assert response.status_code == 400
         assert "printer not found" in response.json()["detail"].lower()
+
+
+class TestTargetLocationFeature:
+    """Tests for queue items with target_location (Issue #220)."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Location Test Printer {counter}",
+                "ip_address": f"192.168.1.{50 + counter}",
+                "serial_number": f"TESTLOC{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"location_test_{counter}.3mf",
+                "print_name": f"Location Test Print {counter}",
+                "file_path": f"/tmp/location_test_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"lochash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+        _counter = [0]
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            if "printer_id" not in kwargs and "target_model" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": counter,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_target_location(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify item can be added with target_model and target_location."""
+        # Create a printer with model X1C so the API can validate
+        await printer_factory(model="X1C", location="Office")
+        archive = await archive_factory()
+
+        data = {
+            "target_model": "X1C",
+            "target_location": "Workbench",
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_model"] == "X1C"
+        assert result["target_location"] == "Workbench"
+        assert result["printer_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_location_without_model_ignored(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify target_location without target_model is allowed (location is just ignored)."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "target_location": "Workbench",  # This gets ignored since printer_id is set
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        # The API accepts this but the location is only used with target_model
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        # Location may or may not be stored since it's meaningless without target_model
+        # The important thing is the request succeeds
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_item_target_location_in_response(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Verify target_location is returned in queue item response."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Workshop",
+        )
+
+        response = await async_client.get(f"/api/v1/queue/{item.id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_model"] == "X1C"
+        assert result["target_location"] == "Workshop"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_list_includes_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location is included in queue list."""
+        await queue_item_factory(
+            printer_id=None,
+            target_model="P1S",
+            target_location="Garage",
+        )
+
+        response = await async_client.get("/api/v1/queue/")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) >= 1
+
+        # Find our item
+        our_item = next((i for i in items if i["target_location"] == "Garage"), None)
+        assert our_item is not None
+        assert our_item["target_model"] == "P1S"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location can be updated on existing queue item."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Office",
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"target_location": "Basement"},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_location"] == "Basement"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location can be cleared (set to None)."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Office",
+        )
+
+        # Note: Setting to empty string should clear it
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"target_location": None},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_location"] is None

+ 1 - 1
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,

+ 4 - 9
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(() => {

+ 3 - 0
frontend/src/api/client.ts

@@ -1067,6 +1067,7 @@ export interface PrintQueueItem {
   id: number;
   printer_id: number | null;  // null = unassigned
   target_model: string | null;  // Target printer model for model-based assignment
+  target_location: string | null;  // Target location filter for model-based assignment
   required_filament_types: string[] | null;  // Required filament types for model-based assignment
   waiting_reason: string | null;  // Why a model-based job hasn't started yet
   // Either archive_id OR library_file_id must be set (archive created at print start)
@@ -1105,6 +1106,7 @@ export interface PrintQueueItem {
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
+  target_location?: string | null;  // Target location filter (only used with target_model)
   // Either archive_id OR library_file_id must be provided
   archive_id?: number | null;
   library_file_id?: number | null;
@@ -1126,6 +1128,7 @@ export interface PrintQueueItemCreate {
 export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
+  target_location?: string | null;  // Target location filter (only used with target_model)
   position?: number;
   scheduled_time?: string | null;
   require_previous_success?: boolean;

+ 69 - 5
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -38,6 +38,10 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
   targetModel?: string | null;
   /** Handler for target model change */
   onTargetModelChange?: (model: string | null) => void;
+  /** Selected target location (when assignmentMode is 'model') */
+  targetLocation?: string | null;
+  /** Handler for target location change */
+  onTargetLocationChange?: (location: string | null) => void;
   /** Suggested model from sliced file (for pre-selection) */
   slicedForModel?: string | null;
 }
@@ -227,6 +231,8 @@ export function PrinterSelector({
   onAssignmentModeChange,
   targetModel,
   onTargetModelChange,
+  targetLocation,
+  onTargetLocationChange,
   slicedForModel,
 }: PrinterSelectorWithMappingProps) {
   // State for showing all printers vs only matching model
@@ -257,6 +263,16 @@ export function PrinterSelector({
     return [...new Set(models)].sort();
   }, [activePrinters]);
 
+  // Get unique locations for the selected target model (for location filtering)
+  const uniqueLocations = useMemo(() => {
+    if (!targetModel) return [];
+    const locations = activePrinters
+      .filter(p => p.model === targetModel && p.location)
+      .map(p => p.location)
+      .filter((l): l is string => Boolean(l));
+    return [...new Set(locations)].sort();
+  }, [activePrinters, targetModel]);
+
   // Check if model-based assignment is available (need callbacks and multiple printers of same model)
   const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
 
@@ -370,11 +386,59 @@ export function PrinterSelector({
         </div>
       )}
 
-      {/* Model info (when in model mode) */}
-      {assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
-        <p className="text-xs text-bambu-gray mb-4">
-          Scheduler will assign to first available idle {targetModel} printer
-        </p>
+      {/* Model selection and location filter (when in model mode) */}
+      {assignmentMode === 'model' && modelAssignmentAvailable && (
+        <div className="space-y-3 mb-4">
+          {/* Model selector */}
+          <div>
+            <label className="block text-xs text-bambu-gray mb-1">Target Model</label>
+            <select
+              value={targetModel || ''}
+              onChange={(e) => {
+                onTargetModelChange!(e.target.value || null);
+                // Clear location when model changes
+                if (onTargetLocationChange) {
+                  onTargetLocationChange(null);
+                }
+              }}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+            >
+              <option value="">Select a model...</option>
+              {uniqueModels.map((model) => (
+                <option key={model} value={model}>
+                  {model}
+                </option>
+              ))}
+            </select>
+          </div>
+
+          {/* Location filter (only show when target model is selected and locations exist) */}
+          {targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (
+            <div>
+              <label className="block text-xs text-bambu-gray mb-1">Location Filter (optional)</label>
+              <select
+                value={targetLocation || ''}
+                onChange={(e) => onTargetLocationChange(e.target.value || null)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+              >
+                <option value="">Any location</option>
+                {uniqueLocations.map((location) => (
+                  <option key={location} value={location}>
+                    {location}
+                  </option>
+                ))}
+              </select>
+            </div>
+          )}
+
+          {/* Info text */}
+          {targetModel && (
+            <p className="text-xs text-bambu-gray">
+              Scheduler will assign to first available idle {targetModel} printer
+              {targetLocation ? ` in ${targetLocation}` : ''}
+            </p>
+          )}
+        </div>
       )}
 
       {/* Multi-select header (only in printer mode) */}

+ 13 - 0
frontend/src/components/PrintModal/index.tsx

@@ -135,6 +135,14 @@ export function PrintModal({
     return null;
   });
 
+  // Target location for model-based assignment (optional filter)
+  const [targetLocation, setTargetLocation] = useState<string | null>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.target_location) {
+      return queueItem.target_location;
+    }
+    return null;
+  });
+
   // Track initial values for clearing mappings on change (edit mode only)
   const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -369,6 +377,7 @@ export function PrintModal({
     const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
+      target_location: assignmentMode === 'model' ? targetLocation : null,
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -397,6 +406,7 @@ export function PrintModal({
           const updateData: PrintQueueItemUpdate = {
             printer_id: null,
             target_model: targetModel,
+            target_location: targetLocation,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
@@ -445,6 +455,7 @@ export function PrintModal({
             const updateData: PrintQueueItemUpdate = {
               printer_id: printerId,
               target_model: null,
+              target_location: null,
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               auto_off_after: scheduleOptions.autoOffAfter,
               manual_start: scheduleOptions.scheduleType === 'manual',
@@ -626,6 +637,8 @@ export function PrintModal({
               onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
               targetModel={targetModel}
               onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
+              targetLocation={targetLocation}
+              onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
               slicedForModel={slicedForModel}
             />
 

+ 4 - 0
frontend/src/components/PrintModal/types.ts

@@ -130,6 +130,10 @@ export interface PrinterSelectorProps {
   targetModel?: string | null;
   /** Handler for target model change */
   onTargetModelChange?: (model: string | null) => void;
+  /** Selected target location (when assignmentMode is 'model') */
+  targetLocation?: string | null;
+  /** Handler for target location change */
+  onTargetLocationChange?: (location: string | null) => void;
   /** Suggested model from sliced file (for pre-selection) */
   slicedForModel?: string | null;
 }

+ 13 - 1
frontend/src/pages/PrintersPage.tsx

@@ -3548,6 +3548,7 @@ function AddPrinterModal({
     ip_address: '',
     access_code: '',
     model: '',
+    location: '',
     auto_archive: true,
   });
 
@@ -3894,6 +3895,17 @@ function AddPrinterModal({
                 </optgroup>
               </select>
             </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Location / Group (optional)</label>
+              <input
+                type="text"
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                value={form.location || ''}
+                onChange={(e) => setForm({ ...form, location: e.target.value })}
+                placeholder="e.g., Workshop, Office, Basement"
+              />
+              <p className="text-xs text-bambu-gray mt-1">Used to group printers and filter queue jobs</p>
+            </div>
             <div className="flex items-center gap-2">
               <input
                 type="checkbox"
@@ -4265,7 +4277,7 @@ function EditPrinterModal({
                 onChange={(e) => setForm({ ...form, location: e.target.value })}
                 placeholder="e.g., Workshop, Office, Basement"
               />
-              <p className="text-xs text-bambu-gray mt-1">Used to group printers on the dashboard</p>
+              <p className="text-xs text-bambu-gray mt-1">Used to group printers and filter queue jobs</p>
             </div>
             <div className="flex items-center gap-2">
               <input

+ 62 - 7
frontend/src/pages/QueuePage.tsx

@@ -1,4 +1,4 @@
-import { useState, useMemo, useEffect } from 'react';
+import { useState, useMemo, useEffect, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
 import {
@@ -419,7 +419,7 @@ function SortableQueueItem({
             <span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
               <Printer className="w-3.5 h-3.5" />
               {item.target_model
-                ? `Any ${item.target_model}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
+                ? `Any ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
                 : item.printer_id === null
                   ? 'Unassigned'
                   : (item.printer_name || `Printer #${item.printer_id}`)}
@@ -579,6 +579,7 @@ export function QueuePage() {
   const { hasPermission, hasAnyPermission, canModify } = useAuth();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
+  const [filterLocation, setFilterLocation] = useState<string>('');
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
   const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
   const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
@@ -738,8 +739,39 @@ export function QueuePage() {
     );
   };
 
+  // Get unique locations from printers for the filter dropdown
+  const uniqueLocations = useMemo(() => {
+    const locations = new Set<string>();
+    printers?.forEach(p => {
+      if (p.location) locations.add(p.location);
+    });
+    // Also include locations from queue items (for model-based assignments)
+    queue?.forEach(item => {
+      if (item.target_location) locations.add(item.target_location);
+    });
+    return Array.from(locations).sort();
+  }, [printers, queue]);
+
+  // Helper to check if a queue item matches the location filter
+  const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
+    if (!filterLocation) return true;
+    // For model-based assignments, check target_location
+    if (item.target_location) return item.target_location === filterLocation;
+    // For printer-based assignments, check the printer's location
+    if (item.printer_id) {
+      const printer = printers?.find(p => p.id === item.printer_id);
+      return printer?.location === filterLocation;
+    }
+    return false;
+  }, [filterLocation, printers]);
+
   const pendingItems = useMemo(() => {
-    const items = queue?.filter(i => i.status === 'pending') || [];
+    let items = queue?.filter(i => i.status === 'pending') || [];
+
+    // Apply location filter
+    if (filterLocation) {
+      items = items.filter(matchesLocationFilter);
+    }
 
     // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
     const getScheduledTime = (item: PrintQueueItem): number => {
@@ -766,7 +798,7 @@ export function QueuePage() {
       }
       return pendingSortAsc ? cmp : -cmp;
     });
-  }, [queue, pendingSortBy, pendingSortAsc]);
+  }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation]);
 
   const handleSelectAll = () => {
     const allPendingIds = pendingItems.map(i => i.id);
@@ -777,9 +809,19 @@ export function QueuePage() {
     }
   };
 
-  const activeItems = queue?.filter(i => i.status === 'printing') || [];
+  const activeItems = useMemo(() => {
+    let items = queue?.filter(i => i.status === 'printing') || [];
+    if (filterLocation) {
+      items = items.filter(matchesLocationFilter);
+    }
+    return items;
+  }, [queue, filterLocation, matchesLocationFilter]);
+
   const historyItems = useMemo(() => {
-    const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
+    let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
+    if (filterLocation) {
+      items = items.filter(matchesLocationFilter);
+    }
     return [...items].sort((a, b) => {
       let cmp: number;
       if (historySortBy === 'name') {
@@ -794,7 +836,7 @@ export function QueuePage() {
       }
       return historySortAsc ? -cmp : cmp;
     });
-  }, [queue, historySortBy, historySortAsc]);
+  }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
 
   // Calculate total queue time
   const totalQueueTime = useMemo(() => {
@@ -923,6 +965,19 @@ export function QueuePage() {
           <option value="cancelled">Cancelled</option>
         </select>
 
+        {uniqueLocations.length > 0 && (
+          <select
+            className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            value={filterLocation}
+            onChange={(e) => setFilterLocation(e.target.value)}
+          >
+            <option value="">All Locations</option>
+            {uniqueLocations.map((loc) => (
+              <option key={loc} value={loc}>{loc}</option>
+            ))}
+          </select>
+        )}
+
         <div className="flex-1" />
 
         {historyItems.length > 0 && (

+ 12 - 0
frontend/vitest.config.ts

@@ -7,6 +7,18 @@ export default defineConfig({
   test: {
     globals: true,
     environment: 'jsdom',
+    pool: 'threads',
+    poolOptions: {
+      threads: {
+        maxThreads: 14,
+        minThreads: 4,
+      },
+    },
+    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'],

File diff suppressed because it is too large
+ 0 - 0
icons/5f21bc794a4e4521b72c6564029ed5d9.svg


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D-vJDFzo.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-1q7Yxq-H.js"></script>
+    <script type="module" crossorigin src="/assets/index-D-vJDFzo.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff