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