Browse Source

fix(camera): restore new-window camera view with auth enabled

  Two root causes in the "Camera View Mode = Window" path when auth is on (#979):

  1. PrintersPage opened the popup with `noopener`, which severed the opener
     link and prevented the browser from copying sessionStorage (auth token)
     into the new window. The popup booted unauthenticated, POST
     /printers/camera/stream-token returned 401, and the <img> src went out
     with no ?token=. The backend's RequireCameraStreamTokenIfAuthEnabled
     then rejected every frame with "Valid camera stream token required".
  2. CameraPage computed its stream URL from the module-level stream-token
     cache in withStreamToken(). That cache is populated by a useEffect in
     useStreamTokenSync that runs after render, so even after the token
     resolved the first post-arrival render still produced a tokenless URL
     and nothing triggered another render.

  Fix:
  - Drop `noopener` from the camera popup features (same-origin, trusted).
  - Subscribe CameraPage to the `camera-stream-token` React Query so the
    page re-renders the moment the token arrives.
  - Gate currentUrl on `waitingForStreamToken` and append the token directly
    from the reactive query value instead of the effect-synced module cache.

  Embedded overlay mode was unaffected. Added CameraPage tests covering both
  the auth-enabled (token required, src empty until it arrives, then includes
  ?token=) and auth-disabled (src rendered immediately without token) paths.
maziggy 1 month ago
parent
commit
62950e37c8

+ 1 - 0
CHANGELOG.md

@@ -23,6 +23,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Plate-Clear Confirmation Disabled by Default** — New installs ship with Settings → Workflow → "Require Plate-Clear Confirmation" off. Multiple new users reported queued prints appearing to not start because the prompt was waiting for acknowledgement; opt in from Workflow if you want the confirmation gate.
 - **Plate-Clear Confirmation Disabled by Default** — New installs ship with Settings → Workflow → "Require Plate-Clear Confirmation" off. Multiple new users reported queued prints appearing to not start because the prompt was waiting for acknowledgement; opt in from Workflow if you want the confirmation gate.
 
 
 ### Fixed
 ### Fixed
+- **Camera Popup Shows "Valid camera stream token required" With Auth Enabled** ([#979](https://github.com/maziggy/bambuddy/issues/979)) — When Camera View Mode was set to "Window" and authentication was enabled, clicking the camera button opened a popup that immediately failed with `"Valid camera stream token required"`, while the embedded overlay kept working. Two root causes: (1) `window.open(...)` passed `noopener` in the popup features, which severed the opener link and prevented the browser from copying sessionStorage (where the auth token lives) into the popup — so the new window booted unauthenticated and the `POST /printers/camera/stream-token` fetch returned 401, leaving the `<img>` src without the required `?token=` query param; (2) even once the token arrived, `CameraPage` computed its URL from the module-level stream-token cache on render and never re-rendered when the cache was updated in a `useEffect`, so the first paint locked in a tokenless URL that the backend kept rejecting. Fixed by dropping `noopener` from the camera popup features (same-origin, trusted window) so sessionStorage is inherited, subscribing `CameraPage` to the `camera-stream-token` React Query so it re-renders the moment the token resolves, and appending the token directly from the reactive query value instead of the effect-synced module cache — the `<img>` src stays empty until the token is ready, so no tokenless request ever leaves the popup. Embedded-overlay mode was unaffected. Thanks to @VREmma for the reproducer.
 - **Obico ML API Got 401 When Fetching Snapshot with Auth Enabled** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — The Obico failure-detection service handed the ML API container a snapshot URL (`/api/v1/printers/{id}/camera/snapshot`) for it to `GET` directly, but when Bambuddy authentication was enabled the endpoint returned 401 and the ML API surfaced "Failed to get image" (visible as a 400 from the ML API back to Bambuddy). The detection service now appends a short-lived camera-stream token to the snapshot URL — the same token scheme already used by `<img>`-based camera consumers, which the snapshot endpoint already accepts. The token is cached on the service and refreshed before its 60-minute expiry, so no extra per-call DB churn. When auth is disabled the token is simply ignored. Thanks to @fblix for reporting.
 - **Obico ML API Got 401 When Fetching Snapshot with Auth Enabled** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — The Obico failure-detection service handed the ML API container a snapshot URL (`/api/v1/printers/{id}/camera/snapshot`) for it to `GET` directly, but when Bambuddy authentication was enabled the endpoint returned 401 and the ML API surfaced "Failed to get image" (visible as a 400 from the ML API back to Bambuddy). The detection service now appends a short-lived camera-stream token to the snapshot URL — the same token scheme already used by `<img>`-based camera consumers, which the snapshot endpoint already accepts. The token is cached on the service and refreshed before its 60-minute expiry, so no extra per-call DB churn. When auth is disabled the token is simply ignored. Thanks to @fblix for reporting.
 - **Direct Print from Library Not Attributed to User** — Clicking the Print button on a library file dispatched the job with no `created_by_id`, so the resulting archive had no owner and the print didn't show up in per-user statistics. The Queue and Reprint paths already forwarded the authenticated user; the library `POST /files/{file_id}/print` endpoint now does the same, reading the user from the JWT and passing it through to the dispatcher so direct prints are attributed like queued and reprinted ones.
 - **Direct Print from Library Not Attributed to User** — Clicking the Print button on a library file dispatched the job with no `created_by_id`, so the resulting archive had no owner and the print didn't show up in per-user statistics. The Queue and Reprint paths already forwarded the authenticated user; the library `POST /files/{file_id}/print` endpoint now does the same, reading the user from the JWT and passing it through to the dispatcher so direct prints are attributed like queued and reprinted ones.
 - **Add/Edit Printer Modal Clipped on Short Viewports** ([#964](https://github.com/maziggy/bambuddy/issues/964)) — On short or zoomed-in browser windows, the Add Printer and Edit Printer dialogs exceeded the viewport height with no scroll, hiding the lower fields (Access Code, Model, Location) and the Save button. Users had to zoom the browser out to complete the form. The modal overlay now scrolls and the card caps at `calc(100vh - 2rem)` with internal overflow so every field stays reachable regardless of viewport height. Thanks to @MartinNYHC for reporting.
 - **Add/Edit Printer Modal Clipped on Short Viewports** ([#964](https://github.com/maziggy/bambuddy/issues/964)) — On short or zoomed-in browser windows, the Add Printer and Edit Printer dialogs exceeded the viewport height with no scroll, hiding the lower fields (Access Code, Model, Location) and the Save button. Users had to zoom the browser out to complete the form. The modal overlay now scrolls and the card caps at `calc(100vh - 2rem)` with internal overflow so every field stays reachable regardless of viewport height. Thanks to @MartinNYHC for reporting.

+ 50 - 0
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -127,6 +127,56 @@ describe('CameraPage', () => {
     });
     });
   });
   });
 
 
+  describe('stream token handling (#979)', () => {
+    it('does not render image src until stream token arrives when auth is enabled', async () => {
+      let resolveToken!: (value: unknown) => void;
+      const tokenPromise = new Promise((resolve) => {
+        resolveToken = resolve;
+      });
+
+      server.use(
+        http.get('*/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false })
+        ),
+        http.post('*/api/v1/printers/camera/stream-token', async () => {
+          await tokenPromise;
+          return HttpResponse.json({ token: 'tok-abc' });
+        })
+      );
+
+      renderCameraPage(1);
+
+      // Before the token resolves the <img> should not have a src pointing at
+      // the stream endpoint — otherwise the backend would 401 with the
+      // "Valid camera stream token required" error from #979.
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+      const img = document.querySelector('img') as HTMLImageElement | null;
+      expect(img).not.toBeNull();
+      expect(img?.getAttribute('src') || '').not.toContain('/camera/stream');
+
+      resolveToken(undefined);
+
+      // After the token resolves the image src picks it up as ?token=...
+      await waitFor(() => {
+        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
+        expect(src).toContain('/camera/stream');
+        expect(src).toContain('token=tok-abc');
+      });
+    });
+
+    it('renders image src immediately when auth is disabled (no token required)', async () => {
+      renderCameraPage(1);
+
+      await waitFor(() => {
+        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
+        expect(src).toContain(`/api/v1/printers/1/camera/stream`);
+        expect(src).not.toContain('token=');
+      });
+    });
+  });
+
   describe('invalid printer', () => {
   describe('invalid printer', () => {
     it('shows invalid printer message for ID 0', async () => {
     it('shows invalid printer message for ID 0', async () => {
       renderCameraPage(0);
       renderCameraPage(0);

+ 27 - 5
frontend/src/pages/CameraPage.tsx

@@ -3,9 +3,10 @@ import { useParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
-import { api, getAuthToken, withStreamToken } from '../api/client';
+import { api, getAuthToken, getStreamToken, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { useStreamTokenSync } from '../hooks/useCameraStreamToken';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 
 
@@ -18,10 +19,22 @@ export function CameraPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, authEnabled } = useAuth();
   const { printerId } = useParams<{ printerId: string }>();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
   const id = parseInt(printerId || '0', 10);
 
 
+  // Subscribe to the stream-token query so this page re-renders once the token
+  // arrives. useStreamTokenSync (mounted in App) already owns the fetch; this
+  // useQuery call dedupes via the shared key and just reads the cached value.
+  useStreamTokenSync();
+  const { data: streamTokenData } = useQuery({
+    queryKey: ['camera-stream-token'],
+    queryFn: () => api.getCameraStreamToken(),
+    enabled: authEnabled,
+    staleTime: 50 * 60 * 1000,
+  });
+  const streamTokenValue = streamTokenData?.token ?? getStreamToken();
+
   const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
   const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [streamError, setStreamError] = useState(false);
   const [streamError, setStreamError] = useState(false);
@@ -574,11 +587,20 @@ export function CameraPage() {
     setPanOffset({ x: 0, y: 0 });
     setPanOffset({ x: 0, y: 0 });
   };
   };
 
 
-  const currentUrl = transitioning
+  // When auth is enabled, wait for the stream token before rendering the <img>
+  // src — otherwise the first request fires without ?token= and the backend
+  // rejects it with "Valid camera stream token required" (see #979). We append
+  // the token directly from the reactive query value instead of relying on the
+  // module-level cache in withStreamToken(), because that cache is updated in a
+  // useEffect that runs after render.
+  const waitingForStreamToken = authEnabled && !streamTokenValue;
+  const appendToken = (url: string) =>
+    streamTokenValue ? `${url}&token=${encodeURIComponent(streamTokenValue)}` : withStreamToken(url);
+  const currentUrl = transitioning || waitingForStreamToken
     ? ''
     ? ''
     : streamMode === 'stream'
     : streamMode === 'stream'
-      ? withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`)
-      : withStreamToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
+      ? appendToken(`/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`)
+      : appendToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
 
 
   const isDisabled = streamLoading || transitioning || isReconnecting;
   const isDisabled = streamLoading || transitioning || isReconnecting;
 
 

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

@@ -4151,7 +4151,9 @@ function PrinterCard({
                       `height=${state.height}`,
                       `height=${state.height}`,
                       state.left !== undefined ? `left=${state.left}` : '',
                       state.left !== undefined ? `left=${state.left}` : '',
                       state.top !== undefined ? `top=${state.top}` : '',
                       state.top !== undefined ? `top=${state.top}` : '',
-                      'menubar=no,toolbar=no,location=no,status=no,noopener',
+                      // No `noopener`: same-origin popup needs opener so the browser
+                      // copies sessionStorage (auth token) into the new window.
+                      'menubar=no,toolbar=no,location=no,status=no',
                     ].filter(Boolean).join(',');
                     ].filter(Boolean).join(',');
                     window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
                     window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
                   }
                   }

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Bcx_I7VB.js"></script>
+    <script type="module" crossorigin src="/assets/index-DlUYLmlY.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
     <link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
   </head>
   </head>
   <body>
   <body>

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