aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/atuin_server/router.rs
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 00:54:30 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 00:54:30 +0200
commit5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8 (patch)
treec64baa8d5866c8e339eaf660dd3f94f30a3f7d8a /crates/turtle/src/atuin_server/router.rs
parentchore: Somewhat simplify sync code (diff)
downloadatuin-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/turtle/src/atuin_server/router.rs')
-rw-r--r--crates/turtle/src/atuin_server/router.rs155
1 files changed, 155 insertions, 0 deletions
diff --git a/crates/turtle/src/atuin_server/router.rs b/crates/turtle/src/atuin_server/router.rs
new file mode 100644
index 00000000..11a16148
--- /dev/null
+++ b/crates/turtle/src/atuin_server/router.rs
@@ -0,0 +1,155 @@
+use crate::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::atuin_server::{
+ handlers::{ErrorResponseStatus, RespExt},
+ metrics,
+ settings::Settings,
+};
+use crate::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)),
+ )
+}