Browse Source

Security: add token-based auth for all media endpoints

  Camera streams, snapshots, thumbnails, timelapse videos, photos, QR
  codes, and cover images served via <img>/<video> tags were previously
  unauthenticated because browser media elements cannot send Authorization
  headers. When auth is enabled, these endpoints are now protected by a
  reusable stream token (?token=xxx) obtained from POST
  /printers/camera/stream-token (requires CAMERA_VIEW permission).
maziggy 2 months ago
parent
commit
3887938e8f

+ 3 - 0
CHANGELOG.md

@@ -30,6 +30,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy System Tab** — Added a "System" tab to SpoolBuddy Settings showing live OS stats from the Raspberry Pi: CPU temperature, core count, load average, memory usage, disk usage, OS distro/kernel/architecture, Python version, and system uptime. Stats are collected by the daemon every heartbeat (10s) using stdlib-only reads from `/proc` and `/sys` — no additional dependencies required. Usage bars turn amber at 70% and red at 90%; CPU temperature is color-coded green/amber/red.
 - **SpoolBuddy Boot Splash Polished** — New splash image displays only the SpoolBuddy logo (removed Bambuddy branding) with green glow bloom, radial gradient background, light rays, and vignette. A generator script (`generate_splash.py`) is included for easy customization. Also reduced redundant initramfs rebuilds during install by deferring the rebuild until after the Plymouth theme is configured.
 
