Просмотр исходного кода

fix(frontend): thumbnails blank until reload after sign-in

  On auth-enabled instances, logging out and back in left the File Manager
  (and occasionally the Archives page) full of broken thumbnails until a
  manual page reload. Thumbnail URLs are gated by a short-lived camera
  stream token that <img> tags cannot send via Authorization headers, so
  the token is appended as ?token=… at render time.

  Two races broke this after sign-in:

  1. The token query was keyed on ['camera-stream-token'] alone and fired
     while the user was still on the login page. It 401'd, React Query
     cached the failure with a 50-minute staleTime, and nothing invalidated
     it after login — the token never arrived.

  2. Even when the token did arrive, the module-level variable holding it
     was not reactive, so pages that had already rendered kept serving
     image URLs with no token in them.

  Fixes:

  - Include user.id in the query key and gate with
    `enabled: authEnabled ? !!user : true`. A new sign-in produces a new
    key and triggers a fresh fetch; no anonymous fetch is cached.
  - When the token transitions from null to a value, walk the DOM once
    and update src on every <img>/<video> pointing at /api/v1/ without
    the current token so already-rendered pages reload in place.
  - Mirror the query key/gate in CameraPage so it shares the cache entry.

  The DOM-rewrite logic is extracted into rewriteMediaSrcWithToken() with
  unit tests covering: appending to a query-less URL, & separator with an
  existing query, skipping URLs that already carry the current token,
  replacing a stale token (trailing and middle positions), leaving
  non-/api/v1/ URLs alone, updating <video>, and URL-encoding tokens with
  special characters.
maziggy 1 месяц назад
Родитель
Сommit
32c0b169bd

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.4b1] - Unreleased
 
 ### Fixed
