diff --git a/app/src/lib/websocket/index.ts b/app/src/lib/websocket/index.ts index 3afcce89..3be557ce 100644 --- a/app/src/lib/websocket/index.ts +++ b/app/src/lib/websocket/index.ts @@ -10,8 +10,10 @@ import { useSettingsStore, useUserStore } from '@/pinia' export function buildWebSocketUrl(url: string, token: string, shortToken: string, nodeId?: number): string { const node_id = nodeId && nodeId > 0 ? `&x_node_id=${nodeId}` : '' - // Use shortToken if available (without base64 encoding), otherwise use regular token (with base64 encoding) - const authParam = shortToken ? `token=${shortToken}` : `token=${btoa(token)}` + // Use shortToken if available (without base64 encoding), otherwise use regular token (URL-safe base64). + // URL-safe base64 avoids `+` chars that get decoded as spaces in query strings. + const longTokenParam = btoa(token).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + const authParam = shortToken ? `token=${shortToken}` : `token=${longTokenParam}` // In development mode, keep WebSocket same-origin so the browser // connects through the dev server instead of the private backend port. @@ -41,8 +43,15 @@ export function useWebSocket( const settings = useSettingsStore() const { token, shortToken } = storeToRefs(userStore) - // Snapshot the URL at call time — must NOT be reactive to avoid - // tearing down in-flight connections when shortToken arrives later. + // Snapshot the URL at call time — must NOT be reactive to avoid tearing down + // in-flight connections (e.g. terminal, log tail) when shortToken arrives later. + // When shortToken is empty we fall back to the URL-safe base64 long token, + // which the backend still accepts. We deliberately do NOT trigger + // fetchShortToken() here: /token/short can return 403 if the secure-session + // cookie is stale, and the global HTTP interceptor turns any 403 into a + // forced logout — which would kick out otherwise-valid sessions on any + // WebSocket-backed page. Short-token refresh is handled by the user store's + // token watcher (see app/src/pinia/moudule/user.ts). const wsUrl = buildWebSocketUrl(url, token.value, shortToken.value, settings.node.id) return vueUseWebSocket(wsUrl, { diff --git a/app/src/views/site/site_edit/components/Cert/ObtainCert.vue b/app/src/views/site/site_edit/components/Cert/ObtainCert.vue index 30a2d281..aff79b0b 100644 --- a/app/src/views/site/site_edit/components/Cert/ObtainCert.vue +++ b/app/src/views/site/site_edit/components/Cert/ObtainCert.vue @@ -164,6 +164,11 @@ async function job() { return } + // Wait for Vue to mount ObtainCertLive after step transitions to 2; without + // this tick refObtainCertLive.value is still null and issueCert() silently + // no-ops via its optional-chain call. + await nextTick() + issueCert() } function toggle(status: boolean) { diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 58208f1a..1be4b968 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -46,6 +46,12 @@ func getTokenWS(c *gin.Context) (token string) { if token = c.Query("token"); token != "" { if len(token) > 16 { + // Try URL-safe base64 first (browsers send `+` -> `-`, `/` -> `_` to + // avoid query-string corruption); fall back to standard base64 for + // backward compatibility with older clients. + if tokenBytes, err := base64.RawURLEncoding.DecodeString(token); err == nil { + return string(tokenBytes) + } tokenBytes, _ := base64.StdEncoding.DecodeString(token) return string(tokenBytes) } diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 1ba0d018..f8377bf3 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -34,6 +34,22 @@ func TestGetTokenWS_NoCookieFallback(t *testing.T) { assert.Equal(t, jwt, token) }) + t.Run("decodes URL-safe base64 token from query", func(t *testing.T) { + // Pick a payload long enough that its encoded form is > 16 chars and + // whose std-base64 encoding contains `+` / `/` so the URL-safe variant + // differs (would have been corrupted by `c.Query` decoding `+` as space). + jwt := "eyJhbGciOiJIUzI1NiJ9.test??>>>>>>>>" + urlSafe := base64.RawURLEncoding.EncodeToString([]byte(jwt)) + std := base64.StdEncoding.EncodeToString([]byte(jwt)) + assert.NotEqual(t, urlSafe, std, "test payload must encode differently in std vs URL-safe") + assert.Greater(t, len(urlSafe), 16, "encoded payload must be > 16 chars to hit the long-token branch") + + c := newTestGinContext(t, "GET", "/ws?token="+urlSafe, nil) + + token := getTokenWS(c) + assert.Equal(t, jwt, token) + }) + t.Run("does NOT read from cookie", func(t *testing.T) { c := newTestGinContext(t, "GET", "/ws", nil) c.Request.AddCookie(&http.Cookie{Name: "token", Value: "cookie-jwt-token"})