pinakes/crates/pinakes-server/src/routes/users.rs
NotAShelf 625077f341
pinakes-server: add utoipa annotations to all routes; fix tests
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I28cf5b7b7cff8e90e123d624d97cf9656a6a6964
2026-03-22 17:58:39 +03:00

337 lines
9.8 KiB
Rust

use axum::{
Json,
extract::{Path, State},
};
use pinakes_core::users::{CreateUserRequest, UpdateUserRequest, UserId};
use crate::{
dto::{
GrantLibraryAccessRequest,
RevokeLibraryAccessRequest,
UserLibraryResponse,
UserResponse,
},
error::ApiError,
state::AppState,
};
/// List all users (admin only)
#[utoipa::path(
get,
path = "/api/v1/admin/users",
tag = "users",
responses(
(status = 200, description = "List of users", body = Vec<UserResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn list_users(
State(state): State<AppState>,
) -> Result<Json<Vec<UserResponse>>, ApiError> {
let users = state.storage.list_users().await?;
Ok(Json(users.into_iter().map(UserResponse::from).collect()))
}
/// Create a new user (admin only)
#[utoipa::path(
post,
path = "/api/v1/admin/users",
tag = "users",
request_body(
content = inline(serde_json::Value),
description = "username, password, role, and optional profile fields",
content_type = "application/json"
),
responses(
(status = 200, description = "User created", body = UserResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_user(
State(state): State<AppState>,
Json(req): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>, ApiError> {
// Validate username
if req.username.is_empty() || req.username.len() > 255 {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"username must be 1-255 characters".into(),
),
));
}
// Validate password
if req.password.len() < 8 {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"password must be at least 8 characters".into(),
),
));
}
// Hash password
let password_hash = pinakes_core::users::auth::hash_password(&req.password)?;
// Create user - rely on DB unique constraint for username to avoid TOCTOU
// race
let user = state
.storage
.create_user(&req.username, &password_hash, req.role, req.profile)
.await
.map_err(|e| {
// Map unique constraint violations to a user-friendly conflict error
let err_str = e.to_string();
if err_str.contains("UNIQUE")
|| err_str.contains("unique")
|| err_str.contains("duplicate key")
{
ApiError(pinakes_core::error::PinakesError::DuplicateHash(
"username already exists".into(),
))
} else {
ApiError(e)
}
})?;
Ok(Json(UserResponse::from(user)))
}
/// Get a specific user by ID
#[utoipa::path(
get,
path = "/api/v1/admin/users/{id}",
tag = "users",
params(("id" = String, Path, description = "User ID")),
responses(
(status = 200, description = "User details", body = UserResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_user(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<UserResponse>, ApiError> {
let user_id: UserId =
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Invalid user ID".into(),
))
})?;
let user = state.storage.get_user(user_id).await?;
Ok(Json(UserResponse::from(user)))
}
/// Update a user
#[utoipa::path(
patch,
path = "/api/v1/admin/users/{id}",
tag = "users",
params(("id" = String, Path, description = "User ID")),
request_body(
content = inline(serde_json::Value),
description = "Optional password, role, or profile fields to update",
content_type = "application/json"
),
responses(
(status = 200, description = "User updated", body = UserResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_user(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, ApiError> {
let user_id: UserId =
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Invalid user ID".into(),
))
})?;
// Hash password if provided
let password_hash = if let Some(ref password) = req.password {
if password.len() < 8 {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"password must be at least 8 characters".into(),
),
));
}
Some(pinakes_core::users::auth::hash_password(password)?)
} else {
None
};
let user = state
.storage
.update_user(user_id, password_hash.as_deref(), req.role, req.profile)
.await?;
Ok(Json(UserResponse::from(user)))
}
/// Delete a user (admin only)
#[utoipa::path(
delete,
path = "/api/v1/admin/users/{id}",
tag = "users",
params(("id" = String, Path, description = "User ID")),
responses(
(status = 200, description = "User deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_user(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user_id: UserId =
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Invalid user ID".into(),
))
})?;
state.storage.delete_user(user_id).await?;
Ok(Json(serde_json::json!({"deleted": true})))
}
/// Get user's accessible libraries
#[utoipa::path(
get,
path = "/api/v1/admin/users/{id}/libraries",
tag = "users",
params(("id" = String, Path, description = "User ID")),
responses(
(status = 200, description = "User libraries", body = Vec<UserLibraryResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn get_user_libraries(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Vec<UserLibraryResponse>>, ApiError> {
let user_id: UserId =
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Invalid user ID".into(),
))
})?;
let libraries = state.storage.get_user_libraries(user_id).await?;
Ok(Json(
libraries
.into_iter()
.map(UserLibraryResponse::from)
.collect(),
))
}
fn validate_root_path(path: &str) -> Result<(), ApiError> {
if path.is_empty() || path.len() > 4096 {
return Err(ApiError::bad_request("root_path must be 1-4096 bytes"));
}
if !path.starts_with('/') {
return Err(ApiError::bad_request("root_path must be an absolute path"));
}
if path.split('/').any(|segment| segment == "..") {
return Err(ApiError::bad_request(
"root_path must not contain '..' traversal components",
));
}
Ok(())
}
/// Grant library access to a user (admin only)
#[utoipa::path(
post,
path = "/api/v1/admin/users/{id}/libraries",
tag = "users",
params(("id" = String, Path, description = "User ID")),
request_body = GrantLibraryAccessRequest,
responses(
(status = 200, description = "Access granted"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn grant_library_access(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<GrantLibraryAccessRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
validate_root_path(&req.root_path)?;
let user_id: UserId =
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Invalid user ID".into(),
))
})?;
state
.storage
.grant_library_access(user_id, &req.root_path, req.permission)
.await?;
Ok(Json(serde_json::json!({"granted": true})))
}
/// Revoke library access from a user (admin only)
///
/// Uses a JSON body instead of a path parameter because `root_path` may contain
/// slashes that conflict with URL routing.
#[utoipa::path(
delete,
path = "/api/v1/admin/users/{id}/libraries",
tag = "users",
params(("id" = String, Path, description = "User ID")),
request_body = RevokeLibraryAccessRequest,
responses(
(status = 200, description = "Access revoked"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn revoke_library_access(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<RevokeLibraryAccessRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
validate_root_path(&req.root_path)?;
let user_id: UserId =
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Invalid user ID".into(),
))
})?;
state
.storage
.revoke_library_access(user_id, &req.root_path)
.await?;
Ok(Json(serde_json::json!({"revoked": true})))
}