diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 00:54:30 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 00:54:30 +0200 |
| commit | 5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8 (patch) | |
| tree | c64baa8d5866c8e339eaf660dd3f94f30a3f7d8a /crates/atuin-server | |
| parent | chore: Somewhat simplify sync code (diff) | |
| download | atuin-5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8.zip | |
chore: Move everything into one big crate
That helps remove duplicated code and rustc/cargo will now also show
dead code correctly.
Diffstat (limited to 'crates/atuin-server')
| l--------- | crates/atuin-server/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | crates/atuin-server/Cargo.toml | 45 | ||||
| -rw-r--r-- | crates/atuin-server/server.toml | 38 | ||||
| -rw-r--r-- | crates/atuin-server/src/bin/main.rs | 73 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/health.rs | 15 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/history.rs | 237 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/mod.rs | 60 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/record.rs | 42 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/status.rs | 45 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/user.rs | 269 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/v0/me.rs | 16 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/v0/mod.rs | 3 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/v0/record.rs | 114 | ||||
| -rw-r--r-- | crates/atuin-server/src/handlers/v0/store.rs | 37 | ||||
| -rw-r--r-- | crates/atuin-server/src/lib.rs | 89 | ||||
| -rw-r--r-- | crates/atuin-server/src/metrics.rs | 55 | ||||
| -rw-r--r-- | crates/atuin-server/src/router.rs | 155 | ||||
| -rw-r--r-- | crates/atuin-server/src/settings.rs | 113 | ||||
| -rw-r--r-- | crates/atuin-server/src/utils.rs | 15 |
19 files changed, 0 insertions, 1422 deletions
diff --git a/crates/atuin-server/CHANGELOG.md b/crates/atuin-server/CHANGELOG.md deleted file mode 120000 index 699cc9e7..00000000 --- a/crates/atuin-server/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../../CHANGELOG.md
\ No newline at end of file diff --git a/crates/atuin-server/Cargo.toml b/crates/atuin-server/Cargo.toml deleted file mode 100644 index b7779899..00000000 --- a/crates/atuin-server/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -[package] -name = "atuin-server" -edition = "2024" -description = "server library for atuin" - -rust-version = { workspace = true } -version = { workspace = true } -authors = { workspace = true } -license = { workspace = true } -homepage = { workspace = true } -repository = { workspace = true } - -[lib] -name = "atuin_server" -path = "src/lib.rs" - -[[bin]] -name = "atuin-server" -path = "src/bin/main.rs" - -[dependencies] -atuin-common = { workspace = true } -atuin-server-database = { workspace = true } -atuin-server-postgres = { workspace = true } -atuin-server-sqlite = { workspace = true } - -tracing = { workspace = true } -time = { workspace = true } -eyre = { workspace = true } -config = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -rand = { workspace = true } -tokio = { workspace = true } -axum = "0.8" -fs-err = { workspace = true } -tower = { workspace = true } -tower-http = { version = "0.6", features = ["trace"] } -reqwest = { workspace = true } -argon2 = "0.5" -semver = { workspace = true } -metrics-exporter-prometheus = { version = "0.18", default-features = false } -metrics = "0.24" -clap = { workspace = true } -tracing-subscriber = { workspace = true } diff --git a/crates/atuin-server/server.toml b/crates/atuin-server/server.toml deleted file mode 100644 index 9ff95890..00000000 --- a/crates/atuin-server/server.toml +++ /dev/null @@ -1,38 +0,0 @@ -## 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" -# db_uri="sqlite:///config/atuin-server.db" - -## Optional: URI for read replica database -## If set, read-only queries will be routed to this database -# read_db_uri="postgres://username:password@localhost-replica/atuin" - -## Maximum size for one history entry -# max_history_length = 8192 - -## Maximum size for one record entry -## 1024 * 1024 * 1024 -# max_record_size = 1073741824 - -## Webhook to be called when user registers on the servers -# register_webhook_username = "" - -## Default page size for requests -# page_size = 1100 - -# [metrics] -# enable = false -# host = 127.0.0.1 -# port = 9001 - -## Enable legacy sync v1 routes (history-based sync) -## Set to false to disable and use only the newer record-based sync -# sync_v1_enabled = true diff --git a/crates/atuin-server/src/bin/main.rs b/crates/atuin-server/src/bin/main.rs deleted file mode 100644 index 960bdf6e..00000000 --- a/crates/atuin-server/src/bin/main.rs +++ /dev/null @@ -1,73 +0,0 @@ -#![forbid(unsafe_code)] - -use std::net::SocketAddr; - -use atuin_server::{Settings, example_config, launch, launch_metrics_server}; -use atuin_server_database::DbType; -use atuin_server_postgres::Postgres; -use atuin_server_sqlite::Sqlite; - -use clap::Parser; -use eyre::{Context, Result, eyre}; -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; - -#[derive(Parser, Debug)] -#[clap( - name = "atuin-server", - about = "Atuin sync server", - version, - infer_subcommands = true -)] -enum Cmd { - /// Start the server - Start { - /// The host address to bind - #[clap(long)] - host: Option<String>, - - /// The port to bind - #[clap(long, short)] - port: Option<u16>, - }, - - /// Print server example configuration - DefaultConfig, -} - -#[tokio::main] -async fn main() -> Result<()> { - let cmd = Cmd::parse(); - - tracing_subscriber::registry() - .with(fmt::layer()) - .with(EnvFilter::from_default_env()) - .init(); - - tracing::trace!(command = ?cmd, "server command"); - - match cmd { - Cmd::Start { host, port } => { - let settings = Settings::new().wrap_err("could not load server settings")?; - let host = host.as_ref().unwrap_or(&settings.host).clone(); - let port = port.unwrap_or(settings.port); - let addr = SocketAddr::new(host.parse()?, port); - - if settings.metrics.enable { - tokio::spawn(launch_metrics_server( - settings.metrics.host.clone(), - settings.metrics.port, - )); - } - - match settings.db_settings.db_type() { - DbType::Postgres => launch::<Postgres>(settings, addr).await, - DbType::Sqlite => launch::<Sqlite>(settings, addr).await, - DbType::Unknown => Err(eyre!("db_uri must start with postgres:// or sqlite://")), - } - } - Cmd::DefaultConfig => { - println!("{}", example_config()); - Ok(()) - } - } -} diff --git a/crates/atuin-server/src/handlers/health.rs b/crates/atuin-server/src/handlers/health.rs deleted file mode 100644 index aebd1e8f..00000000 --- a/crates/atuin-server/src/handlers/health.rs +++ /dev/null @@ -1,15 +0,0 @@ -use axum::{Json, http, response::IntoResponse}; - -use serde::Serialize; - -#[derive(Serialize)] -pub struct HealthResponse { - pub status: &'static str, -} - -pub async fn health_check() -> impl IntoResponse { - ( - http::StatusCode::OK, - Json(HealthResponse { status: "healthy" }), - ) -} diff --git a/crates/atuin-server/src/handlers/history.rs b/crates/atuin-server/src/handlers/history.rs deleted file mode 100644 index bdafcc60..00000000 --- a/crates/atuin-server/src/handlers/history.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::{collections::HashMap, convert::TryFrom}; - -use axum::{ - Json, - extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, -}; -use metrics::counter; -use time::{Month, UtcOffset}; -use tracing::{debug, error, instrument}; - -use super::{ErrorResponse, ErrorResponseStatus, RespExt}; -use crate::{ - router::{AppState, UserAuth}, - utils::client_version_min, -}; -use atuin_server_database::{ - Database, - calendar::{TimePeriod, TimePeriodInfo}, - models::NewHistory, -}; - -use atuin_common::api::*; - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn count<DB: Database>( - UserAuth(user): UserAuth, - state: State<AppState<DB>>, -) -> Result<Json<CountResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - match db.count_history_cached(&user).await { - // By default read out the cached value - Ok(count) => Ok(Json(CountResponse { count })), - - // If that fails, fallback on a full COUNT. Cache is built on a POST - // only - Err(_) => match db.count_history(&user).await { - Ok(count) => Ok(Json(CountResponse { count })), - Err(_) => Err(ErrorResponse::reply("failed to query history count") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)), - }, - } -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn list<DB: Database>( - req: Query<SyncHistoryRequest>, - UserAuth(user): UserAuth, - headers: HeaderMap, - state: State<AppState<DB>>, -) -> Result<Json<SyncHistoryResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - - let agent = headers - .get("user-agent") - .map_or("", |v| v.to_str().unwrap_or("")); - - let variable_page_size = client_version_min(agent, ">=15.0.0").unwrap_or(false); - - let page_size = if variable_page_size { - state.settings.page_size - } else { - 100 - }; - - if req.sync_ts.unix_timestamp_nanos() < 0 || req.history_ts.unix_timestamp_nanos() < 0 { - error!("client asked for history from < epoch 0"); - counter!("atuin_history_epoch_before_zero").increment(1); - - return Err( - ErrorResponse::reply("asked for history from before epoch 0") - .with_status(StatusCode::BAD_REQUEST), - ); - } - - let history = db - .list_history(&user, req.sync_ts, req.history_ts, &req.host, page_size) - .await; - - if let Err(e) = history { - error!("failed to load history: {}", e); - return Err(ErrorResponse::reply("failed to load history") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - - let history: Vec<String> = history - .unwrap() - .iter() - .map(|i| i.data.to_string()) - .collect(); - - debug!( - "loaded {} items of history for user {}", - history.len(), - user.id - ); - - counter!("atuin_history_returned").increment(history.len() as u64); - - Ok(Json(SyncHistoryResponse { history })) -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn delete<DB: Database>( - UserAuth(user): UserAuth, - state: State<AppState<DB>>, - Json(req): Json<DeleteHistoryRequest>, -) -> Result<Json<MessageResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - - // user_id is the ID of the history, as set by the user (the server has its own ID) - let deleted = db.delete_history(&user, req.client_id).await; - - if let Err(e) = deleted { - error!("failed to delete history: {}", e); - return Err(ErrorResponse::reply("failed to delete history") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - - Ok(Json(MessageResponse { - message: String::from("deleted OK"), - })) -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn add<DB: Database>( - UserAuth(user): UserAuth, - state: State<AppState<DB>>, - Json(req): Json<Vec<AddHistoryRequest>>, -) -> Result<(), ErrorResponseStatus<'static>> { - let State(AppState { database, settings }) = state; - - debug!("request to add {} history items", req.len()); - counter!("atuin_history_uploaded").increment(req.len() as u64); - - let mut history: Vec<NewHistory> = req - .into_iter() - .map(|h| NewHistory { - client_id: h.id, - user_id: user.id, - hostname: h.hostname, - timestamp: h.timestamp, - data: h.data, - }) - .collect(); - - history.retain(|h| { - // keep if within limit, or limit is 0 (unlimited) - let keep = h.data.len() <= settings.max_history_length || settings.max_history_length == 0; - - // Don't return an error here. We want to insert as much of the - // history list as we can, so log the error and continue going. - if !keep { - counter!("atuin_history_too_long").increment(1); - - tracing::warn!( - "history too long, got length {}, max {}", - h.data.len(), - settings.max_history_length - ); - } - - keep - }); - - if let Err(e) = database.add_history(&history).await { - error!("failed to add history: {}", e); - - return Err(ErrorResponse::reply("failed to add history") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - }; - - Ok(()) -} - -#[derive(serde::Deserialize, Debug)] -pub struct CalendarQuery { - #[serde(default = "serde_calendar::zero")] - year: i32, - #[serde(default = "serde_calendar::one")] - month: u8, - - #[serde(default = "serde_calendar::utc")] - tz: UtcOffset, -} - -mod serde_calendar { - use time::UtcOffset; - - pub fn zero() -> i32 { - 0 - } - - pub fn one() -> u8 { - 1 - } - - pub fn utc() -> UtcOffset { - UtcOffset::UTC - } -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn calendar<DB: Database>( - Path(focus): Path<String>, - Query(params): Query<CalendarQuery>, - UserAuth(user): UserAuth, - state: State<AppState<DB>>, -) -> Result<Json<HashMap<u64, TimePeriodInfo>>, ErrorResponseStatus<'static>> { - let focus = focus.as_str(); - - let year = params.year; - let month = Month::try_from(params.month).map_err(|e| ErrorResponseStatus { - error: ErrorResponse { - reason: e.to_string().into(), - }, - status: StatusCode::BAD_REQUEST, - })?; - - let period = match focus { - "year" => TimePeriod::Year, - "month" => TimePeriod::Month { year }, - "day" => TimePeriod::Day { year, month }, - _ => { - return Err(ErrorResponse::reply("invalid focus: use year/month/day") - .with_status(StatusCode::BAD_REQUEST)); - } - }; - - let db = &state.0.database; - let focus = db.calendar(&user, period, params.tz).await.map_err(|_| { - ErrorResponse::reply("failed to query calendar") - .with_status(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - Ok(Json(focus)) -} diff --git a/crates/atuin-server/src/handlers/mod.rs b/crates/atuin-server/src/handlers/mod.rs deleted file mode 100644 index 2176ac5e..00000000 --- a/crates/atuin-server/src/handlers/mod.rs +++ /dev/null @@ -1,60 +0,0 @@ -use atuin_common::api::{ErrorResponse, IndexResponse}; -use atuin_server_database::Database; -use axum::{Json, extract::State, http, response::IntoResponse}; - -use crate::router::AppState; - -pub mod health; -pub mod history; -pub mod record; -pub mod status; -pub mod user; -pub mod v0; - -const VERSION: &str = env!("CARGO_PKG_VERSION"); - -pub async fn index<DB: Database>(state: State<AppState<DB>>) -> Json<IndexResponse> { - let homage = r#""Through the fathomless deeps of space swims the star turtle Great A'Tuin, bearing on its back the four giant elephants who carry on their shoulders the mass of the Discworld." -- Sir Terry Pratchett"#; - - let version = state - .settings - .fake_version - .clone() - .unwrap_or(VERSION.to_string()); - - Json(IndexResponse { - homage: homage.to_string(), - version, - }) -} - -impl IntoResponse for ErrorResponseStatus<'_> { - fn into_response(self) -> axum::response::Response { - (self.status, Json(self.error)).into_response() - } -} - -pub struct ErrorResponseStatus<'a> { - pub error: ErrorResponse<'a>, - pub status: http::StatusCode, -} - -pub trait RespExt<'a> { - fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a>; - fn reply(reason: &'a str) -> Self; -} - -impl<'a> RespExt<'a> for ErrorResponse<'a> { - fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a> { - ErrorResponseStatus { - error: self, - status, - } - } - - fn reply(reason: &'a str) -> ErrorResponse<'a> { - Self { - reason: reason.into(), - } - } -} diff --git a/crates/atuin-server/src/handlers/record.rs b/crates/atuin-server/src/handlers/record.rs deleted file mode 100644 index 410c54bd..00000000 --- a/crates/atuin-server/src/handlers/record.rs +++ /dev/null @@ -1,42 +0,0 @@ -use axum::{Json, http::StatusCode, response::IntoResponse}; -use serde_json::json; -use tracing::instrument; - -use super::{ErrorResponse, ErrorResponseStatus, RespExt}; -use crate::router::UserAuth; - -use atuin_common::record::{EncryptedData, Record}; - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn post(UserAuth(user): UserAuth) -> Result<(), ErrorResponseStatus<'static>> { - // anyone who has actually used the old record store (a very small number) will see this error - // upon trying to sync. - // 1. The status endpoint will say that the server has nothing - // 2. The client will try to upload local records - // 3. Sync will fail with this error - - // If the client has no local records, they will see the empty index and do nothing. For the - // vast majority of users, this is the case. - return Err( - ErrorResponse::reply("record store deprecated; please upgrade") - .with_status(StatusCode::BAD_REQUEST), - ); -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn index(UserAuth(user): UserAuth) -> axum::response::Response { - let ret = json!({ - "hosts": {} - }); - - ret.to_string().into_response() -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn next( - UserAuth(user): UserAuth, -) -> Result<Json<Vec<Record<EncryptedData>>>, ErrorResponseStatus<'static>> { - let records = Vec::new(); - - Ok(Json(records)) -} diff --git a/crates/atuin-server/src/handlers/status.rs b/crates/atuin-server/src/handlers/status.rs deleted file mode 100644 index 9c152d51..00000000 --- a/crates/atuin-server/src/handlers/status.rs +++ /dev/null @@ -1,45 +0,0 @@ -use axum::{Json, extract::State, http::StatusCode}; -use tracing::instrument; - -use super::{ErrorResponse, ErrorResponseStatus, RespExt}; -use crate::router::{AppState, UserAuth}; -use atuin_server_database::Database; - -use atuin_common::api::*; - -const VERSION: &str = env!("CARGO_PKG_VERSION"); - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn status<DB: Database>( - UserAuth(user): UserAuth, - state: State<AppState<DB>>, -) -> Result<Json<StatusResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - - let deleted = db.deleted_history(&user).await.unwrap_or(vec![]); - - let count = match db.count_history_cached(&user).await { - // By default read out the cached value - Ok(count) => count, - - // If that fails, fallback on a full COUNT. Cache is built on a POST - // only - Err(_) => match db.count_history(&user).await { - Ok(count) => count, - Err(_) => { - return Err(ErrorResponse::reply("failed to query history count") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }, - }; - - tracing::debug!(user = user.username, "requested sync status"); - - Ok(Json(StatusResponse { - count, - deleted, - username: user.username, - version: VERSION.to_string(), - page_size: state.settings.page_size, - })) -} diff --git a/crates/atuin-server/src/handlers/user.rs b/crates/atuin-server/src/handlers/user.rs deleted file mode 100644 index dda7a381..00000000 --- a/crates/atuin-server/src/handlers/user.rs +++ /dev/null @@ -1,269 +0,0 @@ -use std::borrow::Borrow; -use std::collections::HashMap; -use std::time::Duration; - -use argon2::{ - Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, - password_hash::SaltString, -}; -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, -}; -use metrics::counter; - -use rand::rngs::OsRng; -use tracing::{debug, error, info, instrument}; - -use atuin_common::tls::ensure_crypto_provider; - -use super::{ErrorResponse, ErrorResponseStatus, RespExt}; -use crate::router::{AppState, UserAuth}; -use atuin_server_database::{ - Database, DbError, - models::{NewSession, NewUser}, -}; - -use reqwest::header::CONTENT_TYPE; - -use atuin_common::{api::*, utils::crypto_random_string}; - -pub fn verify_str(hash: &str, password: &str) -> bool { - let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); - let Ok(hash) = PasswordHash::new(hash) else { - return false; - }; - arg2.verify_password(password.as_bytes(), &hash).is_ok() -} - -// Try to send a Discord webhook once - if it fails, we don't retry. "At most once", and best effort. -// Don't return the status because if this fails, we don't really care. -async fn send_register_hook(url: &str, username: String, registered: String) { - ensure_crypto_provider(); - let hook = HashMap::from([ - ("username", username), - ("content", format!("{registered} has just signed up!")), - ]); - - let client = reqwest::Client::new(); - - let resp = client - .post(url) - .timeout(Duration::new(5, 0)) - .header(CONTENT_TYPE, "application/json") - .json(&hook) - .send() - .await; - - match resp { - Ok(_) => info!("register webhook sent ok!"), - Err(e) => error!("failed to send register webhook: {}", e), - } -} - -#[instrument(skip_all, fields(user.username = username.as_str()))] -pub async fn get<DB: Database>( - Path(username): Path<String>, - state: State<AppState<DB>>, -) -> Result<Json<UserResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - let user = match db.get_user(username.as_ref()).await { - Ok(user) => user, - Err(DbError::NotFound) => { - debug!("user not found: {}", username); - return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); - } - Err(DbError::Other(err)) => { - error!("database error: {}", err); - return Err(ErrorResponse::reply("database error") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }; - - Ok(Json(UserResponse { - username: user.username, - })) -} - -#[instrument(skip_all)] -pub async fn register<DB: Database>( - state: State<AppState<DB>>, - Json(register): Json<RegisterRequest>, -) -> Result<Json<RegisterResponse>, ErrorResponseStatus<'static>> { - if !state.settings.open_registration { - return Err( - ErrorResponse::reply("this server is not open for registrations") - .with_status(StatusCode::BAD_REQUEST), - ); - } - - for c in register.username.chars() { - match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' => {} - _ => { - return Err(ErrorResponse::reply( - "Only alphanumeric and hyphens (-) are allowed in usernames", - ) - .with_status(StatusCode::BAD_REQUEST)); - } - } - } - - let hashed = hash_secret(®ister.password); - - let new_user = NewUser { - email: register.email.clone(), - username: register.username.clone(), - password: hashed, - }; - - let db = &state.0.database; - let user_id = match db.add_user(&new_user).await { - Ok(id) => id, - Err(e) => { - error!("failed to add user: {}", e); - return Err( - ErrorResponse::reply("failed to add user").with_status(StatusCode::BAD_REQUEST) - ); - } - }; - - // 24 bytes encoded as base64 - let token = crypto_random_string::<24>(); - - let new_session = NewSession { - user_id, - token: (&token).into(), - }; - - if let Some(url) = &state.settings.register_webhook_url { - // Could probs be run on another thread, but it's ok atm - send_register_hook( - url, - state.settings.register_webhook_username.clone(), - register.username, - ) - .await; - } - - counter!("atuin_users_registered").increment(1); - - match db.add_session(&new_session).await { - Ok(_) => Ok(Json(RegisterResponse { - session: token, - auth: Some("cli".into()), - })), - Err(e) => { - error!("failed to add session: {}", e); - Err(ErrorResponse::reply("failed to register user") - .with_status(StatusCode::BAD_REQUEST)) - } - } -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn delete<DB: Database>( - UserAuth(user): UserAuth, - state: State<AppState<DB>>, -) -> Result<Json<DeleteUserResponse>, ErrorResponseStatus<'static>> { - debug!("request to delete user {}", user.id); - - let db = &state.0.database; - if let Err(e) = db.delete_user(&user).await { - error!("failed to delete user: {}", e); - - return Err(ErrorResponse::reply("failed to delete user") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - }; - - counter!("atuin_users_deleted").increment(1); - - Ok(Json(DeleteUserResponse {})) -} - -#[instrument(skip_all, fields(user.id = user.id, change_password))] -pub async fn change_password<DB: Database>( - UserAuth(mut user): UserAuth, - state: State<AppState<DB>>, - Json(change_password): Json<ChangePasswordRequest>, -) -> Result<Json<ChangePasswordResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - - let verified = verify_str( - user.password.as_str(), - change_password.current_password.borrow(), - ); - if !verified { - return Err( - ErrorResponse::reply("password is not correct").with_status(StatusCode::UNAUTHORIZED) - ); - } - - let hashed = hash_secret(&change_password.new_password); - user.password = hashed; - - if let Err(e) = db.update_user_password(&user).await { - error!("failed to change user password: {}", e); - - return Err(ErrorResponse::reply("failed to change user password") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - }; - Ok(Json(ChangePasswordResponse {})) -} - -#[instrument(skip_all, fields(user.username = login.username.as_str()))] -pub async fn login<DB: Database>( - state: State<AppState<DB>>, - login: Json<LoginRequest>, -) -> Result<Json<LoginResponse>, ErrorResponseStatus<'static>> { - let db = &state.0.database; - let user = match db.get_user(login.username.borrow()).await { - Ok(u) => u, - Err(DbError::NotFound) => { - return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); - } - Err(DbError::Other(e)) => { - error!("failed to get user {}: {}", login.username.clone(), e); - - return Err(ErrorResponse::reply("database error") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }; - - let session = match db.get_user_session(&user).await { - Ok(u) => u, - Err(DbError::NotFound) => { - debug!("user session not found for user id={}", user.id); - return Err(ErrorResponse::reply("user not found").with_status(StatusCode::NOT_FOUND)); - } - Err(DbError::Other(err)) => { - error!("database error for user {}: {}", login.username, err); - return Err(ErrorResponse::reply("database error") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }; - - let verified = verify_str(user.password.as_str(), login.password.borrow()); - - if !verified { - debug!(user = user.username, "login failed"); - return Err( - ErrorResponse::reply("password is not correct").with_status(StatusCode::UNAUTHORIZED) - ); - } - - debug!(user = user.username, "login success"); - - Ok(Json(LoginResponse { - session: session.token, - auth: Some("cli".into()), - })) -} - -fn hash_secret(password: &str) -> String { - let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); - let salt = SaltString::generate(&mut OsRng); - let hash = arg2.hash_password(password.as_bytes(), &salt).unwrap(); - hash.to_string() -} diff --git a/crates/atuin-server/src/handlers/v0/me.rs b/crates/atuin-server/src/handlers/v0/me.rs deleted file mode 100644 index 7960b479..00000000 --- a/crates/atuin-server/src/handlers/v0/me.rs +++ /dev/null @@ -1,16 +0,0 @@ -use axum::Json; -use tracing::instrument; - -use crate::handlers::ErrorResponseStatus; -use crate::router::UserAuth; - -use atuin_common::api::*; - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn get( - UserAuth(user): UserAuth, -) -> Result<Json<MeResponse>, ErrorResponseStatus<'static>> { - Ok(Json(MeResponse { - username: user.username, - })) -} diff --git a/crates/atuin-server/src/handlers/v0/mod.rs b/crates/atuin-server/src/handlers/v0/mod.rs deleted file mode 100644 index d6f880f2..00000000 --- a/crates/atuin-server/src/handlers/v0/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) mod me; -pub(crate) mod record; -pub(crate) mod store; diff --git a/crates/atuin-server/src/handlers/v0/record.rs b/crates/atuin-server/src/handlers/v0/record.rs deleted file mode 100644 index 5c57910b..00000000 --- a/crates/atuin-server/src/handlers/v0/record.rs +++ /dev/null @@ -1,114 +0,0 @@ -use axum::{Json, extract::Query, extract::State, http::StatusCode}; -use metrics::counter; -use serde::Deserialize; -use tracing::{error, instrument}; - -use crate::{ - handlers::{ErrorResponse, ErrorResponseStatus, RespExt}, - router::{AppState, UserAuth}, -}; -use atuin_server_database::Database; - -use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus}; - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn post<DB: Database>( - UserAuth(user): UserAuth, - state: State<AppState<DB>>, - Json(records): Json<Vec<Record<EncryptedData>>>, -) -> Result<(), ErrorResponseStatus<'static>> { - let State(AppState { database, settings }) = state; - - tracing::debug!( - count = records.len(), - user = user.username, - "request to add records" - ); - - counter!("atuin_record_uploaded").increment(records.len() as u64); - - let keep = records - .iter() - .all(|r| r.data.data.len() <= settings.max_record_size || settings.max_record_size == 0); - - if !keep { - counter!("atuin_record_too_large").increment(1); - - return Err( - ErrorResponse::reply("could not add records; record too large") - .with_status(StatusCode::BAD_REQUEST), - ); - } - - if let Err(e) = database.add_records(&user, &records).await { - error!("failed to add record: {}", e); - - return Err(ErrorResponse::reply("failed to add record") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - }; - - Ok(()) -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn index<DB: Database>( - UserAuth(user): UserAuth, - state: State<AppState<DB>>, -) -> Result<Json<RecordStatus>, ErrorResponseStatus<'static>> { - let State(AppState { - database, - settings: _, - }) = state; - - let record_index = match database.status(&user).await { - Ok(index) => index, - Err(e) => { - error!("failed to get record index: {}", e); - - return Err(ErrorResponse::reply("failed to calculate record index") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }; - - tracing::debug!(user = user.username, "record index request"); - - Ok(Json(record_index)) -} - -#[derive(Deserialize)] -pub struct NextParams { - host: HostId, - tag: String, - start: Option<RecordIdx>, - count: u64, -} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn next<DB: Database>( - params: Query<NextParams>, - UserAuth(user): UserAuth, - state: State<AppState<DB>>, -) -> Result<Json<Vec<Record<EncryptedData>>>, ErrorResponseStatus<'static>> { - let State(AppState { - database, - settings: _, - }) = state; - let params = params.0; - - let records = match database - .next_records(&user, params.host, params.tag, params.start, params.count) - .await - { - Ok(records) => records, - Err(e) => { - error!("failed to get record index: {}", e); - - return Err(ErrorResponse::reply("failed to calculate record index") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - }; - - counter!("atuin_record_downloaded").increment(records.len() as u64); - - Ok(Json(records)) -} diff --git a/crates/atuin-server/src/handlers/v0/store.rs b/crates/atuin-server/src/handlers/v0/store.rs deleted file mode 100644 index 6ca455d7..00000000 --- a/crates/atuin-server/src/handlers/v0/store.rs +++ /dev/null @@ -1,37 +0,0 @@ -use axum::{extract::Query, extract::State, http::StatusCode}; -use metrics::counter; -use serde::Deserialize; -use tracing::{error, instrument}; - -use crate::{ - handlers::{ErrorResponse, ErrorResponseStatus, RespExt}, - router::{AppState, UserAuth}, -}; -use atuin_server_database::Database; - -#[derive(Deserialize)] -pub struct DeleteParams {} - -#[instrument(skip_all, fields(user.id = user.id))] -pub async fn delete<DB: Database>( - _params: Query<DeleteParams>, - UserAuth(user): UserAuth, - state: State<AppState<DB>>, -) -> Result<(), ErrorResponseStatus<'static>> { - let State(AppState { - database, - settings: _, - }) = state; - - if let Err(e) = database.delete_store(&user).await { - counter!("atuin_store_delete_failed").increment(1); - error!("failed to delete store {e:?}"); - - return Err(ErrorResponse::reply("failed to delete store") - .with_status(StatusCode::INTERNAL_SERVER_ERROR)); - } - - counter!("atuin_store_deleted").increment(1); - - Ok(()) -} diff --git a/crates/atuin-server/src/lib.rs b/crates/atuin-server/src/lib.rs deleted file mode 100644 index 02e50e1e..00000000 --- a/crates/atuin-server/src/lib.rs +++ /dev/null @@ -1,89 +0,0 @@ -#![forbid(unsafe_code)] - -use std::future::Future; -use std::net::SocketAddr; - -use atuin_server_database::Database; -use axum::{Router, serve}; -use eyre::{Context, Result}; - -mod handlers; -mod metrics; -mod router; -mod utils; - -pub use settings::Settings; -pub use settings::example_config; - -pub mod settings; - -use tokio::net::TcpListener; -use tokio::signal; - -#[cfg(target_family = "unix")] -async fn shutdown_signal() { - let mut term = signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to register signal handler"); - let mut interrupt = signal::unix::signal(signal::unix::SignalKind::interrupt()) - .expect("failed to register signal handler"); - - tokio::select! { - _ = term.recv() => {}, - _ = interrupt.recv() => {}, - }; - eprintln!("Shutting down gracefully..."); -} - -pub async fn launch<Db: Database>(settings: Settings, addr: SocketAddr) -> Result<()> { - launch_with_tcp_listener::<Db>( - settings, - TcpListener::bind(addr) - .await - .context("could not connect to socket")?, - shutdown_signal(), - ) - .await -} - -pub async fn launch_with_tcp_listener<Db: Database>( - settings: Settings, - listener: TcpListener, - shutdown: impl Future<Output = ()> + Send + 'static, -) -> Result<()> { - let r = make_router::<Db>(settings).await?; - - serve(listener, r.into_make_service()) - .with_graceful_shutdown(shutdown) - .await?; - - Ok(()) -} - -// The separate listener means it's much easier to ensure metrics are not accidentally exposed to -// the public. -pub async fn launch_metrics_server(host: String, port: u16) -> Result<()> { - let listener = TcpListener::bind((host, port)) - .await - .context("failed to bind metrics tcp")?; - - let recorder_handle = metrics::setup_metrics_recorder(); - - let router = Router::new().route( - "/metrics", - axum::routing::get(move || std::future::ready(recorder_handle.render())), - ); - - serve(listener, router.into_make_service()) - .with_graceful_shutdown(shutdown_signal()) - .await?; - - Ok(()) -} - -async fn make_router<Db: Database>(settings: Settings) -> Result<Router, eyre::Error> { - let db = Db::new(&settings.db_settings) - .await - .wrap_err_with(|| format!("failed to connect to db: {:?}", settings.db_settings))?; - let r = router::router(db, settings); - Ok(r) -} diff --git a/crates/atuin-server/src/metrics.rs b/crates/atuin-server/src/metrics.rs deleted file mode 100644 index ebd0dd2d..00000000 --- a/crates/atuin-server/src/metrics.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::time::Instant; - -use axum::{ - extract::{MatchedPath, Request}, - middleware::Next, - response::IntoResponse, -}; -use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle}; - -pub fn setup_metrics_recorder() -> PrometheusHandle { - const EXPONENTIAL_SECONDS: &[f64] = &[ - 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, - ]; - - PrometheusBuilder::new() - .set_buckets_for_metric( - Matcher::Full("http_requests_duration_seconds".to_string()), - EXPONENTIAL_SECONDS, - ) - .unwrap() - .install_recorder() - .unwrap() -} - -/// Middleware to record some common HTTP metrics -/// Generic over B to allow for arbitrary body types (eg Vec<u8>, Streams, a deserialized thing, etc) -/// Someday tower-http might provide a metrics middleware: https://github.com/tower-rs/tower-http/issues/57 -pub async fn track_metrics(req: Request, next: Next) -> impl IntoResponse { - let start = Instant::now(); - - let path = match req.extensions().get::<MatchedPath>() { - Some(matched_path) => matched_path.as_str().to_owned(), - _ => req.uri().path().to_owned(), - }; - - let method = req.method().clone(); - - // Run the rest of the request handling first, so we can measure it and get response - // codes. - let response = next.run(req).await; - - let latency = start.elapsed().as_secs_f64(); - let status = response.status().as_u16().to_string(); - - let labels = [ - ("method", method.to_string()), - ("path", path), - ("status", status), - ]; - - metrics::counter!("http_requests_total", &labels).increment(1); - metrics::histogram!("http_requests_duration_seconds", &labels).record(latency); - - response -} diff --git a/crates/atuin-server/src/router.rs b/crates/atuin-server/src/router.rs deleted file mode 100644 index 2d679759..00000000 --- a/crates/atuin-server/src/router.rs +++ /dev/null @@ -1,155 +0,0 @@ -use atuin_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse}; -use axum::{ - Router, - extract::{FromRequestParts, Request}, - http::{self, request::Parts}, - middleware::Next, - response::{IntoResponse, Response}, - routing::{delete, get, patch, post}, -}; -use eyre::Result; -use tower::ServiceBuilder; -use tower_http::trace::TraceLayer; - -use super::handlers; -use crate::{ - handlers::{ErrorResponseStatus, RespExt}, - metrics, - settings::Settings, -}; -use atuin_server_database::{Database, DbError, models::User}; - -pub struct UserAuth(pub User); - -impl<DB: Send + Sync> FromRequestParts<AppState<DB>> for UserAuth -where - DB: Database, -{ - type Rejection = ErrorResponseStatus<'static>; - - async fn from_request_parts( - req: &mut Parts, - state: &AppState<DB>, - ) -> Result<Self, Self::Rejection> { - let auth_header = req - .headers - .get(http::header::AUTHORIZATION) - .ok_or_else(|| { - ErrorResponse::reply("missing authorization header") - .with_status(http::StatusCode::BAD_REQUEST) - })?; - let auth_header = auth_header.to_str().map_err(|_| { - ErrorResponse::reply("invalid authorization header encoding") - .with_status(http::StatusCode::BAD_REQUEST) - })?; - let (typ, token) = auth_header.split_once(' ').ok_or_else(|| { - ErrorResponse::reply("invalid authorization header encoding") - .with_status(http::StatusCode::BAD_REQUEST) - })?; - - if typ != "Token" { - return Err( - ErrorResponse::reply("invalid authorization header encoding") - .with_status(http::StatusCode::BAD_REQUEST), - ); - } - - let user = state - .database - .get_session_user(token) - .await - .map_err(|e| match e { - DbError::NotFound => ErrorResponse::reply("session not found") - .with_status(http::StatusCode::FORBIDDEN), - DbError::Other(e) => { - tracing::error!(error = ?e, "could not query user session"); - ErrorResponse::reply("could not query user session") - .with_status(http::StatusCode::INTERNAL_SERVER_ERROR) - } - })?; - - Ok(UserAuth(user)) - } -} - -async fn teapot() -> impl IntoResponse { - // This used to return 418: 🫖 - // Much as it was fun, it wasn't as useful or informative as it should be - (http::StatusCode::NOT_FOUND, "404 not found") -} - -async fn clacks_overhead(request: Request, next: Next) -> Response { - let mut response = next.run(request).await; - - let gnu_terry_value = "GNU Terry Pratchett, Kris Nova"; - let gnu_terry_header = "X-Clacks-Overhead"; - - response - .headers_mut() - .insert(gnu_terry_header, gnu_terry_value.parse().unwrap()); - response -} - -/// Ensure that we only try and sync with clients on the same major version -async fn semver(request: Request, next: Next) -> Response { - let mut response = next.run(request).await; - response - .headers_mut() - .insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse().unwrap()); - - response -} - -#[derive(Clone)] -pub struct AppState<DB: Database> { - pub database: DB, - pub settings: Settings, -} - -pub fn router<DB: Database>(database: DB, settings: Settings) -> Router { - let mut routes = Router::new() - .route("/", get(handlers::index)) - .route("/healthz", get(handlers::health::health_check)); - - // Sync v1 routes - can be disabled in favor of record-based sync - if settings.sync_v1_enabled { - routes = routes - .route("/sync/count", get(handlers::history::count)) - .route("/sync/history", get(handlers::history::list)) - .route("/sync/calendar/{focus}", get(handlers::history::calendar)) - .route("/sync/status", get(handlers::status::status)) - .route("/history", post(handlers::history::add)) - .route("/history", delete(handlers::history::delete)); - } - - let routes = routes - .route("/user/{username}", get(handlers::user::get)) - .route("/account", delete(handlers::user::delete)) - .route("/account/password", patch(handlers::user::change_password)) - .route("/register", post(handlers::user::register)) - .route("/login", post(handlers::user::login)) - .route("/record", post(handlers::record::post)) - .route("/record", get(handlers::record::index)) - .route("/record/next", get(handlers::record::next)) - .route("/api/v0/me", get(handlers::v0::me::get)) - .route("/api/v0/record", post(handlers::v0::record::post)) - .route("/api/v0/record", get(handlers::v0::record::index)) - .route("/api/v0/record/next", get(handlers::v0::record::next)) - .route("/api/v0/store", delete(handlers::v0::store::delete)); - - let path = settings.path.as_str(); - if path.is_empty() { - routes - } else { - Router::new().nest(path, routes) - } - .fallback(teapot) - .with_state(AppState { database, settings }) - .layer( - ServiceBuilder::new() - .layer(axum::middleware::from_fn(clacks_overhead)) - .layer(TraceLayer::new_for_http()) - .layer(axum::middleware::from_fn(metrics::track_metrics)) - .layer(axum::middleware::from_fn(semver)), - ) -} diff --git a/crates/atuin-server/src/settings.rs b/crates/atuin-server/src/settings.rs deleted file mode 100644 index 3a612be9..00000000 --- a/crates/atuin-server/src/settings.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::{io::prelude::*, path::PathBuf}; - -use atuin_server_database::DbSettings; -use config::{Config, Environment, File as ConfigFile, FileFormat}; -use eyre::{Result, eyre}; -use fs_err::{File, create_dir_all}; -use serde::{Deserialize, Serialize}; - -static EXAMPLE_CONFIG: &str = include_str!("../server.toml"); - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Metrics { - #[serde(alias = "enabled")] - pub enable: bool, - pub host: String, - pub port: u16, -} - -impl Default for Metrics { - fn default() -> Self { - Self { - enable: false, - host: String::from("127.0.0.1"), - port: 9001, - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Settings { - pub host: String, - pub port: u16, - pub path: String, - pub open_registration: bool, - pub max_history_length: usize, - pub max_record_size: usize, - pub page_size: i64, - pub register_webhook_url: Option<String>, - pub register_webhook_username: String, - pub metrics: Metrics, - - /// Enable legacy sync v1 routes (history-based sync) - /// Set to false to use only the newer record-based sync - pub sync_v1_enabled: bool, - - /// Advertise a version that is not what we are _actually_ running - /// Many clients compare their version with api.atuin.sh, and if they differ, notify the user - /// that an update is available. - /// Now that we take beta releases, we should be able to advertise a different version to avoid - /// notifying users when the server runs something that is not a stable release. - pub fake_version: Option<String>, - - #[serde(flatten)] - pub db_settings: DbSettings, -} - -impl Settings { - pub fn new() -> Result<Self> { - let mut config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") { - PathBuf::from(p) - } else { - let mut config_file = PathBuf::new(); - let config_dir = atuin_common::utils::config_dir(); - config_file.push(config_dir); - config_file - }; - - config_file.push("server.toml"); - - // create the config file if it does not exist - let mut config_builder = Config::builder() - .set_default("host", "127.0.0.1")? - .set_default("port", 8888)? - .set_default("open_registration", false)? - .set_default("max_history_length", 8192)? - .set_default("max_record_size", 1024 * 1024 * 1024)? // pretty chonky - .set_default("path", "")? - .set_default("register_webhook_username", "")? - .set_default("page_size", 1100)? - .set_default("metrics.enable", false)? - .set_default("metrics.host", "127.0.0.1")? - .set_default("metrics.port", 9001)? - .set_default("sync_v1_enabled", true)? - .add_source( - Environment::with_prefix("atuin") - .prefix_separator("_") - .separator("__"), - ); - - config_builder = if config_file.exists() { - config_builder.add_source(ConfigFile::new( - config_file.to_str().unwrap(), - FileFormat::Toml, - )) - } else { - create_dir_all(config_file.parent().unwrap())?; - let mut file = File::create(config_file)?; - file.write_all(EXAMPLE_CONFIG.as_bytes())?; - - config_builder - }; - - let config = config_builder.build()?; - - config - .try_deserialize() - .map_err(|e| eyre!("failed to deserialize: {}", e)) - } -} - -pub fn example_config() -> &'static str { - EXAMPLE_CONFIG -} diff --git a/crates/atuin-server/src/utils.rs b/crates/atuin-server/src/utils.rs deleted file mode 100644 index 12e9ac1b..00000000 --- a/crates/atuin-server/src/utils.rs +++ /dev/null @@ -1,15 +0,0 @@ -use eyre::Result; -use semver::{Version, VersionReq}; - -pub fn client_version_min(user_agent: &str, req: &str) -> Result<bool> { - if user_agent.is_empty() { - return Ok(false); - } - - let version = user_agent.replace("atuin/", ""); - - let req = VersionReq::parse(req)?; - let version = Version::parse(version.as_str())?; - - Ok(req.matches(&version)) -} |