+- **Thumbnails Blank Until Reload After Sign-In** — On auth-enabled instances, signing out and back in left the File Manager (and occasionally the Archives page) full of broken thumbnails until the page was manually reloaded. Thumbnail URLs are gated by a short-lived camera-stream token that `<img>` tags can't send via `Authorization` headers, so the token is appended as `?token=…` at render time. Two race conditions conspired to break this: (1) the token query was keyed only on `['camera-stream-token']` and fired while the user was still on the login page, 401'd, and stayed cached — after sign-in nothing invalidated it; (2) when the token did eventually arrive, the global variable holding it was not reactive, so any File Manager / Archives page that had already rendered kept serving image URLs with no token. The token query now includes the user id in its key and is gated on `!!user`, so a new login always triggers a fresh fetch; and when the token transitions from null to a value, `useStreamTokenSync` walks the DOM once and updates `src` on every already-rendered `<img>`/`<video>` pointing at `/api/v1/` without the current token, reloading them in place.
 - **P2S Firmware Check Shows Stale "Latest" Version** ([#1030](https://github.com/maziggy/bambuddy/issues/1030)) — On P2S (and X2D) the Firmware Info modal reported `01.01.01.00` as the newest available release even though `01.02.00.00` had shipped on the Bambu Lab wiki weeks earlier, so the "update available" badge never appeared. Two silent regex mismatches in the wiki scraper caused `_fetch_all_versions_from_wiki()` to return an empty list: (1) the section-heading anchor parser required a dash between the version bytes and the release date (`id="h-01020000-20260409"`), but P2S and X2D publish anchors without the dash (`id="h-0102000020260409"`); (2) the text-based fallback only accepted ASCII parens around the date, while P2S, X2D, A1 and A1-mini headings render dates in full-width `(YYYYMMDD)` (U+FF08/U+FF09). When both paths failed, the code silently fell back to the Bambu Lab download page, which still lagged at `01.01.01.00`. The anchor regex now accepts an optional dash and the fallback accepts both paren styles; added regression tests for the no-dash anchor and full-width paren shapes. Thanks to @Minebuddy for reporting.
 - **Library File Print-Usage Tracking** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — `LibraryFile.print_count` and `last_printed_at` are now updated on every successful queued print completion. Previously both fields were defined on the model and displayed in the File Manager, but nothing ever wrote to them — every file in every library showed as never printed. Now counts increment cumulatively and `last_printed_at` stamps the completion timestamp (UTC). Failed, cancelled and user-aborted prints are intentionally excluded, so the fields represent "successful usage" rather than "attempted usage." This unblocks sorting the File Manager by last-printed date and is a prerequisite for the scheduled-purge feature requested in #1008. Thanks to @cadtoolbox for the report.
 

+ 86 - 0
frontend/src/__tests__/hooks/useCameraStreamToken.test.ts

@@ -0,0 +1,86 @@
+/**
+ * Unit tests for rewriteMediaSrcWithToken — the DOM walker that retrofits a
+ * camera stream token onto <img>/<video> src URLs that rendered before the
+ * token arrived (regression guard for the post-login blank-thumbnails bug).
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { rewriteMediaSrcWithToken } from '../../hooks/useCameraStreamToken';
+
+describe('rewriteMediaSrcWithToken', () => {
+  let root: HTMLDivElement;
+
+  beforeEach(() => {
+    root = document.createElement('div');
+    document.body.appendChild(root);
+  });
+
+  afterEach(() => {
+    root.remove();
+  });
+
+  const addImg = (src: string) => {
+    const img = document.createElement('img');
+    img.setAttribute('src', src);
+    root.appendChild(img);
+    return img;
+  };
+
+  const addVideo = (src: string) => {
+    const v = document.createElement('video');
+    v.setAttribute('src', src);
+    root.appendChild(v);
+    return v;
+  };
+
+  it('appends token to /api/v1/ images that have no query string', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail');
+    const count = rewriteMediaSrcWithToken(root, 'abc123');
+    expect(count).toBe(1);
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=abc123');
+  });
+
+  it('appends token to URLs that already have a query string using & separator', () => {
+    const img = addImg('/api/v1/archives/5/thumbnail?v=1700000000000');
+    rewriteMediaSrcWithToken(root, 'abc123');
+    expect(img.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?v=1700000000000&token=abc123');
+  });
+
+  it('leaves images alone that already carry the current token', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail?token=abc123');
+    const count = rewriteMediaSrcWithToken(root, 'abc123');
+    expect(count).toBe(0);
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=abc123');
+  });
+
+  it('replaces a stale token with the current one', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail?token=OLD');
+    rewriteMediaSrcWithToken(root, 'NEW');
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=NEW');
+  });
+
+  it('replaces a stale token that sits in the middle of the query string', () => {
+    const img = addImg('/api/v1/archives/5/thumbnail?token=OLD&v=1700000000000');
+    rewriteMediaSrcWithToken(root, 'NEW');
+    // Old token stripped, v preserved, new token appended.
+    expect(img.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?v=1700000000000&token=NEW');
+  });
+
+  it('ignores images that do not point at /api/v1/', () => {
+    const img = addImg('https://cdn.example.com/static/logo.png');
+    rewriteMediaSrcWithToken(root, 'abc123');
+    expect(img.getAttribute('src')).toBe('https://cdn.example.com/static/logo.png');
+  });
+
+  it('updates <video> elements as well', () => {
+    const v = addVideo('/api/v1/printers/7/camera/stream?fps=10');
+    rewriteMediaSrcWithToken(root, 'abc123');
+    expect(v.getAttribute('src')).toBe('/api/v1/printers/7/camera/stream?fps=10&token=abc123');
+  });
+
+  it('url-encodes tokens containing special characters', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail');
+    rewriteMediaSrcWithToken(root, 'a b/c=d');
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=a%20b%2Fc%3Dd');
+  });
+});

+ 40 - 4
frontend/src/hooks/useCameraStreamToken.ts

@@ -3,6 +3,31 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { api, setStreamToken, getStreamToken, withStreamToken } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 
+/**
+ * Walks the DOM and updates every <img>/<video> pointing at /api/v1/ so its
+ * src carries the current stream token. Exported for unit testing; called
+ * from useStreamTokenSync when the token arrives after first render.
+ */
+export function rewriteMediaSrcWithToken(root: ParentNode, token: string): number {
+  const tokenParam = `token=${encodeURIComponent(token)}`;
+  let updated = 0;
+  root
+    .querySelectorAll<HTMLImageElement | HTMLVideoElement>(
+      'img[src*="/api/v1/"], video[src*="/api/v1/"]'
+    )
+    .forEach((el) => {
+      const src = el.getAttribute('src') || '';
+      if (src.includes(tokenParam)) return;
+      const withoutToken = src.replace(/([?&])token=[^&]*(&|$)/, (_m, pre, post) =>
+        post === '&' ? pre : pre === '?' ? '' : ''
+      );
+      const sep = withoutToken.includes('?') ? '&' : '?';
+      el.src = `${withoutToken}${sep}${tokenParam}`;
+      updated += 1;
+    });
+  return updated;
+}
+
 /**
  * Fetches and caches a stream token for <img>/<video> src URLs.
  * Stores the token globally via setStreamToken() so URL generators
@@ -16,20 +41,31 @@ import { useAuth } from '../contexts/AuthContext';
  * Components that need token-protected URLs can import withStreamToken directly.
  */
 export function useStreamTokenSync() {
-  const { authEnabled } = useAuth();
+  const { authEnabled, user } = useAuth();
   const queryClient = useQueryClient();
   const refreshingRef = useRef(false);
 
+  // Key the token by user id so a login/logout invalidates the cache
+  // automatically — otherwise a failed anonymous fetch on the login page
+  // would be cached and never retried after sign-in.
   const { data } = useQuery({
-    queryKey: ['camera-stream-token'],
+    queryKey: ['camera-stream-token', user?.id ?? null],
     queryFn: () => api.getCameraStreamToken(),
-    enabled: authEnabled,
+    enabled: authEnabled ? !!user : true,
     staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
     refetchInterval: 50 * 60 * 1000,
   });
 
   useEffect(() => {
-    setStreamToken(data?.token ?? null);
+    const newToken = data?.token ?? null;
+    setStreamToken(newToken);
+
+    // Images/videos that rendered before the token arrived have src URLs
+    // without ?token=…; update them in place so they reload with auth.
+    if (newToken) {
+      rewriteMediaSrcWithToken(document, newToken);
+    }
+
     return () => setStreamToken(null);
   }, [data?.token]);
 

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

@@ -19,7 +19,7 @@ export function CameraPage() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission, authEnabled } = useAuth();
+  const { hasPermission, authEnabled, user } = useAuth();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
 
@@ -28,9 +28,9 @@ export function CameraPage() {
   // useQuery call dedupes via the shared key and just reads the cached value.
   useStreamTokenSync();
   const { data: streamTokenData } = useQuery({
-    queryKey: ['camera-stream-token'],
+    queryKey: ['camera-stream-token', user?.id ?? null],
     queryFn: () => api.getCameraStreamToken(),
-    enabled: authEnabled,
+    enabled: authEnabled ? !!user : true,
     staleTime: 50 * 60 * 1000,
   });
   const streamTokenValue = streamTokenData?.token ?? getStreamToken();

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CyyNqzi1.js


+ 1 - 1
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов