瀏覽代碼

fix(websocket): stop the ws-token reconnect loop on auth failure

    After the GHSA-r2qv gate (b7d7c825), /api/v1/ws needs a token from
    POST /api/v1/auth/ws-token (Permission.WEBSOCKET_CONNECT). When the mint
    failed, useWebSocket swallowed the error, opened a tokenless socket, the
    server closed it 4401, and ws.onclose rescheduled connect() every 3s -
    an endless loop that hammered /auth/ws-token. The dominant trigger is a
    validly-logged-in user whose group lacks WEBSOCKET_CONNECT (mint returns
    403). A secondary leak: the unmount-triggered onclose could schedule a
    post-unmount reconnect.

    Classify the mint failure: 401 (JWT expired; request() already clears it
    and dispatches auth:expired) or 403 (valid session, missing permission;
    degrade to REST polling) now stop the hook - no tokenless socket, no
    reconnect. A 4401 close is terminal. Network/5xx still reconnect. A
    disposedRef set in cleanup before close() prevents the unmount-race
    reconnect. Same 401/403 no-open guard applied to StreamOverlayPage.

    Also surface a one-line hint under the WebSocket permission in the group
    editor (all 11 locales) explaining that live updates need it and fall
    back to polling without it - rather than auto-granting the permission,
    which would partly undo the GHSA-r2qv gate.
maziggy 2 月之前
父節點
當前提交
c751047ed8

+ 98 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -68,6 +68,15 @@ class MockWebSocket {
     }
   }
 
+  // Helper to simulate the server closing with a specific code (e.g. 4401,
+  // the /ws auth-rejection close code).
+  simulateClose(code: number) {
+    this.readyState = MockWebSocket.CLOSED;
+    if (this.onclose) {
+      this.onclose(new CloseEvent('close', { code }));
+    }
+  }
+
   // Helper to simulate receiving a message
   simulateMessage(data: unknown) {
     if (this.onmessage) {
@@ -668,6 +677,95 @@ describe('useWebSocket hook', () => {
       vi.useRealTimers();
     });
 
+    it('does NOT reconnect after an auth-rejection close (4401)', async () => {
+      // Regression: a 4401 (ws-token invalid/expired or caller lacks
+      // WEBSOCKET_CONNECT) used to reschedule connect() every 3s, spamming
+      // /auth/ws-token forever. It must be terminal now.
+      vi.useFakeTimers();
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+
+      await vi.advanceTimersByTimeAsync(0);
+      const firstWs = wsInstances[wsInstances.length - 1]!;
+      act(() => {
+        firstWs.open();
+      });
+
+      const instanceCountBefore = wsInstances.length;
+
+      // Server rejects auth.
+      act(() => {
+        firstWs.simulateClose(4401);
+      });
+
+      // No reconnect even after the 3s window elapses.
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(instanceCountBefore);
+
+      vi.useRealTimers();
+    });
+
+    it('does NOT open a socket or reconnect when ws-token mint returns 403', async () => {
+      // Mike/Forge's case: an authenticated user whose group lacks
+      // WEBSOCKET_CONNECT. POST /auth/ws-token returns 403; the hook must NOT
+      // fall through to a tokenless socket (server closes it 4401) and must NOT
+      // enter the reconnect loop — it degrades to REST polling instead.
+      vi.useFakeTimers();
+
+      vi.stubGlobal(
+        'fetch',
+        vi.fn(async () => ({
+          ok: false,
+          status: 403,
+          statusText: 'Forbidden',
+          headers: { get: () => null },
+          json: async () => ({ detail: 'Insufficient permissions' }),
+        })),
+      );
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+
+      // Flush the token-mint rejection, then let the (would-be) reconnect
+      // window pass. No socket should ever be constructed.
+      await vi.advanceTimersByTimeAsync(0);
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(0);
+
+      vi.useRealTimers();
+    });
+
+    it('does NOT reconnect when a close fires during unmount', async () => {
+      // The provider unmounting (e.g. logout redirect) must not leave a
+      // scheduled reconnect behind — the cleanup marks disposed before
+      // close(), so the resulting onclose is a no-op.
+      vi.useFakeTimers();
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      const { unmount } = renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      await vi.advanceTimersByTimeAsync(0);
+      const ws = wsInstances[wsInstances.length - 1]!;
+      act(() => {
+        ws.open();
+      });
+
+      const instanceCountBefore = wsInstances.length;
+
+      // Unmount closes the socket, which fires onclose synchronously.
+      act(() => {
+        unmount();
+      });
+
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(instanceCountBefore);
+
+      vi.useRealTimers();
+    });
+
     it('cleans up on unmount', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 

+ 50 - 11
frontend/src/hooks/useWebSocket.ts

@@ -2,9 +2,16 @@ import { useQueryClient } from '@tanstack/react-query';
 import { useCallback, useEffect, useRef, useState } from 'react';
 import { useToast } from '../contexts/ToastContext';
 import { useTranslation } from 'react-i18next';
-import { api } from '../api/client';
+import { api, ApiError } from '../api/client';
 import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
 
+// The only auth-failure close code /api/v1/ws emits (websocket.py
+// _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid /
+// expired, or the caller lacks WEBSOCKET_CONNECT — none of which a reconnect can
+// fix without a fresh login (which remounts this provider anyway). Treat it as
+// terminal so we don't respawn the /auth/ws-token loop.
+const WS_CLOSE_UNAUTHORIZED = 4401;
+
 interface WebSocketMessage {
   type: string;
   printer_id?: number;
@@ -20,6 +27,11 @@ interface WebSocketMessage {
 export function useWebSocket() {
   const wsRef = useRef<WebSocket | null>(null);
   const reconnectTimeoutRef = useRef<number | null>(null);
+  // Set true by the effect cleanup so a close event fired *during* unmount
+  // can't schedule a reconnect after the provider is gone (the old code cleared
+  // reconnectTimeoutRef, then .close() ran ws.onclose which set a *fresh*
+  // timeout — a leaked reconnect that kept minting ws-tokens post-logout).
+  const disposedRef = useRef(false);
   const queryClient = useQueryClient();
   const [isConnected, setIsConnected] = useState(false);
   const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
@@ -71,7 +83,7 @@ export function useWebSocket() {
   }, []);
 
   const connect = useCallback(async () => {
-    if (wsRef.current?.readyState === WebSocket.OPEN) {
+    if (disposedRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
       return;
     }
 
@@ -80,19 +92,34 @@ export function useWebSocket() {
     // helper (via ``api.getWebSocketToken``) so the JWT Authorization header
     // is attached — a raw ``fetch()`` with ``credentials: 'include'`` would
     // miss it (Bambuddy uses Bearer tokens, not cookies, for JWT auth).
-    // Auth-disabled deployments accept connections without a token, so we
-    // treat a missing/failed token mint as non-fatal here and let the
-    // WebSocket close with code 4401 if the server actually rejects us.
+    // Auth-disabled deployments accept connections without a token.
     let token: string | undefined;
     try {
       const resp = await api.getWebSocketToken();
       token = resp.token;
-    } catch {
-      // Token mint failed — most likely auth is disabled (no JWT to attach,
-      // 401 response) or the user isn't authenticated yet. Fall through and
-      // try the WebSocket anyway. Auth-disabled deployments succeed;
-      // auth-enabled deployments close with 4401 and the reconnect loop
-      // kicks in once the user logs in.
+    } catch (err) {
+      // A 401/403 from the token mint is an AUTH decision, not a transient
+      // blip, so retrying is pointless and hammers /auth/ws-token every 3s:
+      //   401 — the JWT expired. ``request()`` already cleared it and
+      //         dispatched ``auth:expired``, so the route guard is redirecting
+      //         to /login and this provider is about to unmount.
+      //   403 — the user is validly logged in but their group lacks
+      //         WEBSOCKET_CONNECT. They stay logged in; live updates simply
+      //         degrade to the REST polling the query cache already does.
+      // Either way: do NOT open a tokenless socket (the server just closes it
+      // 4401) and do NOT reconnect. The old catch-all fell through to a
+      // tokenless socket whose 4401 close rescheduled connect() forever. A
+      // network/5xx error is not auth — fall through and let the socket + its
+      // reconnect loop handle it (auth-disabled deployments also land here with
+      // no token and connect fine).
+      const status = err instanceof ApiError ? err.status : 0;
+      if (status === 401 || status === 403) {
+        return;
+      }
+    }
+
+    if (disposedRef.current) {
+      return;
     }
 
     const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -140,6 +167,14 @@ export function useWebSocket() {
       setIsConnected(false);
       wsRef.current = null;
 
+      // Don't reconnect after an auth rejection (4401) or once the provider has
+      // unmounted — both would just respawn the /auth/ws-token loop. A 4401 is
+      // terminal (needs a fresh login, which remounts us); every other close
+      // code is treated as a network drop and gets the 3s reconnect.
+      if (disposedRef.current || event.code === WS_CLOSE_UNAUTHORIZED) {
+        return;
+      }
+
       // Reconnect after 3 seconds
       reconnectTimeoutRef.current = window.setTimeout(() => {
         connect();
@@ -424,9 +459,13 @@ export function useWebSocket() {
     // connect() is async after the GHSA-r2qv fix (mints a ws-token first).
     // Fire-and-forget at mount; the inner reconnect loop also calls
     // connect() in the ws.onclose handler.
+    disposedRef.current = false;
     void connect();
 
     return () => {
+      // Mark disposed BEFORE closing so the ws.onclose triggered by close()
+      // sees it and won't schedule a post-unmount reconnect.
+      disposedRef.current = true;
       if (reconnectTimeoutRef.current) {
         clearTimeout(reconnectTimeoutRef.current);
       }

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -3121,6 +3121,7 @@ export default {
       clearAll: 'Alle abwählen',
       permissionsSelected: '{{count}} ausgewählt',
       noResults: 'Keine Berechtigungen entsprechen Ihrer Suche',
+      websocketHint: 'Erforderlich für Live-Aktualisierungen. Ohne diese Berechtigung greift die Oberfläche auf regelmäßiges Abrufen zurück.',
     },
   },
 

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -3150,6 +3150,7 @@ export default {
       clearAll: 'Clear All',
       permissionsSelected: '{{count}} selected',
       noResults: 'No permissions match your search',
+      websocketHint: 'Required for live updates. Without it, the interface falls back to periodic polling.',
     },
   },
 

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -3124,6 +3124,7 @@ export default {
       clearAll: 'Borrar todo',
       permissionsSelected: '{{count}} seleccionados',
       noResults: 'Ningún permiso coincide con su búsqueda',
+      websocketHint: 'Necesario para las actualizaciones en vivo. Sin este permiso, la interfaz recurre al sondeo periódico.',
     },
   },
 

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -3110,6 +3110,7 @@ export default {
       clearAll: 'Tout désélectionner',
       permissionsSelected: '{{count}} sélectionnée(s)',
       noResults: 'Aucune permission ne correspond à votre recherche',
+      websocketHint: "Requis pour les mises à jour en direct. Sans cette permission, l'interface bascule sur une actualisation périodique.",
     },
   },
 

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -3109,6 +3109,7 @@ export default {
       clearAll: 'Deseleziona tutto',
       permissionsSelected: '{{count}} selezionati',
       noResults: 'Nessun permesso corrisponde alla ricerca',
+      websocketHint: "Necessario per gli aggiornamenti in tempo reale. Senza questo permesso, l'interfaccia ripiega sul polling periodico.",
     },
   },
 

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -3121,6 +3121,7 @@ export default {
       clearAll: 'すべて解除',
       permissionsSelected: '{{count}}件選択',
       noResults: '検索に一致する権限がありません',
+      websocketHint: 'ライブ更新に必要です。この権限がないと、インターフェースは定期的なポーリングに切り替わります。',
     },
   },
 

+ 2 - 1
frontend/src/i18n/locales/ko.ts

@@ -2951,7 +2951,8 @@ export default {
       selectAll: '모두 선택',
       clearAll: '모두 해제',
       permissionsSelected: '{{count}}개 선택됨',
-      noResults: '검색과 일치하는 권한이 없습니다'
+      noResults: '검색과 일치하는 권한이 없습니다',
+      websocketHint: '실시간 업데이트에 필요합니다. 이 권한이 없으면 인터페이스는 주기적 폴링으로 대체됩니다.'
     }
   },
   users: {

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3109,6 +3109,7 @@ export default {
       clearAll: 'Limpar Tudo',
       permissionsSelected: '{{count}} selecionada(s)',
       noResults: 'Nenhuma permissão corresponde à sua pesquisa',
+      websocketHint: 'Necessário para atualizações em tempo real. Sem esta permissão, a interface recorre à sondagem periódica.',
     },
   },
 

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -3125,6 +3125,7 @@ export default {
       clearAll: 'Tümünü Temizle',
       permissionsSelected: '{{count}} seçildi',
       noResults: 'Aramanızla eşleşen izin yok',
+      websocketHint: 'Canlı güncellemeler için gereklidir. Bu izin olmadan arayüz düzenli yoklamaya geri döner.',
     },
   },
 

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3109,6 +3109,7 @@ export default {
       clearAll: '清除全部',
       permissionsSelected: '已选 {{count}} 个',
       noResults: '没有权限匹配您的搜索',
+      websocketHint: '实时更新所需。缺少此权限时,界面将回退到定期轮询。',
     },
   },
 

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3109,6 +3109,7 @@ export default {
       clearAll: '清除全部',
       permissionsSelected: '已選 {{count}} 個',
       noResults: '沒有權限匹配您的搜尋',
+      websocketHint: '即時更新所需。缺少此權限時,介面將回退到定期輪詢。',
     },
   },
 

+ 9 - 2
frontend/src/pages/GroupEditPage.tsx

@@ -282,9 +282,16 @@ export function GroupEditPage() {
                         type="checkbox"
                         checked={permissions.includes(perm.value)}
                         onChange={() => togglePermission(perm.value)}
-                        className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
+                        className="w-4 h-4 shrink-0 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
                       />
-                      <span className="text-sm text-bambu-gray">{perm.label}</span>
+                      <span className="flex flex-col">
+                        <span className="text-sm text-bambu-gray">{perm.label}</span>
+                        {perm.value === 'websocket:connect' && (
+                          <span className="text-xs text-bambu-gray/60">
+                            {t('groups.editor.websocketHint')}
+                          </span>
+                        )}
+                      </span>
                     </label>
                   ))}
                 </div>

+ 11 - 6
frontend/src/pages/StreamOverlayPage.tsx

@@ -3,7 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
-import { api, withStreamToken } from '../api/client';
+import { api, ApiError, withStreamToken } from '../api/client';
 import type { PrinterStatus } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 
@@ -157,11 +157,16 @@ export function StreamOverlayPage() {
       try {
         const resp = await api.getWebSocketToken();
         token = resp.token;
-      } catch {
-        // Token mint failed — auth disabled, no JWT yet, or transient
-        // network error. Fall through; auth-disabled deployments still
-        // succeed, auth-enabled ones close with 4401 and the page's
-        // polling fallback continues to refresh the status.
+      } catch (err) {
+        // A 401 (JWT expired) / 403 (no WEBSOCKET_CONNECT permission) is an
+        // auth decision — a tokenless socket would just be closed 4401, so
+        // skip opening one and let the REST polling fallback keep the overlay
+        // fresh. There's no reconnect loop on this page, so this is purely
+        // avoiding one doomed socket per mount. A network/5xx error is not
+        // auth: fall through and try anyway (auth-disabled deployments land
+        // here with no token and connect fine).
+        const status = err instanceof ApiError ? err.status : 0;
+        if (status === 401 || status === 403) return;
       }
       if (cancelled) return;
 

文件差異過大導致無法顯示
+ 0 - 0
static/assets/index-DnnRRVNb.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-CaHy_042.js"></script>
+    <script type="module" crossorigin src="/assets/index-DnnRRVNb.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BxVhuRti.css">
   </head>
   <body>

部分文件因文件數量過多而無法顯示