diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 18:02:55 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 18:02:55 +0200 |
| commit | 0b6ca5cb8ca4c46265e08e13053260d9b5cff568 (patch) | |
| tree | 9dc656095f806e6dd1177e40b9a87cf6d6f10f1b | |
| parent | chore(server): Remove the last remnants of the "hub" sync-server thingy (diff) | |
| download | atuin-0b6ca5cb8ca4c46265e08e13053260d9b5cff568.zip | |
feat(server): Make user stuff stateless
36 files changed, 104 insertions, 1740 deletions
diff --git a/crates/turtle/src/atuin_client/api_client.rs b/crates/turtle/src/atuin_client/api_client.rs index 46995c9a..b4657a47 100644 --- a/crates/turtle/src/atuin_client/api_client.rs +++ b/crates/turtle/src/atuin_client/api_client.rs @@ -1,11 +1,10 @@ -use std::collections::HashMap; use std::env; use std::time::Duration; use eyre::{Result, bail, eyre}; use reqwest::{ Response, StatusCode, Url, - header::{AUTHORIZATION, HeaderMap, USER_AGENT}, + header::{AUTHORIZATION, HeaderMap}, }; use tracing::debug; @@ -15,10 +14,7 @@ use crate::atuin_common::{ tls::ensure_crypto_provider, }; use crate::atuin_common::{ - api::{ - ChangePasswordRequest, ErrorResponse, LoginRequest, LoginResponse, MeResponse, - RegisterResponse, - }, + api::{ErrorResponse, MeResponse}, record::RecordStatus, }; @@ -63,65 +59,6 @@ fn make_url(address: &str, path: &str) -> Result<String> { Ok(url.to_string()) } -pub(crate) async fn register( - address: &str, - username: &str, - email: &str, - password: &str, -) -> Result<RegisterResponse> { - ensure_crypto_provider(); - let mut map = HashMap::new(); - map.insert("username", username); - map.insert("email", email); - map.insert("password", password); - - let url = make_url(address, &format!("/user/{username}"))?; - let resp = reqwest::get(url).await?; - - if resp.status().is_success() { - bail!("username already in use"); - } - - let url = make_url(address, "/register")?; - let client = reqwest::Client::new(); - let resp = client - .post(url) - .header(USER_AGENT, APP_USER_AGENT) - .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION) - .json(&map) - .send() - .await?; - let resp = handle_resp_error(resp).await?; - - if !ensure_version(&resp)? { - bail!("could not register user due to version mismatch"); - } - - let session = resp.json::<RegisterResponse>().await?; - Ok(session) -} - -pub(crate) async fn login(address: &str, req: LoginRequest) -> Result<LoginResponse> { - ensure_crypto_provider(); - let url = make_url(address, "/login")?; - let client = reqwest::Client::new(); - - let resp = client - .post(url) - .header(USER_AGENT, APP_USER_AGENT) - .json(&req) - .send() - .await?; - let resp = handle_resp_error(resp).await?; - - if !ensure_version(&resp)? { - bail!("Could not login due to version mismatch"); - } - - let session = resp.json::<LoginResponse>().await?; - Ok(session) -} - pub(crate) fn ensure_version(response: &Response) -> Result<bool> { let version = response.headers().get(ATUIN_HEADER_VERSION); @@ -287,48 +224,4 @@ impl<'a> Client<'a> { Ok(index) } - - pub(crate) async fn delete(&self) -> Result<()> { - let url = make_url(self.sync_addr, "/account")?; - let url = Url::parse(url.as_str())?; - - let resp = self.client.delete(url).send().await?; - - if resp.status() == 403 { - bail!("invalid login details"); - } else if resp.status() == 200 { - Ok(()) - } else { - bail!("Unknown error"); - } - } - - pub(crate) async fn change_password( - &self, - current_password: String, - new_password: String, - ) -> Result<()> { - let url = make_url(self.sync_addr, "/account/password")?; - let url = Url::parse(url.as_str())?; - - let resp = self - .client - .patch(url) - .json(&ChangePasswordRequest { - current_password, - new_password, - }) - .send() - .await?; - - if resp.status() == 401 { - bail!("current password is incorrect") - } else if resp.status() == 403 { - bail!("invalid login details"); - } else if resp.status() == 200 { - Ok(()) - } else { - bail!("Unknown error"); - } - } } diff --git a/crates/turtle/src/atuin_client/auth.rs b/crates/turtle/src/atuin_client/auth.rs deleted file mode 100644 index 620e127e..00000000 --- a/crates/turtle/src/atuin_client/auth.rs +++ /dev/null @@ -1,181 +0,0 @@ -use eyre::{Context, Result, bail}; -use reqwest::{Url, header::USER_AGENT}; - -use crate::{ - atuin_client::api_client, - atuin_common::{ - api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ChangePasswordRequest, LoginRequest}, - tls::ensure_crypto_provider, - }, -}; - -use crate::atuin_client::settings::Settings; - -static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION")); - -/// Result of an auth operation -pub(crate) struct AuthResponse { - pub(crate) session: String, -} - -/// Resolve the appropriate [`AuthClient`] for the current settings. -pub(crate) async fn auth_client(settings: &Settings) -> LegacyAuthClient { - LegacyAuthClient::new( - &settings.sync_address, - settings.session_token().await.ok(), - settings.network_connect_timeout, - settings.network_timeout, - ) -} - -// --------------------------------------------------------------------------- -// Legacy backend — talks to the Rust sync server -// --------------------------------------------------------------------------- - -pub(crate) struct LegacyAuthClient { - address: String, - session_token: Option<String>, - connect_timeout: u64, - timeout: u64, -} - -impl LegacyAuthClient { - pub(crate) fn new( - address: &str, - session_token: Option<String>, - connect_timeout: u64, - timeout: u64, - ) -> Self { - Self { - address: address.to_string(), - session_token, - connect_timeout, - timeout, - } - } - - fn authenticated_client(&self) -> Result<reqwest::Client> { - let token = self - .session_token - .as_deref() - .ok_or_else(|| eyre::eyre!("Not logged in"))?; - - ensure_crypto_provider(); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::AUTHORIZATION, - format!("Token {token}").parse()?, - ); - headers.insert(USER_AGENT, APP_USER_AGENT.parse()?); - headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?); - - Ok(reqwest::Client::builder() - .default_headers(headers) - .connect_timeout(std::time::Duration::new(self.connect_timeout, 0)) - .timeout(std::time::Duration::new(self.timeout, 0)) - .build()?) - } -} - -impl LegacyAuthClient { - /// Log in with username + password, optionally providing a TOTP code. - pub(crate) async fn login(&self, username: &str, password: &str) -> Result<AuthResponse> { - // The legacy server has no 2FA support; totp_code is ignored. - let resp = api_client::login( - &self.address, - LoginRequest { - username: username.to_string(), - password: password.to_string(), - }, - ) - .await?; - - Ok(AuthResponse { - session: resp.session, - }) - } - - /// Register a new account. - pub(crate) async fn register( - &self, - username: &str, - email: &str, - password: &str, - ) -> Result<AuthResponse> { - let resp = api_client::register(&self.address, username, email, password).await?; - Ok(AuthResponse { - session: resp.session, - }) - } - - /// Change the account password, optionally providing a TOTP code. - pub(crate) async fn change_password( - &self, - current_password: &str, - new_password: &str, - _totp_code: Option<&str>, - ) -> Result<()> { - let client = self.authenticated_client()?; - let url = make_url(&self.address, "/account/password")?; - - let resp = client - .patch(&url) - .json(&ChangePasswordRequest { - current_password: current_password.to_string(), - new_password: new_password.to_string(), - }) - .send() - .await?; - - match resp.status().as_u16() { - 200 => Ok(()), - 401 => bail!("current password is incorrect"), - 403 => bail!("invalid login details"), - _ => bail!("unknown error"), - } - } - - /// Delete the account, requiring the current password and optionally a TOTP code. - pub(crate) async fn delete_account( - &self, - password: &str, - _totp_code: Option<&str>, - ) -> Result<()> { - let client = self.authenticated_client()?; - let url = make_url(&self.address, "/account")?; - - let resp = client - .delete(&url) - .json(&serde_json::json!({ "password": password })) - .send() - .await?; - - match resp.status().as_u16() { - 200 => Ok(()), - 401 => bail!("password is incorrect"), - 403 => bail!("invalid login details"), - _ => bail!("unknown error"), - } - } -} - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -fn make_url(address: &str, path: &str) -> Result<String> { - let address = if address.ends_with('/') { - address.to_string() - } else { - format!("{address}/") - }; - - let path = path.strip_prefix('/').unwrap_or(path); - - let url = Url::parse(&address) - .context("failed to parse server address")? - .join(path) - .context("failed to join URL path")?; - - Ok(url.to_string()) -} diff --git a/crates/turtle/src/atuin_client/login.rs b/crates/turtle/src/atuin_client/login.rs deleted file mode 100644 index 91876744..00000000 --- a/crates/turtle/src/atuin_client/login.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::path::PathBuf; - -use crate::atuin_common::api::LoginRequest; -use eyre::{Context, Result, bail}; -use tokio::fs::File; -use tokio::io::AsyncWriteExt; - -use crate::atuin_client::{ - api_client, - encryption::{decode_key, load_key}, - record::{sqlite_store::SqliteStore, store::Store}, - settings::Settings, -}; - -pub(crate) async fn login( - settings: &Settings, - store: &SqliteStore, - username: String, - password: String, - key: String, -) -> Result<String> { - let key_path = settings.key_path.as_str(); - let key_path = PathBuf::from(key_path); - - if !key_path.exists() { - if decode_key(key.clone()).is_err() { - bail!("the specified key was invalid"); - } - - let mut file = File::create(&key_path).await?; - file.write_all(key.as_bytes()).await?; - } else { - // we now know that the user has logged in specifying a key, AND that the key path - // exists - - // 1. check if the saved key and the provided key match. if so, nothing to do. - // 2. if not, re-encrypt the local history and overwrite the key - let current_key: [u8; 32] = load_key(settings)?.into(); - - let encoded = key.clone(); // gonna want to save it in a bit - let new_key: [u8; 32] = decode_key(key) - .context("could not decode provided key - is not valid base64")? - .into(); - - if new_key != current_key { - println!("\nRe-encrypting local store with new key"); - - store.re_encrypt(¤t_key, &new_key).await?; - - println!("Writing new key"); - let mut file = File::create(&key_path).await?; - file.write_all(encoded.as_bytes()).await?; - } - } - - let session = api_client::login( - settings.sync_address.as_str(), - LoginRequest { username, password }, - ) - .await?; - - Settings::meta_store() - .await? - .save_session(&session.session) - .await?; - - Ok(session.session) -} diff --git a/crates/turtle/src/atuin_client/logout.rs b/crates/turtle/src/atuin_client/logout.rs deleted file mode 100644 index 2ec41e40..00000000 --- a/crates/turtle/src/atuin_client/logout.rs +++ /dev/null @@ -1,16 +0,0 @@ -use eyre::Result; - -use crate::atuin_client::settings::Settings; - -pub(crate) async fn logout() -> Result<()> { - let meta = Settings::meta_store().await?; - - if meta.logged_in().await? { - meta.delete_session().await?; - println!("You have logged out!"); - } else { - println!("You are not logged in"); - } - - Ok(()) -} diff --git a/crates/turtle/src/atuin_client/meta.rs b/crates/turtle/src/atuin_client/meta.rs index 92902c08..f3815b9e 100644 --- a/crates/turtle/src/atuin_client/meta.rs +++ b/crates/turtle/src/atuin_client/meta.rs @@ -142,22 +142,6 @@ impl MetaStore { ) .await } - - pub(crate) async fn session_token(&self) -> Result<Option<String>> { - self.get(KEY_SESSION).await - } - - pub(crate) async fn save_session(&self, token: &str) -> Result<()> { - self.set(KEY_SESSION, token).await - } - - pub(crate) async fn delete_session(&self) -> Result<()> { - self.delete(KEY_SESSION).await - } - - pub(crate) async fn logged_in(&self) -> Result<bool> { - Ok(self.session_token().await?.is_some()) - } } #[cfg(test)] @@ -205,22 +189,4 @@ mod tests { let t = store.last_sync().await.unwrap(); assert!(t > OffsetDateTime::UNIX_EPOCH); } - - #[tokio::test] - async fn test_session_crud() { - let store = new_test_store().await; - - assert!(!store.logged_in().await.unwrap()); - assert_eq!(store.session_token().await.unwrap(), None); - - store.save_session("tok123").await.unwrap(); - assert!(store.logged_in().await.unwrap()); - assert_eq!( - store.session_token().await.unwrap(), - Some("tok123".to_string()) - ); - - store.delete_session().await.unwrap(); - assert!(!store.logged_in().await.unwrap()); - } } diff --git a/crates/turtle/src/atuin_client/mod.rs b/crates/turtle/src/atuin_client/mod.rs index ff376c0c..a4323f56 100644 --- a/crates/turtle/src/atuin_client/mod.rs +++ b/crates/turtle/src/atuin_client/mod.rs @@ -1,18 +1,11 @@ #[cfg(feature = "sync")] pub(crate) mod api_client; -#[cfg(feature = "sync")] -pub(crate) mod auth; -#[cfg(feature = "sync")] -pub(crate) mod login; -#[cfg(feature = "sync")] -pub(crate) mod register; pub(crate) mod database; pub(crate) mod distro; pub(crate) mod encryption; pub(crate) mod history; pub(crate) mod import; -pub(crate) mod logout; pub(crate) mod meta; pub(crate) mod ordering; pub(crate) mod plugin; diff --git a/crates/turtle/src/atuin_client/record/sync.rs b/crates/turtle/src/atuin_client/record/sync.rs index 36eaec91..4284da87 100644 --- a/crates/turtle/src/atuin_client/record/sync.rs +++ b/crates/turtle/src/atuin_client/record/sync.rs @@ -62,8 +62,10 @@ pub(crate) async fn build_client(settings: &Settings) -> Result<Client<'_>, Sync Client::new( &settings.sync_address, settings - .sync_auth_token() + .sync_auth() .await + .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })? + .into_auth_token() .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?, settings.network_connect_timeout, settings.network_timeout, diff --git a/crates/turtle/src/atuin_client/register.rs b/crates/turtle/src/atuin_client/register.rs deleted file mode 100644 index 1c78b6bc..00000000 --- a/crates/turtle/src/atuin_client/register.rs +++ /dev/null @@ -1,20 +0,0 @@ -use eyre::Result; - -use crate::atuin_client::{api_client, settings::Settings}; - -pub(crate) async fn register_classic( - settings: &Settings, - username: String, - email: String, - password: String, -) -> Result<String> { - let session = - api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; - - let meta = Settings::meta_store().await?; - meta.save_session(&session.session).await?; - - let _key = crate::atuin_client::encryption::load_key(settings)?; - - Ok(session.session) -} diff --git a/crates/turtle/src/atuin_client/settings.rs b/crates/turtle/src/atuin_client/settings.rs index 5ee7cb77..e8ff98ee 100644 --- a/crates/turtle/src/atuin_client/settings.rs +++ b/crates/turtle/src/atuin_client/settings.rs @@ -1038,7 +1038,7 @@ impl Settings { } pub(crate) async fn should_sync(&self) -> Result<bool> { - if !self.auto_sync || !Self::meta_store().await?.logged_in().await? { + if !self.auto_sync || !self.have_sync_key().await? { return Ok(false); } @@ -1055,52 +1055,14 @@ impl Settings { } } - pub(crate) async fn logged_in(&self) -> Result<bool> { - Self::meta_store().await?.logged_in().await + pub(crate) async fn have_sync_key(&self) -> Result<bool> { + let sa = self.sync_auth().await?; + Ok(matches!(sa, SyncAuth::Legacy { .. })) } - pub(crate) async fn session_token(&self) -> Result<String> { - match Self::meta_store().await?.session_token().await? { - Some(token) => Ok(token), - None => Err(eyre!("Tried to load session; not logged in")), - } - } - - /// Examines the configured sync target and available tokens to determine - /// the correct auth strategy. Also performs cleanup of mis-stored tokens - /// (e.g. a CLI token incorrectly saved in the Hub session slot). - #[cfg(feature = "sync")] - pub(crate) async fn resolve_sync_auth(&self) -> SyncAuth { - let meta = match Self::meta_store().await { - Ok(m) => m, - Err(e) => { - return SyncAuth::NotLoggedIn { - reason: format!("Failed to open meta store: {e}"), - }; - } - }; - - // Self-hosted / legacy server - match meta.session_token().await { - Ok(Some(token)) => SyncAuth::Legacy { token }, - _ => SyncAuth::NotLoggedIn { - reason: "Not logged in. Run 'atuin login' to authenticate \ - with your sync server." - .into(), - }, - } - } - - /// Returns the appropriate auth token for sync operations. - /// - /// Delegates to [`resolve_sync_auth`] and converts the result to an - /// `AuthToken`. Callers that need to distinguish between auth states - /// (e.g. to show different UI) should call `resolve_sync_auth` directly. - #[cfg(feature = "sync")] - pub(crate) async fn sync_auth_token( - &self, - ) -> Result<crate::atuin_client::api_client::AuthToken> { - self.resolve_sync_auth().await.into_auth_token() + pub(crate) async fn sync_auth(&self) -> Result<SyncAuth> { + // TODO(@bpeetz): Add this <2026-06-11> + todo!() } pub(crate) fn default_filter_mode(&self, git_root: bool) -> FilterMode { diff --git a/crates/turtle/src/atuin_common/api.rs b/crates/turtle/src/atuin_common/api.rs index 4566c6e9..c18db04f 100644 --- a/crates/turtle/src/atuin_common/api.rs +++ b/crates/turtle/src/atuin_common/api.rs @@ -11,35 +11,17 @@ pub(crate) static ATUIN_VERSION: LazyLock<Version> = LazyLock::new(|| Version::parse(ATUIN_CARGO_VERSION).expect("failed to parse self semver")); #[derive(Debug, Serialize, Deserialize)] -pub(crate) struct UserResponse { - pub(crate) username: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub(crate) struct RegisterRequest { - pub(crate) email: String, - pub(crate) username: String, - pub(crate) password: String, -} - -#[derive(Debug, Serialize, Deserialize)] pub(crate) struct RegisterResponse { pub(crate) session: String, } #[derive(Debug, Serialize, Deserialize)] -pub(crate) struct DeleteUserResponse {} - -#[derive(Debug, Serialize, Deserialize)] pub(crate) struct ChangePasswordRequest { pub(crate) current_password: String, pub(crate) new_password: String, } #[derive(Debug, Serialize, Deserialize)] -pub(crate) struct ChangePasswordResponse {} - -#[derive(Debug, Serialize, Deserialize)] pub(crate) struct LoginRequest { pub(crate) username: String, pub(crate) password: String, diff --git a/crates/turtle/src/atuin_daemon/components/sync.rs b/crates/turtle/src/atuin_daemon/components/sync.rs index 93d1024a..fdd00b5f 100644 --- a/crates/turtle/src/atuin_daemon/components/sync.rs +++ b/crates/turtle/src/atuin_daemon/components/sync.rs @@ -190,7 +190,7 @@ async fn do_sync_tick( tracing::info!("sync tick"); // Check if logged in - let logged_in = match settings.logged_in().await { + let logged_in = match settings.have_sync_key().await { Ok(v) => v, Err(e) => { tracing::warn!("failed to check login status, skipping sync tick: {e}"); diff --git a/crates/turtle/src/atuin_server/database/db/mod.rs b/crates/turtle/src/atuin_server/database/db/mod.rs index e0c6b736..4ec51bf1 100644 --- a/crates/turtle/src/atuin_server/database/db/mod.rs +++ b/crates/turtle/src/atuin_server/database/db/mod.rs @@ -4,16 +4,13 @@ use rand::Rng; use crate::{ atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus}, - atuin_server::database::{ - DbError, DbResult, DbSettings, - models::{NewSession, NewUser, Session, User}, - }, + atuin_server::database::{DbError, DbResult, DbSettings, models::User}, }; use sqlx::postgres::PgPoolOptions; use tracing::instrument; use uuid::Uuid; -use wrappers::{DbRecord, DbSession, DbUser}; +use wrappers::DbRecord; mod wrappers; @@ -96,148 +93,6 @@ impl Database { } #[instrument(skip_all)] - pub(crate) async fn get_user(&self, username: &str) -> DbResult<User> { - sqlx::query_as("select id, username, email, password from users where username = $1") - .bind(username) - .fetch_one(self.read_pool()) - .await - .map_err(Into::into) - .map(|DbUser(user)| user) - } - - #[instrument(skip_all)] - pub(crate) async fn get_session_user(&self, token: &str) -> DbResult<User> { - sqlx::query_as( - "select users.id, users.username, users.email, users.password from users - inner join sessions - on users.id = sessions.user_id - and sessions.token = $1", - ) - .bind(token) - .fetch_one(self.read_pool()) - .await - .map_err(Into::into) - .map(|DbUser(user)| user) - } - - pub(crate) async fn delete_store(&self, user: &User) -> DbResult<()> { - let mut tx = self.pool.begin().await?; - - sqlx::query( - "delete from store - where user_id = $1", - ) - .bind(user.id) - .execute(&mut *tx) - .await?; - - sqlx::query( - "delete from store_idx_cache - where user_id = $1", - ) - .bind(user.id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(()) - } - - #[instrument(skip_all)] - pub(crate) async fn delete_user(&self, u: &User) -> DbResult<()> { - sqlx::query("delete from sessions where user_id = $1") - .bind(u.id) - .execute(&self.pool) - .await?; - - sqlx::query("delete from history where user_id = $1") - .bind(u.id) - .execute(&self.pool) - .await?; - - sqlx::query("delete from store where user_id = $1") - .bind(u.id) - .execute(&self.pool) - .await?; - - sqlx::query("delete from total_history_count_user where user_id = $1") - .bind(u.id) - .execute(&self.pool) - .await?; - - sqlx::query("delete from users where id = $1") - .bind(u.id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - #[instrument(skip_all)] - pub(crate) async fn update_user_password(&self, user: &User) -> DbResult<()> { - sqlx::query( - "update users - set password = $1 - where id = $2", - ) - .bind(&user.password) - .bind(user.id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - #[instrument(skip_all)] - pub(crate) async fn add_user(&self, user: &NewUser) -> DbResult<i64> { - let email: &str = &user.email; - let username: &str = &user.username; - let password: &str = &user.password; - - let res: (i64,) = sqlx::query_as( - "insert into users - (username, email, password) - values($1, $2, $3) - returning id", - ) - .bind(username) - .bind(email) - .bind(password) - .fetch_one(&self.pool) - .await?; - - Ok(res.0) - } - - #[instrument(skip_all)] - pub(crate) async fn add_session(&self, session: &NewSession) -> DbResult<()> { - let token: &str = &session.token; - - sqlx::query( - "insert into sessions - (user_id, token) - values($1, $2)", - ) - .bind(session.user_id) - .bind(token) - .execute(&self.pool) - .await?; - - Ok(()) - } - - #[instrument(skip_all)] - pub(crate) async fn get_user_session(&self, u: &User) -> DbResult<Session> { - sqlx::query_as("select id, user_id, token from sessions where user_id = $1") - .bind(u.id) - .fetch_one(self.read_pool()) - .await - .map_err(Into::into) - .map(|DbSession(session)| session) - } - - #[instrument(skip_all)] pub(crate) async fn add_records( &self, user: &User, @@ -258,10 +113,10 @@ impl Database { let id = crate::atuin_common::utils::uuid_v7(); let result = sqlx::query( - "insert into store - (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - on conflict do nothing + " + INSERT INTO store (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON conflict DO nothing ", ) .bind(id) @@ -293,10 +148,11 @@ impl Database { // we've built the map of heads for this push, so commit it to the database for ((host, tag), idx) in heads { sqlx::query( - "insert into store_idx_cache - (user_id, host, tag, idx) - values ($1, $2, $3, $4) - on conflict(user_id, host, tag) do update set idx = greatest(store_idx_cache.idx, $4) + " + INSERT INTO store_idx_cache (user_id, host, tag, idx) + VALUES ($1, $2, $3, $4) + ON conflict(user_id, host, tag) DO update + SET idx = greatest(store_idx_cache.idx, $4) ", ) .bind(user.id) @@ -304,8 +160,7 @@ impl Database { .bind(tag) .bind(idx as i64) .execute(&mut *tx) - .await - ?; + .await?; } tx.commit().await?; @@ -326,13 +181,15 @@ impl Database { let start = start.unwrap_or(0); let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as( - "select client_id, host, idx, timestamp, version, tag, data, cek from store - where user_id = $1 - and tag = $2 - and host = $3 - and idx >= $4 - order by idx asc - limit $5", + " + SELECT client_id, host, idx, timestamp, version, tag, data, cek FROM store + WHERE user_id = $1 + AND tag = $2 + AND host = $3 + AND idx >= $4 + ORDER BY idx asc + LIMIT $5 + ", ) .bind(user.id) .bind(tag.clone()) @@ -366,9 +223,6 @@ impl Database { } pub(crate) async fn status(&self, user: &User) -> DbResult<RecordStatus> { - const STATUS_SQL: &str = - "select host, tag, max(idx) from store where user_id = $1 group by host, tag"; - // If IDX_CACHE_ROLLOUT is set, then we // 1. Read the value of the var, use it as a % chance of using the cache // 2. If we use the cache, just read from the cache table @@ -381,16 +235,29 @@ impl Database { let mut res: Vec<(Uuid, String, i64)> = if use_idx_cache { tracing::debug!("using idx cache for user {}", user.id); - sqlx::query_as("select host, tag, idx from store_idx_cache where user_id = $1") - .bind(user.id) - .fetch_all(self.read_pool()) - .await? + sqlx::query_as( + " + SELECT host, tag, idx + FROM store_idx_cache + WHERE user_id = $1 + ", + ) + .bind(user.id) + .fetch_all(self.read_pool()) + .await? } else { tracing::debug!("using aggregate query for user {}", user.id); - sqlx::query_as(STATUS_SQL) - .bind(user.id) - .fetch_all(self.read_pool()) - .await? + sqlx::query_as( + " + SELECT host, tag, max(idx) + FROM store + WHERE user_id = $1 + GROUP BY host, tag + ", + ) + .bind(user.id) + .fetch_all(self.read_pool()) + .await? }; res.sort(); diff --git a/crates/turtle/src/atuin_server/database/db/wrappers.rs b/crates/turtle/src/atuin_server/database/db/wrappers.rs index c0633202..40fd5b4a 100644 --- a/crates/turtle/src/atuin_server/database/db/wrappers.rs +++ b/crates/turtle/src/atuin_server/database/db/wrappers.rs @@ -1,25 +1,12 @@ use crate::{ atuin_common::record::{EncryptedData, Host, Record}, - atuin_server::database::models::{Session, User}, + atuin_server::database::models::Session, }; -use ::sqlx::{FromRow, Result}; use sqlx::{Row, postgres::PgRow}; -pub struct DbUser(pub User); pub struct DbSession(pub Session); pub struct DbRecord(pub Record<EncryptedData>); -impl<'a> FromRow<'a, PgRow> for DbUser { - fn from_row(row: &'a PgRow) -> Result<Self> { - Ok(Self(User { - id: row.try_get("id")?, - username: row.try_get("username")?, - email: row.try_get("email")?, - password: row.try_get("password")?, - })) - } -} - impl<'a> ::sqlx::FromRow<'a, PgRow> for DbSession { fn from_row(row: &'a PgRow) -> ::sqlx::Result<Self> { Ok(Self(Session { diff --git a/crates/turtle/src/atuin_server/database/models.rs b/crates/turtle/src/atuin_server/database/models.rs index e47d614d..3fa6f471 100644 --- a/crates/turtle/src/atuin_server/database/models.rs +++ b/crates/turtle/src/atuin_server/database/models.rs @@ -1,52 +1,5 @@ -use time::OffsetDateTime; - -pub(crate) struct History { - pub(crate) id: i64, - pub(crate) client_id: String, // a client generated ID - pub(crate) user_id: i64, - pub(crate) hostname: String, - pub(crate) timestamp: OffsetDateTime, - - /// All the data we have about this command, encrypted. - /// - /// Currently this is an encrypted msgpack object, but this may change in the future. - pub(crate) data: String, - - pub(crate) created_at: OffsetDateTime, -} - -pub(crate) struct NewHistory { - pub(crate) client_id: String, - pub(crate) user_id: i64, - pub(crate) hostname: String, - pub(crate) timestamp: OffsetDateTime, - - /// All the data we have about this command, encrypted. - /// - /// Currently this is an encrypted msgpack object, but this may change in the future. - pub(crate) data: String, -} +use uuid::Uuid; pub(crate) struct User { - pub(crate) id: i64, - pub(crate) username: String, - pub(crate) email: String, - pub(crate) password: String, -} - -pub(crate) struct Session { - pub(crate) id: i64, - pub(crate) user_id: i64, - pub(crate) token: String, -} - -pub(crate) struct NewUser { - pub(crate) username: String, - pub(crate) email: String, - pub(crate) password: String, -} - -pub(crate) struct NewSession { - pub(crate) user_id: i64, - pub(crate) token: String, + pub(crate) id: Uuid, } diff --git a/crates/turtle/src/atuin_server/handlers/health.rs b/crates/turtle/src/atuin_server/handlers/health.rs deleted file mode 100644 index d39f7aa5..00000000 --- a/crates/turtle/src/atuin_server/handlers/health.rs +++ /dev/null @@ -1,15 +0,0 @@ -use axum::{Json, http, response::IntoResponse}; - -use serde::Serialize; - -#[derive(Serialize)] -pub(crate) struct HealthResponse { - pub(crate) status: &'static str, -} - -pub(crate) async fn health_check() -> impl IntoResponse { - ( - http::StatusCode::OK, - Json(HealthResponse { status: "healthy" }), - ) -} diff --git a/crates/turtle/src/atuin_server/handlers/mod.rs b/crates/turtle/src/atuin_server/handlers/mod.rs index 3b935834..7aded3de 100644 --- a/crates/turtle/src/atuin_server/handlers/mod.rs +++ b/crates/turtle/src/atuin_server/handlers/mod.rs @@ -3,9 +3,6 @@ use axum::{Json, extract::State, http, response::IntoResponse}; use crate::atuin_server::router::AppState; -pub(crate) mod health; -pub(crate) mod record; -pub(crate) mod user; pub(crate) mod v0; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -17,7 +14,7 @@ pub(crate) async fn index(state: State<AppState>) -> Json<IndexResponse> { .settings .fake_version .clone() - .unwrap_or(VERSION.to_string()); + .unwrap_or_else(|| VERSION.to_string()); Json(IndexResponse { homage: homage.to_string(), diff --git a/crates/turtle/src/atuin_server/handlers/record.rs b/crates/turtle/src/atuin_server/handlers/record.rs deleted file mode 100644 index 39060423..00000000 --- a/crates/turtle/src/atuin_server/handlers/record.rs +++ /dev/null @@ -1,42 +0,0 @@ -use axum::{Json, http::StatusCode, response::IntoResponse}; -use serde_json::json; -use tracing::instrument; - -use super::{ErrorResponse, ErrorResponseStatus, RespExt}; -use crate::atuin_server::router::UserAuth; - -use crate::atuin_common::record::{EncryptedData, Record}; - -#[instrument(skip_all, fields(user.id = user.id))] -pub(crate) async fn post(UserAuth(user): UserAuth) -> Result<(), ErrorResponseStatus<'static>> { - // anyone who has actually used the old record store (a very small number) will see this error - // upon trying to sync. - // 1. The status endpoint will say that the server has nothing - // 2. The client will try to upload local records - // 3. Sync will fail with this error - - // If the client has no local records, they will see the empty index and do nothing. For the - // vast majority of users, this is the case. - return Err( - ErrorResponse::reply("record store deprecated; please upgrade") - .with_status(StatusCode::BAD_REQUEST), - ); -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub(crate) async fn index(UserAuth(user): UserAuth) -> axum::response::Response { - let ret = json!({ - "hosts": {} - }); - - ret.to_string().into_response() -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub(crate) async fn next( - UserAuth(user): UserAuth, -) -> Result<Json<Vec<Record<EncryptedData>>>, ErrorResponseStatus<'static>> { - let records = Vec::new(); - - Ok(Json(records)) -} diff --git a/crates/turtle/src/atuin_server/handlers/user.rs b/crates/turtle/src/atuin_server/handlers/user.rs deleted file mode 100644 index e777acc3..00000000 --- a/crates/turtle/src/atuin_server/handlers/user.rs +++ /dev/null @@ -1,267 +0,0 @@ -use std::borrow::Borrow; -use std::collections::HashMap; -use std::time::Duration; - -use argon2::{ - Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, - password_hash::SaltString, -}; -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, -}; -use metrics::counter; - -use rand::rngs::OsRng; -use tracing::{debug, error, info, instrument}; - -use crate::{ - atuin_common::tls::ensure_crypto_provider, - atuin_server::database::{ - DbError, - models::{NewSession, NewUser}, - }, -}; - -use super::{ErrorResponse, ErrorResponseStatus, RespExt}; -use crate::atuin_server::router::{AppState, UserAuth}; - -use reqwest::header::CONTENT_TYPE; - -use crate::atuin_common::{api::*, utils::crypto_random_string}; - -pub(crate) fn verify_str(hash: &str, password: &str) -> bool { - let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); - let Ok(hash) = PasswordHash::new(hash) else { - return false; - }; - arg2.verify_password(password.as_bytes(), &hash).is_ok() -} - -// Try to send a Discord webhook once - if it fails, we don't retry. "At most once", and best effort. -// Don't return the status because if this fails, we don't really care. -async fn send_register_hook(url: &str, username: String, registered: String) { - ensure_crypto_provider(); - let hook = HashMap::from([ - ("username", username), - ("content", format!("{registered} has just signed up!")), - ]); - - let client = reqwest::Client::new(); - - let resp = client - .post(url) - .timeout(Duration::new(5, 0)) - .header(CONTENT_TYPE, "application/json") - .json(&hook) - .send() - .await; - - match resp { - Ok(_) => info!("register webhook sent ok!"), - Err(e) => error!("failed to send register webhook: {}", e), - } -} - -#[instrument(skip_all, fields(user.username = username.as_str()))] -pub(crate) async fn get( - Path(username): Path<String>, - state: State<AppState>, -) -> Result<Json<UserResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - let user = match db.get_user(username.as_ref()).await { - Ok(user) => user, - Err(DbError::NotFound) => { - debug!("user not found: {}", username); - return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); - } - Err(DbError::Other(err)) => { - error!("database error: {}", err); - return Err(ErrorResponse::reply("database error") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }; - - Ok(Json(UserResponse { - username: user.username, - })) -} - -#[instrument(skip_all)] -pub(crate) async fn register( - state: State<AppState>, - Json(register): Json<RegisterRequest>, -) -> Result<Json<RegisterResponse>, ErrorResponseStatus<'static>> { - if !state.settings.open_registration { - return Err( - ErrorResponse::reply("this server is not open for registrations") - .with_status(StatusCode::BAD_REQUEST), - ); - } - - for c in register.username.chars() { - match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' => {} - _ => { - return Err(ErrorResponse::reply( - "Only alphanumeric and hyphens (-) are allowed in usernames", - ) - .with_status(StatusCode::BAD_REQUEST)); - } - } - } - - let hashed = hash_secret(®ister.password); - - let new_user = NewUser { - email: register.email.clone(), - username: register.username.clone(), - password: hashed, - }; - - let db = &state.0.database; - let user_id = match db.add_user(&new_user).await { - Ok(id) => id, - Err(e) => { - error!("failed to add user: {}", e); - return Err( - ErrorResponse::reply("failed to add user").with_status(StatusCode::BAD_REQUEST) - ); - } - }; - - // 24 bytes encoded as base64 - let token = crypto_random_string::<24>(); - - let new_session = NewSession { - user_id, - token: (&token).into(), - }; - - if let Some(url) = &state.settings.register_webhook_url { - // Could probs be run on another thread, but it's ok atm - send_register_hook( - url, - state.settings.register_webhook_username.clone(), - register.username, - ) - .await; - } - - counter!("atuin_users_registered").increment(1); - - match db.add_session(&new_session).await { - Ok(_) => Ok(Json(RegisterResponse { session: token })), - Err(e) => { - error!("failed to add session: {}", e); - Err(ErrorResponse::reply("failed to register user") - .with_status(StatusCode::BAD_REQUEST)) - } - } -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub(crate) async fn delete( - UserAuth(user): UserAuth, - state: State<AppState>, -) -> Result<Json<DeleteUserResponse>, ErrorResponseStatus<'static>> { - debug!("request to delete user {}", user.id); - - let db = &state.0.database; - if let Err(e) = db.delete_user(&user).await { - error!("failed to delete user: {}", e); - - return Err(ErrorResponse::reply("failed to delete user") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - }; - - counter!("atuin_users_deleted").increment(1); - - Ok(Json(DeleteUserResponse {})) -} - -#[instrument(skip_all, fields(user.id = user.id, change_password))] -pub(crate) async fn change_password( - UserAuth(mut user): UserAuth, - state: State<AppState>, - Json(change_password): Json<ChangePasswordRequest>, -) -> Result<Json<ChangePasswordResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - - let verified = verify_str( - user.password.as_str(), - change_password.current_password.borrow(), - ); - if !verified { - return Err( - ErrorResponse::reply("password is not correct").with_status(StatusCode::UNAUTHORIZED) - ); - } - - let hashed = hash_secret(&change_password.new_password); - user.password = hashed; - - if let Err(e) = db.update_user_password(&user).await { - error!("failed to change user password: {}", e); - - return Err(ErrorResponse::reply("failed to change user password") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - Ok(Json(ChangePasswordResponse {})) -} - -#[instrument(skip_all, fields(user.username = login.username.as_str()))] -pub(crate) async fn login( - state: State<AppState>, - login: Json<LoginRequest>, -) -> Result<Json<LoginResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - let user = match db.get_user(login.username.borrow()).await { - Ok(u) => u, - Err(DbError::NotFound) => { - return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); - } - Err(DbError::Other(e)) => { - error!("failed to get user {}: {}", login.username.clone(), e); - - return Err(ErrorResponse::reply("database error") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }; - - let session = match db.get_user_session(&user).await { - Ok(u) => u, - Err(DbError::NotFound) => { - debug!("user session not found for user id={}", user.id); - return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); - } - Err(DbError::Other(err)) => { - error!("database error for user {}: {}", login.username, err); - return Err(ErrorResponse::reply("database error") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }; - - let verified = verify_str(user.password.as_str(), login.password.borrow()); - - if !verified { - debug!(user = user.username, "login failed"); - return Err( - ErrorResponse::reply("password is not correct").with_status(StatusCode::UNAUTHORIZED) - ); - } - - debug!(user = user.username, "login success"); - - Ok(Json(LoginResponse { - session: session.token, - })) -} - -fn hash_secret(password: &str) -> String { - let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); - let salt = SaltString::generate(&mut OsRng); - let hash = arg2.hash_password(password.as_bytes(), &salt).unwrap(); - hash.to_string() -} diff --git a/crates/turtle/src/atuin_server/handlers/v0/me.rs b/crates/turtle/src/atuin_server/handlers/v0/me.rs deleted file mode 100644 index 1f5f5016..00000000 --- a/crates/turtle/src/atuin_server/handlers/v0/me.rs +++ /dev/null @@ -1,16 +0,0 @@ -use axum::Json; -use tracing::instrument; - -use crate::atuin_server::handlers::ErrorResponseStatus; -use crate::atuin_server::router::UserAuth; - -use crate::atuin_common::api::*; - -#[instrument(skip_all, fields(user.id = user.id))] -pub(crate) async fn get( - UserAuth(user): UserAuth, -) -> Result<Json<MeResponse>, ErrorResponseStatus<'static>> { - Ok(Json(MeResponse { - username: user.username, - })) -} diff --git a/crates/turtle/src/atuin_server/handlers/v0/mod.rs b/crates/turtle/src/atuin_server/handlers/v0/mod.rs index d6f880f2..78fb47b8 100644 --- a/crates/turtle/src/atuin_server/handlers/v0/mod.rs +++ b/crates/turtle/src/atuin_server/handlers/v0/mod.rs @@ -1,3 +1 @@ -pub(crate) mod me; pub(crate) mod record; -pub(crate) mod store; diff --git a/crates/turtle/src/atuin_server/handlers/v0/record.rs b/crates/turtle/src/atuin_server/handlers/v0/record.rs index 88027547..9350e1c8 100644 --- a/crates/turtle/src/atuin_server/handlers/v0/record.rs +++ b/crates/turtle/src/atuin_server/handlers/v0/record.rs @@ -10,7 +10,7 @@ use crate::atuin_server::{ use crate::atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus}; -#[instrument(skip_all, fields(user.id = user.id))] +#[instrument(skip_all, fields(user.id = user.id.to_string()))] pub(crate) async fn post( UserAuth(user): UserAuth, state: State<AppState>, @@ -20,7 +20,7 @@ pub(crate) async fn post( tracing::debug!( count = records.len(), - user = user.username, + user = user.id.to_string(), "request to add records" ); @@ -44,12 +44,12 @@ pub(crate) async fn post( return Err(ErrorResponse::reply("failed to add record") .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - }; + } Ok(()) } -#[instrument(skip_all, fields(user.id = user.id))] +#[instrument(skip_all, fields(user.id = user.id.to_string()))] pub(crate) async fn index( UserAuth(user): UserAuth, state: State<AppState>, @@ -69,7 +69,7 @@ pub(crate) async fn index( } }; - tracing::debug!(user = user.username, "record index request"); + tracing::debug!(user = user.id.to_string(), "record index request"); Ok(Json(record_index)) } @@ -82,7 +82,7 @@ pub(crate) struct NextParams { count: u64, } -#[instrument(skip_all, fields(user.id = user.id))] +#[instrument(skip_all, fields(user.id = user.id.to_string()))] pub(crate) async fn next( params: Query<NextParams>, UserAuth(user): UserAuth, diff --git a/crates/turtle/src/atuin_server/handlers/v0/store.rs b/crates/turtle/src/atuin_server/handlers/v0/store.rs deleted file mode 100644 index f0aa1b36..00000000 --- a/crates/turtle/src/atuin_server/handlers/v0/store.rs +++ /dev/null @@ -1,36 +0,0 @@ -use axum::{extract::Query, extract::State, http::StatusCode}; -use metrics::counter; -use serde::Deserialize; -use tracing::{error, instrument}; - -use crate::atuin_server::{ - handlers::{ErrorResponse, ErrorResponseStatus, RespExt}, - router::{AppState, UserAuth}, -}; - -#[derive(Deserialize)] -pub(crate) struct DeleteParams {} - -#[instrument(skip_all, fields(user.id = user.id))] -pub(crate) async fn delete( - _params: Query<DeleteParams>, - UserAuth(user): UserAuth, - state: State<AppState>, -) -> Result<(), ErrorResponseStatus<'static>> { - let State(AppState { - database, - settings: _, - }) = state; - - if let Err(e) = database.delete_store(&user).await { - counter!("atuin_store_delete_failed").increment(1); - error!("failed to delete store {e:?}"); - - return Err(ErrorResponse::reply("failed to delete store") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - - counter!("atuin_store_deleted").increment(1); - - Ok(()) -} diff --git a/crates/turtle/src/atuin_server/router.rs b/crates/turtle/src/atuin_server/router.rs index 778e699a..dfc2cac4 100644 --- a/crates/turtle/src/atuin_server/router.rs +++ b/crates/turtle/src/atuin_server/router.rs @@ -1,18 +1,19 @@ use crate::{ atuin_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse}, - atuin_server::database::{DbError, db::Database, models::User}, + atuin_server::database::{db::Database, models::User}, }; use axum::{ Router, - extract::{FromRequestParts, Request}, + extract::{FromRequestParts, Path, Request}, http::{self, request::Parts}, middleware::Next, response::{IntoResponse, Response}, - routing::{delete, get, patch, post}, + routing::{get, post}, }; use eyre::Result; use tower::ServiceBuilder; use tower_http::trace::TraceLayer; +use uuid::Uuid; use super::handlers; use crate::atuin_server::{ @@ -30,42 +31,19 @@ impl FromRequestParts<AppState> for UserAuth { req: &mut Parts, state: &AppState, ) -> Result<Self, Self::Rejection> { - let auth_header = req - .headers - .get(http::header::AUTHORIZATION) - .ok_or_else(|| { - ErrorResponse::reply("missing authorization header") - .with_status(http::StatusCode::BAD_REQUEST) - })?; - let auth_header = auth_header.to_str().map_err(|_| { - ErrorResponse::reply("invalid authorization header encoding") - .with_status(http::StatusCode::BAD_REQUEST) - })?; - let (typ, token) = auth_header.split_once(' ').ok_or_else(|| { - ErrorResponse::reply("invalid authorization header encoding") - .with_status(http::StatusCode::BAD_REQUEST) - })?; + let user_id = { + let Path(user_id) = + <Path<Uuid> as FromRequestParts<AppState>>::from_request_parts(req, state) + .await + .map_err(|_| { + ErrorResponse::reply("invalid user_id path param") + .with_status(http::StatusCode::BAD_REQUEST) + })?; - if typ != "Token" { - return Err( - ErrorResponse::reply("invalid authorization header encoding") - .with_status(http::StatusCode::BAD_REQUEST), - ); - } + user_id + }; - let user = state - .database - .get_session_user(token) - .await - .map_err(|e| match e { - DbError::NotFound => ErrorResponse::reply("session not found") - .with_status(http::StatusCode::FORBIDDEN), - DbError::Other(e) => { - tracing::error!(error = ?e, "could not query user session"); - ErrorResponse::reply("could not query user session") - .with_status(http::StatusCode::INTERNAL_SERVER_ERROR) - } - })?; + let user = User { id: user_id }; Ok(UserAuth(user)) } @@ -96,22 +74,12 @@ pub(crate) struct AppState { pub(crate) fn router(database: Database, settings: Settings) -> Router { let routes = Router::new() .route("/", get(handlers::index)) - .route("/healthz", get(handlers::health::health_check)); - - let routes = routes - .route("/user/{username}", get(handlers::user::get)) - .route("/account", delete(handlers::user::delete)) - .route("/account/password", patch(handlers::user::change_password)) - .route("/register", post(handlers::user::register)) - .route("/login", post(handlers::user::login)) - .route("/record", post(handlers::record::post)) - .route("/record", get(handlers::record::index)) - .route("/record/next", get(handlers::record::next)) - .route("/api/v0/me", get(handlers::v0::me::get)) - .route("/api/v0/record", post(handlers::v0::record::post)) - .route("/api/v0/record", get(handlers::v0::record::index)) - .route("/api/v0/record/next", get(handlers::v0::record::next)) - .route("/api/v0/store", delete(handlers::v0::store::delete)); + .route("/api/v0/{user_id}/record", post(handlers::v0::record::post)) + .route("/api/v0/{user_id}/record", get(handlers::v0::record::index)) + .route( + "/api/v0/{user_id}/record/next", + get(handlers::v0::record::next), + ); let path = settings.path.as_str(); if path.is_empty() { diff --git a/crates/turtle/src/atuin_server/settings.rs b/crates/turtle/src/atuin_server/settings.rs index b62f24e1..9424715d 100644 --- a/crates/turtle/src/atuin_server/settings.rs +++ b/crates/turtle/src/atuin_server/settings.rs @@ -30,12 +30,9 @@ pub(crate) struct Settings { pub(crate) host: String, pub(crate) port: u16, pub(crate) path: String, - pub(crate) open_registration: bool, pub(crate) max_history_length: usize, pub(crate) max_record_size: usize, pub(crate) page_size: i64, - pub(crate) register_webhook_url: Option<String>, - pub(crate) register_webhook_username: String, pub(crate) metrics: Metrics, /// Advertise a version that is not what we are _actually_ running @@ -66,11 +63,9 @@ impl Settings { let config_builder = Config::builder() .set_default("host", "127.0.0.1")? .set_default("port", 8888)? - .set_default("open_registration", false)? .set_default("max_history_length", 8192)? .set_default("max_record_size", 1024 * 1024 * 1024)? // pretty chonky .set_default("path", "")? - .set_default("register_webhook_username", "")? .set_default("page_size", 1100)? .set_default("metrics.enable", false)? .set_default("metrics.host", "127.0.0.1")? diff --git a/crates/turtle/src/command/client.rs b/crates/turtle/src/command/client.rs index 15df60f8..9d5b4605 100644 --- a/crates/turtle/src/command/client.rs +++ b/crates/turtle/src/command/client.rs @@ -43,9 +43,6 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) { #[cfg(feature = "sync")] mod sync; -#[cfg(feature = "sync")] -mod account; - #[cfg(feature = "daemon")] mod daemon; @@ -58,7 +55,6 @@ mod info; mod init; mod search; mod server; -mod setup; mod stats; mod store; mod wrapped; @@ -66,10 +62,6 @@ mod wrapped; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub(crate) enum Cmd { - /// Setup Atuin features - #[command()] - Setup, - /// Manipulate shell history #[command(subcommand)] History(history::Cmd), @@ -92,10 +84,6 @@ pub(crate) enum Cmd { #[command(subcommand)] Server(server::Cmd), - /// Manage your sync account - #[cfg(feature = "sync")] - Account(account::Cmd), - /// Manage the atuin data store #[command(subcommand)] Store(store::Cmd), @@ -333,7 +321,6 @@ impl Cmd { let theme = theme_manager.load_theme(theme_name.as_str(), settings.theme.max_depth); match self { - Self::Setup => setup::run(&settings).await, Self::Import(import) => import.run(&db).await, Self::Stats(stats) => stats.run(&db, &settings, theme).await, Self::Search(search) => search.run(db, &mut settings, sqlite_store, theme).await, @@ -341,9 +328,6 @@ impl Cmd { #[cfg(feature = "sync")] Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await, - #[cfg(feature = "sync")] - Self::Account(account) => account.run(settings, sqlite_store).await, - Self::Store(store) => store.run(&settings, &db, sqlite_store).await, Self::Server(server) => server.run().await, diff --git a/crates/turtle/src/command/client/account.rs b/crates/turtle/src/command/client/account.rs deleted file mode 100644 index f2ceb10b..00000000 --- a/crates/turtle/src/command/client/account.rs +++ /dev/null @@ -1,47 +0,0 @@ -use clap::{Args, Subcommand}; -use eyre::Result; - -use crate::atuin_client::record::sqlite_store::SqliteStore; -use crate::atuin_client::settings::Settings; - -pub(crate) mod change_password; -pub(crate) mod delete; -pub(crate) mod login; -pub(crate) mod logout; -pub(crate) mod register; - -#[derive(Args, Debug)] -pub(crate) struct Cmd { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -pub(crate) enum Commands { - /// Login to the configured server - Login(login::Cmd), - - /// Register a new account - Register(register::Cmd), - - /// Log out - Logout, - - /// Delete your account, and all synced data - Delete(delete::Cmd), - - /// Change your password - ChangePassword(change_password::Cmd), -} - -impl Cmd { - pub(crate) async fn run(self, settings: Settings, store: SqliteStore) -> Result<()> { - match self.command { - Commands::Login(l) => l.run(&settings, &store).await, - Commands::Register(r) => r.run(&settings).await, - Commands::Logout => logout::run().await, - Commands::Delete(d) => d.run(&settings).await, - Commands::ChangePassword(c) => c.run(&settings).await, - } - } -} diff --git a/crates/turtle/src/command/client/account/change_password.rs b/crates/turtle/src/command/client/account/change_password.rs deleted file mode 100644 index b23f518d..00000000 --- a/crates/turtle/src/command/client/account/change_password.rs +++ /dev/null @@ -1,55 +0,0 @@ -use clap::Parser; -use eyre::{Result, bail}; - -use crate::atuin_client::{auth, settings::Settings}; -use rpassword::prompt_password; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - #[clap(long, short)] - pub(crate) current_password: Option<String>, - - #[clap(long, short)] - pub(crate) new_password: Option<String>, - - /// The two-factor authentication code for your account, if any - #[clap(long, short)] - pub(crate) totp_code: Option<String>, -} - -impl Cmd { - pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { - if !settings.logged_in().await? { - bail!("You are not logged in"); - } - - let client = auth::auth_client(settings).await; - - let current_password = self.current_password.clone().unwrap_or_else(|| { - prompt_password("Please enter the current password: ") - .expect("Failed to read from input") - }); - - if current_password.is_empty() { - bail!("please provide the current password"); - } - - let new_password = self.new_password.clone().unwrap_or_else(|| { - prompt_password("Please enter the new password: ").expect("Failed to read from input") - }); - - if new_password.is_empty() { - bail!("please provide a new password"); - } - - let totp_code = self.totp_code.clone(); - - client - .change_password(¤t_password, &new_password, totp_code.as_deref()) - .await?; - - println!("Account password successfully changed!"); - - Ok(()) - } -} diff --git a/crates/turtle/src/command/client/account/delete.rs b/crates/turtle/src/command/client/account/delete.rs deleted file mode 100644 index 722c39ec..00000000 --- a/crates/turtle/src/command/client/account/delete.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::atuin_client::{auth, settings::Settings}; -use clap::Parser; -use eyre::{Result, bail}; - -use super::login::read_user_password; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - #[clap(long, short)] - pub(crate) password: Option<String>, - - /// The two-factor authentication code for your account, if any - #[clap(long, short)] - pub(crate) totp_code: Option<String>, -} - -impl Cmd { - pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { - if !settings.logged_in().await? { - bail!("You are not logged in"); - } - - let client = auth::auth_client(settings).await; - - let password = self.password.clone().unwrap_or_else(read_user_password); - - if password.is_empty() { - bail!("please provide your password"); - } - - let mut totp_code = self.totp_code.clone(); - - client - .delete_account(&password, totp_code.as_deref()) - .await?; - - // Clean up sessions from meta store - let meta = Settings::meta_store().await?; - meta.delete_session().await?; - - println!("Your account is deleted"); - - Ok(()) - } -} diff --git a/crates/turtle/src/command/client/account/login.rs b/crates/turtle/src/command/client/account/login.rs deleted file mode 100644 index e9513879..00000000 --- a/crates/turtle/src/command/client/account/login.rs +++ /dev/null @@ -1,201 +0,0 @@ -use std::{io, path::PathBuf}; - -use clap::Parser; -use eyre::{Context, Result, bail}; -use tokio::{fs::File, io::AsyncWriteExt}; - -use crate::atuin_client::{ - auth, - encryption::{decode_key, load_key}, - record::sqlite_store::SqliteStore, - record::store::Store, - record::sync::{self, SyncError}, - settings::{Settings, SyncAuth}, -}; -use rpassword::prompt_password; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - #[clap(long, short)] - pub(crate) username: Option<String>, - - #[clap(long, short)] - pub(crate) password: Option<String>, - - /// The encryption key for your account - #[clap(long, short)] - pub(crate) key: Option<String>, - - /// The two-factor authentication code for your account, if any - #[clap(long, short)] - pub(crate) totp_code: Option<String>, - - #[clap(long, hide = true)] - pub(crate) from_registration: bool, -} - -fn get_input() -> Result<String> { - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) -} - -impl Cmd { - pub(crate) async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { - match settings.resolve_sync_auth().await { - SyncAuth::Legacy { .. } => { - println!("You are logged in to your sync server."); - println!("Run 'atuin logout' to log out."); - return Ok(()); - } - SyncAuth::NotLoggedIn { .. } => {} - } - - self.run_legacy_login(settings, store).await?; - - verify_key_against_remote(settings).await - } - - /// Legacy login: always prompt for username/password interactively - /// (or accept them via flags). - async fn run_legacy_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { - let username = or_user_input(self.username.clone(), "username"); - let password = self.password.clone().unwrap_or_else(read_user_password); - - self.prompt_and_store_key(settings, store).await?; - - let client = auth::auth_client(settings).await; - let response = client.login(&username, &password).await?; - - Settings::meta_store() - .await? - .save_session(&response.session) - .await?; - - println!("Logged in!"); - Ok(()) - } - - async fn prompt_and_store_key(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { - let key_path = settings.key_path.as_str(); - let key_path = PathBuf::from(key_path); - - println!("IMPORTANT"); - println!( - "If you are already logged in on another machine, you must ensure that the key you use here is the same as the key you used there." - ); - println!("You can find your key by running 'atuin key' on the other machine."); - println!("Do not share this key with anyone."); - println!("\nRead more here: https://docs.atuin.sh/guide/sync/#login \n"); - - let key = or_user_input( - self.key.clone(), - "encryption key [blank to use existing key file]", - ); - - if key.is_empty() { - if key_path.exists() { - let bytes = fs_err::read_to_string(&key_path).context(format!( - "Existing key file at '{}' could not be read", - key_path.to_string_lossy() - ))?; - if decode_key(bytes).is_err() { - bail!(format!( - "The key in existing key file at '{}' is invalid", - key_path.to_string_lossy() - )); - } - } else { - panic!( - "No key provided and no existing key file found. Please use 'atuin key' on your other machine, or recover your key from a backup" - ) - } - } else if !key_path.exists() { - if decode_key(key.clone()).is_err() { - bail!("The specified key is invalid"); - } - - let mut file = File::create(&key_path).await?; - file.write_all(key.as_bytes()).await?; - } else { - // we now know that the user has logged in specifying a key, AND that the key path - // exists - - // 1. check if the saved key and the provided key match. if so, nothing to do. - // 2. if not, re-encrypt the local history and overwrite the key - let current_key: [u8; 32] = load_key(settings)?.into(); - - let encoded = key.clone(); // gonna want to save it in a bit - let new_key: [u8; 32] = decode_key(key) - .context("Could not decode provided key; is not valid base64-encoded key")? - .into(); - - if new_key != current_key { - println!("\nRe-encrypting local store with new key"); - - store.re_encrypt(¤t_key, &new_key).await?; - - println!("Writing new key"); - let mut file = File::create(&key_path).await?; - file.write_all(encoded.as_bytes()).await?; - } - } - - Ok(()) - } -} - -async fn verify_key_against_remote(settings: &Settings) -> Result<()> { - let key: [u8; 32] = load_key(settings) - .context("could not load encryption key for verification")? - .into(); - - let client = sync::build_client(settings).await?; - let remote_index = match client.record_status().await { - Ok(idx) => idx, - Err(e) => { - tracing::warn!("could not fetch remote status to verify key: {e}"); - return Ok(()); - } - }; - - match sync::check_encryption_key(&client, &remote_index, &key).await { - Ok(()) => Ok(()), - Err(SyncError::WrongKey) => { - // Roll back the saved session so the user is not left in a - // half-authenticated state with a key that can't read the data. - if let Ok(meta) = Settings::meta_store().await { - let _ = meta.delete_session().await; - } - crate::print_error::print_error( - "Wrong encryption key", - "The encryption key on this machine does not match the data on the server. \ - You have been logged out.\n\n\ - To fix this, find your existing key by running `atuin key` on a machine that \ - already syncs successfully, then run `atuin login` again here with that key.", - ); - std::process::exit(1); - } - Err(e) => { - // Non-key error (e.g. transient network issue). Don't fail the - // login — the user is authenticated and can sync later when the - // network recovers. - tracing::warn!("could not verify encryption key against remote: {e}"); - Ok(()) - } - } -} - -pub(super) fn or_user_input(value: Option<String>, name: &'static str) -> String { - value.unwrap_or_else(|| read_user_input(name)) -} - -pub(super) fn read_user_password() -> String { - let password = prompt_password("Please enter password: "); - password.expect("Failed to read from input") -} - -fn read_user_input(name: &'static str) -> String { - eprint!("Please enter {name}: "); - get_input().expect("Failed to read from input") -} diff --git a/crates/turtle/src/command/client/account/logout.rs b/crates/turtle/src/command/client/account/logout.rs deleted file mode 100644 index 5708e34c..00000000 --- a/crates/turtle/src/command/client/account/logout.rs +++ /dev/null @@ -1,5 +0,0 @@ -use eyre::Result; - -pub(crate) async fn run() -> Result<()> { - crate::atuin_client::logout::logout().await -} diff --git a/crates/turtle/src/command/client/account/register.rs b/crates/turtle/src/command/client/account/register.rs deleted file mode 100644 index 64fb9f8d..00000000 --- a/crates/turtle/src/command/client/account/register.rs +++ /dev/null @@ -1,67 +0,0 @@ -use clap::Parser; -use eyre::{Result, bail}; - -use super::login::or_user_input; -use crate::atuin_client::settings::{Settings, SyncAuth}; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - #[clap(long, short)] - pub(crate) username: Option<String>, - - #[clap(long, short)] - pub(crate) password: Option<String>, - - #[clap(long, short)] - pub(crate) email: Option<String>, -} - -impl Cmd { - pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { - match settings.resolve_sync_auth().await { - SyncAuth::Legacy { .. } => { - println!("You are already logged in."); - println!("Run 'atuin logout' to log out."); - return Ok(()); - } - - SyncAuth::NotLoggedIn { .. } => {} - } - - // Legacy registration flow - println!("Registering for an Atuin Sync account"); - - let username = or_user_input(self.username.clone(), "username"); - let email = or_user_input(self.email.clone(), "email"); - let password = self - .password - .clone() - .unwrap_or_else(super::login::read_user_password); - - if password.is_empty() { - bail!("please provide a password"); - } - - let session = crate::atuin_client::api_client::register( - settings.sync_address.as_str(), - &username, - &email, - &password, - ) - .await?; - - let meta = Settings::meta_store().await?; - meta.save_session(&session.session).await?; - - let _key = crate::atuin_client::encryption::load_key(settings)?; - - println!( - "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe." - ); - println!( - "You will need it to log in on other devices, and we cannot help recover it if you lose it." - ); - - Ok(()) - } -} diff --git a/crates/turtle/src/command/client/doctor.rs b/crates/turtle/src/command/client/doctor.rs index 1ed90c47..eec690a5 100644 --- a/crates/turtle/src/command/client/doctor.rs +++ b/crates/turtle/src/command/client/doctor.rs @@ -243,15 +243,8 @@ struct SyncInfo { } impl SyncInfo { - pub(crate) async fn new(settings: &Settings) -> Self { - // Build auth state description from raw token state without calling - // resolve_sync_auth(), which has side effects (token migration cleanup) - // that a diagnostic command should not trigger. - let meta = Settings::meta_store().await.ok(); - let has_cli_token = match &meta { - Some(m) => m.session_token().await.ok().flatten().is_some(), - None => false, - }; + pub(crate) async fn new(settings: &Settings) -> Result<Self> { + let has_cli_token = settings.have_sync_key().await?; let auth_state = if has_cli_token { "Self-hosted (authenticated)".into() @@ -259,13 +252,13 @@ impl SyncInfo { "Not authenticated".into() }; - Self { + Ok(Self { auth_state, auto_sync: settings.auto_sync, last_sync: Settings::last_sync() .await .map_or_else(|_| "no last sync".to_string(), |v| v.to_string()), - } + }) } } @@ -318,11 +311,11 @@ struct AtuinInfo { } impl AtuinInfo { - pub(crate) async fn new(settings: &Settings) -> Self { - let logged_in = settings.logged_in().await.unwrap_or(false); + pub(crate) async fn new(settings: &Settings) -> Result<Self> { + let logged_in = settings.have_sync_key().await?; let sync = if logged_in { - Some(SyncInfo::new(settings).await) + Some(SyncInfo::new(settings).await?) } else { None }; @@ -335,13 +328,13 @@ impl AtuinInfo { Err(_) => "error".to_string(), }; - Self { + Ok(Self { version: crate::VERSION.to_string(), commit: crate::SHA.to_string(), sync, sqlite_version, setting_paths: SettingPaths::new(settings), - } + }) } } @@ -353,12 +346,12 @@ struct DoctorDump { } impl DoctorDump { - pub(crate) async fn new(settings: &Settings) -> Self { - Self { - atuin: AtuinInfo::new(settings).await, + pub(crate) async fn new(settings: &Settings) -> Result<Self> { + Ok(Self { + atuin: AtuinInfo::new(settings).await?, shell: ShellInfo::new(), system: SystemInfo::new(), - } + }) } } @@ -399,7 +392,7 @@ fn checks(info: &DoctorDump) { pub(crate) async fn run(settings: &Settings) -> Result<()> { println!("{}", "Atuin Doctor".bold()); println!("Checking for diagnostics"); - let dump = DoctorDump::new(settings).await; + let dump = DoctorDump::new(settings).await?; checks(&dump); diff --git a/crates/turtle/src/command/client/setup.rs b/crates/turtle/src/command/client/setup.rs deleted file mode 100644 index 3231b6ec..00000000 --- a/crates/turtle/src/command/client/setup.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::atuin_client::settings::Settings; - -use colored::Colorize; -use eyre::Result; -use std::io::{self, Write}; -use toml_edit::{DocumentMut, value}; - -pub(crate) async fn run(_settings: &Settings) -> Result<()> { - let enable_ai = prompt( - "Atuin AI", - "This will enable command generation and other AI features via the question mark key", - Some( - "By default, Atuin AI only has access to the name and version of your operating system and shell - your shell history is not sent to the AI.", - ), - )?; - - let enable_daemon = prompt( - "Atuin Daemon", - "This will enable improved search and history sync using a persistent background process", - None, - )?; - - let config_file = Settings::get_config_path()?; - let config_str = tokio::fs::read_to_string(&config_file).await?; - let mut doc = config_str.parse::<DocumentMut>()?; - - let mut changed = false; - if enable_ai { - changed = true; - if !doc.contains_key("ai") { - doc["ai"] = toml_edit::table(); - } - doc["ai"]["enabled"] = value(true); - } - - if enable_daemon { - changed = true; - if !doc.contains_key("daemon") { - doc["daemon"] = toml_edit::table(); - } - doc["daemon"]["enabled"] = value(true); - doc["daemon"]["autostart"] = value(true); - doc["search_mode"] = value("daemon-fuzzy"); - } - - if changed { - tokio::fs::write(config_file, doc.to_string()).await?; - - println!( - "{check} Settings updated successfully", - check = "✓".bold().bright_green() - ); - } else { - println!( - "{check} No settings changed", - check = "✓".bold().bright_green() - ); - } - - Ok(()) -} - -pub(crate) fn prompt(feature: &str, description: &str, note: Option<&str>) -> Result<bool> { - println!( - "> Enable {feature}?", - feature = feature.bold().bright_blue() - ); - if let Some(note) = note { - println!(" {description}"); - print!(" {note} {q} ", q = "[Y/n]".bold()); - } else { - print!(" {description} {q} ", q = "[Y/n]".bold()); - } - - io::stdout().flush().ok(); - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let answer = input.trim().to_lowercase(); - Ok(answer.is_empty() || answer == "y" || answer == "yes") -} diff --git a/crates/turtle/src/command/client/store/push.rs b/crates/turtle/src/command/client/store/push.rs index 042ad201..30177dbd 100644 --- a/crates/turtle/src/command/client/store/push.rs +++ b/crates/turtle/src/command/client/store/push.rs @@ -43,7 +43,7 @@ impl Push { let client = Client::new( &settings.sync_address, - settings.sync_auth_token().await?, + settings.sync_auth().await?.into_auth_token()?, settings.network_connect_timeout, settings.network_timeout * 10, // we may be deleting a lot of data... so up the // timeout diff --git a/crates/turtle/src/command/client/sync.rs b/crates/turtle/src/command/client/sync.rs index 8d7cb50a..7adf90ed 100644 --- a/crates/turtle/src/command/client/sync.rs +++ b/crates/turtle/src/command/client/sync.rs @@ -11,8 +11,6 @@ use crate::atuin_client::{ mod status; -use crate::command::client::account; - #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub(crate) enum Cmd { @@ -23,15 +21,6 @@ pub(crate) enum Cmd { force: bool, }, - /// Login to the configured server - Login(account::login::Cmd), - - /// Log out - Logout, - - /// Register with the configured server - Register(account::register::Cmd), - /// Print the encryption key for transfer to another machine Key {}, @@ -48,9 +37,6 @@ impl Cmd { ) -> Result<()> { match self { Self::Sync { force } => run(&settings, force, db, store).await, - Self::Login(l) => l.run(&settings, &store).await, - Self::Logout => account::logout::run().await, - Self::Register(r) => r.run(&settings).await, Self::Status => status::run(&settings).await, Self::Key {} => { use crate::atuin_client::encryption::{encode_key, load_key}; diff --git a/crates/turtle/src/command/client/sync/status.rs b/crates/turtle/src/command/client/sync/status.rs index cb0d86e4..27b10dbd 100644 --- a/crates/turtle/src/command/client/sync/status.rs +++ b/crates/turtle/src/command/client/sync/status.rs @@ -1,16 +1,16 @@ -use crate::{SHA, VERSION}; use crate::atuin_client::{api_client, settings::Settings}; +use crate::{SHA, VERSION}; use colored::Colorize; use eyre::{Result, bail}; pub(crate) async fn run(settings: &Settings) -> Result<()> { - if !settings.logged_in().await? { + if !settings.have_sync_key().await? { bail!("You are not logged in to a sync server - cannot show sync status"); } let client = api_client::Client::new( &settings.sync_address, - settings.sync_auth_token().await?, + settings.sync_auth().await?.into_auth_token()?, settings.network_connect_timeout, settings.network_timeout, )?; |
