useCameraStreamToken.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { useEffect, useRef } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import {
  4. api,
  5. setStreamToken,
  6. getStreamToken,
  7. setMediaToken,
  8. getMediaToken,
  9. withStreamToken,
  10. withMediaToken,
  11. } from '../api/client';
  12. import { useAuth } from '../contexts/AuthContext';
  13. /** True for the three live-camera routes, which take the camera stream token.
  14. * Everything else under /api/v1/ that a browser loads as an element src is
  15. * media and takes the media token (#3025). */
  16. export function isCameraUrl(src: string): boolean {
  17. return src.includes('/camera/');
  18. }
  19. /**
  20. * Walks the DOM and updates every <img>/<video> pointing at /api/v1/ so its
  21. * src carries the right token: the camera token for live-camera URLs, the
  22. * media token for everything else. Either may be null -- a user without
  23. * camera:view has no camera token, and their thumbnails must still be
  24. * rewritten. Exported for unit testing; called from useStreamTokenSync when a
  25. * token arrives after first render.
  26. */
  27. export function rewriteMediaSrcWithToken(
  28. root: ParentNode,
  29. mediaToken: string | null,
  30. cameraToken: string | null
  31. ): number {
  32. let updated = 0;
  33. root
  34. .querySelectorAll<HTMLImageElement | HTMLVideoElement>(
  35. 'img[src*="/api/v1/"], video[src*="/api/v1/"]'
  36. )
  37. .forEach((el) => {
  38. const src = el.getAttribute('src') || '';
  39. const token = isCameraUrl(src) ? cameraToken : mediaToken;
  40. if (!token) return;
  41. const tokenParam = `token=${encodeURIComponent(token)}`;
  42. if (src.includes(tokenParam)) return;
  43. const withoutToken = src.replace(/([?&])token=[^&]*(&|$)/, (_m, pre, post) =>
  44. post === '&' ? pre : pre === '?' ? '' : ''
  45. );
  46. const sep = withoutToken.includes('?') ? '&' : '?';
  47. el.src = `${withoutToken}${sep}${tokenParam}`;
  48. updated += 1;
  49. });
  50. return updated;
  51. }
  52. /**
  53. * Fetches and caches the query-param tokens <img>/<video> src URLs need, and
  54. * publishes them through setMediaToken() / setStreamToken() so the URL
  55. * generators in client.ts pick them up automatically.
  56. *
  57. * Two tokens, fetched independently (#3025):
  58. *
  59. * media — every signed-in user gets one. Thumbnails, plate previews,
  60. * timelapses, cover images and link icons ride on it.
  61. * camera — only users with camera:view, because that is what minting one
  62. * costs. Asking for it unconditionally would 403 on every page
  63. * load for everyone else.
  64. *
  65. * Also listens for global image/video load errors on token-protected URLs and
  66. * refreshes the matching token (e.g. after a backend restart drops them).
  67. *
  68. * Mount this hook once near the app root. Components that need token-protected
  69. * URLs can import withMediaToken / withStreamToken directly.
  70. */
  71. export function useStreamTokenSync() {
  72. const { authEnabled, user, loading: authLoading, hasPermission } = useAuth();
  73. const queryClient = useQueryClient();
  74. const refreshingRef = useRef(false);
  75. // Key the tokens by user id so a login/logout invalidates the cache
  76. // automatically — otherwise a failed anonymous fetch on the login page
  77. // would be cached and never retried after sign-in.
  78. //
  79. // Race-aware gate (same shape as ColorCatalogProvider): wait for
  80. // ``checkAuthStatus`` to finish before deciding whether to fetch.
  81. // The previous form ``authEnabled ? !!user : true`` evaluated to
  82. // ``true`` on first render because ``authEnabled`` defaults to false,
  83. // firing a 401 POST on the login page before AuthContext had a chance
  84. // to settle on ``authEnabled=true, user=null``.
  85. const signedIn = !authLoading && (!authEnabled || user !== null);
  86. const { data: mediaData } = useQuery({
  87. queryKey: ['media-token', user?.id ?? null],
  88. queryFn: () => api.getMediaToken(),
  89. enabled: signedIn,
  90. staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
  91. refetchInterval: 50 * 60 * 1000,
  92. });
  93. // Only ask for a camera token when the user may actually have one. When auth
  94. // is disabled hasPermission() is vacuously true, which is correct — the mint
  95. // endpoint is open then too.
  96. const canViewCamera = !authEnabled || hasPermission('camera:view');
  97. const { data: cameraData } = useQuery({
  98. queryKey: ['camera-stream-token', user?.id ?? null],
  99. queryFn: () => api.getCameraStreamToken(),
  100. enabled: signedIn && canViewCamera,
  101. staleTime: 50 * 60 * 1000,
  102. refetchInterval: 50 * 60 * 1000,
  103. });
  104. const mediaTokenValue = mediaData?.token ?? null;
  105. const cameraTokenValue = cameraData?.token ?? null;
  106. useEffect(() => {
  107. setMediaToken(mediaTokenValue);
  108. setStreamToken(cameraTokenValue);
  109. // Images/videos that rendered before a token arrived have src URLs
  110. // without ?token=…; update them in place so they reload with auth.
  111. if (mediaTokenValue || cameraTokenValue) {
  112. rewriteMediaSrcWithToken(document, mediaTokenValue, cameraTokenValue);
  113. }
  114. return () => {
  115. setMediaToken(null);
  116. setStreamToken(null);
  117. };
  118. }, [mediaTokenValue, cameraTokenValue]);
  119. // Listen for image/video load errors on token-protected URLs.
  120. // When the backend restarts, in-memory tokens are lost and all
  121. // thumbnail/stream requests return 401. This handler detects that and
  122. // forces a refresh of whichever token the failing URL used, so images
  123. // recover without a page reload.
  124. useEffect(() => {
  125. if (!authEnabled) return;
  126. const handleError = (event: Event) => {
  127. const el = event.target;
  128. if (!(el instanceof HTMLImageElement || el instanceof HTMLVideoElement)) return;
  129. const src = el.src || '';
  130. const camera = isCameraUrl(src);
  131. const token = camera ? getStreamToken() : getMediaToken();
  132. if (!token || !src.includes(`token=${encodeURIComponent(token)}`)) return;
  133. // This image/video used one of our tokens and failed — likely invalid
  134. if (refreshingRef.current) return;
  135. refreshingRef.current = true;
  136. queryClient.invalidateQueries({
  137. queryKey: camera ? ['camera-stream-token'] : ['media-token'],
  138. });
  139. // Reset after a delay so future errors can trigger another refresh
  140. setTimeout(() => {
  141. refreshingRef.current = false;
  142. }, 5000);
  143. };
  144. // Use capture phase to catch errors before they're swallowed
  145. document.addEventListener('error', handleError, true);
  146. return () => document.removeEventListener('error', handleError, true);
  147. }, [authEnabled, queryClient]);
  148. }
  149. /**
  150. * Hook for components that need to wrap camera URLs with the stream token.
  151. * Returns a withToken function that appends ?token=xxx when auth is enabled.
  152. */
  153. export function useCameraStreamToken() {
  154. return { withToken: withStreamToken };
  155. }
  156. /**
  157. * Hook for components that need to wrap media URLs with the media token.
  158. */
  159. export function useMediaToken() {
  160. return { withToken: withMediaToken };
  161. }