//! 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; use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}; use crate::{ auth_tokens::{load_access_token, load_refresh_token, store_tokens}, replay::Replay, settings::SyncBackend, SyncError, SyncProvider, }; // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- /// 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. 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, } 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())) } } #[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); 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 } } 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)) } } // --------------------------------------------------------------------------- // Response extraction helpers // --------------------------------------------------------------------------- /// 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}"))) } } /// 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}"))) } } /// 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. 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(), }); assert_eq!(provider.backend_name(), "solitaire_server"); } }