+### Security
+- **Token-Based Auth for Media Endpoints** — Camera streams, snapshots, thumbnails, timelapse videos, photos, QR codes, and cover images served via `<img>`/`<video>` tags now require a stream token query parameter (`?token=xxx`) when authentication is enabled. Previously these endpoints were unauthenticated because browser media elements cannot send `Authorization` headers. The frontend obtains a 60-minute reusable token via `POST /printers/camera/stream-token` (requires `CAMERA_VIEW` permission) and automatically appends it to all media URLs. Affects endpoints in camera, archives, library, printers, print-log, and external-links routes. When auth is disabled (default for local installs), behavior is unchanged — no token required. Reported by Sacha Vaudey via security email.
+
 ### Fixed
 - **Print Fails on Files With Spaces in Name** ([#824](https://github.com/maziggy/bambuddy/issues/824)) — Printing files with spaces in their filename (e.g. "Junktion Box PRO 90.3mf") caused the printer to silently ignore the print command and remain IDLE. The FTP upload succeeded, but the MQTT print command's `url` field (`ftp://file name.3mf`) contained unencoded spaces that the firmware couldn't parse. Fixed by replacing spaces with underscores in the remote filename before upload. Reported by @benjamdev.
 - **SpoolBuddy Low Filament Warning Missing Slot Number** — The status bar low filament warning showed "AMS B" instead of the specific slot like "B2". Now uses `formatSlotLabel` to display the full slot label (e.g. "Low Filament: PLA (B2) - 4% remaining").

+ 15 - 7
backend/app/api/routes/archives.py

@@ -13,6 +13,7 @@ from sqlalchemy import and_, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     require_ownership_permission,
 )
@@ -1385,10 +1386,11 @@ async def download_archive_for_slicer(
 async def get_thumbnail(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the thumbnail image.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -1416,10 +1418,11 @@ async def get_thumbnail(
 async def get_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the timelapse video.
 
-    Note: Unauthenticated - loaded via <video> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2047,10 +2050,11 @@ async def get_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get a specific photo.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
@@ -2118,10 +2122,11 @@ async def get_qrcode(
     request: Request,
     size: int = 200,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Generate a QR code that links to this archive.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     try:
         import qrcode
@@ -2430,13 +2435,14 @@ async def get_gcode(
 async def get_plate_preview(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the plate preview image from the 3MF file.
 
     Returns the slicer-generated plate thumbnail which shows the model
     with correct colors and positioning.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2862,10 +2868,11 @@ async def get_plate_thumbnail(
     archive_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the thumbnail image for a specific plate.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -3176,10 +3183,11 @@ async def get_project_image(
     archive_id: int,
     image_path: str,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get an image from the 3MF project page.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     from backend.app.services.archive import ProjectPageParser
 

+ 23 - 4
backend/app/api/routes/camera.py

@@ -11,7 +11,11 @@ from fastapi.responses import Response, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
+    RequirePermissionIfAuthEnabled,
+    create_camera_stream_token,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
@@ -478,19 +482,32 @@ async def generate_rtsp_mjpeg_stream(
         await proxy_server.wait_closed()
 
 
+@router.post("/camera/stream-token")
+async def create_stream_token(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+):
+    """Create a reusable token for camera stream/snapshot access.
+
+    Returns a token valid for 60 minutes that can be appended as ?token=xxx
+    to camera stream/snapshot URLs loaded via <img> tags.
+    """
+    return {"token": create_camera_stream_token()}
+
+
 @router.get("/{printer_id}/camera/stream")
 async def camera_stream(
     printer_id: int,
     request: Request,
     fps: int = 10,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Stream live video from printer camera as MJPEG.
 
     This endpoint returns a multipart MJPEG stream that can be used directly
     in an <img> tag or video player.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
 
     Uses external camera if configured, otherwise uses built-in camera:
     - External: MJPEG, RTSP, or HTTP snapshot
@@ -720,12 +737,13 @@ async def stop_camera_stream(
 async def camera_snapshot(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Capture a single frame from the printer camera.
 
     Returns a JPEG image.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     import tempfile
     from pathlib import Path
@@ -1198,10 +1216,11 @@ async def get_reference_thumbnail(
     printer_id: int,
     index: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get thumbnail image for a calibration reference.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     from fastapi.responses import Response
 

+ 3 - 2
backend/app/api/routes/external_links.py

@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -239,10 +239,11 @@ async def delete_icon(
 async def get_icon(
     link_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the custom icon for an external link.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()

+ 7 - 1
backend/app/api/routes/library.py

@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
     require_ownership_permission,
     require_permission_if_auth_enabled,
 )
@@ -1871,6 +1872,7 @@ async def get_library_file_plate_thumbnail(
     file_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the thumbnail image for a specific plate from a library file."""
     from starlette.responses import Response
@@ -2397,7 +2399,11 @@ async def download_library_file_for_slicer(
 
 
 @router.get("/files/{file_id}/thumbnail")
-async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
+async def get_thumbnail(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
+):
     """Get a file's thumbnail."""
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()

+ 3 - 2
backend/app/api/routes/print_log.py

@@ -6,7 +6,7 @@ from fastapi.responses import FileResponse
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -92,10 +92,11 @@ async def get_print_log(
 async def get_print_log_thumbnail(
     entry_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the thumbnail for a print log entry.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     entry = await db.get(PrintLogEntry, entry_id)
     if not entry or not entry.thumbnail_path:

+ 2 - 2
backend/app/api/routes/printers.py

@@ -8,7 +8,7 @@ from fastapi.responses import Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -714,8 +714,8 @@ async def get_printer_cover(
     printer_id: int,
     view: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
-    # Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
     """Get the cover image for the current print job.
 
     Args:

+ 56 - 0
backend/app/core/auth.py

@@ -136,6 +136,38 @@ def verify_slicer_download_token(token: str, resource_type: str, resource_id: in
     return True
 
 
+# --- Camera stream tokens ---
+# Reusable tokens for camera stream/snapshot endpoints loaded via <img> tags.
+# Unlike slicer tokens, these are NOT single-use (streams reconnect on errors)
+# and have a longer expiry. Maps token → expiry.
+_camera_stream_tokens: dict[str, datetime] = {}
+CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
+
+
+def create_camera_stream_token() -> str:
+    """Create a reusable token for camera stream/snapshot access."""
+    now = datetime.now(timezone.utc)
+    # Cleanup expired tokens
+    expired = [k for k, exp in _camera_stream_tokens.items() if exp < now]
+    for k in expired:
+        del _camera_stream_tokens[k]
+
+    token = secrets.token_urlsafe(24)
+    _camera_stream_tokens[token] = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
+    return token
+
+
+def verify_camera_stream_token(token: str) -> bool:
+    """Verify a camera stream token is valid."""
+    expiry = _camera_stream_tokens.get(token)
+    if not expiry:
+        return False
+    if datetime.now(timezone.utc) > expiry:
+        del _camera_stream_tokens[token]
+        return False
+    return True
+
+
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.
 
@@ -705,6 +737,30 @@ def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     return Depends(require_permission_if_auth_enabled(*permissions))
 
 
+def require_camera_stream_token_if_auth_enabled():
+    """Dependency that validates a camera stream token query param when auth is enabled.
+
+    Used for camera stream/snapshot endpoints that are loaded via <img> tags
+    which cannot send Authorization headers. The frontend obtains a token from
+    POST /printers/camera/stream-token and appends it as ?token=xxx.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not verify_camera_stream_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
+            )
+
+    return checker
+
+
+RequireCameraStreamTokenIfAuthEnabled = Depends(require_camera_stream_token_if_auth_enabled())
+
+
 def require_ownership_permission(
     all_permission: str | Permission,
     own_permission: str | Permission,

+ 7 - 0
frontend/src/App.tsx

@@ -21,6 +21,7 @@ import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
 import { NotificationsPage } from './pages/NotificationsPage';
 import { useWebSocket } from './hooks/useWebSocket';
+import { useStreamTokenSync } from './hooks/useCameraStreamToken';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
@@ -40,6 +41,11 @@ const queryClient = new QueryClient({
   },
 });
 
+function StreamTokenSync() {
+  useStreamTokenSync();
+  return null;
+}
+
 function WebSocketProvider({ children }: { children: React.ReactNode }) {
   useWebSocket();
   return <>{children}</>;
@@ -107,6 +113,7 @@ function App() {
       <ToastProvider>
         <QueryClientProvider client={queryClient}>
           <AuthProvider>
+            <StreamTokenSync />
             <BrowserRouter>
               <Routes>
                 {/* Setup page - only accessible if auth not enabled */}

+ 37 - 17
frontend/src/api/client.ts

@@ -18,6 +18,25 @@ export function getAuthToken(): string | null {
   return authToken;
 }
 
+// Stream token for image/video URLs loaded via <img>/<video> tags
+// (these can't send Authorization headers, so a query param token is used)
+let streamToken: string | null = null;
+
+export function setStreamToken(token: string | null) {
+  streamToken = token;
+}
+
+export function getStreamToken(): string | null {
+  return streamToken;
+}
+
+/** Append the stream token to a URL if available (for <img>/<video> src). */
+export function withStreamToken(url: string): string {
+  if (!streamToken) return url;
+  const sep = url.includes('?') ? '&' : '?';
+  return `${url}${sep}token=${encodeURIComponent(streamToken)}`;
+}
+
 function parseContentDispositionFilename(header: string | null): string | null {
   if (!header) return null;
   // RFC 5987: filename*=utf-8''percent-encoded-name
@@ -2530,7 +2549,7 @@ export const api = {
       is_multi_plate: boolean;
     }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
   getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
-    `${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`,
+    withStreamToken(`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`),
   downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
     const headers: Record<string, string> = {};
     if (authToken) {
@@ -2748,9 +2767,9 @@ export const api = {
     request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', {
       method: 'POST',
     }),
-  getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
+  getArchiveThumbnail: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`),
   getArchivePlateThumbnail: (id: number, plateIndex: number) =>
-    `${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`,
+    withStreamToken(`${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`),
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   downloadArchive: async (id: number, filename?: string): Promise<void> => {
     const headers: Record<string, string> = {};
@@ -2775,8 +2794,8 @@ export const api = {
     window.URL.revokeObjectURL(url);
   },
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
-  getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
-  getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
+  getArchivePlatePreview: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/plate-preview`),
+  getArchiveTimelapse: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`),
   scanArchiveTimelapse: (id: number) =>
     request<{
       status: string;
@@ -2870,7 +2889,7 @@ export const api = {
   },
   // Photos
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
-    `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,
+    withStreamToken(`${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`),
   uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -3001,7 +3020,7 @@ export const api = {
 
   // QR Code
   getArchiveQRCodeUrl: (archiveId: number, size = 200) =>
-    `${API_BASE}/archives/${archiveId}/qrcode?size=${size}`,
+    withStreamToken(`${API_BASE}/archives/${archiveId}/qrcode?size=${size}`),
   getArchiveCapabilities: (id: number) =>
     request<{
       has_model: boolean;
@@ -3048,7 +3067,7 @@ export const api = {
       body: JSON.stringify(data),
     }),
   getArchiveProjectImageUrl: (archiveId: number, imagePath: string) =>
-    `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
+    withStreamToken(`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`),
   getArchiveForSlicer: (id: number, filename: string) => {
     const safe = filename.replace(/[/\\?#]/g, '_');
     return `${API_BASE}/archives/${id}/file/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;
@@ -3162,7 +3181,7 @@ export const api = {
     if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));
     return request<PrintLogResponse>(`/print-log/?${searchParams}`);
   },
-  getPrintLogThumbnail: (id: number) => `${API_BASE}/print-log/${id}/thumbnail`,
+  getPrintLogThumbnail: (id: number) => withStreamToken(`${API_BASE}/print-log/${id}/thumbnail`),
   clearPrintLog: () =>
     request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
 
@@ -3767,10 +3786,12 @@ export const api = {
     }),
 
   // Camera
+  getCameraStreamToken: () =>
+    request<{ token: string }>('/printers/camera/stream-token', { method: 'POST' }),
   getCameraStreamUrl: (printerId: number, fps = 10) =>
-    `${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`,
+    withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
   getCameraSnapshotUrl: (printerId: number) =>
-    `${API_BASE}/printers/${printerId}/camera/snapshot`,
+    withStreamToken(`${API_BASE}/printers/${printerId}/camera/snapshot`),
   testCameraConnection: (printerId: number) =>
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
   getCameraStatus: (printerId: number) =>
@@ -3811,9 +3832,8 @@ export const api = {
       max_references: number;
     }>(`/printers/${printerId}/camera/plate-detection/references`);
   },
-  getPlateReferenceThumbnailUrl: (printerId: number, index: number) => {
-    return `${API_BASE}/printers/${printerId}/camera/plate-detection/references/${index}/thumbnail`;
-  },
+  getPlateReferenceThumbnailUrl: (printerId: number, index: number) =>
+    withStreamToken(`${API_BASE}/printers/${printerId}/camera/plate-detection/references/${index}/thumbnail`),
   updatePlateReferenceLabel: (printerId: number, index: number, label: string) => {
     const params = new URLSearchParams();
     params.set('label', label);
@@ -3869,7 +3889,7 @@ export const api = {
   },
   deleteExternalLinkIcon: (id: number) =>
     request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
-  getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`,
+  getExternalLinkIconUrl: (id: number) => withStreamToken(`${API_BASE}/external-links/${id}/icon`),
 
   // Projects
   getProjects: (status?: string) => {
@@ -4170,9 +4190,9 @@ export const api = {
     document.body.removeChild(a);
     window.URL.revokeObjectURL(url);
   },
-  getLibraryFileThumbnailUrl: (id: number) => `${API_BASE}/library/files/${id}/thumbnail`,
+  getLibraryFileThumbnailUrl: (id: number) => withStreamToken(`${API_BASE}/library/files/${id}/thumbnail`),
   getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
-    `${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`,
+    withStreamToken(`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`),
   getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
   moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
     request<{ status: string; moved: number }>('/library/files/move', {

+ 2 - 2
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
-import { api, getAuthToken } from '../api/client';
+import { api, getAuthToken, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { ChamberLight } from './icons/ChamberLight';
@@ -550,7 +550,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     }
   }, [isDragging, isResizing, dragOffset]);
 
-  const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`;
+  const streamUrl = withStreamToken(`/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`);
 
   return (
     <div

+ 2 - 2
frontend/src/components/SkipObjectsModal.tsx

@@ -2,7 +2,7 @@ import { useState } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Monitor, AlertCircle, Box, Maximize2 } from 'lucide-react';
-import { api } from '../api/client';
+import { api, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { ConfirmModal } from './ConfirmModal';
@@ -136,7 +136,7 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
                 <div className="relative cursor-pointer group" onClick={() => setEnlarged(true)}>
                   {status?.cover_url ? (
                     <img
-                      src={`${status.cover_url}?view=top`}
+                      src={withStreamToken(`${status.cover_url}?view=top`)}
                       alt={t('printers.printPreview')}
                       className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
                     />

+ 37 - 0
frontend/src/hooks/useCameraStreamToken.ts

@@ -0,0 +1,37 @@
+import { useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { api, setStreamToken, withStreamToken } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+
+/**
+ * Fetches and caches a stream token for <img>/<video> src URLs.
+ * Stores the token globally via setStreamToken() so URL generators
+ * in client.ts can use withStreamToken() automatically.
+ *
+ * Mount this hook once near the app root (e.g., in App.tsx or a layout component).
+ * Components that need token-protected URLs can import withStreamToken directly.
+ */
+export function useStreamTokenSync() {
+  const { authEnabled } = useAuth();
+
+  const { data } = useQuery({
+    queryKey: ['camera-stream-token'],
+    queryFn: () => api.getCameraStreamToken(),
+    enabled: authEnabled,
+    staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
+    refetchInterval: 50 * 60 * 1000,
+  });
+
+  useEffect(() => {
+    setStreamToken(data?.token ?? null);
+    return () => setStreamToken(null);
+  }, [data?.token]);
+}
+
+/**
+ * Hook for components that need to wrap URLs with the stream token.
+ * Returns a withToken function that appends ?token=xxx when auth is enabled.
+ */
+export function useCameraStreamToken() {
+  return { withToken: withStreamToken };
+}

+ 3 - 3
frontend/src/pages/CameraPage.tsx

@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
-import { api, getAuthToken } from '../api/client';
+import { api, getAuthToken, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
@@ -577,8 +577,8 @@ export function CameraPage() {
   const currentUrl = transitioning
     ? ''
     : streamMode === 'stream'
-      ? `/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`
-      : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
+      ? withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`)
+      : withStreamToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
 
   const isDisabled = streamLoading || transitioning || isReconnecting;
 

+ 3 - 3
frontend/src/pages/FileManagerPage.tsx

@@ -716,7 +716,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
         {file.thumbnail_path ? (
           <img
-            src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? `?v=${thumbnailVersion}` : ''}`}
+            src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersion}`) : ''}`}
             alt={file.filename}
             className="w-full h-full object-cover"
           />
@@ -1925,7 +1925,7 @@ export function FileManagerPage() {
                         <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
                           {file.thumbnail_path ? (
                             <img
-                              src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
+                              src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersions[file.id]}`) : ''}`}
                               alt=""
                               className="w-full h-full object-cover"
                             />
@@ -1940,7 +1940,7 @@ export function FileManagerPage() {
                           <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
                             <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
                               <img
-                                src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
+                                src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersions[file.id]}`) : ''}`}
                                 alt={file.filename}
                                 className="w-full h-full object-contain"
                               />

+ 2 - 2
frontend/src/pages/PrintersPage.tsx

@@ -50,7 +50,7 @@ import {
 } from 'lucide-react';
 
 import { useNavigate } from 'react-router-dom';
-import { api, discoveryApi, firmwareApi } from '../api/client';
+import { api, discoveryApi, firmwareApi, withStreamToken } from '../api/client';
 import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError } from '../api/client';
 import { Card, CardContent } from '../components/Card';
@@ -1037,7 +1037,7 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
   const cacheBustedUrl = useMemo(() => {
     if (!url) return null;
     const sep = url.includes('?') ? '&' : '?';
-    return `${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`;
+    return withStreamToken(`${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`);
   }, [url, printName]);
 
   // Reset loaded/error state when the image URL changes

+ 2 - 2
frontend/src/pages/StreamOverlayPage.tsx

@@ -3,7 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
-import { api } from '../api/client';
+import { api, withStreamToken } from '../api/client';
 import type { PrinterStatus } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 
@@ -191,7 +191,7 @@ export function StreamOverlayPage() {
 
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   const progress = status.progress || 0;
-  const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
+  const streamUrl = withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`);
 
   return (
     <div className="min-h-screen bg-black relative overflow-hidden">

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BJ5z52r8.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-zs_wImoT.js"></script>
+    <script type="module" crossorigin src="/assets/index-BJ5z52r8.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DWwO6mIe.css">
   </head>
   <body>

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