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