Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
197 lines
5.4 KiB
Rust
197 lines
5.4 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Path, State},
|
|
};
|
|
use pinakes_core::users::{CreateUserRequest, UpdateUserRequest, UserId};
|
|
|
|
use crate::{dto::*, error::ApiError, state::AppState};
|
|
|
|
/// List all users (admin only)
|
|
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)
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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(),
|
|
))
|
|
}
|
|
|
|
/// Grant library access to a user (admin only)
|
|
pub async fn grant_library_access(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<GrantLibraryAccessRequest>,
|
|
) -> 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
|
|
.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.
|
|
pub async fn revoke_library_access(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<RevokeLibraryAccessRequest>,
|
|
) -> 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
|
|
.revoke_library_access(user_id, &req.root_path)
|
|
.await?;
|
|
Ok(Json(serde_json::json!({"revoked": true})))
|
|
}
|