//! HTTP transport to a ThoughtSync server. //! //! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The //! engine that moves notes — push, pull, cursor — grows on top of the same client, //! which is why the timeout, identity headers and error vocabulary live here rather //! than inline at each call site. //! //! Nothing here runs unless the user has linked a server; the app is local-first and //! fully usable with no network at all. use std::time::Duration; use reqwest::{RequestBuilder, StatusCode}; use serde::{Deserialize, Serialize}; use super::compat::{self, Compatibility, ServerInfo}; /// Timeout for the short request/response calls in this module. Kept tight because a /// user is watching a button while they run, and the most common mistake — a wrong /// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would /// just look frozen. The sync engine's bulk transfers will need their own, longer one. const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); /// What the link UI needs after a handshake: where we ended up (the normalized URL, /// which may differ from what was typed), who answered, and whether we can work /// with them. #[derive(Debug, Serialize)] pub struct ProbeResult { pub base_url: String, pub server: ServerInfo, pub compatibility: Compatibility, } /// The account a device token belongs to. Surfaced after linking so the user can /// confirm they linked the account they meant to — easy to get wrong on a server /// hosting more than one. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Identity { pub id: String, pub email: String, #[serde(default)] pub display_name: String, } #[derive(Deserialize)] struct DeviceLoginResponse { token: String, user: Identity, } fn http() -> Result { reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) .build() .map_err(|e| format!("Could not start the network client: {e}")) } /// Attach the client-identity headers every request carries, plus a bearer token /// when we hold one. fn prepare(builder: RequestBuilder, token: Option<&str>) -> RequestBuilder { let mut builder = builder; for (name, value) in compat::client_headers() { builder = builder.header(name, value); } match token { Some(t) => builder.bearer_auth(t), None => builder, } } fn unexpected_status(base_url: &str, status: StatusCode) -> String { format!( "{base_url} answered with HTTP {}. Check the address — a reverse proxy or a \ different site may be answering there.", status.as_u16() ) } /// Ask a server who it is and whether we can sync with it. /// /// `Err` means we never got a usable answer (bad address, unreachable, not a /// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok` /// with a verdict — that distinction matters, because the two need very different /// messages: one is "check what you typed", the other is "update something". pub async fn probe(raw_url: &str) -> Result { let base_url = compat::normalize_base_url(raw_url) .ok_or("Enter a server address, like https://notes.example.com")?; let request = prepare(http()?.get(config_url(&base_url)), None); let response = request .send() .await .map_err(|e| describe_transport_error(&base_url, &e))?; let status = response.status(); if !status.is_success() { return Err(unexpected_status(&base_url, status)); } // Something answered 200 that isn't a ThoughtSync server (a router login page, a // captive portal). Report the address, not the parse error, which would mean // nothing to the person reading it. let server: ServerInfo = response.json().await.map_err(|_| { format!( "{base_url} responded, but not with ThoughtSync's configuration. \ Is that the right address?" ) })?; let compatibility = compat::evaluate(&server); Ok(ProbeResult { base_url, server, compatibility, }) } /// Exchange email + password for a device bearer token. /// /// The fresh-install path: it needs no existing session, which is what lets a brand /// new desktop install link without visiting the web app first. pub async fn device_login( base_url: &str, email: &str, password: &str, device_name: &str, ) -> Result<(String, Identity), String> { let body = serde_json::json!({ "email": email, "password": password, "name": device_name, }); let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body); let response = request .send() .await .map_err(|e| describe_transport_error(base_url, &e))?; let status = response.status(); if status == StatusCode::UNAUTHORIZED { return Err("That email and password didn't match an account on this server.".to_string()); } if !status.is_success() { return Err(unexpected_status(base_url, status)); } let parsed: DeviceLoginResponse = response .json() .await .map_err(|_| format!("{base_url} signed us in but sent an unexpected reply."))?; Ok((parsed.token, parsed.user)) } /// Validate a token by asking whom it belongs to. /// /// Used when the user pastes a token issued from the web app. Storing it unverified /// would turn a copy/paste slip into a failure that only surfaces at the next sync, /// far from the thing that caused it. pub async fn fetch_identity(base_url: &str, token: &str) -> Result { let request = prepare(http()?.get(me_url(base_url)), Some(token)); let response = request .send() .await .map_err(|e| describe_transport_error(base_url, &e))?; let status = response.status(); if status == StatusCode::UNAUTHORIZED { let message = "That token isn't valid on this server — it may have been revoked. \ Issue a new one from the web app under Account → Linked devices."; return Err(message.to_string()); } if !status.is_success() { return Err(unexpected_status(base_url, status)); } response .json() .await .map_err(|_| format!("{base_url} accepted the token but sent an unexpected reply.")) } /// The public, unauthenticated endpoint carrying the handshake. fn config_url(base_url: &str) -> String { format!("{base_url}/api/config") } fn device_login_url(base_url: &str) -> String { format!("{base_url}/api/auth/device-login") } fn me_url(base_url: &str) -> String { format!("{base_url}/api/auth/me") } /// Turn a transport failure into something a person can act on. reqwest's own /// Display is accurate but reads like a stack trace. fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String { if err.is_timeout() { format!( "{base_url} didn't respond within {} seconds. It may be offline, or \ unreachable from this network.", REQUEST_TIMEOUT.as_secs() ) } else if err.is_connect() { format!( "Couldn't reach {base_url}. Check the address and that the server is \ running. If it uses plain HTTP, include http:// explicitly." ) } else { format!("Couldn't reach {base_url}: {err}") } } #[cfg(test)] mod tests { use super::*; #[test] fn urls_join_without_doubling_slashes() { // normalize_base_url has already stripped any trailing slash, so plain // concatenation is correct — this pins that assumption. assert_eq!( config_url("https://notes.example.com"), "https://notes.example.com/api/config" ); assert_eq!( device_login_url("https://notes.example.com"), "https://notes.example.com/api/auth/device-login" ); assert_eq!( me_url("https://notes.example.com"), "https://notes.example.com/api/auth/me" ); } #[test] fn urls_preserve_a_port_and_subpath() { assert_eq!( config_url("http://192.168.1.10:8000/thoughtsync"), "http://192.168.1.10:8000/thoughtsync/api/config" ); } }