use std::sync::Arc; use api_types::{AuthMode, Credentials, LoginReq, LoginResp, Me, OkResp, RootInfo, UserInfo}; use axum::Json; use axum::extract::State; use axum::http::{HeaderMap, StatusCode, Uri, 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(), public_url: state.public_url.clone(), })); } 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(), // A removed root leaves a stale id behind; the client never // sees it. default_root_id: user .default_root_id .filter(|id| roots.iter().any(|r| r.id == *id)), auth_mode: user.auth_mode, has_password: user.has_password, }), roots, allow_writable_shares: state.db.allow_writable_shares().await?, thumbnails_available: state.thumbs.is_some(), public_url: state.public_url.clone(), }) } /// 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 = "patch_field")] pub language: Option>, /// `null` clears the default root (back to the root picker). #[serde(default, deserialize_with = "patch_field")] pub default_root_id: Option>, } /// Deserializes a nullable patch field into the three-state 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 a value → `Some`, so only /// the outer wrap is custom. fn patch_field<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, T: serde::Deserialize<'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; } if let Some(root_id) = body.default_root_id { if let Some(id) = root_id && !roots.iter().any(|r| r.id == id) { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "not one of your folders", "err_invalid_default_root", )); } state.db.set_user_default_root(user.id, root_id).await?; user.default_root_id = root_id; } 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 — the password leg of signing in. /// /// A correct password signs in, unless the account also requires a passkey: /// then a challenge comes back instead of a session. A wrong password gets /// the same error either way. /// /// `state_id` is the other order. A passkey sign-in that landed on an account /// requiring both legs parks the identified user under that handle, so this /// route already knows who is asking and only needs the password. pub async fn login( State(state): State>, uri: Uri, headers: HeaderMap, Json(body): Json, ) -> Result { let name = match &body.state_id { Some(state_id) => { let Some(crate::webauthn::Pending::NeedsPassword { user_id }) = crate::webauthn::take(state_id) else { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "that took too long, please try again", "err_challenge_expired", )); }; match state.db.find_user_by_id(user_id).await? { Some(u) => u.name, None => return Err(invalid_credentials()), } } None => match body.name.as_deref().map(str::trim) { Some(n) if !n.is_empty() => n.to_string(), _ => return Err(invalid_credentials()), }, }; // Online guessing gets slower per failed attempt on this name. let delay = auth::login_delay(&name); if !delay.is_zero() { tokio::time::sleep(delay).await; } let verified = state.db.verify_password(&name, &body.password).await?; auth::record_login(&name, verified.is_some()); let Some(user) = verified else { return Err(invalid_credentials()); }; // A passkey is also required, and this request did not come from one. if user.auth_mode == AuthMode::Both && body.state_id.is_none() { return second_factor(&state, &user, &uri, &headers).await; } crate::api::passkeys::sign_in(&state, user.id).await } fn invalid_credentials() -> ApiError { ApiError::localized( StatusCode::UNAUTHORIZED, "invalid name or password", "err_invalid_credentials", ) } /// The password passed; ask for the passkey that must follow it. /// /// The relying party is built here rather than taken as an extractor. It needs /// a domain name, and most sign-ins do not need it at all — an extractor on /// `login` would fail every sign-in on a server reached by bare IP. async fn second_factor( state: &AppState, user: &crate::db::User, uri: &Uri, headers: &HeaderMap, ) -> Result { let rp = crate::webauthn::relying_party(state, uri, headers)?; let keys: Vec = crate::api::passkeys::load_passkeys(state, user.id) .await? .into_iter() .map(|(_, k)| k) .collect(); // Only reachable if every stored passkey became unreadable: the mode // cannot be set without one, and the last one cannot be deleted under it. if keys.is_empty() { return Err(ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "this account requires a passkey but has none", )); } let (options, auth) = rp.start_passkey_authentication(&keys).map_err(|e| { tracing::warn!(error = ?e, "cannot start the second factor"); ApiError::localized( StatusCode::INTERNAL_SERVER_ERROR, "that passkey could not be used", "err_passkey_failed", ) })?; let challenge = crate::api::passkeys::challenge( crate::webauthn::Pending::Authenticate { user_id: user.id, state: Box::new(auth), second_factor: true, }, &options, )?; Ok(Json(LoginResp { ok: false, passkey_challenge: Some(challenge), ..Default::default() }) .into_response()) } /// 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 }