From 59706e62c12dd58efeea68df68d165b8809327ca Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:50:18 +0400 Subject: [PATCH] refactor: better error handling --- donut-sync/src/auth/auth.guard.ts | 6 +- src-tauri/src/cloud_auth.rs | 171 +++++++++++++++++++++++++----- src-tauri/src/lib.rs | 8 +- src-tauri/src/sync/client.rs | 38 +++++-- src-tauri/src/sync/engine.rs | 17 +++ src-tauri/src/sync/manifest.rs | 7 +- src/hooks/use-cloud-auth.ts | 11 +- 7 files changed, 212 insertions(+), 46 deletions(-) diff --git a/donut-sync/src/auth/auth.guard.ts b/donut-sync/src/auth/auth.guard.ts index d025c53..06976e5 100644 --- a/donut-sync/src/auth/auth.guard.ts +++ b/donut-sync/src/auth/auth.guard.ts @@ -61,8 +61,10 @@ export class AuthGuard implements CanActivate { profileLimit: decoded.profileLimit || 0, } satisfies UserContext; return true; - } catch { - // JWT verification failed — fall through to error + } catch (err) { + this.logger.warn( + `JWT verification failed: ${err instanceof Error ? err.message : err}`, + ); } } diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index f8c01b3..5fc5d10 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -96,6 +96,7 @@ struct CloudProxyConfigResponse { pub struct CloudAuthManager { client: Client, state: Mutex>, + refresh_lock: tokio::sync::Mutex<()>, } lazy_static! { @@ -108,6 +109,7 @@ impl CloudAuthManager { Self { client: Client::new(), state: Mutex::new(state), + refresh_lock: tokio::sync::Mutex::new(()), } } @@ -388,9 +390,37 @@ impl CloudAuthManager { .map_err(|e| format!("Failed to parse response: {e}"))?; // Store tokens + log::info!( + "Storing access token (len={}) and refresh token (len={})", + result.access_token.len(), + result.refresh_token.len() + ); Self::store_access_token(&result.access_token)?; Self::store_refresh_token(&result.refresh_token)?; + // Verify tokens survived the encrypt/decrypt round-trip + match Self::load_access_token() { + Ok(Some(loaded)) if loaded == result.access_token => { + log::info!( + "Access token verified after store/load (len={})", + loaded.len() + ); + } + Ok(Some(loaded)) => { + log::error!( + "Access token CORRUPTED during store/load: original_len={}, loaded_len={}", + result.access_token.len(), + loaded.len() + ); + } + Ok(None) => { + log::error!("Access token missing immediately after store"); + } + Err(e) => { + log::error!("Failed to load access token for verification: {e}"); + } + } + // Build and persist auth state let auth_state = CloudAuthState { user: result.user, @@ -398,6 +428,13 @@ impl CloudAuthManager { }; Self::store_auth_state(&auth_state)?; + log::info!( + "Login successful: plan={}, subscription_status={}, proxy_bandwidth_limit={}MB", + auth_state.user.plan, + auth_state.user.subscription_status, + auth_state.user.proxy_bandwidth_limit_mb + ); + // Update in-memory state let mut state = self.state.lock().await; *state = Some(auth_state.clone()); @@ -406,6 +443,9 @@ impl CloudAuthManager { } pub async fn refresh_access_token(&self) -> Result<(), String> { + let _guard = self.refresh_lock.lock().await; + log::info!("Refreshing access token (holding lock)..."); + let refresh_token = Self::load_refresh_token()?.ok_or_else(|| "No refresh token stored".to_string())?; @@ -420,14 +460,8 @@ impl CloudAuthManager { if !response.status().is_success() { let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - // Refresh token expired — clear everything - PROXY_MANAGER.remove_cloud_proxy(); - self.clear_auth().await; - let _ = crate::events::emit_empty("cloud-auth-expired"); - return Err("Session expired. Please log in again.".to_string()); - } let body = response.text().await.unwrap_or_default(); + log::warn!("Token refresh failed ({status}): {body}"); return Err(format!("Token refresh failed ({status}): {body}")); } @@ -439,9 +473,20 @@ impl CloudAuthManager { Self::store_access_token(&result.access_token)?; Self::store_refresh_token(&result.refresh_token)?; + log::info!("Access token refreshed successfully"); Ok(()) } + /// Invalidate the session: clear all auth state and notify the frontend. + /// Only call this when the session is definitively dead (explicit logout + /// or repeated background refresh failures). + pub async fn invalidate_session(&self) { + log::warn!("Invalidating session — clearing all auth state"); + PROXY_MANAGER.remove_cloud_proxy(); + self.clear_auth().await; + let _ = crate::events::emit_empty("cloud-auth-expired"); + } + pub async fn fetch_profile(&self) -> Result { let user = self .api_call_with_retry(|access_token| { @@ -574,6 +619,7 @@ impl CloudAuthManager { } /// API call with 401 retry: if first attempt gets 401, refresh access token and retry once. + /// Uses refresh_lock to prevent concurrent token rotations from racing. async fn api_call_with_retry(&self, make_request: F) -> Result where F: Fn(String) -> Fut + Send, @@ -581,13 +627,22 @@ impl CloudAuthManager { { let access_token = Self::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?; - match make_request(access_token).await { + match make_request(access_token.clone()).await { Ok(result) => Ok(result), - Err(e) if e.contains("(401)") => { - // Try refreshing the access token + Err(e) if e.contains("(401") || e.contains("Unauthorized") => { + log::info!("Got 401/Unauthorized response, attempting token refresh..."); + + // Check if another caller already refreshed while we waited + let current_token = Self::load_access_token()?.unwrap_or_default(); + if current_token != access_token && !current_token.is_empty() { + log::info!("Token was already refreshed by another caller, retrying..."); + return make_request(current_token).await; + } + self.refresh_access_token().await?; let new_token = Self::load_access_token()?.ok_or_else(|| "Not logged in after refresh".to_string())?; + log::info!("Token refreshed, retrying request..."); make_request(new_token).await } Err(e) => Err(e), @@ -619,6 +674,8 @@ impl CloudAuthManager { let status = response.status(); if status == reqwest::StatusCode::FORBIDDEN { + let body = response.text().await.unwrap_or_default(); + log::warn!("Proxy config returned 403: {body}"); return Err("__403__".to_string()); } @@ -646,8 +703,15 @@ impl CloudAuthManager { /// Sync the cloud-managed proxy: fetch config and upsert or remove pub async fn sync_cloud_proxy(&self) { + log::info!("Syncing cloud proxy configuration..."); match self.fetch_proxy_config().await { Ok(Some(config)) => { + log::info!( + "Cloud proxy config received: host={}, port={}, protocol={}", + config.host, + config.port, + config.protocol + ); let settings = ProxySettings { proxy_type: config.protocol, host: config.host, @@ -657,7 +721,7 @@ impl CloudAuthManager { }; match PROXY_MANAGER.upsert_cloud_proxy(settings) { Ok(_) => { - log::debug!("Cloud proxy synced successfully"); + log::info!("Cloud proxy synced successfully"); // Propagate credential changes to derived location proxies PROXY_MANAGER.update_cloud_derived_proxies(); } @@ -665,14 +729,42 @@ impl CloudAuthManager { } } Ok(None) => { + log::info!("No cloud proxy config available (user may not have proxy bandwidth)"); PROXY_MANAGER.remove_cloud_proxy(); } Err(e) => { - log::warn!("Failed to sync cloud proxy: {e}"); + log::error!("Failed to sync cloud proxy: {e}"); } } } + /// Report the number of sync-enabled profiles to the cloud backend + pub async fn report_sync_profile_count(&self, count: i64) -> Result<(), String> { + self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/auth/sync-profile-usage"); + let client = reqwest::Client::new(); + async move { + let response = client + .post(&url) + .header("Authorization", format!("Bearer {access_token}")) + .json(&serde_json::json!({ "count": count })) + .send() + .await + .map_err(|e| format!("Failed to report profile usage: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Profile usage report failed ({status}): {body}")); + } + + Ok(()) + } + }) + .await + } + /// Fetch country list from the cloud backend pub async fn fetch_countries(&self) -> Result, String> { self @@ -782,6 +874,22 @@ impl CloudAuthManager { continue; } + // Proactively refresh the access token if it's expired or expiring soon. + // This runs first so subsequent API calls use a fresh token. + if let Ok(Some(token)) = Self::load_access_token() { + if Self::is_jwt_expiring_soon(&token) { + if let Err(e) = CLOUD_AUTH.refresh_access_token().await { + log::warn!("Failed to refresh cloud access token: {e}"); + // If the refresh token itself was rejected, session is irrecoverable + if e.contains("(401") || e.contains("Unauthorized") { + log::warn!("Refresh token rejected — invalidating session"); + CLOUD_AUTH.invalidate_session().await; + continue; + } + } + } + } + match CLOUD_AUTH.get_or_refresh_sync_token().await { Ok(Some(_)) => { log::debug!("Cloud sync token refreshed successfully"); @@ -792,15 +900,6 @@ impl CloudAuthManager { } } - // Also refresh the access token if needed - if let Ok(Some(token)) = Self::load_access_token() { - if Self::is_jwt_expiring_soon(&token) { - if let Err(e) = CLOUD_AUTH.refresh_access_token().await { - log::warn!("Failed to refresh cloud access token: {e}"); - } - } - } - // Refresh profile data periodically if let Err(e) = CLOUD_AUTH.fetch_profile().await { log::debug!("Failed to refresh cloud profile: {e}"); @@ -829,16 +928,28 @@ pub async fn cloud_verify_otp( ) -> Result { let state = CLOUD_AUTH.verify_otp(&email, &code).await?; + let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await; + log::info!( + "Post-login: plan={}, has_active_subscription={}", + state.user.plan, + has_subscription + ); + // Pre-fetch sync token so sync can start immediately - if CLOUD_AUTH.has_active_paid_subscription().await { - if let Err(e) = CLOUD_AUTH.get_or_refresh_sync_token().await { - log::warn!("Failed to pre-fetch sync token after login: {e}"); + if has_subscription { + log::info!("Pre-fetching sync token..."); + match CLOUD_AUTH.get_or_refresh_sync_token().await { + Ok(Some(_)) => log::info!("Sync token pre-fetched successfully"), + Ok(None) => log::warn!("Sync token not available despite active subscription"), + Err(e) => log::error!("Failed to pre-fetch sync token after login: {e}"), } } // Sync cloud proxy after login CLOUD_AUTH.sync_cloud_proxy().await; + let _ = crate::events::emit_empty("cloud-auth-changed"); + let _ = &app_handle; Ok(state) } @@ -856,7 +967,17 @@ pub async fn cloud_refresh_profile() -> Result { #[tauri::command] pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> { CLOUD_AUTH.logout().await?; - let _ = &app_handle; + + // Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab) + let manager = crate::settings_manager::SettingsManager::instance(); + if let Ok(sync_settings) = manager.get_sync_settings() { + if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) { + let _ = manager.save_sync_server_url(None); + } + } + let _ = manager.remove_sync_token(&app_handle).await; + + let _ = crate::events::emit_empty("cloud-auth-changed"); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0322e7..7a17b15 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1092,15 +1092,13 @@ pub fn run() { // Start cloud auth background refresh loop let app_handle_cloud = app.handle().clone(); tauri::async_runtime::spawn(async move { - // On startup, refresh access token + sync token if cloud auth is active + // On startup, refresh sync token and proxy if cloud auth is active. + // api_call_with_retry handles 401/refresh internally — no direct + // refresh_access_token call needed. if cloud_auth::CLOUD_AUTH.is_logged_in().await { - if let Err(e) = cloud_auth::CLOUD_AUTH.refresh_access_token().await { - log::warn!("Failed to refresh cloud access token on startup: {e}"); - } if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await { log::warn!("Failed to refresh cloud sync token on startup: {e}"); } - // Sync cloud proxy credentials on startup cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await; } cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await; diff --git a/src-tauri/src/sync/client.rs b/src-tauri/src/sync/client.rs index 9e2bad4..2f6c111 100644 --- a/src-tauri/src/sync/client.rs +++ b/src-tauri/src/sync/client.rs @@ -34,7 +34,9 @@ impl SyncClient { .map_err(|e| SyncError::NetworkError(e.to_string()))?; if response.status().is_client_error() { - return Err(SyncError::AuthError("Invalid or missing token".to_string())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); } response @@ -62,7 +64,9 @@ impl SyncClient { .map_err(|e| SyncError::NetworkError(e.to_string()))?; if response.status().is_client_error() { - return Err(SyncError::AuthError("Invalid or missing token".to_string())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); } response @@ -85,7 +89,9 @@ impl SyncClient { .map_err(|e| SyncError::NetworkError(e.to_string()))?; if response.status().is_client_error() { - return Err(SyncError::AuthError("Invalid or missing token".to_string())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); } response @@ -109,7 +115,9 @@ impl SyncClient { .map_err(|e| SyncError::NetworkError(e.to_string()))?; if response.status().is_client_error() { - return Err(SyncError::AuthError("Invalid or missing token".to_string())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); } response @@ -133,7 +141,9 @@ impl SyncClient { .map_err(|e| SyncError::NetworkError(e.to_string()))?; if response.status().is_client_error() { - return Err(SyncError::AuthError("Invalid or missing token".to_string())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); } response @@ -148,7 +158,11 @@ impl SyncClient { data: &[u8], content_type: Option<&str>, ) -> SyncResult<()> { - let mut req = self.client.put(presigned_url).body(data.to_vec()); + let mut req = self + .client + .put(presigned_url) + .header("Content-Length", data.len().to_string()) + .body(data.to_vec()); if let Some(ct) = content_type { req = req.header("Content-Type", ct); @@ -214,7 +228,9 @@ impl SyncClient { .map_err(|e| SyncError::NetworkError(e.to_string()))?; if response.status().is_client_error() { - return Err(SyncError::AuthError("Invalid or missing token".to_string())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); } response @@ -242,7 +258,9 @@ impl SyncClient { .map_err(|e| SyncError::NetworkError(e.to_string()))?; if response.status().is_client_error() { - return Err(SyncError::AuthError("Invalid or missing token".to_string())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); } response @@ -270,7 +288,9 @@ impl SyncClient { .map_err(|e| SyncError::NetworkError(e.to_string()))?; if response.status().is_client_error() { - return Err(SyncError::AuthError("Invalid or missing token".to_string())); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); } response diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index fcf1b9f..1098f0b 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1146,6 +1146,23 @@ pub async fn set_profile_sync_enabled( ); } + // Report updated sync-enabled profile count to the cloud backend + if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { + let sync_count = profile_manager + .list_profiles() + .map(|profiles| profiles.iter().filter(|p| p.sync_enabled).count()) + .unwrap_or(0); + + tokio::spawn(async move { + if let Err(e) = crate::cloud_auth::CLOUD_AUTH + .report_sync_profile_count(sync_count as i64) + .await + { + log::warn!("Failed to report sync profile count: {e}"); + } + }); + } + Ok(()) } diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index b98277d..cf80f2f 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -23,9 +23,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ "blob_storage/**", "*.log", "*.tmp", - "LOG", - "LOG.old", - "LOCK", + "**/LOG", + "**/LOG.old", + "**/LOCK", + "**/*-journal", ".donut-sync/**", ]; diff --git a/src/hooks/use-cloud-auth.ts b/src/hooks/use-cloud-auth.ts index ee6eccd..141acb3 100644 --- a/src/hooks/use-cloud-auth.ts +++ b/src/hooks/use-cloud-auth.ts @@ -32,12 +32,19 @@ export function useCloudAuth(): UseCloudAuthReturn { useEffect(() => { loadUser(); - const unlistenPromise = listen("cloud-auth-expired", () => { + const unlistenExpired = listen("cloud-auth-expired", () => { setAuthState(null); }); + const unlistenChanged = listen("cloud-auth-changed", () => { + loadUser(); + }); + return () => { - void unlistenPromise.then((unlisten) => { + void unlistenExpired.then((unlisten) => { + unlisten(); + }); + void unlistenChanged.then((unlisten) => { unlisten(); }); };