From ca263834e93814105ca9ea77ab213cff0bc95faa Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Wed, 17 May 2023 21:28:37 +0100 Subject: Restructure account commands to account subcommand (#984) * Stop running triggers on history delete * Move to account management dir * Alter trigger function to only run for inserts * wip * Add atuin account subcommands, and re-org delete * Clarify docs * Delete silly dupe migration * Um where did this come from * Oops, insert only plz --- atuin-client/src/api_client.rs | 2 +- .../20230515221038_trigger-delete-only.sql | 30 +++++ atuin/src/command/client.rs | 10 ++ atuin/src/command/client/account.rs | 41 ++++++ atuin/src/command/client/account/delete.rs | 23 ++++ atuin/src/command/client/account/login.rs | 142 +++++++++++++++++++++ atuin/src/command/client/account/logout.rs | 19 +++ atuin/src/command/client/account/register.rs | 49 +++++++ atuin/src/command/client/sync.rs | 16 +-- atuin/src/command/client/sync/delete.rs | 23 ---- atuin/src/command/client/sync/login.rs | 142 --------------------- atuin/src/command/client/sync/logout.rs | 19 --- atuin/src/command/client/sync/register.rs | 49 ------- atuin/src/command/mod.rs | 1 + docs/docs/commands/sync.md | 4 +- 15 files changed, 324 insertions(+), 246 deletions(-) create mode 100644 atuin-server/migrations/20230515221038_trigger-delete-only.sql create mode 100644 atuin/src/command/client/account.rs create mode 100644 atuin/src/command/client/account/delete.rs create mode 100644 atuin/src/command/client/account/login.rs create mode 100644 atuin/src/command/client/account/logout.rs create mode 100644 atuin/src/command/client/account/register.rs delete mode 100644 atuin/src/command/client/sync/delete.rs delete mode 100644 atuin/src/command/client/sync/login.rs delete mode 100644 atuin/src/command/client/sync/logout.rs delete mode 100644 atuin/src/command/client/sync/register.rs diff --git a/atuin-client/src/api_client.rs b/atuin-client/src/api_client.rs index 2abb8159..5ea84b9d 100644 --- a/atuin-client/src/api_client.rs +++ b/atuin-client/src/api_client.rs @@ -219,7 +219,7 @@ impl<'a> Client<'a> { } pub async fn delete(&self) -> Result<()> { - let url = format!("{}/register", self.sync_addr); + let url = format!("{}/account", self.sync_addr); let url = Url::parse(url.as_str())?; let resp = self.client.delete(url).send().await?; diff --git a/atuin-server/migrations/20230515221038_trigger-delete-only.sql b/atuin-server/migrations/20230515221038_trigger-delete-only.sql new file mode 100644 index 00000000..3d0bba52 --- /dev/null +++ b/atuin-server/migrations/20230515221038_trigger-delete-only.sql @@ -0,0 +1,30 @@ +-- We do not need to run the trigger on deletes, as the only time we are deleting history is when the user +-- has already been deleted +-- This actually slows down deleting all the history a good bit! + +create or replace function user_history_count() +returns trigger as +$func$ +begin + if (TG_OP='INSERT') then + update total_history_count_user set total = total + 1 where user_id = new.user_id; + + if not found then + insert into total_history_count_user(user_id, total) + values ( + new.user_id, + (select count(1) from history where user_id = new.user_id) + ); + end if; + end if; + + return NEW; -- this is actually ignored for an after trigger, but oh well +end; +$func$ +language plpgsql volatile -- pldfplplpflh +cost 100; -- default value + +create or replace trigger tg_user_history_count + after insert on history + for each row + execute procedure user_history_count(); diff --git a/atuin/src/command/client.rs b/atuin/src/command/client.rs index 2a825638..6a2d8689 100644 --- a/atuin/src/command/client.rs +++ b/atuin/src/command/client.rs @@ -9,6 +9,9 @@ use env_logger::Builder; #[cfg(feature = "sync")] mod sync; +#[cfg(feature = "sync")] +mod account; + mod history; mod import; mod search; @@ -34,6 +37,9 @@ pub enum Cmd { #[cfg(feature = "sync")] #[command(flatten)] Sync(sync::Cmd), + + #[cfg(feature = "sync")] + Account(account::Cmd), } impl Cmd { @@ -54,8 +60,12 @@ impl Cmd { Self::Import(import) => import.run(&mut db).await, Self::Stats(stats) => stats.run(&mut db, &settings).await, Self::Search(search) => search.run(db, &mut settings).await, + #[cfg(feature = "sync")] Self::Sync(sync) => sync.run(settings, &mut db).await, + + #[cfg(feature = "sync")] + Self::Account(account) => account.run(settings).await, } } } diff --git a/atuin/src/command/client/account.rs b/atuin/src/command/client/account.rs new file mode 100644 index 00000000..2a4a0772 --- /dev/null +++ b/atuin/src/command/client/account.rs @@ -0,0 +1,41 @@ +use clap::{Args, Subcommand}; +use eyre::Result; + +use atuin_client::settings::Settings; + +pub mod delete; +pub mod login; +pub mod logout; +pub mod register; + +#[derive(Args)] +pub struct Cmd { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +pub 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, +} + +impl Cmd { + pub async fn run(self, settings: Settings) -> Result<()> { + match self.command { + Commands::Login(l) => l.run(&settings).await, + Commands::Register(r) => r.run(&settings).await, + Commands::Logout => logout::run(&settings), + Commands::Delete => delete::run(&settings).await, + } + } +} diff --git a/atuin/src/command/client/account/delete.rs b/atuin/src/command/client/account/delete.rs new file mode 100644 index 00000000..63e5b747 --- /dev/null +++ b/atuin/src/command/client/account/delete.rs @@ -0,0 +1,23 @@ +use atuin_client::{api_client, encryption::load_encoded_key, settings::Settings}; +use eyre::{bail, Result}; +use std::path::PathBuf; + +pub async fn run(settings: &Settings) -> Result<()> { + let session_path = settings.session_path.as_str(); + + if !PathBuf::from(session_path).exists() { + bail!("You are not logged in"); + } + + let client = api_client::Client::new( + &settings.sync_address, + &settings.session_token, + load_encoded_key(settings)?, + )?; + + client.delete().await?; + + println!("Your account is deleted"); + + Ok(()) +} diff --git a/atuin/src/command/client/account/login.rs b/atuin/src/command/client/account/login.rs new file mode 100644 index 00000000..9bfe0b40 --- /dev/null +++ b/atuin/src/command/client/account/login.rs @@ -0,0 +1,142 @@ +use std::{io, path::PathBuf}; + +use clap::Parser; +use eyre::{bail, Context, Result}; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::{ + api_client, + encryption::{decode_key, encode_key, new_key, Key}, + settings::Settings, +}; +use atuin_common::api::LoginRequest; +use rpassword::prompt_password; + +#[derive(Parser)] +pub struct Cmd { + #[clap(long, short)] + pub username: Option, + + #[clap(long, short)] + pub password: Option, + + /// The encryption key for your account + #[clap(long, short)] + pub key: Option, +} + +fn get_input() -> Result { + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) +} + +impl Cmd { + pub async fn run(&self, settings: &Settings) -> Result<()> { + let session_path = settings.session_path.as_str(); + + if PathBuf::from(session_path).exists() { + println!( + "You are already logged in! Please run 'atuin logout' if you wish to login again" + ); + + return Ok(()); + } + + let username = or_user_input(&self.username, "username"); + let key = or_user_input(&self.key, "encryption key [blank to use existing key file]"); + let password = self.password.clone().unwrap_or_else(read_user_password); + + let key_path = settings.key_path.as_str(); + if key.is_empty() { + if PathBuf::from(key_path).exists() { + let bytes = fs_err::read_to_string(key_path) + .context("existing key file couldn't be read")?; + if decode_key(bytes).is_err() { + bail!("the key in existing key file was invalid"); + } + } else { + println!("No key file exists, creating a new"); + let _key = new_key(settings)?; + } + } else { + // try parse the key as a mnemonic... + let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { + Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?, + Err(err) => { + if let Some(err) = err.downcast_ref::() { + match err { + // assume they copied in the base64 key + bip39::ErrorKind::InvalidWord => key, + bip39::ErrorKind::InvalidChecksum => { + bail!("key mnemonic was not valid") + } + bip39::ErrorKind::InvalidKeysize(_) + | bip39::ErrorKind::InvalidWordLength(_) + | bip39::ErrorKind::InvalidEntropyLength(_, _) => { + bail!("key was not the correct length") + } + } + } else { + // unknown error. assume they copied the base64 key + key + } + } + }; + + 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?; + } + + let session = api_client::login( + settings.sync_address.as_str(), + LoginRequest { username, password }, + ) + .await?; + + let session_path = settings.session_path.as_str(); + let mut file = File::create(session_path).await?; + file.write_all(session.session.as_bytes()).await?; + + println!("Logged in!"); + + Ok(()) + } +} + +pub(super) fn or_user_input(value: &'_ Option, name: &'static str) -> String { + value.clone().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") +} + +#[cfg(test)] +mod tests { + use atuin_client::encryption::Key; + + #[test] + fn mnemonic_round_trip() { + let key = Key::from([ + 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, 2, + 7, 9, 5, + ]); + let phrase = bip39::Mnemonic::from_entropy(&key, bip39::Language::English) + .unwrap() + .into_phrase(); + let mnemonic = bip39::Mnemonic::from_phrase(&phrase, bip39::Language::English).unwrap(); + assert_eq!(mnemonic.entropy(), key.as_slice()); + assert_eq!(phrase, "adapt amused able anxiety mother adapt beef gaze amount else seat alcohol cage lottery avoid scare alcohol cactus school avoid coral adjust catch pink"); + } +} diff --git a/atuin/src/command/client/account/logout.rs b/atuin/src/command/client/account/logout.rs new file mode 100644 index 00000000..90b49d6d --- /dev/null +++ b/atuin/src/command/client/account/logout.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; + +use eyre::{Context, Result}; +use fs_err::remove_file; + +use atuin_client::settings::Settings; + +pub fn run(settings: &Settings) -> Result<()> { + let session_path = settings.session_path.as_str(); + + if PathBuf::from(session_path).exists() { + remove_file(session_path).context("Failed to remove session file")?; + println!("You have logged out!"); + } else { + println!("You are not logged in"); + } + + Ok(()) +} diff --git a/atuin/src/command/client/account/register.rs b/atuin/src/command/client/account/register.rs new file mode 100644 index 00000000..6b51fac8 --- /dev/null +++ b/atuin/src/command/client/account/register.rs @@ -0,0 +1,49 @@ +use clap::Parser; +use eyre::Result; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::{api_client, settings::Settings}; + +#[derive(Parser)] +pub struct Cmd { + #[clap(long, short)] + pub username: Option, + + #[clap(long, short)] + pub password: Option, + + #[clap(long, short)] + pub email: Option, +} + +impl Cmd { + pub async fn run(self, settings: &Settings) -> Result<()> { + run(settings, &self.username, &self.email, &self.password).await + } +} + +pub async fn run( + settings: &Settings, + username: &Option, + email: &Option, + password: &Option, +) -> Result<()> { + use super::login::or_user_input; + let username = or_user_input(username, "username"); + let email = or_user_input(email, "email"); + let password = password + .clone() + .unwrap_or_else(super::login::read_user_password); + + let session = + api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; + + let path = settings.session_path.as_str(); + let mut file = File::create(path).await?; + file.write_all(session.session.as_bytes()).await?; + + // Create a new key, and save it to disk + let _key = atuin_client::encryption::new_key(settings)?; + + Ok(()) +} diff --git a/atuin/src/command/client/sync.rs b/atuin/src/command/client/sync.rs index 12664be5..061b7376 100644 --- a/atuin/src/command/client/sync.rs +++ b/atuin/src/command/client/sync.rs @@ -3,12 +3,10 @@ use eyre::{Result, WrapErr}; use atuin_client::{database::Database, settings::Settings}; -mod delete; -mod login; -mod logout; -mod register; mod status; +use crate::command::client::account; + #[derive(Subcommand)] #[command(infer_subcommands = true)] pub enum Cmd { @@ -20,16 +18,13 @@ pub enum Cmd { }, /// Login to the configured server - Login(login::Cmd), + Login(account::login::Cmd), /// Log out Logout, /// Register with the configured server - Register(register::Cmd), - - /// Unregister with the configured server - Unregister, + Register(account::register::Cmd), /// Print the encryption key for transfer to another machine Key { @@ -46,9 +41,8 @@ impl Cmd { match self { Self::Sync { force } => run(&settings, force, db).await, Self::Login(l) => l.run(&settings).await, - Self::Logout => logout::run(&settings), + Self::Logout => account::logout::run(&settings), Self::Register(r) => r.run(&settings).await, - Self::Unregister => delete::run(&settings).await, Self::Status => status::run(&settings, db).await, Self::Key { base64 } => { use atuin_client::encryption::{encode_key, load_key}; diff --git a/atuin/src/command/client/sync/delete.rs b/atuin/src/command/client/sync/delete.rs deleted file mode 100644 index 63e5b747..00000000 --- a/atuin/src/command/client/sync/delete.rs +++ /dev/null @@ -1,23 +0,0 @@ -use atuin_client::{api_client, encryption::load_encoded_key, settings::Settings}; -use eyre::{bail, Result}; -use std::path::PathBuf; - -pub async fn run(settings: &Settings) -> Result<()> { - let session_path = settings.session_path.as_str(); - - if !PathBuf::from(session_path).exists() { - bail!("You are not logged in"); - } - - let client = api_client::Client::new( - &settings.sync_address, - &settings.session_token, - load_encoded_key(settings)?, - )?; - - client.delete().await?; - - println!("Your account is deleted"); - - Ok(()) -} diff --git a/atuin/src/command/client/sync/login.rs b/atuin/src/command/client/sync/login.rs deleted file mode 100644 index 9bfe0b40..00000000 --- a/atuin/src/command/client/sync/login.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::{io, path::PathBuf}; - -use clap::Parser; -use eyre::{bail, Context, Result}; -use tokio::{fs::File, io::AsyncWriteExt}; - -use atuin_client::{ - api_client, - encryption::{decode_key, encode_key, new_key, Key}, - settings::Settings, -}; -use atuin_common::api::LoginRequest; -use rpassword::prompt_password; - -#[derive(Parser)] -pub struct Cmd { - #[clap(long, short)] - pub username: Option, - - #[clap(long, short)] - pub password: Option, - - /// The encryption key for your account - #[clap(long, short)] - pub key: Option, -} - -fn get_input() -> Result { - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) -} - -impl Cmd { - pub async fn run(&self, settings: &Settings) -> Result<()> { - let session_path = settings.session_path.as_str(); - - if PathBuf::from(session_path).exists() { - println!( - "You are already logged in! Please run 'atuin logout' if you wish to login again" - ); - - return Ok(()); - } - - let username = or_user_input(&self.username, "username"); - let key = or_user_input(&self.key, "encryption key [blank to use existing key file]"); - let password = self.password.clone().unwrap_or_else(read_user_password); - - let key_path = settings.key_path.as_str(); - if key.is_empty() { - if PathBuf::from(key_path).exists() { - let bytes = fs_err::read_to_string(key_path) - .context("existing key file couldn't be read")?; - if decode_key(bytes).is_err() { - bail!("the key in existing key file was invalid"); - } - } else { - println!("No key file exists, creating a new"); - let _key = new_key(settings)?; - } - } else { - // try parse the key as a mnemonic... - let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { - Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?, - Err(err) => { - if let Some(err) = err.downcast_ref::() { - match err { - // assume they copied in the base64 key - bip39::ErrorKind::InvalidWord => key, - bip39::ErrorKind::InvalidChecksum => { - bail!("key mnemonic was not valid") - } - bip39::ErrorKind::InvalidKeysize(_) - | bip39::ErrorKind::InvalidWordLength(_) - | bip39::ErrorKind::InvalidEntropyLength(_, _) => { - bail!("key was not the correct length") - } - } - } else { - // unknown error. assume they copied the base64 key - key - } - } - }; - - 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?; - } - - let session = api_client::login( - settings.sync_address.as_str(), - LoginRequest { username, password }, - ) - .await?; - - let session_path = settings.session_path.as_str(); - let mut file = File::create(session_path).await?; - file.write_all(session.session.as_bytes()).await?; - - println!("Logged in!"); - - Ok(()) - } -} - -pub(super) fn or_user_input(value: &'_ Option, name: &'static str) -> String { - value.clone().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") -} - -#[cfg(test)] -mod tests { - use atuin_client::encryption::Key; - - #[test] - fn mnemonic_round_trip() { - let key = Key::from([ - 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, 2, - 7, 9, 5, - ]); - let phrase = bip39::Mnemonic::from_entropy(&key, bip39::Language::English) - .unwrap() - .into_phrase(); - let mnemonic = bip39::Mnemonic::from_phrase(&phrase, bip39::Language::English).unwrap(); - assert_eq!(mnemonic.entropy(), key.as_slice()); - assert_eq!(phrase, "adapt amused able anxiety mother adapt beef gaze amount else seat alcohol cage lottery avoid scare alcohol cactus school avoid coral adjust catch pink"); - } -} diff --git a/atuin/src/command/client/sync/logout.rs b/atuin/src/command/client/sync/logout.rs deleted file mode 100644 index 90b49d6d..00000000 --- a/atuin/src/command/client/sync/logout.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::path::PathBuf; - -use eyre::{Context, Result}; -use fs_err::remove_file; - -use atuin_client::settings::Settings; - -pub fn run(settings: &Settings) -> Result<()> { - let session_path = settings.session_path.as_str(); - - if PathBuf::from(session_path).exists() { - remove_file(session_path).context("Failed to remove session file")?; - println!("You have logged out!"); - } else { - println!("You are not logged in"); - } - - Ok(()) -} diff --git a/atuin/src/command/client/sync/register.rs b/atuin/src/command/client/sync/register.rs deleted file mode 100644 index 6b51fac8..00000000 --- a/atuin/src/command/client/sync/register.rs +++ /dev/null @@ -1,49 +0,0 @@ -use clap::Parser; -use eyre::Result; -use tokio::{fs::File, io::AsyncWriteExt}; - -use atuin_client::{api_client, settings::Settings}; - -#[derive(Parser)] -pub struct Cmd { - #[clap(long, short)] - pub username: Option, - - #[clap(long, short)] - pub password: Option, - - #[clap(long, short)] - pub email: Option, -} - -impl Cmd { - pub async fn run(self, settings: &Settings) -> Result<()> { - run(settings, &self.username, &self.email, &self.password).await - } -} - -pub async fn run( - settings: &Settings, - username: &Option, - email: &Option, - password: &Option, -) -> Result<()> { - use super::login::or_user_input; - let username = or_user_input(username, "username"); - let email = or_user_input(email, "email"); - let password = password - .clone() - .unwrap_or_else(super::login::read_user_password); - - let session = - api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; - - let path = settings.session_path.as_str(); - let mut file = File::create(path).await?; - file.write_all(session.session.as_bytes()).await?; - - // Create a new key, and save it to disk - let _key = atuin_client::encryption::new_key(settings)?; - - Ok(()) -} diff --git a/atuin/src/command/mod.rs b/atuin/src/command/mod.rs index 4ed1691a..bcd209d6 100644 --- a/atuin/src/command/mod.rs +++ b/atuin/src/command/mod.rs @@ -49,6 +49,7 @@ impl AtuinCmd { match self { #[cfg(feature = "client")] Self::Client(client) => client.run(), + #[cfg(feature = "server")] Self::Server(server) => server.run(), Self::Contributors => { diff --git a/docs/docs/commands/sync.md b/docs/docs/commands/sync.md index 8cd12c54..21d41337 100644 --- a/docs/docs/commands/sync.md +++ b/docs/docs/commands/sync.md @@ -40,9 +40,11 @@ here! You can delete your sync account with ``` -atuin unregister +atuin account delete ``` +This will remove your account and all synchronized history from the server. Local data will not be touched! + ## Key As all your data is encrypted, Atuin generates a key for you. It's stored in the -- cgit v1.3.1