The desktop's sync_unlink and the ffi's unlink were both written out in full: try the revoke, clear the link either way, and log the outcome. link::unlink(db, held) now does that. Each client reads its link with state::credentials (with its seal) before the await and passes it in. DRY pass #2, batch 1, F3 (#5372). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
113 lines
4.2 KiB
Rust
113 lines
4.2 KiB
Rust
//! 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<u32>,
|
|
}
|
|
|
|
/// 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<Granted, String> {
|
|
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<u32>,
|
|
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<RevokeOutcome, String> {
|
|
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)
|
|
}
|