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