|
@@ -3,6 +3,31 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
import { api, setStreamToken, getStreamToken, withStreamToken } from '../api/client';
|
|
import { api, setStreamToken, getStreamToken, withStreamToken } from '../api/client';
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
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.
|
|
* Fetches and caches a stream token for <img>/<video> src URLs.
|
|
|
* Stores the token globally via setStreamToken() so URL generators
|
|
* 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.
|
|
* Components that need token-protected URLs can import withStreamToken directly.
|
|
|
*/
|
|
*/
|
|
|
export function useStreamTokenSync() {
|
|
export function useStreamTokenSync() {
|
|
|
- const { authEnabled } = useAuth();
|
|
|
|
|
|
|
+ const { authEnabled, user } = useAuth();
|
|
|
const queryClient = useQueryClient();
|
|
const queryClient = useQueryClient();
|
|
|
const refreshingRef = useRef(false);
|
|
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({
|
|
const { data } = useQuery({
|
|
|
- queryKey: ['camera-stream-token'],
|
|
|
|
|
|
|
+ queryKey: ['camera-stream-token', user?.id ?? null],
|
|
|
queryFn: () => api.getCameraStreamToken(),
|
|
queryFn: () => api.getCameraStreamToken(),
|
|
|
- enabled: authEnabled,
|
|
|
|
|
|
|
+ enabled: authEnabled ? !!user : true,
|
|
|
staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
|
|
staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
|
|
|
refetchInterval: 50 * 60 * 1000,
|
|
refetchInterval: 50 * 60 * 1000,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
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);
|
|
return () => setStreamToken(null);
|
|
|
}, [data?.token]);
|
|
}, [data?.token]);
|
|
|
|
|
|