mirror of
https://github.com/atuinsh/atuin.git
synced 2024-11-22 00:03:49 +01:00
* Add encryption * Add login and register command * Add count endpoint * Write initial sync push * Add single sync command Confirmed working for one client only * Automatically sync on a configurable frequency * Add key command, key arg to login * Only load session if it exists * Use sync and history timestamps for download * Bind other key code Seems like some systems have this code for up arrow? I'm not sure why, and it's not an easy one to google. * Simplify upload * Try and fix download sync loop * Change sync order to avoid uploading what we just downloaded * Multiline import fix * Fix time parsing * Fix importing history with no time * Add hostname to sync * Use hostname to filter sync * Fixes * Add binding * Stuff from yesterday * Set cursor modes * Make clippy happy * Bump version
This commit is contained in:
parent
a1fcf54f93
commit
5751463942
954
Cargo.lock
generated
954
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "atuin"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
authors = ["Ellie Huxtable <e@elm.sh>"]
|
||||
edition = "2018"
|
||||
license = "MIT"
|
||||
@ -9,20 +9,22 @@ description = "atuin - magical shell history"
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
fern = "0.6.0"
|
||||
chrono = "0.4"
|
||||
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"
|
||||
hostname = "0.3.1"
|
||||
whoami = "1.1.2"
|
||||
rocket = "0.4.7"
|
||||
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"
|
||||
@ -31,6 +33,12 @@ diesel = { version = "1.4.4", features = ["postgres", "chrono"] }
|
||||
diesel_migrations = "1.4.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"
|
||||
|
||||
[dependencies.rusqlite]
|
||||
version = "0.25"
|
||||
|
65
config.toml
65
config.toml
@ -3,36 +3,41 @@
|
||||
# This section specifies the config for a local client,
|
||||
# ie where your shell history is on your local machine
|
||||
[local]
|
||||
# (optional)
|
||||
# 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"
|
||||
# (optional, default us)
|
||||
# date format used, either "us" or "uk"
|
||||
dialect = "uk"
|
||||
# (optional, default false)
|
||||
# whether to enable sync of history. requires authentication
|
||||
sync = false
|
||||
# (optional, default 5m)
|
||||
# how often to sync history. note that this is only triggered when a command is ran, and the last sync was >= this value ago
|
||||
# set it to 0 to sync after every command
|
||||
sync_frequency = "5m"
|
||||
# (optional, default https://atuin.elliehuxtable.com)
|
||||
# address of the sync server
|
||||
sync_address = "https://atuin.elliehuxtable.com"
|
||||
## 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
|
||||
[remote]
|
||||
# (optional, default 127.0.0.1)
|
||||
# host to bind, can also be passed via CLI args
|
||||
host = "127.0.0.1"
|
||||
# (optional, default 8888)
|
||||
# port to bind, can also be passed via CLI args
|
||||
port = 8888
|
||||
# (optional, default false)
|
||||
# whether to allow anyone to register an account
|
||||
open_registration = false
|
||||
# (required)
|
||||
# URI for postgres (using development creds here)
|
||||
db_uri="postgres://username:password@localhost/atuin"
|
||||
## 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"
|
||||
|
@ -4,8 +4,10 @@ create table history (
|
||||
id bigserial primary key,
|
||||
client_id text not null unique, -- the client-generated ID
|
||||
user_id bigserial not null, -- allow multiple users
|
||||
mac varchar(128) not null, -- store a hashed mac address, to identify machines - more likely to be unique than hostname
|
||||
hostname text not null, -- a unique identifier from the client (can be hashed, random, whatever)
|
||||
timestamp timestamp not null, -- one of the few non-encrypted metadatas
|
||||
|
||||
data varchar(8192) not null -- store the actual history data, encrypted. I don't wanna know!
|
||||
data varchar(8192) not null, -- store the actual history data, encrypted. I don't wanna know!
|
||||
|
||||
created_at timestamp not null default current_timestamp
|
||||
);
|
||||
|
@ -1,6 +1,11 @@
|
||||
-- Your SQL goes here
|
||||
create table users (
|
||||
id bigserial primary key, -- also store our own ID
|
||||
username varchar(32) not null unique, -- being able to contact users is useful
|
||||
email varchar(128) not null unique, -- being able to contact users is useful
|
||||
password varchar(128) not null unique
|
||||
);
|
||||
|
||||
-- the prior index is case sensitive :(
|
||||
CREATE UNIQUE INDEX email_unique_idx on users (LOWER(email));
|
||||
CREATE UNIQUE INDEX username_unique_idx on users (LOWER(username));
|
||||
|
36
src/api.rs
Normal file
36
src/api.rs
Normal file
@ -0,0 +1,36 @@
|
||||
use chrono::Utc;
|
||||
|
||||
// This is shared between the client and the server, and has the data structures
|
||||
// representing the requests/responses for each method.
|
||||
// TODO: Properly define responses rather than using json!
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AddHistoryRequest {
|
||||
pub id: String,
|
||||
pub timestamp: chrono::DateTime<Utc>,
|
||||
pub data: String,
|
||||
pub hostname: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CountResponse {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ListHistoryResponse {
|
||||
pub history: Vec<String>,
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
use std::env;
|
||||
|
||||
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;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
pub enum Cmd {
|
||||
@ -50,21 +53,13 @@ fn print_list(h: &[History]) {
|
||||
}
|
||||
|
||||
impl Cmd {
|
||||
pub fn run(&self, db: &mut impl Database) -> Result<()> {
|
||||
pub fn run(&self, settings: &Settings, db: &mut impl Database) -> Result<()> {
|
||||
match self {
|
||||
Self::Start { command: words } => {
|
||||
let command = words.join(" ");
|
||||
let cwd = env::current_dir()?.display().to_string();
|
||||
|
||||
let h = History::new(
|
||||
chrono::Utc::now().timestamp_nanos(),
|
||||
command,
|
||||
cwd,
|
||||
-1,
|
||||
-1,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let h = History::new(chrono::Utc::now(), command, cwd, -1, -1, None, None);
|
||||
|
||||
// print the ID
|
||||
// we use this as the key for calling end
|
||||
@ -76,10 +71,23 @@ impl Cmd {
|
||||
Self::End { id, exit } => {
|
||||
let mut h = db.load(id)?;
|
||||
h.exit = *exit;
|
||||
h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp;
|
||||
h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos();
|
||||
|
||||
db.update(&h)?;
|
||||
|
||||
if settings.local.should_sync()? {
|
||||
match fork() {
|
||||
Ok(Fork::Parent(child)) => {
|
||||
debug!("launched sync background process with PID {}", child);
|
||||
}
|
||||
Ok(Fork::Child) => {
|
||||
debug!("running periodic background sync");
|
||||
sync::sync(settings, false, db)?;
|
||||
}
|
||||
Err(_) => println!("Fork failed"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
48
src/command/login.rs
Normal file
48
src/command/login.rs
Normal file
@ -0,0 +1,48 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::prelude::*;
|
||||
|
||||
use eyre::Result;
|
||||
use structopt::StructOpt;
|
||||
|
||||
use crate::settings::Settings;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
#[structopt(setting(structopt::clap::AppSettings::DeriveDisplayOrder))]
|
||||
pub struct Cmd {
|
||||
#[structopt(long, short)]
|
||||
pub username: String,
|
||||
|
||||
#[structopt(long, short)]
|
||||
pub password: String,
|
||||
|
||||
#[structopt(long, short, about = "the encryption key for your account")]
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl Cmd {
|
||||
pub fn run(&self, settings: &Settings) -> Result<()> {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("username", self.username.clone());
|
||||
map.insert("password", self.password.clone());
|
||||
|
||||
let url = format!("{}/login", settings.local.sync_address);
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client.post(url).json(&map).send()?;
|
||||
|
||||
let session = resp.json::<HashMap<String, String>>()?;
|
||||
let session = session["session"].clone();
|
||||
|
||||
let session_path = settings.local.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 mut file = File::create(key_path)?;
|
||||
file.write_all(&base64::decode(self.key.clone())?)?;
|
||||
|
||||
println!("Logged in!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -9,9 +9,12 @@ mod event;
|
||||
mod history;
|
||||
mod import;
|
||||
mod init;
|
||||
mod login;
|
||||
mod register;
|
||||
mod search;
|
||||
mod server;
|
||||
mod stats;
|
||||
mod sync;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
pub enum AtuinCmd {
|
||||
@ -38,6 +41,21 @@ pub enum AtuinCmd {
|
||||
|
||||
#[structopt(about = "interactive history search")]
|
||||
Search { query: Vec<String> },
|
||||
|
||||
#[structopt(about = "sync with the configured server")]
|
||||
Sync {
|
||||
#[structopt(long, short, about = "force re-download everything")]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
#[structopt(about = "login to the configured server")]
|
||||
Login(login::Cmd),
|
||||
|
||||
#[structopt(about = "register with the configured server")]
|
||||
Register(register::Cmd),
|
||||
|
||||
#[structopt(about = "print the encryption key for transfer to another machine")]
|
||||
Key,
|
||||
}
|
||||
|
||||
pub fn uuid_v4() -> String {
|
||||
@ -47,13 +65,27 @@ pub fn uuid_v4() -> String {
|
||||
impl AtuinCmd {
|
||||
pub fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> {
|
||||
match self {
|
||||
Self::History(history) => history.run(db),
|
||||
Self::History(history) => history.run(settings, db),
|
||||
Self::Import(import) => import.run(db),
|
||||
Self::Server(server) => server.run(settings),
|
||||
Self::Stats(stats) => stats.run(db, settings),
|
||||
Self::Init => init::init(),
|
||||
Self::Search { query } => search::run(&query, db),
|
||||
|
||||
Self::Sync { force } => sync::run(settings, force, db),
|
||||
Self::Login(l) => l.run(settings),
|
||||
Self::Register(r) => register::run(
|
||||
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())?;
|
||||
println!("{}", base64::encode(key));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Self::Uuid => {
|
||||
println!("{}", uuid_v4());
|
||||
Ok(())
|
||||
|
54
src/command/register.rs
Normal file
54
src/command/register.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::prelude::*;
|
||||
|
||||
use eyre::{eyre, Result};
|
||||
use structopt::StructOpt;
|
||||
|
||||
use crate::settings::Settings;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
#[structopt(setting(structopt::clap::AppSettings::DeriveDisplayOrder))]
|
||||
pub struct Cmd {
|
||||
#[structopt(long, short)]
|
||||
pub username: String,
|
||||
|
||||
#[structopt(long, short)]
|
||||
pub email: String,
|
||||
|
||||
#[structopt(long, short)]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub fn run(settings: &Settings, username: &str, email: &str, password: &str) -> Result<()> {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("username", username);
|
||||
map.insert("email", email);
|
||||
map.insert("password", password);
|
||||
|
||||
let url = format!("{}/user/{}", settings.local.sync_address, username);
|
||||
let resp = reqwest::blocking::get(url)?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
println!("Username is already in use! Please try another.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url = format!("{}/register", settings.local.sync_address);
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client.post(url).json(&map).send()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
println!("Failed to register user - please check your details and try again");
|
||||
return Err(eyre!("failed to register user"));
|
||||
}
|
||||
|
||||
let session = resp.json::<HashMap<String, String>>()?;
|
||||
let session = session["session"].clone();
|
||||
|
||||
let path = settings.local.session_path.as_str();
|
||||
let mut file = File::create(path)?;
|
||||
file.write_all(session.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
@ -171,7 +171,8 @@ fn select_history(query: &[String], db: &mut impl Database) -> Result<String> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let mut content = Span::raw(m.command.to_string());
|
||||
let mut content =
|
||||
Span::raw(m.command.to_string().replace("\n", " ").replace("\t", " "));
|
||||
|
||||
if let Some(selected) = app.results_state.selected() {
|
||||
if selected == i {
|
||||
|
@ -24,10 +24,10 @@ impl Cmd {
|
||||
match self {
|
||||
Self::Start { host, port } => {
|
||||
let host = host.as_ref().map_or(
|
||||
settings.remote.host.clone(),
|
||||
settings.server.host.clone(),
|
||||
std::string::ToString::to_string,
|
||||
);
|
||||
let port = port.map_or(settings.remote.port, |p| p);
|
||||
let port = port.map_or(settings.server.port, |p| p);
|
||||
|
||||
server::launch(settings, host, port);
|
||||
}
|
||||
|
15
src/command/sync.rs
Normal file
15
src/command/sync.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use eyre::Result;
|
||||
|
||||
use crate::local::database::Database;
|
||||
use crate::local::sync;
|
||||
use crate::settings::Settings;
|
||||
|
||||
pub fn run(settings: &Settings, force: bool, db: &mut impl Database) -> Result<()> {
|
||||
sync::sync(settings, force, db)?;
|
||||
println!(
|
||||
"Sync complete! {} items in database, force: {}",
|
||||
db.history_count()?,
|
||||
force
|
||||
);
|
||||
Ok(())
|
||||
}
|
94
src/local/api_client.rs
Normal file
94
src/local/api_client.rs
Normal file
@ -0,0 +1,94 @@
|
||||
use chrono::Utc;
|
||||
use eyre::Result;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
|
||||
use crate::api::{AddHistoryRequest, CountResponse, ListHistoryResponse};
|
||||
use crate::local::encryption::{decrypt, load_key};
|
||||
use crate::local::history::History;
|
||||
use crate::settings::Settings;
|
||||
use crate::utils::hash_str;
|
||||
|
||||
pub struct Client<'a> {
|
||||
settings: &'a Settings,
|
||||
}
|
||||
|
||||
impl<'a> Client<'a> {
|
||||
pub const fn new(settings: &'a Settings) -> Self {
|
||||
Client { settings }
|
||||
}
|
||||
|
||||
pub fn count(&self) -> Result<i64> {
|
||||
let url = format!("{}/sync/count", self.settings.local.sync_address);
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header(
|
||||
AUTHORIZATION,
|
||||
format!("Token {}", self.settings.local.session_token),
|
||||
)
|
||||
.send()?;
|
||||
|
||||
let count = resp.json::<CountResponse>()?;
|
||||
|
||||
Ok(count.count)
|
||||
}
|
||||
|
||||
pub fn get_history(
|
||||
&self,
|
||||
sync_ts: chrono::DateTime<Utc>,
|
||||
history_ts: chrono::DateTime<Utc>,
|
||||
host: Option<String>,
|
||||
) -> Result<Vec<History>> {
|
||||
let key = load_key(self.settings)?;
|
||||
|
||||
let host = match host {
|
||||
None => hash_str(&format!("{}:{}", whoami::hostname(), whoami::username())),
|
||||
Some(h) => h,
|
||||
};
|
||||
|
||||
// this allows for syncing between users on the same machine
|
||||
let url = format!(
|
||||
"{}/sync/history?sync_ts={}&history_ts={}&host={}",
|
||||
self.settings.local.sync_address,
|
||||
sync_ts.to_rfc3339(),
|
||||
history_ts.to_rfc3339(),
|
||||
host,
|
||||
);
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header(
|
||||
AUTHORIZATION,
|
||||
format!("Token {}", self.settings.local.session_token),
|
||||
)
|
||||
.send()?;
|
||||
|
||||
let history = resp.json::<ListHistoryResponse>()?;
|
||||
let history = history
|
||||
.history
|
||||
.iter()
|
||||
.map(|h| serde_json::from_str(h).expect("invalid base64"))
|
||||
.map(|h| decrypt(&h, &key).expect("failed to decrypt history! check your key"))
|
||||
.collect();
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
pub fn post_history(&self, history: &[AddHistoryRequest]) -> Result<()> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
let url = format!("{}/history", self.settings.local.sync_address);
|
||||
client
|
||||
.post(url)
|
||||
.json(history)
|
||||
.header(
|
||||
AUTHORIZATION,
|
||||
format!("Token {}", self.settings.local.session_token),
|
||||
)
|
||||
.send()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
use chrono::prelude::*;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
|
||||
@ -21,6 +22,10 @@ pub trait Database {
|
||||
fn update(&self, h: &History) -> Result<()>;
|
||||
fn history_count(&self) -> Result<i64>;
|
||||
|
||||
fn first(&self) -> Result<History>;
|
||||
fn last(&self) -> Result<History>;
|
||||
fn before(&self, timestamp: chrono::DateTime<Utc>, count: i64) -> Result<Vec<History>>;
|
||||
|
||||
fn prefix_search(&self, query: &str) -> Result<Vec<History>>;
|
||||
}
|
||||
|
||||
@ -44,9 +49,7 @@ impl Sqlite {
|
||||
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
if create {
|
||||
Self::setup_db(&conn)?;
|
||||
}
|
||||
Self::setup_db(&conn)?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@ -70,6 +73,14 @@ impl Sqlite {
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"create table if not exists history_encrypted (
|
||||
id text primary key,
|
||||
data blob not null
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -87,7 +98,7 @@ impl Sqlite {
|
||||
) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
h.id,
|
||||
h.timestamp,
|
||||
h.timestamp.timestamp_nanos(),
|
||||
h.duration,
|
||||
h.exit,
|
||||
h.command,
|
||||
@ -146,7 +157,7 @@ impl Database for Sqlite {
|
||||
"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, h.duration, h.exit, h.command, h.cwd, h.session, h.hostname],
|
||||
params![h.id, h.timestamp.timestamp_nanos(), h.duration, h.exit, h.command, h.cwd, h.session, h.hostname],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
@ -183,6 +194,38 @@ impl Database for Sqlite {
|
||||
Ok(history_iter.filter_map(Result::ok).collect())
|
||||
}
|
||||
|
||||
fn first(&self) -> Result<History> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT * FROM history order by timestamp asc limit 1")?;
|
||||
|
||||
let history = stmt.query_row(params![], |row| history_from_sqlite_row(None, row))?;
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
fn last(&self) -> Result<History> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT * FROM history order by timestamp desc limit 1")?;
|
||||
|
||||
let history = stmt.query_row(params![], |row| history_from_sqlite_row(None, row))?;
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
fn before(&self, timestamp: chrono::DateTime<Utc>, count: i64) -> Result<Vec<History>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT * FROM history where timestamp <= ? order by timestamp desc limit ?",
|
||||
)?;
|
||||
|
||||
let history_iter = stmt.query_map(params![timestamp.timestamp_nanos(), count], |row| {
|
||||
history_from_sqlite_row(None, row)
|
||||
})?;
|
||||
|
||||
Ok(history_iter.filter_map(Result::ok).collect())
|
||||
}
|
||||
|
||||
fn query(&self, query: &str, params: impl Params) -> Result<Vec<History>> {
|
||||
let mut stmt = self.conn.prepare(query)?;
|
||||
|
||||
@ -218,7 +261,7 @@ fn history_from_sqlite_row(
|
||||
|
||||
Ok(History {
|
||||
id,
|
||||
timestamp: row.get(1)?,
|
||||
timestamp: Utc.timestamp_nanos(row.get(1)?),
|
||||
duration: row.get(2)?,
|
||||
exit: row.get(3)?,
|
||||
command: row.get(4)?,
|
||||
|
108
src/local/encryption.rs
Normal file
108
src/local/encryption.rs
Normal file
@ -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::local::history::History;
|
||||
use crate::settings::Settings;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EncryptedHistory {
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub nonce: secretbox::Nonce,
|
||||
}
|
||||
|
||||
// Loads the secret key, will create + save if it doesn't exist
|
||||
pub fn load_key(settings: &Settings) -> Result<secretbox::Key> {
|
||||
let path = settings.local.key_path.as_str();
|
||||
|
||||
if PathBuf::from(path).exists() {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let key: secretbox::Key = rmp_serde::from_read_ref(&bytes)?;
|
||||
Ok(key)
|
||||
} else {
|
||||
let key = secretbox::gen_key();
|
||||
let buf = rmp_serde::to_vec(&key)?;
|
||||
|
||||
let mut file = File::create(path)?;
|
||||
file.write_all(&buf)?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt(history: &History, key: &secretbox::Key) -> Result<EncryptedHistory> {
|
||||
// serialize with msgpack
|
||||
let buf = rmp_serde::to_vec(history)?;
|
||||
|
||||
let nonce = secretbox::gen_nonce();
|
||||
|
||||
let ciphertext = secretbox::seal(&buf, &nonce, key);
|
||||
|
||||
Ok(EncryptedHistory { ciphertext, nonce })
|
||||
}
|
||||
|
||||
pub fn decrypt(encrypted_history: &EncryptedHistory, key: &secretbox::Key) -> Result<History> {
|
||||
let plaintext = secretbox::open(&encrypted_history.ciphertext, &encrypted_history.nonce, key)
|
||||
.map_err(|_| eyre!("failed to open secretbox - invalid key?"))?;
|
||||
|
||||
let history = rmp_serde::from_read_ref(&plaintext)?;
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
|
||||
use crate::local::history::History;
|
||||
|
||||
use super::{decrypt, encrypt};
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() {
|
||||
let key1 = secretbox::gen_key();
|
||||
let key2 = secretbox::gen_key();
|
||||
|
||||
let history = History::new(
|
||||
chrono::Utc::now(),
|
||||
"ls".to_string(),
|
||||
"/home/ellie".to_string(),
|
||||
0,
|
||||
1,
|
||||
Some("beep boop".to_string()),
|
||||
Some("booop".to_string()),
|
||||
);
|
||||
|
||||
let e1 = encrypt(&history, &key1).unwrap();
|
||||
let e2 = encrypt(&history, &key2).unwrap();
|
||||
|
||||
assert_ne!(e1.ciphertext, e2.ciphertext);
|
||||
assert_ne!(e1.nonce, e2.nonce);
|
||||
|
||||
// test decryption works
|
||||
// this should pass
|
||||
match decrypt(&e1, &key1) {
|
||||
Err(e) => assert!(false, "failed to decrypt, got {}", e),
|
||||
Ok(h) => assert_eq!(h, history),
|
||||
};
|
||||
|
||||
// this should err
|
||||
match decrypt(&e2, &key1) {
|
||||
Ok(_) => assert!(false, "expected an error decrypting with invalid key"),
|
||||
Err(_) => {}
|
||||
};
|
||||
}
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
use std::env;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::command::uuid_v4;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
// Any new fields MUST be Optional<>!
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct History {
|
||||
pub id: String,
|
||||
pub timestamp: i64,
|
||||
pub timestamp: chrono::DateTime<Utc>,
|
||||
pub duration: i64,
|
||||
pub exit: i64,
|
||||
pub command: String,
|
||||
@ -17,7 +20,7 @@ pub struct History {
|
||||
|
||||
impl History {
|
||||
pub fn new(
|
||||
timestamp: i64,
|
||||
timestamp: chrono::DateTime<Utc>,
|
||||
command: String,
|
||||
cwd: String,
|
||||
exit: i64,
|
||||
@ -29,7 +32,7 @@ impl History {
|
||||
.or_else(|| env::var("ATUIN_SESSION").ok())
|
||||
.unwrap_or_else(uuid_v4);
|
||||
let hostname =
|
||||
hostname.unwrap_or_else(|| hostname::get().unwrap().to_str().unwrap().to_string());
|
||||
hostname.unwrap_or_else(|| format!("{}:{}", whoami::hostname(), whoami::username()));
|
||||
|
||||
Self {
|
||||
id: uuid_v4(),
|
||||
|
@ -4,7 +4,9 @@
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::{fs::File, path::Path};
|
||||
|
||||
use eyre::{Result, WrapErr};
|
||||
use chrono::prelude::*;
|
||||
use chrono::Utc;
|
||||
use eyre::{eyre, Result};
|
||||
|
||||
use super::history::History;
|
||||
|
||||
@ -13,6 +15,7 @@ pub struct Zsh {
|
||||
file: BufReader<File>,
|
||||
|
||||
pub loc: u64,
|
||||
pub counter: i64,
|
||||
}
|
||||
|
||||
// this could probably be sped up
|
||||
@ -32,19 +35,23 @@ impl Zsh {
|
||||
Ok(Self {
|
||||
file: buf,
|
||||
loc: loc as u64,
|
||||
counter: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_extended(line: &str) -> History {
|
||||
fn parse_extended(line: &str, counter: i64) -> History {
|
||||
let line = line.replacen(": ", "", 2);
|
||||
let (time, duration) = line.split_once(':').unwrap();
|
||||
let (duration, command) = duration.split_once(';').unwrap();
|
||||
|
||||
let time = time.parse::<i64>().map_or_else(
|
||||
|_| chrono::Utc::now().timestamp_nanos(),
|
||||
|t| t * 1_000_000_000,
|
||||
);
|
||||
let time = time
|
||||
.parse::<i64>()
|
||||
.unwrap_or_else(|_| chrono::Utc::now().timestamp());
|
||||
|
||||
let offset = chrono::Duration::milliseconds(counter);
|
||||
let time = Utc.timestamp(time, 0);
|
||||
let time = time + offset;
|
||||
|
||||
let duration = duration.parse::<i64>().map_or(-1, |t| t * 1_000_000_000);
|
||||
|
||||
@ -60,6 +67,18 @@ fn parse_extended(line: &str) -> History {
|
||||
)
|
||||
}
|
||||
|
||||
impl Zsh {
|
||||
fn read_line(&mut self) -> Option<Result<String>> {
|
||||
let mut line = String::new();
|
||||
|
||||
match self.file.read_line(&mut line) {
|
||||
Ok(0) => None,
|
||||
Ok(_) => Some(Ok(line)),
|
||||
Err(e) => Some(Err(eyre!("failed to read line: {}", e))), // we can skip past things like invalid utf8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Zsh {
|
||||
type Item = Result<History>;
|
||||
|
||||
@ -68,54 +87,89 @@ impl Iterator for Zsh {
|
||||
// These lines begin with :
|
||||
// So, if the line begins with :, parse it. Otherwise it's just
|
||||
// the command
|
||||
let mut line = String::new();
|
||||
let line = self.read_line()?;
|
||||
|
||||
match self.file.read_line(&mut line) {
|
||||
Ok(0) => None,
|
||||
Ok(_) => {
|
||||
let extended = line.starts_with(':');
|
||||
if let Err(e) = line {
|
||||
return Some(Err(e)); // :(
|
||||
}
|
||||
|
||||
if extended {
|
||||
Some(Ok(parse_extended(line.as_str())))
|
||||
} else {
|
||||
Some(Ok(History::new(
|
||||
chrono::Utc::now().timestamp_nanos(), // what else? :/
|
||||
line.trim_end().to_string(),
|
||||
String::from("unknown"),
|
||||
-1,
|
||||
-1,
|
||||
None,
|
||||
None,
|
||||
)))
|
||||
}
|
||||
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;
|
||||
}
|
||||
Err(e) => Some(Err(e).wrap_err("failed to parse line")),
|
||||
|
||||
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");
|
||||
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, 1_613_322_469_000_000_000);
|
||||
assert_eq!(parsed.timestamp, Utc.timestamp(1_613_322_469, 0));
|
||||
|
||||
let parsed = parse_extended(": 1613322469:10;cargo install atuin;cargo update");
|
||||
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, 1_613_322_469_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̷");
|
||||
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, 1_613_322_469_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));
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
pub mod api_client;
|
||||
pub mod database;
|
||||
pub mod encryption;
|
||||
pub mod history;
|
||||
pub mod import;
|
||||
pub mod sync;
|
||||
|
135
src/local/sync.rs
Normal file
135
src/local/sync.rs
Normal file
@ -0,0 +1,135 @@
|
||||
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)
|
||||
fn sync_download(
|
||||
force: bool,
|
||||
client: &api_client::Client,
|
||||
db: &mut impl Database,
|
||||
) -> Result<(i64, i64)> {
|
||||
let remote_count = client.count()?;
|
||||
|
||||
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())?;
|
||||
|
||||
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
|
||||
fn sync_upload(
|
||||
settings: &Settings,
|
||||
_force: bool,
|
||||
client: &api_client::Client,
|
||||
db: &mut impl Database,
|
||||
) -> Result<()> {
|
||||
let initial_remote_count = client.count()?;
|
||||
let mut remote_count = initial_remote_count;
|
||||
|
||||
let local_count = db.history_count()?;
|
||||
|
||||
let key = load_key(settings)?; // encryption key
|
||||
|
||||
// first just try the most recent set
|
||||
|
||||
let mut cursor = Utc::now();
|
||||
|
||||
while local_count > remote_count {
|
||||
let last = db.before(cursor, HISTORY_PAGE_SIZE)?;
|
||||
let mut buffer = Vec::<AddHistoryRequest>::new();
|
||||
|
||||
if last.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for i in last {
|
||||
let data = encrypt(&i, &key)?;
|
||||
let data = serde_json::to_string(&data)?;
|
||||
|
||||
let add_hist = AddHistoryRequest {
|
||||
id: i.id,
|
||||
timestamp: i.timestamp,
|
||||
data,
|
||||
hostname: hash_str(i.hostname.as_str()),
|
||||
};
|
||||
|
||||
buffer.push(add_hist);
|
||||
}
|
||||
|
||||
// anything left over outside of the 100 block size
|
||||
client.post_history(&buffer)?;
|
||||
cursor = buffer.last().unwrap().timestamp;
|
||||
|
||||
remote_count = client.count()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sync(settings: &Settings, force: bool, db: &mut impl Database) -> Result<()> {
|
||||
let client = api_client::Client::new(settings);
|
||||
|
||||
sync_upload(settings, force, &client, db)?;
|
||||
|
||||
let download = sync_download(force, &client, db)?;
|
||||
|
||||
debug!("sync downloaded {}", download.0);
|
||||
|
||||
Local::save_sync_time()?;
|
||||
|
||||
Ok(())
|
||||
}
|
19
src/main.rs
19
src/main.rs
@ -6,7 +6,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use eyre::{eyre, Result};
|
||||
use structopt::StructOpt;
|
||||
use structopt::{clap::AppSettings, StructOpt};
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
@ -30,18 +30,21 @@ use command::AtuinCmd;
|
||||
use local::database::Sqlite;
|
||||
use settings::Settings;
|
||||
|
||||
mod api;
|
||||
mod command;
|
||||
mod local;
|
||||
mod remote;
|
||||
mod settings;
|
||||
mod utils;
|
||||
|
||||
pub mod schema;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
#[structopt(
|
||||
author = "Ellie Huxtable <e@elm.sh>",
|
||||
version = "0.4.0",
|
||||
about = "Magical shell history"
|
||||
version = "0.5.0",
|
||||
about = "Magical shell history",
|
||||
global_settings(&[AppSettings::ColoredHelp, AppSettings::DeriveDisplayOrder])
|
||||
)]
|
||||
struct Atuin {
|
||||
#[structopt(long, parse(from_os_str), help = "db file path")]
|
||||
@ -52,9 +55,7 @@ struct Atuin {
|
||||
}
|
||||
|
||||
impl Atuin {
|
||||
fn run(self) -> Result<()> {
|
||||
let settings = Settings::new()?;
|
||||
|
||||
fn run(self, settings: &Settings) -> Result<()> {
|
||||
let db_path = if let Some(db_path) = self.db {
|
||||
let path = db_path
|
||||
.to_str()
|
||||
@ -67,11 +68,13 @@ impl Atuin {
|
||||
|
||||
let mut db = Sqlite::new(db_path)?;
|
||||
|
||||
self.atuin.run(&mut db, &settings)
|
||||
self.atuin.run(&mut db, settings)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let settings = Settings::new()?;
|
||||
|
||||
fern::Dispatch::new()
|
||||
.format(|out, message, record| {
|
||||
out.finish(format_args!(
|
||||
@ -85,5 +88,5 @@ fn main() -> Result<()> {
|
||||
.chain(std::io::stdout())
|
||||
.apply()?;
|
||||
|
||||
Atuin::from_args().run()
|
||||
Atuin::from_args().run(&settings)
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
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;
|
||||
|
||||
@ -9,7 +11,11 @@ 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;
|
||||
|
||||
@ -19,20 +25,6 @@ pub enum KeyError {
|
||||
Invalid,
|
||||
}
|
||||
|
||||
pub fn hash_str(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 verify_str(secret: &str, verify: &str) -> bool {
|
||||
sodiumoxide::init().unwrap();
|
||||
|
||||
@ -95,19 +87,54 @@ impl<'a, 'r> FromRequest<'a, 'r> for User {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Register {
|
||||
email: String,
|
||||
password: String,
|
||||
#[get("/user/<user>")]
|
||||
#[allow(clippy::clippy::needless_pass_by_value)]
|
||||
pub fn get_user(user: String, conn: AtuinDbConn) -> ApiResponse {
|
||||
use crate::schema::users::dsl::{username, users};
|
||||
|
||||
let user: Result<String, diesel::result::Error> = users
|
||||
.select(username)
|
||||
.filter(username.eq(user))
|
||||
.first(&*conn);
|
||||
|
||||
if user.is_err() {
|
||||
return ApiResponse {
|
||||
json: json!({
|
||||
"message": "could not find user",
|
||||
}),
|
||||
status: Status::NotFound,
|
||||
};
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
|
||||
ApiResponse {
|
||||
json: json!({ "username": user.as_str() }),
|
||||
status: Status::Ok,
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/register", data = "<register>")]
|
||||
#[allow(clippy::clippy::needless_pass_by_value)]
|
||||
pub fn register(conn: AtuinDbConn, register: Json<Register>) -> ApiResponse {
|
||||
let hashed = hash_str(register.password.as_str());
|
||||
pub fn register(
|
||||
conn: AtuinDbConn,
|
||||
register: Json<RegisterRequest>,
|
||||
settings: State<Settings>,
|
||||
) -> ApiResponse {
|
||||
if !settings.server.open_registration {
|
||||
return ApiResponse {
|
||||
status: Status::BadRequest,
|
||||
json: json!({
|
||||
"message": "registrations are not open"
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let hashed = hash_secret(register.password.as_str());
|
||||
|
||||
let new_user = NewUser {
|
||||
email: register.email.as_str(),
|
||||
username: register.username.as_str(),
|
||||
password: hashed.as_str(),
|
||||
};
|
||||
|
||||
@ -119,8 +146,7 @@ pub fn register(conn: AtuinDbConn, register: Json<Register>) -> ApiResponse {
|
||||
return ApiResponse {
|
||||
status: Status::BadRequest,
|
||||
json: json!({
|
||||
"status": "error",
|
||||
"message": "failed to create user - is the email already in use?",
|
||||
"message": "failed to create user - username or email in use?",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -139,32 +165,26 @@ pub fn register(conn: AtuinDbConn, register: Json<Register>) -> ApiResponse {
|
||||
{
|
||||
Ok(_) => ApiResponse {
|
||||
status: Status::Ok,
|
||||
json: json!({"status": "ok", "message": "user created!", "session": token}),
|
||||
json: json!({"message": "user created!", "session": token}),
|
||||
},
|
||||
Err(_) => ApiResponse {
|
||||
status: Status::BadRequest,
|
||||
json: json!({"status": "error", "message": "failed to create user"}),
|
||||
json: json!({ "message": "failed to create user"}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Login {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[post("/login", data = "<login>")]
|
||||
#[allow(clippy::clippy::needless_pass_by_value)]
|
||||
pub fn login(conn: AtuinDbConn, login: Json<Login>) -> ApiResponse {
|
||||
pub fn login(conn: AtuinDbConn, login: Json<LoginRequest>) -> ApiResponse {
|
||||
let user = users::table
|
||||
.filter(users::email.eq(login.email.as_str()))
|
||||
.filter(users::username.eq(login.username.as_str()))
|
||||
.first(&*conn);
|
||||
|
||||
if user.is_err() {
|
||||
return ApiResponse {
|
||||
status: Status::NotFound,
|
||||
json: json!({"status": "error", "message": "user not found"}),
|
||||
json: json!({"message": "user not found"}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -178,7 +198,7 @@ pub fn login(conn: AtuinDbConn, login: Json<Login>) -> ApiResponse {
|
||||
if session.is_err() {
|
||||
return ApiResponse {
|
||||
status: Status::InternalServerError,
|
||||
json: json!({"status": "error", "message": "something went wrong"}),
|
||||
json: json!({"message": "something went wrong"}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -187,7 +207,7 @@ pub fn login(conn: AtuinDbConn, login: Json<Login>) -> ApiResponse {
|
||||
if !verified {
|
||||
return ApiResponse {
|
||||
status: Status::NotFound,
|
||||
json: json!({"status": "error", "message": "user not found"}),
|
||||
json: json!({"message": "user not found"}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -195,6 +215,6 @@ pub fn login(conn: AtuinDbConn, login: Json<Login>) -> ApiResponse {
|
||||
|
||||
ApiResponse {
|
||||
status: Status::Ok,
|
||||
json: json!({"status": "ok", "token": session.token}),
|
||||
json: json!({"session": session.token}),
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ pub struct AtuinDbConn(diesel::PgConnection);
|
||||
|
||||
// TODO: connection pooling
|
||||
pub fn establish_connection(settings: &Settings) -> PgConnection {
|
||||
let database_url = &settings.remote.db_uri;
|
||||
let database_url = &settings.server.db_uri;
|
||||
PgConnection::establish(database_url)
|
||||
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
|
||||
}
|
||||
|
@ -1,23 +1,26 @@
|
||||
use chrono::naive::NaiveDateTime;
|
||||
use chrono::prelude::*;
|
||||
|
||||
use crate::schema::{history, sessions, users};
|
||||
|
||||
#[derive(Identifiable, Queryable, Associations)]
|
||||
#[derive(Deserialize, Serialize, Identifiable, Queryable, Associations)]
|
||||
#[table_name = "history"]
|
||||
#[belongs_to(User)]
|
||||
pub struct History {
|
||||
pub id: i64,
|
||||
pub client_id: String,
|
||||
pub client_id: String, // a client generated ID
|
||||
pub user_id: i64,
|
||||
pub mac: String,
|
||||
pub hostname: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
|
||||
pub data: String,
|
||||
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Associations)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
@ -35,8 +38,8 @@ pub struct Session {
|
||||
pub struct NewHistory<'a> {
|
||||
pub client_id: &'a str,
|
||||
pub user_id: i64,
|
||||
pub mac: &'a str,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub hostname: String,
|
||||
pub timestamp: chrono::NaiveDateTime,
|
||||
|
||||
pub data: &'a str,
|
||||
}
|
||||
@ -44,6 +47,7 @@ pub struct NewHistory<'a> {
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "users"]
|
||||
pub struct NewUser<'a> {
|
||||
pub username: &'a str,
|
||||
pub email: &'a str,
|
||||
pub password: &'a str,
|
||||
}
|
||||
|
@ -17,13 +17,15 @@ use super::auth::*;
|
||||
embed_migrations!("migrations");
|
||||
|
||||
pub fn launch(settings: &Settings, host: String, port: u16) {
|
||||
let settings: Settings = settings.clone(); // clone so rocket can manage it
|
||||
|
||||
let mut database_config = HashMap::new();
|
||||
let mut databases = HashMap::new();
|
||||
|
||||
database_config.insert("url", Value::from(settings.remote.db_uri.clone()));
|
||||
database_config.insert("url", Value::from(settings.server.db_uri.clone()));
|
||||
databases.insert("atuin", Value::from(database_config));
|
||||
|
||||
let connection = establish_connection(settings);
|
||||
let connection = establish_connection(&settings);
|
||||
embedded_migrations::run(&connection).expect("failed to run migrations");
|
||||
|
||||
let config = Config::build(Environment::Production)
|
||||
@ -36,8 +38,20 @@ pub fn launch(settings: &Settings, host: String, port: u16) {
|
||||
|
||||
let app = rocket::custom(config);
|
||||
|
||||
app.mount("/", routes![index, register, add_history, login])
|
||||
.attach(AtuinDbConn::fairing())
|
||||
.register(catchers![internal_error, bad_request])
|
||||
.launch();
|
||||
app.mount(
|
||||
"/",
|
||||
routes![
|
||||
index,
|
||||
register,
|
||||
add_history,
|
||||
login,
|
||||
get_user,
|
||||
sync_count,
|
||||
sync_list
|
||||
],
|
||||
)
|
||||
.manage(settings)
|
||||
.attach(AtuinDbConn::fairing())
|
||||
.register(catchers![internal_error, bad_request])
|
||||
.launch();
|
||||
}
|
||||
|
@ -1,14 +1,22 @@
|
||||
use self::diesel::prelude::*;
|
||||
use chrono::Utc;
|
||||
use rocket::http::uri::Uri;
|
||||
use rocket::http::RawStr;
|
||||
use rocket::http::{ContentType, Status};
|
||||
use rocket::request::FromFormValue;
|
||||
use rocket::request::Request;
|
||||
use rocket::response;
|
||||
use rocket::response::{Responder, Response};
|
||||
use rocket_contrib::databases::diesel;
|
||||
use rocket_contrib::json::{Json, JsonValue};
|
||||
|
||||
use super::database::AtuinDbConn;
|
||||
use super::models::{NewHistory, User};
|
||||
use self::diesel::prelude::*;
|
||||
|
||||
use crate::api::AddHistoryRequest;
|
||||
use crate::schema::history;
|
||||
use crate::settings::HISTORY_PAGE_SIZE;
|
||||
|
||||
use super::database::AtuinDbConn;
|
||||
use super::models::{History, NewHistory, User};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiResponse {
|
||||
@ -46,40 +54,36 @@ pub fn bad_request(_req: &Request) -> ApiResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddHistory {
|
||||
id: String,
|
||||
timestamp: i64,
|
||||
data: String,
|
||||
mac: String,
|
||||
}
|
||||
|
||||
#[post("/history", data = "<add_history>")]
|
||||
#[allow(
|
||||
clippy::clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::clippy::needless_pass_by_value
|
||||
)]
|
||||
pub fn add_history(conn: AtuinDbConn, user: User, add_history: Json<AddHistory>) -> ApiResponse {
|
||||
let secs: i64 = add_history.timestamp / 1_000_000_000;
|
||||
let nanosecs: u32 = (add_history.timestamp - (secs * 1_000_000_000)) as u32;
|
||||
let datetime = chrono::NaiveDateTime::from_timestamp(secs, nanosecs);
|
||||
|
||||
let new_history = NewHistory {
|
||||
client_id: add_history.id.as_str(),
|
||||
user_id: user.id,
|
||||
mac: add_history.mac.as_str(),
|
||||
timestamp: datetime,
|
||||
data: add_history.data.as_str(),
|
||||
};
|
||||
pub fn add_history(
|
||||
conn: AtuinDbConn,
|
||||
user: User,
|
||||
add_history: Json<Vec<AddHistoryRequest>>,
|
||||
) -> ApiResponse {
|
||||
let new_history: Vec<NewHistory> = add_history
|
||||
.iter()
|
||||
.map(|h| NewHistory {
|
||||
client_id: h.id.as_str(),
|
||||
hostname: h.hostname.to_string(),
|
||||
user_id: user.id,
|
||||
timestamp: h.timestamp.naive_utc(),
|
||||
data: h.data.as_str(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
match diesel::insert_into(history::table)
|
||||
.values(&new_history)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(&*conn)
|
||||
{
|
||||
Ok(_) => ApiResponse {
|
||||
status: Status::Ok,
|
||||
json: json!({"status": "ok", "message": "history added", "id": new_history.client_id}),
|
||||
json: json!({"status": "ok", "message": "history added"}),
|
||||
},
|
||||
Err(_) => ApiResponse {
|
||||
status: Status::BadRequest,
|
||||
@ -87,3 +91,95 @@ pub fn add_history(conn: AtuinDbConn, user: User, add_history: Json<AddHistory>)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/sync/count")]
|
||||
#[allow(clippy::wildcard_imports, clippy::needless_pass_by_value)]
|
||||
pub fn sync_count(conn: AtuinDbConn, user: User) -> ApiResponse {
|
||||
use crate::schema::history::dsl::*;
|
||||
|
||||
// we need to return the number of history items we have for this user
|
||||
// in the future I'd like to use something like a merkel tree to calculate
|
||||
// which day specifically needs syncing
|
||||
let count = history
|
||||
.filter(user_id.eq(user.id))
|
||||
.count()
|
||||
.first::<i64>(&*conn);
|
||||
|
||||
if count.is_err() {
|
||||
error!("failed to count: {}", count.err().unwrap());
|
||||
|
||||
return ApiResponse {
|
||||
json: json!({"message": "internal server error"}),
|
||||
status: Status::InternalServerError,
|
||||
};
|
||||
}
|
||||
|
||||
ApiResponse {
|
||||
status: Status::Ok,
|
||||
json: json!({"count": count.ok()}),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UtcDateTime(chrono::DateTime<Utc>);
|
||||
|
||||
impl<'v> FromFormValue<'v> for UtcDateTime {
|
||||
type Error = &'v RawStr;
|
||||
|
||||
fn from_form_value(form_value: &'v RawStr) -> Result<UtcDateTime, &'v RawStr> {
|
||||
let time = Uri::percent_decode(form_value.as_bytes()).map_err(|_| form_value)?;
|
||||
let time = time.to_string();
|
||||
|
||||
match chrono::DateTime::parse_from_rfc3339(time.as_str()) {
|
||||
Ok(t) => Ok(UtcDateTime(t.with_timezone(&Utc))),
|
||||
Err(e) => {
|
||||
error!("failed to parse time {}, got: {}", time, e);
|
||||
Err(form_value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request a list of all history items added to the DB after a given timestamp.
|
||||
// Provide the current hostname, so that we don't send the client data that
|
||||
// originated from them
|
||||
#[get("/sync/history?<sync_ts>&<history_ts>&<host>")]
|
||||
#[allow(clippy::wildcard_imports, clippy::needless_pass_by_value)]
|
||||
pub fn sync_list(
|
||||
conn: AtuinDbConn,
|
||||
user: User,
|
||||
sync_ts: UtcDateTime,
|
||||
history_ts: UtcDateTime,
|
||||
host: String,
|
||||
) -> ApiResponse {
|
||||
use crate::schema::history::dsl::*;
|
||||
|
||||
// we need to return the number of history items we have for this user
|
||||
// in the future I'd like to use something like a merkel tree to calculate
|
||||
// which day specifically needs syncing
|
||||
// TODO: Allow for configuring the page size, both from params, and setting
|
||||
// the max in config. 100 is fine for now.
|
||||
let h = history
|
||||
.filter(user_id.eq(user.id))
|
||||
.filter(hostname.ne(host))
|
||||
.filter(created_at.ge(sync_ts.0.naive_utc()))
|
||||
.filter(timestamp.ge(history_ts.0.naive_utc()))
|
||||
.order(timestamp.asc())
|
||||
.limit(HISTORY_PAGE_SIZE)
|
||||
.load::<History>(&*conn);
|
||||
|
||||
if let Err(e) = h {
|
||||
error!("failed to load history: {}", e);
|
||||
|
||||
return ApiResponse {
|
||||
json: json!({"message": "internal server error"}),
|
||||
status: Status::InternalServerError,
|
||||
};
|
||||
}
|
||||
|
||||
let user_data: Vec<String> = h.unwrap().iter().map(|i| i.data.to_string()).collect();
|
||||
|
||||
ApiResponse {
|
||||
status: Status::Ok,
|
||||
json: json!({ "history": user_data }),
|
||||
}
|
||||
}
|
||||
|
@ -3,9 +3,10 @@ table! {
|
||||
id -> Int8,
|
||||
client_id -> Text,
|
||||
user_id -> Int8,
|
||||
mac -> Varchar,
|
||||
hostname -> Text,
|
||||
timestamp -> Timestamp,
|
||||
data -> Varchar,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +21,7 @@ table! {
|
||||
table! {
|
||||
users (id) {
|
||||
id -> Int8,
|
||||
username -> Varchar,
|
||||
email -> Varchar,
|
||||
password -> Varchar,
|
||||
}
|
||||
|
137
src/settings.rs
137
src/settings.rs
@ -1,31 +1,90 @@
|
||||
use std::path::PathBuf;
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::io::prelude::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use config::{Config, File};
|
||||
use chrono::prelude::*;
|
||||
use chrono::Utc;
|
||||
use config::{Config, File as ConfigFile};
|
||||
use directories::ProjectDirs;
|
||||
use eyre::{eyre, Result};
|
||||
use std::fs;
|
||||
use parse_duration::parse;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub const HISTORY_PAGE_SIZE: i64 = 100;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Local {
|
||||
pub dialect: String,
|
||||
pub sync: bool,
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Remote {
|
||||
impl Local {
|
||||
pub fn save_sync_time() -> Result<()> {
|
||||
let sync_time_path = ProjectDirs::from("com", "elliehuxtable", "atuin")
|
||||
.ok_or_else(|| eyre!("could not determine key file location"))?;
|
||||
let sync_time_path = sync_time_path.data_dir().join("last_sync_time");
|
||||
|
||||
std::fs::write(sync_time_path, Utc::now().to_rfc3339())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn last_sync() -> Result<chrono::DateTime<Utc>> {
|
||||
let sync_time_path = ProjectDirs::from("com", "elliehuxtable", "atuin");
|
||||
|
||||
if sync_time_path.is_none() {
|
||||
debug!("failed to load projectdirs, not syncing");
|
||||
return Err(eyre!("could not load project dirs"));
|
||||
}
|
||||
|
||||
let sync_time_path = sync_time_path.unwrap();
|
||||
let sync_time_path = sync_time_path.data_dir().join("last_sync_time");
|
||||
|
||||
if !sync_time_path.exists() {
|
||||
return Ok(Utc.ymd(1970, 1, 1).and_hms(0, 0, 0));
|
||||
}
|
||||
|
||||
let time = std::fs::read_to_string(sync_time_path)?;
|
||||
let time = chrono::DateTime::parse_from_rfc3339(time.as_str())?;
|
||||
|
||||
Ok(time.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
pub fn should_sync(&self) -> Result<bool> {
|
||||
if !self.auto_sync {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match parse(self.sync_frequency.as_str()) {
|
||||
Ok(d) => {
|
||||
let d = chrono::Duration::from_std(d).unwrap();
|
||||
Ok(Utc::now() - Local::last_sync()? >= d)
|
||||
}
|
||||
Err(e) => Err(eyre!("failed to check sync: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Server {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub db_uri: String,
|
||||
pub open_registration: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub local: Local,
|
||||
pub remote: Remote,
|
||||
pub server: Server,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@ -33,7 +92,7 @@ impl Settings {
|
||||
let config_dir = ProjectDirs::from("com", "elliehuxtable", "atuin").unwrap();
|
||||
let config_dir = config_dir.config_dir();
|
||||
|
||||
fs::create_dir_all(config_dir)?;
|
||||
create_dir_all(config_dir)?;
|
||||
|
||||
let mut config_file = PathBuf::new();
|
||||
config_file.push(config_dir);
|
||||
@ -45,31 +104,61 @@ impl Settings {
|
||||
let mut s = Config::new();
|
||||
|
||||
let db_path = ProjectDirs::from("com", "elliehuxtable", "atuin")
|
||||
.ok_or_else(|| {
|
||||
eyre!("could not determine db file location\nspecify one using the --db flag")
|
||||
})?
|
||||
.ok_or_else(|| eyre!("could not determine db file location"))?
|
||||
.data_dir()
|
||||
.join("history.db");
|
||||
|
||||
s.set_default("local.db_path", db_path.to_str())?;
|
||||
s.set_default("local.dialect", "us")?;
|
||||
s.set_default("local.sync", false)?;
|
||||
s.set_default("local.sync_frequency", "5m")?;
|
||||
s.set_default("local.sync_address", "https://atuin.ellie.wtf")?;
|
||||
let key_path = ProjectDirs::from("com", "elliehuxtable", "atuin")
|
||||
.ok_or_else(|| eyre!("could not determine key file location"))?
|
||||
.data_dir()
|
||||
.join("key");
|
||||
|
||||
s.set_default("remote.host", "127.0.0.1")?;
|
||||
s.set_default("remote.port", 8888)?;
|
||||
s.set_default("remote.open_registration", false)?;
|
||||
s.set_default("remote.db_uri", "please set a postgres url")?;
|
||||
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", "please set a postgres url")?;
|
||||
|
||||
if config_file.exists() {
|
||||
s.merge(File::with_name(config_file.to_str().unwrap()))?;
|
||||
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)?;
|
||||
}
|
||||
|
||||
// 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())?;
|
||||
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)?;
|
||||
} else {
|
||||
s.set("local.session_token", "not logged in")?;
|
||||
}
|
||||
|
||||
s.try_into()
|
||||
.map_err(|e| eyre!("failed to deserialize: {}", e))
|
||||
|
@ -1,4 +1,6 @@
|
||||
# Source this in your ~/.zshrc
|
||||
autoload -U add-zsh-hook
|
||||
|
||||
export ATUIN_SESSION=$(atuin uuid)
|
||||
export ATUIN_HISTORY="atuin history list"
|
||||
export ATUIN_BINDKEYS="true"
|
||||
@ -20,24 +22,12 @@ _atuin_search(){
|
||||
emulate -L zsh
|
||||
zle -I
|
||||
|
||||
# Switch to cursor mode, then back to application
|
||||
echoti rmkx
|
||||
# swap stderr and stdout, so that the tui stuff works
|
||||
# TODO: not this
|
||||
output=$(atuin search $BUFFER 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ -n $output ]] ; then
|
||||
LBUFFER=$output
|
||||
fi
|
||||
|
||||
zle reset-prompt
|
||||
}
|
||||
|
||||
_atuin_up_search(){
|
||||
emulate -L zsh
|
||||
zle -I
|
||||
|
||||
# swap stderr and stdout, so that the tui stuff works
|
||||
# TODO: not this
|
||||
output=$(atuin search $BUFFER 3>&1 1>&2 2>&3)
|
||||
echoti smkx
|
||||
|
||||
if [[ -n $output ]] ; then
|
||||
LBUFFER=$output
|
||||
@ -50,9 +40,11 @@ add-zsh-hook preexec _atuin_preexec
|
||||
add-zsh-hook precmd _atuin_precmd
|
||||
|
||||
zle -N _atuin_search_widget _atuin_search
|
||||
zle -N _atuin_up_search_widget _atuin_up_search
|
||||
|
||||
if [[ $ATUIN_BINDKEYS == "true" ]]; then
|
||||
bindkey '^r' _atuin_search_widget
|
||||
bindkey '^[[A' _atuin_up_search_widget
|
||||
|
||||
# depends on terminal mode
|
||||
bindkey '^[[A' _atuin_search_widget
|
||||
bindkey '^[OA' _atuin_search_widget
|
||||
fi
|
||||
|
24
src/utils.rs
Normal file
24
src/utils.rs
Normal file
@ -0,0 +1,24 @@
|
||||
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()
|
||||
}
|
Loading…
Reference in New Issue
Block a user