auth.rs
⎇
Raw
1use std::sync::Arc;
2
3use api_types::{Credentials, Me, OkResp, RootInfo, UserInfo};
4use axum::Json;
5use axum::extract::State;
6use axum::http::{HeaderMap, StatusCode, header};
7use axum::response::{IntoResponse, Response};
8use serde::Deserialize;
9
10use crate::api::common::{
11 SessionUser, display_name, hash_password, session_auth, validate_account_name,
12 validate_password,
13};
14use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
15use crate::db::{RootRow, User};
16use crate::error::{ApiError, AppState};
17
18/// GET /api/auth/me
19///
20/// - No users at all → `200 {"first_boot": true}`
21/// - No/invalid session → `401`
22/// - Valid session → user info + visible roots
23pub async fn me(
24 State(state): State<Arc<AppState>>,
25 headers: HeaderMap,
26) -> Result<Json<Me>, ApiError> {
27 if state.db.user_count().await? == 0 {
28 return Ok(Json(Me {
29 first_boot: true,
30 user: None,
31 roots: Vec::new(),
32 allow_writable_shares: false,
33 thumbnails_available: state.thumbs.is_some(),
34 public_url: state.public_url.clone(),
35 }));
36 }
37
38 let (user, roots) = session_auth(&headers, &state).await?;
39 Ok(Json(me_for(&state, &user, roots).await?))
40}
41
42/// Build the `/api/auth/me` payload for an authenticated user.
43async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me, ApiError> {
44 let roots: Vec<RootInfo> = roots
45 .into_iter()
46 .map(|r| RootInfo {
47 id: r.id,
48 name: display_name(state, &r.path),
49 path: r.path,
50 mode: r.mode,
51 })
52 .collect();
53
54 Ok(Me {
55 first_boot: false,
56 user: Some(UserInfo {
57 id: user.id,
58 name: user.name.clone(),
59 is_admin: user.is_admin,
60 single_click_open: user.single_click,
61 thumbnails: user.thumbnails,
62 language: user.language.clone(),
63 // A removed root leaves a stale id behind; the client never
64 // sees it.
65 default_root_id: user
66 .default_root_id
67 .filter(|id| roots.iter().any(|r| r.id == *id)),
68 }),
69 roots,
70 allow_writable_shares: state.db.allow_writable_shares().await?,
71 thumbnails_available: state.thumbs.is_some(),
72 public_url: state.public_url.clone(),
73 })
74}
75
76/// PUT /api/auth/me
77///
78/// Update the signed-in user's profile settings. Each field is optional;
79/// omitted fields are left untouched. Returns the fresh `/me` payload so
80/// clients can apply the change immediately.
81#[derive(Deserialize)]
82pub(crate) struct ProfilePatch {
83 #[serde(default)]
84 pub single_click_open: Option<bool>,
85 pub thumbnails: Option<bool>,
86 #[serde(default, deserialize_with = "patch_field")]
87 pub language: Option<Option<String>>,
88 /// `null` clears the default root (back to the root picker).
89 #[serde(default, deserialize_with = "patch_field")]
90 pub default_root_id: Option<Option<i64>>,
91}
92
93/// Deserializes a nullable patch field into the three-state value:
94/// `"de"` → `Some(Some("de"))`, `null` → `Some(None)` (a missing field
95/// never calls this and stays `None` via `#[serde(default)]`). The inner
96/// `Option<T>` already maps `null` → `None` and a value → `Some`, so only
97/// the outer wrap is custom.
98fn patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
99where
100 D: serde::Deserializer<'de>,
101 T: serde::Deserialize<'de>,
102{
103 serde::Deserialize::deserialize(deserializer).map(Some)
104}
105
106/// A language tag we are willing to store: short, ASCII letters/digits and
107/// `-`/`_` (BCP-47 style). Checked at the trust boundary so a raw API
108/// client cannot write arbitrary blobs into the DB.
109fn valid_language(tag: &str) -> bool {
110 !tag.is_empty()
111 && tag.len() <= 12
112 && tag
113 .bytes()
114 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
115}
116
117pub async fn update_profile(
118 State(state): State<Arc<AppState>>,
119 SessionUser { mut user, roots }: SessionUser,
120 Json(body): Json<ProfilePatch>,
121) -> Result<Json<Me>, ApiError> {
122 if let Some(Some(ref tag)) = body.language
123 && !valid_language(tag)
124 {
125 return Err(ApiError::localized(
126 StatusCode::BAD_REQUEST,
127 "invalid language tag",
128 "err_invalid_language",
129 ));
130 }
131 if let Some(v) = body.single_click_open {
132 state.db.set_user_single_click(user.id, v).await?;
133 user.single_click = v;
134 }
135 if let Some(v) = body.thumbnails {
136 state.db.set_user_thumbnails(user.id, v).await?;
137 user.thumbnails = v;
138 }
139 if let Some(lang) = body.language {
140 state.db.set_user_language(user.id, lang.as_deref()).await?;
141 user.language = lang;
142 }
143 if let Some(root_id) = body.default_root_id {
144 if let Some(id) = root_id
145 && !roots.iter().any(|r| r.id == id)
146 {
147 return Err(ApiError::localized(
148 StatusCode::BAD_REQUEST,
149 "not one of your folders",
150 "err_invalid_default_root",
151 ));
152 }
153 state.db.set_user_default_root(user.id, root_id).await?;
154 user.default_root_id = root_id;
155 }
156 Ok(Json(me_for(&state, &user, roots).await?))
157}
158
159fn already_set_up() -> ApiError {
160 ApiError::localized(
161 StatusCode::CONFLICT,
162 "server is already set up",
163 "err_already_set_up",
164 )
165}
166
167/// POST /api/auth/setup — create the first admin account.
168/// Only available while no users exist.
169pub async fn setup(
170 State(state): State<Arc<AppState>>,
171 Json(body): Json<Credentials>,
172) -> Result<Response, ApiError> {
173 let name = body.name.trim();
174 validate_account_name(name)?;
175 validate_password(&body.password)?;
176 // A cheap pre-check: it keeps a POST to an already-configured server from
177 // paying for an Argon2 hash. `create_admin` re-checks atomically.
178 if state.db.user_count().await? > 0 {
179 return Err(already_set_up());
180 }
181
182 let pass_hash = hash_password(&body.password).await?;
183 // `None` = another setup request won the race between the check above and
184 // this insert.
185 let Some(user) = state.db.create_admin(name, &pass_hash).await? else {
186 return Err(already_set_up());
187 };
188
189 let token = auth::random_token();
190 state.db.create_session(user.id, &token).await?;
191
192 let mut res = Json(OkResp {}).into_response();
193 res.headers_mut().insert(
194 header::SET_COOKIE,
195 session_cookie(&token, state.https).parse().unwrap(),
196 );
197 Ok(res)
198}
199
200/// POST /api/auth/login
201pub async fn login(
202 State(state): State<Arc<AppState>>,
203 Json(body): Json<Credentials>,
204) -> Result<Response, ApiError> {
205 // Online guessing gets slower per failed attempt on this name.
206 let delay = auth::login_delay(&body.name);
207 if !delay.is_zero() {
208 tokio::time::sleep(delay).await;
209 }
210 let verified = state.db.verify_password(&body.name, &body.password).await?;
211 auth::record_login(&body.name, verified.is_some());
212 let Some(user) = verified else {
213 return Err(ApiError::localized(
214 StatusCode::UNAUTHORIZED,
215 "invalid name or password",
216 "err_invalid_credentials",
217 ));
218 };
219
220 let token = auth::random_token();
221 state.db.create_session(user.id, &token).await?;
222
223 let mut res = Json(OkResp {}).into_response();
224 res.headers_mut().insert(
225 header::SET_COOKIE,
226 session_cookie(&token, state.https).parse().unwrap(),
227 );
228 Ok(res)
229}
230
231/// POST /api/auth/logout
232pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
233 if let Some(token) = parse_session_cookie(&headers) {
234 let _ = state.db.delete_session(&token).await;
235 }
236 let mut res = Json(OkResp {}).into_response();
237 res.headers_mut().insert(
238 header::SET_COOKIE,
239 clear_session_cookie(state.https).parse().unwrap(),
240 );
241 res
242}
243