//! Concrete [`SyncProvider`] implementations and a factory for constructing //! the correct provider from a [`SyncBackend`] setting. //! //! # Backends //! //! | Struct | Backend | //! |---|---| //! | [`LocalOnlyProvider`] | No-op; used when sync is disabled | //! | [`SolitaireServerClient`] | Self-hosted Ferrous Solitaire server (JWT auth) | //! //! Use [`provider_for_backend`] to obtain a `Box` //! without matching on [`SyncBackend`] anywhere else in the codebase. use async_trait::async_trait; #[cfg(not(target_arch = "wasm32"))] use solitaire_sync::{ChallengeGoal, LeaderboardEntry}; use solitaire_sync::{SyncPayload, SyncResponse}; use crate::{SyncError, SyncProvider}; #[cfg(not(target_arch = "wasm32"))] use crate::{ auth_tokens::{load_access_token, load_refresh_token, store_tokens}, replay::Replay, settings::SyncBackend, }; // --------------------------------------------------------------------------- // LocalOnlyProvider // --------------------------------------------------------------------------- /// A no-op sync provider used when the player has not configured any backend. /// /// Both [`pull`](SyncProvider::pull) and [`push`](SyncProvider::push) always /// return [`SyncError::UnsupportedPlatform`], so callers know no remote data /// is available without treating it as a fatal error. pub struct LocalOnlyProvider; #[async_trait] impl SyncProvider for LocalOnlyProvider { async fn pull(&self) -> Result { Err(SyncError::UnsupportedPlatform) } async fn push(&self, _payload: &SyncPayload) -> Result { Err(SyncError::UnsupportedPlatform) } fn backend_name(&self) -> &'static str { "local" } fn is_authenticated(&self) -> bool { false } } // --------------------------------------------------------------------------- // SolitaireServerClient // --------------------------------------------------------------------------- // Native-only: HTTP sync client and factory function. // On wasm32 these are gated out because reqwest uses native OS networking // (mio + hyper) which does not compile for wasm32-unknown-unknown. // --------------------------------------------------------------------------- /// HTTP sync client for the self-hosted Ferrous Solitaire server. /// /// Authenticates via JWT stored in the OS keychain. On a 401 response the /// client automatically attempts a token refresh and retries the request once /// before returning an error. #[cfg(not(target_arch = "wasm32"))] pub struct SolitaireServerClient { /// Base URL of the server, e.g. `"https://solitaire.example.com"`. /// Trailing slashes are stripped on construction. base_url: String, /// The player's username on this server — used as the keychain key. username: String, /// Shared `reqwest` client (keeps connection pools alive across calls). client: reqwest::Client, } #[cfg(not(target_arch = "wasm32"))] impl SolitaireServerClient { /// Construct a new client for the given server URL and username. /// /// The `base_url` trailing slash is stripped so URL construction is /// consistent regardless of how the user entered the setting. pub fn new(base_url: impl Into, username: impl Into) -> Self { Self { base_url: base_url.into().trim_end_matches('/').to_owned(), username: username.into(), client: reqwest::Client::new(), } } /// Authenticate with a username + password and return `(access_token, refresh_token)`. /// /// On success call [`crate::auth_tokens::store_tokens`] with the returned pair. /// The client's `username` field is used as the credential — the caller must /// construct the client with the correct username before calling this. pub async fn login(&self, password: &str) -> Result<(String, String), SyncError> { let resp = self .client .post(format!("{}/api/auth/login", self.base_url)) .json(&serde_json::json!({ "username": self.username, "password": password, })) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; Self::extract_auth_tokens(resp).await } /// Register a new account with a username + password and return `(access_token, refresh_token)`. /// /// On success call [`crate::auth_tokens::store_tokens`] with the returned pair. pub async fn register(&self, password: &str) -> Result<(String, String), SyncError> { let resp = self .client .post(format!("{}/api/auth/register", self.base_url)) .json(&serde_json::json!({ "username": self.username, "password": password, })) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; Self::extract_auth_tokens(resp).await } /// Parse `{ "access_token": "...", "refresh_token": "..." }` from an auth response. async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> { let status = resp.status(); if !status.is_success() { let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({})); let msg = body["error"] .as_str() .or_else(|| body["message"].as_str()) .unwrap_or("authentication failed"); return Err(if status == reqwest::StatusCode::CONFLICT { SyncError::Auth("username already taken".into()) } else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { SyncError::Auth("invalid credentials".into()) } else if status == reqwest::StatusCode::BAD_REQUEST { SyncError::Auth(msg.to_string()) } else { SyncError::Network(format!("server returned {status}")) }); } let body: serde_json::Value = resp .json() .await .map_err(|e| SyncError::Serialization(e.to_string()))?; let access = body["access_token"] .as_str() .ok_or_else(|| SyncError::Serialization("missing access_token".into()))? .to_string(); let refresh = body["refresh_token"] .as_str() .ok_or_else(|| SyncError::Serialization("missing refresh_token".into()))? .to_string(); Ok((access, refresh)) } /// Attempt to refresh the access token using the stored refresh token. /// /// The server rotates refresh tokens on each call: the response includes a /// new refresh token that replaces the old one. Both tokens are persisted /// to the OS keychain on success. async fn refresh_token(&self) -> Result<(), SyncError> { let old_refresh = load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?; let resp = self .client .post(format!("{}/api/auth/refresh", self.base_url)) .json(&serde_json::json!({ "refresh_token": old_refresh })) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if !resp.status().is_success() { return Err(SyncError::Auth("refresh failed".into())); } let body: serde_json::Value = resp .json() .await .map_err(|e| SyncError::Serialization(e.to_string()))?; let new_access = body["access_token"].as_str().ok_or_else(|| { SyncError::Serialization("missing access_token in refresh response".into()) })?; // Server rotates refresh tokens — store the new one. // Fall back to the old token if the field is absent (pre-rotation server). let new_refresh = body["refresh_token"].as_str().unwrap_or(&old_refresh); store_tokens(&self.username, new_access, new_refresh) .map_err(|e| SyncError::Auth(e.to_string())) } /// Load the current access token from the OS keychain. fn access_token(&self) -> Result { load_access_token(&self.username).map_err(|e| SyncError::Auth(e.to_string())) } } #[cfg(not(target_arch = "wasm32"))] #[async_trait] impl SyncProvider for SolitaireServerClient { /// Fetch the latest sync payload from the server. /// /// On HTTP 401 the client refreshes the access token and retries once. async fn pull(&self) -> Result { let token = self.access_token()?; let url = format!("{}/api/sync/pull", self.base_url); let resp = self .client .get(&url) .bearer_auth(&token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { // Token expired — refresh and retry once. self.refresh_token().await?; let new_token = self.access_token()?; let resp = self .client .get(&url) .bearer_auth(new_token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; return extract_pull_body(resp).await; } extract_pull_body(resp).await } /// Push the local payload to the server and return the merged response. /// /// On HTTP 401 the client refreshes the access token and retries once. async fn push(&self, payload: &SyncPayload) -> Result { let token = self.access_token()?; let url = format!("{}/api/sync/push", self.base_url); let resp = self .client .post(&url) .bearer_auth(&token) .json(payload) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { // Token expired — refresh and retry once. self.refresh_token().await?; let new_token = self.access_token()?; let resp = self .client .post(&url) .bearer_auth(new_token) .json(payload) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; return extract_push_body(resp).await; } extract_push_body(resp).await } fn backend_name(&self) -> &'static str { "solitaire_server" } /// Returns `true` if a valid access token is present in the OS keychain. fn is_authenticated(&self) -> bool { load_access_token(&self.username).is_ok() } /// Fetch today's daily challenge from the server. /// /// Does not require authentication — the endpoint is public. Returns `None` /// on any non-success HTTP status so the caller falls back to the local seed. async fn fetch_daily_challenge(&self) -> Result, SyncError> { let url = format!("{}/api/daily-challenge", self.base_url); let resp = self .client .get(&url) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status().is_success() { let goal: ChallengeGoal = resp .json() .await .map_err(|e| SyncError::Serialization(e.to_string()))?; Ok(Some(goal)) } else { // Non-fatal — caller will use the locally computed seed instead. Ok(None) } } async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> { let token = self.access_token()?; let url = format!("{}/api/leaderboard/opt-in", self.base_url); // Enforce the server's 32-char column limit at the client boundary so // the server never receives an over-length name regardless of caller. let display_name: String = display_name.chars().take(32).collect(); let resp = self .client .post(&url) .bearer_auth(&token) .json(&serde_json::json!({ "display_name": display_name })) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { self.refresh_token().await?; let new_token = self.access_token()?; let resp = self .client .post(&url) .bearer_auth(new_token) .json(&serde_json::json!({ "display_name": display_name })) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if !resp.status().is_success() { return Err(SyncError::Auth(format!("opt-in failed: {}", resp.status()))); } return Ok(()); } if !resp.status().is_success() { return Err(SyncError::Auth(format!("opt-in failed: {}", resp.status()))); } Ok(()) } async fn opt_out_leaderboard(&self) -> Result<(), SyncError> { let token = self.access_token()?; let url = format!("{}/api/leaderboard/opt-in", self.base_url); let resp = self .client .delete(&url) .bearer_auth(&token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { self.refresh_token().await?; let new_token = self.access_token()?; let resp = self .client .delete(&url) .bearer_auth(new_token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if !resp.status().is_success() { return Err(SyncError::Auth(format!( "opt-out failed: {}", resp.status() ))); } return Ok(()); } if !resp.status().is_success() { return Err(SyncError::Auth(format!( "opt-out failed: {}", resp.status() ))); } Ok(()) } async fn delete_account(&self) -> Result<(), SyncError> { let token = self.access_token()?; let url = format!("{}/api/account", self.base_url); let resp = self .client .delete(&url) .bearer_auth(&token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { self.refresh_token().await?; let new_token = self.access_token()?; let resp = self .client .delete(&url) .bearer_auth(new_token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if !resp.status().is_success() { return Err(SyncError::Auth(format!( "delete account failed: {}", resp.status() ))); } return Ok(()); } if !resp.status().is_success() { return Err(SyncError::Auth(format!( "delete account failed: {}", resp.status() ))); } Ok(()) } async fn fetch_leaderboard(&self) -> Result, SyncError> { let token = self.access_token()?; let url = format!("{}/api/leaderboard", self.base_url); let resp = self .client .get(&url) .bearer_auth(&token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { self.refresh_token().await?; let new_token = self.access_token()?; let resp = self .client .get(&url) .bearer_auth(new_token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; return extract_leaderboard_body(resp).await; } extract_leaderboard_body(resp).await } /// Upload a winning replay to `POST /api/replays`. On success the /// server returns `{ "id": "" }`; this method composes that /// id with the configured base URL into the player-shareable /// `/replays/` link and returns it. Mirrors the `push` /// auth flow: 401 triggers a token refresh and one retry. async fn push_replay(&self, replay: &Replay) -> Result { let token = self.access_token()?; let url = format!("{}/api/replays", self.base_url); let resp = self .client .post(&url) .bearer_auth(&token) .json(replay) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { self.refresh_token().await?; let new_token = self.access_token()?; let resp = self .client .post(&url) .bearer_auth(new_token) .json(replay) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; return self.share_url_from_response(resp).await; } self.share_url_from_response(resp).await } } #[cfg(not(target_arch = "wasm32"))] impl SolitaireServerClient { /// Pulled out of `push_replay` so both the first attempt and the /// post-401-retry attempt go through the same parse path. async fn share_url_from_response(&self, resp: reqwest::Response) -> Result { let status = resp.status(); if !status.is_success() { return Err( if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { SyncError::Auth(format!("server returned {status}")) } else { SyncError::Network(format!("server returned {status}")) }, ); } let body: serde_json::Value = resp .json() .await .map_err(|e| SyncError::Serialization(e.to_string()))?; let id = body["id"] .as_str() .ok_or_else(|| SyncError::Serialization("upload response missing `id`".into()))?; Ok(format!("{}/replays/{}", self.base_url, id)) } /// Fetch the authenticated user's profile (`GET /api/me`). /// /// Returns `(username, avatar_url)`. `avatar_url` is `None` when the user /// has not set an avatar. Returns an error on network failure or if the /// token is expired and refresh also fails. pub async fn fetch_me(&self) -> Result<(String, Option), SyncError> { let token = self.access_token()?; let url = format!("{}/api/me", self.base_url); let resp = self .client .get(&url) .bearer_auth(&token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { self.refresh_token().await?; let new_token = self.access_token()?; let resp = self .client .get(&url) .bearer_auth(new_token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; return Self::extract_me_body(resp).await; } Self::extract_me_body(resp).await } /// Like [`fetch_me`] but uses an explicit token instead of reading from the /// OS keychain. Useful immediately after login/register when the token has /// not yet been persisted. pub async fn fetch_me_with_token( &self, token: &str, ) -> Result<(String, Option), SyncError> { let url = format!("{}/api/me", self.base_url); let resp = self .client .get(&url) .bearer_auth(token) .send() .await .map_err(|e| SyncError::Network(e.to_string()))?; Self::extract_me_body(resp).await } async fn extract_me_body( resp: reqwest::Response, ) -> Result<(String, Option), SyncError> { let status = resp.status(); if !status.is_success() { return Err(SyncError::Network(format!("GET /api/me returned {status}"))); } let body: serde_json::Value = resp .json() .await .map_err(|e| SyncError::Serialization(e.to_string()))?; let username = body["username"].as_str().unwrap_or("").to_string(); let avatar_url = body["avatar_url"].as_str().map(str::to_string); Ok((username, avatar_url)) } } // --------------------------------------------------------------------------- // Response extraction helpers (native-only, use reqwest::Response) // --------------------------------------------------------------------------- #[cfg(not(target_arch = "wasm32"))] /// Deserialize a pull response body as [`SyncResponse`] and return its /// `merged` field, or map non-200 statuses to the appropriate [`SyncError`]. /// /// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as /// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are /// classified as network/transport errors so the UI shows the right message. async fn extract_pull_body(resp: reqwest::Response) -> Result { let status = resp.status(); if status.is_success() { let sync_resp: SyncResponse = resp .json() .await .map_err(|e| SyncError::Serialization(e.to_string()))?; Ok(sync_resp.merged) } else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { Err(SyncError::Auth(format!("server returned {status}"))) } else { Err(SyncError::Network(format!("server returned {status}"))) } } #[cfg(not(target_arch = "wasm32"))] /// Deserialize a leaderboard response body as `Vec`. async fn extract_leaderboard_body( resp: reqwest::Response, ) -> Result, SyncError> { let status = resp.status(); if status.is_success() { resp.json() .await .map_err(|e| SyncError::Serialization(e.to_string())) } else { Err(SyncError::Network(format!("server returned {status}"))) } } #[cfg(not(target_arch = "wasm32"))] /// Deserialize a push response body as [`SyncResponse`], or map non-200 /// statuses to the appropriate [`SyncError`]. /// /// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as /// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are /// classified as network/transport errors so the UI shows the right message. async fn extract_push_body(resp: reqwest::Response) -> Result { let status = resp.status(); if status.is_success() { resp.json() .await .map_err(|e| SyncError::Serialization(e.to_string())) } else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { Err(SyncError::Auth(format!("server returned {status}"))) } else { Err(SyncError::Network(format!("server returned {status}"))) } } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /// Construct the appropriate [`SyncProvider`] for the given [`SyncBackend`] /// setting. /// /// This is the **one** place in the codebase that matches on [`SyncBackend`] /// variants. All other code receives a `Box` /// and remains backend-agnostic. #[cfg(not(target_arch = "wasm32"))] pub fn provider_for_backend(backend: &SyncBackend) -> Box { match backend { SyncBackend::Local => Box::new(LocalOnlyProvider), SyncBackend::SolitaireServer { url, username, .. } => { Box::new(SolitaireServerClient::new(url.clone(), username.clone())) } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn local_provider_backend_name() { assert_eq!(LocalOnlyProvider.backend_name(), "local"); } #[test] fn local_provider_not_authenticated() { assert!(!LocalOnlyProvider.is_authenticated()); } #[tokio::test] async fn local_provider_pull_returns_unsupported() { let err = LocalOnlyProvider.pull().await.unwrap_err(); assert!(matches!(err, SyncError::UnsupportedPlatform)); } #[test] fn server_client_strips_trailing_slash() { let c = SolitaireServerClient::new("https://example.com/", "alice"); assert_eq!(c.base_url, "https://example.com"); } #[test] fn server_client_backend_name() { let c = SolitaireServerClient::new("https://example.com", "alice"); assert_eq!(c.backend_name(), "solitaire_server"); } #[test] fn factory_local_returns_local_provider() { let provider = provider_for_backend(&SyncBackend::Local); assert_eq!(provider.backend_name(), "local"); } #[test] fn factory_server_returns_server_client() { let provider = provider_for_backend(&SyncBackend::SolitaireServer { url: "https://example.com".to_string(), username: "bob".to_string(), avatar_url: None, }); assert_eq!(provider.backend_name(), "solitaire_server"); } }