From a2afedea6656dbe5ed4122859a958eda734364cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Wed, 26 Feb 2025 14:57:11 +0100 Subject: [PATCH 1/6] [48] implement user endpoints --- src/routes/auth.rs | 14 +- src/routes/mod.rs | 2 + src/routes/swagger.rs | 12 + src/routes/tournament.rs | 4 +- src/routes/user.rs | 525 +++++++++++++++++++++++++++++++++++++++ src/users/infradmin.rs | 4 +- src/users/mod.rs | 33 ++- src/users/photourl.rs | 75 ++++-- src/users/queries.rs | 146 ----------- 9 files changed, 644 insertions(+), 171 deletions(-) create mode 100644 src/routes/user.rs delete mode 100644 src/users/queries.rs diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 1af4b8a..5a945b0 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -44,6 +44,8 @@ pub struct LoginRequest { /// Providing the token either by including it in the /// request header or sending the cookie is required /// to perform any further operations. +/// By default, the only existing account is the infrastructure admin +/// with username and password "admin". #[utoipa::path(post, path = "/auth/login", request_body=LoginRequest, responses ( @@ -59,7 +61,7 @@ pub struct LoginRequest { ) ) ] -pub async fn auth_login( +async fn auth_login( cookies: Cookies, State(state): State, Json(body): Json, @@ -165,3 +167,13 @@ async fn auth_clear_to_response( Err(e) => e.respond(), } } + +fn get_admin_credentials() -> String { + r#" + { + "login": "admin", + "password": "admin" + } + "# + .to_owned() +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 430c8fe..f1ad5e2 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -12,6 +12,7 @@ mod swagger; mod team; mod teapot; mod tournament; +mod user; mod utils; mod version; @@ -28,4 +29,5 @@ pub fn routes() -> Router { .merge(attendee::route()) .merge(motion::route()) .merge(debate::route()) + .merge(user::route()) } diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 1e5c09d..364abe2 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -3,6 +3,7 @@ use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use crate::routes::auth; +use crate::routes::user; use crate::setup::AppState; use crate::routes::attendee; @@ -11,6 +12,7 @@ use crate::routes::motion; use crate::routes::team; use crate::routes::tournament; use crate::users::permissions; +use crate::users::photourl; use crate::users::roles; use super::health_check; @@ -56,6 +58,12 @@ pub fn route() -> Router { attendee::patch_attendee_by_id, attendee::delete_attendee_by_id, auth::auth_login, + auth::auth_clear, + user::get_users, + user::create_user, + user::get_user_by_id, + user::patch_user_by_id, + user::delete_user_by_id, ), components(schemas( version::VersionDetails, @@ -74,6 +82,10 @@ pub fn route() -> Router { permissions::Permission, roles::Role, auth::LoginRequest, + user::UserWithPassword, + user::UserPatch, + crate::users::User, + photourl::PhotoUrl )) )] diff --git a/src/routes/tournament.rs b/src/routes/tournament.rs index 41a3268..ea8dda2 100644 --- a/src/routes/tournament.rs +++ b/src/routes/tournament.rs @@ -9,7 +9,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use sqlx::{query, query_as, Pool, Postgres}; use tower_cookies::Cookies; -use tracing::{error, info}; +use tracing::error; use utoipa::ToSchema; use uuid::Uuid; @@ -19,7 +19,7 @@ use uuid::Uuid; pub struct Tournament { #[serde(skip_deserializing)] #[serde(default = "Uuid::now_v7")] - id: Uuid, + pub id: Uuid, // Full name of the tournament. Must be unique. full_name: String, shortened_name: String, diff --git a/src/routes/user.rs b/src/routes/user.rs new file mode 100644 index 0000000..606a289 --- /dev/null +++ b/src/routes/user.rs @@ -0,0 +1,525 @@ +use crate::{omni_error::OmniError, setup::AppState, users::{photourl::PhotoUrl, roles::Role, User}}; +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use rand::rngs::OsRng; +use serde::Deserialize; +use sqlx::{query, Pool, Postgres}; +use tower_cookies::Cookies; +use tracing::error; +use utoipa::ToSchema; +use uuid::Uuid; +use serde_json::Error as JsonError; + +use super::tournament::Tournament; + +#[derive(Deserialize, ToSchema)] +pub struct UserPatch { + pub handle: Option, + pub picture_link: Option, + pub password: Option, +} + + +#[derive(Clone, Deserialize, ToSchema)] +pub struct UserWithPassword { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + pub id: Uuid, + pub handle: String, + pub picture_link: Option, + pub password: String, +} + +impl User { + pub async fn get_by_id(id: Uuid, pool: &Pool) -> Result { + let user = + sqlx::query!("SELECT handle, picture_link FROM users WHERE id = $1", id) + .fetch_one(pool) + .await?; + + Ok(User { + id, + handle: user.handle, + picture_link: match user.picture_link { + Some(url) => Some(PhotoUrl::new(&url)?), + None => None, + }, + }) + } + + pub async fn get_by_handle( + handle: &str, + pool: &Pool, + ) -> Result { + let user = sqlx::query!( + "SELECT id, picture_link FROM users WHERE handle = $1", + handle + ) + .fetch_one(pool) + .await?; + + Ok(User { + id: user.id, + handle: handle.to_string(), + picture_link: match user.picture_link { + Some(url) => Some(PhotoUrl::new(&url)?), + None => None, + }, + }) + } + + pub async fn get_all(pool: &Pool) -> Result, OmniError> { + let users = sqlx::query!("SELECT id, handle, picture_link FROM users") + .fetch_all(pool) + .await? + .iter() + .map(|u| { + Ok(User { + id: u.id, + handle: u.handle.clone(), + picture_link: match u.picture_link.clone() { + Some(url) => Some(PhotoUrl::new(&url)?), + None => None, + }, + }) + }) + .collect::, OmniError>>()?; + Ok(users) + } + + pub async fn post( + user: User, + pass: String, + pool: &Pool, + ) -> Result { + let pic = match &user.picture_link { + Some(url) => Some(url.as_str()), + None => None, + }; + let hash = { + let argon = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + match argon.hash_password(pass.as_bytes(), &salt) { + Ok(hash) => hash.to_string(), + Err(e) => return Err(e)?, + } + }; + match sqlx::query!( + "INSERT INTO users VALUES ($1, $2, $3, $4)", + &user.id, + &user.handle, + pic, + hash + ) + .execute(pool) + .await + { + Ok(_) => Ok(user), + Err(e) => Err(e)?, + } + } + + + pub async fn patch( + self, + patch: UserPatch, + pool: &Pool, + ) -> Result { + let picture_link = match &patch.picture_link { + Some(url) => Some(url.clone()), + None => self.picture_link.clone(), + }; + let updated_user = User { + id: self.id, + handle: patch.handle.clone().unwrap_or(self.handle.clone()), + picture_link + }; + if patch.password != None { + self.update_user_and_change_password(&patch, pool).await?; + } + self.update_user_without_changing_password(&patch, pool).await?; + Ok(updated_user) + } + + async fn update_user_and_change_password(&self, patch: &UserPatch, pool: &Pool) -> Result<(), OmniError> { + let picture_link = match &patch.picture_link { + Some(url) => Some(url.as_url().to_string()), + None => Some(self.picture_link.as_ref().unwrap().as_str().to_owned()), + }; + let password_hash = self.generate_password_hash(&patch.password.as_ref().unwrap()).unwrap().clone(); + match query!("UPDATE users SET handle = $1, picture_link = $2, password_hash = $3 WHERE id = $4", + patch.handle, + picture_link, + password_hash, + self.id + ).execute(pool).await { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + async fn update_user_without_changing_password(&self, patch: &UserPatch, pool: &Pool) -> Result<(), OmniError> { + let picture_link = match &patch.picture_link { + Some(url) => Some(url.as_url().to_string()), + None => Some(self.picture_link.as_ref().unwrap().as_str().to_owned()), + }; + match query!("UPDATE users SET handle = $1, picture_link = $2 WHERE id = $3", + patch.handle, + picture_link, + self.id + ).execute(pool).await { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM users WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => { + Err(e)? + } + } + } + + // ---------- DATABASE HELPERS ---------- + pub async fn get_roles( + &self, + tournament: Uuid, + pool: &Pool, + ) -> Result, OmniError> { + let roles_result = sqlx::query!( + "SELECT roles FROM roles WHERE user_id = $1 AND tournament_id = $2", + self.id, + tournament + ) + .fetch_optional(pool) + .await?; + + if roles_result.is_none() { + return Ok(vec![]); + } + + let roles = roles_result.unwrap().roles; + let vec = match roles { + Some(vec) => vec + .iter() + .map(|role| serde_json::from_str(role.as_str())) + .collect::, JsonError>>()?, + None => vec![], + }; + Ok(vec) + } + + pub async fn is_organizer_of_any_tournament(&self, pool: &Pool) -> Result { + let tournaments = Tournament::get_all(pool).await?; + for tournament in tournaments { + let roles = self.get_roles(tournament.id, pool).await?; + if roles.contains(&Role::Organizer) { + return Ok(true); + } + } + return Ok(false); + + } + + pub async fn invalidate_all_sessions(&self, pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM sessions WHERE user_id = $1", self.id).execute(pool).await { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + fn generate_password_hash(&self, password: &str) -> Result { + let hash = { + let argon = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + match argon.hash_password(password.as_bytes(), &salt) { + Ok(hash) => hash.to_string(), + Err(e) => return Err(e)?, + } + }; + Ok(hash) + } +} + +impl From for User { + fn from(value: UserWithPassword) -> Self { + User { + id: value.id, + handle: value.handle, + picture_link: value.picture_link + } + } +} + +pub fn route() -> Router { + Router::new() + .route("/user", get(get_users).post(create_user)) + .route( + "/user/:id", + get(get_user_by_id) + .delete(delete_user_by_id) + .patch(patch_user_by_id), + ) +} + +/// Get a list of all users +/// +/// This request only returns the users the user is permitted to see. +/// The user must be given any role within a user to see it. +#[utoipa::path(get, path = "/user", + responses( + ( + status=200, description = "Ok", + body=Vec, + example=json!(get_users_list_example()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "Authentication error" + ), + (status=500, description = "Internal server error") +))] +async fn get_users( + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + User::authenticate(&headers, cookies, pool).await?; + + match User::get_all(pool).await { + Ok(users) => Ok(Json(users).into_response()), + Err(e) => { + error!("Error listing users: {e}"); + Err(e)? + } + } +} + +/// Create a new user +/// +/// Available to the infrastructure admin and tournament Organizers. +#[utoipa::path( + post, + request_body=User, + path = "/user", + responses + ( + ( + status=200, + description = "User created successfully", + body=User, + example=json!(get_user_example_with_id()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to create users" + ), + (status=404, description = "User not found"), + (status=500, description = "Internal server error") + ) +)] +async fn create_user( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(json): Json, +) -> Result { + let pool = &state.connection_pool; + let user = User::authenticate(&headers, cookies, &pool).await?; + if !user.is_infrastructure_admin() && !user.is_organizer_of_any_tournament(pool).await? { + return Err(OmniError::UnauthorizedError); + } + + let user_without_password = User::from(json.clone()); + match User::post(user_without_password, json.password, pool).await { + Ok(user) => Ok(Json(user).into_response()), + Err(e) => { + error!("Error creating a new user: {e}"); + Err(e)? + } + } +} + +/// Get details of an existing user +/// +/// Every user is permitted to use this endpoint. +#[utoipa::path(get, path = "/user/{id}", + responses + ( + ( + status=200, description = "Ok", body=User, + example=json! + (get_user_example_with_id()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "Authentication error" + ), + (status=404, description = "User not found"), + (status=500, description = "Internal server error") + ), +)] +async fn get_user_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + User::authenticate(&headers, cookies, pool).await?; + + match User::get_by_id(id, pool).await { + Ok(user) => Ok(Json(user).into_response()), + Err(e) => { + error!("Error getting a user with id {}: {e}", id); + Err(e) + } + } +} + +/// Patch an existing user +/// +/// Available to the infrastructure admin and the user modifying their own account. +#[utoipa::path(patch, path = "/user/{id}", + request_body=UserPatch, + responses( + ( + status=200, description = "User patched successfully", + body=User, + example=json!(get_user_example_with_id()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify this user" + ), + (status=404, description = "User not found"), + (status=409, description = "A user with this name already exists"), + (status=500, description = "Internal server error") + ) +)] +async fn patch_user_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(new_user): Json, +) -> Result { + let pool = &state.connection_pool; + let requesting_user = + User::authenticate(&headers, cookies, &pool).await?; + + let user_to_be_patched = User::get_by_id(id, pool).await?; + + match requesting_user.is_infrastructure_admin() || requesting_user.id == user_to_be_patched.id { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + match requesting_user.patch(new_user, pool).await { + Ok(patched_user) => Ok(Json(patched_user).into_response()), + Err(e) => { + error!("Error patching a user with id {}: {e}", id); + Err(e)? + } + } +} + + +/// Delete an existing user. +/// +/// Available only to the infrastructure admin, +/// who's account cannot be deleted. +/// Deleted user is automatically logged out of all sessions. +/// This operation is only allowed when there are no resources +/// referencing this user. +#[utoipa::path(delete, path = "/user/{id}", + responses( + (status=204, description = "User deleted successfully"), + (status=400, description = "Bad request"), + (status=401, description = "The user is not permitted to delete this user"), + (status=404, description = "User not found"), + (status=409, description = "Other resources reference this user. They must be deleted first") + ), +)] +async fn delete_user_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + let requesting_user = + User::authenticate(&headers, cookies, pool).await?; + + match requesting_user.is_infrastructure_admin() { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + let user_to_be_deleted = User::get_by_id(id, pool).await?; + + match user_to_be_deleted.is_infrastructure_admin() { + true => return Err(OmniError::UnauthorizedError), + false => () + } + + user_to_be_deleted.invalidate_all_sessions(pool).await?; + match user_to_be_deleted.delete(pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => + { + if e.is_sqlx_foreign_key_violation() { + return Err(OmniError::DependentResourcesError) + } + else { + error!("Error deleting a user with id {id}: {e}"); + return Err(e)?; + } + }, + } +} + +fn get_user_example_with_id() -> String { + r#" + { + "id": "01941265-8b3c-733f-a6ae-075c079f2f81", + "handle": "jmanczak", + "picture_link": "https://placehold.co/128x128" + } + "# + .to_owned() +} + +fn get_users_list_example() -> String { + r#" + [ + { + "id": "01941265-8b3c-733f-a6ae-075c079f2f81", + "handle": "jmanczak", + "picture_link": "https://placehold.co/128x128" + }, + { + "id": "01941265-8b3c-733f-a6ae-091c079c2921", + "handle": "Matthew Goodman", + "picture_link": "https://placehold.co/128x128" + } + ] + "#.to_owned() +} diff --git a/src/users/infradmin.rs b/src/users/infradmin.rs index 2cbfff9..33901d6 100644 --- a/src/users/infradmin.rs +++ b/src/users/infradmin.rs @@ -17,7 +17,7 @@ impl User { User { id: Uuid::max(), handle: String::from("admin"), - profile_picture: None, + picture_link: None, } } } @@ -30,7 +30,7 @@ pub async fn guarantee_infrastructure_admin_exists(pool: &Pool) { Ok(Some(_)) => (), Ok(None) => { let admin = User::new_infrastructure_admin(); - match User::create(admin, "admin".to_string(), pool).await { + match User::post(admin, "admin".to_string(), pool).await { Ok(_) => info!("Infrastructure admin created."), Err(e) => { let err = OmniError::from(e); diff --git a/src/users/mod.rs b/src/users/mod.rs index b33639a..e9fa6c5 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -5,6 +5,7 @@ use roles::Role; use serde::Serialize; use sqlx::{Pool, Postgres}; use tower_cookies::Cookies; +use utoipa::ToSchema; use uuid::Uuid; use crate::omni_error::OmniError; @@ -13,14 +14,16 @@ pub mod auth; pub mod infradmin; pub mod permissions; pub mod photourl; -pub mod queries; pub mod roles; -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, ToSchema)] pub struct User { pub id: Uuid, + /// User handle used to log in and presented to other users. + /// Must be unique. pub handle: String, - pub profile_picture: Option, + /// A link to a profile picture. Accepted extensions are: png, jpg, jpeg, and webp. + pub picture_link: Option, } pub struct TournamentUser { @@ -55,6 +58,26 @@ impl TournamentUser { .any(|role| role.get_role_permissions().contains(&permission)) } } + + pub async fn get_by_id( + user: Uuid, + tournament: Uuid, + pool: &Pool, + ) -> Result { + let user = User::get_by_id(user, pool).await?; + let roles = user.get_roles(tournament, pool).await?; + Ok(TournamentUser { user, roles }) + } + + pub async fn get_by_handle( + handle: &str, + tournament: Uuid, + pool: &Pool, + ) -> Result { + let user = User::get_by_handle(handle, pool).await?; + let roles = user.get_roles(tournament, pool).await?; + Ok(TournamentUser { user, roles }) + } } #[test] @@ -63,9 +86,7 @@ fn construct_tournament_user() { user: User { id: Uuid::now_v7(), handle: String::from("some_org"), - profile_picture: Some( - PhotoUrl::new("https://i.imgur.com/hbrb2U0.png").unwrap(), - ), + picture_link: Some(PhotoUrl::new("https://i.imgur.com/hbrb2U0.png").unwrap()), }, roles: vec![Role::Organizer, Role::Judge, Role::Marshall], }; diff --git a/src/users/photourl.rs b/src/users/photourl.rs index 3527c99..12d884a 100644 --- a/src/users/photourl.rs +++ b/src/users/photourl.rs @@ -1,18 +1,21 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::error::Error; use url::Url; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Clone, Deserialize, ToSchema)] +#[serde(try_from = "String", into = "String")] pub struct PhotoUrl { url: Url, } +/// A type for storing links to photo URLs. When constructed, the link is automatically validated. impl PhotoUrl { pub fn new(str: &str) -> Result { let url = Url::parse(str).map_err(PhotoUrlError::InvalidUrl)?; if PhotoUrl::has_valid_extension(&url) { - Ok(Self { url }) + Ok(PhotoUrl { url }) } else { Err(PhotoUrlError::InvalidUrlExtension) } @@ -22,6 +25,10 @@ impl PhotoUrl { &self.url } + pub fn as_str(&self) -> &str { + self.url.as_str() + } + fn has_valid_extension(url: &Url) -> bool { let path = url.path(); if let Some(filename) = path.split("/").last() { @@ -39,6 +46,20 @@ impl PhotoUrl { } } +impl TryFrom for PhotoUrl { + type Error = PhotoUrlError; + + fn try_from(value: String) -> Result { + PhotoUrl::new(&value) + } +} + +impl Into for PhotoUrl { + fn into(self) -> String { + self.as_str().to_owned() + } +} + #[derive(Debug)] pub enum PhotoUrlError { InvalidUrl(url::ParseError), @@ -58,9 +79,13 @@ impl std::fmt::Display for PhotoUrlError { impl Error for PhotoUrlError {} -#[test] -fn valid_extension_test() { - let expect_false = vec![ +#[cfg(test)] +mod tests { + use url::Url; + + use crate::users::photourl::{PhotoUrl, PhotoUrlError}; + + const EXPECT_FALSE: [&str; 10] = [ "https://manczak.net", "unix://hello.net/apng", "unix://hello.net/ajpg", @@ -72,20 +97,42 @@ fn valid_extension_test() { "unix://hello.net/a/.jpg", "unix://hello.net/a./jpg", ]; - for url in expect_false { - let url = Url::parse(url).unwrap(); - assert!(PhotoUrl::has_valid_extension(&url) == false); - } - let expect_true = vec![ + + const EXPECT_TRUE: [&str; 7] = [ "https://manczak.net/jmanczak.png", "https://manczak.net/jmanczak.jpg", "https://manczak.net/jmanczak.jpeg", "unix://hello.net/a.jpeg", "unix://hello.net/a.jpg", "unix://hello.net/a.png", + "https://placehold.co/128x128.png", ]; - for url in expect_true { - let url = Url::parse(url).unwrap(); - assert!(PhotoUrl::has_valid_extension(&url) == true); + + #[test] + fn valid_extension_test() { + for url in EXPECT_FALSE { + let url = Url::parse(url).unwrap(); + assert!(PhotoUrl::has_valid_extension(&url) == false); + } + for url in EXPECT_TRUE { + let url = Url::parse(url).unwrap(); + assert!(PhotoUrl::has_valid_extension(&url) == true); + } + } + + #[test] + fn photo_url_deserialization() { + for url in EXPECT_TRUE { + let str = format!("\"{url}\""); + let _json: PhotoUrl = serde_json::from_str(&str).unwrap(); + } + } + + #[test] + fn photo_bad_url_deserialization() { + for url in EXPECT_FALSE { + let json: Result = serde_json::from_str(url); + assert!(json.is_err()); + } } } diff --git a/src/users/queries.rs b/src/users/queries.rs deleted file mode 100644 index 8fcf84d..0000000 --- a/src/users/queries.rs +++ /dev/null @@ -1,146 +0,0 @@ -use super::{photourl::PhotoUrl, roles::Role, TournamentUser, User}; -use crate::omni_error::OmniError; -use argon2::{ - password_hash::{rand_core::OsRng, SaltString}, - Argon2, PasswordHasher, -}; -use serde_json::Error as JsonError; -use sqlx::{Pool, Postgres}; -use uuid::Uuid; - -impl User { - pub async fn get_by_id(id: Uuid, pool: &Pool) -> Result { - let user = - sqlx::query!("SELECT handle, picture_link FROM users WHERE id = $1", id) - .fetch_one(pool) - .await?; - - Ok(User { - id, - handle: user.handle, - profile_picture: match user.picture_link { - Some(url) => Some(PhotoUrl::new(&url)?), - None => None, - }, - }) - } - pub async fn get_by_handle( - handle: &str, - pool: &Pool, - ) -> Result { - let user = sqlx::query!( - "SELECT id, picture_link FROM users WHERE handle = $1", - handle - ) - .fetch_one(pool) - .await?; - - Ok(User { - id: user.id, - handle: handle.to_string(), - profile_picture: match user.picture_link { - Some(url) => Some(PhotoUrl::new(&url)?), - None => None, - }, - }) - } - pub async fn get_all(pool: &Pool) -> Result, OmniError> { - let users = sqlx::query!("SELECT id, handle, picture_link FROM users") - .fetch_all(pool) - .await? - .iter() - .map(|u| { - Ok(User { - id: u.id, - handle: u.handle.clone(), - profile_picture: match u.picture_link.clone() { - Some(url) => Some(PhotoUrl::new(&url)?), - None => None, - }, - }) - }) - .collect::, OmniError>>()?; - Ok(users) - } - pub async fn create( - user: User, - pass: String, - pool: &Pool, - ) -> Result { - let pic = match &user.profile_picture { - Some(url) => Some(url.as_url().to_string()), - None => None, - }; - let hash = { - let argon = Argon2::default(); - let salt = SaltString::generate(&mut OsRng); - match argon.hash_password(pass.as_bytes(), &salt) { - Ok(hash) => hash.to_string(), - Err(e) => return Err(e)?, - } - }; - match sqlx::query!( - "INSERT INTO users VALUES ($1, $2, $3, $4)", - &user.id, - &user.handle, - pic, - hash - ) - .execute(pool) - .await - { - Ok(_) => Ok(user), - Err(e) => Err(e)?, - } - } - // ---------- DATABASE HELPERS ---------- - pub async fn get_roles( - &self, - tournament: Uuid, - pool: &Pool, - ) -> Result, OmniError> { - let roles_result = sqlx::query!( - "SELECT roles FROM roles WHERE user_id = $1 AND tournament_id = $2", - self.id, - tournament - ) - .fetch_optional(pool) - .await?; - - if roles_result.is_none() { - return Ok(vec![]); - } - - let roles = roles_result.unwrap().roles; - let vec = match roles { - Some(vec) => vec - .iter() - .map(|role| serde_json::from_str(role.as_str())) - .collect::, JsonError>>()?, - None => vec![], - }; - - Ok(vec) - } -} - -impl TournamentUser { - pub async fn get_by_id( - user: Uuid, - tournament: Uuid, - pool: &Pool, - ) -> Result { - let user = User::get_by_id(user, pool).await?; - let roles = user.get_roles(tournament, pool).await?; - Ok(TournamentUser { user, roles }) - } - pub async fn get_by_handle( - handle: &str, - tournament: Uuid, - pool: &Pool, - ) -> Result { - let user = User::get_by_handle(handle, pool).await?; - let roles = user.get_roles(tournament, pool).await?; - Ok(TournamentUser { user, roles }) - } -} From e657c6d400fe7b754402b6c1e2c077ddded5b811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Wed, 26 Feb 2025 21:10:20 +0100 Subject: [PATCH 2/6] [48] code cleanup --- ...64e714b357ec8353cde04d572bb63eb4b6397.json | 16 +++++++ ...71cef087a159c6ee7182d8ca929ecb748f3b7.json | 14 ++++++ ...17cc0d9425384b7b164e98cc472a14eb8702f.json | 6 +-- ...4113eff29e4d2bf4387e506a11984fdc8e107.json | 6 +-- ...1dd3f2c8a5898d003d002bca7f3034906ba20.json | 2 +- ...06247399abb8c545c1c03718fd97261870418.json | 17 ++++++++ ...4f244f72dccb60ab15e40d41a9f43d988cbc4.json | 2 +- ...5ff75e1c3fce230f849bd6f28769256c7df42.json | 2 +- ...2a3d14a8bdb38cbdad2069ecea6b100bee629.json | 14 ++++++ ...04de65a2b089d086ac6cad6301c7dd26a6aa7.json | 2 +- src/routes/auth.rs | 10 ----- src/routes/user.rs | 43 ++++++++----------- 12 files changed, 89 insertions(+), 45 deletions(-) create mode 100644 .sqlx/query-47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397.json create mode 100644 .sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json create mode 100644 .sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json create mode 100644 .sqlx/query-e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629.json diff --git a/.sqlx/query-47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397.json b/.sqlx/query-47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397.json new file mode 100644 index 0000000..c8b655e --- /dev/null +++ b/.sqlx/query-47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET handle = $1, picture_link = $2 WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "47ccf2685590c3652d1ea4e4fb764e714b357ec8353cde04d572bb63eb4b6397" +} diff --git a/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json b/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json new file mode 100644 index 0000000..f62678a --- /dev/null +++ b/.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM users WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7" +} diff --git a/.sqlx/query-5abe4f4116b3861dff0a9a34f5f17cc0d9425384b7b164e98cc472a14eb8702f.json b/.sqlx/query-5abe4f4116b3861dff0a9a34f5f17cc0d9425384b7b164e98cc472a14eb8702f.json index fb7d1b8..5621db9 100644 --- a/.sqlx/query-5abe4f4116b3861dff0a9a34f5f17cc0d9425384b7b164e98cc472a14eb8702f.json +++ b/.sqlx/query-5abe4f4116b3861dff0a9a34f5f17cc0d9425384b7b164e98cc472a14eb8702f.json @@ -10,12 +10,12 @@ }, { "ordinal": 1, - "name": "marshall_user_id", + "name": "motion_id", "type_info": "Uuid" }, { "ordinal": 2, - "name": "motion_id", + "name": "marshall_user_id", "type_info": "Uuid" }, { @@ -30,7 +30,7 @@ "nullable": [ false, true, - true, + false, false ] }, diff --git a/.sqlx/query-864ac7f88d9bc3eeef8d9ab3ed84113eff29e4d2bf4387e506a11984fdc8e107.json b/.sqlx/query-864ac7f88d9bc3eeef8d9ab3ed84113eff29e4d2bf4387e506a11984fdc8e107.json index b8fe941..25e1e0a 100644 --- a/.sqlx/query-864ac7f88d9bc3eeef8d9ab3ed84113eff29e4d2bf4387e506a11984fdc8e107.json +++ b/.sqlx/query-864ac7f88d9bc3eeef8d9ab3ed84113eff29e4d2bf4387e506a11984fdc8e107.json @@ -10,12 +10,12 @@ }, { "ordinal": 1, - "name": "marshall_user_id", + "name": "motion_id", "type_info": "Uuid" }, { "ordinal": 2, - "name": "motion_id", + "name": "marshall_user_id", "type_info": "Uuid" }, { @@ -32,7 +32,7 @@ "nullable": [ false, true, - true, + false, false ] }, diff --git a/.sqlx/query-958612cd17da15782d75e3c626a1dd3f2c8a5898d003d002bca7f3034906ba20.json b/.sqlx/query-958612cd17da15782d75e3c626a1dd3f2c8a5898d003d002bca7f3034906ba20.json index 219579f..f636e2c 100644 --- a/.sqlx/query-958612cd17da15782d75e3c626a1dd3f2c8a5898d003d002bca7f3034906ba20.json +++ b/.sqlx/query-958612cd17da15782d75e3c626a1dd3f2c8a5898d003d002bca7f3034906ba20.json @@ -43,7 +43,7 @@ false, false, true, - true, + false, false, false ] diff --git a/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json b/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json new file mode 100644 index 0000000..94caeb0 --- /dev/null +++ b/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET handle = $1, picture_link = $2, password_hash = $3 WHERE id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418" +} diff --git a/.sqlx/query-d50400d2ecb00b4943f6d5221d84f244f72dccb60ab15e40d41a9f43d988cbc4.json b/.sqlx/query-d50400d2ecb00b4943f6d5221d84f244f72dccb60ab15e40d41a9f43d988cbc4.json index a95c75d..d6be5bc 100644 --- a/.sqlx/query-d50400d2ecb00b4943f6d5221d84f244f72dccb60ab15e40d41a9f43d988cbc4.json +++ b/.sqlx/query-d50400d2ecb00b4943f6d5221d84f244f72dccb60ab15e40d41a9f43d988cbc4.json @@ -35,7 +35,7 @@ "nullable": [ false, true, - true, + false, false ] }, diff --git a/.sqlx/query-e31c06ab13608a8a01145fd926b5ff75e1c3fce230f849bd6f28769256c7df42.json b/.sqlx/query-e31c06ab13608a8a01145fd926b5ff75e1c3fce230f849bd6f28769256c7df42.json index 7ab1103..9f2ec82 100644 --- a/.sqlx/query-e31c06ab13608a8a01145fd926b5ff75e1c3fce230f849bd6f28769256c7df42.json +++ b/.sqlx/query-e31c06ab13608a8a01145fd926b5ff75e1c3fce230f849bd6f28769256c7df42.json @@ -41,7 +41,7 @@ false, false, true, - true, + false, false, false ] diff --git a/.sqlx/query-e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629.json b/.sqlx/query-e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629.json new file mode 100644 index 0000000..d2e871a --- /dev/null +++ b/.sqlx/query-e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM sessions WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e9ee477fc969775d4a868a773162a3d14a8bdb38cbdad2069ecea6b100bee629" +} diff --git a/.sqlx/query-eb0968ee0779fc08c09a6ab506004de65a2b089d086ac6cad6301c7dd26a6aa7.json b/.sqlx/query-eb0968ee0779fc08c09a6ab506004de65a2b089d086ac6cad6301c7dd26a6aa7.json index 316ae15..1c5ab40 100644 --- a/.sqlx/query-eb0968ee0779fc08c09a6ab506004de65a2b089d086ac6cad6301c7dd26a6aa7.json +++ b/.sqlx/query-eb0968ee0779fc08c09a6ab506004de65a2b089d086ac6cad6301c7dd26a6aa7.json @@ -48,7 +48,7 @@ false, false, true, - true, + false, false, false ] diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 5a945b0..1605ca7 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -167,13 +167,3 @@ async fn auth_clear_to_response( Err(e) => e.respond(), } } - -fn get_admin_credentials() -> String { - r#" - { - "login": "admin", - "password": "admin" - } - "# - .to_owned() -} diff --git a/src/routes/user.rs b/src/routes/user.rs index 606a289..9e8f3da 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -95,21 +95,14 @@ impl User { pub async fn post( user: User, - pass: String, + password: String, pool: &Pool, ) -> Result { let pic = match &user.picture_link { Some(url) => Some(url.as_str()), None => None, }; - let hash = { - let argon = Argon2::default(); - let salt = SaltString::generate(&mut OsRng); - match argon.hash_password(pass.as_bytes(), &salt) { - Ok(hash) => hash.to_string(), - Err(e) => return Err(e)?, - } - }; + let hash = User::generate_password_hash(&password).unwrap(); match sqlx::query!( "INSERT INTO users VALUES ($1, $2, $3, $4)", &user.id, @@ -152,7 +145,7 @@ impl User { Some(url) => Some(url.as_url().to_string()), None => Some(self.picture_link.as_ref().unwrap().as_str().to_owned()), }; - let password_hash = self.generate_password_hash(&patch.password.as_ref().unwrap()).unwrap().clone(); + let password_hash = User::generate_password_hash(&patch.password.as_ref().unwrap()).unwrap().clone(); match query!("UPDATE users SET handle = $1, picture_link = $2, password_hash = $3 WHERE id = $4", patch.handle, picture_link, @@ -163,6 +156,18 @@ impl User { Err(e) => Err(e)?, } } + + fn generate_password_hash(password: &str) -> Result { + let hash = { + let argon = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + match argon.hash_password(password.as_bytes(), &salt) { + Ok(hash) => hash.to_string(), + Err(e) => return Err(e)?, + } + }; + Ok(hash) + } async fn update_user_without_changing_password(&self, patch: &UserPatch, pool: &Pool) -> Result<(), OmniError> { let picture_link = match &patch.picture_link { @@ -239,18 +244,6 @@ impl User { Err(e) => Err(e)?, } } - - fn generate_password_hash(&self, password: &str) -> Result { - let hash = { - let argon = Argon2::default(); - let salt = SaltString::generate(&mut OsRng); - match argon.hash_password(password.as_bytes(), &salt) { - Ok(hash) => hash.to_string(), - Err(e) => return Err(e)?, - } - }; - Ok(hash) - } } impl From for User { @@ -501,7 +494,7 @@ fn get_user_example_with_id() -> String { { "id": "01941265-8b3c-733f-a6ae-075c079f2f81", "handle": "jmanczak", - "picture_link": "https://placehold.co/128x128" + "picture_link": "https://placehold.co/128x128.png" } "# .to_owned() @@ -513,12 +506,12 @@ fn get_users_list_example() -> String { { "id": "01941265-8b3c-733f-a6ae-075c079f2f81", "handle": "jmanczak", - "picture_link": "https://placehold.co/128x128" + "picture_link": "https://placehold.co/128x128.png" }, { "id": "01941265-8b3c-733f-a6ae-091c079c2921", "handle": "Matthew Goodman", - "picture_link": "https://placehold.co/128x128" + "picture_link": "https://placehold.co/128x128.png" } ] "#.to_owned() From 7f92507f7454a8dbbffc1c95af028be2ba76079a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Wed, 26 Feb 2025 21:18:23 +0100 Subject: [PATCH 3/6] [48] fix user patch endpoint --- src/routes/user.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/user.rs b/src/routes/user.rs index 9e8f3da..34bd234 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -323,6 +323,7 @@ async fn get_users( description = "The user is not permitted to create users" ), (status=404, description = "User not found"), + (status=422, description = "Invalid picture link"), (status=500, description = "Internal server error") ) )] @@ -404,6 +405,7 @@ async fn get_user_by_id( ), (status=404, description = "User not found"), (status=409, description = "A user with this name already exists"), + (status=422, description = "Invalid picture link"), (status=500, description = "Internal server error") ) )] @@ -425,7 +427,7 @@ async fn patch_user_by_id( false => return Err(OmniError::UnauthorizedError), } - match requesting_user.patch(new_user, pool).await { + match user_to_be_patched.patch(new_user, pool).await { Ok(patched_user) => Ok(Json(patched_user).into_response()), Err(e) => { error!("Error patching a user with id {}: {e}", id); From bd48b4214648d662c99f3e3c6ceec9fa6ec4a516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Mon, 25 Aug 2025 13:11:40 +0200 Subject: [PATCH 4/6] [48] apply code review suggestions --- ...9e1860de7e7e46cddfd4dcb02a4452c9856bf.json | 15 ++ ...06247399abb8c545c1c03718fd97261870418.json | 17 -- src/routes/swagger.rs | 3 +- src/routes/user_routes.rs | 228 +---------------- src/users/infradmin.rs | 2 +- src/users/mod.rs | 10 +- src/users/queries.rs | 242 ++++++++++++++++++ 7 files changed, 273 insertions(+), 244 deletions(-) create mode 100644 .sqlx/query-24ea33795a75c8cf5a55ee719369e1860de7e7e46cddfd4dcb02a4452c9856bf.json delete mode 100644 .sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json create mode 100644 src/users/queries.rs diff --git a/.sqlx/query-24ea33795a75c8cf5a55ee719369e1860de7e7e46cddfd4dcb02a4452c9856bf.json b/.sqlx/query-24ea33795a75c8cf5a55ee719369e1860de7e7e46cddfd4dcb02a4452c9856bf.json new file mode 100644 index 0000000..bbe562f --- /dev/null +++ b/.sqlx/query-24ea33795a75c8cf5a55ee719369e1860de7e7e46cddfd4dcb02a4452c9856bf.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET password_hash = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "24ea33795a75c8cf5a55ee719369e1860de7e7e46cddfd4dcb02a4452c9856bf" +} diff --git a/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json b/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json deleted file mode 100644 index 94caeb0..0000000 --- a/.sqlx/query-ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET handle = $1, picture_link = $2, password_hash = $3 WHERE id = $4", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "ba18f9564ce40802573e18b6c3706247399abb8c545c1c03718fd97261870418" -} diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index cd2740a..9c202c1 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -23,6 +23,7 @@ use crate::tournament::team; use crate::users::permissions; use crate::users::photourl; use crate::users::roles; +use crate::users::UserPatch; use super::health_check; use super::teapot; @@ -107,7 +108,7 @@ pub fn route() -> Router { room::Room, room::RoomPatch, user_routes::UserWithPassword, - user_routes::UserPatch, + crate::users::UserPatch, crate::users::User, photourl::PhotoUrl )) diff --git a/src/routes/user_routes.rs b/src/routes/user_routes.rs index 1d050b3..8119d9c 100644 --- a/src/routes/user_routes.rs +++ b/src/routes/user_routes.rs @@ -1,5 +1,4 @@ -use crate::{omni_error::OmniError, setup::AppState, tournament::Tournament, users::{photourl::PhotoUrl, roles::Role, User}}; -use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use crate::{omni_error::OmniError, setup::AppState, users::{photourl::PhotoUrl, UserPatch, User}}; use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, @@ -7,9 +6,7 @@ use axum::{ routing::get, Json, Router, }; -use rand::rngs::OsRng; use serde::Deserialize; -use sqlx::{query, Pool, Postgres}; use tower_cookies::Cookies; use tracing::error; use utoipa::ToSchema; @@ -17,13 +14,6 @@ use uuid::Uuid; use serde_json::Error as JsonError; -#[derive(Deserialize, ToSchema)] -pub struct UserPatch { - pub handle: Option, - pub picture_link: Option, - pub password: Option, -} - #[derive(Clone, Deserialize, ToSchema)] pub struct UserWithPassword { @@ -35,216 +25,6 @@ pub struct UserWithPassword { pub password: String, } -impl User { - pub async fn get_by_id(id: Uuid, pool: &Pool) -> Result { - let user = - sqlx::query!("SELECT handle, picture_link FROM users WHERE id = $1", id) - .fetch_one(pool) - .await?; - - Ok(User { - id, - handle: user.handle, - picture_link: match user.picture_link { - Some(url) => Some(PhotoUrl::new(&url)?), - None => None, - }, - }) - } - - pub async fn get_by_handle( - handle: &str, - pool: &Pool, - ) -> Result { - let user = sqlx::query!( - "SELECT id, picture_link FROM users WHERE handle = $1", - handle - ) - .fetch_one(pool) - .await?; - - Ok(User { - id: user.id, - handle: handle.to_string(), - picture_link: match user.picture_link { - Some(url) => Some(PhotoUrl::new(&url)?), - None => None, - }, - }) - } - - pub async fn get_all(pool: &Pool) -> Result, OmniError> { - let users = sqlx::query!("SELECT id, handle, picture_link FROM users") - .fetch_all(pool) - .await? - .iter() - .map(|u| { - Ok(User { - id: u.id, - handle: u.handle.clone(), - picture_link: match u.picture_link.clone() { - Some(url) => Some(PhotoUrl::new(&url)?), - None => None, - }, - }) - }) - .collect::, OmniError>>()?; - Ok(users) - } - - pub async fn post( - user: User, - password: String, - pool: &Pool, - ) -> Result { - let pic = match &user.picture_link { - Some(url) => Some(url.as_str()), - None => None, - }; - let hash = User::generate_password_hash(&password).unwrap(); - match sqlx::query!( - "INSERT INTO users VALUES ($1, $2, $3, $4)", - &user.id, - &user.handle, - pic, - hash - ) - .execute(pool) - .await - { - Ok(_) => Ok(user), - Err(e) => Err(e)?, - } - } - - - pub async fn patch( - self, - patch: UserPatch, - pool: &Pool, - ) -> Result { - let picture_link = match &patch.picture_link { - Some(url) => Some(url.clone()), - None => self.picture_link.clone(), - }; - let updated_user = User { - id: self.id, - handle: patch.handle.clone().unwrap_or(self.handle.clone()), - picture_link - }; - if patch.password != None { - self.update_user_and_change_password(&patch, pool).await?; - } - self.update_user_without_changing_password(&patch, pool).await?; - Ok(updated_user) - } - - async fn update_user_and_change_password(&self, patch: &UserPatch, pool: &Pool) -> Result<(), OmniError> { - let picture_link = match &patch.picture_link { - Some(url) => Some(url.as_url().to_string()), - None => Some(self.picture_link.as_ref().unwrap().as_str().to_owned()), - }; - let password_hash = User::generate_password_hash(&patch.password.as_ref().unwrap()).unwrap().clone(); - match query!("UPDATE users SET handle = $1, picture_link = $2, password_hash = $3 WHERE id = $4", - patch.handle, - picture_link, - password_hash, - self.id - ).execute(pool).await { - Ok(_) => Ok(()), - Err(e) => Err(e)?, - } - } - - fn generate_password_hash(password: &str) -> Result { - let hash = { - let argon = Argon2::default(); - let salt = SaltString::generate(&mut OsRng); - match argon.hash_password(password.as_bytes(), &salt) { - Ok(hash) => hash.to_string(), - Err(e) => return Err(e)?, - } - }; - Ok(hash) - } - - async fn update_user_without_changing_password(&self, patch: &UserPatch, pool: &Pool) -> Result<(), OmniError> { - let picture_link = match &patch.picture_link { - Some(url) => Some(url.as_url().to_string()), - None => Some(self.picture_link.as_ref().unwrap().as_str().to_owned()), - }; - match query!("UPDATE users SET handle = $1, picture_link = $2 WHERE id = $3", - patch.handle, - picture_link, - self.id - ).execute(pool).await { - Ok(_) => Ok(()), - Err(e) => Err(e)?, - } - } - - - pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { - match query!("DELETE FROM users WHERE id = $1", self.id) - .execute(connection_pool) - .await - { - Ok(_) => Ok(()), - Err(e) => { - Err(e)? - } - } - } - - // ---------- DATABASE HELPERS ---------- - pub async fn get_roles( - &self, - tournament: Uuid, - pool: &Pool, - ) -> Result, OmniError> { - let roles_result = sqlx::query!( - "SELECT roles FROM roles WHERE user_id = $1 AND tournament_id = $2", - self.id, - tournament - ) - .fetch_optional(pool) - .await?; - - if roles_result.is_none() { - return Ok(vec![]); - } - - let roles = roles_result.unwrap().roles; - let vec = match roles { - Some(vec) => vec - .iter() - .map(|role| serde_json::from_str(role.as_str())) - .collect::, JsonError>>()?, - None => vec![], - }; - Ok(vec) - } - - pub async fn is_organizer_of_any_tournament(&self, pool: &Pool) -> Result { - let tournaments = Tournament::get_all(pool).await?; - for tournament in tournaments { - let roles = self.get_roles(tournament.id, pool).await?; - if roles.contains(&Role::Organizer) { - return Ok(true); - } - } - return Ok(false); - - } - - pub async fn invalidate_all_sessions(&self, pool: &Pool) -> Result<(), OmniError> { - match query!("DELETE FROM sessions WHERE user_id = $1", self.id).execute(pool).await { - Ok(_) => Ok(()), - Err(e) => Err(e)?, - } - } -} - impl From for User { fn from(value: UserWithPassword) -> Self { User { @@ -334,12 +114,12 @@ async fn create_user( ) -> Result { let pool = &state.connection_pool; let user = User::authenticate(&headers, cookies, &pool).await?; - if !user.is_infrastructure_admin() && !user.is_organizer_of_any_tournament(pool).await? { + if !user.is_infrastructure_admin() && !user.can_create_users_within_any_tournament(pool).await? { return Err(OmniError::UnauthorizedError); } let user_without_password = User::from(json.clone()); - match User::post(user_without_password, json.password, pool).await { + match User::create_user(user_without_password, json.password, pool).await { Ok(user) => Ok(Json(user).into_response()), Err(e) => { error!("Error creating a new user: {e}"); @@ -427,7 +207,7 @@ async fn patch_user_by_id( } match user_to_be_patched.patch(new_user, pool).await { - Ok(patched_user) => Ok(Json(patched_user).into_response()), + Ok(patched_user) => Ok(Json(patched_user as User).into_response()), Err(e) => { error!("Error patching a user with id {}: {e}", id); Err(e)? diff --git a/src/users/infradmin.rs b/src/users/infradmin.rs index 33901d6..a1b8d6f 100644 --- a/src/users/infradmin.rs +++ b/src/users/infradmin.rs @@ -30,7 +30,7 @@ pub async fn guarantee_infrastructure_admin_exists(pool: &Pool) { Ok(Some(_)) => (), Ok(None) => { let admin = User::new_infrastructure_admin(); - match User::post(admin, "admin".to_string(), pool).await { + match User::create_user(admin, "admin".to_string(), pool).await { Ok(_) => info!("Infrastructure admin created."), Err(e) => { let err = OmniError::from(e); diff --git a/src/users/mod.rs b/src/users/mod.rs index e9fa6c5..c627a3f 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -2,7 +2,7 @@ use axum::http::HeaderMap; use permissions::Permission; use photourl::PhotoUrl; use roles::Role; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::{Pool, Postgres}; use tower_cookies::Cookies; use utoipa::ToSchema; @@ -14,6 +14,7 @@ pub mod auth; pub mod infradmin; pub mod permissions; pub mod photourl; +pub mod queries; pub mod roles; #[derive(Serialize, Clone, ToSchema)] @@ -26,6 +27,13 @@ pub struct User { pub picture_link: Option, } +#[derive(Deserialize, ToSchema, Clone)] +pub struct UserPatch { + pub handle: Option, + pub picture_link: Option, + pub password: Option, +} + pub struct TournamentUser { pub user: User, pub roles: Vec, diff --git a/src/users/queries.rs b/src/users/queries.rs new file mode 100644 index 0000000..cb2e6e9 --- /dev/null +++ b/src/users/queries.rs @@ -0,0 +1,242 @@ +use crate::users::{permissions::Permission as P, UserPatch}; +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use rand::rngs::OsRng; +use serde::Deserialize; +use serde_json::Error as JsonError; +use sqlx::{query, Pool, Postgres}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + omni_error::OmniError, + tournament::Tournament, + users::{photourl::PhotoUrl, roles::Role, TournamentUser, User}, +}; + +impl User { + pub async fn get_by_id(id: Uuid, pool: &Pool) -> Result { + let user = + sqlx::query!("SELECT handle, picture_link FROM users WHERE id = $1", id) + .fetch_one(pool) + .await?; + + Ok(User { + id, + handle: user.handle, + picture_link: match user.picture_link { + Some(url) => Some(PhotoUrl::new(&url)?), + None => None, + }, + }) + } + + pub async fn get_by_handle( + handle: &str, + pool: &Pool, + ) -> Result { + let user = sqlx::query!( + "SELECT id, picture_link FROM users WHERE handle = $1", + handle + ) + .fetch_one(pool) + .await?; + + Ok(User { + id: user.id, + handle: handle.to_string(), + picture_link: match user.picture_link { + Some(url) => Some(PhotoUrl::new(&url)?), + None => None, + }, + }) + } + + pub async fn get_all(pool: &Pool) -> Result, OmniError> { + let users = sqlx::query!("SELECT id, handle, picture_link FROM users") + .fetch_all(pool) + .await? + .iter() + .map(|u| { + Ok(User { + id: u.id, + handle: u.handle.clone(), + picture_link: match u.picture_link.clone() { + Some(url) => Some(PhotoUrl::new(&url)?), + None => None, + }, + }) + }) + .collect::, OmniError>>()?; + Ok(users) + } + + pub async fn create_user( + user: User, + password: String, + pool: &Pool, + ) -> Result { + let pic = match &user.picture_link { + Some(url) => Some(url.as_str()), + None => None, + }; + let hash = User::generate_password_hash(&password).unwrap(); + match sqlx::query!( + "INSERT INTO users VALUES ($1, $2, $3, $4)", + &user.id, + &user.handle, + pic, + hash + ) + .execute(pool) + .await + { + Ok(_) => Ok(user), + Err(e) => Err(e)?, + } + } + + pub async fn patch( + self, + patch: UserPatch, + pool: &Pool, + ) -> Result { + let picture_link = match &patch.picture_link { + Some(url) => Some(url.clone()), + None => self.picture_link.clone(), + }; + let updated_user = User { + id: self.id, + handle: patch.handle.clone().unwrap_or(self.handle.clone()), + picture_link, + }; + if patch.password.is_some() { + let new_password = patch.clone().password.unwrap(); + self.change_user_password(&new_password, pool).await?; + } + self.patch_user_data(&patch, pool).await?; + Ok(updated_user) + } + + async fn change_user_password( + &self, + new_password: &str, + pool: &Pool, + ) -> Result<(), OmniError> { + let password_hash = User::generate_password_hash(new_password).unwrap().clone(); + match query!( + "UPDATE users SET password_hash = $1 WHERE id = $2", + password_hash, + self.id + ) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + fn generate_password_hash(password: &str) -> Result { + let hash = { + let argon = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + match argon.hash_password(password.as_bytes(), &salt) { + Ok(hash) => hash.to_string(), + Err(e) => return Err(e)?, + } + }; + Ok(hash) + } + + async fn patch_user_data( + &self, + patch: &UserPatch, + pool: &Pool, + ) -> Result<(), OmniError> { + let picture_link = match &patch.picture_link { + Some(url) => Some(url.as_url().to_string()), + None => Some(self.picture_link.as_ref().unwrap().as_str().to_owned()), + }; + match query!( + "UPDATE users SET handle = $1, picture_link = $2 WHERE id = $3", + patch.handle, + picture_link, + self.id + ) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM users WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + // ---------- DATABASE HELPERS ---------- + pub async fn get_roles( + &self, + tournament: Uuid, + pool: &Pool, + ) -> Result, OmniError> { + let roles_result = sqlx::query!( + "SELECT roles FROM roles WHERE user_id = $1 AND tournament_id = $2", + self.id, + tournament + ) + .fetch_optional(pool) + .await?; + + if roles_result.is_none() { + return Ok(vec![]); + } + + let roles = roles_result.unwrap().roles; + let vec = match roles { + Some(vec) => vec + .iter() + .map(|role| serde_json::from_str(role.as_str())) + .collect::, JsonError>>()?, + None => vec![], + }; + Ok(vec) + } + + pub async fn can_create_users_within_any_tournament( + &self, + pool: &Pool, + ) -> Result { + let tournaments = Tournament::get_all(pool).await?; + for tournament in tournaments { + let tournament_user = + TournamentUser::get_by_id(self.id, tournament.id, &pool).await?; + if tournament_user.has_permission(P::CreateUsersManually) + || tournament_user.has_permission(P::CreateUsersWithLink) + { + return Ok(true); + } + } + return Ok(false); + } + + pub async fn invalidate_all_sessions( + &self, + pool: &Pool, + ) -> Result<(), OmniError> { + match query!("DELETE FROM sessions WHERE user_id = $1", self.id) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } +} From 67e55b579d44d8ff45f0e0c9367ada8d1e1bae33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Tue, 26 Aug 2025 16:16:19 +0200 Subject: [PATCH 5/6] [48] rename a selection of user methods --- src/routes/user_routes.rs | 2 +- src/users/infradmin.rs | 2 +- src/users/queries.rs | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/routes/user_routes.rs b/src/routes/user_routes.rs index 8119d9c..ae34b5c 100644 --- a/src/routes/user_routes.rs +++ b/src/routes/user_routes.rs @@ -119,7 +119,7 @@ async fn create_user( } let user_without_password = User::from(json.clone()); - match User::create_user(user_without_password, json.password, pool).await { + match User::create(user_without_password, json.password, pool).await { Ok(user) => Ok(Json(user).into_response()), Err(e) => { error!("Error creating a new user: {e}"); diff --git a/src/users/infradmin.rs b/src/users/infradmin.rs index a1b8d6f..f7cd071 100644 --- a/src/users/infradmin.rs +++ b/src/users/infradmin.rs @@ -30,7 +30,7 @@ pub async fn guarantee_infrastructure_admin_exists(pool: &Pool) { Ok(Some(_)) => (), Ok(None) => { let admin = User::new_infrastructure_admin(); - match User::create_user(admin, "admin".to_string(), pool).await { + match User::create(admin, "admin".to_string(), pool).await { Ok(_) => info!("Infrastructure admin created."), Err(e) => { let err = OmniError::from(e); diff --git a/src/users/queries.rs b/src/users/queries.rs index cb2e6e9..8b31912 100644 --- a/src/users/queries.rs +++ b/src/users/queries.rs @@ -70,7 +70,7 @@ impl User { Ok(users) } - pub async fn create_user( + pub async fn create( user: User, password: String, pool: &Pool, @@ -111,13 +111,13 @@ impl User { }; if patch.password.is_some() { let new_password = patch.clone().password.unwrap(); - self.change_user_password(&new_password, pool).await?; + self.change_password(&new_password, pool).await?; } - self.patch_user_data(&patch, pool).await?; + self.update_data(&patch, pool).await?; Ok(updated_user) } - async fn change_user_password( + async fn change_password( &self, new_password: &str, pool: &Pool, @@ -148,7 +148,7 @@ impl User { Ok(hash) } - async fn patch_user_data( + async fn update_data( &self, patch: &UserPatch, pool: &Pool, From 59bdd1ca16c385bdc5270b80576f51ac5222dd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Wed, 27 Aug 2025 09:23:47 +0200 Subject: [PATCH 6/6] [48] apply code review suggestions separate user personalization from password changes --- src/routes/swagger.rs | 2 ++ src/routes/user_routes.rs | 71 ++++++++++++++++++++++++++++++++++++--- src/users/mod.rs | 2 +- src/users/queries.rs | 7 ++-- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 9c202c1..b50446b 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -85,6 +85,7 @@ pub fn route() -> Router { user_routes::get_user_by_id, user_routes::patch_user_by_id, user_routes::delete_user_by_id, + user_routes::change_user_password, ), components(schemas( version::VersionDetails, @@ -108,6 +109,7 @@ pub fn route() -> Router { room::Room, room::RoomPatch, user_routes::UserWithPassword, + user_routes::UserPasswordPatch, crate::users::UserPatch, crate::users::User, photourl::PhotoUrl diff --git a/src/routes/user_routes.rs b/src/routes/user_routes.rs index ae34b5c..b223c5f 100644 --- a/src/routes/user_routes.rs +++ b/src/routes/user_routes.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - routing::get, + routing::{get, patch}, Json, Router, }; use serde::Deserialize; @@ -11,11 +11,11 @@ use tower_cookies::Cookies; use tracing::error; use utoipa::ToSchema; use uuid::Uuid; -use serde_json::Error as JsonError; #[derive(Clone, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] pub struct UserWithPassword { #[serde(skip_deserializing)] #[serde(default = "Uuid::now_v7")] @@ -35,6 +35,12 @@ impl From for User { } } +#[derive(Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UserPasswordPatch { + pub new_password: String, +} + pub fn route() -> Router { Router::new() .route("/user", get(get_users).post(create_user)) @@ -44,6 +50,7 @@ pub fn route() -> Router { .delete(delete_user_by_id) .patch(patch_user_by_id), ) + .route("/user/:id/password", patch(change_user_password)) } /// Get a list of all users @@ -63,7 +70,9 @@ pub fn route() -> Router { description = "Authentication error" ), (status=500, description = "Internal server error") -))] + ), + tag = "user" +)] async fn get_users( State(state): State, headers: HeaderMap, @@ -104,7 +113,8 @@ async fn get_users( (status=404, description = "User not found"), (status=422, description = "Invalid picture link"), (status=500, description = "Internal server error") - ) + ), + tag = "user" )] async fn create_user( State(state): State, @@ -147,6 +157,7 @@ async fn create_user( (status=404, description = "User not found"), (status=500, description = "Internal server error") ), + tag = "user" )] async fn get_user_by_id( Path(id): Path, @@ -168,7 +179,9 @@ async fn get_user_by_id( /// Patch an existing user /// +/// Allows to modify user data not related to security. /// Available to the infrastructure admin and the user modifying their own account. +/// In order to change user password, use the /user/{id}/password endpoint. #[utoipa::path(patch, path = "/user/{id}", request_body=UserPatch, responses( @@ -186,7 +199,8 @@ async fn get_user_by_id( (status=409, description = "A user with this name already exists"), (status=422, description = "Invalid picture link"), (status=500, description = "Internal server error") - ) + ), + tag = "user" )] async fn patch_user_by_id( Path(id): Path, @@ -215,6 +229,52 @@ async fn patch_user_by_id( } } +/// Change user password +/// +/// Available to the infrastructure admin and the user modifying their own account. +#[utoipa::path(patch, path = "/user/{id}/password", + request_body=UserPasswordPatch, + responses( + ( + status=200, description = "User password changed successfully", + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify this user" + ), + (status=404, description = "User not found"), + (status=500, description = "Internal server error") + ), + tag = "user" +)] +async fn change_user_password( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(password_patch): Json, +) -> Result { + let pool = &state.connection_pool; + let requesting_user = + User::authenticate(&headers, cookies, &pool).await?; + + let user_to_be_patched = User::get_by_id(id, pool).await?; + + match requesting_user.is_infrastructure_admin() || requesting_user.id == user_to_be_patched.id { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + match user_to_be_patched.change_password(&password_patch.new_password, pool).await { + Ok(()) => Ok(StatusCode::OK.into_response()), + Err(e) => { + error!("Error changing password of a user with id {}: {e}", id); + Err(e)? + } + } +} + /// Delete an existing user. /// @@ -231,6 +291,7 @@ async fn patch_user_by_id( (status=404, description = "User not found"), (status=409, description = "Other resources reference this user. They must be deleted first") ), + tag = "user" )] async fn delete_user_by_id( Path(id): Path, diff --git a/src/users/mod.rs b/src/users/mod.rs index c627a3f..e1f2898 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -28,10 +28,10 @@ pub struct User { } #[derive(Deserialize, ToSchema, Clone)] +#[serde(deny_unknown_fields)] pub struct UserPatch { pub handle: Option, pub picture_link: Option, - pub password: Option, } pub struct TournamentUser { diff --git a/src/users/queries.rs b/src/users/queries.rs index 8b31912..248b559 100644 --- a/src/users/queries.rs +++ b/src/users/queries.rs @@ -109,15 +109,11 @@ impl User { handle: patch.handle.clone().unwrap_or(self.handle.clone()), picture_link, }; - if patch.password.is_some() { - let new_password = patch.clone().password.unwrap(); - self.change_password(&new_password, pool).await?; - } self.update_data(&patch, pool).await?; Ok(updated_user) } - async fn change_password( + pub async fn change_password( &self, new_password: &str, pool: &Pool, @@ -227,6 +223,7 @@ impl User { return Ok(false); } + /// Invalidates all sessions; implementations must promptly log the user out. pub async fn invalidate_all_sessions( &self, pool: &Pool,