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