use std::sync::Arc; use api_types::{Credentials, Me, OkResp, RootInfo, UserInfo}; use axum::Json; use axum::extract::State; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; use crate::api::common::{ SessionUser, display_name, hash_password, session_auth, validate_account_name, validate_password, }; use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie}; use crate::db::{RootRow, User}; use crate::error::{ApiError, AppState}; /// GET /api/auth/me /// /// - No users at all → `200 {"first_boot": true}` /// - No/invalid session → `401` /// - Valid session → user info + visible roots pub async fn me( State(state): State>, headers: HeaderMap, ) -> Result, ApiError> { if state.db.user_count().await? == 0 { return Ok(Json(Me { first_boot: true, user: None, roots: Vec::new(), allow_writable_shares: false, thumbnails_available: state.thumbs.is_some(), })); } let (user, roots) = session_auth(&headers, &state).await?; Ok(Json(me_for(&state, &user, roots).await?)) } /// Build the `/api/auth/me` payload for an authenticated user. async fn me_for(state: &AppState, user: &User, roots: Vec) -> Result { let roots: Vec = roots .into_iter() .map(|r| RootInfo { id: r.id, name: display_name(state, &r.path), path: r.path, mode: r.mode, }) .collect(); Ok(Me { first_boot: false, user: Some(UserInfo { id: user.id, name: user.name.clone(), is_admin: user.is_admin, single_click_open: user.single_click, thumbnails: user.thumbnails, language: user.language.clone(), }), roots, allow_writable_shares: state.db.allow_writable_shares().await?, thumbnails_available: state.thumbs.is_some(), }) } /// PUT /api/auth/me /// /// Update the signed-in user's profile settings. Each field is optional; /// omitted fields are left untouched. Returns the fresh `/me` payload so /// clients can apply the change immediately. #[derive(Deserialize)] pub(crate) struct ProfilePatch { #[serde(default)] pub single_click_open: Option, pub thumbnails: Option, #[serde(default, deserialize_with = "language_field")] pub language: Option>, } /// Deserializes `ProfilePatch::language` into the three-state patch value: /// `"de"` → `Some(Some("de"))`, `null` → `Some(None)` (a missing field /// never calls this and stays `None` via `#[serde(default)]`). The inner /// `Option` already maps `null` → `None` and `"de"` → `Some`, so /// only the outer wrap is custom. fn language_field<'de, D>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, { serde::Deserialize::deserialize(deserializer).map(Some) } /// A language tag we are willing to store: short, ASCII letters/digits and /// `-`/`_` (BCP-47 style). Checked at the trust boundary so a raw API /// client cannot write arbitrary blobs into the DB. fn valid_language(tag: &str) -> bool { !tag.is_empty() && tag.len() <= 12 && tag .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } pub async fn update_profile( State(state): State>, SessionUser { mut user, roots }: SessionUser, Json(body): Json, ) -> Result, ApiError> { if let Some(Some(ref tag)) = body.language && !valid_language(tag) { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "invalid language tag", "err_invalid_language", )); } if let Some(v) = body.single_click_open { state.db.set_user_single_click(user.id, v).await?; user.single_click = v; } if let Some(v) = body.thumbnails { state.db.set_user_thumbnails(user.id, v).await?; user.thumbnails = v; } if let Some(lang) = body.language { state.db.set_user_language(user.id, lang.as_deref()).await?; user.language = lang; } Ok(Json(me_for(&state, &user, roots).await?)) } fn already_set_up() -> ApiError { ApiError::localized( StatusCode::CONFLICT, "server is already set up", "err_already_set_up", ) } /// POST /api/auth/setup — create the first admin account. /// Only available while no users exist. pub async fn setup( State(state): State>, Json(body): Json, ) -> Result { let name = body.name.trim(); validate_account_name(name)?; validate_password(&body.password)?; // A cheap pre-check: it keeps a POST to an already-configured server from // paying for an Argon2 hash. `create_admin` re-checks atomically. if state.db.user_count().await? > 0 { return Err(already_set_up()); } let pass_hash = hash_password(&body.password).await?; // `None` = another setup request won the race between the check above and // this insert. let Some(user) = state.db.create_admin(name, &pass_hash).await? else { return Err(already_set_up()); }; let token = auth::random_token(); state.db.create_session(user.id, &token).await?; let mut res = Json(OkResp {}).into_response(); res.headers_mut().insert( header::SET_COOKIE, session_cookie(&token, state.https).parse().unwrap(), ); Ok(res) } /// POST /api/auth/login pub async fn login( State(state): State>, Json(body): Json, ) -> Result { // Online guessing gets slower per failed attempt on this name. let delay = auth::login_delay(&body.name); if !delay.is_zero() { tokio::time::sleep(delay).await; } let verified = state.db.verify_password(&body.name, &body.password).await?; auth::record_login(&body.name, verified.is_some()); let Some(user) = verified else { return Err(ApiError::localized( StatusCode::UNAUTHORIZED, "invalid name or password", "err_invalid_credentials", )); }; let token = auth::random_token(); state.db.create_session(user.id, &token).await?; let mut res = Json(OkResp {}).into_response(); res.headers_mut().insert( header::SET_COOKIE, session_cookie(&token, state.https).parse().unwrap(), ); Ok(res) } /// POST /api/auth/logout pub async fn logout(State(state): State>, headers: HeaderMap) -> Response { if let Some(token) = parse_session_cookie(&headers) { let _ = state.db.delete_session(&token).await; } let mut res = Json(OkResp {}).into_response(); res.headers_mut().insert( header::SET_COOKIE, clear_session_cookie(state.https).parse().unwrap(), ); res }