summaryrefslogtreecommitdiffstats
path: root/crates/atuin-server
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-server')
-rw-r--r--crates/atuin-server/Cargo.toml39
-rw-r--r--crates/atuin-server/server.toml34
-rw-r--r--crates/atuin-server/src/handlers/history.rs237
-rw-r--r--crates/atuin-server/src/handlers/mod.rs58
-rw-r--r--crates/atuin-server/src/handlers/record.rs45
-rw-r--r--crates/atuin-server/src/handlers/status.rs43
-rw-r--r--crates/atuin-server/src/handlers/user.rs258
-rw-r--r--crates/atuin-server/src/handlers/v0/me.rs16
-rw-r--r--crates/atuin-server/src/handlers/v0/mod.rs3
-rw-r--r--crates/atuin-server/src/handlers/v0/record.rs112
-rw-r--r--crates/atuin-server/src/handlers/v0/store.rs37
-rw-r--r--crates/atuin-server/src/lib.rs144
-rw-r--r--crates/atuin-server/src/metrics.rs56
-rw-r--r--crates/atuin-server/src/router.rs149
-rw-r--r--crates/atuin-server/src/settings.rs151
-rw-r--r--crates/atuin-server/src/utils.rs15
16 files changed, 1397 insertions, 0 deletions
diff --git a/crates/atuin-server/Cargo.toml b/crates/atuin-server/Cargo.toml
new file mode 100644
index 00000000..a6b8a9f6
--- /dev/null
+++ b/crates/atuin-server/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+name = "atuin-server"
+edition = "2021"
+description = "server library for atuin"
+
+rust-version = { workspace = true }
+version = { workspace = true }
+authors = { workspace = true }
+license = { workspace = true }
+homepage = { workspace = true }
+repository = { workspace = true }
+
+[dependencies]
+atuin-common = { path = "../atuin-common", version = "18.2.0" }
+atuin-server-database = { path = "../atuin-server-database", version = "18.2.0" }
+
+tracing = "0.1"
+time = { workspace = true }
+eyre = { workspace = true }
+uuid = { workspace = true }
+config = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+base64 = { workspace = true }
+rand = { workspace = true }
+tokio = { workspace = true }
+async-trait = { workspace = true }
+axum = "0.7.4"
+axum-server = { version = "0.6.0", features = ["tls-rustls"] }
+fs-err = { workspace = true }
+tower = "0.4"
+tower-http = { version = "0.5.1", features = ["trace"] }
+reqwest = { workspace = true }
+rustls = "0.21"
+rustls-pemfile = "2.1"
+argon2 = "0.5.3"
+semver = { workspace = true }
+metrics-exporter-prometheus = "0.12.1"
+metrics = "0.21.1"
diff --git a/crates/atuin-server/server.toml b/crates/atuin-server/server.toml
new file mode 100644
index 00000000..946769c9
--- /dev/null
+++ b/crates/atuin-server/server.toml
@@ -0,0 +1,34 @@
+## 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"
+
+## 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
+
+# [tls]
+# enable = false
+# cert_path = ""
+# pkey_path = ""
diff --git a/crates/atuin-server/src/handlers/history.rs b/crates/atuin-server/src/handlers/history.rs
new file mode 100644
index 00000000..05bbe740
--- /dev/null
+++ b/crates/atuin-server/src/handlers/history.rs
@@ -0,0 +1,237 @@
+use std::{collections::HashMap, convert::TryFrom};
+
+use axum::{
+ extract::{Path, Query, State},
+ http::{HeaderMap, StatusCode},
+ Json,
+};
+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::{
+ calendar::{TimePeriod, TimePeriodInfo},
+ models::NewHistory,
+ Database,
+};
+
+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", 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", 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", 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", 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
new file mode 100644
index 00000000..50f82336
--- /dev/null
+++ b/crates/atuin-server/src/handlers/mod.rs
@@ -0,0 +1,58 @@
+use atuin_common::api::{ErrorResponse, IndexResponse};
+use atuin_server_database::Database;
+use axum::{extract::State, http, response::IntoResponse, Json};
+
+use crate::router::AppState;
+
+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"#;
+
+ // Error with a -1 response
+ // It's super unlikely this will happen
+ let count = state.database.total_history().await.unwrap_or(-1);
+
+ Json(IndexResponse {
+ homage: homage.to_string(),
+ version: VERSION.to_string(),
+ total_history: count,
+ })
+}
+
+impl<'a> IntoResponse for ErrorResponseStatus<'a> {
+ 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 {
+ Self {
+ reason: reason.into(),
+ }
+ }
+}
diff --git a/crates/atuin-server/src/handlers/record.rs b/crates/atuin-server/src/handlers/record.rs
new file mode 100644
index 00000000..bf454949
--- /dev/null
+++ b/crates/atuin-server/src/handlers/record.rs
@@ -0,0 +1,45 @@
+use axum::{http::StatusCode, response::IntoResponse, Json};
+use serde_json::json;
+use tracing::instrument;
+
+use super::{ErrorResponse, ErrorResponseStatus, RespExt};
+use crate::router::UserAuth;
+use atuin_server_database::Database;
+
+use atuin_common::record::{EncryptedData, Record};
+
+#[instrument(skip_all, fields(user.id = user.id))]
+pub async fn post<DB: Database>(
+ 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<DB: Database>(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
new file mode 100644
index 00000000..3c22232c
--- /dev/null
+++ b/crates/atuin-server/src/handlers/status.rs
@@ -0,0 +1,43 @@
+use axum::{extract::State, http::StatusCode, Json};
+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))
+ }
+ },
+ };
+
+ 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
new file mode 100644
index 00000000..e5651fe2
--- /dev/null
+++ b/crates/atuin-server/src/handlers/user.rs
@@ -0,0 +1,258 @@
+use std::borrow::Borrow;
+use std::collections::HashMap;
+use std::time::Duration;
+
+use argon2::{
+ password_hash::SaltString, Algorithm, Argon2, Params, PasswordHash, PasswordHasher,
+ PasswordVerifier, Version,
+};
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ Json,
+};
+use metrics::counter;
+use rand::rngs::OsRng;
+use tracing::{debug, error, info, instrument};
+use uuid::Uuid;
+
+use super::{ErrorResponse, ErrorResponseStatus, RespExt};
+use crate::router::{AppState, UserAuth};
+use atuin_server_database::{
+ models::{NewSession, NewUser},
+ Database, DbError,
+};
+
+use reqwest::header::CONTENT_TYPE;
+
+use atuin_common::api::*;
+
+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) {
+ 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(&register.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)
+ );
+ }
+ };
+
+ let token = Uuid::new_v4().as_simple().to_string();
+
+ 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", 1);
+
+ match db.add_session(&new_session).await {
+ Ok(_) => Ok(Json(RegisterResponse { session: token })),
+ 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", 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 {
+ return Err(
+ ErrorResponse::reply("password is not correct").with_status(StatusCode::UNAUTHORIZED)
+ );
+ }
+
+ Ok(Json(LoginResponse {
+ session: session.token,
+ }))
+}
+
+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
new file mode 100644
index 00000000..7960b479
--- /dev/null
+++ b/crates/atuin-server/src/handlers/v0/me.rs
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 00000000..d6f880f2
--- /dev/null
+++ b/crates/atuin-server/src/handlers/v0/mod.rs
@@ -0,0 +1,3 @@
+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
new file mode 100644
index 00000000..321c34c2
--- /dev/null
+++ b/crates/atuin-server/src/handlers/v0/record.rs
@@ -0,0 +1,112 @@
+use axum::{extract::Query, extract::State, http::StatusCode, Json};
+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", 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", 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));
+ }
+ };
+
+ 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", 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
new file mode 100644
index 00000000..941f2487
--- /dev/null
+++ b/crates/atuin-server/src/handlers/v0/store.rs
@@ -0,0 +1,37 @@
+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