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