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), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden"), ), security(("bearer_auth" = [])) )] pub async fn list_users( State(state): State, ) -> Result>, 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, Json(req): Json, ) -> Result, 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, Path(id): Path, ) -> Result, ApiError> { let user_id: UserId = id.parse::().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, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let user_id: UserId = id.parse::().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, Path(id): Path, ) -> Result, ApiError> { let user_id: UserId = id.parse::().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), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden"), ), security(("bearer_auth" = [])) )] pub async fn get_user_libraries( State(state): State, Path(id): Path, ) -> Result>, ApiError> { let user_id: UserId = id.parse::().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, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { validate_root_path(&req.root_path)?; let user_id: UserId = id.parse::().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, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { validate_root_path(&req.root_path)?; let user_id: UserId = id.parse::().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}))) }