//! HTTP transport to an Inkwell 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, Response, 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); /// One file going up can be far larger than a page of notes, and a timeout shorter /// than the transfer is not a failure that retrying ever fixes — the same upload /// would time out again every cycle. const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600); /// 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() ) } /// Send `request`, failing the way every call to the server fails alike: a /// transport error, described for a person, and a 401, as `unauthorized` (what a /// rejected credential means depends on the call). Any other status is the /// caller's to read; [`send`] is this plus "anything but success is unexpected". async fn send_raw( base_url: &str, request: RequestBuilder, unauthorized: &str, ) -> Result { let response = request .send() .await .map_err(|e| describe_transport_error(base_url, &e))?; if response.status() == StatusCode::UNAUTHORIZED { return Err(unauthorized.to_string()); } Ok(response) } /// [`send_raw`], and anything but a success is [`unexpected_status`]. async fn send( base_url: &str, request: RequestBuilder, unauthorized: &str, ) -> Result { let response = send_raw(base_url, request, unauthorized).await?; let status = response.status(); if !status.is_success() { return Err(unexpected_status(base_url, status)); } Ok(response) } /// The server's own words for a refusal, when it gave some (`{"error": "…"}`). /// "file is too large (max 25 MB)" is what a person can act on; a status is not. async fn server_reason(response: Response) -> Option { let body = response.json::().await.ok()?; body.get("error")?.as_str().map(String::from) } /// 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 an /// Inkwell 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")?; // Before anything is sent, and in particular before a password or token is. compat::refuse_public_cleartext(&base_url)?; 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 an Inkwell 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 Inkwell'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 refused = "That email and password didn't match an account on this server."; let response = send(base_url, request, refused).await?; 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 refused = "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."; let response = send(base_url, request, refused).await?; 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, shares: bool, ) -> Result { // `shares=1` only to a server advertising `shares`: it asks for the notes shared // with this account and the `revoked` list, which an older server would ignore. let url = if shares { format!("{base_url}/api/sync/changes?since={since}&shares=1") } else { format!("{base_url}/api/sync/changes?since={since}") }; let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token)); let response = send(base_url, request, TOKEN_REJECTED).await?; 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 = send(base_url, request, TOKEN_REJECTED).await?; response .bytes() .await .map(|b| b.to_vec()) .map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}")) } /// Why an upload didn't land, split by whether trying again could help. #[derive(Debug, PartialEq, Eq)] pub enum UploadError { /// Worth another attempt next cycle: the network, a server error, or a note the /// server hasn't got yet. Retry(String), /// The server refused this file and will refuse it the same way every time — /// too large, an id in use, bytes that don't match their hash. Refused(String), } /// Upload one file attached on this device, under the id it was given here. /// /// `PUT /api/sync/attachments/` takes the raw bytes; idempotent, so a retry of /// an upload whose reply was lost answers 200 and stores nothing twice. #[allow(clippy::too_many_arguments)] pub async fn upload_attachment( base_url: &str, token: &str, note_id: &str, attachment_id: &str, filename: &str, mime: &str, sha256: &str, bytes: Vec, ) -> Result<(), UploadError> { let url = format!("{base_url}/api/sync/attachments/{attachment_id}"); let client = http_with(UPLOAD_TIMEOUT).map_err(UploadError::Retry)?; let request = prepare(client.put(url), Some(token)) .query(&[ ("note_id", note_id), ("filename", filename), ("sha256", sha256), ]) .header(reqwest::header::CONTENT_TYPE, mime) .body(bytes); let response = request .send() .await .map_err(|e| UploadError::Retry(describe_transport_error(base_url, &e)))?; let status = response.status(); if status.is_success() { return Ok(()); } if status == StatusCode::UNAUTHORIZED { return Err(UploadError::Retry(TOKEN_REJECTED.to_string())); } // The server's own words, when it gave some: "file is too large (max 25 MB)" is // what a person can act on, and a bare status code is not. let reason = server_reason(response) .await .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); if status.is_client_error() && status != StatusCode::NOT_FOUND { Err(UploadError::Refused(reason)) } else { // 404: the note isn't on the server yet (its push was rejected, say). 5xx: // the server's problem, and likely a passing one. Err(UploadError::Retry(reason)) } } /// 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 = send(base_url, request, TOKEN_REJECTED).await?; response .text() .await .map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}")) } // --- sharing (#5175) ----------------------------------------------------------- // // The Share dialog on a linked device asks the server directly, over the device // token: who is on the instance, and who a note is shared with. Shares are the // server's, so there is nothing to keep offline and nothing to queue. /// Someone on the instance a note can be shared with. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct Member { pub id: String, #[serde(default)] pub display_name: String, #[serde(default)] pub email: String, } /// A group the admin made, which a note can be shared with (#5177). Sharing with it /// reaches whoever is in it at the time. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct ShareGroup { pub id: String, #[serde(default)] pub name: String, #[serde(default)] pub member_count: u32, } /// Everyone a note can be shared with: the other people on the instance, and every /// group. `groups` defaults empty for a server from before groups. #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct Directory { #[serde(default)] pub members: Vec, #[serde(default)] pub groups: Vec, } /// One share of a note, at `view` or `edit`: to a person (`member`) or to a group /// (`group`), never both. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct NoteShare { pub id: String, #[serde(default)] pub member: Option, #[serde(default)] pub group: Option, pub permission: String, #[serde(default)] pub created_at: Option, } /// Who a new share goes to. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ShareTarget { Member(String), Group(String), } impl ShareTarget { fn body(&self, permission: &str) -> serde_json::Value { match self { ShareTarget::Member(id) => { serde_json::json!({ "user_id": id, "permission": permission }) } ShareTarget::Group(id) => { serde_json::json!({ "group_id": id, "permission": permission }) } } } } #[derive(Deserialize)] struct SharesReply { shares: Vec, } /// A note made on this device that hasn't been pushed yet is a 404 to the server, /// and so is one this account doesn't own. const SHARE_NOT_FOUND: &str = "The server doesn't have this note yet. Sync, then share it."; async fn read_reply( base_url: &str, request: RequestBuilder, ) -> Result { let response = send_raw(base_url, request, TOKEN_REJECTED).await?; let status = response.status(); if status == StatusCode::NOT_FOUND { return Err(SHARE_NOT_FOUND.to_string()); } if !status.is_success() { // The server's own words when it gave some ("choose someone to share with"). let reason = server_reason(response).await; return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status))); } response .json() .await .map_err(|e| format!("Couldn't read the reply from {base_url}: {e}")) } /// Everyone on the instance but this account, and every group. pub async fn directory(base_url: &str, token: &str) -> Result { let url = format!("{base_url}/api/users/directory"); read_reply(base_url, prepare(http()?.get(url), Some(token))).await } pub async fn list_shares( base_url: &str, token: &str, note_id: &str, ) -> Result, String> { let url = format!("{base_url}/api/notes/{note_id}/shares"); let reply: SharesReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?; Ok(reply.shares) } /// Share with a person or a group, or change their permission. Answers the note's /// shares. pub async fn share_note( base_url: &str, token: &str, note_id: &str, target: &ShareTarget, permission: &str, ) -> Result, String> { let url = format!("{base_url}/api/notes/{note_id}/shares"); let body = target.body(permission); let reply: SharesReply = read_reply( base_url, prepare(http()?.post(url), Some(token)).json(&body), ) .await?; Ok(reply.shares) } pub async fn unshare_note( base_url: &str, token: &str, note_id: &str, share_id: &str, ) -> Result, String> { let url = format!("{base_url}/api/notes/{note_id}/shares/{share_id}"); let reply: SharesReply = read_reply(base_url, prepare(http()?.delete(url), Some(token))).await?; Ok(reply.shares) } /// 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}") } } /// 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 request = prepare(http()?.get(url), Some(token)); let response = send_raw(base_url, request, TOKEN_REJECTED).await?; let status = response.status(); if status == StatusCode::NOT_FOUND { return Ok(None); } 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 request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token)); let mut response = send(base_url, request, TOKEN_REJECTED).await?; 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}")) } #[cfg(test)] mod tests { use super::*; #[test] fn a_share_names_a_person_or_a_group() { assert_eq!( ShareTarget::Member("u1".into()).body("view"), serde_json::json!({ "user_id": "u1", "permission": "view" }) ); assert_eq!( ShareTarget::Group("g1".into()).body("edit"), serde_json::json!({ "group_id": "g1", "permission": "edit" }) ); let raw = r#"{"id":"s2","member":null, "group":{"id":"g1","name":"Family","member_count":3}, "permission":"edit"}"#; let share: NoteShare = serde_json::from_str(raw).expect("a group share reads"); assert!(share.member.is_none()); assert_eq!(share.group.expect("group").member_count, 3); // A server from before groups sends no `groups` at all. let older: Directory = serde_json::from_str(r#"{"members":[]}"#).expect("directory"); assert!(older.groups.is_empty()); } #[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/inkwell"), "http://192.168.1.10:8000/inkwell/api/config" ); } }