//! Linking a device to a server, and unlinking it. //! //! One flow for every client. The desktop and Android each wrote it out, the same //! steps in the same order; what differs between them is only how the token is //! kept (Android seals it, `state::TokenSeal`) and how the result is reported. use rusqlite::Connection; use super::client::{self, Identity, RevokeOutcome}; use super::compat::Compatibility; use super::state::{self, TokenSeal}; use crate::local::Db; /// How a device proves whose it is. pub enum Credential<'a> { /// A password login, which mints a token named `device_name` on the server. Password { email: &'a str, password: &'a str, device_name: &'a str, }, /// A device token pasted from the web app, for anyone who would rather not /// type a password into an app. Verified before it is kept, so a copy/paste /// slip fails here rather than at the next sync. Token(&'a str), } /// A server that accepted this device, before anything is kept. pub struct Granted { pub base_url: String, pub token: String, pub identity: Identity, /// Carried through so a client can warn about a `degraded` server right after /// linking, instead of staying silent until a feature quietly does nothing. pub compatibility: Compatibility, pub retention_days: Option, } /// Ask the server at `url` to accept this device. Nothing is stored. /// /// The handshake runs FIRST, and an incompatible server is refused before any /// credential is sent: that is exactly the case where a later failure would be /// hardest to attribute. pub async fn authenticate(url: &str, credential: Credential<'_>) -> Result { let probe = client::probe(url).await?; if let Compatibility::Incompatible { reason, .. } = &probe.compatibility { return Err(reason.clone()); } let base_url = probe.base_url; let (token, identity) = match credential { Credential::Password { email, password, device_name, } => client::device_login(&base_url, email, password, device_name).await?, Credential::Token(token) => { let identity = client::fetch_identity(&base_url, token).await?; (token.to_string(), identity) } }; Ok(Granted { base_url, token, identity, compatibility: probe.compatibility, retention_days: probe.server.trash_retention_days, }) } /// Keep a link: sealed when the client has a seal, plain when it has none. /// /// The server's trash-retention window is adopted at the same time, so the Trash /// view stops counting down against this device's offline default the moment it /// is no longer the policy in force. pub fn store( conn: &Connection, base_url: &str, token: &str, retention_days: Option, seal: Option<&dyn TokenSeal>, ) -> rusqlite::Result<()> { match seal { Some(seal) => state::set_sealed_link(conn, base_url, token, seal)?, None => state::set_link(conn, base_url, token)?, } if let Some(days) = retention_days { state::set_server_retention(conn, i64::from(days))?; } log::info!("linked to {base_url}"); Ok(()) } /// Stop syncing, and retire this device's token on the server. /// /// `held` is the link as `state::credentials` read it, which the caller reads and /// releases before this awaits: a std MutexGuard isn't Send, and holding the store /// through a round-trip would freeze every note operation. None skips the revoke. /// /// The local half is unconditional. Someone unlinking because the device is being /// sold or handed on must not be held to it by a server that is offline or gone, /// so the revoke is tried first, its outcome returned for the UI to report /// honestly, and the link cleared either way. pub async fn unlink(db: &Db, held: Option<(String, String)>) -> Result { let revoked = match &held { Some((base_url, token)) => client::revoke_self(base_url, token).await, None => RevokeOutcome::Skipped, }; let conn = db.conn()?; state::clear_link(&conn).map_err(|e| e.to_string())?; log::info!("unlinked from server (server-side token: {revoked:?})"); Ok(revoked) }