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