common.rs
⎇
Raw
1//! Authenticated-user extractor shared by all protected API routes.
2
3use std::sync::Arc;
4
5use axum::extract::FromRequestParts;
6use axum::http::StatusCode;
7use axum::http::request::Parts;
8
9use crate::auth::parse_session_cookie;
10use crate::db::{RootRow, ShareRow, User};
11use crate::error::{ApiError, AppState};
12
13/// Authorization for the file API: which roots the caller may touch.
14///
15/// Deliberately carries no [`User`]. A share visitor is anonymous, so there
16/// is no identity to expose here. A handler that trusted a user id from this
17/// extractor would treat a share visitor as the share's creator.
18pub struct AuthUser {
19 pub roots: Vec<RootRow>,
20 /// Present when authenticated via a public share token. The single entry
21 /// in `roots` is the shared item (its path is the share's `target`), so all
22 /// file operations are scoped to it.
23 pub share: Option<ShareRow>,
24}
25
26impl FromRequestParts<Arc<AppState>> for AuthUser {
27 type Rejection = ApiError;
28
29 async fn from_request_parts(
30 parts: &mut Parts,
31 state: &Arc<AppState>,
32 ) -> Result<Self, Self::Rejection> {
33 // 1. Public share token (`?share=<token>` or `X-Share-Token`). Checked
34 // first: a request that explicitly carries a share token is a share
35 // request, even if a session is also present (so a signed-in user
36 // viewing a share link sees the shared scope).
37 if let Some(share_token) = share_token_from_request(parts) {
38 let share = live_share(state, &share_token).await?;
39 // The password guards the files, not just the share page. A check
40 // only on resolve would leave the file API open to anyone holding
41 // the link.
42 if share_is_locked(state, &share, &parts.headers).await? {
43 return Err(crate::api::shares::locked_error());
44 }
45 let roots = vec![RootRow {
46 id: share.id,
47 path: share.target.clone(),
48 mode: share.mode,
49 }];
50 return Ok(AuthUser {
51 roots,
52 share: Some(share),
53 });
54 }
55
56 // 2. Signed-in session. Admins also get the whole server root,
57 // read-only, under `ADMIN_ROOT` (the folder picker in user
58 // management browses it).
59 let (user, mut roots) = session_auth(&parts.headers, state).await?;
60 if user.is_admin {
61 roots.push(RootRow {
62 id: api_types::ADMIN_ROOT,
63 path: ".".to_string(),
64 mode: api_types::Mode::Ro,
65 });
66 }
67 Ok(AuthUser { roots, share: None })
68 }
69}
70
71/// Extractor for routes that must never be reachable with a share token:
72/// share management and admin.
73///
74/// A share token only proves that the caller holds a share link. It says
75/// nothing about *who* the caller is, so it must not stand in for the share
76/// creator's identity. Requests that carry one are rejected outright instead
77/// of silently falling back to the session, so a share visitor cannot act as
78/// the creator by also having a cookie.
79pub struct SessionUser {
80 pub user: User,
81 pub roots: Vec<RootRow>,
82}
83
84impl FromRequestParts<Arc<AppState>> for SessionUser {
85 type Rejection = ApiError;
86
87 async fn from_request_parts(
88 parts: &mut Parts,
89 state: &Arc<AppState>,
90 ) -> Result<Self, Self::Rejection> {
91 if share_token_from_request(parts).is_some() {
92 return Err(ApiError::localized(
93 StatusCode::FORBIDDEN,
94 "a share token cannot be used here; sign in instead",
95 "err_share_token_forbidden",
96 ));
97 }
98 let (user, roots) = session_auth(&parts.headers, state).await?;
99 Ok(SessionUser { user, roots })
100 }
101}
102
103/// Authenticate via the session cookie only, returning the user and roots.
104pub(crate) async fn session_auth(
105 headers: &axum::http::HeaderMap,
106 state: &AppState,
107) -> Result<(User, Vec<RootRow>), ApiError> {
108 let Some(token) = parse_session_cookie(headers) else {
109 return Err(ApiError::localized(
110 StatusCode::UNAUTHORIZED,
111 "not signed in",
112 "err_not_signed_in",
113 ));
114 };
115 let Some((user, roots)) = state.db.session_user_with_roots(&token).await? else {
116 return Err(ApiError::localized(
117 StatusCode::UNAUTHORIZED,
118 "session expired, please sign in again",
119 "err_session_expired",
120 ));
121 };
122 Ok((user, roots))
123}
124
125/// Whether `share` still needs its password entered by this caller.
126pub(crate) async fn share_is_locked(
127 state: &AppState,
128 share: &ShareRow,
129 headers: &axum::http::HeaderMap,
130) -> Result<bool, ApiError> {
131 if share.password_hash.is_none() {
132 return Ok(false);
133 }
134 let Some(unlock) = crate::auth::parse_share_cookie(headers, share.id) else {
135 return Ok(true);
136 };
137 Ok(!state.db.share_unlock_valid(&unlock, share.id).await?)
138}
139
140/// A share that exists and has not expired.
141pub(crate) async fn live_share(state: &AppState, token: &str) -> Result<ShareRow, ApiError> {
142 match state.db.share_by_token(token).await? {
143 Some(share) if !share.is_expired() => Ok(share),
144 Some(_) => Err(ApiError::localized(
145 StatusCode::GONE,
146 "this share has expired",
147 "err_share_expired",
148 )),
149 None => Err(ApiError::localized(
150 StatusCode::NOT_FOUND,
151 "share not found",
152 "err_share_not_found",
153 )),
154 }
155}
156
157/// The field name is [`api_types::P_SHARE`]; `tests::share_query_field_is_p_share`
158/// pins it.
159#[derive(serde::Deserialize)]
160struct ShareQuery {
161 share: Option<String>,
162}
163
164/// Extract the share token from a request, if present: a `?share=<token>`
165/// query param or an `X-Share-Token` header.
166fn share_token_from_request(parts: &Parts) -> Option<String> {
167 let query = axum::extract::Query::<ShareQuery>::try_from_uri(&parts.uri)
168 .ok()
169 .and_then(|q| q.0.share)
170 .filter(|s| !s.is_empty());
171 if query.is_some() {
172 return query;
173 }
174 parts
175 .headers
176 .get("x-share-token")
177 .and_then(|v| v.to_str().ok())
178 .filter(|s| !s.is_empty())
179 .map(|s| s.to_string())
180}
181
182// ---------------------------------------------------------------------------
183// Shared validation / naming helpers
184// ---------------------------------------------------------------------------
185
186/// UI name of a root or share target `rel` (relative to the server root):
187/// its last path component, or the configured root name for `.`.
188pub(crate) fn display_name(state: &AppState, rel: &str) -> String {
189 if rel == "." {
190 return state.root_name.clone();
191 }
192 std::path::Path::new(rel)
193 .file_name()
194 .map(|s| s.to_string_lossy().into_owned())
195 .unwrap_or_else(|| rel.to_string())
196}
197
198pub(crate) fn root_info(state: &AppState, r: &RootRow) -> api_types::RootInfo {
199 api_types::RootInfo {
200 id: r.id,
201 name: display_name(state, &r.path),
202 path: r.path.clone(),
203 mode: r.mode,
204 }
205}
206
207/// A resolved absolute path re-expressed relative to the server root — the
208/// form `shares.target` is stored in, so share lookups and share revokes both
209/// speak the same spelling of a path.
210pub(crate) fn target_rel(state: &AppState, abs: &std::path::Path) -> String {
211 let rel = abs
212 .strip_prefix(&state.root)
213 .map(|p| p.to_string_lossy().into_owned())
214 .unwrap_or_else(|_| ".".to_string());
215 // The server root strips to the empty string, which has no last component
216 // for [`display_name`] to show. Spell it `.`, the form that already means
217 // "the whole root" everywhere else.
218 if rel.is_empty() { ".".to_string() } else { rel }
219}
220
221pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> {
222 let n = name.trim();
223 if n.is_empty() || n.len() > 64 {
224 return Err(ApiError::localized(
225 StatusCode::BAD_REQUEST,
226 "name must be 1–64 characters",
227 "err_name_length",
228 ));
229 }
230 Ok(())
231}
232
233/// Run blocking work (filesystem, mostly) on the blocking pool.
234///
235/// The join itself can only fail if the task panicked or the runtime is
236/// shutting down; both are `500`. Every caller used to spell that out, so
237/// the `?` on the outer result is the join and the inner one is the work.
238pub(crate) async fn blocking<T, E>(
239 f: impl FnOnce() -> Result<T, E> + Send + 'static,
240) -> Result<T, ApiError>
241where
242 T: Send + 'static,
243 E: Into<ApiError> + Send + 'static,
244{
245 tokio::task::spawn_blocking(f)
246 .await
247 .map_err(|_| ApiError::internal())?
248 .map_err(Into::into)
249}
250
251/// Hash a password off the async executor. Argon2 is slow by design, so
252/// running it inline would block a tokio worker thread for the whole cost.
253pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
254 let pw = pw.to_string();
255 let _slot = crate::auth::ARGON2_SLOTS.acquire().await;
256 tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw))
257 .await
258 .map_err(|_| ApiError::internal())?
259 .map_err(|e| {
260 ApiError::new(
261 StatusCode::INTERNAL_SERVER_ERROR,
262 format!("hashing failed: {e}"),
263 )
264 })
265}
266
267/// A user-chosen label for a credential: trimmed, bounded, never empty.
268pub(crate) fn credential_label(raw: &str, fallback: &str) -> String {
269 let trimmed = raw.trim();
270 if trimmed.is_empty() {
271 return fallback.to_string();
272 }
273 trimmed.chars().take(64).collect()
274}
275
276pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> {
277 if pw.len() < 8 {
278 return Err(ApiError::localized(
279 StatusCode::BAD_REQUEST,
280 "password must be at least 8 characters",
281 "err_password_short",
282 ));
283 }
284 Ok(())
285}
286
287/// Extractor for admin-only routes: a signed-in user who is an admin.
288/// Built on [`SessionUser`], so a share token never grants admin.
289pub struct AdminUser {
290 pub user: User,
291}
292
293impl FromRequestParts<Arc<AppState>> for AdminUser {
294 type Rejection = ApiError;
295
296 async fn from_request_parts(
297 parts: &mut Parts,
298 state: &Arc<AppState>,
299 ) -> Result<Self, Self::Rejection> {
300 let auth = SessionUser::from_request_parts(parts, state).await?;
301 if !auth.user.is_admin {
302 return Err(ApiError::localized(
303 StatusCode::FORBIDDEN,
304 "admin only",
305 "err_admin_only",
306 ));
307 }
308 Ok(AdminUser { user: auth.user })
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn share_query_field_is_p_share() {
318 let (mut parts, ()) = axum::http::Request::builder()
319 .uri(format!("/api/files/1?{}=abc", api_types::P_SHARE))
320 .body(())
321 .unwrap()
322 .into_parts();
323 assert_eq!(share_token_from_request(&parts).as_deref(), Some("abc"));
324 // An empty param falls through to the header.
325 parts.uri = format!("/api/files/1?{}=", api_types::P_SHARE)
326 .parse()
327 .unwrap();
328 parts
329 .headers
330 .insert("x-share-token", "def".parse().unwrap());
331 assert_eq!(share_token_from_request(&parts).as_deref(), Some("def"));
332 }
333}
334