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-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-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/src/routes/auth.rs b/src/routes/auth.rs index 5118a90..0405931 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -63,6 +63,8 @@ async fn auth_me( /// 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 ( @@ -78,7 +80,7 @@ async fn auth_me( ) ) ] -pub async fn auth_login( +async fn auth_login( cookies: Cookies, State(state): State, Json(body): Json, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5ddeb7d..2f6bc6e 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -14,6 +14,7 @@ mod swagger; mod team_routes; mod teapot; mod tournament_routes; +mod user_routes; mod version; pub fn routes() -> Router { @@ -29,6 +30,7 @@ pub fn routes() -> Router { .merge(attendee_routes::route()) .merge(motion_routes::route()) .merge(debate_routes::route()) + .merge(user_routes::route()) .merge(location_routes::route()) .merge(room_routes::route()) } diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index bafaf3c..b50446b 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_routes; use crate::setup::AppState; use crate::routes::attendee_routes; @@ -20,7 +21,9 @@ use crate::tournament::motion; use crate::tournament::room; 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; @@ -66,6 +69,7 @@ pub fn route() -> Router { attendee_routes::delete_attendee_by_id, auth::auth_login, auth::auth_me, + auth::auth_clear, location_routes::create_location, location_routes::get_locations, location_routes::get_location_by_id, @@ -76,6 +80,12 @@ pub fn route() -> Router { room_routes::get_room_by_id, room_routes::patch_room_by_id, room_routes::delete_room_by_id, + user_routes::get_users, + user_routes::create_user, + 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, @@ -98,6 +108,11 @@ pub fn route() -> Router { location::LocationPatch, 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 new file mode 100644 index 0000000..b223c5f --- /dev/null +++ b/src/routes/user_routes.rs @@ -0,0 +1,360 @@ +use crate::{omni_error::OmniError, setup::AppState, users::{photourl::PhotoUrl, UserPatch, User}}; +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, patch}, + Json, Router, +}; +use serde::Deserialize; +use tower_cookies::Cookies; +use tracing::error; +use utoipa::ToSchema; +use uuid::Uuid; + + + +#[derive(Clone, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +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 From for User { + fn from(value: UserWithPassword) -> Self { + User { + id: value.id, + handle: value.handle, + picture_link: value.picture_link + } + } +} + +#[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)) + .route( + "/user/:id", + get(get_user_by_id) + .delete(delete_user_by_id) + .patch(patch_user_by_id), + ) + .route("/user/:id/password", patch(change_user_password)) +} + +/// 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") + ), + tag = "user" +)] +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=422, description = "Invalid picture link"), + (status=500, description = "Internal server error") + ), + tag = "user" +)] +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.can_create_users_within_any_tournament(pool).await? { + return Err(OmniError::UnauthorizedError); + } + + let user_without_password = User::from(json.clone()); + 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}"); + 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") + ), + tag = "user" +)] +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 +/// +/// 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( + ( + 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=422, description = "Invalid picture link"), + (status=500, description = "Internal server error") + ), + tag = "user" +)] +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 user_to_be_patched.patch(new_user, pool).await { + Ok(patched_user) => Ok(Json(patched_user as User).into_response()), + Err(e) => { + error!("Error patching a user with id {}: {e}", id); + Err(e)? + } + } +} + +/// 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. +/// +/// 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") + ), + tag = "user" +)] +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.png" + } + "# + .to_owned() +} + +fn get_users_list_example() -> String { + r#" + [ + { + "id": "01941265-8b3c-733f-a6ae-075c079f2f81", + "handle": "jmanczak", + "picture_link": "https://placehold.co/128x128.png" + }, + { + "id": "01941265-8b3c-733f-a6ae-091c079c2921", + "handle": "Matthew Goodman", + "picture_link": "https://placehold.co/128x128.png" + } + ] + "#.to_owned() +} diff --git a/src/tournament/mod.rs b/src/tournament/mod.rs index 1425c6c..7fb62ed 100644 --- a/src/tournament/mod.rs +++ b/src/tournament/mod.rs @@ -8,6 +8,7 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::omni_error::OmniError; +use crate::users; pub(crate) mod attendee; pub(crate) mod debate; diff --git a/src/users/infradmin.rs b/src/users/infradmin.rs index 2cbfff9..f7cd071 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, } } } diff --git a/src/users/mod.rs b/src/users/mod.rs index b33639a..e1f2898 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -2,9 +2,10 @@ 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; use uuid::Uuid; use crate::omni_error::OmniError; @@ -16,11 +17,21 @@ 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, +} + +#[derive(Deserialize, ToSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct UserPatch { + pub handle: Option, + pub picture_link: Option, } pub struct TournamentUser { @@ -55,6 +66,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 +94,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 index 8fcf84d..248b559 100644 --- a/src/users/queries.rs +++ b/src/users/queries.rs @@ -1,13 +1,18 @@ -use super::{photourl::PhotoUrl, roles::Role, TournamentUser, User}; -use crate::omni_error::OmniError; -use argon2::{ - password_hash::{rand_core::OsRng, SaltString}, - Argon2, PasswordHasher, -}; +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::{Pool, Postgres}; +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 = @@ -18,12 +23,13 @@ impl User { Ok(User { id, handle: user.handle, - profile_picture: match user.picture_link { + picture_link: match user.picture_link { Some(url) => Some(PhotoUrl::new(&url)?), None => None, }, }) } + pub async fn get_by_handle( handle: &str, pool: &Pool, @@ -38,12 +44,13 @@ impl User { Ok(User { id: user.id, handle: handle.to_string(), - profile_picture: match user.picture_link { + 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) @@ -53,7 +60,7 @@ impl User { Ok(User { id: u.id, handle: u.handle.clone(), - profile_picture: match u.picture_link.clone() { + picture_link: match u.picture_link.clone() { Some(url) => Some(PhotoUrl::new(&url)?), None => None, }, @@ -62,23 +69,17 @@ impl User { .collect::, OmniError>>()?; Ok(users) } + pub async fn create( user: User, - pass: String, + password: String, pool: &Pool, ) -> Result { - let pic = match &user.profile_picture { - Some(url) => Some(url.as_url().to_string()), + 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, @@ -93,6 +94,89 @@ impl 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, + }; + self.update_data(&patch, pool).await?; + Ok(updated_user) + } + + pub async fn change_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 update_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, @@ -119,28 +203,37 @@ impl User { .collect::, JsonError>>()?, None => vec![], }; - Ok(vec) } -} -impl TournamentUser { - pub async fn get_by_id( - user: Uuid, - tournament: Uuid, + pub async fn can_create_users_within_any_tournament( + &self, pool: &Pool, - ) -> Result { - let user = User::get_by_id(user, pool).await?; - let roles = user.get_roles(tournament, pool).await?; - Ok(TournamentUser { user, roles }) + ) -> 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 get_by_handle( - handle: &str, - tournament: Uuid, + + /// Invalidates all sessions; implementations must promptly log the user out. + pub async fn invalidate_all_sessions( + &self, pool: &Pool, - ) -> Result { - let user = User::get_by_handle(handle, pool).await?; - let roles = user.get_roles(tournament, pool).await?; - Ok(TournamentUser { user, roles }) + ) -> Result<(), OmniError> { + match query!("DELETE FROM sessions WHERE user_id = $1", self.id) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } } }