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