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::{display_name, hash_password, validate_account_name, validate_password}; use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie}; use crate::db::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, })); } let user = require_user(&state, &headers).await?; Ok(Json(me_for(&state, &user).await?)) } /// The signed-in user behind the session cookie, or 401. async fn require_user(state: &AppState, headers: &HeaderMap) -> Result { let Some(token) = parse_session_cookie(headers) else { return Err(ApiError::localized( StatusCode::UNAUTHORIZED, "not signed in", "err_not_signed_in", )); }; state.db.session_user(&token).await?.ok_or_else(|| { ApiError::localized( StatusCode::UNAUTHORIZED, "session expired, please sign in again", "err_session_expired", ) }) } /// Build the `/api/auth/me` payload for an authenticated user. async fn me_for(state: &AppState, user: &User) -> Result { let roots: Vec = state .db .user_roots(user.id) .await? .into_iter() .map(|r| RootInfo { id: r.id, name: display_name(&state.root, &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, language: user.language.clone(), }), roots, allow_writable_shares: state.db.allow_writable_shares().await?, }) } /// 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, #[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>, headers: HeaderMap, Json(body): Json, ) -> Result, ApiError> { let mut user = require_user(&state, &headers).await?; 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(lang) = body.language { state.db.set_user_language(user.id, lang.as_deref()).await?; user.language = lang; } Ok(Json(me_for(&state, &user).await?)) } /// 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)?; if state.db.user_count().await? > 0 { return Err(ApiError::localized( StatusCode::CONFLICT, "server is already set up", "err_already_set_up", )); } let pass_hash = hash_password(&body.password).await?; let user = state.db.create_admin(name, &pass_hash).await?; let token = auth::random_token(); state.db.create_session(user.id, &token).await?; let mut res = Json(OkResp { ok: true }).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 { ok: true }).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 { ok: true }).into_response(); res.headers_mut().insert( header::SET_COOKIE, clear_session_cookie(state.https).parse().unwrap(), ); res }