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