From a21737e2b7f8d1e426726bdd7536033f299d476a Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Tue, 20 Apr 2021 21:53:07 +0100 Subject: 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 --- .gitignore | 1 + Cargo.lock | 166 ++++++++++----------- Cargo.toml | 31 ++-- Dockerfile | 53 +++---- atuin-client/Cargo.toml | 40 ++++++ atuin-client/config.toml | 24 ++++ atuin-client/src/api_client.rs | 96 +++++++++++++ atuin-client/src/database.rs | 272 +++++++++++++++++++++++++++++++++++ atuin-client/src/encryption.rs | 108 ++++++++++++++ atuin-client/src/history.rs | 66 +++++++++ atuin-client/src/import.rs | 176 +++++++++++++++++++++++ atuin-client/src/lib.rs | 13 ++ atuin-client/src/settings.rs | 149 +++++++++++++++++++ atuin-client/src/sync.rs | 142 ++++++++++++++++++ atuin-common/Cargo.toml | 21 +++ atuin-common/src/api.rs | 70 +++++++++ atuin-common/src/lib.rs | 5 + atuin-common/src/utils.rs | 29 ++++ atuin-server/Cargo.toml | 38 +++++ atuin-server/server.toml | 11 ++ atuin-server/src/auth.rs | 222 ++++++++++++++++++++++++++++ atuin-server/src/database.rs | 202 ++++++++++++++++++++++++++ atuin-server/src/handlers/history.rs | 89 ++++++++++++ atuin-server/src/handlers/mod.rs | 6 + atuin-server/src/handlers/user.rs | 141 ++++++++++++++++++ atuin-server/src/lib.rs | 30 ++++ atuin-server/src/models.rs | 49 +++++++ atuin-server/src/router.rs | 123 ++++++++++++++++ atuin-server/src/settings.rs | 57 ++++++++ config.toml | 43 ------ src/api.rs | 70 --------- src/command/history.rs | 10 +- src/command/import.rs | 6 +- src/command/login.rs | 8 +- src/command/mod.rs | 40 +++--- src/command/register.rs | 8 +- src/command/search.rs | 5 +- src/command/server.rs | 15 +- src/command/stats.rs | 8 +- src/command/sync.rs | 6 +- src/local/api_client.rs | 95 ------------ src/local/database.rs | 272 ----------------------------------- src/local/encryption.rs | 108 -------------- src/local/history.rs | 66 --------- src/local/import.rs | 176 ----------------------- src/local/mod.rs | 6 - src/local/sync.rs | 141 ------------------ src/main.rs | 39 +---- src/server/auth.rs | 222 ---------------------------- src/server/database.rs | 202 -------------------------- src/server/handlers/history.rs | 89 ------------ src/server/handlers/mod.rs | 6 - src/server/handlers/user.rs | 140 ------------------ src/server/mod.rs | 23 --- src/server/models.rs | 49 ------- src/server/router.rs | 121 ---------------- src/settings.rs | 172 ---------------------- src/utils.rs | 24 ---- 58 files changed, 2353 insertions(+), 2247 deletions(-) create mode 100644 atuin-client/Cargo.toml create mode 100644 atuin-client/config.toml create mode 100644 atuin-client/src/api_client.rs create mode 100644 atuin-client/src/database.rs create mode 100644 atuin-client/src/encryption.rs create mode 100644 atuin-client/src/history.rs create mode 100644 atuin-client/src/import.rs create mode 100644 atuin-client/src/lib.rs create mode 100644 atuin-client/src/settings.rs create mode 100644 atuin-client/src/sync.rs create mode 100644 atuin-common/Cargo.toml create mode 100644 atuin-common/src/api.rs create mode 100644 atuin-common/src/lib.rs create mode 100644 atuin-common/src/utils.rs create mode 100644 atuin-server/Cargo.toml create mode 100644 atuin-server/server.toml create mode 100644 atuin-server/src/auth.rs create mode 100644 atuin-server/src/database.rs create mode 100644 atuin-server/src/handlers/history.rs create mode 100644 atuin-server/src/handlers/mod.rs create mode 100644 atuin-server/src/handlers/user.rs create mode 100644 atuin-server/src/lib.rs create mode 100644 atuin-server/src/models.rs create mode 100644 atuin-server/src/router.rs create mode 100644 atuin-server/src/settings.rs delete mode 100644 config.toml delete mode 100644 src/api.rs delete mode 100644 src/local/api_client.rs delete mode 100644 src/local/database.rs delete mode 100644 src/local/encryption.rs delete mode 100644 src/local/history.rs delete mode 100644 src/local/import.rs delete mode 100644 src/local/mod.rs delete mode 100644 src/local/sync.rs delete mode 100644 src/server/auth.rs delete mode 100644 src/server/database.rs delete mode 100644 src/server/handlers/history.rs delete mode 100644 src/server/handlers/mod.rs delete mode 100644 src/server/handlers/user.rs delete mode 100644 src/server/mod.rs delete mode 100644 src/server/models.rs delete mode 100644 src/server/router.rs delete mode 100644 src/settings.rs delete mode 100644 src/utils.rs diff --git a/.gitignore b/.gitignore index fedaa2b1..45e115b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target +*/target .env diff --git a/Cargo.lock b/Cargo.lock index 2f7d6d73..16e8e82d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "addr2line" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55f82cfe485775d02112886f4169bde0c5894d75e79ead7eafe7e40a25e45f7" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "ahash" version = "0.4.7" @@ -100,17 +85,46 @@ name = "atuin" version = "0.5.0" dependencies = [ "async-trait", + "atuin-client", + "atuin-common", + "atuin-server", "base64", "chrono", "chrono-english", "cli-table", - "config", "directories", - "dotenv", "eyre", "fern", "fork", - "human-panic", + "humantime", + "indicatif", + "itertools", + "log", + "reqwest", + "rusqlite", + "serde 1.0.125", + "serde_derive", + "serde_json", + "structopt", + "termion", + "tokio", + "tui", + "unicode-width", +] + +[[package]] +name = "atuin-client" +version = "0.1.0" +dependencies = [ + "async-trait", + "atuin-common", + "base64", + "chrono", + "chrono-english", + "config", + "directories", + "eyre", + "fern", "humantime", "indicatif", "itertools", @@ -126,11 +140,55 @@ dependencies = [ "serde_json", "shellexpand", "sodiumoxide", + "tokio", + "urlencoding", + "uuid", + "whoami", +] + +[[package]] +name = "atuin-common" +version = "0.1.0" +dependencies = [ + "chrono", + "eyre", + "rmp-serde", + "rust-crypto", + "serde 1.0.125", + "serde_derive", + "serde_json", + "sodiumoxide", + "uuid", + "warp", +] + +[[package]] +name = "atuin-server" +version = "0.1.0" +dependencies = [ + "async-trait", + "atuin-common", + "base64", + "chrono", + "chrono-english", + "config", + "directories", + "eyre", + "fern", + "fork", + "indicatif", + "log", + "parse_duration", + "rand 0.8.3", + "reqwest", + "rmp-serde", + "rust-crypto", + "serde 1.0.125", + "serde_derive", + "serde_json", + "sodiumoxide", "sqlx", - "structopt", - "termion", "tokio", - "tui", "unicode-width", "urlencoding", "uuid", @@ -144,20 +202,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" -[[package]] -name = "backtrace" -version = "0.3.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d117600f438b1707d4e4ae15d3595657288f8235a0eb593e80ecc98ab34e1bc" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - [[package]] name = "base64" version = "0.13.0" @@ -771,12 +815,6 @@ dependencies = [ "wasi 0.10.2+wasi-snapshot-preview1", ] -[[package]] -name = "gimli" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6503fe142514ca4799d4c26297c4248239fe8838d827db6bd6065c6ed29a6ce" - [[package]] name = "h2" version = "0.3.2" @@ -907,21 +945,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47" -[[package]] -name = "human-panic" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39f357a500abcbd7c5f967c1d45c8838585b36743823b9d43488f24850534e36" -dependencies = [ - "backtrace", - "os_type", - "serde 1.0.125", - "serde_derive", - "termcolor", - "toml", - "uuid", -] - [[package]] name = "humantime" version = "2.1.0" @@ -1168,16 +1191,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "miniz_oxide" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" -dependencies = [ - "adler", - "autocfg", -] - [[package]] name = "mio" version = "0.7.11" @@ -1377,12 +1390,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" -[[package]] -name = "object" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9a7ab5d64814df0fe4a4b5ead45ed6c5f181ee3ff04ba344313a6c80446c5d4" - [[package]] name = "once_cell" version = "1.5.2" @@ -1428,15 +1435,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "os_type" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edc011af0ae98b7f88cf7e4a83b70a54a75d2b8cb013d6efd02e5956207e9eb" -dependencies = [ - "regex", -] - [[package]] name = "parking_lot" version = "0.11.1" @@ -1900,12 +1898,6 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e52c148ef37f8c375d49d5a73aa70713125b7f19095948a923f80afdeb22ec2" -[[package]] -name = "rustc-demangle" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e3bad0ee36814ca07d7968269dd4b7ec89ec2da10c4bb613928d3077083c232" - [[package]] name = "rustc-serialize" version = "0.3.24" diff --git a/Cargo.toml b/Cargo.toml index 9a57f947..57cd00e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,47 +1,40 @@ [package] name = "atuin" version = "0.5.0" -authors = ["Ellie Huxtable "] +authors = ["Ellie Huxtable "] edition = "2018" license = "MIT" description = "atuin - magical shell history" +[workspace] +members = ["./atuin-client", "./atuin-server", "./atuin-common"] + [dependencies] +atuin-server = { path = "atuin-server", version = "0.1.0" } +atuin-client = { path = "atuin-client", version = "0.1.0" } +atuin-common = { path = "atuin-common", version = "0.1.0" } + log = "0.4" fern = {version = "0.6.0", features = ["colored"] } chrono = { version = "0.4", features = ["serde"] } eyre = "0.6" -shellexpand = "2" structopt = "0.3" directories = "3" -uuid = { version = "0.8", features = ["v4"] } indicatif = "0.15.0" -whoami = "1.1.2" -chrono-english = "0.1.4" -cli-table = "0.4" -config = "0.11" serde_derive = "1.0.125" serde = "1.0.125" serde_json = "1.0.64" -rmp-serde = "0.15.4" tui = "0.14" termion = "1.5" unicode-width = "0.1" itertools = "0.10.0" -dotenv = "0.15.0" -sodiumoxide = "0.2.6" -reqwest = { version = "0.11", features = ["blocking", "json"] } -base64 = "0.13.0" fork = "0.1.18" -parse_duration = "2.1.1" -rand = "0.8.3" -rust-crypto = "^0.2" -human-panic = "1.0.3" tokio = { version = "1", features = ["full"] } -warp = "0.3" -sqlx = { version = "0.5", features = [ "runtime-tokio-native-tls", "uuid", "chrono", "postgres" ] } async-trait = "0.1.49" -urlencoding = "1.1.1" +chrono-english = "0.1.4" +cli-table = "0.4" +reqwest = { version = "0.11", features = ["blocking", "json"] } +base64 = "0.13.0" humantime = "2.1.0" [dependencies.rusqlite] diff --git a/Dockerfile b/Dockerfile index 0c19ef6d..e7125414 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,22 @@ -FROM rust:1.51-buster as builder - -RUN cargo new --bin atuin -WORKDIR /atuin -COPY ./Cargo.toml ./Cargo.toml -COPY ./Cargo.lock ./Cargo.lock - -RUN cargo build --release - -RUN rm src/*.rs - -ADD . ./ - -RUN rm ./target/release/deps/atuin* -RUN cargo build --release - -FROM debian:buster-slim - -RUN apt-get update \ - && apt-get install -y ca-certificates tzdata libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -EXPOSE 8888 - -ENV TZ=Etc/UTC -ENV RUST_LOG=info -ENV ATUIN_CONFIG=/config/config.toml - -COPY --from=builder /atuin/target/release/atuin ./atuin - -ENTRYPOINT ["./atuin"] +FROM lukemathwalker/cargo-chef as planner +WORKDIR app +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM lukemathwalker/cargo-chef as cacher +WORKDIR app +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json + +FROM rust as builder +WORKDIR app +COPY . . +# Copy over the cached dependencies +COPY --from=cacher /app/target target +COPY --from=cacher $CARGO_HOME $CARGO_HOME +RUN cargo build --release --bin atuin + +FROM debian:buster-slim as runtime +WORKDIR app +COPY --from=builder /app/target/release/atuin /usr/local/bin +ENTRYPOINT ["/usr/local/bin/atuin"] diff --git a/atuin-client/Cargo.toml b/atuin-client/Cargo.toml new file mode 100644 index 00000000..06c96a9a --- /dev/null +++ b/atuin-client/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "atuin-client" +version = "0.1.0" +authors = ["Ellie Huxtable "] +edition = "2018" +license = "MIT" +description = "client library for atuin" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +atuin-common = { path = "../atuin-common", version = "0.1.0" } + +log = "0.4" +fern = {version = "0.6.0", features = ["colored"] } +chrono = { version = "0.4", features = ["serde"] } +eyre = "0.6" +directories = "3" +uuid = { version = "0.8", features = ["v4"] } +indicatif = "0.15.0" +whoami = "1.1.2" +chrono-english = "0.1.4" +config = "0.11" +serde_derive = "1.0.125" +serde = "1.0.125" +serde_json = "1.0.64" +rmp-serde = "0.15.4" +sodiumoxide = "0.2.6" +reqwest = { version = "0.11", features = ["blocking", "json"] } +base64 = "0.13.0" +parse_duration = "2.1.1" +rand = "0.8.3" +rust-crypto = "^0.2" +tokio = { version = "1", features = ["full"] } +async-trait = "0.1.49" +urlencoding = "1.1.1" +humantime = "2.1.0" +rusqlite= { version = "0.25", features = ["bundled"] } +itertools = "0.10.0" +shellexpand = "2" diff --git a/atuin-client/config.toml b/atuin-client/config.toml new file mode 100644 index 00000000..33e5b545 --- /dev/null +++ b/atuin-client/config.toml @@ -0,0 +1,24 @@ +## where to store your database, default is your system data directory +## mac: ~/Library/Application Support/com.elliehuxtable.atuin/history.db +## linux: ~/.local/share/atuin/history.db +# db_path = "~/.history.db" + +## where to store your encryption key, default is your system data directory +# key_path = "~/.key" + +## where to store your auth session token, default is your system data directory +# session_path = "~/.key" + +## date format used, either "us" or "uk" +# dialect = "uk" + +## enable or disable automatic sync +# auto_sync = true + +## how often to sync history. note that this is only triggered when a command +## is ran, so sync intervals may well be longer +## set it to 0 to sync after every command +# sync_frequency = "5m" + +## address of the sync server +# sync_address = "https://api.atuin.sh" diff --git a/atuin-client/src/api_client.rs b/atuin-client/src/api_client.rs new file mode 100644 index 00000000..db2802c3 --- /dev/null +++ b/atuin-client/src/api_client.rs @@ -0,0 +1,96 @@ +use chrono::Utc; +use eyre::Result; +use reqwest::header::{HeaderMap, AUTHORIZATION}; +use reqwest::Url; +use sodiumoxide::crypto::secretbox; + +use atuin_common::api::{AddHistoryRequest, CountResponse, SyncHistoryResponse}; +use atuin_common::utils::hash_str; + +use crate::encryption::decrypt; +use crate::history::History; + +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 { + 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::().await?; + + Ok(count.count) + } + + pub async fn get_history( + &self, + sync_ts: chrono::DateTime, + history_ts: chrono::DateTime, + host: Option, + ) -> Result> { + 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::().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/atuin-client/src/database.rs b/atuin-client/src/database.rs new file mode 100644 index 00000000..abc22bb8 --- /dev/null +++ b/atuin-client/src/database.rs @@ -0,0 +1,272 @@ +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; + fn list(&self) -> Result>; + fn range(&self, from: chrono::DateTime, to: chrono::DateTime) + -> Result>; + + fn query(&self, query: &str, params: impl Params) -> Result>; + fn update(&self, h: &History) -> Result<()>; + fn history_count(&self) -> Result; + + fn first(&self) -> Result; + fn last(&self) -> Result; + fn before(&self, timestamp: chrono::DateTime, count: i64) -> Result>; + + fn prefix_search(&self, query: &str) -> Result>; +} + +// 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) -> Result { + 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 { + 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> { + 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, + to: chrono::DateTime, + ) -> Result> { + 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 { + 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 { + 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, count: i64) -> Result> { + 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> { + 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> { + self.query( + "select * from history where command like ?1 || '%' order by timestamp asc limit 1000", + &[query], + ) + } + + fn history_count(&self) -> Result { + 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, + row: &rusqlite::Row, +) -> Result { + 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/atuin-client/src/encryption.rs b/atuin-client/src/encryption.rs new file mode 100644 index 00000000..37153f94 --- /dev/null +++ b/atuin-client/src/encryption.rs @@ -0,0 +1,108 @@ +// 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::history::History; +use crate::settings::Settings; + +#[derive(Debug, Serialize, Deserialize)] +pub struct EncryptedHistory { + pub ciphertext: Vec, + pub nonce: secretbox::Nonce, +} + +// Loads the secret key, will create + save if it doesn't exist +pub fn load_key(settings: &Settings) -> Result { + let path = settings.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 { + // 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 { + 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/atuin-client/src/history.rs b/atuin-client/src/history.rs new file mode 100644 index 00000000..7f607784 --- /dev/null +++ b/atuin-client/src/history.rs @@ -0,0 +1,66 @@ +use std::env; +use std::hash::{Hash, Hasher}; + +use chrono::Utc; + +use atuin_common::utils::uuid_v4; + +// Any new fields MUST be Optional<>! +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct History { + pub id: String, + pub timestamp: chrono::DateTime, + 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, + command: String, + cwd: String, + exit: i64, + duration: i64, + session: Option, + hostname: Option, + ) -> 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(&self, state: &mut H) { + self.command.hash(state); + } +} diff --git a/atuin-client/src/import.rs b/atuin-client/src/import.rs new file mode 100644 index 00000000..3b0b2a69 --- /dev/null +++ b/atuin-client/src/import.rs @@ -0,0 +1,176 @@ +// 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, + + pub loc: u64, + pub counter: i64, +} + +// this could probably be sped up +fn count_lines(buf: &mut BufReader) -> Result { + let lines = buf.lines().count(); + buf.seek(SeekFrom::Start(0))?; + + Ok(lines) +} + +impl Zsh { + pub fn new(path: impl AsRef) -> Result { + 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::() + .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::().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> { + 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; + + fn next(&mut self) -> Option { + // 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/atuin-client/src/lib.rs b/atuin-client/src/lib.rs new file mode 100644 index 00000000..1207bfdb --- /dev/null +++ b/atuin-client/src/lib.rs @@ -0,0 +1,13 @@ +#[macro_use] +extern crate log; + +#[macro_use] +extern crate serde_derive; + +pub mod api_client; +pub mod database; +pub mod encryption; +pub mod history; +pub mod import; +pub mod settings; +pub mod sync; diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs new file mode 100644 index 00000000..e28963c0 --- /dev/null +++ b/atuin-client/src/settings.rs @@ -0,0 +1,149 @@ +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 Settings { + 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 Settings { + 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> { + 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 { + 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() - Settings::last_sync()? >= d) + } + Err(e) => Err(eyre!("failed to check sync: {}", e)), + } + } + + pub fn new() -> Result { + 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 + }; + + 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("db_path", db_path.to_str())?; + s.set_default("key_path", key_path.to_str())?; + s.set_default("session_path", session_path.to_str())?; + s.set_default("dialect", "us")?; + s.set_default("auto_sync", true)?; + s.set_default("sync_frequency", "5m")?; + s.set_default("sync_address", "https://api.atuin.sh")?; + + 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("db_path")?; + let db_path = shellexpand::full(db_path.as_str())?; + s.set("db_path", db_path.to_string())?; + + let key_path = s.get_str("key_path")?; + let key_path = shellexpand::full(key_path.as_str())?; + s.set("key_path", key_path.to_string())?; + + let session_path = s.get_str("session_path")?; + let session_path = shellexpand::full(session_path.as_str())?; + s.set("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("session_token", token.trim())?; + } else { + s.set("session_token", "not logged in")?; + } + + s.try_into() + .map_err(|e| eyre!("failed to deserialize: {}", e)) + } +} diff --git a/atuin-client/src/sync.rs b/atuin-client/src/sync.rs new file mode 100644 index 00000000..0ca8d3a6 --- /dev/null +++ b/atuin-client/src/sync.rs @@ -0,0 +1,142 @@ +use std::convert::TryInto; + +use chrono::prelude::*; +use eyre::Result; + +use atuin_common::{api::AddHistoryRequest, utils::hash_str}; + +use crate::api_client; +use crate::database::Database; +use crate::encryption::{encrypt, load_key}; +use crate::settings::{Settings, HISTORY_PAGE_SIZE}; + +// 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 { + Settings::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::::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.sync_address.as_str(), + settings.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); + + Settings::save_sync_time()?; + + Ok(()) +} diff --git a/atuin-common/Cargo.toml b/atuin-common/Cargo.toml new file mode 100644 index 00000000..efe7da63 --- /dev/null +++ b/atuin-common/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "atuin-common" +version = "0.1.0" +authors = ["Ellie Huxtable "] +edition = "2018" +license = "MIT" +description = "common library for atuin" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rust-crypto = "^0.2" +sodiumoxide = "0.2.6" +chrono = { version = "0.4", features = ["serde"] } +eyre = "0.6" +serde_derive = "1.0.125" +serde = "1.0.125" +serde_json = "1.0.64" +rmp-serde = "0.15.4" +warp = "0.3" +uuid = { version = "0.8", features = ["v4"] } diff --git a/atuin-common/src/api.rs b/atuin-common/src/api.rs new file mode 100644 index 00000000..82ee6604 --- /dev/null +++ b/atuin-common/src/api.rs @@ -0,0 +1,70 @@ +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, + 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, + pub history_ts: chrono::DateTime, + pub host: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SyncHistoryResponse { + pub history: Vec, +} + +#[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/atuin-common/src/lib.rs b/atuin-common/src/lib.rs new file mode 100644 index 00000000..0a01e10d --- /dev/null +++ b/atuin-common/src/lib.rs @@ -0,0 +1,5 @@ +#[macro_use] +extern crate serde_derive; + +pub mod api; +pub mod utils; diff --git a/atuin-common/src/utils.rs b/atuin-common/src/utils.rs new file mode 100644 index 00000000..ac5738b3 --- /dev/null +++ b/atuin-common/src/utils.rs @@ -0,0 +1,29 @@ +use crypto::digest::Digest; +use crypto::sha2::Sha256; +use sodiumoxide::crypto::pwhash::argon2id13; +use uuid::Uuid; + +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() +} + +pub fn uuid_v4() -> String { + Uuid::new_v4().to_simple().to_string() +} diff --git a/atuin-server/Cargo.toml b/atuin-server/Cargo.toml new file mode 100644 index 00000000..2cf85d26 --- /dev/null +++ b/atuin-server/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "atuin-server" +version = "0.1.0" +authors = ["Ellie Huxtable "] +edition = "2018" +license = "MIT" +description = "server library for atuin" + +[dependencies] +atuin-common = { path = "../atuin-common", version = "0.1.0" } + +log = "0.4" +fern = {version = "0.6.0", features = ["colored"] } +chrono = { version = "0.4", features = ["serde"] } +eyre = "0.6" +directories = "3" +uuid = { version = "0.8", features = ["v4"] } +indicatif = "0.15.0" +whoami = "1.1.2" +chrono-english = "0.1.4" +config = "0.11" +serde_derive = "1.0.125" +serde = "1.0.125" +serde_json = "1.0.64" +rmp-serde = "0.15.4" +unicode-width = "0.1" +sodiumoxide = "0.2.6" +reqwest = { version = "0.11", features = ["blocking", "json"] } +base64 = "0.13.0" +fork = "0.1.18" +parse_duration = "2.1.1" +rand = "0.8.3" +rust-crypto = "^0.2" +tokio = { version = "1", features = ["full"] } +warp = "0.3" +sqlx = { version = "0.5", features = [ "runtime-tokio-native-tls", "uuid", "chrono", "postgres" ] } +async-trait = "0.1.49" +urlencoding = "1.1.1" diff --git a/atuin-server/server.toml b/atuin-server/server.toml new file mode 100644 index 00000000..808f15f1 --- /dev/null +++ b/atuin-server/server.toml @@ -0,0 +1,11 @@ +## host to bind, can also be passed via CLI args +# host = "127.0.0.1" + +## port to bind, can also be passed via CLI args +# port = 8888 + +## whether to allow anyone to register an account +# open_registration = false + +## URI for postgres (using development creds here) +# db_uri="postgres://username:password@localhost/atuin" diff --git a/atuin-server/src/auth.rs b/atuin-server/src/auth.rs new file mode 100644 index 00000000..52a73108 --- /dev/null +++ b/atuin-server/src/auth.rs @@ -0,0 +1,222 @@ +/* +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 { + 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::() + .succeeded() + .expect("failed to load database"); + + let session = sessions::table + .filter(sessions::token.eq(session)) + .first::(&*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/")] +#[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 = 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 = "")] +#[allow(clippy::clippy::needless_pass_by_value)] +pub fn register( + conn: AtuinDbConn, + register: Json, + settings: State, +) -> 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 = "")] +#[allow(clippy::clippy::needless_pass_by_value)] +pub fn login(conn: AtuinDbConn, login: Json) -> 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/atuin-server/src/database.rs b/atuin-server/src/database.rs new file mode 100644 index 00000000..5945baaf --- /dev/null +++ b/atuin-server/src/database.rs @@ -0,0 +1,202 @@ +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; + async fn get_session_user(&self, token: &str) -> Result; + async fn add_session(&self, session: &NewSession) -> Result<()>; + + async fn get_user(&self, username: String) -> Result; + async fn get_user_session(&self, u: &User) -> Result; + async fn add_user(&self, user: NewUser) -> Result; + + async fn count_history(&self, user: &User) -> Result; + async fn list_history( + &self, + user: &User, + created_since: chrono::NaiveDateTime, + since: chrono::NaiveDateTime, + host: String, + ) -> Result>; + async fn add_history(&self, history: &[NewHistory]) -> Result<()>; +} + +#[derive(Clone)] +pub struct Postgres { + pool: sqlx::Pool, +} + +impl Postgres { + pub async fn new(uri: &str) -> Result { + 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 { + let res: Option = + 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 { + let res: Option = + 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 { + let res: Option = 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 { + 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> { + 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 { + 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 { + let res: Option = + 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/atuin-server/src/handlers/history.rs b/atuin-server/src/handlers/history.rs new file mode 100644 index 00000000..1aebdde1 --- /dev/null +++ b/atuin-server/src/handlers/history.rs @@ -0,0 +1,89 @@ +use std::convert::Infallible; + +use warp::{http::StatusCode, reply::json}; + +use crate::database::Database; +use crate::models::{NewHistory, User}; +use atuin_common::api::{ + AddHistoryRequest, CountResponse, ErrorResponse, SyncHistoryRequest, SyncHistoryResponse, +}; + +pub async fn count( + user: User, + db: impl Database + Clone + Send + Sync, +) -> Result, 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, 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 = 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, + user: User, + db: impl Database + Clone + Send + Sync, +) -> Result, Infallible> { + debug!("request to add {} history items", req.len()); + + let history: Vec = 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/atuin-server/src/handlers/mod.rs b/atuin-server/src/handlers/mod.rs new file mode 100644 index 00000000..3c20538c --- /dev/null +++ b/atuin-server/src/handlers/mod.rs @@ -0,0 +1,6 @@ +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/atuin-server/src/handlers/user.rs b/atuin-server/src/handlers/user.rs new file mode 100644 index 00000000..6b142cdc --- /dev/null +++ b/atuin-server/src/handlers/user.rs @@ -0,0 +1,141 @@ +use std::convert::Infallible; + +use sodiumoxide::crypto::pwhash::argon2id13; +use uuid::Uuid; +use warp::http::StatusCode; +use warp::reply::json; + +use atuin_common::api::{ + ErrorResponse, LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, UserResponse, +}; +use atuin_common::utils::hash_secret; + +use crate::database::Database; +use crate::models::{NewSession, NewUser}; +use crate::settings::Settings; + +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, 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, Infallible> { + if !settings.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, 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/atuin-server/src/lib.rs b/atuin-server/src/lib.rs new file mode 100644 index 00000000..36b6ffa7 --- /dev/null +++ b/atuin-server/src/lib.rs @@ -0,0 +1,30 @@ +use std::net::IpAddr; + +use eyre::Result; + +use crate::settings::Settings; + +#[macro_use] +extern crate log; + +#[macro_use] +extern crate serde_derive; + +pub mod auth; +pub mod database; +pub mod handlers; +pub mod models; +pub mod router; +pub mod settings; + +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::()?; + + let r = router::router(settings).await?; + + warp::serve(r).run((host, port)).await; + + Ok(()) +} diff --git a/atuin-server/src/models.rs b/atuin-server/src/models.rs new file mode 100644 index 00000000..fbf1897e --- /dev/null +++ b/atuin-server/src/models.rs @@ -0,0 +1,49 @@ +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/atuin-server/src/router.rs b/atuin-server/src/router.rs new file mode 100644 index 00000000..d106068d --- /dev/null +++ b/atuin-server/src/router.rs @@ -0,0 +1,123 @@ +use std::convert::Infallible; + +use eyre::Result; +use warp::Filter; + +use atuin_common::api::SyncHistoryRequest; + +use super::handlers; +use super::{database::Database, database::Postgres}; +use crate::models::User; +use crate::settings::Settings; + +fn with_settings( + settings: Settings, +) -> impl Filter + Clone { + warp::any().map(move || settings.clone()) +} + +fn with_db( + db: impl Database + Clone + Send + Sync, +) -> impl Filter + Clone { + warp::any().map(move || db.clone()) +} + +fn with_user( + postgres: Postgres, +) -> impl Filter + Clone { + warp::header::("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 + Clone> { + let postgres = Postgres::new(settings.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::()) + .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::()) + .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/atuin-server/src/settings.rs b/atuin-server/src/settings.rs new file mode 100644 index 00000000..596b9018 --- /dev/null +++ b/atuin-server/src/settings.rs @@ -0,0 +1,57 @@ +use std::fs::{create_dir_all, File}; +use std::io::prelude::*; +use std::path::PathBuf; + +use config::{Config, Environment, File as ConfigFile}; +use directories::ProjectDirs; +use eyre::{eyre, Result}; + +pub const HISTORY_PAGE_SIZE: i64 = 100; + +#[derive(Clone, Debug, Deserialize)] +pub struct Settings { + pub host: String, + pub port: u16, + pub db_uri: String, + pub open_registration: bool, +} + +impl Settings { + pub fn new() -> Result { + 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("server.toml"); + config_file + }; + + // create the config file if it does not exist + + let mut s = Config::new(); + + if config_file.exists() { + s.merge(ConfigFile::with_name(config_file.to_str().unwrap()))?; + } else { + let example_config = include_bytes!("../server.toml"); + let mut file = File::create(config_file)?; + file.write_all(example_config)?; + } + + s.set_default("host", "127.0.0.1")?; + s.set_default("port", 8888)?; + s.set_default("open_registration", false)?; + s.set_default("db_uri", "default_uri")?; + + s.merge(Environment::with_prefix("atuin").separator("_"))?; + + s.try_into() + .map_err(|e| eyre!("failed to deserialize: {}", e)) + } +} diff --git a/config.toml b/config.toml deleted file mode 100644 index fe776d6e..00000000 --- a/config.toml +++ /dev/null @@ -1,43 +0,0 @@ -# A'tuin example config - -# This section specifies the config for a local client, -# ie where your shell history is on your local machine -[local] -## where to store your database, default is your system data directory -## mac: ~/Library/Application Support/com.elliehuxtable.atuin/history.db -## linux: ~/.local/share/atuin/history.db -# db_path = "~/.history.db" - -## where to store your encryption key, default is your system data directory -# key_path = "~/.key" - -## where to store your auth session token, default is your system data directory -# session_path = "~/.key" - -## date format used, either "us" or "uk" -# dialect = "uk" - -## enable or disable automatic sync -# auto_sync = true - -## how often to sync history. note that this is only triggered when a command -## is ran, so sync intervals may well be longer -## set it to 0 to sync after every command -# sync_frequency = "5m" - -## address of the sync server -# sync_address = "https://api.atuin.sh" - -# This section configures the sync server, if you decide to host your own -[server] -## host to bind, can also be passed via CLI args -# host = "127.0.0.1" - -## port to bind, can also be passed via CLI args -# port = 8888 - -## whether to allow anyone to register an account -# open_registration = false - -## URI for postgres (using development creds here) -# db_uri="postgres://username:password@localhost/atuin" 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, - 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, - pub history_ts: chrono::DateTime, - pub host: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SyncHistoryResponse { - pub history: Vec, -} - -#[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::>()?; 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(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::>()?; 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 { - 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::().await?; - - Ok(count.count) - } - - pub async fn get_history( - &self, - sync_ts: chrono::DateTime, - history_ts: chrono::DateTime, - host: Option, - ) -> Result> { - 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::().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; - fn list(&self) -> Result>; - fn range(&self, from: chrono::DateTime, to: chrono::DateTime) - -> Result>; - - fn query(&self, query: &str, params: impl Params) -> Result>; - fn update(&self, h: &History) -> Result<()>; - fn history_count(&self) -> Result; - - fn first(&self) -> Result; - fn last(&self) -> Result; - fn before(&self, timestamp: chrono::DateTime, count: i64) -> Result>; - - fn prefix_search(&self, query: &str) -> Result>; -} - -// 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) -> Result { - 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 { - 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> { - 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, - to: chrono::DateTime, - ) -> Result> { - 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 { - 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 { - 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, count: i64) -> Result> { - 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> { - 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> { - self.query( - "select * from history where command like ?1 || '%' order by timestamp asc limit 1000", - &[query], - ) - } - - fn history_count(&self) -> Result { - 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, - row: &rusqlite::Row, -) -> Result { - 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, - pub nonce: secretbox::Nonce, -} - -// Loads the secret key, will create + save if it doesn't exist -pub fn load_key(settings: &Settings) -> Result { - 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 { - // 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 { - 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, - 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, - command: String, - cwd: String, - exit: i64, - duration: i64, - session: Option, - hostname: Option, - ) -> 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(&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, - - pub loc: u64, - pub counter: i64, -} - -// this could probably be sped up -fn count_lines(buf: &mut BufReader) -> Result { - let lines = buf.lines().count(); - buf.seek(SeekFrom::Start(0))?; - - Ok(lines) -} - -impl Zsh { - pub fn new(path: impl AsRef) -> Result { - 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::() - .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::().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> { - 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; - - fn next(&mut self) -> Option { - // 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::::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, - #[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 { - 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::() - .succeeded() - .expect("failed to load database"); - - let session = sessions::table - .filter(sessions::token.eq(session)) - .first::(&*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/")] -#[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 = 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 = "")] -#[allow(clippy::clippy::needless_pass_by_value)] -pub fn register( - conn: AtuinDbConn, - register: Json, - settings: State, -) -> 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 = "")] -#[allow(clippy::clippy::needless_pass_by_value)] -pub fn login(conn: AtuinDbConn, login: Json) -> 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; - async fn get_session_user(&self, token: &str) -> Result; - async fn add_session(&self, session: &NewSession) -> Result<()>; - - async fn get_user(&self, username: String) -> Result; - async fn get_user_session(&self, u: &User) -> Result; - async fn add_user(&self, user: NewUser) -> Result; - - async fn count_history(&self, user: &User) -> Result; - async fn list_history( - &self, - user: &User, - created_since: chrono::NaiveDateTime, - since: chrono::NaiveDateTime, - host: String, - ) -> Result>; - async fn add_history(&self, history: &[NewHistory]) -> Result<()>; -} - -#[derive(Clone)] -pub struct Postgres { - pool: sqlx::Pool, -} - -impl Postgres { - pub async fn new(uri: &str) -> Result { - 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 { - let res: Option = - 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 { - let res: Option = - 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 { - let res: Option = 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 { - 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> { - 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 { - 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 { - let res: Option = - 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, 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, 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 = 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, - user: User, - db: impl Database + Clone + Send + Sync, -) -> Result, Infallible> { - debug!("request to add {} history items", req.len()); - - let history: Vec = 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, 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, 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, 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::()?; - - 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 + Clone { - warp::any().map(move || settings.clone()) -} - -fn with_db( - db: impl Database + Clone + Send + Sync, -) -> impl Filter + Clone { - warp::any().map(move || db.clone()) -} - -fn with_user( - postgres: Postgres, -) -> impl Filter + Clone { - warp::header::("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 + 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::()) - .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::()) - .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> { - 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 { - 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 { - 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() -} -- cgit v1.3.1