diff options
| author | Ellie Huxtable <e@elm.sh> | 2021-04-20 21:53:07 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-04-20 20:53:07 +0000 |
| commit | a21737e2b7f8d1e426726bdd7536033f299d476a (patch) | |
| tree | e940afdff9c145d25d9a2895fd44a77d70719a2e /src | |
| parent | Switch to Warp + SQLx, use async, switch to Rust stable (#36) (diff) | |
| download | atuin-a21737e2b7f8d1e426726bdd7536033f299d476a.zip | |
Use cargo workspaces (#37)
* Switch to Cargo workspaces
Breaking things into "client", "server" and "common" makes managing the
codebase much easier!
client - anything running on a user's machine for adding history
server - handles storing/syncing history and running a HTTP server
common - request/response API definitions, common utils, etc
* Update dockerfile
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.rs | 70 | ||||
| -rw-r--r-- | src/command/history.rs | 10 | ||||
| -rw-r--r-- | src/command/import.rs | 6 | ||||
| -rw-r--r-- | src/command/login.rs | 8 | ||||
| -rw-r--r-- | src/command/mod.rs | 40 | ||||
| -rw-r--r-- | src/command/register.rs | 8 | ||||
| -rw-r--r-- | src/command/search.rs | 5 | ||||
| -rw-r--r-- | src/command/server.rs | 15 | ||||
| -rw-r--r-- | src/command/stats.rs | 8 | ||||
| -rw-r--r-- | src/command/sync.rs | 6 | ||||
| -rw-r--r-- | src/local/api_client.rs | 95 | ||||
| -rw-r--r-- | src/local/database.rs | 272 | ||||
| -rw-r--r-- | src/local/encryption.rs | 108 | ||||
| -rw-r--r-- | src/local/history.rs | 66 | ||||
| -rw-r--r-- | src/local/import.rs | 176 | ||||
| -rw-r--r-- | src/local/mod.rs | 6 | ||||
| -rw-r--r-- | src/local/sync.rs | 141 | ||||
| -rw-r--r-- | src/main.rs | 39 | ||||
| -rw-r--r-- | src/server/auth.rs | 222 | ||||
| -rw-r--r-- | src/server/database.rs | 202 | ||||
| -rw-r--r-- | src/server/handlers/history.rs | 89 | ||||
| -rw-r--r-- | src/server/handlers/mod.rs | 6 | ||||
| -rw-r--r-- | src/server/handlers/user.rs | 140 | ||||
| -rw-r--r-- | src/server/mod.rs | 23 | ||||
| -rw-r--r-- | src/server/models.rs | 49 | ||||
| -rw-r--r-- | src/server/router.rs | 121 | ||||
| -rw-r--r-- | src/settings.rs | 172 | ||||
| -rw-r--r-- | src/utils.rs | 24 |
28 files changed, 60 insertions, 2067 deletions
diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index 82ee6604..00000000 --- a/src/api.rs +++ /dev/null @@ -1,70 +0,0 @@ -use chrono::Utc; - -#[derive(Debug, Serialize, Deserialize)] -pub struct UserResponse { - pub username: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RegisterRequest { - pub email: String, - pub username: String, - pub password: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RegisterResponse { - pub session: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginRequest { - pub username: String, - pub password: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginResponse { - pub session: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct AddHistoryRequest { - pub id: String, - pub timestamp: chrono::DateTime<Utc>, - pub data: String, - pub hostname: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct CountResponse { - pub count: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SyncHistoryRequest { - pub sync_ts: chrono::DateTime<chrono::FixedOffset>, - pub history_ts: chrono::DateTime<chrono::FixedOffset>, - pub host: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SyncHistoryResponse { - pub history: Vec<String>, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ErrorResponse { - pub reason: String, -} - -impl ErrorResponse { - pub fn reply(reason: &str, status: warp::http::StatusCode) -> impl warp::Reply { - warp::reply::with_status( - warp::reply::json(&ErrorResponse { - reason: String::from(reason), - }), - status, - ) - } -} diff --git a/src/command/history.rs b/src/command/history.rs index 627efae4..2b691bac 100644 --- a/src/command/history.rs +++ b/src/command/history.rs @@ -4,10 +4,10 @@ use eyre::Result; use fork::{fork, Fork}; use structopt::StructOpt; -use crate::local::database::Database; -use crate::local::history::History; -use crate::local::sync; -use crate::settings::Settings; +use atuin_client::database::Database; +use atuin_client::history::History; +use atuin_client::settings::Settings; +use atuin_client::sync; #[derive(StructOpt)] pub enum Cmd { @@ -79,7 +79,7 @@ impl Cmd { db.update(&h)?; - if settings.local.should_sync()? { + if settings.should_sync()? { match fork() { Ok(Fork::Parent(child)) => { debug!("launched sync background process with PID {}", child); diff --git a/src/command/import.rs b/src/command/import.rs index ae927aaf..56fb30a7 100644 --- a/src/command/import.rs +++ b/src/command/import.rs @@ -5,9 +5,9 @@ use directories::UserDirs; use eyre::{eyre, Result}; use structopt::StructOpt; -use crate::local::database::Database; -use crate::local::history::History; -use crate::local::import::Zsh; +use atuin_client::database::Database; +use atuin_client::history::History; +use atuin_client::import::Zsh; use indicatif::ProgressBar; #[derive(StructOpt)] diff --git a/src/command/login.rs b/src/command/login.rs index 636ac0d3..eaeb297f 100644 --- a/src/command/login.rs +++ b/src/command/login.rs @@ -5,7 +5,7 @@ use std::io::prelude::*; use eyre::{eyre, Result}; use structopt::StructOpt; -use crate::settings::Settings; +use atuin_client::settings::Settings; #[derive(StructOpt)] #[structopt(setting(structopt::clap::AppSettings::DeriveDisplayOrder))] @@ -26,7 +26,7 @@ impl Cmd { map.insert("username", self.username.clone()); map.insert("password", self.password.clone()); - let url = format!("{}/login", settings.local.sync_address); + let url = format!("{}/login", settings.sync_address); let client = reqwest::blocking::Client::new(); let resp = client.post(url).json(&map).send()?; @@ -38,11 +38,11 @@ impl Cmd { let session = resp.json::<HashMap<String, String>>()?; let session = session["session"].clone(); - let session_path = settings.local.session_path.as_str(); + let session_path = settings.session_path.as_str(); let mut file = File::create(session_path)?; file.write_all(session.as_bytes())?; - let key_path = settings.local.key_path.as_str(); + let key_path = settings.key_path.as_str(); let mut file = File::create(key_path)?; file.write_all(&base64::decode(self.key.clone())?)?; diff --git a/src/command/mod.rs b/src/command/mod.rs index cd857e9f..6fd52613 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,9 +1,12 @@ +use std::path::PathBuf; + use eyre::Result; use structopt::StructOpt; -use uuid::Uuid; -use crate::local::database::Database; -use crate::settings::Settings; +use atuin_client::database::Sqlite; +use atuin_client::settings::Settings as ClientSettings; +use atuin_common::utils::uuid_v4; +use atuin_server::settings::Settings as ServerSettings; mod event; mod history; @@ -58,30 +61,33 @@ pub enum AtuinCmd { Key, } -pub fn uuid_v4() -> String { - Uuid::new_v4().to_simple().to_string() -} - impl AtuinCmd { - pub async fn run<T: Database + Send>(self, db: &mut T, settings: &Settings) -> Result<()> { + pub async fn run(self) -> Result<()> { + let client_settings = ClientSettings::new()?; + let server_settings = ServerSettings::new()?; + + let db_path = PathBuf::from(client_settings.db_path.as_str()); + + let mut db = Sqlite::new(db_path)?; + match self { - Self::History(history) => history.run(settings, db).await, - Self::Import(import) => import.run(db), - Self::Server(server) => server.run(settings).await, - Self::Stats(stats) => stats.run(db, settings), + Self::History(history) => history.run(&client_settings, &mut db).await, + Self::Import(import) => import.run(&mut db), + Self::Server(server) => server.run(&server_settings).await, + Self::Stats(stats) => stats.run(&mut db, &client_settings), Self::Init => init::init(), - Self::Search { query } => search::run(&query, db), + Self::Search { query } => search::run(&query, &mut db), - Self::Sync { force } => sync::run(settings, force, db).await, - Self::Login(l) => l.run(settings), + Self::Sync { force } => sync::run(&client_settings, force, &mut db).await, + Self::Login(l) => l.run(&client_settings), Self::Register(r) => register::run( - settings, + &client_settings, r.username.as_str(), r.email.as_str(), r.password.as_str(), ), Self::Key => { - let key = std::fs::read(settings.local.key_path.as_str())?; + let key = std::fs::read(client_settings.key_path.as_str())?; println!("{}", base64::encode(key)); Ok(()) } diff --git a/src/command/register.rs b/src/command/register.rs index 62bbeaeb..1126645a 100644 --- a/src/command/register.rs +++ b/src/command/register.rs @@ -5,7 +5,7 @@ use std::io::prelude::*; use eyre::{eyre, Result}; use structopt::StructOpt; -use crate::settings::Settings; +use atuin_client::settings::Settings; #[derive(StructOpt)] #[structopt(setting(structopt::clap::AppSettings::DeriveDisplayOrder))] @@ -26,7 +26,7 @@ pub fn run(settings: &Settings, username: &str, email: &str, password: &str) -> map.insert("email", email); map.insert("password", password); - let url = format!("{}/user/{}", settings.local.sync_address, username); + let url = format!("{}/user/{}", settings.sync_address, username); let resp = reqwest::blocking::get(url)?; if resp.status().is_success() { @@ -34,7 +34,7 @@ pub fn run(settings: &Settings, username: &str, email: &str, password: &str) -> return Ok(()); } - let url = format!("{}/register", settings.local.sync_address); + let url = format!("{}/register", settings.sync_address); let client = reqwest::blocking::Client::new(); let resp = client.post(url).json(&map).send()?; @@ -46,7 +46,7 @@ pub fn run(settings: &Settings, username: &str, email: &str, password: &str) -> let session = resp.json::<HashMap<String, String>>()?; let session = session["session"].clone(); - let path = settings.local.session_path.as_str(); + let path = settings.session_path.as_str(); let mut file = File::create(path)?; file.write_all(session.as_bytes())?; diff --git a/src/command/search.rs b/src/command/search.rs index d7b477da..773c112f 100644 --- a/src/command/search.rs +++ b/src/command/search.rs @@ -14,9 +14,10 @@ use tui::{ }; use unicode_width::UnicodeWidthStr; +use atuin_client::database::Database; +use atuin_client::history::History; + use crate::command::event::{Event, Events}; -use crate::local::database::Database; -use crate::local::history::History; const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/command/server.rs b/src/command/server.rs index a7835092..2fcf23d6 100644 --- a/src/command/server.rs +++ b/src/command/server.rs @@ -1,8 +1,8 @@ use eyre::Result; use structopt::StructOpt; -use crate::server; -use crate::settings::Settings; +use atuin_server::launch; +use atuin_server::settings::Settings; #[derive(StructOpt)] pub enum Cmd { @@ -23,13 +23,12 @@ impl Cmd { pub async fn run(&self, settings: &Settings) -> Result<()> { match self { Self::Start { host, port } => { - let host = host.as_ref().map_or( - settings.server.host.clone(), - std::string::ToString::to_string, - ); - let port = port.map_or(settings.server.port, |p| p); + let host = host + .as_ref() + .map_or(settings.host.clone(), std::string::ToString::to_string); + let port = port.map_or(settings.port, |p| p); - server::launch(settings, host, port).await + launch(settings, host, port).await } } } diff --git a/src/command/stats.rs b/src/command/stats.rs index 694484bc..0da303d7 100644 --- a/src/command/stats.rs +++ b/src/command/stats.rs @@ -8,9 +8,9 @@ use cli_table::{format::Justify, print_stdout, Cell, Style, Table}; use eyre::{eyre, Result}; use structopt::StructOpt; -use crate::local::database::Database; -use crate::local::history::History; -use crate::settings::Settings; +use atuin_client::database::Database; +use atuin_client::history::History; +use atuin_client::settings::Settings; #[derive(StructOpt)] pub enum Cmd { @@ -80,7 +80,7 @@ impl Cmd { words.join(" ") }; - let start = match settings.local.dialect.to_lowercase().as_str() { + let start = match settings.dialect.to_lowercase().as_str() { "uk" => parse_date_string(&words, Local::now(), Dialect::Uk)?, _ => parse_date_string(&words, Local::now(), Dialect::Us)?, }; diff --git a/src/command/sync.rs b/src/command/sync.rs index 88217b3c..d70b554f 100644 --- a/src/command/sync.rs +++ b/src/command/sync.rs @@ -1,8 +1,8 @@ use eyre::Result; -use crate::local::database::Database; -use crate::local::sync; -use crate::settings::Settings; +use atuin_client::database::Database; +use atuin_client::settings::Settings; +use atuin_client::sync; pub async fn run(settings: &Settings, force: bool, db: &mut (impl Database + Send)) -> Result<()> { sync::sync(settings, force, db).await?; diff --git a/src/local/api_client.rs b/src/local/api_client.rs deleted file mode 100644 index 1b64a295..00000000 --- a/src/local/api_client.rs +++ /dev/null @@ -1,95 +0,0 @@ -use chrono::Utc; -use eyre::Result; -use reqwest::header::{HeaderMap, AUTHORIZATION}; -use reqwest::Url; -use sodiumoxide::crypto::secretbox; - -use crate::api::{AddHistoryRequest, CountResponse, SyncHistoryResponse}; -use crate::local::encryption::decrypt; -use crate::local::history::History; -use crate::utils::hash_str; - -pub struct Client<'a> { - sync_addr: &'a str, - token: &'a str, - key: secretbox::Key, - client: reqwest::Client, -} - -impl<'a> Client<'a> { - pub fn new(sync_addr: &'a str, token: &'a str, key: secretbox::Key) -> Self { - Client { - sync_addr, - token, - key, - client: reqwest::Client::new(), - } - } - - pub async fn count(&self) -> Result<i64> { - let url = format!("{}/sync/count", self.sync_addr); - let url = Url::parse(url.as_str())?; - let token = format!("Token {}", self.token); - let token = token.parse()?; - - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, token); - - let resp = self.client.get(url).headers(headers).send().await?; - - let count = resp.json::<CountResponse>().await?; - - Ok(count.count) - } - - pub async fn get_history( - &self, - sync_ts: chrono::DateTime<Utc>, - history_ts: chrono::DateTime<Utc>, - host: Option<String>, - ) -> Result<Vec<History>> { - let host = match host { - None => hash_str(&format!("{}:{}", whoami::hostname(), whoami::username())), - Some(h) => h, - }; - - let url = format!( - "{}/sync/history?sync_ts={}&history_ts={}&host={}", - self.sync_addr, - urlencoding::encode(sync_ts.to_rfc3339().as_str()), - urlencoding::encode(history_ts.to_rfc3339().as_str()), - host, - ); - - let resp = self - .client - .get(url) - .header(AUTHORIZATION, format!("Token {}", self.token)) - .send() - .await?; - - let history = resp.json::<SyncHistoryResponse>().await?; - let history = history - .history - .iter() - .map(|h| serde_json::from_str(h).expect("invalid base64")) - .map(|h| decrypt(&h, &self.key).expect("failed to decrypt history! check your key")) - .collect(); - - Ok(history) - } - - pub async fn post_history(&self, history: &[AddHistoryRequest]) -> Result<()> { - let url = format!("{}/history", self.sync_addr); - let url = Url::parse(url.as_str())?; - - self.client - .post(url) - .json(history) - .header(AUTHORIZATION, format!("Token {}", self.token)) - .send() - .await?; - - Ok(()) - } -} diff --git a/src/local/database.rs b/src/local/database.rs deleted file mode 100644 index abc22bb8..00000000 --- a/src/local/database.rs +++ /dev/null @@ -1,272 +0,0 @@ -use chrono::prelude::*; -use chrono::Utc; -use std::path::Path; - -use eyre::Result; - -use rusqlite::{params, Connection}; -use rusqlite::{Params, Transaction}; - -use super::history::History; - -pub trait Database { - fn save(&mut self, h: &History) -> Result<()>; - fn save_bulk(&mut self, h: &[History]) -> Result<()>; - - fn load(&self, id: &str) -> Result<History>; - fn list(&self) -> Result<Vec<History>>; - fn range(&self, from: chrono::DateTime<Utc>, to: chrono::DateTime<Utc>) - -> Result<Vec<History>>; - - fn query(&self, query: &str, params: impl Params) -> Result<Vec<History>>; - fn update(&self, h: &History) -> Result<()>; - fn history_count(&self) -> Result<i64>; - - fn first(&self) -> Result<History>; - fn last(&self) -> Result<History>; - fn before(&self, timestamp: chrono::DateTime<Utc>, count: i64) -> Result<Vec<History>>; - - fn prefix_search(&self, query: &str) -> Result<Vec<History>>; -} - -// Intended for use on a developer machine and not a sync server. -// TODO: implement IntoIterator -pub struct Sqlite { - conn: Connection, -} - -impl Sqlite { - pub fn new(path: impl AsRef<Path>) -> Result<Self> { - let path = path.as_ref(); - debug!("opening sqlite database at {:?}", path); - - let create = !path.exists(); - if create { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; - } - } - - let conn = Connection::open(path)?; - - Self::setup_db(&conn)?; - - Ok(Self { conn }) - } - - fn setup_db(conn: &Connection) -> Result<()> { - debug!("running sqlite database setup"); - - conn.execute( - "create table if not exists history ( - id text primary key, - timestamp integer not null, - duration integer not null, - exit integer not null, - command text not null, - cwd text not null, - session text not null, - hostname text not null, - - unique(timestamp, cwd, command) - )", - [], - )?; - - conn.execute( - "create table if not exists history_encrypted ( - id text primary key, - data blob not null - )", - [], - )?; - - Ok(()) - } - - fn save_raw(tx: &Transaction, h: &History) -> Result<()> { - tx.execute( - "insert or ignore into history ( - id, - timestamp, - duration, - exit, - command, - cwd, - session, - hostname - ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - h.id, - h.timestamp.timestamp_nanos(), - h.duration, - h.exit, - h.command, - h.cwd, - h.session, - h.hostname - ], - )?; - - Ok(()) - } -} - -impl Database for Sqlite { - fn save(&mut self, h: &History) -> Result<()> { - debug!("saving history to sqlite"); - - let tx = self.conn.transaction()?; - Self::save_raw(&tx, h)?; - tx.commit()?; - - Ok(()) - } - - fn save_bulk(&mut self, h: &[History]) -> Result<()> { - debug!("saving history to sqlite"); - - let tx = self.conn.transaction()?; - for i in h { - Self::save_raw(&tx, i)? - } - tx.commit()?; - - Ok(()) - } - - fn load(&self, id: &str) -> Result<History> { - debug!("loading history item"); - - let mut stmt = self.conn.prepare( - "select id, timestamp, duration, exit, command, cwd, session, hostname from history - where id = ?1", - )?; - - let history = stmt.query_row(params![id], |row| { - history_from_sqlite_row(Some(id.to_string()), row) - })?; - - Ok(history) - } - - fn update(&self, h: &History) -> Result<()> { - debug!("updating sqlite history"); - - self.conn.execute( - "update history - set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8 - where id = ?1", - params![h.id, h.timestamp.timestamp_nanos(), h.duration, h.exit, h.command, h.cwd, h.session, h.hostname], - )?; - - Ok(()) - } - - fn list(&self) -> Result<Vec<History>> { - debug!("listing history"); - - let mut stmt = self - .conn - .prepare("SELECT * FROM history order by timestamp asc")?; - - let history_iter = stmt.query_map(params![], |row| history_from_sqlite_row(None, row))?; - - Ok(history_iter.filter_map(Result::ok).collect()) - } - - fn range( - &self, - from: chrono::DateTime<Utc>, - to: chrono::DateTime<Utc>, - ) -> Result<Vec<History>> { - debug!("listing history from {:?} to {:?}", from, to); - - let mut stmt = self.conn.prepare( - "SELECT * FROM history where timestamp >= ?1 and timestamp <= ?2 order by timestamp asc", - )?; - - let history_iter = stmt.query_map( - params![from.timestamp_nanos(), to.timestamp_nanos()], - |row| history_from_sqlite_row(None, row), - )?; - - Ok(history_iter.filter_map(Result::ok).collect()) - } - - fn first(&self) -> Result<History> { - let mut stmt = self - .conn - .prepare("SELECT * FROM history order by timestamp asc limit 1")?; - - let history = stmt.query_row(params![], |row| history_from_sqlite_row(None, row))?; - - Ok(history) - } - - fn last(&self) -> Result<History> { - let mut stmt = self - .conn - .prepare("SELECT * FROM history order by timestamp desc limit 1")?; - - let history = stmt.query_row(params![], |row| history_from_sqlite_row(None, row))?; - - Ok(history) - } - - fn before(&self, timestamp: chrono::DateTime<Utc>, count: i64) -> Result<Vec<History>> { - let mut stmt = self - .conn - .prepare("SELECT * FROM history where timestamp < ? order by timestamp desc limit ?")?; - - let history_iter = stmt.query_map(params![timestamp.timestamp_nanos(), count], |row| { - history_from_sqlite_row(None, row) - })?; - - Ok(history_iter.filter_map(Result::ok).collect()) - } - - fn query(&self, query: &str, params: impl Params) -> Result<Vec<History>> { - let mut stmt = self.conn.prepare(query)?; - - let history_iter = stmt.query_map(params, |row| history_from_sqlite_row(None, row))?; - - Ok(history_iter.filter_map(Result::ok).collect()) - } - - fn prefix_search(&self, query: &str) -> Result<Vec<History>> { - self.query( - "select * from history where command like ?1 || '%' order by timestamp asc limit 1000", - &[query], - ) - } - - fn history_count(&self) -> Result<i64> { - let res: i64 = - self.conn - .query_row_and_then("select count(1) from history;", params![], |row| row.get(0))?; - - Ok(res) - } -} - -fn history_from_sqlite_row( - id: Option<String>, - row: &rusqlite::Row, -) -> Result<History, rusqlite::Error> { - let id = match id { - Some(id) => id, - None => row.get(0)?, - }; - - Ok(History { - id, - timestamp: Utc.timestamp_nanos(row.get(1)?), - duration: row.get(2)?, - exit: row.get(3)?, - command: row.get(4)?, - cwd: row.get(5)?, - session: row.get(6)?, - hostname: row.get(7)?, - }) -} diff --git a/src/local/encryption.rs b/src/local/encryption.rs deleted file mode 100644 index 3c1699e3..00000000 --- a/src/local/encryption.rs +++ /dev/null @@ -1,108 +0,0 @@ -// The general idea is that we NEVER send cleartext history to the server -// This way the odds of anything private ending up where it should not are -// very low -// The server authenticates via the usual username and password. This has -// nothing to do with the encryption, and is purely authentication! The client -// generates its own secret key, and encrypts all shell history with libsodium's -// secretbox. The data is then sent to the server, where it is stored. All -// clients must share the secret in order to be able to sync, as it is needed -// to decrypt - -use std::fs::File; -use std::io::prelude::*; -use std::path::PathBuf; - -use eyre::{eyre, Result}; -use sodiumoxide::crypto::secretbox; - -use crate::local::history::History; -use crate::settings::Settings; - -#[derive(Debug, Serialize, Deserialize)] -pub struct EncryptedHistory { - pub ciphertext: Vec<u8>, - pub nonce: secretbox::Nonce, -} - -// Loads the secret key, will create + save if it doesn't exist -pub fn load_key(settings: &Settings) -> Result<secretbox::Key> { - let path = settings.local.key_path.as_str(); - - if PathBuf::from(path).exists() { - let bytes = std::fs::read(path)?; - let key: secretbox::Key = rmp_serde::from_read_ref(&bytes)?; - Ok(key) - } else { - let key = secretbox::gen_key(); - let buf = rmp_serde::to_vec(&key)?; - - let mut file = File::create(path)?; - file.write_all(&buf)?; - - Ok(key) - } -} - -pub fn encrypt(history: &History, key: &secretbox::Key) -> Result<EncryptedHistory> { - // serialize with msgpack - let buf = rmp_serde::to_vec(history)?; - - let nonce = secretbox::gen_nonce(); - - let ciphertext = secretbox::seal(&buf, &nonce, key); - - Ok(EncryptedHistory { ciphertext, nonce }) -} - -pub fn decrypt(encrypted_history: &EncryptedHistory, key: &secretbox::Key) -> Result<History> { - let plaintext = secretbox::open(&encrypted_history.ciphertext, &encrypted_history.nonce, key) - .map_err(|_| eyre!("failed to open secretbox - invalid key?"))?; - - let history = rmp_serde::from_read_ref(&plaintext)?; - - Ok(history) -} - -#[cfg(test)] -mod test { - use sodiumoxide::crypto::secretbox; - - use crate::local::history::History; - - use super::{decrypt, encrypt}; - - #[test] - fn test_encrypt_decrypt() { - let key1 = secretbox::gen_key(); - let key2 = secretbox::gen_key(); - - let history = History::new( - chrono::Utc::now(), - "ls".to_string(), - "/home/ellie".to_string(), - 0, - 1, - Some("beep boop".to_string()), - Some("booop".to_string()), - ); - - let e1 = encrypt(&history, &key1).unwrap(); - let e2 = encrypt(&history, &key2).unwrap(); - - assert_ne!(e1.ciphertext, e2.ciphertext); - assert_ne!(e1.nonce, e2.nonce); - - // test decryption works - // this should pass - match decrypt(&e1, &key1) { - Err(e) => assert!(false, "failed to decrypt, got {}", e), - Ok(h) => assert_eq!(h, history), - }; - - // this should err - match decrypt(&e2, &key1) { - Ok(_) => assert!(false, "expected an error decrypting with invalid key"), - Err(_) => {} - }; - } -} diff --git a/src/local/history.rs b/src/local/history.rs deleted file mode 100644 index 1712f8b9..00000000 --- a/src/local/history.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::env; -use std::hash::{Hash, Hasher}; - -use chrono::Utc; - -use crate::command::uuid_v4; - -// Any new fields MUST be Optional<>! -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct History { - pub id: String, - pub timestamp: chrono::DateTime<Utc>, - pub duration: i64, - pub exit: i64, - pub command: String, - pub cwd: String, - pub session: String, - pub hostname: String, -} - -impl History { - pub fn new( - timestamp: chrono::DateTime<Utc>, - command: String, - cwd: String, - exit: i64, - duration: i64, - session: Option<String>, - hostname: Option<String>, - ) -> Self { - let session = session - .or_else(|| env::var("ATUIN_SESSION").ok()) - .unwrap_or_else(uuid_v4); - let hostname = - hostname.unwrap_or_else(|| format!("{}:{}", whoami::hostname(), whoami::username())); - - Self { - id: uuid_v4(), - timestamp, - command, - cwd, - exit, - duration, - session, - hostname, - } - } -} - -impl PartialEq for History { - // for the sakes of listing unique history only, we do not care about - // anything else - // obviously this does not refer to the *same* item of history, but when - // we only render the command, it looks the same - fn eq(&self, other: &Self) -> bool { - self.command == other.command - } -} - -impl Eq for History {} - -impl Hash for History { - fn hash<H: Hasher>(&self, state: &mut H) { - self.command.hash(state); - } -} diff --git a/src/local/import.rs b/src/local/import.rs deleted file mode 100644 index 3b0b2a69..00000000 --- a/src/local/import.rs +++ /dev/null @@ -1,176 +0,0 @@ -// import old shell history! -// automatically hoover up all that we can find - -use std::io::{BufRead, BufReader, Seek, SeekFrom}; -use std::{fs::File, path::Path}; - -use chrono::prelude::*; -use chrono::Utc; -use eyre::{eyre, Result}; -use itertools::Itertools; - -use super::history::History; - -#[derive(Debug)] -pub struct Zsh { - file: BufReader<File>, - - pub loc: u64, - pub counter: i64, -} - -// this could probably be sped up -fn count_lines(buf: &mut BufReader<File>) -> Result<usize> { - let lines = buf.lines().count(); - buf.seek(SeekFrom::Start(0))?; - - Ok(lines) -} - -impl Zsh { - pub fn new(path: impl AsRef<Path>) -> Result<Self> { - let file = File::open(path)?; - let mut buf = BufReader::new(file); - let loc = count_lines(&mut buf)?; - - Ok(Self { - file: buf, - loc: loc as u64, - counter: 0, - }) - } -} - -fn parse_extended(line: &str, counter: i64) -> History { - let line = line.replacen(": ", "", 2); - let (time, duration) = line.splitn(2, ':').collect_tuple().unwrap(); - let (duration, command) = duration.splitn(2, ';').collect_tuple().unwrap(); - - let time = time - .parse::<i64>() - .unwrap_or_else(|_| chrono::Utc::now().timestamp()); - - let offset = chrono::Duration::milliseconds(counter); - let time = Utc.timestamp(time, 0); - let time = time + offset; - - let duration = duration.parse::<i64>().map_or(-1, |t| t * 1_000_000_000); - - // use nanos, because why the hell not? we won't display them. - History::new( - time, - command.trim_end().to_string(), - String::from("unknown"), - 0, // assume 0, we have no way of knowing :( - duration, - None, - None, - ) -} - -impl Zsh { - fn read_line(&mut self) -> Option<Result<String>> { - let mut line = String::new(); - - match self.file.read_line(&mut line) { - Ok(0) => None, - Ok(_) => Some(Ok(line)), - Err(e) => Some(Err(eyre!("failed to read line: {}", e))), // we can skip past things like invalid utf8 - } - } -} - -impl Iterator for Zsh { - type Item = Result<History>; - - fn next(&mut self) -> Option<Self::Item> { - // ZSH extended history records the timestamp + command duration - // These lines begin with : - // So, if the line begins with :, parse it. Otherwise it's just - // the command - let line = self.read_line()?; - - if let Err(e) = line { - return Some(Err(e)); // :( - } - - let mut line = line.unwrap(); - - while line.ends_with("\\\n") { - let next_line = self.read_line()?; - - if next_line.is_err() { - // There's a chance that the last line of a command has invalid - // characters, the only safe thing to do is break :/ - // usually just invalid utf8 or smth - // however, we really need to avoid missing history, so it's - // better to have some items that should have been part of - // something else, than to miss things. So break. - break; - } - - line.push_str(next_line.unwrap().as_str()); - } - - // We have to handle the case where a line has escaped newlines. - // Keep reading until we have a non-escaped newline - - let extended = line.starts_with(':'); - - if extended { - self.counter += 1; - Some(Ok(parse_extended(line.as_str(), self.counter))) - } else { - let time = chrono::Utc::now(); - let offset = chrono::Duration::seconds(self.counter); - let time = time - offset; - - self.counter += 1; - - Some(Ok(History::new( - time, - line.trim_end().to_string(), - String::from("unknown"), - -1, - -1, - None, - None, - ))) - } - } -} - -#[cfg(test)] -mod test { - use chrono::prelude::*; - use chrono::Utc; - - use super::parse_extended; - - #[test] - fn test_parse_extended_simple() { - let parsed = parse_extended(": 1613322469:0;cargo install atuin", 0); - - assert_eq!(parsed.command, "cargo install atuin"); - assert_eq!(parsed.duration, 0); - assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); - - let parsed = parse_extended(": 1613322469:10;cargo install atuin;cargo update", 0); - - assert_eq!(parsed.command, "cargo install atuin;cargo update"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); - - let parsed = parse_extended(": 1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷", 0); - - assert_eq!(parsed.command, "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); - - let parsed = parse_extended(": 1613322469:10;cargo install \\n atuin\n", 0); - - assert_eq!(parsed.command, "cargo install \\n atuin"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0)); - } -} diff --git a/src/local/mod.rs b/src/local/mod.rs deleted file mode 100644 index 9fe31292..00000000 --- a/src/local/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod api_client; -pub mod database; -pub mod encryption; -pub mod history; -pub mod import; -pub mod sync; diff --git a/src/local/sync.rs b/src/local/sync.rs deleted file mode 100644 index e0feb759..00000000 --- a/src/local/sync.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::convert::TryInto; - -use chrono::prelude::*; -use eyre::Result; - -use crate::local::api_client; -use crate::local::database::Database; -use crate::local::encryption::{encrypt, load_key}; -use crate::settings::{Local, Settings, HISTORY_PAGE_SIZE}; -use crate::{api::AddHistoryRequest, utils::hash_str}; - -// Currently sync is kinda naive, and basically just pages backwards through -// history. This means newly added stuff shows up properly! We also just use -// the total count in each database to indicate whether a sync is needed. -// I think this could be massively improved! If we had a way of easily -// indicating count per time period (hour, day, week, year, etc) then we can -// easily pinpoint where we are missing data and what needs downloading. Start -// with year, then find the week, then the day, then the hour, then download it -// all! The current naive approach will do for now. - -// Check if remote has things we don't, and if so, download them. -// Returns (num downloaded, total local) -async fn sync_download( - force: bool, - client: &api_client::Client<'_>, - db: &mut (impl Database + Send), -) -> Result<(i64, i64)> { - let remote_count = client.count().await?; - - let initial_local = db.history_count()?; - let mut local_count = initial_local; - - let mut last_sync = if force { - Utc.timestamp_millis(0) - } else { - Local::last_sync()? - }; - - let mut last_timestamp = Utc.timestamp_millis(0); - - let host = if force { Some(String::from("")) } else { None }; - - while remote_count > local_count { - let page = client - .get_history(last_sync, last_timestamp, host.clone()) - .await?; - - if page.len() < HISTORY_PAGE_SIZE.try_into().unwrap() { - break; - } - - db.save_bulk(&page)?; - - local_count = db.history_count()?; - - let page_last = page - .last() - .expect("could not get last element of page") - .timestamp; - - // in the case of a small sync frequency, it's possible for history to - // be "lost" between syncs. In this case we need to rewind the sync - // timestamps - if page_last == last_timestamp { - last_timestamp = Utc.timestamp_millis(0); - last_sync = last_sync - chrono::Duration::hours(1); - } else { - last_timestamp = page_last; - } - } - - Ok((local_count - initial_local, local_count)) -} - -// Check if we have things remote doesn't, and if so, upload them -async fn sync_upload( - settings: &Settings, - _force: bool, - client: &api_client::Client<'_>, - db: &mut (impl Database + Send), -) -> Result<()> { - let initial_remote_count = client.count().await?; - let mut remote_count = initial_remote_count; - - let local_count = db.history_count()?; - - let key = load_key(settings)?; // encryption key - - // first just try the most recent set - - let mut cursor = Utc::now(); - - while local_count > remote_count { - let last = db.before(cursor, HISTORY_PAGE_SIZE)?; - let mut buffer = Vec::<AddHistoryRequest>::new(); - - if last.is_empty() { - break; - } - - for i in last { - let data = encrypt(&i, &key)?; - let data = serde_json::to_string(&data)?; - - let add_hist = AddHistoryRequest { - id: i.id, - timestamp: i.timestamp, - data, - hostname: hash_str(i.hostname.as_str()), - }; - - buffer.push(add_hist); - } - - // anything left over outside of the 100 block size - client.post_history(&buffer).await?; - cursor = buffer.last().unwrap().timestamp; - - remote_count = client.count().await?; - } - - Ok(()) -} - -pub async fn sync(settings: &Settings, force: bool, db: &mut (impl Database + Send)) -> Result<()> { - let client = api_client::Client::new( - settings.local.sync_address.as_str(), - settings.local.session_token.as_str(), - load_key(settings)?, - ); - - sync_upload(settings, force, &client, db).await?; - - let download = sync_download(force, &client, db).await?; - - debug!("sync downloaded {}", download.0); - - Local::save_sync_time()?; - - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index 0045a943..c116d1f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,16 @@ #![warn(clippy::pedantic, clippy::nursery)] #![allow(clippy::use_self)] // not 100% reliable -use std::path::PathBuf; - -use eyre::{eyre, Result}; +use eyre::Result; use fern::colors::{Color, ColoredLevelConfig}; -use human_panic::setup_panic; use structopt::{clap::AppSettings, StructOpt}; #[macro_use] extern crate log; -#[macro_use] -extern crate serde_derive; - use command::AtuinCmd; -use local::database::Sqlite; -use settings::Settings; -mod api; mod command; -mod local; -mod server; -mod settings; -mod utils; #[derive(StructOpt)] #[structopt( @@ -33,28 +20,13 @@ mod utils; global_settings(&[AppSettings::ColoredHelp, AppSettings::DeriveDisplayOrder]) )] struct Atuin { - #[structopt(long, parse(from_os_str), help = "db file path")] - db: Option<PathBuf>, - #[structopt(subcommand)] atuin: AtuinCmd, } impl Atuin { - async fn run(self, settings: &Settings) -> Result<()> { - let db_path = if let Some(db_path) = self.db { - let path = db_path - .to_str() - .ok_or_else(|| eyre!("path {:?} was not valid UTF-8", db_path))?; - let path = shellexpand::full(path)?; - PathBuf::from(path.as_ref()) - } else { - PathBuf::from(settings.local.db_path.as_str()) - }; - - let mut db = Sqlite::new(db_path)?; - - self.atuin.run(&mut db, settings).await + async fn run(self) -> Result<()> { + self.atuin.run().await } } @@ -78,8 +50,5 @@ async fn main() -> Result<()> { .chain(std::io::stdout()) .apply()?; - let settings = Settings::new()?; - setup_panic!(); - - Atuin::from_args().run(&settings).await + Atuin::from_args().run().await } diff --git a/src/server/auth.rs b/src/server/auth.rs deleted file mode 100644 index 52a73108..00000000 --- a/src/server/auth.rs +++ /dev/null @@ -1,222 +0,0 @@ -/* -use self::diesel::prelude::*; -use eyre::Result; -use rocket::http::Status; -use rocket::request::{self, FromRequest, Outcome, Request}; -use rocket::State; -use rocket_contrib::databases::diesel; -use sodiumoxide::crypto::pwhash::argon2id13; - -use rocket_contrib::json::Json; -use uuid::Uuid; - -use super::models::{NewSession, NewUser, Session, User}; -use super::views::ApiResponse; - -use crate::api::{LoginRequest, RegisterRequest}; -use crate::schema::{sessions, users}; -use crate::settings::Settings; -use crate::utils::hash_secret; - -use super::database::AtuinDbConn; - -#[derive(Debug)] -pub enum KeyError { - Missing, - Invalid, -} - -pub fn verify_str(secret: &str, verify: &str) -> bool { - sodiumoxide::init().unwrap(); - - let mut padded = [0_u8; 128]; - secret.as_bytes().iter().enumerate().for_each(|(i, val)| { - padded[i] = *val; - }); - - match argon2id13::HashedPassword::from_slice(&padded) { - Some(hp) => argon2id13::pwhash_verify(&hp, verify.as_bytes()), - None => false, - } -} - -impl<'a, 'r> FromRequest<'a, 'r> for User { - type Error = KeyError; - - fn from_request(request: &'a Request<'r>) -> request::Outcome<User, Self::Error> { - let session: Vec<_> = request.headers().get("authorization").collect(); - - if session.is_empty() { - return Outcome::Failure((Status::BadRequest, KeyError::Missing)); - } else if session.len() > 1 { - return Outcome::Failure((Status::BadRequest, KeyError::Invalid)); - } - - let session: Vec<_> = session[0].split(' ').collect(); - - if session.len() != 2 { - return Outcome::Failure((Status::BadRequest, KeyError::Invalid)); - } - - if session[0] != "Token" { - return Outcome::Failure((Status::BadRequest, KeyError::Invalid)); - } - - let session = session[1]; - - let db = request - .guard::<AtuinDbConn>() - .succeeded() - .expect("failed to load database"); - - let session = sessions::table - .filter(sessions::token.eq(session)) - .first::<Session>(&*db); - - if session.is_err() { - return Outcome::Failure((Status::Unauthorized, KeyError::Invalid)); - } - - let session = session.unwrap(); - - let user = users::table.find(session.user_id).first(&*db); - - match user { - Ok(user) => Outcome::Success(user), - Err(_) => Outcome::Failure((Status::Unauthorized, KeyError::Invalid)), - } - } -} - -#[get("/user/<user>")] -#[allow(clippy::clippy::needless_pass_by_value)] -pub fn get_user(user: String, conn: AtuinDbConn) -> ApiResponse { - use crate::schema::users::dsl::{username, users}; - - let user: Result<String, diesel::result::Error> = users - .select(username) - .filter(username.eq(user)) - .first(&*conn); - - if user.is_err() { - return ApiResponse { - json: json!({ - "message": "could not find user", - }), - status: Status::NotFound, - }; - } - - let user = user.unwrap(); - - ApiResponse { - json: json!({ "username": user.as_str() }), - status: Status::Ok, - } -} - -#[post("/register", data = "<register>")] -#[allow(clippy::clippy::needless_pass_by_value)] -pub fn register( - conn: AtuinDbConn, - register: Json<RegisterRequest>, - settings: State<Settings>, -) -> ApiResponse { - if !settings.server.open_registration { - return ApiResponse { - status: Status::BadRequest, - json: json!({ - "message": "registrations are not open" - }), - }; - } - - let hashed = hash_secret(register.password.as_str()); - - let new_user = NewUser { - email: register.email.as_str(), - username: register.username.as_str(), - password: hashed.as_str(), - }; - - let user = diesel::insert_into(users::table) - .values(&new_user) - .get_result(&*conn); - - if user.is_err() { - return ApiResponse { - status: Status::BadRequest, - json: json!({ - "message": "failed to create user - username or email in use?", - }), - }; - } - - let user: User = user.unwrap(); - let token = Uuid::new_v4().to_simple().to_string(); - - let new_session = NewSession { - user_id: user.id, - token: token.as_str(), - }; - - match diesel::insert_into(sessions::table) - .values(&new_session) - .execute(&*conn) - { - Ok(_) => ApiResponse { - status: Status::Ok, - json: json!({"message": "user created!", "session": token}), - }, - Err(_) => ApiResponse { - status: Status::BadRequest, - json: json!({ "message": "failed to create user"}), - }, - } -} - -#[post("/login", data = "<login>")] -#[allow(clippy::clippy::needless_pass_by_value)] -pub fn login(conn: AtuinDbConn, login: Json<LoginRequest>) -> ApiResponse { - let user = users::table - .filter(users::username.eq(login.username.as_str())) - .first(&*conn); - - if user.is_err() { - return ApiResponse { - status: Status::NotFound, - json: json!({"message": "user not found"}), - }; - } - - let user: User = user.unwrap(); - - let session = sessions::table - .filter(sessions::user_id.eq(user.id)) - .first(&*conn); - - // a session should exist... - if session.is_err() { - return ApiResponse { - status: Status::InternalServerError, - json: json!({"message": "something went wrong"}), - }; - } - - let verified = verify_str(user.password.as_str(), login.password.as_str()); - - if !verified { - return ApiResponse { - status: Status::NotFound, - json: json!({"message": "user not found"}), - }; - } - - let session: Session = session.unwrap(); - - ApiResponse { - status: Status::Ok, - json: json!({"session": session.token}), - } -} -*/ diff --git a/src/server/database.rs b/src/server/database.rs deleted file mode 100644 index 5945baaf..00000000 --- a/src/server/database.rs +++ /dev/null @@ -1,202 +0,0 @@ -use async_trait::async_trait; - -use eyre::{eyre, Result}; -use sqlx::postgres::PgPoolOptions; - -use crate::settings::HISTORY_PAGE_SIZE; - -use super::models::{History, NewHistory, NewSession, NewUser, Session, User}; - -#[async_trait] -pub trait Database { - async fn get_session(&self, token: &str) -> Result<Session>; - async fn get_session_user(&self, token: &str) -> Result<User>; - async fn add_session(&self, session: &NewSession) -> Result<()>; - - async fn get_user(&self, username: String) -> Result<User>; - async fn get_user_session(&self, u: &User) -> Result<Session>; - async fn add_user(&self, user: NewUser) -> Result<i64>; - - async fn count_history(&self, user: &User) -> Result<i64>; - async fn list_history( - &self, - user: &User, - created_since: chrono::NaiveDateTime, - since: chrono::NaiveDateTime, - host: String, - ) -> Result<Vec<History>>; - async fn add_history(&self, history: &[NewHistory]) -> Result<()>; -} - -#[derive(Clone)] -pub struct Postgres { - pool: sqlx::Pool<sqlx::postgres::Postgres>, -} - -impl Postgres { - pub async fn new(uri: &str) -> Result<Self, sqlx::Error> { - let pool = PgPoolOptions::new() - .max_connections(100) - .connect(uri) - .await?; - - Ok(Self { pool }) - } -} - -#[async_trait] -impl Database for Postgres { - async fn get_session(&self, token: &str) -> Result<Session> { - let res: Option<Session> = - sqlx::query_as::<_, Session>("select * from sessions where token = $1") - .bind(token) - .fetch_optional(&self.pool) - .await?; - - if let Some(s) = res { - Ok(s) - } else { - Err(eyre!("could not find session")) - } - } - - async fn get_user(&self, username: String) -> Result<User> { - let res: Option<User> = - sqlx::query_as::<_, User>("select * from users where username = $1") - .bind(username) - .fetch_optional(&self.pool) - .await?; - - if let Some(u) = res { - Ok(u) - } else { - Err(eyre!("could not find user")) - } - } - - async fn get_session_user(&self, token: &str) -> Result<User> { - let res: Option<User> = sqlx::query_as::<_, User>( - "select * from users - inner join sessions - on users.id = sessions.user_id - and sessions.token = $1", - ) - .bind(token) - .fetch_optional(&self.pool) - .await?; - - if let Some(u) = res { - Ok(u) - } else { - Err(eyre!("could not find user")) - } - } - - async fn count_history(&self, user: &User) -> Result<i64> { - let res: (i64,) = sqlx::query_as( - "select count(1) from history - where user_id = $1", - ) - .bind(user.id) - .fetch_one(&self.pool) - .await?; - - Ok(res.0) - } - - async fn list_history( - &self, - user: &User, - created_since: chrono::NaiveDateTime, - since: chrono::NaiveDateTime, - host: String, - ) -> Result<Vec<History>> { - let res = sqlx::query_as::<_, History>( - "select * from history - where user_id = $1 - and hostname != $2 - and created_at >= $3 - and timestamp >= $4 - order by timestamp asc - limit $5", - ) - .bind(user.id) - .bind(host) - .bind(created_since) - .bind(since) - .bind(HISTORY_PAGE_SIZE) - .fetch_all(&self.pool) - .await?; - - Ok(res) - } - - async fn add_history(&self, history: &[NewHistory]) -> Result<()> { - let mut tx = self.pool.begin().await?; - - for i in history { - sqlx::query( - "insert into history - (client_id, user_id, hostname, timestamp, data) - values ($1, $2, $3, $4, $5) - on conflict do nothing - ", - ) - .bind(i.client_id) - .bind(i.user_id) - .bind(i.hostname) - .bind(i.timestamp) - .bind(i.data) - .execute(&mut tx) - .await?; - } - - tx.commit().await?; - - Ok(()) - } - - async fn add_user(&self, user: NewUser) -> Result<i64> { - let res: (i64,) = sqlx::query_as( - "insert into users - (username, email, password) - values($1, $2, $3) - returning id", - ) - .bind(user.username.as_str()) - .bind(user.email.as_str()) - .bind(user.password) - .fetch_one(&self.pool) - .await?; - - Ok(res.0) - } - - async fn add_session(&self, session: &NewSession) -> Result<()> { - sqlx::query( - "insert into sessions - (user_id, token) - values($1, $2)", - ) - .bind(session.user_id) - .bind(session.token) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_user_session(&self, u: &User) -> Result<Session> { - let res: Option<Session> = - sqlx::query_as::<_, Session>("select * from sessions where user_id = $1") - .bind(u.id) - .fetch_optional(&self.pool) - .await?; - - if let Some(s) = res { - Ok(s) - } else { - Err(eyre!("could not find session")) - } - } -} diff --git a/src/server/handlers/history.rs b/src/server/handlers/history.rs deleted file mode 100644 index 4fd6f03f..00000000 --- a/src/server/handlers/history.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::convert::Infallible; - -use warp::{http::StatusCode, reply::json}; - -use crate::api::{ - AddHistoryRequest, CountResponse, ErrorResponse, SyncHistoryRequest, SyncHistoryResponse, -}; -use crate::server::database::Database; -use crate::server::models::{NewHistory, User}; - -pub async fn count( - user: User, - db: impl Database + Clone + Send + Sync, -) -> Result<Box<dyn warp::Reply>, Infallible> { - db.count_history(&user).await.map_or( - Ok(Box::new(ErrorResponse::reply( - "failed to query history count", - StatusCode::INTERNAL_SERVER_ERROR, - ))), - |count| Ok(Box::new(json(&CountResponse { count }))), - ) -} - -pub async fn list( - req: SyncHistoryRequest, - user: User, - db: impl Database + Clone + Send + Sync, -) -> Result<Box<dyn warp::Reply>, Infallible> { - let history = db - .list_history( - &user, - req.sync_ts.naive_utc(), - req.history_ts.naive_utc(), - req.host, - ) - .await; - - if let Err(e) = history { - error!("failed to load history: {}", e); - let resp = - ErrorResponse::reply("failed to load history", StatusCode::INTERNAL_SERVER_ERROR); - let resp = Box::new(resp); - return Ok(resp); - } - - let history: Vec<String> = history - .unwrap() - .iter() - .map(|i| i.data.to_string()) - .collect(); - - debug!( - "loaded {} items of history for user {}", - history.len(), - user.id - ); - - Ok(Box::new(json(&SyncHistoryResponse { history }))) -} - -pub async fn add( - req: Vec<AddHistoryRequest>, - user: User, - db: impl Database + Clone + Send + Sync, -) -> Result<Box<dyn warp::Reply>, Infallible> { - debug!("request to add {} history items", req.len()); - - let history: Vec<NewHistory> = req - .iter() - .map(|h| NewHistory { - client_id: h.id.as_str(), - user_id: user.id, - hostname: h.hostname.as_str(), - timestamp: h.timestamp.naive_utc(), - data: h.data.as_str(), - }) - .collect(); - - if let Err(e) = db.add_history(&history).await { - error!("failed to add history: {}", e); - - return Ok(Box::new(ErrorResponse::reply( - "failed to add history", - StatusCode::INTERNAL_SERVER_ERROR, - ))); - }; - - Ok(Box::new(warp::reply())) -} diff --git a/src/server/handlers/mod.rs b/src/server/handlers/mod.rs deleted file mode 100644 index 3c20538c..00000000 --- a/src/server/handlers/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod history; -pub mod user; - -pub const fn index() -> &'static str { - "\"Through the fathomless deeps of space swims the star turtle Great A\u{2019}Tuin, bearing on its back the four giant elephants who carry on their shoulders the mass of the Discworld.\"\n\t-- Sir Terry Pratchett" -} diff --git a/src/server/handlers/user.rs b/src/server/handlers/user.rs deleted file mode 100644 index 782d7dbd..00000000 --- a/src/server/handlers/user.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::convert::Infallible; - -use sodiumoxide::crypto::pwhash::argon2id13; -use uuid::Uuid; -use warp::http::StatusCode; -use warp::reply::json; - -use crate::api::{ - ErrorResponse, LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, UserResponse, -}; -use crate::server::database::Database; -use crate::server::models::{NewSession, NewUser}; -use crate::settings::Settings; -use crate::utils::hash_secret; - -pub fn verify_str(secret: &str, verify: &str) -> bool { - sodiumoxide::init().unwrap(); - - let mut padded = [0_u8; 128]; - secret.as_bytes().iter().enumerate().for_each(|(i, val)| { - padded[i] = *val; - }); - - match argon2id13::HashedPassword::from_slice(&padded) { - Some(hp) => argon2id13::pwhash_verify(&hp, verify.as_bytes()), - None => false, - } -} - -pub async fn get( - username: String, - db: impl Database + Clone + Send + Sync, -) -> Result<Box<dyn warp::Reply>, Infallible> { - let user = match db.get_user(username).await { - Ok(user) => user, - Err(e) => { - debug!("user not found: {}", e); - return Ok(Box::new(ErrorResponse::reply( - "user not found", - StatusCode::NOT_FOUND, - ))); - } - }; - - Ok(Box::new(warp::reply::json(&UserResponse { - username: user.username, - }))) -} - -pub async fn register( - register: RegisterRequest, - settings: Settings, - db: impl Database + Clone + Send + Sync, -) -> Result<Box<dyn warp::Reply>, Infallible> { - if !settings.server.open_registration { - return Ok(Box::new(ErrorResponse::reply( - "this server is not open for registrations", - StatusCode::BAD_REQUEST, - ))); - } - - let hashed = hash_secret(register.password.as_str()); - - let new_user = NewUser { - email: register.email, - username: register.username, - password: hashed, - }; - - let user_id = match db.add_user(new_user).await { - Ok(id) => id, - Err(e) => { - error!("failed to add user: {}", e); - return Ok(Box::new(ErrorResponse::reply( - "failed to add user", - StatusCode::BAD_REQUEST, - ))); - } - }; - - let token = Uuid::new_v4().to_simple().to_string(); - - let new_session = NewSession { - user_id, - token: token.as_str(), - }; - - match db.add_session(&new_session).await { - Ok(_) => Ok(Box::new(json(&RegisterResponse { session: token }))), - Err(e) => { - error!("failed to add session: {}", e); - Ok(Box::new(ErrorResponse::reply( - "failed to register user", - StatusCode::BAD_REQUEST, - ))) - } - } -} - -pub async fn login( - login: LoginRequest, - db: impl Database + Clone + Send + Sync, -) -> Result<Box<dyn warp::Reply>, Infallible> { - let user = match db.get_user(login.username.clone()).await { - Ok(u) => u, - Err(e) => { - error!("failed to get user {}: {}", login.username.clone(), e); - - return Ok(Box::new(ErrorResponse::reply( - "user not found", - StatusCode::NOT_FOUND, - ))); - } - }; - - let session = match db.get_user_session(&user).await { - Ok(u) => u, - Err(e) => { - error!("failed to get session for {}: {}", login.username, e); - - return Ok(Box::new(ErrorResponse::reply( - "user not found", - StatusCode::NOT_FOUND, - ))); - } - }; - - let verified = verify_str(user.password.as_str(), login.password.as_str()); - - if !verified { - return Ok(Box::new(ErrorResponse::reply( - "user not found", - StatusCode::NOT_FOUND, - ))); - } - - Ok(Box::new(warp::reply::json(&LoginResponse { - session: session.token, - }))) -} diff --git a/src/server/mod.rs b/src/server/mod.rs deleted file mode 100644 index d5e083df..00000000 --- a/src/server/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -use std::net::IpAddr; - -use eyre::Result; - -use crate::settings::Settings; - -pub mod auth; -pub mod database; -pub mod handlers; -pub mod models; -pub mod router; - -pub async fn launch(settings: &Settings, host: String, port: u16) -> Result<()> { - // routes to run: - // index, register, add_history, login, get_user, sync_count, sync_list - let host = host.parse::<IpAddr>()?; - - let r = router::router(settings).await?; - - warp::serve(r).run((host, port)).await; - - Ok(()) -} diff --git a/src/server/models.rs b/src/server/models.rs deleted file mode 100644 index fbf1897e..00000000 --- a/src/server/models.rs +++ /dev/null @@ -1,49 +0,0 @@ -use chrono::prelude::*; - -#[derive(sqlx::FromRow)] -pub struct History { - pub id: i64, - pub client_id: String, // a client generated ID - pub user_id: i64, - pub hostname: String, - pub timestamp: NaiveDateTime, - - pub data: String, - - pub created_at: NaiveDateTime, -} - -pub struct NewHistory<'a> { - pub client_id: &'a str, - pub user_id: i64, - pub hostname: &'a str, - pub timestamp: chrono::NaiveDateTime, - - pub data: &'a str, -} - -#[derive(sqlx::FromRow)] -pub struct User { - pub id: i64, - pub username: String, - pub email: String, - pub password: String, -} - -#[derive(sqlx::FromRow)] -pub struct Session { - pub id: i64, - pub user_id: i64, - pub token: String, -} - -pub struct NewUser { - pub username: String, - pub email: String, - pub password: String, -} - -pub struct NewSession<'a> { - pub user_id: i64, - pub token: &'a str, -} diff --git a/src/server/router.rs b/src/server/router.rs deleted file mode 100644 index ed317ab2..00000000 --- a/src/server/router.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::convert::Infallible; - -use eyre::Result; -use warp::Filter; - -use super::handlers; -use super::{database::Database, database::Postgres}; -use crate::server::models::User; -use crate::{api::SyncHistoryRequest, settings::Settings}; - -fn with_settings( - settings: Settings, -) -> impl Filter<Extract = (Settings,), Error = Infallible> + Clone { - warp::any().map(move || settings.clone()) -} - -fn with_db( - db: impl Database + Clone + Send + Sync, -) -> impl Filter<Extract = (impl Database + Clone,), Error = Infallible> + Clone { - warp::any().map(move || db.clone()) -} - -fn with_user( - postgres: Postgres, -) -> impl Filter<Extract = (User,), Error = warp::Rejection> + Clone { - warp::header::<String>("authorization").and_then(move |header: String| { - // async closures are still buggy :( - let postgres = postgres.clone(); - - async move { - let header: Vec<&str> = header.split(' ').collect(); - - let token; - - if header.len() == 2 { - if header[0] != "Token" { - return Err(warp::reject()); - } - - token = header[1]; - } else { - return Err(warp::reject()); - } - - let user = postgres - .get_session_user(token) - .await - .map_err(|_| warp::reject())?; - - Ok(user) - } - }) -} - -pub async fn router( - settings: &Settings, -) -> Result<impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone> { - let postgres = Postgres::new(settings.server.db_uri.as_str()).await?; - let index = warp::get().and(warp::path::end()).map(handlers::index); - - let count = warp::get() - .and(warp::path("sync")) - .and(warp::path("count")) - .and(warp::path::end()) - .and(with_user(postgres.clone())) - .and(with_db(postgres.clone())) - .and_then(handlers::history::count); - - let sync = warp::get() - .and(warp::path("sync")) - .and(warp::path("history")) - .and(warp::query::<SyncHistoryRequest>()) - .and(warp::path::end()) - .and(with_user(postgres.clone())) - .and(with_db(postgres.clone())) - .and_then(handlers::history::list); - - let add_history = warp::post() - .and(warp::path("history")) - .and(warp::path::end()) - .and(warp::body::json()) - .and(with_user(postgres.clone())) - .and(with_db(postgres.clone())) - .and_then(handlers::history::add); - - let user = warp::get() - .and(warp::path("user")) - .and(warp::path::param::<String>()) - .and(warp::path::end()) - .and(with_db(postgres.clone())) - .and_then(handlers::user::get); - - let register = warp::post() - .and(warp::path("register")) - .and(warp::path::end()) - .and(warp::body::json()) - .and(with_settings(settings.clone())) - .and(with_db(postgres.clone())) - .and_then(handlers::user::register); - - let login = warp::post() - .and(warp::path("login")) - .and(warp::path::end()) - .and(warp::body::json()) - .and(with_db(postgres)) - .and_then(handlers::user::login); - - let r = warp::any() - .and( - index - .or(count) - .or(sync) - .or(add_history) - .or(user) - .or(register) - .or(login), - ) - .with(warp::filters::log::log("atuin::api")); - - Ok(r) -} diff --git a/src/settings.rs b/src/settings.rs deleted file mode 100644 index 5325610e..00000000 --- a/src/settings.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::fs::{create_dir_all, File}; -use std::io::prelude::*; -use std::path::{Path, PathBuf}; - -use chrono::prelude::*; -use chrono::Utc; -use config::{Config, Environment, File as ConfigFile}; -use directories::ProjectDirs; -use eyre::{eyre, Result}; -use parse_duration::parse; - -pub const HISTORY_PAGE_SIZE: i64 = 100; - -#[derive(Clone, Debug, Deserialize)] -pub struct Local { - pub dialect: String, - pub auto_sync: bool, - pub sync_address: String, - pub sync_frequency: String, - pub db_path: String, - pub key_path: String, - pub session_path: String, - - // This is automatically loaded when settings is created. Do not set in - // config! Keep secrets and settings apart. - pub session_token: String, -} - -impl Local { - pub fn save_sync_time() -> Result<()> { - let sync_time_path = ProjectDirs::from("com", "elliehuxtable", "atuin") - .ok_or_else(|| eyre!("could not determine key file location"))?; - let sync_time_path = sync_time_path.data_dir().join("last_sync_time"); - - std::fs::write(sync_time_path, Utc::now().to_rfc3339())?; - - Ok(()) - } - - pub fn last_sync() -> Result<chrono::DateTime<Utc>> { - let sync_time_path = ProjectDirs::from("com", "elliehuxtable", "atuin"); - - if sync_time_path.is_none() { - debug!("failed to load projectdirs, not syncing"); - return Err(eyre!("could not load project dirs")); - } - - let sync_time_path = sync_time_path.unwrap(); - let sync_time_path = sync_time_path.data_dir().join("last_sync_time"); - - if !sync_time_path.exists() { - return Ok(Utc.ymd(1970, 1, 1).and_hms(0, 0, 0)); - } - - let time = std::fs::read_to_string(sync_time_path)?; - let time = chrono::DateTime::parse_from_rfc3339(time.as_str())?; - - Ok(time.with_timezone(&Utc)) - } - - pub fn should_sync(&self) -> Result<bool> { - if !self.auto_sync { - return Ok(false); - } - - match parse(self.sync_frequency.as_str()) { - Ok(d) => { - let d = chrono::Duration::from_std(d).unwrap(); - Ok(Utc::now() - Local::last_sync()? >= d) - } - Err(e) => Err(eyre!("failed to check sync: {}", e)), - } - } -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Server { - pub host: String, - pub port: u16, - pub db_uri: String, - pub open_registration: bool, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Settings { - pub local: Local, - pub server: Server, -} - -impl Settings { - pub fn new() -> Result<Self> { - let config_dir = ProjectDirs::from("com", "elliehuxtable", "atuin").unwrap(); - let config_dir = config_dir.config_dir(); - - create_dir_all(config_dir)?; - - let config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG") { - PathBuf::from(p) - } else { - let mut config_file = PathBuf::new(); - config_file.push(config_dir); - config_file.push("config.toml"); - config_file - }; - - // create the config file if it does not exist - - let mut s = Config::new(); - - let db_path = ProjectDirs::from("com", "elliehuxtable", "atuin") - .ok_or_else(|| eyre!("could not determine db file location"))? - .data_dir() - .join("history.db"); - - let key_path = ProjectDirs::from("com", "elliehuxtable", "atuin") - .ok_or_else(|| eyre!("could not determine key file location"))? - .data_dir() - .join("key"); - - let session_path = ProjectDirs::from("com", "elliehuxtable", "atuin") - .ok_or_else(|| eyre!("could not determine session file location"))? - .data_dir() - .join("session"); - - s.set_default("local.db_path", db_path.to_str())?; - s.set_default("local.key_path", key_path.to_str())?; - s.set_default("local.session_path", session_path.to_str())?; - s.set_default("local.dialect", "us")?; - s.set_default("local.auto_sync", true)?; - s.set_default("local.sync_frequency", "5m")?; - s.set_default("local.sync_address", "https://api.atuin.sh")?; - - s.set_default("server.host", "127.0.0.1")?; - s.set_default("server.port", 8888)?; - s.set_default("server.open_registration", false)?; - s.set_default("server.db_uri", "default_uri")?; - - if config_file.exists() { - s.merge(ConfigFile::with_name(config_file.to_str().unwrap()))?; - } else { - let example_config = include_bytes!("../config.toml"); - let mut file = File::create(config_file)?; - file.write_all(example_config)?; - } - - s.merge(Environment::with_prefix("atuin").separator("_"))?; - - // all paths should be expanded - let db_path = s.get_str("local.db_path")?; - let db_path = shellexpand::full(db_path.as_str())?; - s.set("local.db_path", db_path.to_string())?; - - let key_path = s.get_str("local.key_path")?; - let key_path = shellexpand::full(key_path.as_str())?; - s.set("local.key_path", key_path.to_string())?; - - let session_path = s.get_str("local.session_path")?; - let session_path = shellexpand::full(session_path.as_str())?; - s.set("local.session_path", session_path.to_string())?; - - // Finally, set the auth token - if Path::new(session_path.to_string().as_str()).exists() { - let token = std::fs::read_to_string(session_path.to_string())?; - s.set("local.session_token", token.trim())?; - } else { - s.set("local.session_token", "not logged in")?; - } - - s.try_into() - .map_err(|e| eyre!("failed to deserialize: {}", e)) - } -} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index b395b148..00000000 --- a/src/utils.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crypto::digest::Digest; -use crypto::sha2::Sha256; -use sodiumoxide::crypto::pwhash::argon2id13; - -pub fn hash_secret(secret: &str) -> String { - sodiumoxide::init().unwrap(); - let hash = argon2id13::pwhash( - secret.as_bytes(), - argon2id13::OPSLIMIT_INTERACTIVE, - argon2id13::MEMLIMIT_INTERACTIVE, - ) - .unwrap(); - let texthash = std::str::from_utf8(&hash.0).unwrap().to_string(); - - // postgres hates null chars. don't do that to postgres - texthash.trim_end_matches('\u{0}').to_string() -} - -pub fn hash_str(string: &str) -> String { - let mut hasher = Sha256::new(); - hasher.input_str(string); - - hasher.result_str() -} |
