admin.rs
⎇
Raw
1//! Admin API (milestone 7): user management and server settings.
2//! All routes require an admin session (via [`AdminUser`]).
3
4use std::sync::Arc;
5
6use api_types::{AdminUser, CreateUser, Mode, OkResp, Root, RootInfo, Settings, UpdateUser};
7use axum::Json;
8use axum::extract::{Path as AxumPath, State};
9use axum::http::StatusCode;
10
11use crate::api::common::AdminUser as AdminGuard;
12use crate::api::common::{
13 blocking, display_name, hash_password, validate_account_name, validate_password,
14};
15use crate::db::Db;
16use crate::error::{ApiError, AppState};
17use crate::fs;
18
19// ---------------------------------------------------------------------------
20// Helpers
21// ---------------------------------------------------------------------------
22
23fn root_info(state: &AppState, r: &crate::db::RootRow) -> RootInfo {
24 RootInfo {
25 id: r.id,
26 name: display_name(state, &r.path),
27 path: r.path.clone(),
28 mode: r.mode,
29 }
30}
31
32async fn user_info(
33 db: &Db,
34 state: &AppState,
35 user: &crate::db::User,
36) -> Result<AdminUser, ApiError> {
37 let roots = db.user_roots(user.id).await?;
38 Ok(AdminUser {
39 id: user.id,
40 name: user.name.clone(),
41 is_admin: user.is_admin,
42 active: user.active,
43 roots: roots.iter().map(|r| root_info(state, r)).collect(),
44 })
45}
46
47/// Validate each requested root path (must exist, be a directory, and stay
48/// inside the server root). Returns the (path, mode) pairs.
49///
50/// The mode needs no check: `Mode` only deserializes from "rw" or "ro", so a
51/// bad value is rejected by the `Json` extractor before this runs.
52async fn validate_roots(state: &AppState, roots: &[Root]) -> Result<Vec<(String, Mode)>, ApiError> {
53 let mut out = Vec::new();
54 for r in roots {
55 let path = if r.path.trim().is_empty() {
56 ".".to_string()
57 } else {
58 r.path.trim().to_string()
59 };
60 let server_root = state.root.clone();
61 let (path2, label) = (path.clone(), path.clone());
62 // The message is built inside the closure so it carries the
63 // `FsError`, not the join failure.
64 blocking(move || {
65 fs::resolve_root(&server_root, &path2).map_err(|e| {
66 ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{label}': {e}"))
67 })
68 })
69 .await?;
70 out.push((path, r.mode));
71 }
72 Ok(out)
73}
74
75// ---------------------------------------------------------------------------
76// Handlers
77// ---------------------------------------------------------------------------
78
79/// GET /api/admin/users — list all users with their roots.
80pub async fn list_users(
81 State(state): State<Arc<AppState>>,
82 _admin: AdminGuard,
83) -> Result<Json<Vec<AdminUser>>, ApiError> {
84 let out = state
85 .db
86 .all_users_with_roots()
87 .await?
88 .into_iter()
89 .map(|(u, roots)| AdminUser {
90 id: u.id,
91 name: u.name,
92 is_admin: u.is_admin,
93 active: u.active,
94 roots: roots.iter().map(|r| root_info(&state, r)).collect(),
95 })
96 .collect();
97 Ok(Json(out))
98}
99
100/// POST /api/admin/users — create a user.
101pub async fn create_user(
102 State(state): State<Arc<AppState>>,
103 _admin: AdminGuard,
104 Json(body): Json<CreateUser>,
105) -> Result<Json<AdminUser>, ApiError> {
106 let name = body.name.trim().to_string();
107 validate_account_name(&name)?;
108 validate_password(&body.password)?;
109 if state.db.find_user_by_name(&name).await?.is_some() {
110 return Err(ApiError::localized(
111 StatusCode::CONFLICT,
112 "a user with that name already exists",
113 "err_user_exists",
114 ));
115 }
116 let roots = validate_roots(&state, &body.roots).await?;
117
118 let pass_hash = hash_password(&body.password).await?;
119 let user = state
120 .db
121 .create_user(&name, &pass_hash, body.is_admin, &roots)
122 .await?;
123 Ok(Json(user_info(&state.db, &state, &user).await?))
124}
125
126/// PUT /api/admin/users/{id} — update a user (password / is_admin / active /
127/// roots; all optional).
128pub async fn update_user(
129 State(state): State<Arc<AppState>>,
130 admin: AdminGuard,
131 AxumPath(id): AxumPath<i64>,
132 Json(body): Json<UpdateUser>,
133) -> Result<Json<AdminUser>, ApiError> {
134 let target = state.db.find_user_by_id(id).await?.ok_or_else(|| {
135 ApiError::localized(
136 StatusCode::NOT_FOUND,
137 "user not found",
138 "err_user_not_found",
139 )
140 })?;
141
142 // Lockout guards: an admin cannot demote, disable, or delete themselves.
143 if id == admin.user.id {
144 if body.is_admin == Some(false) {
145 return Err(ApiError::localized(
146 StatusCode::BAD_REQUEST,
147 "you cannot remove your own admin rights",
148 "err_own_admin",
149 ));
150 }
151 if body.active == Some(false) {
152 return Err(ApiError::localized(
153 StatusCode::BAD_REQUEST,
154 "you cannot disable your own account",
155 "err_own_account",
156 ));
157 }
158 }
159 // Never allow dropping to zero active admins.
160 let demoting = id != admin.user.id && body.is_admin == Some(false) && target.is_admin;
161 let disabling =
162 id != admin.user.id && body.active == Some(false) && target.active && target.is_admin;
163 if (demoting || disabling) && state.db.count_admins().await? <= 1 {
164 return Err(ApiError::localized(
165 StatusCode::BAD_REQUEST,
166 "cannot remove the last active admin",
167 "err_last_admin",
168 ));
169 }
170
171 let hash = match &body.password {
172 Some(pw) => {
173 validate_password(pw)?;
174 Some(hash_password(pw).await?)
175 }
176 None => None,
177 };
178 let pairs = match &body.roots {
179 Some(roots) => Some(validate_roots(&state, roots).await?),
180 None => None,
181 };
182 state
183 .db
184 .update_user(
185 id,
186 hash.as_deref(),
187 body.is_admin,
188 body.active,
189 pairs.as_deref(),
190 )
191 .await?;
192
193 let updated = state.db.find_user_by_id(id).await?.ok_or_else(|| {
194 ApiError::localized(
195 StatusCode::NOT_FOUND,
196 "user not found",
197 "err_user_not_found",
198 )
199 })?;
200 Ok(Json(user_info(&state.db, &state, &updated).await?))
201}
202
203/// DELETE /api/admin/users/{id} — delete a user (not yourself).
204pub async fn delete_user(
205 State(state): State<Arc<AppState>>,
206 admin: AdminGuard,
207 AxumPath(id): AxumPath<i64>,
208) -> Result<Json<OkResp>, ApiError> {
209 if id == admin.user.id {
210 return Err(ApiError::localized(
211 StatusCode::BAD_REQUEST,
212 "you cannot delete your own account",
213 "err_own_delete",
214 ));
215 }
216 let target = state.db.find_user_by_id(id).await?.ok_or_else(|| {
217 ApiError::localized(
218 StatusCode::NOT_FOUND,
219 "user not found",
220 "err_user_not_found",
221 )
222 })?;
223 if target.is_admin && target.active && state.db.count_admins().await? <= 1 {
224 return Err(ApiError::localized(
225 StatusCode::BAD_REQUEST,
226 "cannot delete the last active admin",
227 "err_last_admin_delete",
228 ));
229 }
230 if !state.db.delete_user(id).await? {
231 return Err(ApiError::localized(
232 StatusCode::NOT_FOUND,
233 "user not found",
234 "err_user_not_found",
235 ));
236 }
237 Ok(Json(OkResp {}))
238}
239
240/// GET /api/admin/settings
241pub async fn get_settings(
242 State(state): State<Arc<AppState>>,
243 _admin: AdminGuard,
244) -> Result<Json<Settings>, ApiError> {
245 Ok(Json(Settings {
246 allow_writable_shares: state.db.allow_writable_shares().await?,
247 search_excludes: state.db.search_excludes().await?,
248 }))
249}
250
251/// PUT /api/admin/settings
252pub async fn update_settings(
253 State(state): State<Arc<AppState>>,
254 _admin: AdminGuard,
255 Json(body): Json<Settings>,
256) -> Result<Json<Settings>, ApiError> {
257 state
258 .db
259 .set_allow_writable_shares(body.allow_writable_shares)
260 .await?;
261 // Normalised so the search can compare plain strings. "." is dropped:
262 // excluding the root would switch search off instead of narrowing it.
263 let mut excludes: Vec<String> = Vec::new();
264 for p in &body.search_excludes {
265 let p = p.trim().replace('\\', "/");
266 let p = p.trim_matches('/');
267 if p.is_empty() || p == "." || excludes.iter().any(|e| e == p) {
268 continue;
269 }
270 excludes.push(p.to_string());
271 }
272 state.db.set_search_excludes(&excludes).await?;
273 Ok(Json(Settings {
274 allow_writable_shares: body.allow_writable_shares,
275 search_excludes: excludes,
276 }))
277}
278