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 // The password guards the files, not just the share
44 // page. A check only on resolve would leave the file
45 // API open to anyone holding the link.
46 if share_is_locked(state, &share, &parts.headers).await? {
47 return Err(crate::api::shares::locked_error());
48 }
49 let roots = vec![RootRow {
50 id: share.id,
51 path: share.target.clone(),
52 mode: share.mode,
53 }];
54 Ok(AuthUser {
55 roots,
56 share: Some(share),
57 })
58 }
59 Some(_) => Err(ApiError::localized(
60 StatusCode::GONE,
61 "this share has expired",
62 "err_share_expired",
63 )),
64 None => Err(ApiError::localized(
65 StatusCode::NOT_FOUND,
66 "share not found",
67 "err_share_not_found",
68 )),
69 };
70 }
71
72 // 2. Signed-in session. Admins also get the whole server root,
73 // read-only, under `ADMIN_ROOT` (the folder picker in user
74 // management browses it).
75 let (user, mut roots) = session_auth(&parts.headers, state).await?;
76 if user.is_admin {
77 roots.push(RootRow {
78 id: api_types::ADMIN_ROOT,
79 path: ".".to_string(),
80 mode: api_types::Mode::Ro,
81 });
82 }
83 Ok(AuthUser { roots, share: None })
84 }
85}
86
87/// Extractor for routes that must never be reachable with a share token:
88/// share management and admin.
89///
90/// A share token only proves that the caller holds a share link. It says
91/// nothing about *who* the caller is, so it must not stand in for the share
92/// creator's identity. Requests that carry one are rejected outright instead
93/// of silently falling back to the session, so a share visitor cannot act as
94/// the creator by also having a cookie.
95pub struct SessionUser {
96 pub user: User,
97 pub roots: Vec<RootRow>,
98}
99
100impl<S> FromRequestParts<S> for SessionUser
101where
102 S: HasState + Send + Sync,
103{
104 type Rejection = ApiError;
105
106 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
107 if share_token_from_request(parts).is_some() {
108 return Err(ApiError::localized(
109 StatusCode::FORBIDDEN,
110 "a share token cannot be used here; sign in instead",
111 "err_share_token_forbidden",
112 ));
113 }
114 let (user, roots) = session_auth(&parts.headers, state.state()).await?;
115 Ok(SessionUser { user, roots })
116 }
117}
118
119/// Authenticate via the session cookie only, returning the user and roots.
120pub(crate) async fn session_auth(
121 headers: &axum::http::HeaderMap,
122 state: &AppState,
123) -> Result<(User, Vec<RootRow>), ApiError> {
124 let Some(token) = parse_session_cookie(headers) else {
125 return Err(ApiError::localized(
126 StatusCode::UNAUTHORIZED,
127 "not signed in",
128 "err_not_signed_in",
129 ));
130 };
131 let Some((user, roots)) = state.db.session_user_with_roots(&token).await? else {
132 return Err(ApiError::localized(
133 StatusCode::UNAUTHORIZED,
134 "session expired, please sign in again",
135 "err_session_expired",
136 ));
137 };
138 Ok((user, roots))
139}
140
141/// Whether `share` still needs its password entered by this caller.
142pub(crate) async fn share_is_locked(
143 state: &AppState,
144 share: &ShareRow,
145 headers: &axum::http::HeaderMap,
146) -> Result<bool, ApiError> {
147 if share.password_hash.is_none() {
148 return Ok(false);
149 }
150 let Some(unlock) = crate::auth::parse_share_cookie(headers, share.id) else {
151 return Ok(true);
152 };
153 Ok(!state.db.share_unlock_valid(&unlock, share.id).await?)
154}
155
156/// Extract the share token from a request, if present: a `?share=<token>`
157/// query param or an `X-Share-Token` header.
158fn share_token_from_request(parts: &Parts) -> Option<String> {
159 if let Some(q) = parts.uri.query() {
160 for pair in q.split('&') {
161 if let Some((k, v)) = pair.split_once('=')
162 && k == P_SHARE
163 && !v.is_empty()
164 {
165 return Some(v.to_string());
166 }
167 }
168 }
169 parts
170 .headers
171 .get("x-share-token")
172 .and_then(|v| v.to_str().ok())
173 .filter(|s| !s.is_empty())
174 .map(|s| s.to_string())
175}
176
177/// Lets the extractor pull the concrete state type out of a generic `S`.
178pub trait HasState {
179 fn state(&self) -> &AppState;
180}
181
182impl HasState for Arc<AppState> {
183 fn state(&self) -> &AppState {
184 self
185 }
186}
187
188// ---------------------------------------------------------------------------
189// Shared validation / naming helpers
190// ---------------------------------------------------------------------------
191
192/// UI name of a root or share target `rel` (relative to the server root):
193/// its last path component, or the configured root name for `.`.
194pub(crate) fn display_name(state: &AppState, rel: &str) -> String {
195 if rel == "." {
196 return state.root_name.clone();
197 }
198 std::path::Path::new(rel)
199 .file_name()
200 .map(|s| s.to_string_lossy().into_owned())
201 .unwrap_or_else(|| rel.to_string())
202}
203
204/// A resolved absolute path re-expressed relative to the server root — the
205/// form `shares.target` is stored in, so share lookups and share revokes both
206/// speak the same spelling of a path.
207pub(crate) fn target_rel(state: &AppState, abs: &std::path::Path) -> String {
208 let rel = abs
209 .strip_prefix(&state.root)
210 .map(|p| p.to_string_lossy().into_owned())
211 .unwrap_or_else(|_| ".".to_string());
212 // The server root strips to the empty string, which has no last component
213 // for [`display_name`] to show. Spell it `.`, the form that already means
214 // "the whole root" everywhere else.
215 if rel.is_empty() { ".".to_string() } else { rel }
216}
217
218pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> {
219 let n = name.trim();
220 if n.is_empty() || n.len() > 64 {
221 return Err(ApiError::localized(
222 StatusCode::BAD_REQUEST,
223 "name must be 1–64 characters",
224 "err_name_length",
225 ));
226 }
227 Ok(())
228}
229
230/// Run blocking work (filesystem, mostly) on the blocking pool.
231///
232/// The join itself can only fail if the task panicked or the runtime is
233/// shutting down; both are `500`. Every caller used to spell that out, so
234/// the `?` on the outer result is the join and the inner one is the work.
235pub(crate) async fn blocking<T, E>(
236 f: impl FnOnce() -> Result<T, E> + Send + 'static,
237) -> Result<T, ApiError>
238where
239 T: Send + 'static,
240 E: Into<ApiError> + Send + 'static,
241{
242 tokio::task::spawn_blocking(f)
243 .await
244 .map_err(|_| internal_error())?
245 .map_err(Into::into)
246}
247
248pub(crate) fn internal_error() -> ApiError {
249 ApiError::localized(
250 StatusCode::INTERNAL_SERVER_ERROR,
251 "internal error",
252 "err_internal",
253 )
254}
255
256/// Hash a password off the async executor. Argon2 is slow by design, so
257/// running it inline would block a tokio worker thread for the whole cost.
258pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
259 let pw = pw.to_string();
260 let _slot = crate::auth::ARGON2_SLOTS.acquire().await;
261 tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw))
262 .await
263 .map_err(|_| {
264 ApiError::localized(
265 StatusCode::INTERNAL_SERVER_ERROR,
266 "internal error",
267 "err_internal",
268 )
269 })?
270 .map_err(|e| {
271 ApiError::new(
272 StatusCode::INTERNAL_SERVER_ERROR,
273 format!("hashing failed: {e}"),
274 )
275 })
276}
277
278pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> {
279 if pw.len() < 8 {
280 return Err(ApiError::localized(
281 StatusCode::BAD_REQUEST,
282 "password must be at least 8 characters",
283 "err_password_short",
284 ));
285 }
286 Ok(())
287}
288
289/// Extractor for admin-only routes: a signed-in user who is an admin.
290/// Built on [`SessionUser`], so a share token never grants admin.
291pub struct AdminUser {
292 pub user: User,
293}
294
295impl<S> FromRequestParts<S> for AdminUser
296where
297 S: HasState + Send + Sync,
298{
299 type Rejection = ApiError;
300
301 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
302 let auth = SessionUser::from_request_parts(parts, state).await?;
303 if !auth.user.is_admin {
304 return Err(ApiError::localized(
305 StatusCode::FORBIDDEN,
306 "admin only",
307 "err_admin_only",
308 ));
309 }
310 Ok(AdminUser { user: auth.user })
311 }
312}
313