aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/atuin_server/handlers
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 18:02:55 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 18:02:55 +0200
commit0b6ca5cb8ca4c46265e08e13053260d9b5cff568 (patch)
tree9dc656095f806e6dd1177e40b9a87cf6d6f10f1b /crates/turtle/src/atuin_server/handlers
parentchore(server): Remove the last remnants of the "hub" sync-server thingy (diff)
downloadatuin-0b6ca5cb8ca4c46265e08e13053260d9b5cff568.zip
feat(server): Make user stuff stateless
Diffstat (limited to 'crates/turtle/src/atuin_server/handlers')
-rw-r--r--crates/turtle/src/atuin_server/handlers/health.rs15
-rw-r--r--crates/turtle/src/atuin_server/handlers/mod.rs5
-rw-r--r--crates/turtle/src/atuin_server/handlers/record.rs42
-rw-r--r--crates/turtle/src/atuin_server/handlers/user.rs267
-rw-r--r--crates/turtle/src/atuin_server/handlers/v0/me.rs16
-rw-r--r--crates/turtle/src/atuin_server/handlers/v0/mod.rs2
-rw-r--r--crates/turtle/src/atuin_server/handlers/v0/record.rs12
-rw-r--r--crates/turtle/src/atuin_server/handlers/v0/store.rs36
8 files changed, 7 insertions, 388 deletions
diff --git a/crates/turtle/src/atuin_server/handlers/health.rs b/crates/turtle/src/atuin_server/handlers/health.rs
deleted file mode 100644
index d39f7aa5..00000000
--- a/crates/turtle/src/atuin_server/handlers/health.rs
+++ /dev/null
@@ -1,15 +0,0 @@
-use axum::{Json, http, response::IntoResponse};
-
-use serde::Serialize;
-
-#[derive(Serialize)]
-pub(crate) struct HealthResponse {
- pub(crate) status: &'static str,
-}
-
-pub(crate) async fn health_check() -> impl IntoResponse {
- (
- http::StatusCode::OK,
- Json(HealthResponse { status: "healthy" }),
- )
-}
diff --git a/crates/turtle/src/atuin_server/handlers/mod.rs b/crates/turtle/src/atuin_server/handlers/mod.rs
index 3b935834..7aded3de 100644
--- a/crates/turtle/src/atuin_server/handlers/mod.rs
+++ b/crates/turtle/src/atuin_server/handlers/mod.rs
@@ -3,9 +3,6 @@ use axum::{Json, extract::State, http, response::IntoResponse};
use crate::atuin_server::router::AppState;
-pub(crate) mod health;
-pub(crate) mod record;
-pub(crate) mod user;
pub(crate) mod v0;
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -17,7 +14,7 @@ pub(crate) async fn index(state: State<AppState>) -> Json<IndexResponse> {
.settings
.fake_version
.clone()
- .unwrap_or(VERSION.to_string());
+ .unwrap_or_else(|| VERSION.to_string());
Json(IndexResponse {
homage: homage.to_string(),
diff --git a/crates/turtle/src/atuin_server/handlers/record.rs b/crates/turtle/src/atuin_server/handlers/record.rs
deleted file mode 100644
index 39060423..00000000
--- a/crates/turtle/src/atuin_server/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::atuin_server::router::UserAuth;
-
-use crate::atuin_common::record::{EncryptedData, Record};
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) 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(crate) 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(crate) async fn next(
- UserAuth(user): UserAuth,
-) -> Result<Json<Vec<Record<EncryptedData>>>, ErrorResponseStatus<'static>> {
- let records = Vec::new();
-
- Ok(Json(records))
-}
diff --git a/crates/turtle/src/atuin_server/handlers/user.rs b/crates/turtle/src/atuin_server/handlers/user.rs
deleted file mode 100644
index e777acc3..00000000
--- a/crates/turtle/src/atuin_server/handlers/user.rs
+++ /dev/null
@@ -1,267 +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 crate::{
- atuin_common::tls::ensure_crypto_provider,
- atuin_server::database::{
- DbError,
- models::{NewSession, NewUser},
- },
-};
-
-use super::{ErrorResponse, ErrorResponseStatus, RespExt};
-use crate::atuin_server::router::{AppState, UserAuth};
-
-use reqwest::header::CONTENT_TYPE;
-
-use crate::atuin_common::{api::*, utils::crypto_random_string};
-
-pub(crate) 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(crate) async fn get(
- Path(username): Path<String>,
- state: State<AppState>,
-) -> 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(crate) async fn register(
- state: State<AppState>,
- 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)
- );
- }
- };
-
- // 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 })),
- 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(crate) async fn delete(
- UserAuth(user): UserAuth,
- state: State<AppState>,
-) -> 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(crate) async fn change_password(
- UserAuth(mut user): UserAuth,
- state: State<AppState>,
- 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(crate) async fn login(
- state: State<AppState>,
- 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,
- }))
-}
-
-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/turtle/src/atuin_server/handlers/v0/me.rs b/crates/turtle/src/atuin_server/handlers/v0/me.rs
deleted file mode 100644
index 1f5f5016..00000000
--- a/crates/turtle/src/atuin_server/handlers/v0/me.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-use axum::Json;
-use tracing::instrument;
-
-use crate::atuin_server::handlers::ErrorResponseStatus;
-use crate::atuin_server::router::UserAuth;
-
-use crate::atuin_common::api::*;
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn get(
- UserAuth(user): UserAuth,
-) -> Result<Json<MeResponse>, ErrorResponseStatus<'static>> {
- Ok(Json(MeResponse {
- username: user.username,
- }))
-}
diff --git a/crates/turtle/src/atuin_server/handlers/v0/mod.rs b/crates/turtle/src/atuin_server/handlers/v0/mod.rs
index d6f880f2..78fb47b8 100644
--- a/crates/turtle/src/atuin_server/handlers/v0/mod.rs
+++ b/crates/turtle/src/atuin_server/handlers/v0/mod.rs
@@ -1,3 +1 @@
-pub(crate) mod me;
pub(crate) mod record;
-pub(crate) mod store;
diff --git a/crates/turtle/src/atuin_server/handlers/v0/record.rs b/crates/turtle/src/atuin_server/handlers/v0/record.rs
index 88027547..9350e1c8 100644
--- a/crates/turtle/src/atuin_server/handlers/v0/record.rs
+++ b/crates/turtle/src/atuin_server/handlers/v0/record.rs
@@ -10,7 +10,7 @@ use crate::atuin_server::{
use crate::atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
-#[instrument(skip_all, fields(user.id = user.id))]
+#[instrument(skip_all, fields(user.id = user.id.to_string()))]
pub(crate) async fn post(
UserAuth(user): UserAuth,
state: State<AppState>,
@@ -20,7 +20,7 @@ pub(crate) async fn post(
tracing::debug!(
count = records.len(),
- user = user.username,
+ user = user.id.to_string(),
"request to add records"
);
@@ -44,12 +44,12 @@ pub(crate) async fn post(
return Err(ErrorResponse::reply("failed to add record")
.with_status(StatusCode::INTERNAL_SERVER_ERROR));
- };
+ }
Ok(())
}
-#[instrument(skip_all, fields(user.id = user.id))]
+#[instrument(skip_all, fields(user.id = user.id.to_string()))]
pub(crate) async fn index(
UserAuth(user): UserAuth,
state: State<AppState>,
@@ -69,7 +69,7 @@ pub(crate) async fn index(
}
};
- tracing::debug!(user = user.username, "record index request");
+ tracing::debug!(user = user.id.to_string(), "record index request");
Ok(Json(record_index))
}
@@ -82,7 +82,7 @@ pub(crate) struct NextParams {
count: u64,
}
-#[instrument(skip_all, fields(user.id = user.id))]
+#[instrument(skip_all, fields(user.id = user.id.to_string()))]
pub(crate) async fn next(
params: Query<NextParams>,
UserAuth(user): UserAuth,
diff --git a/crates/turtle/src/atuin_server/handlers/v0/store.rs b/crates/turtle/src/atuin_server/handlers/v0/store.rs
deleted file mode 100644
index f0aa1b36..00000000
--- a/crates/turtle/src/atuin_server/handlers/v0/store.rs
+++ /dev/null
@@ -1,36 +0,0 @@
-use axum::{extract::Query, extract::State, http::StatusCode};
-use metrics::counter;
-use serde::Deserialize;
-use tracing::{error, instrument};
-
-use crate::atuin_server::{
- handlers::{ErrorResponse, ErrorResponseStatus, RespExt},
- router::{AppState, UserAuth},
-};
-
-#[derive(Deserialize)]
-pub(crate) struct DeleteParams {}
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn delete(
- _params: Query<DeleteParams>,
- UserAuth(user): UserAuth,
- state: State<AppState>,
-) -> 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(())
-}