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