mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user