//! Authenticated-user extractor shared by all protected API routes. use std::sync::Arc; use axum::extract::FromRequestParts; use axum::http::StatusCode; use axum::http::request::Parts; use crate::auth::parse_session_cookie; use crate::db::{RootRow, ShareRow, User}; use crate::error::{ApiError, AppState}; /// Authorization for the file API: which roots the caller may touch. /// /// Deliberately carries no [`User`]. A share visitor is anonymous, so there /// is no identity to expose here. A handler that trusted a user id from this /// extractor would treat a share visitor as the share's creator. pub struct AuthUser { pub roots: Vec, /// Present when authenticated via a public share token. The single entry /// in `roots` is the shared item (its path is the share's `target`), so all /// file operations are scoped to it. pub share: Option, } impl FromRequestParts> for AuthUser { type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &Arc, ) -> Result { // 1. Public share token (`?share=` or `X-Share-Token`). Checked // first: a request that explicitly carries a share token is a share // request, even if a session is also present (so a signed-in user // viewing a share link sees the shared scope). if let Some(share_token) = share_token_from_request(parts) { let share = live_share(state, &share_token).await?; // The password guards the files, not just the share page. A check // only on resolve would leave the file API open to anyone holding // the link. if share_is_locked(state, &share, &parts.headers).await? { return Err(crate::api::shares::locked_error()); } let roots = vec![RootRow { id: share.id, path: share.target.clone(), mode: share.mode, }]; return Ok(AuthUser { roots, share: Some(share), }); } // 2. Signed-in session. Admins also get the whole server root, // read-only, under `ADMIN_ROOT` (the folder picker in user // management browses it). let (user, mut roots) = session_auth(&parts.headers, state).await?; if user.is_admin { roots.push(RootRow { id: api_types::ADMIN_ROOT, path: ".".to_string(), mode: api_types::Mode::Ro, }); } Ok(AuthUser { roots, share: None }) } } /// Extractor for routes that must never be reachable with a share token: /// share management and admin. /// /// A share token only proves that the caller holds a share link. It says /// nothing about *who* the caller is, so it must not stand in for the share /// creator's identity. Requests that carry one are rejected outright instead /// of silently falling back to the session, so a share visitor cannot act as /// the creator by also having a cookie. pub struct SessionUser { pub user: User, pub roots: Vec, } impl FromRequestParts> for SessionUser { type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &Arc, ) -> Result { if share_token_from_request(parts).is_some() { return Err(ApiError::localized( StatusCode::FORBIDDEN, "a share token cannot be used here; sign in instead", "err_share_token_forbidden", )); } let (user, roots) = session_auth(&parts.headers, state).await?; Ok(SessionUser { user, roots }) } } /// Authenticate via the session cookie only, returning the user and roots. pub(crate) async fn session_auth( headers: &axum::http::HeaderMap, state: &AppState, ) -> Result<(User, Vec), ApiError> { let Some(token) = parse_session_cookie(headers) else { return Err(ApiError::localized( StatusCode::UNAUTHORIZED, "not signed in", "err_not_signed_in", )); }; let Some((user, roots)) = state.db.session_user_with_roots(&token).await? else { return Err(ApiError::localized( StatusCode::UNAUTHORIZED, "session expired, please sign in again", "err_session_expired", )); }; Ok((user, roots)) } /// Whether `share` still needs its password entered by this caller. pub(crate) async fn share_is_locked( state: &AppState, share: &ShareRow, headers: &axum::http::HeaderMap, ) -> Result { if share.password_hash.is_none() { return Ok(false); } let Some(unlock) = crate::auth::parse_share_cookie(headers, share.id) else { return Ok(true); }; Ok(!state.db.share_unlock_valid(&unlock, share.id).await?) } /// A share that exists and has not expired. pub(crate) async fn live_share(state: &AppState, token: &str) -> Result { match state.db.share_by_token(token).await? { Some(share) if !share.is_expired() => Ok(share), Some(_) => Err(ApiError::localized( StatusCode::GONE, "this share has expired", "err_share_expired", )), None => Err(ApiError::localized( StatusCode::NOT_FOUND, "share not found", "err_share_not_found", )), } } /// The field name is [`api_types::P_SHARE`]; `tests::share_query_field_is_p_share` /// pins it. #[derive(serde::Deserialize)] struct ShareQuery { share: Option, } /// Extract the share token from a request, if present: a `?share=` /// query param or an `X-Share-Token` header. fn share_token_from_request(parts: &Parts) -> Option { let query = axum::extract::Query::::try_from_uri(&parts.uri) .ok() .and_then(|q| q.0.share) .filter(|s| !s.is_empty()); if query.is_some() { return query; } parts .headers .get("x-share-token") .and_then(|v| v.to_str().ok()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) } // --------------------------------------------------------------------------- // Shared validation / naming helpers // --------------------------------------------------------------------------- /// UI name of a root or share target `rel` (relative to the server root): /// its last path component, or the configured root name for `.`. pub(crate) fn display_name(state: &AppState, rel: &str) -> String { if rel == "." { return state.root_name.clone(); } std::path::Path::new(rel) .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| rel.to_string()) } pub(crate) fn root_info(state: &AppState, r: &RootRow) -> api_types::RootInfo { api_types::RootInfo { id: r.id, name: display_name(state, &r.path), path: r.path.clone(), mode: r.mode, } } /// A resolved absolute path re-expressed relative to the server root — the /// form `shares.target` is stored in, so share lookups and share revokes both /// speak the same spelling of a path. pub(crate) fn target_rel(state: &AppState, abs: &std::path::Path) -> String { let rel = abs .strip_prefix(&state.root) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| ".".to_string()); // The server root strips to the empty string, which has no last component // for [`display_name`] to show. Spell it `.`, the form that already means // "the whole root" everywhere else. if rel.is_empty() { ".".to_string() } else { rel } } pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> { let n = name.trim(); if n.is_empty() || n.len() > 64 { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "name must be 1–64 characters", "err_name_length", )); } Ok(()) } /// Run blocking work (filesystem, mostly) on the blocking pool. /// /// The join itself can only fail if the task panicked or the runtime is /// shutting down; both are `500`. Every caller used to spell that out, so /// the `?` on the outer result is the join and the inner one is the work. pub(crate) async fn blocking( f: impl FnOnce() -> Result + Send + 'static, ) -> Result where T: Send + 'static, E: Into + Send + 'static, { tokio::task::spawn_blocking(f) .await .map_err(|_| ApiError::internal())? .map_err(Into::into) } /// Hash a password off the async executor. Argon2 is slow by design, so /// running it inline would block a tokio worker thread for the whole cost. pub(crate) async fn hash_password(pw: &str) -> Result { let pw = pw.to_string(); let _slot = crate::auth::ARGON2_SLOTS.acquire().await; tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw)) .await .map_err(|_| ApiError::internal())? .map_err(|e| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}"), ) }) } /// A user-chosen label for a credential: trimmed, bounded, never empty. pub(crate) fn credential_label(raw: &str, fallback: &str) -> String { let trimmed = raw.trim(); if trimmed.is_empty() { return fallback.to_string(); } trimmed.chars().take(64).collect() } pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> { if pw.len() < 8 { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "password must be at least 8 characters", "err_password_short", )); } Ok(()) } /// Extractor for admin-only routes: a signed-in user who is an admin. /// Built on [`SessionUser`], so a share token never grants admin. pub struct AdminUser { pub user: User, } impl FromRequestParts> for AdminUser { type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &Arc, ) -> Result { let auth = SessionUser::from_request_parts(parts, state).await?; if !auth.user.is_admin { return Err(ApiError::localized( StatusCode::FORBIDDEN, "admin only", "err_admin_only", )); } Ok(AdminUser { user: auth.user }) } } #[cfg(test)] mod tests { use super::*; #[test] fn share_query_field_is_p_share() { let (mut parts, ()) = axum::http::Request::builder() .uri(format!("/api/files/1?{}=abc", api_types::P_SHARE)) .body(()) .unwrap() .into_parts(); assert_eq!(share_token_from_request(&parts).as_deref(), Some("abc")); // An empty param falls through to the header. parts.uri = format!("/api/files/1?{}=", api_types::P_SHARE) .parse() .unwrap(); parts .headers .insert("x-share-token", "def".parse().unwrap()); assert_eq!(share_token_from_request(&parts).as_deref(), Some("def")); } }