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 01/23] [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 02/23] [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 03/23] [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 d72ce662dc3b30261037a2ebfa1f4aa63619e3a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 28 Feb 2025 18:24:32 +0100 Subject: [PATCH 04/23] [47] implement roles endpoints --- ...dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6.json | 25 ++ ...7dab54fbbacca564475fbf576409aa20604c9.json | 24 ++ ...4dfea2cf508f030286c589ba454a820ce1a90.json | 15 + src/omni_error.rs | 7 + src/routes/mod.rs | 2 + src/routes/role.rs | 402 ++++++++++++++++++ src/routes/swagger.rs | 5 + src/routes/user.rs | 25 +- src/users/permissions.rs | 2 + src/users/roles.rs | 9 +- 10 files changed, 500 insertions(+), 16 deletions(-) create mode 100644 .sqlx/query-091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6.json create mode 100644 .sqlx/query-cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9.json create mode 100644 .sqlx/query-f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90.json create mode 100644 src/routes/role.rs diff --git a/.sqlx/query-091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6.json b/.sqlx/query-091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6.json new file mode 100644 index 0000000..5bb7735 --- /dev/null +++ b/.sqlx/query-091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO roles(id, user_id, tournament_id, roles)\n VALUES ($1, $2, $3, $4) RETURNING roles", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "roles", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "TextArray" + ] + }, + "nullable": [ + true + ] + }, + "hash": "091dd3b8c67f8db6845e2a9afc6dbf89bfe96e535dc6322fa0ea1c3bd07bd4f6" +} diff --git a/.sqlx/query-cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9.json b/.sqlx/query-cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9.json new file mode 100644 index 0000000..7ce939d --- /dev/null +++ b/.sqlx/query-cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE roles SET roles = $1 WHERE user_id = $2 AND tournament_id = $3\n RETURNING roles", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "roles", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "cc46aae0565b6bf3f95f88985747dab54fbbacca564475fbf576409aa20604c9" +} diff --git a/.sqlx/query-f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90.json b/.sqlx/query-f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90.json new file mode 100644 index 0000000..e3c1294 --- /dev/null +++ b/.sqlx/query-f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM roles WHERE user_id = $1 AND tournament_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f61270121616efa1011674c8f3f4dfea2cf508f030286c589ba454a820ce1a90" +} diff --git a/src/omni_error.rs b/src/omni_error.rs index a1c5eda..6f5f908 100644 --- a/src/omni_error.rs +++ b/src/omni_error.rs @@ -9,6 +9,7 @@ const DEPENDENT_RESOURCES_MESSAGE: &str = "Dependent resources must be deleted f const INTERNAL_SERVER_ERROR_MESSAGE: &str = "Internal Server Error"; const UNAUTHORIZED_MESSAGE: &str = "Unauthorized"; const BAD_REQUEST: &str = "Bad Request"; +const ROLES_PARSING_MESSAGE: &str = "Failed to parse user roles"; #[derive(thiserror::Error, Debug)] pub enum OmniError { @@ -43,6 +44,8 @@ pub enum OmniError { UnauthorizedError, #[error("{BAD_REQUEST}")] BadRequestError, + #[error("ROLES_PARSING_MESSAGE")] + RolesParsingError, } impl IntoResponse for OmniError { @@ -134,6 +137,9 @@ impl OmniError { (StatusCode::UNAUTHORIZED, self.clerr()).into_response() } E::BadRequestError => (StatusCode::BAD_REQUEST, self.clerr()).into_response(), + E::RolesParsingError => { + (StatusCode::BAD_REQUEST, self.clerr()).into_response() + } } } @@ -154,6 +160,7 @@ impl OmniError { E::InternalServerError => INTERNAL_SERVER_ERROR_MESSAGE, E::UnauthorizedError => UNAUTHORIZED_MESSAGE, E::BadRequestError => BAD_REQUEST, + E::RolesParsingError => ROLES_PARSING_MESSAGE, } .to_string() } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index f1ad5e2..8253066 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -8,6 +8,7 @@ mod debate; mod health_check; mod infradmin; mod motion; +mod role; mod swagger; mod team; mod teapot; @@ -30,4 +31,5 @@ pub fn routes() -> Router { .merge(motion::route()) .merge(debate::route()) .merge(user::route()) + .merge(role::route()) } diff --git a/src/routes/role.rs b/src/routes/role.rs new file mode 100644 index 0000000..f6fc7c6 --- /dev/null +++ b/src/routes/role.rs @@ -0,0 +1,402 @@ +use core::fmt; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use sqlx::{query, query_as, Pool, Postgres}; +use strum::VariantArray; +use tower_cookies::Cookies; +use tracing::error; +use uuid::Uuid; + +use crate::{ + omni_error::OmniError, + setup::AppState, + users::{permissions::Permission, roles::Role, TournamentUser, User}, +}; + +impl Role { + pub async fn post( + user_id: Uuid, + tournament_id: Uuid, + roles: Vec, + pool: &Pool, + ) -> Result, OmniError> { + let roles_as_strings = Role::roles_vec_to_string_array(&roles); + match query!( + r#"INSERT INTO roles(id, user_id, tournament_id, roles) + VALUES ($1, $2, $3, $4) RETURNING roles"#, + Uuid::now_v7(), + user_id, + tournament_id, + &roles_as_strings + ) + .fetch_one(pool) + .await + { + Ok(record) => { + let string_vec = record.roles.unwrap(); + let mut created_roles: Vec = vec![]; + for role_string in string_vec { + created_roles.push(Role::try_from(role_string)?); + } + return Ok(created_roles); + } + Err(e) => Err(e)?, + } + } + + pub fn roles_vec_to_string_array(roles: &Vec) -> Vec { + let mut string_vec = vec![]; + for role in roles { + string_vec.push(role.to_string()); + } + return string_vec; + } + + pub fn string_to_roles(string: String) -> Result, OmniError> { + let mut roles_vec: Vec = vec![]; + let role_strings = string.split(","); + for role_string in role_strings { + roles_vec.push(Role::try_from(role_string)?); + } + todo!() + } + + pub async fn patch( + user_id: Uuid, + tournament_id: Uuid, + roles: Vec, + pool: &Pool, + ) -> Result, OmniError> { + let roles_as_strings = Role::roles_vec_to_string_array(&roles); + match query!( + r#"UPDATE roles SET roles = $1 WHERE user_id = $2 AND tournament_id = $3 + RETURNING roles"#, + &roles_as_strings, + user_id, + tournament_id + ) + .fetch_one(pool) + .await + { + Ok(record) => { + let string_vec = record.roles.unwrap(); + let mut created_roles: Vec = vec![]; + for role_string in string_vec { + created_roles.push(Role::try_from(role_string)?); + } + return Ok(created_roles); + } + Err(e) => Err(e)?, + } + } + + pub async fn delete( + user_id: Uuid, + tournament_id: Uuid, + pool: &Pool, + ) -> Result<(), OmniError> { + match query!( + r"DELETE FROM roles WHERE user_id = $1 AND tournament_id = $2", + user_id, + tournament_id + ) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } +} + +impl TryFrom<&str> for Role { + type Error = OmniError; + + fn try_from(value: &str) -> Result { + match value { + "Organizer" => Ok(Role::Organizer), + "Marshall" => Ok(Role::Marshall), + "Judge" => Ok(Role::Judge), + _ => Err(OmniError::RolesParsingError), + } + } +} + +impl TryFrom for Role { + type Error = OmniError; + + fn try_from(value: String) -> Result { + match value.as_str() { + "Organizer" => Ok(Role::Organizer), + "Marshall" => Ok(Role::Marshall), + "Judge" => Ok(Role::Judge), + _ => Err(OmniError::RolesParsingError), + } + } +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Role::Organizer => write!(f, "Organizer"), + Role::Judge => write!(f, "Judge"), + Role::Marshall => write!(f, "Marshall"), + } + } +} + +pub fn route() -> Router { + Router::new().route( + "/user/:user_id/tournament/:tournament_id/roles", + post(create_user_roles) + .get(get_user_roles) + .patch(patch_user_roles) + .delete(delete_user_roles), + ) +} + +/// Grant Roles to a user. +/// +/// Available only to Organizers and and the infrastructure admin. +#[utoipa::path( + post, + request_body=Vec, + path = "/user/{user_id}/tournament/{tournament_id}/roles", + responses( + ( + status=200, description = "Roles created successfully", + body=Vec + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify roles within this tournament" + ), + (status=404, description = "User of tournament not found"), + (status=409, description = "The user is already granted roles within this tournament. Use PATCH method to modify user roles"), + (status=500, description = "Internal server error"), + ) +)] +async fn create_user_roles( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, + Json(json): Json>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, pool).await?; + + match tournament_user.has_permission(Permission::WriteRoles) { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + let user_to_be_granted_roles = User::get_by_id(user_id, pool).await?; + let roles = user_to_be_granted_roles + .get_roles(tournament_id, pool) + .await?; + if !roles.is_empty() { + return Err(OmniError::ResourceAlreadyExistsError); + } + + match Role::post(user_id, tournament_id, json, pool).await { + Ok(role) => Ok(Json(role).into_response()), + Err(e) => { + error!( + "Error creating roles for user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e)? + } + } +} + +/// Get roles a user is given within a tournament +/// +/// The user must be given a role within this tournament to use this endpoint. +#[utoipa::path(get, path = "/user/{user_id}/tournament/{tournament_id}/roles", + responses( + (status=200, description = "Ok", body=Vec, + example=json!(get_roles_example()) + ), + (status=400, description="Bad request"), + (status=401, description="The user is not permitted to see roles, meaning they don't have any role within this tournament"), + (status=404, description="User or tournament not found"), + (status=500, description="Internal server error"), + ), +)] +async fn get_user_roles( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.roles.is_empty() { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + let requested_user = User::get_by_id(user_id, pool).await?; + match requested_user.get_roles(tournament_id, pool).await { + Ok(roles) => Ok(Json(roles).into_response()), + Err(e) => { + error!( + "Error getting roles of user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e)? + } + } +} + +/// Overwrite roles a user is given within a tournament +/// +/// Available only to the tournament Organizers and the infrastructure admin. +#[utoipa::path(patch, path = "/tournament/{tournament_id}/role/{id}", + request_body=Vec, + responses( + ( + status=200, description = "Roles patched successfully", + body=Vec, + example=json!(get_roles_example()) + ), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify roles within this tournament" + ), + (status=404, description = "Tournament or user not found, or the user has not been assigned any roles yet"), + (status=500, description = "Internal server error"), + ) +)] +async fn patch_user_roles( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, + Json(new_roles): Json>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, pool).await?; + + match tournament_user.has_permission(Permission::WriteRoles) { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + let modified_user = TournamentUser::get_by_id(user_id, tournament_id, pool).await?; + if modified_user.roles.is_empty() { + return Err(OmniError::ResourceNotFoundError); + } + + match Role::patch(user_id, tournament_id, new_roles, pool).await { + Ok(roles) => Ok(Json(roles).into_response()), + Err(e) => { + error!( + "Error patching roles of user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e) + } + } +} + +/// Delete user roles within a tournament +/// This operation effectively means banning the user from a tournament. +/// Available only to the tournament Organizers and the infrastructure admin. +#[utoipa::path(delete, path = "/tournament/{tournament_id}/role/{id}", + responses + ( + (status=204, description = "Role deleted successfully"), + (status=400, description = "Bad request"), + ( + status=401, + description = "The user is not permitted to modify roles within this tournament" + ), + (status=404, description = "User or tournament not found"), + (status=500, description = "Internal server error"), + ), + +)] +async fn delete_user_roles( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, pool).await?; + + match tournament_user.has_permission(Permission::WriteRoles) { + true => (), + false => return Err(OmniError::UnauthorizedError), + } + + match Role::delete(user_id, tournament_id, pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => { + error!( + "Error deleting roles of user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e)? + } + } +} + +fn get_roles_example() -> String { + r#" + ["Marshall", "Judge"] + "# + .to_owned() +} + +#[test] +fn role_to_string() { + let judge = Role::Judge; + let marshall = Role::Marshall; + let organizer = Role::Organizer; + + assert!(judge.to_string() == "Judge"); + assert!(marshall.to_string() == "Marshall"); + assert!(organizer.to_string() == "Organizer") +} + +#[test] +fn role_vecs_to_string() { + let roles = Role::VARIANTS.to_vec(); + let roles_count = roles.len(); + let roles_as_strings = Role::roles_vec_to_string_array(&roles); + for i in 0..roles_count { + assert!(roles_as_strings[i] == roles[i].to_string()) + } +} + +#[test] +fn string_to_roles() { + let role_strings = vec!["Marshall", "Judge", "Organizer", "Gżdacz"]; + + let marshall_role = Role::try_from(role_strings[0]).unwrap(); + let judge_role = Role::try_from(role_strings[1]).unwrap(); + let organizer_role = Role::try_from(role_strings[2]).unwrap(); + let fake_role = Role::try_from(role_strings[3]); + + assert!(marshall_role == Role::Marshall); + assert!(judge_role == Role::Judge); + assert!(organizer_role == Role::Organizer); + assert!(fake_role.is_err()); +} diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 364abe2..57731d7 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -9,6 +9,7 @@ use crate::setup::AppState; use crate::routes::attendee; use crate::routes::debate; use crate::routes::motion; +use crate::routes::role; use crate::routes::team; use crate::routes::tournament; use crate::users::permissions; @@ -64,6 +65,10 @@ pub fn route() -> Router { user::get_user_by_id, user::patch_user_by_id, user::delete_user_by_id, + role::create_user_roles, + role::get_user_roles, + role::patch_user_roles, + role::delete_user_roles, ), components(schemas( version::VersionDetails, diff --git a/src/routes/user.rs b/src/routes/user.rs index 34bd234..e0956bf 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -37,14 +37,14 @@ pub struct UserWithPassword { } impl User { - pub async fn get_by_id(id: Uuid, pool: &Pool) -> Result { + pub async fn get_by_id(user_id: Uuid, pool: &Pool) -> Result { let user = - sqlx::query!("SELECT handle, picture_link FROM users WHERE id = $1", id) + sqlx::query!("SELECT handle, picture_link FROM users WHERE id = $1", user_id) .fetch_one(pool) .await?; Ok(User { - id, + id: user_id, handle: user.handle, picture_link: match user.picture_link { Some(url) => Some(PhotoUrl::new(&url)?), @@ -200,13 +200,13 @@ impl User { // ---------- DATABASE HELPERS ---------- pub async fn get_roles( &self, - tournament: Uuid, + tournament_id: 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 + tournament_id ) .fetch_optional(pool) .await?; @@ -215,15 +215,12 @@ impl User { 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) + let roles_strings = roles_result.unwrap().roles.unwrap(); + let mut roles_vec = vec![]; + for role_string in roles_strings { + roles_vec.push(Role::try_from(role_string)?); + } + Ok(roles_vec) } pub async fn is_organizer_of_any_tournament(&self, pool: &Pool) -> Result { diff --git a/src/users/permissions.rs b/src/users/permissions.rs index df9044d..a7ec183 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -29,4 +29,6 @@ pub enum Permission { SubmitOwnVerdictVote, SubmitVerdict, + + WriteRoles, } diff --git a/src/users/roles.rs b/src/users/roles.rs index 5763b5e..c2fb9d4 100644 --- a/src/users/roles.rs +++ b/src/users/roles.rs @@ -1,17 +1,22 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use strum::VariantArray; use utoipa::ToSchema; use super::permissions::Permission; -#[derive(Debug, PartialEq, Deserialize, ToSchema)] +#[derive(Debug, PartialEq, Deserialize, ToSchema, VariantArray, Clone, Serialize)] /// Within a tournament, users must be granted roles for their /// permissions to be defined. Each role comes with a predefined /// set of permissions to perform certain operations. /// By default, a newly created user has no roles. +/// Multiple users can have the same role. pub enum Role { + /// This role grants all possible permissions within a tournament. Organizer, + /// Judges can submit their verdicts regarding debates they were assigned to. Judge, + /// Marshalls are responsible for conducting debates. + /// For pragmatic reasons, they can submit verdicts on Judges' behalf. Marshall, } From d186f6d0d0c1a604a1126357d0b24304371eb163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 28 Feb 2025 18:36:10 +0100 Subject: [PATCH 05/23] [47] code cleanup --- src/routes/mod.rs | 2 +- src/routes/role.rs | 51 +++++++++++++++++++++++++++++++++--------- src/routes/swagger.rs | 3 +-- src/routes/user.rs | 4 ++-- src/users/infradmin.rs | 3 ++- src/users/mod.rs | 3 +-- src/users/roles.rs | 44 ------------------------------------ 7 files changed, 48 insertions(+), 62 deletions(-) delete mode 100644 src/users/roles.rs diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8253066..8779fa7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -8,7 +8,7 @@ mod debate; mod health_check; mod infradmin; mod motion; -mod role; +pub(crate) mod role; mod swagger; mod team; mod teapot; diff --git a/src/routes/role.rs b/src/routes/role.rs index f6fc7c6..4477d23 100644 --- a/src/routes/role.rs +++ b/src/routes/role.rs @@ -16,10 +16,50 @@ use uuid::Uuid; use crate::{ omni_error::OmniError, setup::AppState, - users::{permissions::Permission, roles::Role, TournamentUser, User}, + users::{permissions::Permission, TournamentUser, User}, }; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, PartialEq, Deserialize, ToSchema, VariantArray, Clone, Serialize)] +/// Within a tournament, users must be granted roles for their +/// permissions to be defined. Each role comes with a predefined +/// set of permissions to perform certain operations. +/// By default, a newly created user has no roles. +/// Multiple users can have the same role. +pub enum Role { + /// This role grants all possible permissions within a tournament. + Organizer, + /// Judges can submit their verdicts regarding debates they were assigned to. + Judge, + /// Marshalls are responsible for conducting debates. + /// For pragmatic reasons, they can submit verdicts on Judges' behalf. + Marshall, +} + impl Role { + pub fn get_role_permissions(&self) -> Vec { + use Permission as P; + match self { + Role::Organizer => P::VARIANTS.to_vec(), + Role::Judge => vec![ + P::ReadAttendees, + P::ReadDebates, + P::ReadTeams, + P::ReadTournament, + P::SubmitOwnVerdictVote, + ], + Role::Marshall => vec![ + P::ReadDebates, + P::ReadAttendees, + P::ReadTeams, + P::ReadTournament, + P::SubmitVerdict, + ], + } + } + pub async fn post( user_id: Uuid, tournament_id: Uuid, @@ -58,15 +98,6 @@ impl Role { return string_vec; } - pub fn string_to_roles(string: String) -> Result, OmniError> { - let mut roles_vec: Vec = vec![]; - let role_strings = string.split(","); - for role_string in role_strings { - roles_vec.push(Role::try_from(role_string)?); - } - todo!() - } - pub async fn patch( user_id: Uuid, tournament_id: Uuid, diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 57731d7..687c0d2 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -14,7 +14,6 @@ use crate::routes::team; use crate::routes::tournament; use crate::users::permissions; use crate::users::photourl; -use crate::users::roles; use super::health_check; use super::teapot; @@ -85,7 +84,7 @@ pub fn route() -> Router { attendee::Attendee, attendee::AttendeePatch, permissions::Permission, - roles::Role, + role::Role, auth::LoginRequest, user::UserWithPassword, user::UserPatch, diff --git a/src/routes/user.rs b/src/routes/user.rs index e0956bf..1827744 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -1,4 +1,4 @@ -use crate::{omni_error::OmniError, setup::AppState, users::{photourl::PhotoUrl, roles::Role, User}}; +use crate::{omni_error::OmniError, setup::AppState, users::{photourl::PhotoUrl, User}}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use axum::{ extract::{Path, State}, @@ -16,7 +16,7 @@ use utoipa::ToSchema; use uuid::Uuid; use serde_json::Error as JsonError; -use super::tournament::Tournament; +use super::{role::Role, tournament::Tournament}; #[derive(Deserialize, ToSchema)] pub struct UserPatch { diff --git a/src/users/infradmin.rs b/src/users/infradmin.rs index 33901d6..c241418 100644 --- a/src/users/infradmin.rs +++ b/src/users/infradmin.rs @@ -1,7 +1,8 @@ use super::User; +use crate::routes::role::Role; use crate::{ omni_error::OmniError, - users::{permissions::Permission, roles::Role, TournamentUser}, + users::{permissions::Permission, TournamentUser}, }; use sqlx::{Pool, Postgres}; use strum::VariantArray; diff --git a/src/users/mod.rs b/src/users/mod.rs index e9fa6c5..89e854b 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -1,7 +1,7 @@ +use crate::routes::role::Role; use axum::http::HeaderMap; use permissions::Permission; use photourl::PhotoUrl; -use roles::Role; use serde::Serialize; use sqlx::{Pool, Postgres}; use tower_cookies::Cookies; @@ -14,7 +14,6 @@ pub mod auth; pub mod infradmin; pub mod permissions; pub mod photourl; -pub mod roles; #[derive(Serialize, Clone, ToSchema)] pub struct User { diff --git a/src/users/roles.rs b/src/users/roles.rs deleted file mode 100644 index c2fb9d4..0000000 --- a/src/users/roles.rs +++ /dev/null @@ -1,44 +0,0 @@ -use serde::{Deserialize, Serialize}; -use strum::VariantArray; -use utoipa::ToSchema; - -use super::permissions::Permission; - -#[derive(Debug, PartialEq, Deserialize, ToSchema, VariantArray, Clone, Serialize)] -/// Within a tournament, users must be granted roles for their -/// permissions to be defined. Each role comes with a predefined -/// set of permissions to perform certain operations. -/// By default, a newly created user has no roles. -/// Multiple users can have the same role. -pub enum Role { - /// This role grants all possible permissions within a tournament. - Organizer, - /// Judges can submit their verdicts regarding debates they were assigned to. - Judge, - /// Marshalls are responsible for conducting debates. - /// For pragmatic reasons, they can submit verdicts on Judges' behalf. - Marshall, -} - -impl Role { - pub fn get_role_permissions(&self) -> Vec { - use Permission as P; - match self { - Role::Organizer => P::VARIANTS.to_vec(), - Role::Judge => vec![ - P::ReadAttendees, - P::ReadDebates, - P::ReadTeams, - P::ReadTournament, - P::SubmitOwnVerdictVote, - ], - Role::Marshall => vec![ - P::ReadDebates, - P::ReadAttendees, - P::ReadTeams, - P::ReadTournament, - P::SubmitVerdict, - ], - } - } -} From 7d0b8d10616633a548cbcc0b191efda90eee199f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 28 Feb 2025 18:46:50 +0100 Subject: [PATCH 06/23] [47] docs cleanup --- src/routes/mod.rs | 4 ++-- src/routes/{role.rs => roles.rs} | 10 +++++----- src/routes/swagger.rs | 12 ++++++------ src/routes/user.rs | 2 +- src/users/infradmin.rs | 2 +- src/users/mod.rs | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) rename src/routes/{role.rs => roles.rs} (97%) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8779fa7..5703236 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -8,7 +8,7 @@ mod debate; mod health_check; mod infradmin; mod motion; -pub(crate) mod role; +pub(crate) mod roles; mod swagger; mod team; mod teapot; @@ -31,5 +31,5 @@ pub fn routes() -> Router { .merge(motion::route()) .merge(debate::route()) .merge(user::route()) - .merge(role::route()) + .merge(roles::route()) } diff --git a/src/routes/role.rs b/src/routes/roles.rs similarity index 97% rename from src/routes/role.rs rename to src/routes/roles.rs index 4477d23..fbb6817 100644 --- a/src/routes/role.rs +++ b/src/routes/roles.rs @@ -192,7 +192,7 @@ pub fn route() -> Router { ) } -/// Grant Roles to a user. +/// Grant roles to a user /// /// Available only to Organizers and and the infrastructure admin. #[utoipa::path( @@ -250,7 +250,7 @@ async fn create_user_roles( } } -/// Get roles a user is given within a tournament +/// List roles a user is given within a tournament /// /// The user must be given a role within this tournament to use this endpoint. #[utoipa::path(get, path = "/user/{user_id}/tournament/{tournament_id}/roles", @@ -295,7 +295,7 @@ async fn get_user_roles( /// Overwrite roles a user is given within a tournament /// /// Available only to the tournament Organizers and the infrastructure admin. -#[utoipa::path(patch, path = "/tournament/{tournament_id}/role/{id}", +#[utoipa::path(patch, path = "/user/{user_id}/tournament/{tournament_id}/roles", request_body=Vec, responses( ( @@ -348,10 +348,10 @@ async fn patch_user_roles( /// Delete user roles within a tournament /// This operation effectively means banning the user from a tournament. /// Available only to the tournament Organizers and the infrastructure admin. -#[utoipa::path(delete, path = "/tournament/{tournament_id}/role/{id}", +#[utoipa::path(delete, path = "/user/{user_id}/tournament/{tournament_id}/roles", responses ( - (status=204, description = "Role deleted successfully"), + (status=204, description = "Roles deleted successfully"), (status=400, description = "Bad request"), ( status=401, diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 687c0d2..d72c119 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -9,7 +9,7 @@ use crate::setup::AppState; use crate::routes::attendee; use crate::routes::debate; use crate::routes::motion; -use crate::routes::role; +use crate::routes::roles; use crate::routes::team; use crate::routes::tournament; use crate::users::permissions; @@ -64,10 +64,10 @@ pub fn route() -> Router { user::get_user_by_id, user::patch_user_by_id, user::delete_user_by_id, - role::create_user_roles, - role::get_user_roles, - role::patch_user_roles, - role::delete_user_roles, + roles::create_user_roles, + roles::get_user_roles, + roles::patch_user_roles, + roles::delete_user_roles, ), components(schemas( version::VersionDetails, @@ -84,7 +84,7 @@ pub fn route() -> Router { attendee::Attendee, attendee::AttendeePatch, permissions::Permission, - role::Role, + roles::Role, auth::LoginRequest, user::UserWithPassword, user::UserPatch, diff --git a/src/routes/user.rs b/src/routes/user.rs index 1827744..9a62b95 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -16,7 +16,7 @@ use utoipa::ToSchema; use uuid::Uuid; use serde_json::Error as JsonError; -use super::{role::Role, tournament::Tournament}; +use super::{roles::Role, tournament::Tournament}; #[derive(Deserialize, ToSchema)] pub struct UserPatch { diff --git a/src/users/infradmin.rs b/src/users/infradmin.rs index c241418..7f6e989 100644 --- a/src/users/infradmin.rs +++ b/src/users/infradmin.rs @@ -1,5 +1,5 @@ use super::User; -use crate::routes::role::Role; +use crate::routes::roles::Role; use crate::{ omni_error::OmniError, users::{permissions::Permission, TournamentUser}, diff --git a/src/users/mod.rs b/src/users/mod.rs index 89e854b..e364fec 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -1,4 +1,4 @@ -use crate::routes::role::Role; +use crate::routes::roles::Role; use axum::http::HeaderMap; use permissions::Permission; use photourl::PhotoUrl; From 2b0362e3cc8d14a1efd0c8229cf651c64fbd2d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Sat, 1 Mar 2025 14:43:44 +0100 Subject: [PATCH 07/23] [53] fix 401/403 errors confusion --- src/omni_error.rs | 8 ++++++++ src/routes/attendee.rs | 27 ++++++++++++++++----------- src/routes/debate.rs | 25 +++++++++++++++---------- src/routes/motion.rs | 19 +++++++++++-------- src/routes/team.rs | 25 +++++++++++++++---------- src/routes/tournament.rs | 25 +++++++++++++++---------- 6 files changed, 80 insertions(+), 49 deletions(-) diff --git a/src/omni_error.rs b/src/omni_error.rs index 2c9c028..9e5f440 100644 --- a/src/omni_error.rs +++ b/src/omni_error.rs @@ -11,6 +11,8 @@ const UNAUTHORIZED_MESSAGE: &str = "Unauthorized"; const BAD_REQUEST: &str = "Bad Request"; const ATTENDEE_POSITION_MESSAGE: &str = "Attendee position must be in range [1,4] or None"; +const INSUFFICIENT_PERMISSIONS_MESSAGE: &str = + "You are not permitted to perform this operation"; #[derive(thiserror::Error, Debug)] pub enum OmniError { @@ -47,6 +49,8 @@ pub enum OmniError { BadRequestError, #[error("{ATTENDEE_POSITION_MESSAGE}")] AttendeePositionError, + #[error("{INSUFFICIENT_PERMISSIONS_MESSAGE}")] + InsufficientPermissionsError, } impl IntoResponse for OmniError { @@ -141,6 +145,9 @@ impl OmniError { E::AttendeePositionError => { (StatusCode::BAD_REQUEST, self.clerr()).into_response() } + E::InsufficientPermissionsError => { + (StatusCode::FORBIDDEN, self.clerr()).into_response() + } } } @@ -162,6 +169,7 @@ impl OmniError { E::UnauthorizedError => UNAUTHORIZED_MESSAGE, E::BadRequestError => BAD_REQUEST, E::AttendeePositionError => ATTENDEE_POSITION_MESSAGE, + E::InsufficientPermissionsError => INSUFFICIENT_PERMISSIONS_MESSAGE, } .to_string() } diff --git a/src/routes/attendee.rs b/src/routes/attendee.rs index 94e68eb..2671f9c 100644 --- a/src/routes/attendee.rs +++ b/src/routes/attendee.rs @@ -160,9 +160,10 @@ pub fn route() -> Router { body=Attendee, example=json!(get_attendee_example()) ), - (status=400, description = "Bad request",), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to create attendees within this tournament", ), (status=404, description = "Tournament not found"), @@ -187,7 +188,7 @@ async fn create_attendee( match tournament_user.has_permission(Permission::WriteAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } if !attendee.position.is_none() { @@ -219,8 +220,9 @@ async fn create_attendee( example=json!(get_attendees_list_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not get to create attendees within this tournament", ), (status=404, description = "Tournament not found"), @@ -242,7 +244,7 @@ async fn get_attendees( match tournament_user.has_permission(Permission::WriteAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match query_as!(Attendee, "SELECT * FROM attendees") @@ -265,8 +267,9 @@ async fn get_attendees( example=json!(get_attendee_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to get attendees within this tournament", ), (status=404, description = "Tournament or attendee not found"), @@ -288,7 +291,7 @@ async fn get_attendee_by_id( match tournament_user.has_permission(Permission::ReadAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Attendee::get_by_id(id, &state.connection_pool).await { @@ -310,8 +313,9 @@ async fn get_attendee_by_id( example=json!(get_attendee_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to patch attendees within this tournament", ), (status=404, description = "Tournament or attendee not found"), @@ -334,7 +338,7 @@ async fn patch_attendee_by_id( match tournament_user.has_permission(Permission::WriteAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } if !new_attendee.position.is_none() { @@ -364,8 +368,9 @@ async fn patch_attendee_by_id( ( (status=204, description = "Attendee deleted successfully"), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to delete attendees within this tournament", ), (status=404, description = "Tournament or attendee not found"), @@ -387,7 +392,7 @@ async fn delete_attendee_by_id( match tournament_user.has_permission(Permission::WriteAttendees) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let attendee = Attendee::get_by_id(id, pool).await?; diff --git a/src/routes/debate.rs b/src/routes/debate.rs index af1fa4e..f65940b 100644 --- a/src/routes/debate.rs +++ b/src/routes/debate.rs @@ -122,8 +122,9 @@ pub fn route() -> Router { body=Vec, ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to read debates within this tournament", ), (status=404, description = "Tournament not found"), @@ -145,7 +146,7 @@ async fn get_debates( match tournament_user.has_permission(Permission::ReadDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match query_as!(Debate, "SELECT * FROM debates") @@ -171,8 +172,9 @@ async fn get_debates( body=Debate, ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify debates within this tournament", ), (status=404, description = "Tournament or attendee not found"), @@ -192,7 +194,7 @@ async fn create_debate( match tournament_user.has_permission(Permission::WriteDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Debate::post(json, &state.connection_pool).await { @@ -215,8 +217,9 @@ async fn create_debate( body=Debate, ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to read debates within this tournament", ), (status=404, description = "Tournament or debate not found"), @@ -236,7 +239,7 @@ async fn get_debate_by_id( match tournament_user.has_permission(Permission::ReadDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Debate::get_by_id(id, &state.connection_pool).await { @@ -262,8 +265,9 @@ async fn get_debate_by_id( body=Debate, ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify debates within this tournament" ), (status=404, description = "Tournament or debate not found"), @@ -284,7 +288,7 @@ async fn patch_debate_by_id( match tournament_user.has_permission(Permission::WriteDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let existing_debate = Debate::get_by_id(id, &state.connection_pool).await?; @@ -308,8 +312,9 @@ async fn patch_debate_by_id( ( (status=204, description = "Debate deleted successfully"), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify debates within this tournament" ), (status=404, description = "Tournament or debate not found"), @@ -329,7 +334,7 @@ async fn delete_debate_by_id( match tournament_user.has_permission(Permission::WriteDebates) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Debate::get_by_id(id, &state.connection_pool).await { diff --git a/src/routes/motion.rs b/src/routes/motion.rs index 4a3a85c..cd8b65f 100644 --- a/src/routes/motion.rs +++ b/src/routes/motion.rs @@ -149,7 +149,7 @@ async fn get_motions( match tournament_user.has_permission(Permission::ReadMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match query_as!(Motion, "SELECT * FROM motions") @@ -178,8 +178,9 @@ async fn get_motions( example=json!(get_motion_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify motions within this tournament" ), (status=404, description = "Tournament or motion not found"), @@ -200,7 +201,7 @@ State(state): State, match tournament_user.has_permission(Permission::WriteMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Motion::post(json, &state.connection_pool).await { @@ -232,7 +233,7 @@ async fn get_motion_by_id( match tournament_user.has_permission(Permission::ReadMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Motion::get_by_id(id, &state.connection_pool).await { @@ -253,8 +254,9 @@ async fn get_motion_by_id( example=json!(get_motion_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify motions within this tournament" ), (status=404, description = "Tournament or motion not found") @@ -274,7 +276,7 @@ async fn patch_motion_by_id( match tournament_user.has_permission(Permission::WriteMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let existing_motion = Motion::get_by_id(id, pool).await?; @@ -292,8 +294,9 @@ async fn patch_motion_by_id( ( (status=204, description = "Motion deleted successfully"), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify motions within this tournament" ), (status=404, description = "Tournament or motion not found") @@ -313,7 +316,7 @@ async fn delete_motion_by_id( match tournament_user.has_permission(Permission::WriteMotions) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let motion = Motion::get_by_id(id, pool).await?; diff --git a/src/routes/team.rs b/src/routes/team.rs index 785d740..d3ab13b 100644 --- a/src/routes/team.rs +++ b/src/routes/team.rs @@ -133,8 +133,9 @@ pub fn route() -> Router { example=json!(get_team_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify teams within this tournament" ), (status=404, description = "Tournament or team not found"), @@ -154,7 +155,7 @@ async fn create_team( match tournament_user.has_permission(Permission::WriteTeams) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } if team_with_name_exists_in_tournament(&json.full_name, &tournament_id, pool).await? { @@ -180,8 +181,9 @@ async fn create_team( example=json!(get_teams_list_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to read teams within this tournament" ), (status=404, description = "Tournament or team not found"), @@ -203,7 +205,7 @@ async fn get_teams( match tournament_user.has_permission(Permission::ReadTeams) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let _tournament = Tournament::get_by_id(tournament_id, pool).await?; @@ -229,8 +231,9 @@ async fn get_teams( example=json!(get_team_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to read teams within this tournament" ), (status=404, description = "Tournament or team not found"), @@ -250,7 +253,7 @@ async fn get_team_by_id( match tournament_user.has_permission(Permission::ReadTeams) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Team::get_by_id(id, pool).await { @@ -274,8 +277,9 @@ async fn get_team_by_id( example=json!(get_team_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify teams within this tournament" ), (status=404, description = "Tournament or team not found"), @@ -300,7 +304,7 @@ async fn patch_team_by_id( match tournament_user.has_permission(Permission::WriteTeams) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let team = Team::get_by_id(id, pool).await?; @@ -323,8 +327,9 @@ async fn patch_team_by_id( ( (status=204, description = "Team deleted successfully"), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify teams within this tournament" ), (status=404, description = "Tournament or team not found"), @@ -343,7 +348,7 @@ async fn delete_team_by_id( match tournament_user.has_permission(Permission::WriteTeams) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let team = Team::get_by_id(id, pool).await?; diff --git a/src/routes/tournament.rs b/src/routes/tournament.rs index 41a3268..0b9b628 100644 --- a/src/routes/tournament.rs +++ b/src/routes/tournament.rs @@ -141,8 +141,9 @@ pub fn route() -> Router { example=json!(get_tournaments_list_example()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to list any tournaments, meaning they do not have any roles within any tournament." ), (status=500, description = "Internal server error") @@ -169,7 +170,7 @@ async fn get_tournaments( } } if visible_tournaments.is_empty() { - return Err(OmniError::UnauthorizedError); + return Err(OmniError::InsufficientPermissionsError); } Ok(Json(visible_tournaments).into_response()) } @@ -190,8 +191,9 @@ async fn get_tournaments( example=json!(get_tournament_example_with_id()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify this tournament" ), (status=404, description = "Tournament not found"), @@ -207,7 +209,7 @@ async fn create_tournament( let pool = &state.connection_pool; let user = User::authenticate(&headers, cookies, &pool).await?; if !user.is_infrastructure_admin() { - return Err(OmniError::UnauthorizedError); + return Err(OmniError::InsufficientPermissionsError); } let tournament = Tournament::post(json, pool).await?; @@ -226,8 +228,9 @@ async fn create_tournament( (get_tournament_example_with_id()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to read this tournament" ), (status=404, description = "Tournament not found"), @@ -247,7 +250,7 @@ async fn get_tournament_by_id( match tournament_user.has_permission(Permission::ReadTournament) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match Tournament::get_by_id(id, pool).await { Ok(tournament) => Ok(Json(tournament).into_response()), @@ -267,8 +270,9 @@ async fn get_tournament_by_id( example=json!(get_tournament_example_with_id()) ), (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), ( - status=401, + status=403, description = "The user is not permitted to modify this tournament" ), (status=404, description = "Tournament not found"), @@ -290,7 +294,7 @@ async fn patch_tournament_by_id( match tournament_user.has_permission(Permission::WriteTournament) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let tournament = Tournament::get_by_id(id, pool).await?; @@ -313,7 +317,8 @@ async fn patch_tournament_by_id( responses( (status=204, description = "Tournament deleted successfully"), (status=400, description = "Bad request"), - (status=401, description = "The user is not permitted to modify this tournament"), + (status=401, description = "Authentication error"), + (status=403, description = "The user is not permitted to modify this tournament"), (status=404, description = "Tournament not found"), (status=409, description = "Other resources reference this tournament. They must be deleted first") ), @@ -331,7 +336,7 @@ async fn delete_tournament_by_id( match tournament_user.has_permission(Permission::WriteTournament) { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } let tournament = Tournament::get_by_id(id, pool).await?; From 4436dba2f16579f91012851a94d79d220e3bb37f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Sat, 1 Mar 2025 17:21:25 +0100 Subject: [PATCH 08/23] [53] implement judges' affiliations endpoints --- migrations/20241219_init.sql | 7 + src/omni_error.rs | 6 + src/routes/affiliation.rs | 410 +++++++++++++++++++++++++++++++++++ src/routes/mod.rs | 2 + src/users/permissions.rs | 3 + src/users/queries.rs | 10 + 6 files changed, 438 insertions(+) create mode 100644 src/routes/affiliation.rs diff --git a/migrations/20241219_init.sql b/migrations/20241219_init.sql index 937605b..8dbaf8e 100644 --- a/migrations/20241219_init.sql +++ b/migrations/20241219_init.sql @@ -80,3 +80,10 @@ CREATE TABLE IF NOT EXISTS debate_judge_assignments ( judge_user_id UUID NOT NULL REFERENCES users(id), debate_id UUID NOT NULL REFERENCES debates(id) ); + +CREATE TABLE IF NOT EXISTS judge_team_assignments ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + judge_user_id UUID NOT NULL REFERENCES users(id), + team_id UUID NOT NULL REFERENCES teams(id), + tournament_id UUID NOT NULL REFERENCES tournaments(id) +) diff --git a/src/omni_error.rs b/src/omni_error.rs index 9e5f440..4639615 100644 --- a/src/omni_error.rs +++ b/src/omni_error.rs @@ -13,6 +13,8 @@ const ATTENDEE_POSITION_MESSAGE: &str = "Attendee position must be in range [1,4] or None"; const INSUFFICIENT_PERMISSIONS_MESSAGE: &str = "You are not permitted to perform this operation"; +const NOT_A_JUDGE_MESSAGE: &str = + "This user is not a Judge and therefore cannot have affiliations"; #[derive(thiserror::Error, Debug)] pub enum OmniError { @@ -51,6 +53,8 @@ pub enum OmniError { AttendeePositionError, #[error("{INSUFFICIENT_PERMISSIONS_MESSAGE}")] InsufficientPermissionsError, + #[error{"NOT_A_JUDGE_MESSAGE"}] + NotAJudgeError, } impl IntoResponse for OmniError { @@ -148,6 +152,7 @@ impl OmniError { E::InsufficientPermissionsError => { (StatusCode::FORBIDDEN, self.clerr()).into_response() } + E::NotAJudgeError => (StatusCode::CONFLICT, self.clerr()).into_response(), } } @@ -170,6 +175,7 @@ impl OmniError { E::BadRequestError => BAD_REQUEST, E::AttendeePositionError => ATTENDEE_POSITION_MESSAGE, E::InsufficientPermissionsError => INSUFFICIENT_PERMISSIONS_MESSAGE, + E::NotAJudgeError => NOT_A_JUDGE_MESSAGE, } .to_string() } diff --git a/src/routes/affiliation.rs b/src/routes/affiliation.rs new file mode 100644 index 0000000..ffb7cab --- /dev/null +++ b/src/routes/affiliation.rs @@ -0,0 +1,410 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, Error, Pool, Postgres}; +use tower_cookies::Cookies; +use tracing::error; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + omni_error::OmniError, + setup::AppState, + users::{permissions::Permission, roles::Role, TournamentUser, User}, +}; + +use super::tournament::Tournament; + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +/// Some Judges might be affiliated with certain teams, +/// which poses a risk of biased rulings. +/// Tournament Organizers can denote such affiliations. +/// A Judge is prevented from ruling debates wherein +/// one of the sides is a team they're affiliated with. +pub struct Affiliation { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + id: Uuid, + tournament_id: Uuid, + team_id: Uuid, + judge_user_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct AffiliationPatch { + tournament_id: Option, + team_id: Option, + judge_user_id: Option, +} + +impl Affiliation { + async fn post( + affiliation: Affiliation, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Affiliation, + r#"INSERT INTO judge_team_assignments(id, judge_user_id, team_id, tournament_id) + VALUES ($1, $2, $3, $4) RETURNING id, judge_user_id, team_id, tournament_id"#, + affiliation.id, + affiliation.judge_user_id, + affiliation.team_id, + affiliation.tournament_id + ) + .fetch_one(connection_pool) + .await + { + Ok(_) => Ok(affiliation), + Err(e) => Err(e)?, + } + } + + async fn get_by_id( + id: Uuid, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Affiliation, + "SELECT * FROM judge_team_assignments WHERE id = $1", + id + ) + .fetch_one(connection_pool) + .await + { + Ok(affiliation) => Ok(affiliation), + Err(e) => Err(e), + } + } + + async fn patch( + self, + patch: Affiliation, + connection_pool: &Pool, + ) -> Result { + match query!( + "UPDATE judge_team_assignments SET judge_user_id = $1, tournament_id = $2, team_id = $3 WHERE id = $4", + patch.judge_user_id, + patch.tournament_id, + patch.team_id, + self.id, + ) + .execute(connection_pool) + .await + { + Ok(_) => Ok(patch), + Err(e) => Err(e), + } + } + + async fn delete(self, connection_pool: &Pool) -> Result<(), Error> { + match query!("DELETE FROM judge_team_assignments WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e), + } + } + + async fn validate(&self, pool: &Pool) -> Result<(), OmniError> { + let user = User::get_by_id(self.judge_user_id, pool).await?; + if !user.has_role(Role::Judge, self.tournament_id, pool).await? { + return Err(OmniError::NotAJudgeError); + } + + let _tournament = Tournament::get_by_id(self.tournament_id, pool).await?; + + if self.already_exists(pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } + + Ok(()) + } + + async fn already_exists(&self, pool: &Pool) -> Result { + match query_as!(Affiliation, + "SELECT * FROM judge_team_assignments WHERE judge_user_id = $1 AND tournament_id = $2 AND team_id = $3", + self.judge_user_id, + self.tournament_id, + self.team_id + ).fetch_optional(pool).await { + Ok(result) => { + if result.is_none() { + return Ok(false); + } + else { + return Ok(true); + } + }, + Err(e) => Err(e)?, + } + } +} + +pub fn route() -> Router { + Router::new() + .route( + "/affiliation", + get(get_affiliations).post(create_affiliation), + ) + .route( + "/affiliation/:affiliation_id", + get(get_affiliation_by_id) + .patch(patch_affiliation_by_id) + .delete(delete_affiliation_by_id), + ) +} + +/// Create a new affiliation +/// +/// Available only to Organizers and the infrastructure admin. +#[utoipa::path(post, request_body=Affiliation, path = "/user/{user_id}/affiliation", + responses + ( + ( + status=200, description = "Affiliation created successfully", + body=Affiliation, + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + (status=500, description = "Internal server error"), + ) +)] +async fn create_affiliation( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, + Json(affiliation): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + affiliation.validate(pool).await?; + match Affiliation::post(affiliation, pool).await { + Ok(affiliation) => Ok(Json(affiliation).into_response()), + Err(e) => { + error!("Error creating a new affiliation: {e}"); + Err(e) + } + } +} + +#[utoipa::path(get, path = "/user/{user_id}/tournament/{tournament_id}/affiliation", + responses + ( + (status=200, description = "Ok", body=Vec), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + (status=500, description = "Internal server error"), + ) +)] +/// Get a list of all user affiliations. +/// +/// Available only to Organizers and the infrastructure admin. +async fn get_affiliations( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(user_id): Path, + Path(tournament_id): Path, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let affiliated_user = User::get_by_id(user_id, pool).await?; + if !affiliated_user + .has_role(Role::Judge, tournament_id, pool) + .await? + { + return Err(OmniError::NotAJudgeError); + } + + let _tournament = Tournament::get_by_id(tournament_id, pool).await?; + match query_as!( + Affiliation, + "SELECT * FROM judge_team_assignments WHERE tournament_id = $1", + tournament_id + ) + .fetch_all(&state.connection_pool) + .await + { + Ok(affiliations) => Ok(Json(affiliations).into_response()), + Err(e) => { + error!( + "Error getting affiliations of user {} within tournament {}: {e}", + user_id, tournament_id + ); + Err(e)? + } + } +} + +/// Get details of an existing affiliation +/// +/// Available only to Organizers and the infrastructure admin. +#[utoipa::path(get, path = "/user/{user_id}/tournament/{tournament_id}/affiliation/{id}", + responses( + (status=200, description = "Ok", body=Affiliation), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + (status=500, description = "Internal server error"), + ), +)] +async fn get_affiliation_by_id( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, + Path(id): Path, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + match Affiliation::get_by_id(id, pool).await { + Ok(affiliation) => Ok(Json(affiliation).into_response()), + Err(e) => { + error!("Error getting a affiliation with id {id}: {e}"); + Err(e)? + } + } +} + +/// Patch an existing affiliation +/// +/// Available only to Organizers and the infrastructure admin. +#[utoipa::path(patch, path = "/tournament/{tournament_id}/affiliation/{id}", + request_body=Affiliation, + responses( + (status=200, description = "Affiliation patched successfully", body=Affiliation), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + ( + status=409, + description = "This affiliation already exists", + ), + (status=500, description = "Internal server error"), + ) +)] +async fn patch_affiliation_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, + Json(new_affiliation): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let old_affiliation = Affiliation::get_by_id(id, pool).await?; + + let new_affiliation = Affiliation { + id: old_affiliation.id, + judge_user_id: new_affiliation + .judge_user_id + .unwrap_or(old_affiliation.judge_user_id), + tournament_id: new_affiliation + .tournament_id + .unwrap_or(old_affiliation.tournament_id), + team_id: new_affiliation.team_id.unwrap_or(old_affiliation.team_id), + }; + new_affiliation.validate(pool).await?; + + match old_affiliation.patch(new_affiliation, pool).await { + Ok(affiliation) => Ok(Json(affiliation).into_response()), + Err(e) => Err(e)?, + } +} + +/// Delete an existing affiliation +/// +/// Available only to Organizers and the infrastructure admin. +#[utoipa::path(delete, path = "/affiliation/{id}", + responses + ( + (status=204, description = "Affiliation deleted successfully"), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify affiliations within this tournament" + ), + (status=404, description = "Tournament or affiliation not found"), + ), +)] +async fn delete_affiliation_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteAffiliations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let affiliation = Affiliation::get_by_id(id, pool).await?; + match affiliation.delete(&state.connection_pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => { + error!("Error deleting a affiliation with id {id}: {e}"); + Err(e)? + } + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 4bd5ead..73817d0 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,6 +2,7 @@ use axum::Router; use crate::setup::AppState; +mod affiliation; mod attendee; mod auth; mod debate; @@ -27,4 +28,5 @@ pub fn routes() -> Router { .merge(attendee::route()) .merge(motion::route()) .merge(debate::route()) + .merge(affiliation::route()) } diff --git a/src/users/permissions.rs b/src/users/permissions.rs index df9044d..500b5c4 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -29,4 +29,7 @@ pub enum Permission { SubmitOwnVerdictVote, SubmitVerdict, + + ReadAffiliations, + WriteAffiliations, } diff --git a/src/users/queries.rs b/src/users/queries.rs index 8fcf84d..e9c728b 100644 --- a/src/users/queries.rs +++ b/src/users/queries.rs @@ -122,6 +122,16 @@ impl User { Ok(vec) } + + pub async fn has_role( + &self, + role: Role, + tournament_id: Uuid, + pool: &Pool, + ) -> Result { + let roles = self.get_roles(tournament_id, pool).await?; + return Ok(roles.contains(&role)); + } } impl TournamentUser { From c62c167fa13f9d5dd3aa61e3117cc931474e8fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Sat, 1 Mar 2025 17:31:06 +0100 Subject: [PATCH 09/23] [53] update docs --- ...f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc.json | 14 ++++++ ...d45f32f0feefc335f4e1f286e11ae032588bd.json | 17 ++++++++ ...09753514330168329bfe794450132a1d68ffe.json | 40 +++++++++++++++++ ...d4b51f4ce5856e25ed4eec454804bae407528.json | 40 +++++++++++++++++ ...065318d0831d93996fd9f1e5bd0da7aa19712.json | 42 ++++++++++++++++++ ...34415ed0c0288fa2a15f023803caba6860b2d.json | 43 +++++++++++++++++++ src/routes/affiliation.rs | 6 +-- src/routes/swagger.rs | 8 ++++ 8 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 .sqlx/query-06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc.json create mode 100644 .sqlx/query-369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd.json create mode 100644 .sqlx/query-830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe.json create mode 100644 .sqlx/query-d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528.json create mode 100644 .sqlx/query-f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712.json create mode 100644 .sqlx/query-f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d.json diff --git a/.sqlx/query-06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc.json b/.sqlx/query-06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc.json new file mode 100644 index 0000000..66e8048 --- /dev/null +++ b/.sqlx/query-06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM judge_team_assignments WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "06f16ff93f9bedfe4c4102c6af1f1fb97d9e7ebb20cd9ff5fb319a4712f4eefc" +} diff --git a/.sqlx/query-369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd.json b/.sqlx/query-369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd.json new file mode 100644 index 0000000..0b0e899 --- /dev/null +++ b/.sqlx/query-369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE judge_team_assignments SET judge_user_id = $1, tournament_id = $2, team_id = $3 WHERE id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "369b567a846a8f3d517f8210dc7d45f32f0feefc335f4e1f286e11ae032588bd" +} diff --git a/.sqlx/query-830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe.json b/.sqlx/query-830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe.json new file mode 100644 index 0000000..fb4ee8f --- /dev/null +++ b/.sqlx/query-830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM judge_team_assignments WHERE tournament_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "judge_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "team_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "830064b5d531fb8036e611eaa9a09753514330168329bfe794450132a1d68ffe" +} diff --git a/.sqlx/query-d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528.json b/.sqlx/query-d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528.json new file mode 100644 index 0000000..62b1d31 --- /dev/null +++ b/.sqlx/query-d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM judge_team_assignments WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "judge_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "team_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "d31f70c8bf2ec40f9601cdd78a1d4b51f4ce5856e25ed4eec454804bae407528" +} diff --git a/.sqlx/query-f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712.json b/.sqlx/query-f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712.json new file mode 100644 index 0000000..7e55f82 --- /dev/null +++ b/.sqlx/query-f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM judge_team_assignments WHERE judge_user_id = $1 AND tournament_id = $2 AND team_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "judge_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "team_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f60d4ee83c7f15d65001c02bc76065318d0831d93996fd9f1e5bd0da7aa19712" +} diff --git a/.sqlx/query-f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d.json b/.sqlx/query-f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d.json new file mode 100644 index 0000000..132b09a --- /dev/null +++ b/.sqlx/query-f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO judge_team_assignments(id, judge_user_id, team_id, tournament_id)\n VALUES ($1, $2, $3, $4) RETURNING id, judge_user_id, team_id, tournament_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "judge_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "team_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f63ffb95243e206f30224c51d6234415ed0c0288fa2a15f023803caba6860b2d" +} diff --git a/src/routes/affiliation.rs b/src/routes/affiliation.rs index ffb7cab..a48d642 100644 --- a/src/routes/affiliation.rs +++ b/src/routes/affiliation.rs @@ -164,7 +164,7 @@ pub fn route() -> Router { /// Create a new affiliation /// /// Available only to Organizers and the infrastructure admin. -#[utoipa::path(post, request_body=Affiliation, path = "/user/{user_id}/affiliation", +#[utoipa::path(post, request_body=Affiliation, path = "/user/{user_id}/tournament/{tournament_id}/affiliation", responses ( ( @@ -312,7 +312,7 @@ async fn get_affiliation_by_id( /// Patch an existing affiliation /// /// Available only to Organizers and the infrastructure admin. -#[utoipa::path(patch, path = "/tournament/{tournament_id}/affiliation/{id}", +#[utoipa::path(patch, path = "/user/{user_id}/tournament/{tournament_id}/affiliation/{id}", request_body=Affiliation, responses( (status=200, description = "Affiliation patched successfully", body=Affiliation), @@ -370,7 +370,7 @@ async fn patch_affiliation_by_id( /// Delete an existing affiliation /// /// Available only to Organizers and the infrastructure admin. -#[utoipa::path(delete, path = "/affiliation/{id}", +#[utoipa::path(delete, path = "/user/{user_id}/tournament/{tournament_id}/affiliation/{id}", responses ( (status=204, description = "Affiliation deleted successfully"), diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 1e5c09d..78aba62 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -5,6 +5,7 @@ use utoipa_swagger_ui::SwaggerUi; use crate::routes::auth; use crate::setup::AppState; +use crate::routes::affiliation; use crate::routes::attendee; use crate::routes::debate; use crate::routes::motion; @@ -56,6 +57,11 @@ pub fn route() -> Router { attendee::patch_attendee_by_id, attendee::delete_attendee_by_id, auth::auth_login, + affiliation::create_affiliation, + affiliation::get_affiliations, + affiliation::get_affiliation_by_id, + affiliation::patch_affiliation_by_id, + affiliation::delete_affiliation_by_id, ), components(schemas( version::VersionDetails, @@ -74,6 +80,8 @@ pub fn route() -> Router { permissions::Permission, roles::Role, auth::LoginRequest, + affiliation::Affiliation, + affiliation::AffiliationPatch, )) )] From 45b027b3d869bbbcaef0df54ccf953399ccb9ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Tue, 4 Mar 2025 17:38:29 +0100 Subject: [PATCH 10/23] [51] create implementations for rooms and locations --- migrations/20241219_init.sql | 16 ++++ src/tournament_impl/location_impl.rs | 112 +++++++++++++++++++++++++++ src/tournament_impl/mod.rs | 3 + src/tournament_impl/motion_impl.rs | 4 +- src/tournament_impl/room_impl.rs | 108 ++++++++++++++++++++++++++ src/tournament_impl/utils.rs | 10 +++ 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/tournament_impl/location_impl.rs create mode 100644 src/tournament_impl/room_impl.rs create mode 100644 src/tournament_impl/utils.rs diff --git a/migrations/20241219_init.sql b/migrations/20241219_init.sql index 937605b..b66524c 100644 --- a/migrations/20241219_init.sql +++ b/migrations/20241219_init.sql @@ -80,3 +80,19 @@ CREATE TABLE IF NOT EXISTS debate_judge_assignments ( judge_user_id UUID NOT NULL REFERENCES users(id), debate_id UUID NOT NULL REFERENCES debates(id) ); + +CREATE TABLE IF NOT EXISTS locations ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + tournament_id UUID NOT NULL REFERENCES tournaments(id), + address TEXT, + remarks TEXT +); + +CREATE TABLE IF NOT EXISTS rooms ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + location_id UUID NOT NULL REFERENCES locations(id), + remarks TEXT, + is_occupied BOOLEAN NOT NULL DEFAULT FALSE +) diff --git a/src/tournament_impl/location_impl.rs b/src/tournament_impl/location_impl.rs new file mode 100644 index 0000000..e9b559c --- /dev/null +++ b/src/tournament_impl/location_impl.rs @@ -0,0 +1,112 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, Pool, Postgres}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::omni_error::OmniError; + +use super::utils::get_optional_value_to_be_patched; + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +/// Some tournaments stretch across multiple locations. +/// This struct is intended to be a representation of a bigger location +/// (e.g. a particular school or university campus), +/// possibly containing multiple places (i.e. rooms) +/// to conduct debates at. +pub struct Location { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + pub id: Uuid, + pub name: String, + /// A field dedicated to store information about location address. + /// While contents of this field could be included in remarks, + /// its presence prompts the user to include address information + pub address: Option, + pub remarks: Option, + pub tournament_id: Uuid, +} + +pub struct LocationPatch { + pub name: Option, + pub address: Option, + pub remarks: Option, + pub tournament_id: Option, +} + +impl Location { + pub async fn post( + location: Location, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Location, + r#"INSERT INTO locations(id, name, address, remarks, tournament_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id, name, address, remarks, tournament_id"#, + location.id, + location.name, + location.address, + location.remarks, + location.tournament_id + ) + .fetch_one(connection_pool) + .await + { + Ok(_) => Ok(location), + Err(e) => Err(e)?, + } + } + + pub async fn get_by_id( + id: Uuid, + connection_pool: &Pool, + ) -> Result { + match query_as!(Location, "SELECT * FROM locations WHERE id = $1", id) + .fetch_one(connection_pool) + .await + { + Ok(location) => Ok(location), + Err(e) => Err(e)?, + } + } + + pub async fn patch( + self, + new_location: LocationPatch, + connection_pool: &Pool, + ) -> Result { + let patch = Location { + id: self.id, + name: new_location.name.unwrap_or(self.name), + address: get_optional_value_to_be_patched(new_location.address, self.address), + remarks: get_optional_value_to_be_patched(new_location.remarks, self.remarks), + tournament_id: new_location.tournament_id.unwrap_or(self.tournament_id), + }; + match query!( + r#"UPDATE locations set name = $1, address = $2, + remarks = $3, tournament_id = $4 + WHERE id = $5"#, + patch.name, + patch.address, + patch.remarks, + patch.tournament_id, + self.id, + ) + .execute(connection_pool) + .await + { + Ok(_) => Ok(patch), + Err(e) => Err(e)?, + } + } + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM locations WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } +} diff --git a/src/tournament_impl/mod.rs b/src/tournament_impl/mod.rs index 5d7fbc2..8cc2c10 100644 --- a/src/tournament_impl/mod.rs +++ b/src/tournament_impl/mod.rs @@ -8,8 +8,11 @@ use crate::omni_error::OmniError; pub(crate) mod attendee_impl; pub(crate) mod debate_impl; +pub(crate) mod location_impl; pub(crate) mod motion_impl; +pub(crate) mod room_impl; pub(crate) mod team_impl; +pub(crate) mod utils; #[derive(Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] diff --git a/src/tournament_impl/motion_impl.rs b/src/tournament_impl/motion_impl.rs index 7d97544..84df784 100644 --- a/src/tournament_impl/motion_impl.rs +++ b/src/tournament_impl/motion_impl.rs @@ -7,6 +7,8 @@ use uuid::Uuid; use crate::omni_error::OmniError; +use super::utils::get_optional_value_to_be_patched; + #[derive(Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct Motion { @@ -76,7 +78,7 @@ impl Motion { let motion = Motion { id: self.id, motion: patch.motion.unwrap_or(self.motion), - adinfo: patch.adinfo, + adinfo: get_optional_value_to_be_patched(patch.adinfo, self.adinfo), }; match query!( "UPDATE motions SET motion = $1, adinfo = $2 WHERE id = $3", diff --git a/src/tournament_impl/room_impl.rs b/src/tournament_impl/room_impl.rs new file mode 100644 index 0000000..f9b8289 --- /dev/null +++ b/src/tournament_impl/room_impl.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, Pool, Postgres}; +use tracing::error; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::omni_error::OmniError; + +use super::utils::get_optional_value_to_be_patched; + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +/// A debate must be held in a particular place (or Room). +/// A room must be assigned to a preexisting Location. +/// While a debate +pub struct Room { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + pub id: Uuid, + pub name: String, + pub remarks: Option, + pub location_id: Uuid, + pub is_occupied: bool, +} + +pub struct RoomPatch { + pub name: Option, + pub address: Option, + pub remarks: Option, + pub location_id: Option, + pub is_occupied: Option, +} + +impl Room { + pub async fn post( + room: Room, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Room, + r#"INSERT INTO rooms(id, name, remarks, location_id, is_occupied) + VALUES ($1, $2, $3, $4, $5) RETURNING id, name, remarks, location_id, is_occupied"#, + room.id, + room.name, + room.remarks, + room.location_id, + room.is_occupied + ) + .fetch_one(connection_pool) + .await + { + Ok(_) => Ok(room), + Err(e) => Err(e)?, + } + } + + pub async fn get_by_id( + id: Uuid, + connection_pool: &Pool, + ) -> Result { + match query_as!(Room, "SELECT * FROM rooms WHERE id = $1", id) + .fetch_one(connection_pool) + .await + { + Ok(room) => Ok(room), + Err(e) => Err(e)?, + } + } + + pub async fn patch( + self, + new_room: RoomPatch, + connection_pool: &Pool, + ) -> Result { + let patch = Room { + id: self.id, + name: new_room.name.unwrap_or(self.name), + remarks: get_optional_value_to_be_patched(new_room.remarks, self.remarks), + location_id: new_room.location_id.unwrap_or(self.location_id), + is_occupied: new_room.is_occupied.unwrap_or(self.is_occupied), + }; + match query!( + r#"UPDATE rooms set name = $1, + remarks = $2, location_id = $3 + WHERE id = $4"#, + patch.name, + patch.remarks, + patch.location_id, + self.id, + ) + .execute(connection_pool) + .await + { + Ok(_) => Ok(patch), + Err(e) => Err(e)?, + } + } + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM rooms WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } +} diff --git a/src/tournament_impl/utils.rs b/src/tournament_impl/utils.rs new file mode 100644 index 0000000..cb3b1a3 --- /dev/null +++ b/src/tournament_impl/utils.rs @@ -0,0 +1,10 @@ +pub fn get_optional_value_to_be_patched( + old_value: Option, + new_value: Option, +) -> Option { + if new_value.is_some() { + new_value + } else { + old_value + } +} From 114ed647df0a5ff475250f47d081802ba4947a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Tue, 4 Mar 2025 18:19:44 +0100 Subject: [PATCH 11/23] [51] implement endpoints for rooms and locations --- ...e8b5b82d3475c3151d0b372a7ce94bb55a374.json | 46 +++ ...d3a5b0f3252961101098b0a10436908e70cf1.json | 46 +++ ...3ffa151feb75f443f91df0c133c9867eb7ee8.json | 17 + ...a33f597b1610ce8ebd5b3861b7b8e85a3cbd6.json | 18 + ...0d84c2eb56653b8dc2678ddc89e53101774df.json | 14 + ...549b25c94a935ce6f5efbff6aa5a60872ff9f.json | 46 +++ ...2d874fe41870fbeec12f44404056f1384a643.json | 14 + ...48967138ecf6f5807baddf80d76472e3108be.json | 50 +++ ...1d1d5a84cd31be41922403e049dee3240ba5b.json | 23 ++ ...9e3d26d5f98019ab2a90f8a25fc60c889fcc4.json | 23 ++ ...da681801360f32b72b977e2fd9d8105f0c3c9.json | 46 +++ ...adb0f8040f0c8a9fc8f877aa213ee3dd58fdd.json | 50 +++ src/routes/location.rs | 315 ++++++++++++++++++ src/routes/mod.rs | 4 + src/routes/room.rs | 315 ++++++++++++++++++ src/routes/swagger.rs | 18 + src/tournament_impl/location_impl.rs | 5 +- src/tournament_impl/room_impl.rs | 2 + src/users/permissions.rs | 7 + src/users/roles.rs | 5 + 20 files changed, 1063 insertions(+), 1 deletion(-) create mode 100644 .sqlx/query-15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374.json create mode 100644 .sqlx/query-3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1.json create mode 100644 .sqlx/query-60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8.json create mode 100644 .sqlx/query-886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6.json create mode 100644 .sqlx/query-9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df.json create mode 100644 .sqlx/query-9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f.json create mode 100644 .sqlx/query-a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643.json create mode 100644 .sqlx/query-a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be.json create mode 100644 .sqlx/query-a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b.json create mode 100644 .sqlx/query-b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4.json create mode 100644 .sqlx/query-bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9.json create mode 100644 .sqlx/query-da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd.json create mode 100644 src/routes/location.rs create mode 100644 src/routes/room.rs diff --git a/.sqlx/query-15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374.json b/.sqlx/query-15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374.json new file mode 100644 index 0000000..1e94767 --- /dev/null +++ b/.sqlx/query-15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM locations WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "tournament_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "remarks", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "15b002347cae536c70ecb9bad3ee8b5b82d3475c3151d0b372a7ce94bb55a374" +} diff --git a/.sqlx/query-3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1.json b/.sqlx/query-3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1.json new file mode 100644 index 0000000..ea8b0ab --- /dev/null +++ b/.sqlx/query-3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM rooms WHERE location_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "location_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "remarks", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "is_occupied", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "3d8e9754d243aae6bb5e37da1dfd3a5b0f3252961101098b0a10436908e70cf1" +} diff --git a/.sqlx/query-60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8.json b/.sqlx/query-60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8.json new file mode 100644 index 0000000..8c190d3 --- /dev/null +++ b/.sqlx/query-60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE rooms set name = $1,\n remarks = $2, location_id = $3\n WHERE id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "60dcde7c17767532d534ab9322e3ffa151feb75f443f91df0c133c9867eb7ee8" +} diff --git a/.sqlx/query-886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6.json b/.sqlx/query-886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6.json new file mode 100644 index 0000000..6ce0112 --- /dev/null +++ b/.sqlx/query-886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE locations set name = $1, address = $2,\n remarks = $3, tournament_id = $4\n WHERE id = $5", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "886a24e4a1743a5a12f578cf9d3a33f597b1610ce8ebd5b3861b7b8e85a3cbd6" +} diff --git a/.sqlx/query-9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df.json b/.sqlx/query-9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df.json new file mode 100644 index 0000000..9e14891 --- /dev/null +++ b/.sqlx/query-9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM rooms WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9611e66d757a11ccf611a95a2580d84c2eb56653b8dc2678ddc89e53101774df" +} diff --git a/.sqlx/query-9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f.json b/.sqlx/query-9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f.json new file mode 100644 index 0000000..4ac23b4 --- /dev/null +++ b/.sqlx/query-9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM locations WHERE tournament_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "tournament_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "remarks", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "9ab83de6561235e5a2cdc56430a549b25c94a935ce6f5efbff6aa5a60872ff9f" +} diff --git a/.sqlx/query-a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643.json b/.sqlx/query-a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643.json new file mode 100644 index 0000000..eb6b1e0 --- /dev/null +++ b/.sqlx/query-a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM locations WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "a41856f7cf8cfa480f51237d07a2d874fe41870fbeec12f44404056f1384a643" +} diff --git a/.sqlx/query-a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be.json b/.sqlx/query-a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be.json new file mode 100644 index 0000000..7fd738e --- /dev/null +++ b/.sqlx/query-a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO rooms(id, name, remarks, location_id, is_occupied)\n VALUES ($1, $2, $3, $4, $5) RETURNING id, name, remarks, location_id, is_occupied", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "remarks", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "location_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "is_occupied", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Uuid", + "Bool" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "a5f1c510eecbebffe465a480fb548967138ecf6f5807baddf80d76472e3108be" +} diff --git a/.sqlx/query-a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b.json b/.sqlx/query-a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b.json new file mode 100644 index 0000000..b1ae5d0 --- /dev/null +++ b/.sqlx/query-a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM locations WHERE name = $1 AND tournament_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a97c0ca1cad52117bb611b6ce711d1d5a84cd31be41922403e049dee3240ba5b" +} diff --git a/.sqlx/query-b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4.json b/.sqlx/query-b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4.json new file mode 100644 index 0000000..8434731 --- /dev/null +++ b/.sqlx/query-b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM rooms WHERE name = $1 AND location_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b9ad8eea9bf38d4d91dceac0e039e3d26d5f98019ab2a90f8a25fc60c889fcc4" +} diff --git a/.sqlx/query-bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9.json b/.sqlx/query-bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9.json new file mode 100644 index 0000000..73150a3 --- /dev/null +++ b/.sqlx/query-bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM rooms WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "location_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "remarks", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "is_occupied", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "bca111bf0d7354e34678b81605cda681801360f32b72b977e2fd9d8105f0c3c9" +} diff --git a/.sqlx/query-da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd.json b/.sqlx/query-da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd.json new file mode 100644 index 0000000..7d53243 --- /dev/null +++ b/.sqlx/query-da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO locations(id, name, address, remarks, tournament_id)\n VALUES ($1, $2, $3, $4, $5) RETURNING id, name, address, remarks, tournament_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "remarks", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "tournament_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + false + ] + }, + "hash": "da7d5e04c8e73d699ed7918234dadb0f8040f0c8a9fc8f877aa213ee3dd58fdd" +} diff --git a/src/routes/location.rs b/src/routes/location.rs new file mode 100644 index 0000000..c256bd6 --- /dev/null +++ b/src/routes/location.rs @@ -0,0 +1,315 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use sqlx::{query, query_as, Error, Pool, Postgres}; +use tower_cookies::Cookies; +use tracing::error; +use uuid::Uuid; + +use crate::{omni_error::OmniError, setup::AppState, tournament_impl::{location_impl::{Location, LocationPatch}, Tournament}, users::{permissions::Permission, TournamentUser}}; + +const DUPLICATE_NAME_ERROR: &str = "Location with this name already exists within the scope of the tournament, to which the location is assigned."; + +pub fn route() -> Router { + Router::new() + .route("/tournament/:tournament_id/location", get(get_locations).post(create_location)) + .route( + "/tournament/:tournament_id/location/:id", + get(get_location_by_id) + .patch(patch_location_by_id) + .delete(delete_location_by_id), + ) +} + +/// Create a new location +/// +/// Available only to the tournament Organizers. +#[utoipa::path(post, request_body=Location, path = "/tournament/{tournament_id}/location", + responses + ( + ( + status=200, description = "Location created successfully", + body=Location, + example=json!(get_location_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + (status=500, description = "Internal server error"), + ) +)] +async fn create_location( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, + Json(json): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + if location_with_name_exists_in_tournament(&json.name, &tournament_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } + + let _tournament = Tournament::get_by_id(tournament_id, pool).await?; + match Location::post(json, pool).await { + Ok(location) => Ok(Json(location).into_response()), + Err(e) => { + error!("Error creating a new location: {e}"); + Err(e) + }, + } +} + +#[utoipa::path(get, path = "/tournament/{tournament_id}/location", + responses + ( + ( + status=200, description = "Ok", + body=Vec, + example=json!(get_locations_list_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + (status=500, description = "Internal server error"), + ) +)] +/// Get a list of all locations +/// +/// The user must be given a role within this tournament to use this endpoint. +async fn get_locations( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let _tournament = Tournament::get_by_id(tournament_id, pool).await?; + match query_as!(Location, "SELECT * FROM locations WHERE tournament_id = $1", tournament_id) + .fetch_all(&state.connection_pool) + .await + { + Ok(locations) => Ok(Json(locations).into_response()), + Err(e) => { + error!("Error getting a list of locations: {e}"); + Err(e)? + } + } +} + +/// Get details of an existing location +/// +/// The user must be given a role within this tournament to use this endpoint. +#[utoipa::path(get, path = "/tournament/{tournament_id}/location/{id}", + responses( + ( + status=200, description = "Ok", body=Location, + example=json!(get_location_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + (status=500, description = "Internal server error"), + ), +)] +async fn get_location_by_id( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, + Path(id): Path +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + match Location::get_by_id(id, pool).await { + Ok(location) => Ok(Json(location).into_response()), + Err(e) => { + error!("Error getting a location with id {id}: {e}"); + Err(e)? + } + } +} + +/// Patch an existing location +/// +/// Available only to the tournament Organizers. +#[utoipa::path(patch, path = "/tournament/{tournament_id}/location/{id}", + request_body=Location, + responses( + ( + status=200, description = "Location patched successfully", + body=Location, + example=json!(get_location_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + ( + status=409, + description = DUPLICATE_NAME_ERROR, + ), + (status=500, description = "Internal server error"), + ) +)] +async fn patch_location_by_id( + Path((id, tournament_id)): Path<(Uuid, Uuid)>, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(new_location): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let location = Location::get_by_id(id, pool).await?; + if location_with_name_exists_in_tournament(&location.name, &location.tournament_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError) + } + + match location.patch(new_location, pool).await { + Ok(location) => Ok(Json(location).into_response()), + Err(e) => Err(e)?, + } +} + +/// Delete an existing location +/// +/// This operation is only allowed when there are no entities +/// referencing this location. Available only to the tournament Organizers. +#[utoipa::path(delete, path = "/tournament/{tournament_id}/location/{id}", + responses + ( + (status=204, description = "Location deleted successfully"), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify locations within this tournament" + ), + (status=404, description = "Tournament or location not found"), + ), +)] +async fn delete_location_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::WriteLocations) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let location = Location::get_by_id(id, pool).await?; + match location.delete(&state.connection_pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => { + error!("Error deleting a location with id {id}: {e}"); + Err(e)? + } + } +} + +async fn location_with_name_exists_in_tournament( + name: &String, + tournament_id: &Uuid, + connection_pool: &Pool, +) -> Result { + match query!( + "SELECT EXISTS(SELECT 1 FROM locations WHERE name = $1 AND tournament_id = $2)", + name, + tournament_id + ) + .fetch_one(connection_pool) + .await + { + Ok(result) => Ok(result.exists.unwrap()), + Err(e) => Err(e), + } +} + +fn get_location_example() -> String { + r#" + { + "address": "Poznań, Poland", + "name": "ZSK", + "remarks": "Where debatecore was born", + "tournament_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + "# + .to_owned() +} + +fn get_locations_list_example() -> String { + r#" + [ + { + "address": "Poznań, Poland", + "name": "ZSK", + "remarks": "Where debatecore was born", + "tournament_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + { + "address": "Bydgoszcz, Poland", + "name": "Library of the Kazimierz Wielki University", + "remarks": "Where Debate Team Buster prevailed", + "tournament_id": "57a85f64-5784-4562-4acc-35163f66afa6" + }, + ] + "# + .to_owned() +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 4bd5ead..fbbd9ec 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -7,7 +7,9 @@ mod auth; mod debate; mod health_check; mod infradmin; +mod location; mod motion; +mod room; mod swagger; mod team; mod teapot; @@ -27,4 +29,6 @@ pub fn routes() -> Router { .merge(attendee::route()) .merge(motion::route()) .merge(debate::route()) + .merge(location::route()) + .merge(room::route()) } diff --git a/src/routes/room.rs b/src/routes/room.rs new file mode 100644 index 0000000..30c1978 --- /dev/null +++ b/src/routes/room.rs @@ -0,0 +1,315 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use sqlx::{query, query_as, Error, Pool, Postgres}; +use tower_cookies::Cookies; +use tracing::error; +use uuid::Uuid; + +use crate::{omni_error::OmniError, setup::AppState, tournament_impl::{location_impl::Location, room_impl::{Room, RoomPatch}, Tournament}, users::{permissions::Permission, TournamentUser}}; + +const DUPLICATE_NAME_ERROR: &str = "Room with this name already exists within the scope of the tournament, to which the room is assigned."; + +pub fn route() -> Router { + Router::new() + .route("/tournament/:tournament_id/location/:location_id/room", get(get_rooms).post(create_room)) + .route( + "/tournament/:tournament_id/location/:location_id/room/:id", + get(get_room_by_id) + .patch(patch_room_by_id) + .delete(delete_room_by_id), + ) +} + +/// Create a new room +/// +/// Available only to the tournament Organizers. +#[utoipa::path(post, request_body=Room, path = "/tournament/{tournament_id}/location/{location_id}/room", + responses + ( + ( + status=200, description = "Room created successfully", + body=Room, + example=json!(get_room_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + (status=500, description = "Internal server error"), + ) +)] +async fn create_room( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, + Json(json): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ModifyAllRoomDetails) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + if room_with_name_exists_in_location(&json.name, &tournament_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } + + let _tournament = Tournament::get_by_id(tournament_id, pool).await?; + match Room::post(json, pool).await { + Ok(room) => Ok(Json(room).into_response()), + Err(e) => { + error!("Error creating a new room: {e}"); + Err(e) + }, + } +} + +#[utoipa::path(get, path = "/tournament/{tournament_id}/location/{location_id}/room", + responses + ( + ( + status=200, description = "Ok", + body=Vec, + example=json!(get_rooms_list_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + (status=500, description = "Internal server error"), + ) +)] +/// Get a list of all rooms within a location +/// +/// The user must be given a role within this tournament to use this endpoint. +async fn get_rooms( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path((tournament_id, location_id)): Path<(Uuid, Uuid)>, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadRooms) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let _location = Location::get_by_id(tournament_id, pool).await?; + match query_as!(Room, "SELECT * FROM rooms WHERE location_id = $1", location_id) + .fetch_all(&state.connection_pool) + .await + { + Ok(rooms) => Ok(Json(rooms).into_response()), + Err(e) => { + error!("Error getting a list of rooms: {e}"); + Err(e)? + } + } +} + +/// Get details of an existing room +/// +/// The user must be given a role within this tournament to use this endpoint. +#[utoipa::path(get, path = "/tournament/{tournament_id}/location/{location_id}/room/{id}", + responses( + ( + status=200, description = "Ok", body=Room, + example=json!(get_room_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to read rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + (status=500, description = "Internal server error"), + ), +)] +async fn get_room_by_id( + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, + Path(id): Path +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ReadRooms) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + match Room::get_by_id(id, pool).await { + Ok(room) => Ok(Json(room).into_response()), + Err(e) => { + error!("Error getting a room with id {id}: {e}"); + Err(e)? + } + } +} + +/// Patch an existing room +/// +/// Available only to the tournament Organizers. +#[utoipa::path(patch, path = "/tournament/{tournament_id}/location/{location_id}/room/{id}", + request_body=Room, + responses( + ( + status=200, description = "Room patched successfully", + body=Room, + example=json!(get_room_example()) + ), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + ( + status=409, + description = DUPLICATE_NAME_ERROR, + ), + (status=500, description = "Internal server error"), + ) +)] +async fn patch_room_by_id( + Path((id, tournament_id)): Path<(Uuid, Uuid)>, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Json(new_room): Json, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ModifyAllRoomDetails) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let room = Room::get_by_id(id, pool).await?; + if room_with_name_exists_in_location(&room.name, &room.location_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError) + } + + match room.patch(new_room, pool).await { + Ok(room) => Ok(Json(room).into_response()), + Err(e) => Err(e)?, + } +} + +/// Delete an existing room +/// +/// This operation is only allowed when there are no entities +/// referencing this room. Available only to the tournament Organizers. +#[utoipa::path(delete, path = "/tournament/{tournament_id}/location/{location_id}/room/{id}", + responses + ( + (status=204, description = "Room deleted successfully"), + (status=400, description = "Bad request"), + (status=401, description = "Authentication error"), + ( + status=403, + description = "The user is not permitted to modify rooms within this tournament" + ), + (status=404, description = "Tournament or room not found"), + ), +)] +async fn delete_room_by_id( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, + Path(tournament_id): Path, +) -> Result { + let pool = &state.connection_pool; + let tournament_user = + TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + + match tournament_user.has_permission(Permission::ModifyAllRoomDetails) { + true => (), + false => return Err(OmniError::InsufficientPermissionsError), + } + + let room = Room::get_by_id(id, pool).await?; + match room.delete(&state.connection_pool).await { + Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(e) => { + error!("Error deleting a room with id {id}: {e}"); + Err(e)? + } + } +} + +async fn room_with_name_exists_in_location( + name: &String, + location_id: &Uuid, + connection_pool: &Pool, +) -> Result { + match query!( + "SELECT EXISTS(SELECT 1 FROM rooms WHERE name = $1 AND location_id = $2)", + name, + location_id + ) + .fetch_one(connection_pool) + .await + { + Ok(result) => Ok(result.exists.unwrap()), + Err(e) => Err(e), + } +} + +fn get_room_example() -> String { + r#" + { + "is_occupied": true, + "location_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Room 32", + "remarks": "Third floor" + } + "# + .to_owned() +} + +fn get_rooms_list_example() -> String { + r#" + [ + { + "is_occupied": true, + "location_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Room 32", + "remarks": "Third floor" + }, + { + "is_occupied": true, + "location_id": "77abaf34-5782-4562-b3fc-93963f66afa6", + "name": "Room 44", + "remarks": "Fourth floor" + } + ] + "# + .to_owned() +} diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index aa65cf8..ac531c7 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -7,13 +7,17 @@ use crate::setup::AppState; use crate::routes::attendee; use crate::routes::debate; +use crate::routes::location; use crate::routes::motion; +use crate::routes::room; use crate::routes::team; use crate::routes::tournament; use crate::tournament_impl; use crate::tournament_impl::attendee_impl; use crate::tournament_impl::debate_impl; +use crate::tournament_impl::location_impl; use crate::tournament_impl::motion_impl; +use crate::tournament_impl::room_impl; use crate::tournament_impl::team_impl; use crate::users::permissions; use crate::users::roles; @@ -61,6 +65,16 @@ pub fn route() -> Router { attendee::patch_attendee_by_id, attendee::delete_attendee_by_id, auth::auth_login, + location::create_location, + location::get_locations, + location::get_location_by_id, + location::patch_location_by_id, + location::delete_location_by_id, + room::create_room, + room::get_rooms, + room::get_room_by_id, + room::patch_room_by_id, + room::delete_room_by_id, ), components(schemas( version::VersionDetails, @@ -79,6 +93,10 @@ pub fn route() -> Router { permissions::Permission, roles::Role, auth::LoginRequest, + location_impl::Location, + location_impl::LocationPatch, + room_impl::Room, + room_impl::RoomPatch, )) )] diff --git a/src/tournament_impl/location_impl.rs b/src/tournament_impl/location_impl.rs index e9b559c..4d2ce99 100644 --- a/src/tournament_impl/location_impl.rs +++ b/src/tournament_impl/location_impl.rs @@ -18,15 +18,18 @@ pub struct Location { #[serde(skip_deserializing)] #[serde(default = "Uuid::now_v7")] pub id: Uuid, + /// Location name. Must be unique within a tournament. pub name: String, /// A field dedicated to store information about location address. /// While contents of this field could be included in remarks, - /// its presence prompts the user to include address information + /// its presence prompts the user to include address information. pub address: Option, pub remarks: Option, pub tournament_id: Uuid, } +#[derive(ToSchema, Deserialize)] +#[serde(deny_unknown_fields)] pub struct LocationPatch { pub name: Option, pub address: Option, diff --git a/src/tournament_impl/room_impl.rs b/src/tournament_impl/room_impl.rs index f9b8289..36fde7e 100644 --- a/src/tournament_impl/room_impl.rs +++ b/src/tournament_impl/room_impl.rs @@ -17,12 +17,14 @@ pub struct Room { #[serde(skip_deserializing)] #[serde(default = "Uuid::now_v7")] pub id: Uuid, + /// Must be unique within a location. pub name: String, pub remarks: Option, pub location_id: Uuid, pub is_occupied: bool, } +#[derive(ToSchema, Deserialize)] pub struct RoomPatch { pub name: Option, pub address: Option, diff --git a/src/users/permissions.rs b/src/users/permissions.rs index df9044d..9083cfe 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -29,4 +29,11 @@ pub enum Permission { SubmitOwnVerdictVote, SubmitVerdict, + + ReadLocations, + WriteLocations, + + ReadRooms, + ModifyAllRoomDetails, + ChangeRoomOccupationStatus, } diff --git a/src/users/roles.rs b/src/users/roles.rs index 5763b5e..24d1c7e 100644 --- a/src/users/roles.rs +++ b/src/users/roles.rs @@ -26,13 +26,18 @@ impl Role { P::ReadTeams, P::ReadTournament, P::SubmitOwnVerdictVote, + P::ReadLocations, + P::ReadRooms, ], Role::Marshall => vec![ P::ReadDebates, P::ReadAttendees, P::ReadTeams, P::ReadTournament, + P::ReadLocations, + P::ReadRooms, P::SubmitVerdict, + P::ChangeRoomOccupationStatus, ], } } From 362b2403ecdb4f623557597d51ea61447b494412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Tue, 4 Mar 2025 21:09:53 +0100 Subject: [PATCH 12/23] [51] fix id confusion --- src/omni_error.rs | 19 +++++++++++++---- src/routes/location.rs | 20 ++++++++++-------- src/routes/room.rs | 31 ++++++++++++++++++---------- src/tournament_impl/location_impl.rs | 18 ++++++++++++++-- src/tournament_impl/room_impl.rs | 2 +- 5 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/omni_error.rs b/src/omni_error.rs index 533546f..5526b82 100644 --- a/src/omni_error.rs +++ b/src/omni_error.rs @@ -12,6 +12,7 @@ const BAD_REQUEST: &str = "Bad Request"; const ATTENDEE_POSITION_MESSAGE: &str = "Attendee position must be in range 1-4 or None"; const INSUFFICIENT_PERMISSIONS_MESSAGE: &str = "You don't have permissions required to perform this operation"; +const REFERRING_TO_A_NONEXISTENT_RESOURCE: &str = "Referring to a nonexistent resource"; #[derive(thiserror::Error, Debug)] pub enum OmniError { @@ -50,6 +51,8 @@ pub enum OmniError { AttendeePositionError, #[error("INSUFFICIENT_PERMISSIONS_MESSAGE")] InsufficientPermissionsError, + #[error("REFERRING_TO_A_NONEXISTENT_RESOURCE")] + ReferringToNonexistentResourceError, } impl IntoResponse for OmniError { @@ -89,6 +92,13 @@ impl OmniError { return false; } + pub fn is_not_found_error(&self) -> bool { + match self { + OmniError::ResourceNotFoundError => true, + _ => false, + } + } + pub fn respond(self) -> Response { use OmniError as E; const ISE: StatusCode = StatusCode::INTERNAL_SERVER_ERROR; @@ -108,10 +118,7 @@ impl OmniError { return (StatusCode::CONFLICT, RESOURCE_ALREADY_EXISTS_MESSAGE) .into_response(); } else if e.is_foreign_key_violation() { - return ( - StatusCode::BAD_REQUEST, - "Referring to a nonexistent resource", - ) + return OmniError::ReferringToNonexistentResourceError .into_response(); } else { (ISE, "SQLx Error").into_response() @@ -147,6 +154,9 @@ impl OmniError { E::InsufficientPermissionsError => { (StatusCode::FORBIDDEN, self.clerr()).into_response() } + E::ReferringToNonexistentResourceError => { + (StatusCode::NOT_FOUND, self.clerr()).into_response() + } } } @@ -169,6 +179,7 @@ impl OmniError { E::BadRequestError => BAD_REQUEST, E::AttendeePositionError => ATTENDEE_POSITION_MESSAGE, E::InsufficientPermissionsError => INSUFFICIENT_PERMISSIONS_MESSAGE, + E::ReferringToNonexistentResourceError => REFERRING_TO_A_NONEXISTENT_RESOURCE, } .to_string() } diff --git a/src/routes/location.rs b/src/routes/location.rs index c256bd6..cf35dad 100644 --- a/src/routes/location.rs +++ b/src/routes/location.rs @@ -148,12 +148,11 @@ async fn get_location_by_id( State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, - Path(id): Path + Path( (_tournament_id, id)): Path<(Uuid, Uuid)> ) -> Result { let pool = &state.connection_pool; let tournament_user = - TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + TournamentUser::authenticate(id, &headers, cookies, &pool).await?; match tournament_user.has_permission(Permission::ReadLocations) { true => (), @@ -195,7 +194,7 @@ async fn get_location_by_id( ) )] async fn patch_location_by_id( - Path((id, tournament_id)): Path<(Uuid, Uuid)>, + Path((_tournament_id, id)): Path<(Uuid, Uuid)>, State(state): State, headers: HeaderMap, cookies: Cookies, @@ -203,7 +202,7 @@ async fn patch_location_by_id( ) -> Result { let pool = &state.connection_pool; let tournament_user = - TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; + TournamentUser::authenticate(id, &headers, cookies, &pool).await?; match tournament_user.has_permission(Permission::WriteLocations) { true => (), @@ -211,8 +210,12 @@ async fn patch_location_by_id( } let location = Location::get_by_id(id, pool).await?; - if location_with_name_exists_in_tournament(&location.name, &location.tournament_id, pool).await? { - return Err(OmniError::ResourceAlreadyExistsError) + + let name = new_location.name.clone(); + if name.is_some() { + if location_with_name_exists_in_tournament(&name.unwrap(), &location.tournament_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } } match location.patch(new_location, pool).await { @@ -239,11 +242,10 @@ async fn patch_location_by_id( ), )] async fn delete_location_by_id( - Path(id): Path, + Path((tournament_id, id)): Path<(Uuid, Uuid)>, State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, ) -> Result { let pool = &state.connection_pool; let tournament_user = diff --git a/src/routes/room.rs b/src/routes/room.rs index 30c1978..7461534 100644 --- a/src/routes/room.rs +++ b/src/routes/room.rs @@ -50,7 +50,7 @@ async fn create_room( State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, + Path((tournament_id, _location_id)): Path<(Uuid, Uuid)>, Json(json): Json, ) -> Result { let pool = &state.connection_pool; @@ -112,10 +112,8 @@ async fn get_rooms( false => return Err(OmniError::InsufficientPermissionsError), } - let _location = Location::get_by_id(tournament_id, pool).await?; - match query_as!(Room, "SELECT * FROM rooms WHERE location_id = $1", location_id) - .fetch_all(&state.connection_pool) - .await + let location = Location::get_by_id(location_id, pool).await?; + match location.get_rooms(pool).await { Ok(rooms) => Ok(Json(rooms).into_response()), Err(e) => { @@ -148,8 +146,7 @@ async fn get_room_by_id( State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, - Path(id): Path + Path((tournament_id, id)): Path<(Uuid, Uuid)>, ) -> Result { let pool = &state.connection_pool; let tournament_user = @@ -195,7 +192,7 @@ async fn get_room_by_id( ) )] async fn patch_room_by_id( - Path((id, tournament_id)): Path<(Uuid, Uuid)>, + Path(( tournament_id, _location_id, id)): Path<(Uuid, Uuid, Uuid)>, State(state): State, headers: HeaderMap, cookies: Cookies, @@ -211,8 +208,11 @@ async fn patch_room_by_id( } let room = Room::get_by_id(id, pool).await?; - if room_with_name_exists_in_location(&room.name, &room.location_id, pool).await? { - return Err(OmniError::ResourceAlreadyExistsError) + let new_name = new_room.name.clone(); + if new_name.is_some() { + if room_with_name_exists_in_location(&new_name.unwrap(), &room.location_id, pool).await? { + return Err(OmniError::ResourceAlreadyExistsError) + } } match room.patch(new_room, pool).await { @@ -239,7 +239,7 @@ async fn patch_room_by_id( ), )] async fn delete_room_by_id( - Path(id): Path, + Path((_tournament_id, _location_id, id)): Path<(Uuid, Uuid, Uuid)>, State(state): State, headers: HeaderMap, cookies: Cookies, @@ -254,6 +254,15 @@ async fn delete_room_by_id( false => return Err(OmniError::InsufficientPermissionsError), } + match Location::get_by_id(_location_id, pool).await { + Ok(_) => (), + Err(e) => { + if e.is_not_found_error() { + return Err(OmniError::ResourceNotFoundError); + } + } + } + let room = Room::get_by_id(id, pool).await?; match room.delete(&state.connection_pool).await { Ok(_) => Ok(StatusCode::NO_CONTENT.into_response()), diff --git a/src/tournament_impl/location_impl.rs b/src/tournament_impl/location_impl.rs index 4d2ce99..77836c9 100644 --- a/src/tournament_impl/location_impl.rs +++ b/src/tournament_impl/location_impl.rs @@ -1,11 +1,12 @@ use serde::{Deserialize, Serialize}; use sqlx::{query, query_as, Pool, Postgres}; +use tracing::error; use utoipa::ToSchema; use uuid::Uuid; use crate::omni_error::OmniError; -use super::utils::get_optional_value_to_be_patched; +use super::{room_impl::Room, utils::get_optional_value_to_be_patched}; #[derive(Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] @@ -28,7 +29,7 @@ pub struct Location { pub tournament_id: Uuid, } -#[derive(ToSchema, Deserialize)] +#[derive(ToSchema, Deserialize, Clone)] #[serde(deny_unknown_fields)] pub struct LocationPatch { pub name: Option, @@ -112,4 +113,17 @@ impl Location { Err(e) => Err(e)?, } } + + pub async fn get_rooms(&self, pool: &Pool) -> Result, OmniError> { + match query_as!(Room, "SELECT * FROM rooms WHERE location_id = $1", self.id) + .fetch_all(pool) + .await + { + Ok(rooms) => Ok(rooms), + Err(e) => { + error!("Error getting rooms of location {}: {e}", self.id); + Err(e)? + } + } + } } diff --git a/src/tournament_impl/room_impl.rs b/src/tournament_impl/room_impl.rs index 36fde7e..90eca3d 100644 --- a/src/tournament_impl/room_impl.rs +++ b/src/tournament_impl/room_impl.rs @@ -77,7 +77,7 @@ impl Room { let patch = Room { id: self.id, name: new_room.name.unwrap_or(self.name), - remarks: get_optional_value_to_be_patched(new_room.remarks, self.remarks), + remarks: get_optional_value_to_be_patched(self.remarks, new_room.remarks), location_id: new_room.location_id.unwrap_or(self.location_id), is_occupied: new_room.is_occupied.unwrap_or(self.is_occupied), }; From 10c6dc7c6a8f717ec6d42535641da672ddf10d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Tue, 4 Mar 2025 21:16:17 +0100 Subject: [PATCH 13/23] [51] add a Tournament::get_locations helper method --- src/tournament_impl/mod.rs | 40 ++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/tournament_impl/mod.rs b/src/tournament_impl/mod.rs index 8cc2c10..935951e 100644 --- a/src/tournament_impl/mod.rs +++ b/src/tournament_impl/mod.rs @@ -1,3 +1,4 @@ +use location_impl::Location; use serde::{Deserialize, Serialize}; use sqlx::{query, query_as, Pool, Postgres}; use tracing::error; @@ -34,7 +35,7 @@ pub struct TournamentPatch { impl Tournament { pub async fn post( tournament: Tournament, - connection_pool: &Pool, + pool: &Pool, ) -> Result { match query_as!( Tournament, @@ -44,7 +45,7 @@ impl Tournament { tournament.full_name, tournament.shortened_name ) - .fetch_one(connection_pool) + .fetch_one(pool) .await { Ok(_) => Ok(tournament), @@ -52,11 +53,9 @@ impl Tournament { } } - pub async fn get_all( - connection_pool: &Pool, - ) -> Result, OmniError> { + pub async fn get_all(pool: &Pool) -> Result, OmniError> { match query_as!(Tournament, "SELECT * FROM tournaments") - .fetch_all(connection_pool) + .fetch_all(pool) .await { Ok(tournaments) => Ok(tournaments), @@ -66,10 +65,10 @@ impl Tournament { pub async fn get_by_id( id: Uuid, - connection_pool: &Pool, + pool: &Pool, ) -> Result { match query_as!(Tournament, "SELECT * FROM tournaments WHERE id = $1", id) - .fetch_one(connection_pool) + .fetch_one(pool) .await { Ok(tournament) => Ok(tournament), @@ -83,7 +82,7 @@ impl Tournament { pub async fn patch( self, patch: TournamentPatch, - connection_pool: &Pool, + pool: &Pool, ) -> Result { let tournament = Tournament { id: self.id, @@ -96,7 +95,7 @@ impl Tournament { tournament.shortened_name, tournament.id, ) - .execute(connection_pool) + .execute(pool) .await { Ok(_) => Ok(tournament), @@ -104,13 +103,30 @@ impl Tournament { } } - pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + pub async fn delete(self, pool: &Pool) -> Result<(), OmniError> { match query!("DELETE FROM tournaments WHERE id = $1", self.id) - .execute(connection_pool) + .execute(pool) .await { Ok(_) => Ok(()), Err(e) => Err(e)?, } } + + pub async fn get_locations( + &self, + pool: &Pool, + ) -> Result, OmniError> { + match query_as!( + Location, + "SELECT * FROM locations WHERE tournament_id = $1", + self.id + ) + .fetch_all(pool) + .await + { + Ok(locations) => Ok(locations), + Err(e) => Err(e)?, + } + } } From db931da4c359910ba854ea6074ed126b61dbb35c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Wed, 12 Mar 2025 19:02:24 +0100 Subject: [PATCH 14/23] [57] create an endpoint for link-based authentication --- migrations/20241219_init.sql | 8 +++++++ src/routes/auth.rs | 38 +++++++++++++++++++++++++++++++++- src/users/auth/login_tokens.rs | 16 ++++++++++++++ src/users/auth/mod.rs | 1 + src/users/auth/userimpl.rs | 29 +++++++++++++++++++++++++- 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/users/auth/login_tokens.rs diff --git a/migrations/20241219_init.sql b/migrations/20241219_init.sql index 109a714..47832ca 100644 --- a/migrations/20241219_init.sql +++ b/migrations/20241219_init.sql @@ -79,3 +79,11 @@ CREATE TABLE IF NOT EXISTS debate_judge_assignments ( judge_user_id UUID NOT NULL REFERENCES users(id), debate_id UUID NOT NULL REFERENCES debates(id) ); + +CREATE TABLE IF NOT EXISTS login_tokens ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + token_hash TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users(id), + used BOOLEAN NOT NULL DEFAULT FALSE, + expiry TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '2 days' +); diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 2c5dae4..9b06d85 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -14,7 +14,7 @@ use crate::{ }, }; use axum::{ - extract::State, + extract::{Path, State}, http::{header::AUTHORIZATION, HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, @@ -23,11 +23,13 @@ use axum::{ use serde::Deserialize; use sqlx::{Pool, Postgres}; use tower_cookies::Cookies; +use uuid::Uuid; pub fn route() -> Router { Router::new() .route("/auth/login", post(auth_login)) .route("/auth/clear", get(auth_clear)) + .route("/auth/login/:token", post(login_with_link)) } #[derive(Deserialize)] @@ -62,6 +64,40 @@ async fn auth_login( (StatusCode::OK, token).into_response() } +#[utoipa::path( + post, + path = "/auth/login/{token}", + responses( + ( + status = 200, + description = "Returns an auth token to be used for authentication in subsequent requests", + body=String, + example=json!("UaKN-h7_eD5LlKt8ba4P376G0LGvW3JmccCDMUaPaQk") + ), + (status = 401, description = "Provided token was invalid"), + (status = 403, description = "Provided token was used used or expired"), + (status = 500, description = "Internal server error") + ) +)] +/// Log in with a single-use link +/// +/// This endpoint can be used to utilize single-use login links +/// generated with /user/{user_id}/login_link. +async fn login_with_link( + cookies: Cookies, + State(state): State, + Path(token): Path, +) -> Result { + let user = User::auth_via_link(&token, &state.connection_pool).await?; + let (_, token) = match Session::create(&user.id, &state.connection_pool).await { + Ok(o) => o, + Err(e) => Err(e)?, + }; + + set_session_token_cookie(&token, cookies); + Ok((StatusCode::OK, token).into_response()) +} + const TOO_MANY_TOKENS: &str = "Please provide one session token to destroy at a time."; const NO_TOKENS: &str = "Please provide a session token to destroy."; const SESSION_DESTROYED: &str = "Logged out - Session destroyed"; diff --git a/src/users/auth/login_tokens.rs b/src/users/auth/login_tokens.rs new file mode 100644 index 0000000..0a48b31 --- /dev/null +++ b/src/users/auth/login_tokens.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Local, Utc}; +use uuid::Uuid; + +pub struct LoginToken { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub expiry: DateTime, + pub used: bool, +} + +impl LoginToken { + pub fn expired(&self) -> bool { + return &Utc::now() > &self.expiry; + } +} diff --git a/src/users/auth/mod.rs b/src/users/auth/mod.rs index b2605fe..fb61d88 100644 --- a/src/users/auth/mod.rs +++ b/src/users/auth/mod.rs @@ -4,6 +4,7 @@ use tower_cookies::cookie::time::Duration as CookieDuration; pub mod cookie; pub mod crypto; pub mod error; +mod login_tokens; pub mod session; pub mod userimpl; diff --git a/src/users/auth/userimpl.rs b/src/users/auth/userimpl.rs index 07f8fa2..41a1b9e 100644 --- a/src/users/auth/userimpl.rs +++ b/src/users/auth/userimpl.rs @@ -2,7 +2,10 @@ use super::{ cookie::set_session_token_cookie, crypto::hash_token, error::AuthError, session::Session, AUTH_SESSION_COOKIE_NAME, }; -use crate::{omni_error::OmniError, users::User}; +use crate::{ + omni_error::OmniError, + users::{auth::login_tokens::LoginToken, User}, +}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use axum::http::{header::AUTHORIZATION, HeaderMap}; use base64::{prelude::BASE64_STANDARD, Engine}; @@ -118,4 +121,28 @@ impl User { }, } } + + pub async fn auth_via_link( + token: &str, + pool: &Pool, + ) -> Result { + let hashed_token = hash_token(token); + let token_record = sqlx::query_as!( + LoginToken, + "SELECT * FROM login_tokens WHERE token_hash = $1", + hashed_token + ) + .fetch_one(pool) + .await?; + // If no token was found, return 401 + if token_record.expired() { + // Return forbidden + } else if token_record.used { + // Return forbidden + } + match User::get_by_id(token_record.id, pool).await { + Ok(user) => Ok(user), + Err(e) => Err(e), + } + } } From 5c1e64cb52d2dd1d4707771120e10aa6143661bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Thu, 13 Mar 2025 08:57:56 +0100 Subject: [PATCH 15/23] [51] fix location address not being patched --- src/routes/location_routes.rs | 1 + src/routes/room_routes.rs | 6 +++--- src/tournament/location.rs | 4 ++-- src/tournament/room.rs | 2 -- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/routes/location_routes.rs b/src/routes/location_routes.rs index dc4f8ae..0824448 100644 --- a/src/routes/location_routes.rs +++ b/src/routes/location_routes.rs @@ -239,6 +239,7 @@ async fn patch_location_by_id( description = "The user is not permitted to modify locations within this tournament" ), (status=404, description = "Tournament or location not found"), + (status=500, description = "Internal server error"), ), )] async fn delete_location_by_id( diff --git a/src/routes/room_routes.rs b/src/routes/room_routes.rs index 00a7af6..cbf0b1f 100644 --- a/src/routes/room_routes.rs +++ b/src/routes/room_routes.rs @@ -5,7 +5,7 @@ use axum::{ routing::get, Json, Router, }; -use sqlx::{query, query_as, Error, Pool, Postgres}; +use sqlx::{query, Error, Pool, Postgres}; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; @@ -236,14 +236,14 @@ async fn patch_room_by_id( description = "The user is not permitted to modify rooms within this tournament" ), (status=404, description = "Tournament or room not found"), + (status=500, description = "Internal server error"), ), )] async fn delete_room_by_id( - Path((_tournament_id, _location_id, id)): Path<(Uuid, Uuid, Uuid)>, + Path((tournament_id, _location_id, id)): Path<(Uuid, Uuid, Uuid)>, State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, ) -> Result { let pool = &state.connection_pool; let tournament_user = diff --git a/src/tournament/location.rs b/src/tournament/location.rs index fc85fed..43083e4 100644 --- a/src/tournament/location.rs +++ b/src/tournament/location.rs @@ -82,8 +82,8 @@ impl Location { let patch = Location { id: self.id, name: new_location.name.unwrap_or(self.name), - address: get_optional_value_to_be_patched(new_location.address, self.address), - remarks: get_optional_value_to_be_patched(new_location.remarks, self.remarks), + address: get_optional_value_to_be_patched(self.address, new_location.address), + remarks: get_optional_value_to_be_patched(self.remarks, new_location.remarks), tournament_id: new_location.tournament_id.unwrap_or(self.tournament_id), }; match query!( diff --git a/src/tournament/room.rs b/src/tournament/room.rs index 90eca3d..5afcf9d 100644 --- a/src/tournament/room.rs +++ b/src/tournament/room.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; use sqlx::{query, query_as, Pool, Postgres}; -use tracing::error; use utoipa::ToSchema; use uuid::Uuid; @@ -27,7 +26,6 @@ pub struct Room { #[derive(ToSchema, Deserialize)] pub struct RoomPatch { pub name: Option, - pub address: Option, pub remarks: Option, pub location_id: Option, pub is_occupied: Option, From 4c2a9fad179ef7f8583e8e9d3ef366671d03eb8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Thu, 13 Mar 2025 09:14:15 +0100 Subject: [PATCH 16/23] [51] update docs --- src/routes/attendee_routes.rs | 13 +++++++++---- src/routes/debate_routes.rs | 11 ++++++++--- src/routes/location_routes.rs | 11 ++++++++--- src/routes/motion_routes.rs | 18 +++++++++++------- src/routes/room_routes.rs | 11 ++++++++--- src/routes/team_routes.rs | 15 ++++++++++----- src/routes/tournament_routes.rs | 12 +++++++++--- 7 files changed, 63 insertions(+), 28 deletions(-) diff --git a/src/routes/attendee_routes.rs b/src/routes/attendee_routes.rs index 372745b..3bd11c1 100644 --- a/src/routes/attendee_routes.rs +++ b/src/routes/attendee_routes.rs @@ -5,7 +5,7 @@ use axum::{ routing::{get, post}, Json, Router, }; -use sqlx::{query, query_as, Pool, Postgres}; +use sqlx::{query, Pool, Postgres}; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; @@ -58,7 +58,8 @@ pub fn route() -> Router { ( status=500, description = "Internal server error", ), - ) + ), + tag="attendee" )] #[axum::debug_handler] async fn create_attendee( @@ -117,7 +118,8 @@ async fn create_attendee( ( status=500, description = "Internal server error", ), - ) + ), + tag="attendee" )] /// Get a list of all attendees async fn get_attendees( @@ -161,6 +163,7 @@ async fn get_attendees( status=500, description = "Internal server error", ), ), + tag="attendee" )] async fn get_attendee_by_id( Path(id): Path, @@ -205,7 +208,8 @@ async fn get_attendee_by_id( (status=409, description = "Attendee position is duplicated"), (status=422, description = "Attendee position out of range [1-4]"), (status=500, description = "Internal server error"), - ) + ), + tag="attendee" )] async fn patch_attendee_by_id( Path((id, tournament_id)): Path<(Uuid, Uuid)>, @@ -258,6 +262,7 @@ async fn patch_attendee_by_id( status=500, description = "Internal server error", ), ), + tag="attendee" )] async fn delete_attendee_by_id( Path(id): Path, diff --git a/src/routes/debate_routes.rs b/src/routes/debate_routes.rs index 2133d14..a34bf88 100644 --- a/src/routes/debate_routes.rs +++ b/src/routes/debate_routes.rs @@ -36,7 +36,8 @@ pub fn route() -> Router { ), (status=404, description = "Tournament not found"), (status=500, description = "Internal server error"), - ) + ), + tag="debate" )] /// Get a list of all debates /// @@ -83,7 +84,8 @@ async fn get_debates( ), (status=404, description = "Tournament or attendee not found"), (status=500, description = "Internal server error"), - ) + ), + tag="debate" )] async fn create_debate( State(state): State, @@ -128,6 +130,7 @@ async fn create_debate( (status=404, description = "Tournament or debate not found"), (status=500, description = "Internal server error"), ), + tag="debate" )] async fn get_debate_by_id( State(state): State, @@ -174,7 +177,8 @@ async fn get_debate_by_id( ), (status=404, description = "Tournament or debate not found"), (status=500, description = "Internal server error"), - ) + ), + tag="debate" )] async fn patch_debate_by_id( State(state): State, @@ -220,6 +224,7 @@ async fn patch_debate_by_id( (status=404, description = "Tournament or debate not found"), (status=500, description = "Internal server error"), ), + tag="debate" )] async fn delete_debate_by_id( State(state): State, diff --git a/src/routes/location_routes.rs b/src/routes/location_routes.rs index 0824448..b3f5800 100644 --- a/src/routes/location_routes.rs +++ b/src/routes/location_routes.rs @@ -44,7 +44,8 @@ pub fn route() -> Router { ), (status=404, description = "Tournament or location not found"), (status=500, description = "Internal server error"), - ) + ), + tag="location" )] async fn create_location( State(state): State, @@ -92,7 +93,8 @@ async fn create_location( ), (status=404, description = "Tournament or location not found"), (status=500, description = "Internal server error"), - ) + ), + tag="location" )] /// Get a list of all locations /// @@ -143,6 +145,7 @@ async fn get_locations( (status=404, description = "Tournament or location not found"), (status=500, description = "Internal server error"), ), + tag="location" )] async fn get_location_by_id( State(state): State, @@ -191,7 +194,8 @@ async fn get_location_by_id( description = DUPLICATE_NAME_ERROR, ), (status=500, description = "Internal server error"), - ) + ), + tag="location" )] async fn patch_location_by_id( Path((_tournament_id, id)): Path<(Uuid, Uuid)>, @@ -241,6 +245,7 @@ async fn patch_location_by_id( (status=404, description = "Tournament or location not found"), (status=500, description = "Internal server error"), ), + tag="location" )] async fn delete_location_by_id( Path((tournament_id, id)): Path<(Uuid, Uuid)>, diff --git a/src/routes/motion_routes.rs b/src/routes/motion_routes.rs index da1e836..0c9dc6a 100644 --- a/src/routes/motion_routes.rs +++ b/src/routes/motion_routes.rs @@ -1,4 +1,4 @@ -use crate::{omni_error::OmniError, setup::AppState, tournament::{motion::{Motion, MotionPatch}, Tournament}, users::{permissions::Permission, TournamentUser}}; +use crate::{omni_error::OmniError, setup::AppState, tournament::motion::{Motion, MotionPatch}, users::{permissions::Permission, TournamentUser}}; use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, @@ -6,7 +6,6 @@ use axum::{ routing::get, Json, Router, }; -use sqlx::query_as; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; @@ -29,7 +28,9 @@ pub fn route() -> Router { status=200, description = "Ok", body=Vec, example=json!(get_motions_list_example()) -)))] + )), + tag="motion" +)] /// Get a list of all motions /// /// The user must be given a role within this tournament to use this endpoint. @@ -78,8 +79,8 @@ async fn get_motions( ), (status=404, description = "Tournament or motion not found"), (status=409, description = DUPLICATE_MOTION_ERROR) - - ) + ), + tag="motion" )] async fn create_motion( State(state): State, @@ -108,10 +109,11 @@ State(state): State, /// Get details of an existing motion /// /// The user must be given a role within this tournament to use this endpoint. -#[utoipa::path(get, path = "/motion/{id}", +#[utoipa::path(get, path = "/tournament/{tournament_id}/motion/{id}", responses((status=200, description = "Ok", body=Motion, example=json!(get_motion_example()) )), + tag="motion" )] async fn get_motion_by_id( Path(id): Path, @@ -152,7 +154,8 @@ async fn get_motion_by_id( description = "The user is not permitted to modify motions within this tournament" ), (status=404, description = "Tournament or motion not found") - ) + ), + tag="motion" )] async fn patch_motion_by_id( Path(id): Path, @@ -192,6 +195,7 @@ async fn patch_motion_by_id( ), (status=404, description = "Tournament or motion not found") ), + tag="motion" )] async fn delete_motion_by_id( diff --git a/src/routes/room_routes.rs b/src/routes/room_routes.rs index cbf0b1f..7ec7387 100644 --- a/src/routes/room_routes.rs +++ b/src/routes/room_routes.rs @@ -44,7 +44,8 @@ pub fn route() -> Router { ), (status=404, description = "Tournament or room not found"), (status=500, description = "Internal server error"), - ) + ), + tag="room" )] async fn create_room( State(state): State, @@ -92,7 +93,8 @@ async fn create_room( ), (status=404, description = "Tournament or room not found"), (status=500, description = "Internal server error"), - ) + ), + tag="room" )] /// Get a list of all rooms within a location /// @@ -141,6 +143,7 @@ async fn get_rooms( (status=404, description = "Tournament or room not found"), (status=500, description = "Internal server error"), ), + tag="room" )] async fn get_room_by_id( State(state): State, @@ -189,7 +192,8 @@ async fn get_room_by_id( description = DUPLICATE_NAME_ERROR, ), (status=500, description = "Internal server error"), - ) + ), + tag="room" )] async fn patch_room_by_id( Path(( tournament_id, _location_id, id)): Path<(Uuid, Uuid, Uuid)>, @@ -238,6 +242,7 @@ async fn patch_room_by_id( (status=404, description = "Tournament or room not found"), (status=500, description = "Internal server error"), ), + tag="room" )] async fn delete_room_by_id( Path((tournament_id, _location_id, id)): Path<(Uuid, Uuid, Uuid)>, diff --git a/src/routes/team_routes.rs b/src/routes/team_routes.rs index 1a8a712..a1f2fa2 100644 --- a/src/routes/team_routes.rs +++ b/src/routes/team_routes.rs @@ -5,7 +5,7 @@ use axum::{ routing::get, Json, Router, }; -use sqlx::{query, query_as, Error, Pool, Postgres}; +use sqlx::{query, Error, Pool, Postgres}; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; @@ -46,7 +46,8 @@ pub fn route() -> Router { ), (status=404, description = "Tournament or team not found"), (status=500, description = "Internal server error"), - ) + ), + tag="team" )] async fn create_team( State(state): State, @@ -94,7 +95,8 @@ async fn create_team( ), (status=404, description = "Tournament or team not found"), (status=500, description = "Internal server error"), - ) + ), + tag="team" )] /// Get a list of all teams /// @@ -143,6 +145,7 @@ async fn get_teams( (status=404, description = "Tournament or team not found"), (status=500, description = "Internal server error"), ), + tag="team" )] async fn get_team_by_id( State(state): State, @@ -192,7 +195,8 @@ async fn get_team_by_id( description = DUPLICATE_NAME_ERROR, ), (status=500, description = "Internal server error"), - ) + ), + tag="team" )] async fn patch_team_by_id( Path(id): Path, @@ -226,7 +230,7 @@ async fn patch_team_by_id( /// /// This operation is only allowed when there are no entities /// referencing this team. Available only to the tournament Organizers. -#[utoipa::path(delete, path = "/team/{id}", +#[utoipa::path(delete, path = "/tournament/{tournament_id}/team/{id}", responses ( (status=204, description = "Team deleted successfully"), @@ -238,6 +242,7 @@ async fn patch_team_by_id( ), (status=404, description = "Tournament or team not found"), ), + tag="team" )] async fn delete_team_by_id( Path(id): Path, diff --git a/src/routes/tournament_routes.rs b/src/routes/tournament_routes.rs index 059be65..1c42463 100644 --- a/src/routes/tournament_routes.rs +++ b/src/routes/tournament_routes.rs @@ -39,7 +39,9 @@ pub fn route() -> Router { description = "The user is not permitted to list any tournaments, meaning they do not have any roles within any tournament." ), (status=500, description = "Internal server error") -))] + ), + tag="tournament" +)] async fn get_tournaments( State(state): State, headers: HeaderMap, @@ -89,7 +91,8 @@ async fn get_tournaments( ), (status=404, description = "Tournament not found"), (status=500, description = "Internal server error") - ) + ), + tag="tournament" )] async fn create_tournament( State(state): State, @@ -126,6 +129,7 @@ async fn create_tournament( (status=404, description = "Tournament not found"), (status=500, description = "Internal server error") ), + tag="tournament" )] async fn get_tournament_by_id( Path(id): Path, @@ -167,7 +171,8 @@ async fn get_tournament_by_id( (status=404, description = "Tournament not found"), (status=409, description = "A tournament with this name already exists"), (status=500, description = "Internal server error") - ) + ), + tag="tournament" )] async fn patch_tournament_by_id( Path(id): Path, @@ -210,6 +215,7 @@ async fn patch_tournament_by_id( (status=404, description = "Tournament not found"), (status=409, description = "Other resources reference this tournament. They must be deleted first") ), + tag="tournament" )] async fn delete_tournament_by_id( Path(id): Path, From e7f1dbf268aece4c4505706004bda07fca313229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Thu, 13 Mar 2025 13:48:24 +0100 Subject: [PATCH 17/23] [47] post-merge fixes --- src/routes/mod.rs | 6 +- src/routes/{roles.rs => roles_routes.rs} | 178 +---------------------- src/routes/swagger.rs | 11 +- src/routes/user.rs | 5 +- src/tournament/mod.rs | 1 + src/tournament/roles.rs | 170 ++++++++++++++++++++++ src/users/infradmin.rs | 2 +- src/users/mod.rs | 3 +- 8 files changed, 192 insertions(+), 184 deletions(-) rename src/routes/{roles.rs => roles_routes.rs} (63%) create mode 100644 src/tournament/roles.rs diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 0411e22..8aaa984 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,7 @@ use axum::Router; use crate::setup::AppState; +use crate::tournament; mod attendee_routes; mod auth; @@ -8,13 +9,12 @@ mod debate_routes; mod health_check; mod infradmin_routes; mod motion_routes; -pub(crate) mod roles; +mod roles_routes; mod swagger; mod team_routes; mod teapot; mod tournament_routes; mod user; -mod utils; mod version; pub fn routes() -> Router { @@ -31,5 +31,5 @@ pub fn routes() -> Router { .merge(motion_routes::route()) .merge(debate_routes::route()) .merge(user::route()) - .merge(roles::route()) + .merge(roles_routes::route()) } diff --git a/src/routes/roles.rs b/src/routes/roles_routes.rs similarity index 63% rename from src/routes/roles.rs rename to src/routes/roles_routes.rs index fbb6817..a6a3bde 100644 --- a/src/routes/roles.rs +++ b/src/routes/roles_routes.rs @@ -1,13 +1,10 @@ -use core::fmt; - use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - routing::{get, post}, + routing::post, Json, Router, }; -use sqlx::{query, query_as, Pool, Postgres}; use strum::VariantArray; use tower_cookies::Cookies; use tracing::error; @@ -16,172 +13,10 @@ use uuid::Uuid; use crate::{ omni_error::OmniError, setup::AppState, + tournament::roles::Role, users::{permissions::Permission, TournamentUser, User}, }; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Debug, PartialEq, Deserialize, ToSchema, VariantArray, Clone, Serialize)] -/// Within a tournament, users must be granted roles for their -/// permissions to be defined. Each role comes with a predefined -/// set of permissions to perform certain operations. -/// By default, a newly created user has no roles. -/// Multiple users can have the same role. -pub enum Role { - /// This role grants all possible permissions within a tournament. - Organizer, - /// Judges can submit their verdicts regarding debates they were assigned to. - Judge, - /// Marshalls are responsible for conducting debates. - /// For pragmatic reasons, they can submit verdicts on Judges' behalf. - Marshall, -} - -impl Role { - pub fn get_role_permissions(&self) -> Vec { - use Permission as P; - match self { - Role::Organizer => P::VARIANTS.to_vec(), - Role::Judge => vec![ - P::ReadAttendees, - P::ReadDebates, - P::ReadTeams, - P::ReadTournament, - P::SubmitOwnVerdictVote, - ], - Role::Marshall => vec![ - P::ReadDebates, - P::ReadAttendees, - P::ReadTeams, - P::ReadTournament, - P::SubmitVerdict, - ], - } - } - - pub async fn post( - user_id: Uuid, - tournament_id: Uuid, - roles: Vec, - pool: &Pool, - ) -> Result, OmniError> { - let roles_as_strings = Role::roles_vec_to_string_array(&roles); - match query!( - r#"INSERT INTO roles(id, user_id, tournament_id, roles) - VALUES ($1, $2, $3, $4) RETURNING roles"#, - Uuid::now_v7(), - user_id, - tournament_id, - &roles_as_strings - ) - .fetch_one(pool) - .await - { - Ok(record) => { - let string_vec = record.roles.unwrap(); - let mut created_roles: Vec = vec![]; - for role_string in string_vec { - created_roles.push(Role::try_from(role_string)?); - } - return Ok(created_roles); - } - Err(e) => Err(e)?, - } - } - - pub fn roles_vec_to_string_array(roles: &Vec) -> Vec { - let mut string_vec = vec![]; - for role in roles { - string_vec.push(role.to_string()); - } - return string_vec; - } - - pub async fn patch( - user_id: Uuid, - tournament_id: Uuid, - roles: Vec, - pool: &Pool, - ) -> Result, OmniError> { - let roles_as_strings = Role::roles_vec_to_string_array(&roles); - match query!( - r#"UPDATE roles SET roles = $1 WHERE user_id = $2 AND tournament_id = $3 - RETURNING roles"#, - &roles_as_strings, - user_id, - tournament_id - ) - .fetch_one(pool) - .await - { - Ok(record) => { - let string_vec = record.roles.unwrap(); - let mut created_roles: Vec = vec![]; - for role_string in string_vec { - created_roles.push(Role::try_from(role_string)?); - } - return Ok(created_roles); - } - Err(e) => Err(e)?, - } - } - - pub async fn delete( - user_id: Uuid, - tournament_id: Uuid, - pool: &Pool, - ) -> Result<(), OmniError> { - match query!( - r"DELETE FROM roles WHERE user_id = $1 AND tournament_id = $2", - user_id, - tournament_id - ) - .execute(pool) - .await - { - Ok(_) => Ok(()), - Err(e) => Err(e)?, - } - } -} - -impl TryFrom<&str> for Role { - type Error = OmniError; - - fn try_from(value: &str) -> Result { - match value { - "Organizer" => Ok(Role::Organizer), - "Marshall" => Ok(Role::Marshall), - "Judge" => Ok(Role::Judge), - _ => Err(OmniError::RolesParsingError), - } - } -} - -impl TryFrom for Role { - type Error = OmniError; - - fn try_from(value: String) -> Result { - match value.as_str() { - "Organizer" => Ok(Role::Organizer), - "Marshall" => Ok(Role::Marshall), - "Judge" => Ok(Role::Judge), - _ => Err(OmniError::RolesParsingError), - } - } -} - -impl fmt::Display for Role { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Role::Organizer => write!(f, "Organizer"), - Role::Judge => write!(f, "Judge"), - Role::Marshall => write!(f, "Marshall"), - } - } -} - pub fn route() -> Router { Router::new().route( "/user/:user_id/tournament/:tournament_id/roles", @@ -212,7 +47,8 @@ pub fn route() -> Router { (status=404, description = "User of tournament not found"), (status=409, description = "The user is already granted roles within this tournament. Use PATCH method to modify user roles"), (status=500, description = "Internal server error"), - ) + ), + tag = "roles" )] async fn create_user_roles( State(state): State, @@ -263,6 +99,7 @@ async fn create_user_roles( (status=404, description="User or tournament not found"), (status=500, description="Internal server error"), ), + tag = "roles" )] async fn get_user_roles( State(state): State, @@ -310,7 +147,8 @@ async fn get_user_roles( ), (status=404, description = "Tournament or user not found, or the user has not been assigned any roles yet"), (status=500, description = "Internal server error"), - ) + ), + tag = "roles" )] async fn patch_user_roles( State(state): State, @@ -360,7 +198,7 @@ async fn patch_user_roles( (status=404, description = "User or tournament not found"), (status=500, description = "Internal server error"), ), - + tag = "roles" )] async fn delete_user_roles( State(state): State, diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 046ade9..cd58b94 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -9,13 +9,14 @@ use crate::setup::AppState; use crate::routes::attendee_routes; use crate::routes::debate_routes; use crate::routes::motion_routes; -use crate::routes::roles; +use crate::routes::roles_routes; use crate::routes::team_routes; use crate::routes::tournament_routes; use crate::tournament; use crate::tournament::attendee; use crate::tournament::debate; use crate::tournament::motion; +use crate::tournament::roles; use crate::tournament::team; use crate::users::permissions; use crate::users::photourl; @@ -69,10 +70,10 @@ pub fn route() -> Router { user::get_user_by_id, user::patch_user_by_id, user::delete_user_by_id, - roles::create_user_roles, - roles::get_user_roles, - roles::patch_user_roles, - roles::delete_user_roles, + roles_routes::create_user_roles, + roles_routes::get_user_roles, + roles_routes::patch_user_roles, + roles_routes::delete_user_roles, ), components(schemas( version::VersionDetails, diff --git a/src/routes/user.rs b/src/routes/user.rs index 9a62b95..bafce51 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -1,4 +1,4 @@ -use crate::{omni_error::OmniError, setup::AppState, users::{photourl::PhotoUrl, User}}; +use crate::{omni_error::OmniError, setup::AppState, tournament::roles::Role, users::{photourl::PhotoUrl, User}}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use axum::{ extract::{Path, State}, @@ -14,9 +14,8 @@ use tower_cookies::Cookies; use tracing::error; use utoipa::ToSchema; use uuid::Uuid; -use serde_json::Error as JsonError; -use super::{roles::Role, tournament::Tournament}; +use super::tournament::Tournament; #[derive(Deserialize, ToSchema)] pub struct UserPatch { diff --git a/src/tournament/mod.rs b/src/tournament/mod.rs index 93de6f4..04471a3 100644 --- a/src/tournament/mod.rs +++ b/src/tournament/mod.rs @@ -12,6 +12,7 @@ use crate::omni_error::OmniError; pub(crate) mod attendee; pub(crate) mod debate; pub(crate) mod motion; +pub(crate) mod roles; pub(crate) mod team; #[derive(Serialize, Deserialize, ToSchema)] diff --git a/src/tournament/roles.rs b/src/tournament/roles.rs new file mode 100644 index 0000000..d6e2312 --- /dev/null +++ b/src/tournament/roles.rs @@ -0,0 +1,170 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sqlx::{query, Pool, Postgres}; +use strum::VariantArray; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{omni_error::OmniError, users::permissions::Permission}; + +#[derive(Debug, PartialEq, Deserialize, ToSchema, VariantArray, Clone, Serialize)] +/// Within a tournament, users must be granted roles for their +/// permissions to be defined. Each role comes with a predefined +/// set of permissions to perform certain operations. +/// By default, a newly created user has no roles. +/// Multiple users can have the same role. +pub enum Role { + /// This role grants all possible permissions within a tournament. + Organizer, + /// Judges can submit their verdicts regarding debates they were assigned to. + Judge, + /// Marshalls are responsible for conducting debates. + /// For pragmatic reasons, they can submit verdicts on Judges' behalf. + Marshall, +} + +impl Role { + pub fn get_role_permissions(&self) -> Vec { + use Permission as P; + match self { + Role::Organizer => P::VARIANTS.to_vec(), + Role::Judge => vec![ + P::ReadAttendees, + P::ReadDebates, + P::ReadTeams, + P::ReadTournament, + P::SubmitOwnVerdictVote, + ], + Role::Marshall => vec![ + P::ReadDebates, + P::ReadAttendees, + P::ReadTeams, + P::ReadTournament, + P::SubmitVerdict, + ], + } + } + + pub async fn post( + user_id: Uuid, + tournament_id: Uuid, + roles: Vec, + pool: &Pool, + ) -> Result, OmniError> { + let _ = tournament_id; + let roles_as_strings = Role::roles_vec_to_string_array(&roles); + match query!( + r#"INSERT INTO roles(id, user_id, tournament_id, roles) + VALUES ($1, $2, $3, $4) RETURNING roles"#, + Uuid::now_v7(), + user_id, + tournament_id, + &roles_as_strings + ) + .fetch_one(pool) + .await + { + Ok(record) => { + let string_vec = record.roles.unwrap(); + let mut created_roles: Vec = vec![]; + for role_string in string_vec { + created_roles.push(Role::try_from(role_string)?); + } + return Ok(created_roles); + } + Err(e) => Err(e)?, + } + } + + pub fn roles_vec_to_string_array(roles: &Vec) -> Vec { + let mut string_vec = vec![]; + for role in roles { + string_vec.push(role.to_string()); + } + return string_vec; + } + + pub async fn patch( + user_id: Uuid, + tournament_id: Uuid, + roles: Vec, + pool: &Pool, + ) -> Result, OmniError> { + let roles_as_strings = Role::roles_vec_to_string_array(&roles); + match query!( + r#"UPDATE roles SET roles = $1 WHERE user_id = $2 AND tournament_id = $3 + RETURNING roles"#, + &roles_as_strings, + user_id, + tournament_id + ) + .fetch_one(pool) + .await + { + Ok(record) => { + let string_vec = record.roles.unwrap(); + let mut created_roles: Vec = vec![]; + for role_string in string_vec { + created_roles.push(Role::try_from(role_string)?); + } + return Ok(created_roles); + } + Err(e) => Err(e)?, + } + } + + pub async fn delete( + user_id: Uuid, + tournament_id: Uuid, + pool: &Pool, + ) -> Result<(), OmniError> { + match query!( + r"DELETE FROM roles WHERE user_id = $1 AND tournament_id = $2", + user_id, + tournament_id + ) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } +} + +impl TryFrom<&str> for Role { + type Error = OmniError; + + fn try_from(value: &str) -> Result { + match value { + "Organizer" => Ok(Role::Organizer), + "Marshall" => Ok(Role::Marshall), + "Judge" => Ok(Role::Judge), + _ => Err(OmniError::RolesParsingError), + } + } +} + +impl TryFrom for Role { + type Error = OmniError; + + fn try_from(value: String) -> Result { + match value.as_str() { + "Organizer" => Ok(Role::Organizer), + "Marshall" => Ok(Role::Marshall), + "Judge" => Ok(Role::Judge), + _ => Err(OmniError::RolesParsingError), + } + } +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Role::Organizer => write!(f, "Organizer"), + Role::Judge => write!(f, "Judge"), + Role::Marshall => write!(f, "Marshall"), + } + } +} diff --git a/src/users/infradmin.rs b/src/users/infradmin.rs index 7f6e989..0823f9f 100644 --- a/src/users/infradmin.rs +++ b/src/users/infradmin.rs @@ -1,7 +1,7 @@ use super::User; -use crate::routes::roles::Role; use crate::{ omni_error::OmniError, + tournament::roles::Role, users::{permissions::Permission, TournamentUser}, }; use sqlx::{Pool, Postgres}; diff --git a/src/users/mod.rs b/src/users/mod.rs index e364fec..817dad0 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -1,4 +1,3 @@ -use crate::routes::roles::Role; use axum::http::HeaderMap; use permissions::Permission; use photourl::PhotoUrl; @@ -8,7 +7,7 @@ use tower_cookies::Cookies; use utoipa::ToSchema; use uuid::Uuid; -use crate::omni_error::OmniError; +use crate::{omni_error::OmniError, tournament::roles::Role}; pub mod auth; pub mod infradmin; From 27679ed778240794bce79b8f478e14f1f73136d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 14 Mar 2025 10:03:21 +0100 Subject: [PATCH 18/23] [53] refactor: move Affiliation structs and implementations to a separate file --- .../{affiliation.rs => affiliation_routes.rs} | 152 ++---------------- src/routes/mod.rs | 4 +- src/routes/swagger.rs | 13 +- src/tournament/affiliation.rs | 138 ++++++++++++++++ src/tournament/mod.rs | 2 +- src/users/queries.rs | 2 + 6 files changed, 162 insertions(+), 149 deletions(-) rename src/routes/{affiliation.rs => affiliation_routes.rs} (68%) create mode 100644 src/tournament/affiliation.rs diff --git a/src/routes/affiliation.rs b/src/routes/affiliation_routes.rs similarity index 68% rename from src/routes/affiliation.rs rename to src/routes/affiliation_routes.rs index 32d57c1..7ed5901 100644 --- a/src/routes/affiliation.rs +++ b/src/routes/affiliation_routes.rs @@ -5,155 +5,29 @@ use axum::{ routing::get, Json, Router, }; -use serde::{Deserialize, Serialize}; -use sqlx::{query, query_as, Error, Pool, Postgres}; +use sqlx::query_as; use tower_cookies::Cookies; use tracing::error; -use utoipa::ToSchema; use uuid::Uuid; use crate::{ omni_error::OmniError, setup::AppState, - tournament::Tournament, + tournament::{ + affiliation::{Affiliation, AffiliationPatch}, + Tournament, + }, users::{permissions::Permission, roles::Role, TournamentUser, User}, }; -#[derive(Serialize, Deserialize, ToSchema)] -#[serde(deny_unknown_fields)] -/// Some Judges might be affiliated with certain teams, -/// which poses a risk of biased rulings. -/// Tournament Organizers can denote such affiliations. -/// A Judge is prevented from ruling debates wherein -/// one of the sides is a team they're affiliated with. -pub struct Affiliation { - #[serde(skip_deserializing)] - #[serde(default = "Uuid::now_v7")] - id: Uuid, - tournament_id: Uuid, - team_id: Uuid, - judge_user_id: Uuid, -} - -#[derive(Deserialize, ToSchema)] -pub struct AffiliationPatch { - tournament_id: Option, - team_id: Option, - judge_user_id: Option, -} - -impl Affiliation { - async fn post( - affiliation: Affiliation, - connection_pool: &Pool, - ) -> Result { - match query_as!( - Affiliation, - r#"INSERT INTO judge_team_assignments(id, judge_user_id, team_id, tournament_id) - VALUES ($1, $2, $3, $4) RETURNING id, judge_user_id, team_id, tournament_id"#, - affiliation.id, - affiliation.judge_user_id, - affiliation.team_id, - affiliation.tournament_id - ) - .fetch_one(connection_pool) - .await - { - Ok(_) => Ok(affiliation), - Err(e) => Err(e)?, - } - } - - async fn get_by_id( - id: Uuid, - connection_pool: &Pool, - ) -> Result { - match query_as!( - Affiliation, - "SELECT * FROM judge_team_assignments WHERE id = $1", - id - ) - .fetch_one(connection_pool) - .await - { - Ok(affiliation) => Ok(affiliation), - Err(e) => Err(e), - } - } - - async fn patch( - self, - patch: Affiliation, - connection_pool: &Pool, - ) -> Result { - match query!( - "UPDATE judge_team_assignments SET judge_user_id = $1, tournament_id = $2, team_id = $3 WHERE id = $4", - patch.judge_user_id, - patch.tournament_id, - patch.team_id, - self.id, - ) - .execute(connection_pool) - .await - { - Ok(_) => Ok(patch), - Err(e) => Err(e), - } - } - - async fn delete(self, connection_pool: &Pool) -> Result<(), Error> { - match query!("DELETE FROM judge_team_assignments WHERE id = $1", self.id) - .execute(connection_pool) - .await - { - Ok(_) => Ok(()), - Err(e) => Err(e), - } - } - - async fn validate(&self, pool: &Pool) -> Result<(), OmniError> { - let user = User::get_by_id(self.judge_user_id, pool).await?; - if !user.has_role(Role::Judge, self.tournament_id, pool).await? { - return Err(OmniError::NotAJudgeError); - } - - let _tournament = Tournament::get_by_id(self.tournament_id, pool).await?; - - if self.already_exists(pool).await? { - return Err(OmniError::ResourceAlreadyExistsError); - } - - Ok(()) - } - - async fn already_exists(&self, pool: &Pool) -> Result { - match query_as!(Affiliation, - "SELECT * FROM judge_team_assignments WHERE judge_user_id = $1 AND tournament_id = $2 AND team_id = $3", - self.judge_user_id, - self.tournament_id, - self.team_id - ).fetch_optional(pool).await { - Ok(result) => { - if result.is_none() { - return Ok(false); - } - else { - return Ok(true); - } - }, - Err(e) => Err(e)?, - } - } -} - pub fn route() -> Router { Router::new() .route( - "/affiliation", + "/user/:user_id/tournament/:tournament_id/affiliation", get(get_affiliations).post(create_affiliation), ) .route( - "/affiliation/:affiliation_id", + "/user/:user_id/tournament/:tournament_id/affiliation/:id", get(get_affiliation_by_id) .patch(patch_affiliation_by_id) .delete(delete_affiliation_by_id), @@ -184,7 +58,7 @@ async fn create_affiliation( State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, Json(affiliation): Json, ) -> Result { let pool = &state.connection_pool; @@ -227,8 +101,7 @@ async fn get_affiliations( State(state): State, headers: HeaderMap, cookies: Cookies, - Path(user_id): Path, - Path(tournament_id): Path, + Path((user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, ) -> Result { let pool = &state.connection_pool; let tournament_user = @@ -287,8 +160,7 @@ async fn get_affiliation_by_id( State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, - Path(id): Path, + Path((user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, ) -> Result { let pool = &state.connection_pool; let tournament_user = @@ -329,12 +201,12 @@ async fn get_affiliation_by_id( (status=500, description = "Internal server error"), ) )] +#[axum::debug_handler] async fn patch_affiliation_by_id( - Path(id): Path, State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, + Path((user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, Json(new_affiliation): Json, ) -> Result { let pool = &state.connection_pool; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index b540e14..e8dcb47 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,7 +2,7 @@ use axum::Router; use crate::setup::AppState; -mod affiliation; +mod affiliation_routes; mod attendee_routes; mod auth; mod debate_routes; @@ -28,5 +28,5 @@ pub fn routes() -> Router { .merge(attendee_routes::route()) .merge(motion_routes::route()) .merge(debate_routes::route()) - .merge(affiliation::route()) + .merge(affiliation_routes::route()) } diff --git a/src/routes/swagger.rs b/src/routes/swagger.rs index 95a3aa3..206d026 100644 --- a/src/routes/swagger.rs +++ b/src/routes/swagger.rs @@ -5,13 +5,14 @@ use utoipa_swagger_ui::SwaggerUi; use crate::routes::auth; use crate::setup::AppState; -use crate::routes::affiliation; +use crate::routes::affiliation_routes; use crate::routes::attendee_routes; use crate::routes::debate_routes; use crate::routes::motion_routes; use crate::routes::team_routes; use crate::routes::tournament_routes; use crate::tournament; +use crate::tournament::affiliation; use crate::tournament::attendee; use crate::tournament::debate; use crate::tournament::motion; @@ -62,11 +63,11 @@ pub fn route() -> Router { attendee_routes::patch_attendee_by_id, attendee_routes::delete_attendee_by_id, auth::auth_login, - affiliation::create_affiliation, - affiliation::get_affiliations, - affiliation::get_affiliation_by_id, - affiliation::patch_affiliation_by_id, - affiliation::delete_affiliation_by_id, + affiliation_routes::create_affiliation, + affiliation_routes::get_affiliations, + affiliation_routes::get_affiliation_by_id, + affiliation_routes::patch_affiliation_by_id, + affiliation_routes::delete_affiliation_by_id, ), components(schemas( version::VersionDetails, diff --git a/src/tournament/affiliation.rs b/src/tournament/affiliation.rs new file mode 100644 index 0000000..4692268 --- /dev/null +++ b/src/tournament/affiliation.rs @@ -0,0 +1,138 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, Pool, Postgres}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + omni_error::OmniError, + users::{roles::Role, User}, +}; + +use super::Tournament; + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +/// Some Judges might be affiliated with certain teams, +/// which poses a risk of biased rulings. +/// Tournament Organizers can denote such affiliations. +/// A Judge is prevented from ruling debates wherein +/// one of the sides is a team they're affiliated with. +pub struct Affiliation { + #[serde(skip_deserializing)] + #[serde(default = "Uuid::now_v7")] + pub id: Uuid, + pub tournament_id: Uuid, + pub team_id: Uuid, + pub judge_user_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct AffiliationPatch { + pub tournament_id: Option, + pub team_id: Option, + pub judge_user_id: Option, +} + +impl Affiliation { + pub async fn post( + affiliation: Affiliation, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Affiliation, + r#"INSERT INTO judge_team_assignments(id, judge_user_id, team_id, tournament_id) + VALUES ($1, $2, $3, $4) RETURNING id, judge_user_id, team_id, tournament_id"#, + affiliation.id, + affiliation.judge_user_id, + affiliation.team_id, + affiliation.tournament_id + ) + .fetch_one(connection_pool) + .await + { + Ok(_) => Ok(affiliation), + Err(e) => Err(e)?, + } + } + + pub async fn get_by_id( + id: Uuid, + connection_pool: &Pool, + ) -> Result { + match query_as!( + Affiliation, + "SELECT * FROM judge_team_assignments WHERE id = $1", + id + ) + .fetch_one(connection_pool) + .await + { + Ok(affiliation) => Ok(affiliation), + Err(e) => Err(e)?, + } + } + + pub async fn patch( + self, + patch: Affiliation, + connection_pool: &Pool, + ) -> Result { + match query!( + "UPDATE judge_team_assignments SET judge_user_id = $1, tournament_id = $2, team_id = $3 WHERE id = $4", + patch.judge_user_id, + patch.tournament_id, + patch.team_id, + self.id, + ) + .execute(connection_pool) + .await + { + Ok(_) => Ok(patch), + Err(e) => Err(e)?, + } + } + + pub async fn delete(self, connection_pool: &Pool) -> Result<(), OmniError> { + match query!("DELETE FROM judge_team_assignments WHERE id = $1", self.id) + .execute(connection_pool) + .await + { + Ok(_) => Ok(()), + Err(e) => Err(e)?, + } + } + + pub async fn validate(&self, pool: &Pool) -> Result<(), OmniError> { + let user = User::get_by_id(self.judge_user_id, pool).await?; + if !user.has_role(Role::Judge, self.tournament_id, pool).await? { + return Err(OmniError::NotAJudgeError); + } + + let _tournament = Tournament::get_by_id(self.tournament_id, pool).await?; + + if self.already_exists(pool).await? { + return Err(OmniError::ResourceAlreadyExistsError); + } + + Ok(()) + } + + async fn already_exists(&self, pool: &Pool) -> Result { + match query_as!(Affiliation, + "SELECT * FROM judge_team_assignments WHERE judge_user_id = $1 AND tournament_id = $2 AND team_id = $3", + self.judge_user_id, + self.tournament_id, + self.team_id + ).fetch_optional(pool).await { + Ok(result) => { + if result.is_none() { + return Ok(false); + } + else { + return Ok(true); + } + }, + Err(e) => Err(e)?, + } + } +} diff --git a/src/tournament/mod.rs b/src/tournament/mod.rs index 93de6f4..c03eef1 100644 --- a/src/tournament/mod.rs +++ b/src/tournament/mod.rs @@ -1,5 +1,4 @@ use debate::Debate; -use motion::Motion; use serde::{Deserialize, Serialize}; use sqlx::{query, query_as, Pool, Postgres}; use team::Team; @@ -9,6 +8,7 @@ use uuid::Uuid; use crate::omni_error::OmniError; +pub(crate) mod affiliation; pub(crate) mod attendee; pub(crate) mod debate; pub(crate) mod motion; diff --git a/src/users/queries.rs b/src/users/queries.rs index e9c728b..aabf4eb 100644 --- a/src/users/queries.rs +++ b/src/users/queries.rs @@ -6,6 +6,7 @@ use argon2::{ }; use serde_json::Error as JsonError; use sqlx::{Pool, Postgres}; +use tracing::error; use uuid::Uuid; impl User { @@ -130,6 +131,7 @@ impl User { pool: &Pool, ) -> Result { let roles = self.get_roles(tournament_id, pool).await?; + error!("roles parsed"); return Ok(roles.contains(&role)); } } From 78ad916d3483228cb7cf1cec1de4be2ec5e7da74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 14 Mar 2025 11:37:33 +0100 Subject: [PATCH 19/23] [53] fix a roles retrieval mechanism --- src/routes/affiliation_routes.rs | 3 +-- src/tournament/roles.rs | 13 +++++++++++++ src/users/queries.rs | 26 ++++++++++++-------------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/routes/affiliation_routes.rs b/src/routes/affiliation_routes.rs index 5a99544..78b1a16 100644 --- a/src/routes/affiliation_routes.rs +++ b/src/routes/affiliation_routes.rs @@ -256,11 +256,10 @@ async fn patch_affiliation_by_id( ), )] async fn delete_affiliation_by_id( - Path(id): Path, State(state): State, headers: HeaderMap, cookies: Cookies, - Path(tournament_id): Path, + Path((user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, ) -> Result { let pool = &state.connection_pool; let tournament_user = diff --git a/src/tournament/roles.rs b/src/tournament/roles.rs index d6e2312..5010f87 100644 --- a/src/tournament/roles.rs +++ b/src/tournament/roles.rs @@ -159,6 +159,19 @@ impl TryFrom for Role { } } +impl TryFrom<&String> for Role { + type Error = OmniError; + + fn try_from(value: &String) -> Result { + match value.as_str() { + "Organizer" => Ok(Role::Organizer), + "Marshall" => Ok(Role::Marshall), + "Judge" => Ok(Role::Judge), + _ => Err(OmniError::RolesParsingError), + } + } +} + impl fmt::Display for Role { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { diff --git a/src/users/queries.rs b/src/users/queries.rs index 5395965..6cb3bc3 100644 --- a/src/users/queries.rs +++ b/src/users/queries.rs @@ -7,9 +7,7 @@ use argon2::{ password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHasher, }; -use serde_json::Error as JsonError; use sqlx::{query, Pool, Postgres}; -use tracing::error; use uuid::Uuid; impl User { @@ -99,13 +97,13 @@ impl User { // ---------- DATABASE HELPERS ---------- pub async fn get_roles( &self, - tournament: Uuid, + tournament_id: 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 + tournament_id ) .fetch_optional(pool) .await?; @@ -115,15 +113,16 @@ impl User { } 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) + let mut parsed_roles: Vec = vec![]; + match roles { + Some(vec) => { + for value in vec { + parsed_roles.push(Role::try_from(value)?); + } + return Ok(parsed_roles); + } + None => return Ok(vec![]), + } } pub async fn has_role( @@ -133,7 +132,6 @@ impl User { pool: &Pool, ) -> Result { let roles = self.get_roles(tournament_id, pool).await?; - error!("roles parsed"); return Ok(roles.contains(&role)); } From da0ddc3cefeb4af8ea8d012e9a52f27d0c52a3f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 14 Mar 2025 14:55:17 +0100 Subject: [PATCH 20/23] [53] code cleanup --- src/omni_error.rs | 8 +++++--- src/routes/affiliation_routes.rs | 30 ++++++++++++++++++++++++------ src/routes/roles_routes.rs | 1 + src/tournament/affiliation.rs | 2 +- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/omni_error.rs b/src/omni_error.rs index ef15b34..7e88a4c 100644 --- a/src/omni_error.rs +++ b/src/omni_error.rs @@ -57,7 +57,7 @@ pub enum OmniError { #[error("ROLES_PARSING_MESSAGE")] RolesParsingError, #[error{"NOT_A_JUDGE_MESSAGE"}] - NotAJudgeError, + NotAJudgeAffiliationError, } impl IntoResponse for OmniError { @@ -162,7 +162,9 @@ impl OmniError { E::RolesParsingError => { (StatusCode::BAD_REQUEST, self.clerr()).into_response() } - E::NotAJudgeError => (StatusCode::CONFLICT, self.clerr()).into_response(), + E::NotAJudgeAffiliationError => { + (StatusCode::CONFLICT, self.clerr()).into_response() + } } } @@ -186,7 +188,7 @@ impl OmniError { E::InsufficientPermissionsError => INSUFFICIENT_PERMISSIONS_MESSAGE, E::ReferringToNonexistentResourceError => REFERRING_TO_A_NONEXISTENT_RESOURCE, E::RolesParsingError => ROLES_PARSING_MESSAGE, - E::NotAJudgeError => NOT_A_JUDGE_MESSAGE, + E::NotAJudgeAffiliationError => NOT_A_JUDGE_MESSAGE, } .to_string() } diff --git a/src/routes/affiliation_routes.rs b/src/routes/affiliation_routes.rs index 78b1a16..5a7fc55 100644 --- a/src/routes/affiliation_routes.rs +++ b/src/routes/affiliation_routes.rs @@ -14,7 +14,7 @@ use crate::{ omni_error::OmniError, setup::AppState, tournament::{ - affiliation::{Affiliation, AffiliationPatch}, + affiliation::{self, Affiliation, AffiliationPatch}, roles::Role, Tournament, }, @@ -62,6 +62,10 @@ async fn create_affiliation( Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, Json(affiliation): Json, ) -> Result { + if !params_and_affiliation_fields_match(&affiliation, &user_id, &tournament_id) { + return Err(OmniError::BadRequestError); + } + let pool = &state.connection_pool; let tournament_user = TournamentUser::authenticate(tournament_id, &headers, cookies, &pool).await?; @@ -81,6 +85,20 @@ async fn create_affiliation( } } +fn params_and_affiliation_fields_match( + affiliation: &Affiliation, + user_id: &Uuid, + tournament_id: &Uuid, +) -> bool { + if !(&affiliation.judge_user_id == user_id) { + return false; + } + if !(&affiliation.tournament_id == tournament_id) { + return false; + } + return true; +} + #[utoipa::path(get, path = "/user/{user_id}/tournament/{tournament_id}/affiliation", responses ( @@ -102,7 +120,7 @@ async fn get_affiliations( State(state): State, headers: HeaderMap, cookies: Cookies, - Path((user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, + Path((user_id, tournament_id)): Path<(Uuid, Uuid)>, ) -> Result { let pool = &state.connection_pool; let tournament_user = @@ -118,7 +136,7 @@ async fn get_affiliations( .has_role(Role::Judge, tournament_id, pool) .await? { - return Err(OmniError::NotAJudgeError); + return Err(OmniError::NotAJudgeAffiliationError); } let _tournament = Tournament::get_by_id(tournament_id, pool).await?; @@ -161,7 +179,7 @@ async fn get_affiliation_by_id( State(state): State, headers: HeaderMap, cookies: Cookies, - Path((user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, + Path((_user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, ) -> Result { let pool = &state.connection_pool; let tournament_user = @@ -207,7 +225,7 @@ async fn patch_affiliation_by_id( State(state): State, headers: HeaderMap, cookies: Cookies, - Path((user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, + Path((_user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, Json(new_affiliation): Json, ) -> Result { let pool = &state.connection_pool; @@ -259,7 +277,7 @@ async fn delete_affiliation_by_id( State(state): State, headers: HeaderMap, cookies: Cookies, - Path((user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, + Path((_user_id, tournament_id, id)): Path<(Uuid, Uuid, Uuid)>, ) -> Result { let pool = &state.connection_pool; let tournament_user = diff --git a/src/routes/roles_routes.rs b/src/routes/roles_routes.rs index 1262a68..a6a3bde 100644 --- a/src/routes/roles_routes.rs +++ b/src/routes/roles_routes.rs @@ -5,6 +5,7 @@ use axum::{ routing::post, Json, Router, }; +use strum::VariantArray; use tower_cookies::Cookies; use tracing::error; use uuid::Uuid; diff --git a/src/tournament/affiliation.rs b/src/tournament/affiliation.rs index 2720e5f..5a33a5f 100644 --- a/src/tournament/affiliation.rs +++ b/src/tournament/affiliation.rs @@ -102,7 +102,7 @@ impl Affiliation { pub async fn validate(&self, pool: &Pool) -> Result<(), OmniError> { let user = User::get_by_id(self.judge_user_id, pool).await?; if !user.has_role(Role::Judge, self.tournament_id, pool).await? { - return Err(OmniError::NotAJudgeError); + return Err(OmniError::NotAJudgeAffiliationError); } let _tournament = Tournament::get_by_id(self.tournament_id, pool).await?; From 0e619d8ace99998f2b87625159b0e519c787201a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 14 Mar 2025 16:12:24 +0100 Subject: [PATCH 21/23] [57] implement single-use login link generation and usage --- src/routes/auth.rs | 7 +++-- src/routes/user_routes.rs | 53 +++++++++++++++++++++++++++++----- src/users/auth/error.rs | 15 ++++++++-- src/users/auth/login_tokens.rs | 21 +++++++++++++- src/users/auth/userimpl.rs | 19 +++++++----- 5 files changed, 94 insertions(+), 21 deletions(-) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index cccba3a..1815027 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -96,7 +96,7 @@ async fn auth_login( status = 200, description = "Returns an auth token to be used for authentication in subsequent requests", body=String, - example=json!("UaKN-h7_eD5LlKt8ba4P376G0LGvW3JmccCDMUaPaQk") + example=json!("k1ShPhFwn11_0hBQF2Xh56iB-zGx7mwymarrt39QYLo") ), (status = 401, description = "Provided token was invalid"), (status = 403, description = "Provided token was used used or expired"), @@ -112,8 +112,9 @@ async fn login_with_link( State(state): State, Path(token): Path, ) -> Result { - let user = User::auth_via_link(&token, &state.connection_pool).await?; - let (_, token) = match Session::create(&user.id, &state.connection_pool).await { + let pool = &state.connection_pool; + let user = User::auth_via_link(&token, pool).await?; + let (_, token) = match Session::create(&user.id, pool).await { Ok(o) => o, Err(e) => Err(e)?, }; diff --git a/src/routes/user_routes.rs b/src/routes/user_routes.rs index 4d88f05..3f4cbb0 100644 --- a/src/routes/user_routes.rs +++ b/src/routes/user_routes.rs @@ -1,13 +1,15 @@ -use crate::{omni_error::OmniError, setup::AppState, users::{User, UserPatch, UserWithPassword}}; +use crate::{omni_error::OmniError, setup::AppState, users::{auth::crypto::{generate_token, hash_token}, User, UserPatch, UserWithPassword}}; use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - routing::get, + routing::{get, post}, Json, Router, }; +use sqlx::query; use tower_cookies::Cookies; use tracing::error; +use tracing_subscriber::fmt::format; use uuid::Uuid; pub fn route() -> Router { @@ -18,7 +20,7 @@ pub fn route() -> Router { get(get_user_by_id) .delete(delete_user_by_id) .patch(patch_user_by_id), - ) + ).route("/user/:id/login_link", post(generate_login_link)) } /// Get a list of all users @@ -90,7 +92,7 @@ async fn create_user( 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); + return Err(OmniError::InsufficientPermissionsError); } let user_without_password = User::from(json.clone()); @@ -178,7 +180,7 @@ async fn patch_user_by_id( match requesting_user.is_infrastructure_admin() || requesting_user.id == user_to_be_patched.id { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } match user_to_be_patched.patch(new_user, pool).await { @@ -219,13 +221,13 @@ async fn delete_user_by_id( match requesting_user.is_infrastructure_admin() { true => (), - false => return Err(OmniError::UnauthorizedError), + false => return Err(OmniError::InsufficientPermissionsError), } 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), + true => return Err(OmniError::InsufficientPermissionsError), false => () } @@ -245,6 +247,43 @@ async fn delete_user_by_id( } } +/// Generate a login link. +/// +/// Available only to the infrastructure admin. +#[utoipa::path(delete, path = "/user/{id}/login_link", + responses( + (status=200, description = "A single-use login link"), + (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 generate_login_link( + Path(id): Path, + State(state): State, + headers: HeaderMap, + cookies: Cookies, +) -> Result { + let pool = &state.connection_pool; + let user = User::authenticate(&headers, cookies, pool).await?; + if !(user.is_infrastructure_admin()) { + return Err(OmniError::InsufficientPermissionsError) + } + let token = generate_token(); + query!(r#" + INSERT INTO login_tokens (id, token_hash, user_id, used) + VALUES ($1, $2, $3, $4)"#, + Uuid::now_v7(), + hash_token(&token), + id, + false, + ).execute(pool).await?; + let link = format!("/auth/login/{}", token); + Ok((StatusCode::OK, link).into_response()) +} + fn get_user_example_with_id() -> String { r#" { diff --git a/src/users/auth/error.rs b/src/users/auth/error.rs index 489c620..6d59845 100644 --- a/src/users/auth/error.rs +++ b/src/users/auth/error.rs @@ -19,15 +19,24 @@ pub enum AuthError { UnsupportedHeaderAuthScheme, #[error("Can only clear session given in Bearer scheme.")] ClearSessionBearerOnly, + #[error("Provided single-use login token has already been used.")] + TokenAlreadyUsed, + #[error("Provided single-use login token has expired.")] + TokenExpired, + #[error("Invalid token.")] + InvalidToken, } impl AuthError { pub fn status_code(&self) -> StatusCode { use AuthError as E; match self { - E::InvalidCredentials | E::NoCredentials | E::SessionExpired => { - StatusCode::UNAUTHORIZED - } + E::InvalidCredentials + | E::NoCredentials + | E::SessionExpired + | E::TokenAlreadyUsed + | E::TokenExpired + | E::InvalidToken => StatusCode::UNAUTHORIZED, E::NonAsciiHeaderCharacters | E::NoBasicAuthColonSplit | E::BadHeaderAuthSchemeData diff --git a/src/users/auth/login_tokens.rs b/src/users/auth/login_tokens.rs index 0a48b31..377170b 100644 --- a/src/users/auth/login_tokens.rs +++ b/src/users/auth/login_tokens.rs @@ -1,6 +1,10 @@ -use chrono::{DateTime, Local, Utc}; +use chrono::{DateTime, Utc}; +use sqlx::{query, Pool, Postgres}; +use tracing::error; use uuid::Uuid; +use crate::omni_error::OmniError; + pub struct LoginToken { pub id: Uuid, pub user_id: Uuid, @@ -14,3 +18,18 @@ impl LoginToken { return &Utc::now() > &self.expiry; } } + +impl LoginToken { + pub async fn mark_as_used(&self, pool: &Pool) -> Result<(), OmniError> { + match query!("UPDATE login_tokens SET used = true WHERE id = $1", self.id) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => { + error!("Error invalidating token {}: {e}", self.id); + Err(e)? + } + } + } +} diff --git a/src/users/auth/userimpl.rs b/src/users/auth/userimpl.rs index 41a1b9e..c9fd157 100644 --- a/src/users/auth/userimpl.rs +++ b/src/users/auth/userimpl.rs @@ -11,6 +11,7 @@ use axum::http::{header::AUTHORIZATION, HeaderMap}; use base64::{prelude::BASE64_STANDARD, Engine}; use sqlx::{types::chrono::Utc, Pool, Postgres}; use tower_cookies::Cookies; +use tracing::error; impl User { pub async fn authenticate( @@ -132,15 +133,19 @@ impl User { "SELECT * FROM login_tokens WHERE token_hash = $1", hashed_token ) - .fetch_one(pool) + .fetch_optional(pool) .await?; - // If no token was found, return 401 - if token_record.expired() { - // Return forbidden - } else if token_record.used { - // Return forbidden + if token_record.is_none() { + return Err(AuthError::InvalidToken)?; + } + let token = token_record.unwrap(); + if token.expired() { + return Err(AuthError::TokenExpired)?; + } else if token.used { + return Err(AuthError::TokenAlreadyUsed)?; } - match User::get_by_id(token_record.id, pool).await { + token.mark_as_used(pool).await?; + match User::get_by_id(token.user_id, pool).await { Ok(user) => Ok(user), Err(e) => Err(e), } From 49386c4203fc8b390d2a2d390be74387d4ef041d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 14 Mar 2025 16:35:00 +0100 Subject: [PATCH 22/23] [57] change login links to login tokens --- src/routes/auth.rs | 10 +++++----- src/routes/user_routes.rs | 9 ++++----- src/users/auth/userimpl.rs | 1 - 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 1815027..b2ccaa7 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -30,7 +30,7 @@ pub fn route() -> Router { Router::new() .route("/auth/login", post(auth_login)) .route("/auth/clear", get(auth_clear)) - .route("/auth/login/:token", post(login_with_link)) + .route("/auth/login/:token", post(single_use_login)) } #[derive(Deserialize, ToSchema)] @@ -103,11 +103,11 @@ async fn auth_login( (status = 500, description = "Internal server error") ) )] -/// Log in with a single-use link +/// Log in with a single-use token /// -/// This endpoint can be used to utilize single-use login links -/// generated with /user/{user_id}/login_link. -async fn login_with_link( +/// This endpoint can be used to utilize single-use login tokens +/// generated with /user/{user_id}/login_token. +async fn single_use_login( cookies: Cookies, State(state): State, Path(token): Path, diff --git a/src/routes/user_routes.rs b/src/routes/user_routes.rs index 3f4cbb0..2ff0598 100644 --- a/src/routes/user_routes.rs +++ b/src/routes/user_routes.rs @@ -20,7 +20,7 @@ pub fn route() -> Router { get(get_user_by_id) .delete(delete_user_by_id) .patch(patch_user_by_id), - ).route("/user/:id/login_link", post(generate_login_link)) + ).route("/user/:id/login_token", post(generate_login_token)) } /// Get a list of all users @@ -247,7 +247,7 @@ async fn delete_user_by_id( } } -/// Generate a login link. +/// Generate a single-use login token. /// /// Available only to the infrastructure admin. #[utoipa::path(delete, path = "/user/{id}/login_link", @@ -260,7 +260,7 @@ async fn delete_user_by_id( ), tag="user" )] -async fn generate_login_link( +async fn generate_login_token( Path(id): Path, State(state): State, headers: HeaderMap, @@ -280,8 +280,7 @@ async fn generate_login_link( id, false, ).execute(pool).await?; - let link = format!("/auth/login/{}", token); - Ok((StatusCode::OK, link).into_response()) + Ok((StatusCode::OK, token).into_response()) } fn get_user_example_with_id() -> String { diff --git a/src/users/auth/userimpl.rs b/src/users/auth/userimpl.rs index c9fd157..d30eed3 100644 --- a/src/users/auth/userimpl.rs +++ b/src/users/auth/userimpl.rs @@ -11,7 +11,6 @@ use axum::http::{header::AUTHORIZATION, HeaderMap}; use base64::{prelude::BASE64_STANDARD, Engine}; use sqlx::{types::chrono::Utc, Pool, Postgres}; use tower_cookies::Cookies; -use tracing::error; impl User { pub async fn authenticate( From 122cf875648e43f561b46f6d35f7a9b5bde6060b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Dobrzy=C5=84ski?= Date: Fri, 14 Mar 2025 16:58:58 +0100 Subject: [PATCH 23/23] [57] prepare sqlx migrations --- ...89060d566995e85327d3a4accea050ad56c16.json | 17 +++++++ ...c93d8c34b1bc946602d22722354463b5233e8.json | 14 ++++++ ...2f58def3b6ed11f0184c7b2ba4eb34f2f9334.json | 46 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 .sqlx/query-11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16.json create mode 100644 .sqlx/query-b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8.json create mode 100644 .sqlx/query-efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334.json diff --git a/.sqlx/query-11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16.json b/.sqlx/query-11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16.json new file mode 100644 index 0000000..21b902f --- /dev/null +++ b/.sqlx/query-11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO login_tokens (id, token_hash, user_id, used)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "11b85832bba41cd1044b7994b6589060d566995e85327d3a4accea050ad56c16" +} diff --git a/.sqlx/query-b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8.json b/.sqlx/query-b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8.json new file mode 100644 index 0000000..c7d1051 --- /dev/null +++ b/.sqlx/query-b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE login_tokens SET used = true WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b2ac29199fa78ba54f5e71b715dc93d8c34b1bc946602d22722354463b5233e8" +} diff --git a/.sqlx/query-efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334.json b/.sqlx/query-efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334.json new file mode 100644 index 0000000..5c7d502 --- /dev/null +++ b/.sqlx/query-efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM login_tokens WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "used", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "expiry", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "efa73f7a82b0189fd96c262f0322f58def3b6ed11f0184c7b2ba4eb34f2f9334" +}