//! 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::path::Path; 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, } /// What became of this device's token on the SERVER when unlinking. /// /// Not a bool, and not an error: unlinking must never be blocked by the network — /// wanting to stop syncing is a local decision — so the remote half reports back /// instead of failing the call, and each outcome needs different advice. /// /// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum RevokeOutcome { /// The server confirmed it: this token authenticates nothing now. Revoked, /// This server has no self-revoke route — it predates one. The token is still /// live, and only the web app can retire it. Unsupported, /// We couldn't reach the server, or it refused. The token is still live. Failed { reason: String }, /// Nothing to revoke; the app wasn't linked. Skipped, } /// Retire the device token we authenticate with, server-side. /// /// Identified by the token itself rather than a device id, because a token pasted /// from the web app never carried one — a route keyed on the id would work for /// exactly one of the two ways this app can be linked. pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome { let client = match http() { Ok(client) => client, Err(reason) => return RevokeOutcome::Failed { reason }, }; let request = prepare(client.delete(revoke_self_url(base_url)), Some(token)); let response = match request.send().await { Ok(response) => response, Err(e) => { return RevokeOutcome::Failed { reason: describe_transport_error(base_url, &e), } } }; let status = response.status(); // 401 counts as revoked: the token already authenticates nothing — retired by // another device, or purged server-side — which is the state we were asking for. if status.is_success() || status == StatusCode::UNAUTHORIZED { return RevokeOutcome::Revoked; } match status { // No such route: a server older than self-revoke. Any other shape of 404 // (a proxy, a stale base URL) leaves the token live too, so the advice the // user needs is the same either way. StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported, other => RevokeOutcome::Failed { reason: unexpected_status(base_url, other), }, } } 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") } /// `self` rather than a device id: see `revoke_self`. fn revoke_self_url(base_url: &str) -> String { format!("{base_url}/api/auth/devices/self") } /// 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" ); assert_eq!( revoke_self_url("https://notes.example.com"), "https://notes.example.com/api/auth/devices/self" ); } #[test] fn revoke_outcome_serializes_tagged_for_the_frontend() { // The UI decides between "signed out on the server" and "still valid, go // revoke it" by reading this tag, so its shape is part of the contract. let json = serde_json::to_string(&RevokeOutcome::Failed { reason: "offline".into(), }) .expect("outcome serializes"); assert!(json.contains("\"status\":\"failed\""), "got {json}"); let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes"); assert!(json.contains("\"status\":\"revoked\""), "got {json}"); } #[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" ); } } /// The Android client a linked server can hand out. /// /// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there /// means the server has no client to offer, which is an ordinary state and not an /// error — a self-hoster who never touches Android has one. #[derive(Debug, Clone, Deserialize)] pub struct ClientRelease { pub version: String, /// What decides "is this newer". The name is for people and sorts like a string. pub version_code: i64, pub size: i64, pub sha256: String, /// Path on the same server, not an absolute URL — the client joins it to the /// base it is already linked to, so a compromised or misconfigured server /// cannot redirect the download somewhere else. pub url: String, } /// What Android client the linked server has, if any. /// /// `Ok(None)` for a server that simply has none — that is the answer to the /// question, not a failure to answer it. pub async fn fetch_client_release( base_url: &str, token: &str, ) -> Result, String> { let url = format!("{base_url}/api/client/android"); let response = prepare(http()?.get(url), Some(token)) .send() .await .map_err(|e| describe_transport_error(base_url, &e))?; let status = response.status(); if status == StatusCode::NOT_FOUND { return Ok(None); } 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(Some) .map_err(|e| { format!("{base_url} described its Android client in a way this app could not read: {e}") }) } /// Download the client to `dest`, verifying it on the way in. /// /// Streamed rather than buffered: the APK is ~55 MiB and holding that in memory on /// a phone, on top of whatever the app is already using, is how an update gets /// killed by the low-memory killer half way through. /// /// Written to `dest.part` and renamed only once the digest matches, so an /// interrupted download can never be mistaken for a finished one. The digest is /// not a trust anchor — the APK signature is, and Android checks that at install — /// but it catches a truncated or corrupted transfer before the installer is /// bothered with it. pub async fn download_client( base_url: &str, token: &str, release: &ClientRelease, dest: &Path, ) -> Result<(), String> { use sha2::{Digest, Sha256}; use std::io::Write; // The advertised path is joined to the base we are LINKED to. Taking an // absolute URL from the response would let a server point the download at a // host the user never agreed to. let path = release.url.trim_start_matches('/'); let url = format!("{base_url}/{path}"); let mut response = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token)) .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)); } let partial = dest.with_extension("part"); if let Some(parent) = partial.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Couldn't prepare a place to download to: {e}"))?; } let mut file = std::fs::File::create(&partial) .map_err(|e| format!("Couldn't open the download file: {e}"))?; let mut hasher = Sha256::new(); let mut written: i64 = 0; loop { let chunk = response .chunk() .await .map_err(|e| describe_transport_error(base_url, &e))?; let Some(chunk) = chunk else { break }; hasher.update(&chunk); written += chunk.len() as i64; file.write_all(&chunk) .map_err(|e| format!("Couldn't write the download: {e}"))?; } file.flush() .map_err(|e| format!("Couldn't finish writing the download: {e}"))?; drop(file); let digest = format!("{:x}", hasher.finalize()); let mismatch = if written != release.size { Some(format!("expected {} bytes, got {written}", release.size)) } else if !digest.eq_ignore_ascii_case(&release.sha256) { Some("the contents did not match the checksum the server published".to_string()) } else { None }; if let Some(why) = mismatch { // The half-file is removed rather than left: a later run finding it would // have no way to tell it from a good one. let _ = std::fs::remove_file(&partial); return Err(format!("The download from {base_url} was damaged — {why}.")); } std::fs::rename(&partial, dest) .map_err(|e| format!("Couldn't put the downloaded update in place: {e}")) }