//! 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}; use super::wire; /// 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); /// Bulk transfers get much longer: a first full sync can be thousands of notes, and /// failing one at ten seconds would make a large store impossible to ever pull. const SYNC_TIMEOUT: Duration = Duration::from_secs(120); /// Shared by every call that presents a token, so a revoked one reads the same way /// wherever it surfaces. const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \ revoked. Unlink and link again to issue a new one."; /// 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_with(timeout: Duration) -> Result { reqwest::Client::builder() .timeout(timeout) .build() .map_err(|e| format!("Could not start the network client: {e}")) } fn http() -> Result { http_with(REQUEST_TIMEOUT) } /// 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.")) } /// Fetch one page of the change feed, starting after `since`. /// /// The caller loops until `has_more` is false (see `pull::run`); paging lives there /// rather than here so the transport stays a single request/response. pub async fn fetch_changes( base_url: &str, token: &str, since: i64, ) -> Result { let url = format!("{base_url}/api/sync/changes?since={since}"); let request = prepare(http_with(SYNC_TIMEOUT)?.get(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 { return Err(TOKEN_REJECTED.to_string()); } if !status.is_success() { return Err(unexpected_status(base_url, status)); } response .json() .await .map_err(|e| format!("Couldn't read the change feed from {base_url}: {e}")) } /// Download one attachment's bytes. /// /// Metadata already arrived on the delta feed; this is only the payload, fetched /// over the same route the web app uses (owner/shared scoped server-side). pub async fn fetch_attachment( base_url: &str, token: &str, note_id: &str, attachment_id: &str, ) -> Result, String> { let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}"); let request = prepare(http_with(SYNC_TIMEOUT)?.get(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 { return Err(TOKEN_REJECTED.to_string()); } if !status.is_success() { return Err(unexpected_status(base_url, status)); } response .bytes() .await .map(|b| b.to_vec()) .map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}")) } /// Send a batch of changes and hand back the raw reply. /// /// Returns text rather than parsed results so this module stays pure transport — /// `push::parse_results` owns the result shapes, and keeping them there is what lets /// the parsing be unit-tested without a server. pub async fn push_changes( base_url: &str, token: &str, changes: &[T], ) -> Result { let body = serde_json::json!({ "changes": changes }); let url = format!("{base_url}/api/sync/push"); let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).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(TOKEN_REJECTED.to_string()); } if !status.is_success() { return Err(unexpected_status(base_url, status)); } response .text() .await .map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}")) } /// 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() { // No specific duration here: these calls run under two different budgets // (interactive vs bulk sync), and naming the wrong one is worse than naming // none. format!( "{base_url} didn't respond in time. It may be offline, or unreachable \ from this network." ) } 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" ); } }