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