common.rs
⎇
Raw
1//! Authenticated-user extractor shared by all protected API routes.
2
3use std::sync::Arc;
4
5use api_types::P_SHARE;
6use axum::extract::FromRequestParts;
7use axum::http::StatusCode;
8use axum::http::request::Parts;
9
10use crate::auth::parse_session_cookie;
11use crate::db::{RootRow, ShareRow, User};
12use crate::error::{ApiError, AppState};
13
14/// Authorization for the file API: which roots the caller may touch.
15///
16/// Deliberately carries no [`User`]. A share visitor is anonymous, so there
17/// is no identity to expose here. A handler that trusted a user id from this
18/// extractor would treat a share visitor as the share's creator.
19pub struct AuthUser {
20 pub roots: Vec<RootRow>,
21 /// Present when authenticated via a public share token. The single entry
22 /// in `roots` is the shared item (its path is the share's `target`), so all
23 /// file operations are scoped to it.
24 pub share: Option<ShareRow>,
25}
26
27impl<S> FromRequestParts<S> for AuthUser
28where
29 S: HasState + Send + Sync,
30{
31 type Rejection = ApiError;
32
33 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
34 let state = state.state();
35
36 // 1. Public share token (`?share=<token>` or `X-Share-Token`). Checked
37 // first: a request that explicitly carries a share token is a share
38 // request, even if a session is also present (so a signed-in user
39 // viewing a share link sees the shared scope).
40 if let Some(share_token) = share_token_from_request(parts) {
41 return match state.db.share_by_token(&share_token).await? {
42 Some(share) if !share.is_expired() => {
43 let roots = vec![RootRow {
44 id: share.id,
45 path: share.target.clone(),
46 mode: share.mode,
47 }];
48 Ok(AuthUser {
49 roots,
50 share: Some(share),
51 })
52 }
53 Some(_) => Err(ApiError::new(StatusCode::GONE, "this share has expired")),
54 None => Err(ApiError::new(StatusCode::NOT_FOUND, "share not found")),
55 };
56 }
57
58 // 2. Signed-in session.
59 let (_user, roots) = session_auth(parts, state).await?;
60 Ok(AuthUser { roots, share: None })
61 }
62}
63
64/// Extractor for routes that must never be reachable with a share token:
65/// share management and admin.
66///
67/// A share token only proves that the caller holds a share link. It says
68/// nothing about *who* the caller is, so it must not stand in for the share
69/// creator's identity. Requests that carry one are rejected outright instead
70/// of silently falling back to the session, so a share visitor cannot act as
71/// the creator by also having a cookie.
72pub struct SessionUser {
73 pub user: User,
74 pub roots: Vec<RootRow>,
75}
76
77impl<S> FromRequestParts<S> for SessionUser
78where
79 S: HasState + Send + Sync,
80{
81 type Rejection = ApiError;
82
83 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
84 if share_token_from_request(parts).is_some() {
85 return Err(ApiError::new(
86 StatusCode::FORBIDDEN,
87 "a share token cannot be used here; sign in instead",
88 ));
89 }
90 let (user, roots) = session_auth(parts, state.state()).await?;
91 Ok(SessionUser { user, roots })
92 }
93}
94
95/// Authenticate via the session cookie only, returning the user and roots.
96async fn session_auth(parts: &Parts, state: &AppState) -> Result<(User, Vec<RootRow>), ApiError> {
97 let Some(token) = parse_session_cookie(&parts.headers) else {
98 return Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"));
99 };
100 let Some(user) = state.db.session_user(&token).await? else {
101 return Err(ApiError::new(
102 StatusCode::UNAUTHORIZED,
103 "session expired, please sign in again",
104 ));
105 };
106 let roots = state.db.user_roots(user.id).await?;
107 Ok((user, roots))
108}
109
110/// Extract the share token from a request, if present: a `?share=<token>`
111/// query param or an `X-Share-Token` header.
112fn share_token_from_request(parts: &Parts) -> Option<String> {
113 if let Some(q) = parts.uri.query() {
114 for pair in q.split('&') {
115 if let Some((k, v)) = pair.split_once('=')
116 && k == P_SHARE
117 && !v.is_empty()
118 {
119 return Some(v.to_string());
120 }
121 }
122 }
123 parts
124 .headers
125 .get("x-share-token")
126 .and_then(|v| v.to_str().ok())
127 .filter(|s| !s.is_empty())
128 .map(|s| s.to_string())
129}
130
131/// Lets the extractor pull the concrete state type out of a generic `S`.
132pub trait HasState {
133 fn state(&self) -> &AppState;
134}
135
136impl HasState for Arc<AppState> {
137 fn state(&self) -> &AppState {
138 self
139 }
140}
141
142// ---------------------------------------------------------------------------
143// Shared validation / naming helpers
144// ---------------------------------------------------------------------------
145
146/// Display name for a root path: the file/folder name, or the server root's
147/// own name when the path is the whole root (".").
148pub(crate) fn display_name(server_root: &std::path::Path, rel: &str) -> String {
149 let name = if rel == "." {
150 server_root.file_name()
151 } else {
152 std::path::Path::new(rel)
153 .file_name()
154 .filter(|_| !std::path::Path::new(rel).as_os_str().is_empty())
155 };
156 name.map(|s| s.to_string_lossy().into_owned())
157 .unwrap_or_else(|| rel.to_string())
158}
159
160pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> {
161 let n = name.trim();
162 if n.is_empty() || n.len() > 64 {
163 return Err(ApiError::new(
164 StatusCode::BAD_REQUEST,
165 "name must be 1–64 characters",
166 ));
167 }
168 Ok(())
169}
170
171/// Hash a password off the async executor. Argon2 is slow by design, so
172/// running it inline would block a tokio worker thread for the whole cost.
173pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
174 let pw = pw.to_string();
175 tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw))
176 .await
177 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?
178 .map_err(|e| {
179 ApiError::new(
180 StatusCode::INTERNAL_SERVER_ERROR,
181 format!("hashing failed: {e}"),
182 )
183 })
184}
185
186pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> {
187 if pw.len() < 8 {
188 return Err(ApiError::new(
189 StatusCode::BAD_REQUEST,
190 "password must be at least 8 characters",
191 ));
192 }
193 Ok(())
194}
195
196/// Extractor for admin-only routes: a signed-in user who is an admin.
197/// Built on [`SessionUser`], so a share token never grants admin.
198pub struct AdminUser {
199 pub user: User,
200}
201
202impl<S> FromRequestParts<S> for AdminUser
203where
204 S: HasState + Send + Sync,
205{
206 type Rejection = ApiError;
207
208 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
209 let auth = SessionUser::from_request_parts(parts, state).await?;
210 if !auth.user.is_admin {
211 return Err(ApiError::new(StatusCode::FORBIDDEN, "admin only"));
212 }
213 Ok(AdminUser { user: auth.user })
214 }
215}
216