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