The pairing step. Nothing else in the sync arc can move until this works. sync/state.rs owns the link record in the sync_state row M10.4 already put in the local schema. Two safety properties are the reason it isn't just three setters: - Linking a DIFFERENT server resets the change-feed cursor. A cursor is only meaningful against the server that issued it; carrying one across would silently skip every change on the new server below that watermark — data loss wearing the costume of a successful sync. Re-linking the SAME server (a token refresh) keeps it, so a routine re-auth doesn't force a full re-download. - Unlink clears the cursor too, so a later link can't inherit a watermark from a server that never issued it. An unparseable or absent cursor reads as 0 (full sync). That direction is always safe: a redundant re-sync costs time, a too-high cursor costs notes. Likewise a half-written row (server but no token) reports NOT linked. state::Status deliberately has no device_token field — it crosses into the webview, and a long-lived bearer token has no business reachable from page scripts. A test asserts the token never appears in its serialization. Token lives in the app-data SQLite file, not an OS keyring: the keyring crate needs libsecret/DBus on Linux, which adds a C dependency to a binary that has to cross-compile and fails outright on headless/minimal-WM setups — the same class of environment assumption behind the black-window bug. sync_link runs the M10.6 handshake FIRST and refuses an incompatible server before any credential is sent. Two credential paths, because neither covers everyone: device-login (a fresh install has no session to mint a token from) and a pasted token (some users would rather not type a password into a desktop app). A pasted token is verified against /api/auth/me before being stored — auth.py's login_required accepts bearer — since an unverified paste would turn a copy/paste slip into a failure surfacing at the next sync, far from its cause. The store lock is taken only after all network work: a std MutexGuard isn't Send so it cannot cross an await, and holding the store for a round-trip would freeze every note operation in the UI. Unlink is LOCAL only — the token stays valid server-side until revoked under Account -> Linked devices. A pasted token arrives without its device id, so a reliable remote revoke isn't possible from here; the UI must say so rather than imply a revoke that didn't happen. Follow-up filed. No UI yet — that's M10.7e. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
244 lines
8.2 KiB
Rust
244 lines
8.2 KiB
Rust
//! 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, String> {
|
|
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<ProbeResult, String> {
|
|
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<Identity, String> {
|
|
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"
|
|
);
|
|
}
|
|
}
|