From 45cf1352f07cb224ea087669f2679f93e44e8a34 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 27 Jan 2024 19:08:28 +0100 Subject: [PATCH 01/41] feat: authenticate with keycloak and get users --- backend/Cargo.lock | 215 +++++++++++++++++++++----- backend/Cargo.toml | 1 + backend/src/config/app.rs | 23 +++ backend/src/config/keycloak_client.rs | 115 ++++++++++++++ backend/src/config/mod.rs | 1 + backend/src/main.rs | 14 ++ 6 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 backend/src/config/keycloak_client.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 58e1c8a26..1e13d86e6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -359,7 +359,7 @@ checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -394,6 +394,7 @@ dependencies = [ "jsonwebkey", "jsonwebtoken", "log", + "oauth2", "postgis_diesel", "reqwest", "serde", @@ -690,7 +691,7 @@ dependencies = [ "proc-macro2", "quote", "scratch", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -707,7 +708,7 @@ checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -816,9 +817,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common", @@ -995,9 +996,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -1058,7 +1059,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -1108,8 +1109,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1185,7 +1188,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -1252,6 +1255,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" +dependencies = [ + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1291,9 +1307,9 @@ dependencies = [ [[package]] name = "idna" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -1429,7 +1445,7 @@ checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" dependencies = [ "base64 0.21.0", "pem", - "ring", + "ring 0.16.20", "serde", "serde_json", "simple_asn1", @@ -1511,7 +1527,7 @@ version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" dependencies = [ - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -1642,6 +1658,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom", + "http", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.6", + "thiserror", + "url", +] + [[package]] name = "once_cell" version = "1.17.1" @@ -1677,7 +1713,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -1749,9 +1785,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "phf" @@ -1902,9 +1938,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.26" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -2000,6 +2036,7 @@ dependencies = [ "http", "http-body", "hyper", + "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -2009,16 +2046,20 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg", ] @@ -2037,12 +2078,26 @@ dependencies = [ "cc", "libc", "once_cell", - "spin", - "untrusted", + "spin 0.5.2", + "untrusted 0.7.1", "web-sys", "winapi", ] +[[package]] +name = "ring" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babe80d5c16becf6594aa32ad2be8fe08498e7ae60b77de8df700e67f191d7e" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin 0.9.8", + "untrusted 0.9.0", + "windows-sys 0.48.0", +] + [[package]] name = "rust-embed" version = "6.6.1" @@ -2101,6 +2156,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.0", +] + [[package]] name = "ryu" version = "1.0.13" @@ -2147,6 +2223,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.3", + "untrusted 0.9.0", +] + [[package]] name = "security-framework" version = "2.8.2" @@ -2178,22 +2264,22 @@ checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" [[package]] name = "serde" -version = "1.0.159" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c04e8343c3daeec41f58990b9d77068df31209f2af111e059e9fe9646693065" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.159" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c614d17805b093df4b147b51339e7e44bf05ef59fba1e45d83500bcfb4d8585" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -2220,6 +2306,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd154a240de39fdebcf5775d2675c204d7c13cf39a4c697be6493c8e734337c" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2240,7 +2336,7 @@ checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -2264,7 +2360,7 @@ checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -2360,6 +2456,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.4.1" @@ -2398,9 +2500,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.13" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c9da457c5285ac1f936ebd076af6dac17a61cfe7826f2076b4d015cf47bc8ec" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -2446,7 +2548,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -2518,7 +2620,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -2555,6 +2657,17 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls", + "tokio", + "webpki", +] + [[package]] name = "tokio-util" version = "0.7.7" @@ -2596,7 +2709,7 @@ checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] @@ -2684,15 +2797,22 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2718,7 +2838,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.13", + "syn 2.0.48", "uuid", ] @@ -2862,6 +2982,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.3", + "untrusted 0.9.0", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3069,9 +3208,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" dependencies = [ "zeroize_derive", ] @@ -3084,7 +3223,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.48", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 290276b4f..8427c690f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -31,6 +31,7 @@ actix-web-grants = "3.0.1" actix-web-httpauth = "0.8.0" reqwest = { version = "0.11.17", features = ["json"] } jsonwebtoken = "8.3.0" +oauth2 = "4.4.2" # Data diesel = { version = "2.0.2", features = [ diff --git a/backend/src/config/app.rs b/backend/src/config/app.rs index c23097c68..a074b8436 100644 --- a/backend/src/config/app.rs +++ b/backend/src/config/app.rs @@ -3,6 +3,7 @@ use std::env; use dotenvy::dotenv; +use oauth2::{ClientId, ClientSecret, ResourceOwnerPassword, ResourceOwnerUsername}; /// Configuration data for the server. pub struct Config { @@ -16,6 +17,15 @@ pub struct Config { pub auth_discovery_uri: String, /// The `client_id` the frontend should use to log in its users. pub client_id: String, + + /// The `client_id` the backend uses to communicate with the auth server. + pub keycloak_client_id: ClientId, + /// The `client_secret` the backend uses to communicate with the auth server. + pub keycloak_client_secret: ClientSecret, + /// The `username` the backend uses to communicate with the auth server. + pub keycloak_username: ResourceOwnerUsername, + /// The `password` the backend uses to communicate with the auth server. + pub keycloak_password: ResourceOwnerPassword, } impl Config { @@ -42,11 +52,24 @@ impl Config { let client_id = env::var("AUTH_CLIENT_ID") .map_err(|_| "Failed to get AUTH_CLIENT_ID from environment.")?; + let keycloak_client_id = env::var("KEYCLOAK_CLIENT_ID") + .map_err(|_| "Failed to get KEYCLOAK_CLIENT_ID from environment.")?; + let keycloak_client_secret = env::var("KEYCLOAK_CLIENT_SECRET") + .map_err(|_| "Failed to get KEYCLOAK_CLIENT_SECRET from environment.")?; + let keycloak_username = env::var("KEYCLOAK_USERNAME") + .map_err(|_| "Failed to get KEYCLOAK_USERNAME from environment.")?; + let keycloak_password = env::var("KEYCLOAK_PASSWORD") + .map_err(|_| "Failed to get KEYCLOAK_PASSWORD from environment.")?; + Ok(Self { bind_address: (host, port), database_url, auth_discovery_uri, client_id, + keycloak_client_id: ClientId::new(keycloak_client_id), + keycloak_client_secret: ClientSecret::new(keycloak_client_secret), + keycloak_username: ResourceOwnerUsername::new(keycloak_username), + keycloak_password: ResourceOwnerPassword::new(keycloak_password), }) } } diff --git a/backend/src/config/keycloak_client.rs b/backend/src/config/keycloak_client.rs new file mode 100644 index 000000000..083336edd --- /dev/null +++ b/backend/src/config/keycloak_client.rs @@ -0,0 +1,115 @@ +use std::time::Instant; + +use super::app::Config; + +use oauth2::basic::BasicClient; +use oauth2::reqwest::async_http_client; +use oauth2::{ + AccessToken, AuthUrl, HttpRequest, ResourceOwnerPassword, ResourceOwnerUsername, TokenResponse, + TokenUrl, +}; +use reqwest::header::HeaderMap; + +pub struct KeycloakClient { + client: BasicClient, + access_token: Option, + expires_at: Option, + username: ResourceOwnerUsername, + password: ResourceOwnerPassword, +} + +impl KeycloakClient { + pub fn new(config: &Config) -> Result> { + // Create an OAuth2 client by specifying the client ID, client secret, authorization URL and + // token URL. + let client = BasicClient::new( + config.keycloak_client_id.clone(), + Some(config.keycloak_client_secret.clone()), + AuthUrl::new( + "http://localhost:8081/realms/master/protocol/openid-connect/token".to_owned(), + )?, + Some(TokenUrl::new( + "http://localhost:8081/realms/master/protocol/openid-connect/token".to_owned(), + )?), + ); + + Ok(Self { + client, + access_token: None, + expires_at: None, + username: config.keycloak_username.clone(), + password: config.keycloak_password.clone(), + }) + } + + async fn refresh_access_token(&mut self) -> Result<&AccessToken, Box> { + if let Some(expires_at) = self.expires_at { + if expires_at > Instant::now() { + return Ok(self.access_token.as_ref().unwrap()); + } + } + + self.get_access_token().await + } + + async fn get_access_token(&mut self) -> Result<&AccessToken, Box> { + let token_result = self + .client + .exchange_password(&self.username, &self.password) + .request_async(async_http_client) + .await?; + + self.access_token = Some(token_result.access_token().clone()); + if let Some(expires_in) = token_result.expires_in() { + self.expires_at = Instant::now().checked_add(expires_in); + } + + // unwrap is safe here because we just set it + Ok(self.access_token.as_ref().unwrap()) + } + + pub async fn get_users(&mut self) -> Result<(), Box> { + let token = self.refresh_access_token().await?; + let mut headers = HeaderMap::new(); + headers.append( + "Authorization", + format!("Bearer {}", token.secret()).parse()?, + ); + + let res = async_http_client(HttpRequest { + url: "http://localhost:8081/admin/realms/PermaplanT/users".parse()?, + method: reqwest::Method::GET, + headers, + body: vec![], + }) + .await?; + + log::info!("Body: {}", String::from_utf8(res.body)?); + + Ok(()) + } +} + +// pub async fn new(config: &Config) -> Result<(), Box> { +// // Create an OAuth2 client by specifying the client ID, client secret, authorization URL and +// // token URL. +// let client = BasicClient::new( +// config.keycloak_client_id.clone(), +// Some(config.keycloak_client_secret.clone()), +// AuthUrl::new( +// "http://localhost:8081/realms/master/protocol/openid-connect/token".to_owned(), +// )?, +// Some(TokenUrl::new( +// "http://localhost:8081/realms/master/protocol/openid-connect/token".to_owned(), +// )?), +// ); + +// let token_result = client +// .exchange_password(&config.keycloak_username, &config.keycloak_password) +// .request_async(async_http_client) +// .await?; + +// log::info!("Token result: {:?}", token_result); + +// Ok(()) +// } diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index 80c3229ce..d4887dc67 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -4,4 +4,5 @@ pub mod api_doc; pub mod app; pub mod auth; pub mod data; +pub mod keycloak_client; pub mod routes; diff --git a/backend/src/main.rs b/backend/src/main.rs index f18825b19..3117078c9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -88,6 +88,20 @@ async fn main() -> std::io::Result<()> { let data = config::data::init(&config.database_url); start_cronjobs(data.pool.clone()); + let mut client = config::keycloak_client::KeycloakClient::new(&config).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Error creating Keycloak client: {e}"), + ) + })?; + + client.get_users().await.map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Error getting users from Keycloak: {e}"), + ) + })?; + HttpServer::new(move || { App::new() .wrap(cors_configuration()) From 9d89b885814fe2f3cb8b3beb2a0a7863ad2f0ffc Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sun, 28 Jan 2024 14:03:00 +0100 Subject: [PATCH 02/41] refactor: change structure --- backend/src/config/app.rs | 75 +++++++++++++++++++++------ backend/src/config/data.rs | 15 ++++-- backend/src/config/keycloak_client.rs | 51 ++++-------------- backend/src/main.rs | 24 ++------- 4 files changed, 86 insertions(+), 79 deletions(-) diff --git a/backend/src/config/app.rs b/backend/src/config/app.rs index a074b8436..d48bcd438 100644 --- a/backend/src/config/app.rs +++ b/backend/src/config/app.rs @@ -4,6 +4,35 @@ use std::env; use dotenvy::dotenv; use oauth2::{ClientId, ClientSecret, ResourceOwnerPassword, ResourceOwnerUsername}; +use reqwest::Url; + +/// Environment variables that are required for the configuration of the server. +pub struct EnvVars { + pub bind_address_host: &'static str, + pub bind_address_port: &'static str, + pub database_url: &'static str, + pub auth_discovery_uri: &'static str, + pub auth_client_id: &'static str, + pub keycloak_auth_uri: &'static str, + pub keycloak_client_id: &'static str, + pub keycloak_client_secret: &'static str, + pub keycloak_username: &'static str, + pub keycloak_password: &'static str, +} + +/// Holds the names of all required environment variables +const ENV_VARS: EnvVars = EnvVars { + bind_address_host: "BIND_ADDRESS_HOST", + bind_address_port: "BIND_ADDRESS_PORT", + database_url: "DATABASE_URL", + auth_discovery_uri: "AUTH_DISCOVERY_URI", + auth_client_id: "AUTH_CLIENT_ID", + keycloak_auth_uri: "KEYCLOAK_AUTH_URI", + keycloak_client_id: "KEYCLOAK_CLIENT_ID", + keycloak_client_secret: "KEYCLOAK_CLIENT_SECRET", + keycloak_username: "KEYCLOAK_USERNAME", + keycloak_password: "KEYCLOAK_PASSWORD", +}; /// Configuration data for the server. pub struct Config { @@ -18,6 +47,8 @@ pub struct Config { /// The `client_id` the frontend should use to log in its users. pub client_id: String, + /// The URI of the auth server used to acquire a token for the admin API. + pub keycloak_auth_uri: Url, /// The `client_id` the backend uses to communicate with the auth server. pub keycloak_client_id: ClientId, /// The `client_secret` the backend uses to communicate with the auth server. @@ -38,34 +69,39 @@ impl Config { pub fn from_env() -> Result> { load_env_file()?; - let host = env::var("BIND_ADDRESS_HOST") - .map_err(|_| "Failed to get BIND_ADDRESS_HOST from environment.")?; - let port = env::var("BIND_ADDRESS_PORT") - .map_err(|_| "Failed to get BIND_ADDRESS_PORT from environment.")? + let host = env::var(ENV_VARS.bind_address_host) + .map_err(|_| env_error(ENV_VARS.bind_address_host))?; + let port = env::var(ENV_VARS.bind_address_port) + .map_err(|_| env_error(ENV_VARS.bind_address_port))? .parse::() .map_err(|e| e.to_string())?; let database_url = - env::var("DATABASE_URL").map_err(|_| "Failed to get DATABASE_URL from environment.")?; - let auth_discovery_uri = env::var("AUTH_DISCOVERY_URI") - .map_err(|_| "Failed to get AUTH_DISCOVERY_URI from environment.")?; - let client_id = env::var("AUTH_CLIENT_ID") - .map_err(|_| "Failed to get AUTH_CLIENT_ID from environment.")?; + env::var(ENV_VARS.database_url).map_err(|_| env_error(ENV_VARS.database_url))?; + let auth_discovery_uri = env::var(ENV_VARS.auth_discovery_uri) + .map_err(|_| env_error(ENV_VARS.auth_discovery_uri))?; + let client_id = + env::var(ENV_VARS.auth_client_id).map_err(|_| env_error(ENV_VARS.auth_client_id))?; - let keycloak_client_id = env::var("KEYCLOAK_CLIENT_ID") - .map_err(|_| "Failed to get KEYCLOAK_CLIENT_ID from environment.")?; - let keycloak_client_secret = env::var("KEYCLOAK_CLIENT_SECRET") - .map_err(|_| "Failed to get KEYCLOAK_CLIENT_SECRET from environment.")?; - let keycloak_username = env::var("KEYCLOAK_USERNAME") - .map_err(|_| "Failed to get KEYCLOAK_USERNAME from environment.")?; - let keycloak_password = env::var("KEYCLOAK_PASSWORD") - .map_err(|_| "Failed to get KEYCLOAK_PASSWORD from environment.")?; + let keycloak_auth_uri = env::var(ENV_VARS.keycloak_auth_uri) + .map_err(|_| env_error(ENV_VARS.keycloak_auth_uri))? + .parse::() + .map_err(|e| e.to_string())?; + let keycloak_client_id = env::var(ENV_VARS.keycloak_client_id) + .map_err(|_| env_error(ENV_VARS.keycloak_client_id))?; + let keycloak_client_secret = env::var(ENV_VARS.keycloak_client_secret) + .map_err(|_| env_error(ENV_VARS.keycloak_client_secret))?; + let keycloak_username = env::var(ENV_VARS.keycloak_username) + .map_err(|_| env_error(ENV_VARS.keycloak_username))?; + let keycloak_password = env::var(ENV_VARS.keycloak_password) + .map_err(|_| env_error(ENV_VARS.keycloak_password))?; Ok(Self { bind_address: (host, port), database_url, auth_discovery_uri, client_id, + keycloak_auth_uri, keycloak_client_id: ClientId::new(keycloak_client_id), keycloak_client_secret: ClientSecret::new(keycloak_client_secret), keycloak_username: ResourceOwnerUsername::new(keycloak_username), @@ -74,6 +110,11 @@ impl Config { } } +/// Generates an error message for missing env variables +fn env_error(env_var: &str) -> String { + format!("Failed to get {env_var} from environment") +} + /// Load the .env file. A missing file does not result in an error. /// /// # Errors diff --git a/backend/src/config/data.rs b/backend/src/config/data.rs index d07229e89..9cf62911e 100644 --- a/backend/src/config/data.rs +++ b/backend/src/config/data.rs @@ -1,5 +1,7 @@ //! Configurations for the app data that is available to all controllers. +use crate::config::app::Config; +use crate::config::keycloak_client::KeycloakClient; use crate::sse::broadcaster::Broadcaster; use actix_web::web::Data; @@ -11,6 +13,8 @@ pub struct AppDataInner { pub pool: connection::Pool, /// Server-Sent Events broadcaster. pub broadcaster: Broadcaster, + /// Client for the keycloak admin API + pub keycloak_client: KeycloakClient, } /// Initializes the app data that is available to all controllers. @@ -18,9 +22,14 @@ pub struct AppDataInner { /// # Panics /// If the database pool can not be initialized. #[must_use] -pub fn init(database_url: &str) -> Data { - let pool = connection::init_pool(database_url); +pub fn init(config: &Config) -> Data { + let pool = connection::init_pool(&config.database_url); let broadcaster = Broadcaster::new(); + let keycloak_client = KeycloakClient::new(&config); - Data::new(AppDataInner { pool, broadcaster }) + Data::new(AppDataInner { + pool, + broadcaster, + keycloak_client, + }) } diff --git a/backend/src/config/keycloak_client.rs b/backend/src/config/keycloak_client.rs index 083336edd..4063a7c26 100644 --- a/backend/src/config/keycloak_client.rs +++ b/backend/src/config/keycloak_client.rs @@ -19,27 +19,24 @@ pub struct KeycloakClient { } impl KeycloakClient { - pub fn new(config: &Config) -> Result> { - // Create an OAuth2 client by specifying the client ID, client secret, authorization URL and - // token URL. + pub fn new(config: &Config) -> Self { + let auth_url = AuthUrl::from_url(config.keycloak_auth_uri.to_owned()); + let token_url = TokenUrl::from_url(config.keycloak_auth_uri.to_owned()); + let client = BasicClient::new( config.keycloak_client_id.clone(), Some(config.keycloak_client_secret.clone()), - AuthUrl::new( - "http://localhost:8081/realms/master/protocol/openid-connect/token".to_owned(), - )?, - Some(TokenUrl::new( - "http://localhost:8081/realms/master/protocol/openid-connect/token".to_owned(), - )?), + auth_url, + Some(token_url), ); - Ok(Self { + Self { client, access_token: None, expires_at: None, username: config.keycloak_username.clone(), password: config.keycloak_password.clone(), - }) + } } async fn refresh_access_token(&mut self) -> Result<&AccessToken, Box> { @@ -68,7 +65,7 @@ impl KeycloakClient { Ok(self.access_token.as_ref().unwrap()) } - pub async fn get_users(&mut self) -> Result<(), Box> { + pub async fn get(&mut self, url: &str) -> Result> { let token = self.refresh_access_token().await?; let mut headers = HeaderMap::new(); headers.append( @@ -77,39 +74,13 @@ impl KeycloakClient { ); let res = async_http_client(HttpRequest { - url: "http://localhost:8081/admin/realms/PermaplanT/users".parse()?, + url: url.parse()?, method: reqwest::Method::GET, headers, body: vec![], }) .await?; - log::info!("Body: {}", String::from_utf8(res.body)?); - - Ok(()) + Ok(String::from_utf8(res.body)?) } } - -// pub async fn new(config: &Config) -> Result<(), Box> { -// // Create an OAuth2 client by specifying the client ID, client secret, authorization URL and -// // token URL. -// let client = BasicClient::new( -// config.keycloak_client_id.clone(), -// Some(config.keycloak_client_secret.clone()), -// AuthUrl::new( -// "http://localhost:8081/realms/master/protocol/openid-connect/token".to_owned(), -// )?, -// Some(TokenUrl::new( -// "http://localhost:8081/realms/master/protocol/openid-connect/token".to_owned(), -// )?), -// ); - -// let token_result = client -// .exchange_password(&config.keycloak_username, &config.keycloak_password) -// .request_async(async_http_client) -// .await?; - -// log::info!("Token result: {:?}", token_result); - -// Ok(()) -// } diff --git a/backend/src/main.rs b/backend/src/main.rs index 3117078c9..17204aaa1 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -46,14 +46,13 @@ clippy::use_debug, clippy::verbose_file_reads )] -// Cannot fix some errors because dependecies import them. +// Cannot fix some errors because dependencies import them. #![allow(clippy::multiple_crate_versions)] use actix_cors::Cors; use actix_web::{http, middleware::Logger, App, HttpServer}; use config::{api_doc, auth::Config, routes}; use db::{connection::Pool, cronjobs::cleanup_maps}; -use log::info; pub mod config; pub mod controller; @@ -80,28 +79,15 @@ async fn main() -> std::io::Result<()> { Config::init(&config).await; - info!( + log::info!( "Starting server on {}:{}", - config.bind_address.0, config.bind_address.1 + config.bind_address.0, + config.bind_address.1 ); - let data = config::data::init(&config.database_url); + let data = config::data::init(&config); start_cronjobs(data.pool.clone()); - let mut client = config::keycloak_client::KeycloakClient::new(&config).map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("Error creating Keycloak client: {e}"), - ) - })?; - - client.get_users().await.map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("Error getting users from Keycloak: {e}"), - ) - })?; - HttpServer::new(move || { App::new() .wrap(cors_configuration()) From 0aed4d51141ec2f9b518546272203d15c1450e44 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sun, 28 Jan 2024 17:26:23 +0100 Subject: [PATCH 03/41] refactor: improve api --- backend/src/config/data.rs | 2 +- backend/src/config/keycloak_client.rs | 86 ---------------- backend/src/config/mod.rs | 1 - backend/src/keycloak_api/client.rs | 138 ++++++++++++++++++++++++++ backend/src/keycloak_api/mod.rs | 3 + backend/src/main.rs | 2 + 6 files changed, 144 insertions(+), 88 deletions(-) delete mode 100644 backend/src/config/keycloak_client.rs create mode 100644 backend/src/keycloak_api/client.rs create mode 100644 backend/src/keycloak_api/mod.rs diff --git a/backend/src/config/data.rs b/backend/src/config/data.rs index 9cf62911e..8c478cc06 100644 --- a/backend/src/config/data.rs +++ b/backend/src/config/data.rs @@ -1,7 +1,7 @@ //! Configurations for the app data that is available to all controllers. use crate::config::app::Config; -use crate::config::keycloak_client::KeycloakClient; +use crate::keycloak_api::client::KeycloakClient; use crate::sse::broadcaster::Broadcaster; use actix_web::web::Data; diff --git a/backend/src/config/keycloak_client.rs b/backend/src/config/keycloak_client.rs deleted file mode 100644 index 4063a7c26..000000000 --- a/backend/src/config/keycloak_client.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::time::Instant; - -use super::app::Config; - -use oauth2::basic::BasicClient; -use oauth2::reqwest::async_http_client; -use oauth2::{ - AccessToken, AuthUrl, HttpRequest, ResourceOwnerPassword, ResourceOwnerUsername, TokenResponse, - TokenUrl, -}; -use reqwest::header::HeaderMap; - -pub struct KeycloakClient { - client: BasicClient, - access_token: Option, - expires_at: Option, - username: ResourceOwnerUsername, - password: ResourceOwnerPassword, -} - -impl KeycloakClient { - pub fn new(config: &Config) -> Self { - let auth_url = AuthUrl::from_url(config.keycloak_auth_uri.to_owned()); - let token_url = TokenUrl::from_url(config.keycloak_auth_uri.to_owned()); - - let client = BasicClient::new( - config.keycloak_client_id.clone(), - Some(config.keycloak_client_secret.clone()), - auth_url, - Some(token_url), - ); - - Self { - client, - access_token: None, - expires_at: None, - username: config.keycloak_username.clone(), - password: config.keycloak_password.clone(), - } - } - - async fn refresh_access_token(&mut self) -> Result<&AccessToken, Box> { - if let Some(expires_at) = self.expires_at { - if expires_at > Instant::now() { - return Ok(self.access_token.as_ref().unwrap()); - } - } - - self.get_access_token().await - } - - async fn get_access_token(&mut self) -> Result<&AccessToken, Box> { - let token_result = self - .client - .exchange_password(&self.username, &self.password) - .request_async(async_http_client) - .await?; - - self.access_token = Some(token_result.access_token().clone()); - if let Some(expires_in) = token_result.expires_in() { - self.expires_at = Instant::now().checked_add(expires_in); - } - - // unwrap is safe here because we just set it - Ok(self.access_token.as_ref().unwrap()) - } - - pub async fn get(&mut self, url: &str) -> Result> { - let token = self.refresh_access_token().await?; - let mut headers = HeaderMap::new(); - headers.append( - "Authorization", - format!("Bearer {}", token.secret()).parse()?, - ); - - let res = async_http_client(HttpRequest { - url: url.parse()?, - method: reqwest::Method::GET, - headers, - body: vec![], - }) - .await?; - - Ok(String::from_utf8(res.body)?) - } -} diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index d4887dc67..80c3229ce 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -4,5 +4,4 @@ pub mod api_doc; pub mod app; pub mod auth; pub mod data; -pub mod keycloak_client; pub mod routes; diff --git a/backend/src/keycloak_api/client.rs b/backend/src/keycloak_api/client.rs new file mode 100644 index 000000000..7badd906d --- /dev/null +++ b/backend/src/keycloak_api/client.rs @@ -0,0 +1,138 @@ +//! This module contains the implementation of the client for the keycloak admin API. + +use std::time::Instant; + +use oauth2::basic::BasicClient; +use oauth2::reqwest::Error; +use oauth2::{ + AccessToken, AuthUrl, HttpRequest, HttpResponse, ResourceOwnerPassword, ResourceOwnerUsername, + TokenResponse, TokenUrl, +}; +use reqwest::header::HeaderValue; +use serde::de::DeserializeOwned; + +use crate::config::app::Config; + +type Result = std::result::Result>; + +pub struct KeycloakClient { + oauth_api: BasicClient, + username: ResourceOwnerUsername, + password: ResourceOwnerPassword, + base_url: String, + auth_data: Option, +} + +struct AuthData { + access_token: AccessToken, + expires_at: Instant, +} + +impl KeycloakClient { + pub fn new(config: &Config) -> Self { + let auth_url = AuthUrl::from_url(config.keycloak_auth_uri.to_owned()); + let token_url = TokenUrl::from_url(config.keycloak_auth_uri.to_owned()); + let base_url = config + .keycloak_auth_uri + .host() + .expect("URI should have host") + .to_string(); + + let oauth_api = BasicClient::new( + config.keycloak_client_id.clone(), + Some(config.keycloak_client_secret.clone()), + auth_url, + Some(token_url), + ); + + Self { + oauth_api, + username: config.keycloak_username.clone(), + password: config.keycloak_password.clone(), + base_url, + auth_data: None, + } + } + + async fn refresh_access_token(&mut self, client: &reqwest::Client) -> Result { + match &self.auth_data { + Some(AuthData { + access_token, + expires_at, + }) if *expires_at > Instant::now() => { + return Ok(access_token.clone()); + } + _ => self.get_access_token(client).await, + } + } + + /// Helper function to get a new access token from the Keycloak API. + async fn get_access_token(&mut self, client: &reqwest::Client) -> Result { + let token_result = self + .oauth_api + .exchange_password(&self.username, &self.password) + .request_async(|req| self.send_token_request(client, req)) + .await?; + + let token = token_result.access_token().clone(); + let expires_in = token_result.expires_in(); + + if expires_in.is_none() { + return Err("No expires_in in token response".into()); + } + + self.auth_data = Some(AuthData { + access_token: token.clone(), + expires_at: Instant::now() + expires_in.unwrap(), + }); + + return Ok(token); + } + + /// Helper function to send a token request to the Keycloak API. + /// + /// Basically a copy of [`oauth2::reqwest::async_http_client`] + /// but using a client instance instead of a new client. + /// This enables connection pooling. See: https://github.com/seanmonstar/reqwest/discussions/2067 + async fn send_token_request( + &self, + client: &reqwest::Client, + request: HttpRequest, + ) -> std::result::Result> { + let mut request_builder = client + .request(request.method, request.url.as_str()) + .body(request.body); + for (name, value) in &request.headers { + request_builder = request_builder.header(name.as_str(), value.as_bytes()); + } + let request = request_builder.build().map_err(Error::Reqwest)?; + + let response = client.execute(request).await.map_err(Error::Reqwest)?; + + let status_code = response.status(); + let headers = response.headers().to_owned(); + let chunks = response.bytes().await.map_err(Error::Reqwest)?; + Ok(HttpResponse { + status_code, + headers, + body: chunks.to_vec(), + }) + } + + /// Executes a get request authenticated with the access token. + pub async fn get( + &mut self, + client: &reqwest::Client, + path: &str, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + let mut request = reqwest::Request::new(reqwest::Method::GET, url.parse()?); + let token = self.refresh_access_token(client).await?; + let token_header = HeaderValue::from_str(&format!("Bearer {}", token.secret()))?; + request.headers_mut().append("Authorization", token_header); + + let res = client.execute(request.into()).await?; + + res.json().await.map_err(|e| e.into()) + } +} diff --git a/backend/src/keycloak_api/mod.rs b/backend/src/keycloak_api/mod.rs new file mode 100644 index 000000000..e4ca66228 --- /dev/null +++ b/backend/src/keycloak_api/mod.rs @@ -0,0 +1,3 @@ +//! Keycloak admin API + +pub mod client; diff --git a/backend/src/main.rs b/backend/src/main.rs index 17204aaa1..821c27405 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -51,6 +51,7 @@ use actix_cors::Cors; use actix_web::{http, middleware::Logger, App, HttpServer}; + use config::{api_doc, auth::Config, routes}; use db::{connection::Pool, cronjobs::cleanup_maps}; @@ -58,6 +59,7 @@ pub mod config; pub mod controller; pub mod db; pub mod error; +pub mod keycloak_api; pub mod model; /// Auto generated by diesel. #[allow(clippy::missing_docs_in_private_items)] From 17f9a204900887d3b179257290af553796c82266 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Mon, 29 Jan 2024 00:12:20 +0100 Subject: [PATCH 04/41] feat: thread safety --- backend/src/config/data.rs | 18 ++- backend/src/config/routes.rs | 6 +- backend/src/controller/users.rs | 18 ++- backend/src/keycloak_api/api.rs | 197 +++++++++++++++++++++++++++++ backend/src/keycloak_api/client.rs | 138 -------------------- backend/src/keycloak_api/dtos.rs | 13 ++ backend/src/keycloak_api/mod.rs | 3 +- backend/src/service/users.rs | 19 +++ 8 files changed, 264 insertions(+), 148 deletions(-) create mode 100644 backend/src/keycloak_api/api.rs delete mode 100644 backend/src/keycloak_api/client.rs create mode 100644 backend/src/keycloak_api/dtos.rs diff --git a/backend/src/config/data.rs b/backend/src/config/data.rs index 8c478cc06..5a9796fcd 100644 --- a/backend/src/config/data.rs +++ b/backend/src/config/data.rs @@ -1,11 +1,11 @@ //! Configurations for the app data that is available to all controllers. -use crate::config::app::Config; -use crate::keycloak_api::client::KeycloakClient; -use crate::sse::broadcaster::Broadcaster; use actix_web::web::Data; +use crate::config::app::Config; use crate::db::connection; +use crate::keycloak_api::api::KeycloakApi; +use crate::sse::broadcaster::Broadcaster; /// Data available to all controllers. pub struct AppDataInner { @@ -13,8 +13,10 @@ pub struct AppDataInner { pub pool: connection::Pool, /// Server-Sent Events broadcaster. pub broadcaster: Broadcaster, - /// Client for the keycloak admin API - pub keycloak_client: KeycloakClient, + /// Keycloak admin API. + pub keycloak_api: KeycloakApi, + /// Pooled HTTP client. + pub http_client: reqwest::Client, } /// Initializes the app data that is available to all controllers. @@ -25,11 +27,13 @@ pub struct AppDataInner { pub fn init(config: &Config) -> Data { let pool = connection::init_pool(&config.database_url); let broadcaster = Broadcaster::new(); - let keycloak_client = KeycloakClient::new(&config); + let keycloak_api = KeycloakApi::new(&config); + let http_client = reqwest::Client::new(); Data::new(AppDataInner { pool, broadcaster, - keycloak_client, + keycloak_api, + http_client, }) } diff --git a/backend/src/config/routes.rs b/backend/src/config/routes.rs index 2217b90cd..153d5009f 100644 --- a/backend/src/config/routes.rs +++ b/backend/src/config/routes.rs @@ -77,7 +77,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(guided_tours::find_by_user) .service(guided_tours::update), ) - .service(web::scope("/users").service(users::create)) + .service( + web::scope("/users") + .service(users::create) + .service(users::find), + ) .service(web::scope("/blossoms").service(blossoms::gain)) .service(web::scope("/timeline").service(timeline::get_timeline)) .wrap(NormalizePath::trim()) diff --git a/backend/src/controller/users.rs b/backend/src/controller/users.rs index b51117ef3..896986b32 100644 --- a/backend/src/controller/users.rs +++ b/backend/src/controller/users.rs @@ -1,7 +1,7 @@ //! `Users` endpoints. use actix_web::{ - post, + get, post, web::{Data, Json}, HttpResponse, Result, }; @@ -12,6 +12,22 @@ use crate::{ service, }; +/// Endpoint for getting users. +#[utoipa::path( + context_path = "/api/users", + responses( + (status = 200, description = "Find users", body = Vec) + ), + security( + ("oauth2" = []) + ) +)] +#[get("")] +pub async fn find(app_data: Data) -> Result { + let response = service::users::find(&app_data).await?; + Ok(HttpResponse::Ok().json(response)) +} + /// Endpoint for creating an [`Users`](crate::model::entity::Users) entry. /// /// # Errors diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs new file mode 100644 index 000000000..050c3c48b --- /dev/null +++ b/backend/src/keycloak_api/api.rs @@ -0,0 +1,197 @@ +//! This module contains the implementation of the client for the keycloak admin API. + +use std::sync::RwLock; +use std::time::Instant; + +use oauth2::basic::BasicClient; +use oauth2::reqwest::Error; +use oauth2::{ + AccessToken, AuthUrl, HttpRequest, HttpResponse, ResourceOwnerPassword, ResourceOwnerUsername, + TokenResponse, TokenUrl, +}; +use reqwest::header::HeaderValue; +use reqwest::Url; +use serde::de::DeserializeOwned; + +use crate::config::app::Config; +use crate::keycloak_api::dtos::UserDto; + +/// Helper type for results. +type Result = std::result::Result>; + +/// The keycloak admin API. +pub struct KeycloakApi { + /// Oauth2 client for auth with Keycloak. + oauth_api: BasicClient, + /// Username for auth with Keycloak. + username: ResourceOwnerUsername, + /// Password for auth with Keycloak. + password: ResourceOwnerPassword, + /// Base url for the Keycloak admin API. + base_url: Url, + /// Cached access token (needs to be thread safe). + /// Might be expired, in which case it will be refreshed. + auth_data: RwLock>, +} + +/// Helper struct to cache the access token and its expiration time. +#[derive(Clone)] +struct AuthData { + /// The access token. + access_token: AccessToken, + /// The time when the access token expires. + expires_at: Instant, +} + +impl KeycloakApi { + /// Creates a new Keycloak API. + /// + /// # Panics + /// If the config does not contain a valid keycloak auth URI. + #[allow(clippy::expect_used)] + #[must_use] + pub fn new(config: &Config) -> Self { + let auth_url = AuthUrl::from_url(config.keycloak_auth_uri.clone()); + let token_url = TokenUrl::from_url(config.keycloak_auth_uri.clone()); + let mut base_url = to_base_url(token_url.url().clone()); + base_url.set_path("admin/realms/PermaplanT"); + + let oauth_api = BasicClient::new( + config.keycloak_client_id.clone(), + Some(config.keycloak_client_secret.clone()), + auth_url, + Some(token_url), + ); + + Self { + oauth_api, + username: config.keycloak_username.clone(), + password: config.keycloak_password.clone(), + base_url, + auth_data: RwLock::new(None), + } + } + + /// Gets all users from the Keycloak API. + /// + /// # Errors + /// - If the url cannot be parsed. + /// - If the authorization header cannot be created. + /// - If the request fails or the response cannot be deserialized. + pub async fn get_users(&self, client: &reqwest::Client) -> Result> { + self.get::>(client, "/users").await + } + + /// Executes a get request authenticated with the access token. + async fn get(&self, client: &reqwest::Client, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + + let mut request = reqwest::Request::new(reqwest::Method::GET, url.parse()?); + let token = self.refresh_access_token(client).await?; + let token_header = HeaderValue::from_str(&format!("Bearer {}", token.secret()))?; + request.headers_mut().append("Authorization", token_header); + + let res = client.execute(request).await?; + let text = res.text().await?; + + serde_json::from_str(&text).map_err(Into::into) + } + + /// Refreshes the access token if it is expired. + #[allow(clippy::unwrap_used)] + async fn refresh_access_token(&self, client: &reqwest::Client) -> Result { + let auth_data = self.cloned_auth_data(); + + match auth_data.as_ref() { + Some(AuthData { + access_token, + expires_at, + }) if *expires_at > Instant::now() => Ok(access_token.clone()), + _ => self.get_access_token(client).await, + } + } + + /// Gets a new access token from the keycloak API. + async fn get_access_token(&self, client: &reqwest::Client) -> Result { + let token_result = self + .oauth_api + .exchange_password(&self.username, &self.password) + .request_async(|req| send_token_request(client, req)) + .await?; + + let token = token_result.access_token().clone(); + + token_result.expires_in().map_or_else( + || Err("No expires_in in token response".into()), + |expires_in| { + self.update_auth_data(AuthData { + access_token: token.clone(), + expires_at: Instant::now() + expires_in, + }); + + Ok(token) + }, + ) + } + + /// We clone the auth data to avoid holding the read lock longer than necessary. + /// The critical section should be as short as possible. + #[allow(clippy::unwrap_in_result, clippy::unwrap_used)] + fn cloned_auth_data(&self) -> Option { + self.auth_data.read().unwrap().clone() + } + + /// We acquire the write lock to update the token and expiration time. + /// The critical section should be as short as possible. + #[allow(clippy::unwrap_used)] + fn update_auth_data(&self, update: AuthData) { + let mut auth_data = self.auth_data.write().unwrap(); + *auth_data = Some(update); + } +} + +/// Helper function to send a token request. +/// +/// Basically a copy of [`oauth2::reqwest::async_http_client`]. +/// Uses a client instance instead of a new client. +/// This enables connection pooling. See: +async fn send_token_request( + client: &reqwest::Client, + request: HttpRequest, +) -> std::result::Result> { + let mut request_builder = client + .request(request.method, request.url.as_str()) + .body(request.body); + for (name, value) in &request.headers { + request_builder = request_builder.header(name.as_str(), value.as_bytes()); + } + + let req = request_builder.build().map_err(Error::Reqwest)?; + + let response = client.execute(req).await.map_err(Error::Reqwest)?; + + let status_code = response.status(); + let headers = response.headers().to_owned(); + let chunks = response.bytes().await.map_err(Error::Reqwest)?; + Ok(HttpResponse { + status_code, + headers, + body: chunks.to_vec(), + }) +} + +/// Helper function to create a base URL. +/// +/// # Panics +/// If the URL cannot be a base URL. +fn to_base_url(mut url: Url) -> Url { + url.path_segments_mut().map_or_else( + |()| panic!("Cannot set base url"), + |mut segments| { + segments.clear(); + }, + ); + + url.set_query(None); + url +} diff --git a/backend/src/keycloak_api/client.rs b/backend/src/keycloak_api/client.rs deleted file mode 100644 index 7badd906d..000000000 --- a/backend/src/keycloak_api/client.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! This module contains the implementation of the client for the keycloak admin API. - -use std::time::Instant; - -use oauth2::basic::BasicClient; -use oauth2::reqwest::Error; -use oauth2::{ - AccessToken, AuthUrl, HttpRequest, HttpResponse, ResourceOwnerPassword, ResourceOwnerUsername, - TokenResponse, TokenUrl, -}; -use reqwest::header::HeaderValue; -use serde::de::DeserializeOwned; - -use crate::config::app::Config; - -type Result = std::result::Result>; - -pub struct KeycloakClient { - oauth_api: BasicClient, - username: ResourceOwnerUsername, - password: ResourceOwnerPassword, - base_url: String, - auth_data: Option, -} - -struct AuthData { - access_token: AccessToken, - expires_at: Instant, -} - -impl KeycloakClient { - pub fn new(config: &Config) -> Self { - let auth_url = AuthUrl::from_url(config.keycloak_auth_uri.to_owned()); - let token_url = TokenUrl::from_url(config.keycloak_auth_uri.to_owned()); - let base_url = config - .keycloak_auth_uri - .host() - .expect("URI should have host") - .to_string(); - - let oauth_api = BasicClient::new( - config.keycloak_client_id.clone(), - Some(config.keycloak_client_secret.clone()), - auth_url, - Some(token_url), - ); - - Self { - oauth_api, - username: config.keycloak_username.clone(), - password: config.keycloak_password.clone(), - base_url, - auth_data: None, - } - } - - async fn refresh_access_token(&mut self, client: &reqwest::Client) -> Result { - match &self.auth_data { - Some(AuthData { - access_token, - expires_at, - }) if *expires_at > Instant::now() => { - return Ok(access_token.clone()); - } - _ => self.get_access_token(client).await, - } - } - - /// Helper function to get a new access token from the Keycloak API. - async fn get_access_token(&mut self, client: &reqwest::Client) -> Result { - let token_result = self - .oauth_api - .exchange_password(&self.username, &self.password) - .request_async(|req| self.send_token_request(client, req)) - .await?; - - let token = token_result.access_token().clone(); - let expires_in = token_result.expires_in(); - - if expires_in.is_none() { - return Err("No expires_in in token response".into()); - } - - self.auth_data = Some(AuthData { - access_token: token.clone(), - expires_at: Instant::now() + expires_in.unwrap(), - }); - - return Ok(token); - } - - /// Helper function to send a token request to the Keycloak API. - /// - /// Basically a copy of [`oauth2::reqwest::async_http_client`] - /// but using a client instance instead of a new client. - /// This enables connection pooling. See: https://github.com/seanmonstar/reqwest/discussions/2067 - async fn send_token_request( - &self, - client: &reqwest::Client, - request: HttpRequest, - ) -> std::result::Result> { - let mut request_builder = client - .request(request.method, request.url.as_str()) - .body(request.body); - for (name, value) in &request.headers { - request_builder = request_builder.header(name.as_str(), value.as_bytes()); - } - let request = request_builder.build().map_err(Error::Reqwest)?; - - let response = client.execute(request).await.map_err(Error::Reqwest)?; - - let status_code = response.status(); - let headers = response.headers().to_owned(); - let chunks = response.bytes().await.map_err(Error::Reqwest)?; - Ok(HttpResponse { - status_code, - headers, - body: chunks.to_vec(), - }) - } - - /// Executes a get request authenticated with the access token. - pub async fn get( - &mut self, - client: &reqwest::Client, - path: &str, - ) -> Result { - let url = format!("{}{}", self.base_url, path); - let mut request = reqwest::Request::new(reqwest::Method::GET, url.parse()?); - let token = self.refresh_access_token(client).await?; - let token_header = HeaderValue::from_str(&format!("Bearer {}", token.secret()))?; - request.headers_mut().append("Authorization", token_header); - - let res = client.execute(request.into()).await?; - - res.json().await.map_err(|e| e.into()) - } -} diff --git a/backend/src/keycloak_api/dtos.rs b/backend/src/keycloak_api/dtos.rs new file mode 100644 index 000000000..eb99d224c --- /dev/null +++ b/backend/src/keycloak_api/dtos.rs @@ -0,0 +1,13 @@ +//! Dto types for keycloak admin API. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Dto for a user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserDto { + /// The user's ID. + pub id: Uuid, + /// The user's username. + pub username: String, +} diff --git a/backend/src/keycloak_api/mod.rs b/backend/src/keycloak_api/mod.rs index e4ca66228..20ddb348f 100644 --- a/backend/src/keycloak_api/mod.rs +++ b/backend/src/keycloak_api/mod.rs @@ -1,3 +1,4 @@ //! Keycloak admin API -pub mod client; +pub mod api; +pub mod dtos; diff --git a/backend/src/service/users.rs b/backend/src/service/users.rs index 70d822616..4e3951857 100644 --- a/backend/src/service/users.rs +++ b/backend/src/service/users.rs @@ -1,8 +1,10 @@ //! Service layer for user data. use actix_web::web::Data; +use reqwest::StatusCode; use uuid::Uuid; +use crate::keycloak_api::dtos::UserDto; use crate::{ config::data::AppDataInner, error::ServiceError, @@ -22,3 +24,20 @@ pub async fn create( let result = Users::create(user_data, user_id, &mut conn).await?; Ok(result) } + +/// Get all users. +pub async fn find(app_data: &Data) -> Result, ServiceError> { + let users = app_data + .keycloak_api + .get_users(&app_data.http_client) + .await + .map_err(|e| { + log::error!("Error getting user data from Keycloak API: {e}"); + ServiceError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Error getting user data from Keycloak API".to_owned(), + ) + })?; + + Ok(users) +} From 5130c234f091d10509d1794a7994d2cf90e0878c Mon Sep 17 00:00:00 2001 From: Bushuo Date: Wed, 31 Jan 2024 00:04:28 +0100 Subject: [PATCH 05/41] tests: add load-test testing thread safety --- backend/load-testing/README.md | 2 ++ backend/load-testing/src/get-users.js | 36 ++++++++++++++++++++++ backend/load-testing/src/oauth/keycloak.js | 29 +++++++++++++++++ backend/src/config/app.rs | 21 +++++++------ backend/src/keycloak_api/api.rs | 5 ++- 5 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 backend/load-testing/README.md create mode 100644 backend/load-testing/src/get-users.js create mode 100644 backend/load-testing/src/oauth/keycloak.js diff --git a/backend/load-testing/README.md b/backend/load-testing/README.md new file mode 100644 index 000000000..3d80822a3 --- /dev/null +++ b/backend/load-testing/README.md @@ -0,0 +1,2 @@ +This directory contains the load testing scripts for the backend. +The tool used is [k6](https://k6.io/). diff --git a/backend/load-testing/src/get-users.js b/backend/load-testing/src/get-users.js new file mode 100644 index 000000000..f9867b6b1 --- /dev/null +++ b/backend/load-testing/src/get-users.js @@ -0,0 +1,36 @@ +// Load test for the get-users endpoint. +// This test exists to test if the communication between API & Keycloak is efficient. + +import http from "k6/http"; +import { sleep } from "k6"; + +import { authenticate } from "./oauth/keycloak.js"; + +export const options = { + // A number specifying the number of VUs to run concurrently. + vus: 50, + // A string specifying the total duration of the test run. + duration: "1m30s", +}; + +export function setup() { + return authenticate( + "http://localhost:8081/realms/PermaplanT/protocol/openid-connect/token", + "PermaplanT", + "test", + "test" + ); +} + +export default function (data) { + const params = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${data.access_token}`, + }, + }; + + let response = http.get("http://localhost:8080/api/users", params); + console.log(response); + sleep(1); +} diff --git a/backend/load-testing/src/oauth/keycloak.js b/backend/load-testing/src/oauth/keycloak.js new file mode 100644 index 000000000..33e9220b2 --- /dev/null +++ b/backend/load-testing/src/oauth/keycloak.js @@ -0,0 +1,29 @@ +import http from "k6/http"; + +/** + * @function + * @param {string} authUrl + * @param {string} clientId + * @param {string} username + * @param {string} password + */ +export function authenticate(authUrl, clientId, username, password) { + let response; + + const requestBody = { + grant_type: "password", + username: username, + password: password, + client_id: clientId, + }; + + const params = { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }; + + response = http.post(authUrl, requestBody, params); + + return response.json(); +} diff --git a/backend/src/config/app.rs b/backend/src/config/app.rs index d48bcd438..efaddacdc 100644 --- a/backend/src/config/app.rs +++ b/backend/src/config/app.rs @@ -11,9 +11,8 @@ pub struct EnvVars { pub bind_address_host: &'static str, pub bind_address_port: &'static str, pub database_url: &'static str, - pub auth_discovery_uri: &'static str, + pub auth_host: &'static str, pub auth_client_id: &'static str, - pub keycloak_auth_uri: &'static str, pub keycloak_client_id: &'static str, pub keycloak_client_secret: &'static str, pub keycloak_username: &'static str, @@ -25,9 +24,8 @@ const ENV_VARS: EnvVars = EnvVars { bind_address_host: "BIND_ADDRESS_HOST", bind_address_port: "BIND_ADDRESS_PORT", database_url: "DATABASE_URL", - auth_discovery_uri: "AUTH_DISCOVERY_URI", + auth_host: "AUTH_HOST", auth_client_id: "AUTH_CLIENT_ID", - keycloak_auth_uri: "KEYCLOAK_AUTH_URI", keycloak_client_id: "KEYCLOAK_CLIENT_ID", keycloak_client_secret: "KEYCLOAK_CLIENT_SECRET", keycloak_username: "KEYCLOAK_USERNAME", @@ -78,15 +76,18 @@ impl Config { let database_url = env::var(ENV_VARS.database_url).map_err(|_| env_error(ENV_VARS.database_url))?; - let auth_discovery_uri = env::var(ENV_VARS.auth_discovery_uri) - .map_err(|_| env_error(ENV_VARS.auth_discovery_uri))?; + let auth_host = env::var(ENV_VARS.auth_host).map_err(|_| env_error(ENV_VARS.auth_host))?; + + let auth_discovery_uri = + format!("{auth_host}/realms/PermaplanT/.well-known/openid-configuration"); + let keycloak_auth_uri = + format!("{auth_host}/auth/realms/master/protocol/openid-connect/token") + .parse::() + .map_err(|e| e.to_string())?; + let client_id = env::var(ENV_VARS.auth_client_id).map_err(|_| env_error(ENV_VARS.auth_client_id))?; - let keycloak_auth_uri = env::var(ENV_VARS.keycloak_auth_uri) - .map_err(|_| env_error(ENV_VARS.keycloak_auth_uri))? - .parse::() - .map_err(|e| e.to_string())?; let keycloak_client_id = env::var(ENV_VARS.keycloak_client_id) .map_err(|_| env_error(ENV_VARS.keycloak_client_id))?; let keycloak_client_secret = env::var(ENV_VARS.keycloak_client_secret) diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 050c3c48b..e0f802569 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -91,10 +91,9 @@ impl KeycloakApi { let token_header = HeaderValue::from_str(&format!("Bearer {}", token.secret()))?; request.headers_mut().append("Authorization", token_header); - let res = client.execute(request).await?; - let text = res.text().await?; + let res = client.execute(request).await?.json::().await?; - serde_json::from_str(&text).map_err(Into::into) + Ok(res) } /// Refreshes the access token if it is expired. From fb8a79d1821c9b35de89c9a2ece7024f6565cfef Mon Sep 17 00:00:00 2001 From: Bushuo Date: Mon, 5 Feb 2024 22:39:51 +0100 Subject: [PATCH 06/41] fix: use a mutex instead of rwlock --- backend/src/config/data.rs | 6 +-- backend/src/keycloak_api/api.rs | 73 +++++++++++++++------------------ backend/src/test/util.rs | 9 +++- 3 files changed, 43 insertions(+), 45 deletions(-) diff --git a/backend/src/config/data.rs b/backend/src/config/data.rs index 5a9796fcd..1cbaac112 100644 --- a/backend/src/config/data.rs +++ b/backend/src/config/data.rs @@ -4,7 +4,7 @@ use actix_web::web::Data; use crate::config::app::Config; use crate::db::connection; -use crate::keycloak_api::api::KeycloakApi; +use crate::keycloak_api::api::Api; use crate::sse::broadcaster::Broadcaster; /// Data available to all controllers. @@ -14,7 +14,7 @@ pub struct AppDataInner { /// Server-Sent Events broadcaster. pub broadcaster: Broadcaster, /// Keycloak admin API. - pub keycloak_api: KeycloakApi, + pub keycloak_api: Api, /// Pooled HTTP client. pub http_client: reqwest::Client, } @@ -27,7 +27,7 @@ pub struct AppDataInner { pub fn init(config: &Config) -> Data { let pool = connection::init_pool(&config.database_url); let broadcaster = Broadcaster::new(); - let keycloak_api = KeycloakApi::new(&config); + let keycloak_api = Api::new(&config); let http_client = reqwest::Client::new(); Data::new(AppDataInner { diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index e0f802569..0c0aabfc3 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -1,8 +1,8 @@ //! This module contains the implementation of the client for the keycloak admin API. -use std::sync::RwLock; use std::time::Instant; +use actix_web::cookie::time::Duration; use oauth2::basic::BasicClient; use oauth2::reqwest::Error; use oauth2::{ @@ -12,6 +12,7 @@ use oauth2::{ use reqwest::header::HeaderValue; use reqwest::Url; use serde::de::DeserializeOwned; +use tokio::sync::Mutex; use crate::config::app::Config; use crate::keycloak_api::dtos::UserDto; @@ -20,7 +21,7 @@ use crate::keycloak_api::dtos::UserDto; type Result = std::result::Result>; /// The keycloak admin API. -pub struct KeycloakApi { +pub struct Api { /// Oauth2 client for auth with Keycloak. oauth_api: BasicClient, /// Username for auth with Keycloak. @@ -31,7 +32,7 @@ pub struct KeycloakApi { base_url: Url, /// Cached access token (needs to be thread safe). /// Might be expired, in which case it will be refreshed. - auth_data: RwLock>, + auth_data: Mutex>, } /// Helper struct to cache the access token and its expiration time. @@ -43,7 +44,7 @@ struct AuthData { expires_at: Instant, } -impl KeycloakApi { +impl Api { /// Creates a new Keycloak API. /// /// # Panics @@ -68,7 +69,7 @@ impl KeycloakApi { username: config.keycloak_username.clone(), password: config.keycloak_password.clone(), base_url, - auth_data: RwLock::new(None), + auth_data: Mutex::new(None), } } @@ -87,7 +88,7 @@ impl KeycloakApi { let url = format!("{}{}", self.base_url, path); let mut request = reqwest::Request::new(reqwest::Method::GET, url.parse()?); - let token = self.refresh_access_token(client).await?; + let token = self.get_or_refresh_access_token(client).await?; let token_header = HeaderValue::from_str(&format!("Bearer {}", token.secret()))?; request.headers_mut().append("Authorization", token_header); @@ -96,56 +97,46 @@ impl KeycloakApi { Ok(res) } - /// Refreshes the access token if it is expired. + /// Gets the access token or refreshes it if it is expired. #[allow(clippy::unwrap_used)] - async fn refresh_access_token(&self, client: &reqwest::Client) -> Result { - let auth_data = self.cloned_auth_data(); + async fn get_or_refresh_access_token(&self, client: &reqwest::Client) -> Result { + let mut guard = self.auth_data.lock().await; - match auth_data.as_ref() { + match &*guard { Some(AuthData { access_token, expires_at, }) if *expires_at > Instant::now() => Ok(access_token.clone()), - _ => self.get_access_token(client).await, + _ => { + let new_auth_data = self.refresh_access_token(client).await?; + + *guard = Some(new_auth_data.clone()); + drop(guard); + + Ok(new_auth_data.access_token) + } } } - /// Gets a new access token from the keycloak API. - async fn get_access_token(&self, client: &reqwest::Client) -> Result { + /// Refresh the access token. + async fn refresh_access_token(&self, client: &reqwest::Client) -> Result { let token_result = self .oauth_api .exchange_password(&self.username, &self.password) .request_async(|req| send_token_request(client, req)) .await?; - let token = token_result.access_token().clone(); - - token_result.expires_in().map_or_else( - || Err("No expires_in in token response".into()), - |expires_in| { - self.update_auth_data(AuthData { - access_token: token.clone(), - expires_at: Instant::now() + expires_in, - }); - - Ok(token) - }, - ) - } - - /// We clone the auth data to avoid holding the read lock longer than necessary. - /// The critical section should be as short as possible. - #[allow(clippy::unwrap_in_result, clippy::unwrap_used)] - fn cloned_auth_data(&self) -> Option { - self.auth_data.read().unwrap().clone() - } - - /// We acquire the write lock to update the token and expiration time. - /// The critical section should be as short as possible. - #[allow(clippy::unwrap_used)] - fn update_auth_data(&self, update: AuthData) { - let mut auth_data = self.auth_data.write().unwrap(); - *auth_data = Some(update); + let access_token = token_result.access_token().clone(); + let expires_at = token_result + .expires_in() + .map_or(Err("No expiration time"), |expires_in| { + Ok(Instant::now() + expires_in - Duration::seconds(5)) + })?; + + Ok(AuthData { + access_token, + expires_at, + }) } } diff --git a/backend/src/test/util.rs b/backend/src/test/util.rs index 6849908c6..279f49084 100644 --- a/backend/src/test/util.rs +++ b/backend/src/test/util.rs @@ -15,9 +15,12 @@ use diesel_async::{ }; use dotenvy::dotenv; -use crate::config::{app, data::AppDataInner, routes}; use crate::error::ServiceError; use crate::sse::broadcaster::Broadcaster; +use crate::{ + config::{app, data::AppDataInner, routes}, + keycloak_api, +}; use self::token::{generate_token, generate_token_for_user}; @@ -100,6 +103,10 @@ async fn init_test_app_impl( .app_data(Data::new(AppDataInner { pool, broadcaster: Broadcaster::new(), + keycloak_api: keycloak_api::api::Api::new( + &app::Config::from_env().expect("Error loading configuration"), + ), + http_client: reqwest::Client::new(), })) .configure(routes::config), ) From 9436250986fff0948c7d6e0b9104229c8c4515f1 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Mon, 5 Feb 2024 23:14:44 +0100 Subject: [PATCH 07/41] fix: wrong url --- backend/src/config/app.rs | 7 +++---- backend/src/keycloak_api/api.rs | 7 ++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/src/config/app.rs b/backend/src/config/app.rs index efaddacdc..71317e6e7 100644 --- a/backend/src/config/app.rs +++ b/backend/src/config/app.rs @@ -80,10 +80,9 @@ impl Config { let auth_discovery_uri = format!("{auth_host}/realms/PermaplanT/.well-known/openid-configuration"); - let keycloak_auth_uri = - format!("{auth_host}/auth/realms/master/protocol/openid-connect/token") - .parse::() - .map_err(|e| e.to_string())?; + let keycloak_auth_uri = format!("{auth_host}/realms/master/protocol/openid-connect/token") + .parse::() + .map_err(|e| e.to_string())?; let client_id = env::var(ENV_VARS.auth_client_id).map_err(|_| env_error(ENV_VARS.auth_client_id))?; diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 0c0aabfc3..a75688722 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -149,13 +149,10 @@ async fn send_token_request( client: &reqwest::Client, request: HttpRequest, ) -> std::result::Result> { - let mut request_builder = client + let request_builder = client .request(request.method, request.url.as_str()) + .headers(request.headers) .body(request.body); - for (name, value) in &request.headers { - request_builder = request_builder.header(name.as_str(), value.as_bytes()); - } - let req = request_builder.build().map_err(Error::Reqwest)?; let response = client.execute(req).await.map_err(Error::Reqwest)?; From 731fdaf92377cd9225efbeda7be07c271f6db20b Mon Sep 17 00:00:00 2001 From: Bushuo Date: Wed, 7 Feb 2024 00:00:02 +0100 Subject: [PATCH 08/41] feat: add role parsing --- backend/src/config/auth/claims.rs | 9 ++++++++ backend/src/config/auth/user_info.rs | 32 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/backend/src/config/auth/claims.rs b/backend/src/config/auth/claims.rs index 47fdeb89e..3edd1381f 100644 --- a/backend/src/config/auth/claims.rs +++ b/backend/src/config/auth/claims.rs @@ -16,6 +16,15 @@ pub struct Claims { pub sub: Uuid, /// The OAuth2 scope pub scope: String, + /// Realm roles + pub realm_access: RealmAccess, +} + +#[derive(Debug, Clone, Deserialize)] +/// The roles active at the realm level. +pub struct RealmAccess { + /// The roles active at the realm level. + pub roles: Vec, } impl Claims { diff --git a/backend/src/config/auth/user_info.rs b/backend/src/config/auth/user_info.rs index ccba69aaa..e76538cdf 100644 --- a/backend/src/config/auth/user_info.rs +++ b/backend/src/config/auth/user_info.rs @@ -17,6 +17,15 @@ pub struct UserInfo { pub id: Uuid, /// The scopes the current user has. pub scopes: Vec, + /// The roles the current user has. + pub roles: Vec, +} + +/// Roles a user can have +#[derive(Debug, Clone, Deserialize)] +pub enum Role { + /// The user is a member. + Member, } impl From for UserInfo { @@ -24,10 +33,25 @@ impl From for UserInfo { Self { id: value.sub, scopes: value.scope.split(' ').map(str::to_owned).collect(), + roles: value + .realm_access + .roles + .into_iter() + .filter_map(map_realm_access_role) + .collect::>(), } } } +/// Maps a role from the [`super::claims::RealmAccess`] to a [`Role`]. +#[allow(clippy::needless_pass_by_value)] // The function signature is required by `filter_map`. +fn map_realm_access_role(role: String) -> Option { + match role.as_str() { + "member" => Some(Role::Member), + _ => None, + } +} + impl FromRequest for UserInfo { type Future = Ready>; type Error = ServiceError; @@ -50,3 +74,11 @@ impl FromRequest for UserInfo { }) } } + +impl UserInfo { + /// Checks if the user is a member. + #[must_use] + pub fn is_member(&self) -> bool { + self.roles.iter().any(|role| matches!(role, Role::Member)) + } +} From c1077992731e329867625741510cfc264a7f5482 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Fri, 9 Feb 2024 17:15:34 +0100 Subject: [PATCH 09/41] wip: map collaborator api --- .vscode/launch.json | 3 +- .../down.sql | 1 + .../up.sql | 7 ++ backend/src/config/api_doc.rs | 52 +++++++++----- backend/src/config/routes.rs | 5 +- backend/src/controller/map_collaborators.rs | 45 ++++++++++++ backend/src/controller/mod.rs | 1 + backend/src/keycloak_api/api.rs | 72 ++++++++++++++++--- backend/src/keycloak_api/errors.rs | 53 ++++++++++++++ backend/src/keycloak_api/mod.rs | 1 + backend/src/model/dto.rs | 32 +++++++++ .../src/model/dto/map_collaborator_impl.rs | 13 ++++ .../model/dto/new_map_collaborator_impl.rs | 15 ++++ backend/src/model/entity.rs | 13 +++- .../src/model/entity/map_collaborator_impl.rs | 20 ++++++ backend/src/model/entity/plantings_impl.rs | 20 +++--- backend/src/schema.patch | 18 +++-- backend/src/service/map_collaborator.rs | 49 +++++++++++++ backend/src/service/mod.rs | 1 + backend/src/service/users.rs | 20 ++++++ doc/backend/06updating_schema_patch.md | 10 ++- 21 files changed, 399 insertions(+), 52 deletions(-) create mode 100644 backend/migrations/2024-02-07-205556_map_collaborators/down.sql create mode 100644 backend/migrations/2024-02-07-205556_map_collaborators/up.sql create mode 100644 backend/src/controller/map_collaborators.rs create mode 100644 backend/src/keycloak_api/errors.rs create mode 100644 backend/src/model/dto/map_collaborator_impl.rs create mode 100644 backend/src/model/dto/new_map_collaborator_impl.rs create mode 100644 backend/src/model/entity/map_collaborator_impl.rs create mode 100644 backend/src/service/map_collaborator.rs diff --git a/.vscode/launch.json b/.vscode/launch.json index 385af8d09..054626503 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,8 @@ "name": "Debug", "program": "${workspaceFolder}/backend/target/debug/backend", "args": [], - "cwd": "${workspaceFolder}/backend" + "cwd": "${workspaceFolder}/backend", + "envFile": "${workspaceFolder}/backend/.env" }, { "type": "chrome", diff --git a/backend/migrations/2024-02-07-205556_map_collaborators/down.sql b/backend/migrations/2024-02-07-205556_map_collaborators/down.sql new file mode 100644 index 000000000..e5bc570b2 --- /dev/null +++ b/backend/migrations/2024-02-07-205556_map_collaborators/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS map_collaborators; diff --git a/backend/migrations/2024-02-07-205556_map_collaborators/up.sql b/backend/migrations/2024-02-07-205556_map_collaborators/up.sql new file mode 100644 index 000000000..127c267f0 --- /dev/null +++ b/backend/migrations/2024-02-07-205556_map_collaborators/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS map_collaborators ( + map_id integer NOT NULL, + user_id uuid NOT NULL, + created_at timestamp without time zone NOT NULL, + FOREIGN KEY (map_id) REFERENCES maps (id) ON DELETE CASCADE, + PRIMARY KEY (map_id, user_id) +); diff --git a/backend/src/config/api_doc.rs b/backend/src/config/api_doc.rs index 25de500b9..9f2f0ba6f 100644 --- a/backend/src/config/api_doc.rs +++ b/backend/src/config/api_doc.rs @@ -10,8 +10,8 @@ use utoipa_swagger_ui::SwaggerUi; use super::auth::Config; use crate::{ controller::{ - base_layer_image, blossoms, config, guided_tours, layers, map, plant_layer, plantings, - plants, seed, timeline, users, + base_layer_image, blossoms, config, guided_tours, layers, map, map_collaborators, + plant_layer, plantings, plants, seed, timeline, users, }, model::{ dto::{ @@ -26,9 +26,10 @@ use crate::{ }, timeline::{TimelineDto, TimelineEntryDto}, BaseLayerImageDto, ConfigDto, Coordinates, GainedBlossomsDto, GuidedToursDto, LayerDto, - MapDto, NewLayerDto, NewMapDto, NewSeedDto, PageLayerDto, PageMapDto, - PagePlantsSummaryDto, PageSeedDto, PlantsSummaryDto, RelationDto, RelationsDto, - SeedDto, UpdateBaseLayerImageDto, UpdateGuidedToursDto, UpdateMapDto, UsersDto, + MapCollaboratorDto, MapDto, NewLayerDto, NewMapCollaboratorDto, NewMapDto, NewSeedDto, + PageLayerDto, PageMapDto, PagePlantsSummaryDto, PageSeedDto, PlantsSummaryDto, + RelationDto, RelationsDto, SeedDto, UpdateBaseLayerImageDto, UpdateGuidedToursDto, + UpdateMapDto, UsersDto, }, r#enum::{ privacy_option::PrivacyOption, quality::Quality, quantity::Quantity, @@ -37,12 +38,12 @@ use crate::{ }, }; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all config endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`config`] endpoints. #[derive(OpenApi)] #[openapi(paths(config::get), components(schemas(ConfigDto)))] struct ConfigApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all seed endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`seed`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -64,7 +65,7 @@ struct ConfigApiDoc; )] struct SeedApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all plant endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`plants`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -81,7 +82,7 @@ struct SeedApiDoc; )] struct PlantsApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all map endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`map`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -104,7 +105,7 @@ struct PlantsApiDoc; )] struct MapApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all layer endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`layers`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -124,7 +125,7 @@ struct MapApiDoc; )] struct LayerApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all plant layer endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`plant_layer`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -142,7 +143,7 @@ struct LayerApiDoc; )] struct PlantLayerApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all plantings endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`base_layer_image`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -161,7 +162,7 @@ struct PlantLayerApiDoc; )] struct BaseLayerImagesApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all plantings endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`plantings`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -190,7 +191,7 @@ struct BaseLayerImagesApiDoc; )] struct PlantingsApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all user data endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`users`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -205,7 +206,7 @@ struct PlantingsApiDoc; )] struct UsersApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all guided tours endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`guided_tours`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -223,7 +224,7 @@ struct UsersApiDoc; )] struct GuidedToursApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all blossom endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`blossoms`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -254,6 +255,22 @@ struct BlossomsApiDoc; )] struct TimelineApiDoc; +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`map_collaborators`] endpoints. +#[derive(OpenApi)] +#[openapi( + paths( + map_collaborators::create, + ), + components( + schemas( + NewMapCollaboratorDto, + MapCollaboratorDto, + ) + ), + modifiers(&SecurityAddon) +)] +struct MapCollaboratorsApiDoc; + /// Merges `OpenApi` and then serves it using `Swagger`. pub fn config(cfg: &mut web::ServiceConfig) { let mut openapi = ConfigApiDoc::openapi(); @@ -266,6 +283,9 @@ pub fn config(cfg: &mut web::ServiceConfig) { openapi.merge(PlantingsApiDoc::openapi()); openapi.merge(UsersApiDoc::openapi()); openapi.merge(TimelineApiDoc::openapi()); + openapi.merge(GuidedToursApiDoc::openapi()); + openapi.merge(BlossomsApiDoc::openapi()); + openapi.merge(MapCollaboratorsApiDoc::openapi()); cfg.service(SwaggerUi::new("/doc/api/swagger/ui/{_:.*}").url("/doc/api/openapi.json", openapi)); } diff --git a/backend/src/config/routes.rs b/backend/src/config/routes.rs index 153d5009f..1d24393f4 100644 --- a/backend/src/config/routes.rs +++ b/backend/src/config/routes.rs @@ -5,8 +5,8 @@ use actix_web::{middleware::NormalizePath, web}; use actix_web_httpauth::middleware::HttpAuthentication; use crate::controller::{ - base_layer_image, blossoms, config, guided_tours, layers, map, plant_layer, plantings, plants, - seed, sse, timeline, users, + base_layer_image, blossoms, config, guided_tours, layers, map, map_collaborators, plant_layer, + plantings, plants, seed, sse, timeline, users, }; use super::auth::middleware::validator; @@ -68,6 +68,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { ), ), ) + .service(web::scope("/collaborators").service(map_collaborators::create)) .service(timeline::get_timeline), ), ) diff --git a/backend/src/controller/map_collaborators.rs b/backend/src/controller/map_collaborators.rs new file mode 100644 index 000000000..ee7236258 --- /dev/null +++ b/backend/src/controller/map_collaborators.rs @@ -0,0 +1,45 @@ +use actix_web::{ + post, + web::{Data, Json, Path}, + HttpResponse, Result, +}; + +use crate::{ + config::{auth::user_info::UserInfo, data::AppDataInner}, + model::dto::NewMapCollaboratorDto, + service, +}; +/// Endpoint for creating a new [`MapCollaborator`](crate::model::entity::MapCollaborator). +/// +/// # Errors +/// * If the connection to the database could not be established. +#[utoipa::path( + context_path = "/api/maps/{map_id}/collaborators", + params( + ("map_id" = i32, Path, description = "The id of the map on which to collaborate"), + ), + request_body = NewMapCollaboratorDto, + responses( + (status = 201, description = "Add a new map collaborator", body = MapCollaboratorDto), + ), + security( + ("oauth2" = []) + ) +)] +#[post("")] +pub async fn create( + json: Json, + map_id: Path, + user_info: UserInfo, + app_data: Data, +) -> Result { + let response = service::map_collaborator::create( + json.into_inner(), + map_id.into_inner(), + user_info.id, + &app_data, + ) + .await?; + + Ok(HttpResponse::Created().json(response)) +} diff --git a/backend/src/controller/mod.rs b/backend/src/controller/mod.rs index ed97a5b30..e0f4f48a2 100644 --- a/backend/src/controller/mod.rs +++ b/backend/src/controller/mod.rs @@ -6,6 +6,7 @@ pub mod config; pub mod guided_tours; pub mod layers; pub mod map; +pub mod map_collaborators; pub mod plant_layer; pub mod plantings; pub mod plants; diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index a75688722..23329ee5c 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -3,6 +3,7 @@ use std::time::Instant; use actix_web::cookie::time::Duration; +use futures_util::{stream, StreamExt}; use oauth2::basic::BasicClient; use oauth2::reqwest::Error; use oauth2::{ @@ -17,10 +18,13 @@ use tokio::sync::Mutex; use crate::config::app::Config; use crate::keycloak_api::dtos::UserDto; +use super::errors::KeycloakApiError; + /// Helper type for results. -type Result = std::result::Result>; +type Result = std::result::Result; /// The keycloak admin API. +#[derive(Clone)] pub struct Api { /// Oauth2 client for auth with Keycloak. oauth_api: BasicClient, @@ -83,11 +87,60 @@ impl Api { self.get::>(client, "/users").await } + /// Gets all users given their ids from the Keycloak API. + /// + /// # Errors + /// - If the url cannot be parsed. + /// - If the authorization header cannot be created. + /// - If the request fails or the response cannot be deserialized. + pub async fn get_users_by_ids( + &self, + client: &reqwest::Client, + user_ids: Vec, + ) -> Result> { + let x = stream::iter(user_ids) + .map(|id| { + let client = client.clone(); + // TODO: we probably need to move from this OO style to a more functional style + let api = self.clone(); + tokio::spawn(async move { api.get_user_by_id(&client, id).await }) + }) + .buffer_unordered(10); + + let y = x + .map(|res| match res { + Ok(Ok(user)) => Ok(user), + Ok(Err(e)) => Err(e), + Err(e) => return Err(KeycloakApiError::Other(e.to_string())), + }) + .collect::>() + .await + .into_iter() + .collect::, _>>()?; + + Ok(y) + } + + /// Gets a user by its id from the Keycloak API. + /// + /// # Errors + /// - If the url cannot be parsed. + /// - If the authorization header cannot be created. + /// - If the request fails or the response cannot be deserialized. + pub async fn get_user_by_id( + &self, + client: &reqwest::Client, + user_id: uuid::Uuid, + ) -> Result { + self.get::(client, &format!("/users/{user_id}")) + .await + } + /// Executes a get request authenticated with the access token. async fn get(&self, client: &reqwest::Client, path: &str) -> Result { - let url = format!("{}{}", self.base_url, path); + let url = reqwest::Url::parse(&format!("{}{}", self.base_url, path))?; - let mut request = reqwest::Request::new(reqwest::Method::GET, url.parse()?); + let mut request = reqwest::Request::new(reqwest::Method::GET, url); let token = self.get_or_refresh_access_token(client).await?; let token_header = HeaderValue::from_str(&format!("Bearer {}", token.secret()))?; request.headers_mut().append("Authorization", token_header); @@ -127,11 +180,14 @@ impl Api { .await?; let access_token = token_result.access_token().clone(); - let expires_at = token_result - .expires_in() - .map_or(Err("No expiration time"), |expires_in| { - Ok(Instant::now() + expires_in - Duration::seconds(5)) - })?; + let expires_at = token_result.expires_in().map_or_else( + || { + Err(KeycloakApiError::Other( + "No expires_in in token response".to_owned(), + )) + }, + |expires_in| Ok(Instant::now() + expires_in - Duration::seconds(5)), + )?; Ok(AuthData { access_token, diff --git a/backend/src/keycloak_api/errors.rs b/backend/src/keycloak_api/errors.rs new file mode 100644 index 000000000..445371bac --- /dev/null +++ b/backend/src/keycloak_api/errors.rs @@ -0,0 +1,53 @@ +//! Keycloak API errors + +use std::{ + error::Error, + fmt::{self, Display, Formatter}, +}; + +use oauth2::ErrorResponse; + +#[derive(Debug, Clone)] +pub enum KeycloakApiError { + Reqwest(String), + Other(String), +} + +impl Display for KeycloakApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Reqwest(err) => write!(f, "Reqwest error: {err}"), + Self::Other(err) => write!(f, "Other error: {err}"), + } + } +} + +impl Error for KeycloakApiError {} + +impl From for KeycloakApiError { + fn from(err: reqwest::Error) -> Self { + Self::Reqwest(err.to_string()) + } +} + +impl From for KeycloakApiError { + fn from(err: oauth2::url::ParseError) -> Self { + Self::Other(err.to_string()) + } +} + +impl From for KeycloakApiError { + fn from(err: actix_http::header::InvalidHeaderValue) -> Self { + Self::Other(err.to_string()) + } +} + +impl From> for KeycloakApiError +where + RE: Error, + T: ErrorResponse, +{ + fn from(value: oauth2::RequestTokenError) -> Self { + Self::Other(value.to_string()) + } +} diff --git a/backend/src/keycloak_api/mod.rs b/backend/src/keycloak_api/mod.rs index 20ddb348f..f4016f628 100644 --- a/backend/src/keycloak_api/mod.rs +++ b/backend/src/keycloak_api/mod.rs @@ -2,3 +2,4 @@ pub mod api; pub mod dtos; +pub mod errors; diff --git a/backend/src/model/dto.rs b/backend/src/model/dto.rs index d479e8bef..10c7dca59 100644 --- a/backend/src/model/dto.rs +++ b/backend/src/model/dto.rs @@ -21,8 +21,10 @@ pub mod coordinates_impl; pub mod core; pub mod guided_tours_impl; pub mod layer_impl; +pub mod map_collaborator_impl; pub mod map_impl; pub mod new_layer_impl; +pub mod new_map_collaborator_impl; pub mod new_map_impl; pub mod new_seed_impl; pub mod page_impl; @@ -515,3 +517,33 @@ pub struct GainedBlossomsDto { /// The date on which the user gained this Blossom. pub gained_date: NaiveDate, } + +#[typeshare] +#[derive(Serialize, Deserialize, ToSchema)] +/// Information on user collaborating on a map. +pub struct MapCollaboratorDto { + /// The id of the map. + pub map_id: i32, + /// The id of the collaborator. + pub user_id: Uuid, + /// The user name of the collaborator. + pub user_name: String, +} + +#[typeshare] +#[derive(Serialize, Deserialize, ToSchema)] +/// The information of a map collaborator necessary for its creation. +pub struct NewMapCollaboratorDto { + /// The id of the map. + pub map_id: i32, + /// The id of the collaborator. + pub user_id: Uuid, +} + +#[typeshare] +#[derive(Serialize, Deserialize, ToSchema)] +/// Query params for searching map collaborators. +pub struct MapCollaboratorSearchParameters { + /// The id of the map. + pub map_id: i32, +} diff --git a/backend/src/model/dto/map_collaborator_impl.rs b/backend/src/model/dto/map_collaborator_impl.rs new file mode 100644 index 000000000..0274aebb5 --- /dev/null +++ b/backend/src/model/dto/map_collaborator_impl.rs @@ -0,0 +1,13 @@ +use crate::{keycloak_api::dtos::UserDto, model::entity::MapCollaborator}; + +use super::MapCollaboratorDto; + +impl From<(MapCollaborator, UserDto)> for MapCollaboratorDto { + fn from(value: (MapCollaborator, UserDto)) -> Self { + Self { + map_id: value.0.map_id, + user_id: value.0.user_id, + user_name: value.1.username, + } + } +} diff --git a/backend/src/model/dto/new_map_collaborator_impl.rs b/backend/src/model/dto/new_map_collaborator_impl.rs new file mode 100644 index 000000000..0680d2dde --- /dev/null +++ b/backend/src/model/dto/new_map_collaborator_impl.rs @@ -0,0 +1,15 @@ +use chrono::Utc; + +use crate::model::entity::MapCollaborator; + +use super::NewMapCollaboratorDto; + +impl From for MapCollaborator { + fn from(new_map_collaborator: NewMapCollaboratorDto) -> Self { + Self { + map_id: new_map_collaborator.map_id, + user_id: new_map_collaborator.user_id, + created_at: Utc::now().naive_utc(), + } + } +} diff --git a/backend/src/model/entity.rs b/backend/src/model/entity.rs index 0d8ea5f9e..a1d659e76 100644 --- a/backend/src/model/entity.rs +++ b/backend/src/model/entity.rs @@ -4,6 +4,7 @@ pub mod base_layer_images_impl; pub mod blossoms_impl; pub mod guided_tours_impl; pub mod layer_impl; +pub mod map_collaborator_impl; pub mod map_impl; pub mod plant_layer; pub mod plantings; @@ -24,7 +25,8 @@ use postgis_diesel::types::Polygon; use uuid::Uuid; use crate::schema::{ - base_layer_images, blossoms, gained_blossoms, guided_tours, layers, maps, plants, seeds, users, + base_layer_images, blossoms, gained_blossoms, guided_tours, layers, map_collaborators, maps, + plants, seeds, users, }; use super::r#enum::experience::Experience; @@ -956,3 +958,12 @@ pub struct GainedBlossoms { /// The date on which the user gained this Blossom. pub gained_date: NaiveDate, } + +/// The [`MapCollaborator`] entity. +#[derive(Insertable, Identifiable, Queryable)] +#[diesel(primary_key(map_id, user_id), table_name = map_collaborators)] +pub struct MapCollaborator { + pub map_id: i32, + pub user_id: Uuid, + pub created_at: NaiveDateTime, +} diff --git a/backend/src/model/entity/map_collaborator_impl.rs b/backend/src/model/entity/map_collaborator_impl.rs new file mode 100644 index 000000000..368db087a --- /dev/null +++ b/backend/src/model/entity/map_collaborator_impl.rs @@ -0,0 +1,20 @@ +use diesel::{debug_query, pg::Pg, QueryResult}; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use log::debug; + +use crate::{model::dto::NewMapCollaboratorDto, schema::map_collaborators}; + +use super::MapCollaborator; + +impl MapCollaborator { + pub async fn create( + new_map_collaborator: NewMapCollaboratorDto, + conn: &mut AsyncPgConnection, + ) -> QueryResult { + let new_map_collaborator = MapCollaborator::from(new_map_collaborator); + + let query = diesel::insert_into(map_collaborators::table).values(&new_map_collaborator); + debug!("{}", debug_query::(&query)); + query.get_result::(conn).await.map(Into::into) + } +} diff --git a/backend/src/model/entity/plantings_impl.rs b/backend/src/model/entity/plantings_impl.rs index 1bb89b3c0..4364470c9 100644 --- a/backend/src/model/entity/plantings_impl.rs +++ b/backend/src/model/entity/plantings_impl.rs @@ -176,17 +176,15 @@ impl Planting { updates: Vec, conn: &mut AsyncPgConnection, ) -> Vec>> { - let mut futures = Vec::with_capacity(updates.len()); - - for update in updates { - let updated_planting = diesel::update(plantings::table.find(update.id)) - .set(update) - .get_result::(conn); - - futures.push(updated_planting); - } - - futures + // TODO: restrict concurrency + updates + .into_iter() + .map(|update| { + diesel::update(plantings::table.find(update.id)) + .set(update) + .get_result::(conn) + }) + .collect::>() } /// Delete the plantings from the database. diff --git a/backend/src/schema.patch b/backend/src/schema.patch index 88f0f4345..3027746fe 100644 --- a/backend/src/schema.patch +++ b/backend/src/schema.patch @@ -1,15 +1,12 @@ -diff --git a/backend/src/schema.rs b/backend/src/schema.rs 2023-07-20 -index 54f26f46..68427977 100644 ---- a/backend/src/schema.rs -+++ b/backend/src/schema.rs -@@ -10,20 +10,12 @@ pub mod sql_types { - pub struct ExternalSource; +--- src/schema.rs 2024-02-09 17:13:45 ++++ src/schema_tmp.rs 2024-02-09 17:14:30 +@@ -15,20 +15,12 @@ #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "fertility"))] pub struct Fertility; -- #[derive(diesel::sql_types::SqlType)] + #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "geography"))] - pub struct Geography; - @@ -17,14 +14,15 @@ index 54f26f46..68427977 100644 - #[diesel(postgres_type(name = "geometry"))] - pub struct Geometry; - - #[derive(diesel::sql_types::SqlType)] +- #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "growth_rate"))] pub struct GrowthRate; #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "herbaceous_or_woody"))] -@@ -100,16 +92,15 @@ diesel::table! { - is_alternative -> Bool, + pub struct HerbaceousOrWoody; +@@ -176,16 +168,15 @@ + created_at -> Timestamp, } } diff --git a/backend/src/service/map_collaborator.rs b/backend/src/service/map_collaborator.rs new file mode 100644 index 000000000..04f59bcf2 --- /dev/null +++ b/backend/src/service/map_collaborator.rs @@ -0,0 +1,49 @@ +use actix_http::StatusCode; +use actix_web::web::Data; +use uuid::Uuid; + +use crate::{ + config::data::AppDataInner, + error::ServiceError, + model::{ + dto::{MapCollaboratorDto, NewMapCollaboratorDto}, + entity::{Map, MapCollaborator}, + }, + service::users, +}; + +pub async fn create( + new_map_collaborator: NewMapCollaboratorDto, + map_id: i32, + user_id: Uuid, + app_data: &Data, +) -> Result { + if new_map_collaborator.map_id != map_id { + return Err(ServiceError::new( + StatusCode::BAD_REQUEST, + "Path and body map_id do not match".to_owned(), + )); + } + if new_map_collaborator.user_id == user_id { + return Err(ServiceError::new( + StatusCode::BAD_REQUEST, + "You cannot add yourself as a collaborator.".to_owned(), + )); + } + + let mut conn = app_data.pool.get().await?; + + let map = Map::find_by_id(new_map_collaborator.map_id, &mut conn).await?; + let collaborator = users::find_by_id(new_map_collaborator.user_id, app_data).await?; + + if map.owner_id != user_id { + return Err(ServiceError::new( + StatusCode::FORBIDDEN, + "You are not the owner of this map.".to_owned(), + )); + } + + let result = MapCollaborator::create(new_map_collaborator, &mut conn).await?; + + Ok(MapCollaboratorDto::from((result, collaborator))) +} diff --git a/backend/src/service/mod.rs b/backend/src/service/mod.rs index d61b1a1d5..9f30f8c05 100644 --- a/backend/src/service/mod.rs +++ b/backend/src/service/mod.rs @@ -5,6 +5,7 @@ pub mod blossoms; pub mod guided_tours; pub mod layer; pub mod map; +pub mod map_collaborator; pub mod plant_layer; pub mod plantings; pub mod plants; diff --git a/backend/src/service/users.rs b/backend/src/service/users.rs index 4e3951857..fb800c4cf 100644 --- a/backend/src/service/users.rs +++ b/backend/src/service/users.rs @@ -41,3 +41,23 @@ pub async fn find(app_data: &Data) -> Result, Service Ok(users) } + +/// Get a user by its id. +pub async fn find_by_id( + user_id: Uuid, + app_data: &Data, +) -> Result { + let user = app_data + .keycloak_api + .get_user_by_id(&app_data.http_client, user_id) + .await + .map_err(|e| { + log::error!("Error getting user data from Keycloak API: {e}"); + ServiceError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Error getting user data from Keycloak API".to_owned(), + ) + })?; + + Ok(user) +} diff --git a/doc/backend/06updating_schema_patch.md b/doc/backend/06updating_schema_patch.md index 8499c44ec..94464c6b3 100644 --- a/doc/backend/06updating_schema_patch.md +++ b/doc/backend/06updating_schema_patch.md @@ -2,7 +2,7 @@ This document explains how to update the `schema.patch` file used by diesel. -1. Remove the following line from `diesel.toml`: +1. Comment out the following line from `diesel.toml`: ```toml patch_file = "src/schema.patch" @@ -17,7 +17,11 @@ This document explains how to update the `schema.patch` file used by diesel. You should now have a generated `schema.rs` in the backend src folder. 3. Copy the `schema.rs` file e.g. to `schema_tmp.rs`. -4. Run `` diff src/schema.rs `src/schema_tmp.rs` -U6 `` in the backend folder and save the result to the `src/schema.patch` file. -5. Add `patch_file = "src/schema.patch"` to the `diesel.toml` again. + +4. Make the necessary changes to `schema_tmp.rs`. + +5. Run `diff -U6 src/schema.rs src/schema_tmp.rs > src/schema.patch` in the backend folder. + +6. Add `patch_file = "src/schema.patch"` to the `diesel.toml` again. From now on the newly generated patch file should be used by diesel. From a5f6a2a0ad951d844e0bbb0968cd5f5cc23f7be4 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Fri, 9 Feb 2024 23:49:41 +0100 Subject: [PATCH 10/41] feat: find all map collaborators --- backend/src/config/api_doc.rs | 1 + backend/src/config/data.rs | 49 +++++++++------- backend/src/config/routes.rs | 8 ++- backend/src/controller/base_layer_image.rs | 43 +++++++------- backend/src/controller/blossoms.rs | 17 ++---- backend/src/controller/guided_tours.rs | 29 +++------- backend/src/controller/layers.rs | 28 ++++------ backend/src/controller/map.rs | 55 +++++++++--------- backend/src/controller/map_collaborators.rs | 53 +++++++++++++++--- backend/src/controller/plant_layer.rs | 12 ++-- backend/src/controller/plantings.rs | 32 ++++++----- backend/src/controller/plants.rs | 26 ++++----- backend/src/controller/seed.rs | 48 ++++++++-------- backend/src/controller/sse.rs | 13 ++--- backend/src/controller/users.rs | 22 ++++---- backend/src/db/cronjobs.rs | 5 +- backend/src/keycloak_api/api.rs | 10 ++-- backend/src/main.rs | 20 +++++-- .../src/model/entity/map_collaborator_impl.rs | 11 +++- backend/src/service/base_layer_images.rs | 29 +++++----- backend/src/service/blossoms.rs | 7 +-- backend/src/service/guided_tours.rs | 18 +++--- backend/src/service/layer.rs | 26 ++++----- backend/src/service/map.rs | 41 +++++++------- backend/src/service/map_collaborator.rs | 56 ++++++++++++++++--- backend/src/service/plant_layer.rs | 11 ++-- backend/src/service/plantings.rs | 41 ++++++++------ backend/src/service/plants.rs | 27 ++++----- backend/src/service/seed.rs | 40 ++++++------- backend/src/service/users.rs | 56 ++++++++++++------- backend/src/test/util.rs | 20 +++---- 31 files changed, 463 insertions(+), 391 deletions(-) diff --git a/backend/src/config/api_doc.rs b/backend/src/config/api_doc.rs index 9f2f0ba6f..6031890f3 100644 --- a/backend/src/config/api_doc.rs +++ b/backend/src/config/api_doc.rs @@ -260,6 +260,7 @@ struct TimelineApiDoc; #[openapi( paths( map_collaborators::create, + map_collaborators::find, ), components( schemas( diff --git a/backend/src/config/data.rs b/backend/src/config/data.rs index 1cbaac112..4d8d7a0be 100644 --- a/backend/src/config/data.rs +++ b/backend/src/config/data.rs @@ -1,39 +1,46 @@ -//! Configurations for the app data that is available to all controllers. +//! Configurations for shared data that is available to all controllers. use actix_web::web::Data; use crate::config::app::Config; use crate::db::connection; -use crate::keycloak_api::api::Api; +use crate::keycloak_api; use crate::sse::broadcaster::Broadcaster; -/// Data available to all controllers. -pub struct AppDataInner { +/// Helper-Type - Connection pool to the database. +pub type SharedPool = Data; + +/// Helper-Type - Server-Sent Events broadcaster. +pub type SharedBroadcaster = Data; + +/// Helper-Type - Keycloak admin API. +pub type SharedKeycloakApi = Data; + +/// Helper-Type - Pooled HTTP client. +pub type SharedHttpClient = Data; + +/// Data-structure holding the initialized shared data across the application. +pub struct DataInit { /// Connection pool to the database. - pub pool: connection::Pool, + pub pool: SharedPool, /// Server-Sent Events broadcaster. - pub broadcaster: Broadcaster, + pub broadcaster: SharedBroadcaster, /// Keycloak admin API. - pub keycloak_api: Api, + pub keycloak_api: SharedKeycloakApi, /// Pooled HTTP client. - pub http_client: reqwest::Client, + pub http_client: SharedHttpClient, } -/// Initializes the app data that is available to all controllers. +/// Initializes shared data. /// /// # Panics /// If the database pool can not be initialized. #[must_use] -pub fn init(config: &Config) -> Data { - let pool = connection::init_pool(&config.database_url); - let broadcaster = Broadcaster::new(); - let keycloak_api = Api::new(&config); - let http_client = reqwest::Client::new(); - - Data::new(AppDataInner { - pool, - broadcaster, - keycloak_api, - http_client, - }) +pub fn init(config: &Config) -> DataInit { + DataInit { + pool: Data::new(connection::init_pool(&config.database_url)), + broadcaster: Data::new(Broadcaster::new()), + keycloak_api: Data::new(keycloak_api::api::Api::new(&config)), + http_client: Data::new(reqwest::Client::new()), + } } diff --git a/backend/src/config/routes.rs b/backend/src/config/routes.rs index 1d24393f4..79d5d067f 100644 --- a/backend/src/config/routes.rs +++ b/backend/src/config/routes.rs @@ -68,8 +68,12 @@ pub fn config(cfg: &mut web::ServiceConfig) { ), ), ) - .service(web::scope("/collaborators").service(map_collaborators::create)) - .service(timeline::get_timeline), + .service(timeline::get_timeline) + .service( + web::scope("/collaborators") + .service(map_collaborators::create) + .service(map_collaborators::find), + ), ), ) .service( diff --git a/backend/src/controller/base_layer_image.rs b/backend/src/controller/base_layer_image.rs index 0e4d42bb3..7b3975f08 100644 --- a/backend/src/controller/base_layer_image.rs +++ b/backend/src/controller/base_layer_image.rs @@ -2,11 +2,12 @@ use actix_web::{ delete, get, patch, post, - web::{Data, Json, Path}, + web::{Json, Path}, HttpResponse, Result, }; use uuid::Uuid; +use crate::config::data::{SharedBroadcaster, SharedPool}; use crate::{ config::auth::user_info::UserInfo, model::dto::{ @@ -14,11 +15,10 @@ use crate::{ Action, CreateBaseLayerImageActionPayload, DeleteBaseLayerImageActionPayload, UpdateBaseLayerImageActionPayload, }, - DeleteBaseLayerImageDto, + BaseLayerImageDto, DeleteBaseLayerImageDto, UpdateBaseLayerImageDto, }, + service::base_layer_images, }; -use crate::{config::data::AppDataInner, model::dto::BaseLayerImageDto}; -use crate::{model::dto::UpdateBaseLayerImageDto, service::base_layer_images}; /// Endpoint for listing and filtering `BaseLayerImage`. /// @@ -38,9 +38,9 @@ use crate::{model::dto::UpdateBaseLayerImageDto, service::base_layer_images}; ) )] #[get("")] -pub async fn find(path: Path<(i32, i32)>, app_data: Data) -> Result { +pub async fn find(path: Path<(i32, i32)>, pool: SharedPool) -> Result { let (_map_id, layer_id) = path.into_inner(); - let response = base_layer_images::find(&app_data, layer_id).await?; + let response = base_layer_images::find(&pool, layer_id).await?; Ok(HttpResponse::Ok().json(response)) } @@ -65,14 +65,14 @@ pub async fn find(path: Path<(i32, i32)>, app_data: Data) -> Resul pub async fn create( path: Path, json: Json, - app_data: Data, user_info: UserInfo, + pool: SharedPool, + broadcaster: SharedBroadcaster, ) -> Result { let create_dto = json.0; - let dto = base_layer_images::create(create_dto.clone(), &app_data).await?; + let dto = base_layer_images::create(create_dto.clone(), &pool).await?; - app_data - .broadcaster + broadcaster .broadcast( path.into_inner(), Action::CreateBaseLayerImage(CreateBaseLayerImageActionPayload::new( @@ -108,21 +108,18 @@ pub async fn create( pub async fn update( path: Path<(i32, Uuid)>, json: Json, - app_data: Data, user_info: UserInfo, + pool: SharedPool, + broadcaster: SharedBroadcaster, ) -> Result { let (map_id, base_layer_image_id) = path.into_inner(); let update_base_layer_image = json.0; - let dto = base_layer_images::update( - base_layer_image_id, - update_base_layer_image.clone(), - &app_data, - ) - .await?; + let dto = + base_layer_images::update(base_layer_image_id, update_base_layer_image.clone(), &pool) + .await?; - app_data - .broadcaster + broadcaster .broadcast( map_id, Action::UpdateBaseLayerImage(UpdateBaseLayerImageActionPayload::new( @@ -157,16 +154,16 @@ pub async fn update( pub async fn delete( path: Path<(i32, Uuid)>, json: Json, - app_data: Data, user_info: UserInfo, + pool: SharedPool, + broadcaster: SharedBroadcaster, ) -> Result { let (map_id, base_layer_image_id) = path.into_inner(); let delete_dto = json.0; - base_layer_images::delete_by_id(base_layer_image_id, &app_data).await?; + base_layer_images::delete_by_id(base_layer_image_id, &pool).await?; - app_data - .broadcaster + broadcaster .broadcast( map_id, Action::DeleteBaseLayerImage(DeleteBaseLayerImageActionPayload::new( diff --git a/backend/src/controller/blossoms.rs b/backend/src/controller/blossoms.rs index 83505c831..d82286d67 100644 --- a/backend/src/controller/blossoms.rs +++ b/backend/src/controller/blossoms.rs @@ -1,16 +1,9 @@ //! `Blossom` endpoints. -use actix_web::{ - post, - web::{Data, Json}, - HttpResponse, Result, -}; +use actix_web::{post, web::Json, HttpResponse, Result}; -use crate::{ - config::{auth::user_info::UserInfo, data::AppDataInner}, - model::dto::GainedBlossomsDto, - service, -}; +use crate::config::data::SharedPool; +use crate::{config::auth::user_info::UserInfo, model::dto::GainedBlossomsDto, service}; /// Endpoint for gaining a [`Blossom`](crate::model::entity::Blossom). /// @@ -30,8 +23,8 @@ use crate::{ pub async fn gain( gained_blossom_json: Json, user_info: UserInfo, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = service::blossoms::gain(gained_blossom_json.0, user_info.id, &app_data).await?; + let response = service::blossoms::gain(gained_blossom_json.0, user_info.id, &pool).await?; Ok(HttpResponse::Created().json(response)) } diff --git a/backend/src/controller/guided_tours.rs b/backend/src/controller/guided_tours.rs index 9f236f8bc..c08ceb67f 100644 --- a/backend/src/controller/guided_tours.rs +++ b/backend/src/controller/guided_tours.rs @@ -1,16 +1,9 @@ //! `GuidedTours` endpoints. -use actix_web::{ - get, patch, post, - web::{Data, Json}, - HttpResponse, Result, -}; +use actix_web::{get, patch, post, web::Json, HttpResponse, Result}; -use crate::{ - config::{auth::user_info::UserInfo, data::AppDataInner}, - model::dto::UpdateGuidedToursDto, - service, -}; +use crate::config::data::SharedPool; +use crate::{config::auth::user_info::UserInfo, model::dto::UpdateGuidedToursDto, service}; /// Endpoint for setting up a [`GuidedTours`](crate::model::entity::GuidedTours) object. /// @@ -26,8 +19,8 @@ use crate::{ ) )] #[post("")] -pub async fn setup(user_info: UserInfo, app_data: Data) -> Result { - let response = service::guided_tours::setup(user_info.id, &app_data).await?; +pub async fn setup(user_info: UserInfo, pool: SharedPool) -> Result { + let response = service::guided_tours::setup(user_info.id, &pool).await?; Ok(HttpResponse::Created().json(response)) } @@ -45,11 +38,8 @@ pub async fn setup(user_info: UserInfo, app_data: Data) -> Result< ) )] #[get("")] -pub async fn find_by_user( - user_info: UserInfo, - app_data: Data, -) -> Result { - let response = service::guided_tours::find_by_user(user_info.id, &app_data).await?; +pub async fn find_by_user(user_info: UserInfo, pool: SharedPool) -> Result { + let response = service::guided_tours::find_by_user(user_info.id, &pool).await?; Ok(HttpResponse::Ok().json(response)) } @@ -71,9 +61,8 @@ pub async fn find_by_user( pub async fn update( status_update_json: Json, user_info: UserInfo, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = - service::guided_tours::update(status_update_json.0, user_info.id, &app_data).await?; + let response = service::guided_tours::update(status_update_json.0, user_info.id, &pool).await?; Ok(HttpResponse::Ok().json(response)) } diff --git a/backend/src/controller/layers.rs b/backend/src/controller/layers.rs index b584c8b4b..39a6cb762 100644 --- a/backend/src/controller/layers.rs +++ b/backend/src/controller/layers.rs @@ -2,12 +2,12 @@ use actix_web::{ delete, get, post, - web::{Data, Json, Path, Query}, + web::{Json, Path, Query}, HttpResponse, Result, }; -use crate::{config::data::AppDataInner, model::dto::LayerSearchParameters}; -use crate::{model::dto::NewLayerDto, service::layer}; +use crate::model::dto::NewLayerDto; +use crate::{config::data::SharedPool, model::dto::LayerSearchParameters, service::layer}; /// Endpoint for searching layers. /// @@ -30,12 +30,12 @@ use crate::{model::dto::NewLayerDto, service::layer}; pub async fn find( search_query: Query, map_id: Path, - app_data: Data, + pool: SharedPool, ) -> Result { let mut search_params = search_query.into_inner(); search_params.map_id = Some(map_id.into_inner()); - let response = layer::find(search_params, &app_data).await?; + let response = layer::find(search_params, &pool).await?; Ok(HttpResponse::Ok().json(response)) } @@ -56,12 +56,9 @@ pub async fn find( ) )] #[get("/{id}")] -pub async fn find_by_id( - path: Path<(i32, i32)>, - app_data: Data, -) -> Result { +pub async fn find_by_id(path: Path<(i32, i32)>, pool: SharedPool) -> Result { let (_, id) = path.into_inner(); - let response = layer::find_by_id(id, &app_data).await?; + let response = layer::find_by_id(id, &pool).await?; Ok(HttpResponse::Ok().json(response)) } @@ -83,11 +80,8 @@ pub async fn find_by_id( ) )] #[post("")] -pub async fn create( - new_layer: Json, - app_data: Data, -) -> Result { - let dto = layer::create(new_layer.0, &app_data).await?; +pub async fn create(new_layer: Json, pool: SharedPool) -> Result { + let dto = layer::create(new_layer.0, &pool).await?; Ok(HttpResponse::Created().json(dto)) } @@ -108,8 +102,8 @@ pub async fn create( ) )] #[delete("/{id}")] -pub async fn delete(path: Path<(i32, i32)>, app_data: Data) -> Result { +pub async fn delete(path: Path<(i32, i32)>, pool: SharedPool) -> Result { let (_, layer_id) = path.into_inner(); - layer::delete_by_id(layer_id, &app_data).await?; + layer::delete_by_id(layer_id, &pool).await?; Ok(HttpResponse::Ok().finish()) } diff --git a/backend/src/controller/map.rs b/backend/src/controller/map.rs index e267447df..e935e39c4 100644 --- a/backend/src/controller/map.rs +++ b/backend/src/controller/map.rs @@ -3,16 +3,20 @@ use actix_web::web::Query; use actix_web::{ get, patch, post, - web::{Data, Json, Path}, + web::{Json, Path}, HttpResponse, Result, }; use uuid::Uuid; -use crate::config::auth::user_info::UserInfo; -use crate::config::data::AppDataInner; -use crate::model::dto::actions::{Action, UpdateMapGeometryActionPayload}; -use crate::model::dto::{MapSearchParameters, PageParameters, UpdateMapDto, UpdateMapGeometryDto}; -use crate::{model::dto::NewMapDto, service}; +use crate::config::data::SharedBroadcaster; +use crate::{ + config::{auth::user_info::UserInfo, data::SharedPool}, + model::dto::{ + actions::{Action, UpdateMapGeometryActionPayload}, + MapSearchParameters, NewMapDto, PageParameters, UpdateMapDto, UpdateMapGeometryDto, + }, + service, +}; /// Endpoint for fetching or searching all [`Map`](crate::model::entity::Map). /// Search parameters are taken from the URLs query string (e.g. .../api/maps?is_inactive=false&per_page=5). @@ -37,14 +41,10 @@ use crate::{model::dto::NewMapDto, service}; pub async fn find( search_query: Query, page_query: Query, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = service::map::find( - search_query.into_inner(), - page_query.into_inner(), - &app_data, - ) - .await?; + let response = + service::map::find(search_query.into_inner(), page_query.into_inner(), &pool).await?; Ok(HttpResponse::Ok().json(response)) } @@ -62,8 +62,8 @@ pub async fn find( ) )] #[get("/{map_id}")] -pub async fn find_by_id(map_id: Path, app_data: Data) -> Result { - let response = service::map::find_by_id(*map_id, &app_data).await?; +pub async fn find_by_id(map_id: Path, pool: SharedPool) -> Result { + let response = service::map::find_by_id(*map_id, &pool).await?; Ok(HttpResponse::Ok().json(response)) } @@ -85,9 +85,9 @@ pub async fn find_by_id(map_id: Path, app_data: Data) -> Resu pub async fn create( new_map_json: Json, user_info: UserInfo, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = service::map::create(new_map_json.0, user_info.id, &app_data).await?; + let response = service::map::create(new_map_json.0, user_info.id, &pool).await?; Ok(HttpResponse::Created().json(response)) } @@ -110,15 +110,10 @@ pub async fn update( map_update_json: Json, map_id: Path, user_info: UserInfo, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = service::map::update( - map_update_json.0, - map_id.into_inner(), - user_info.id, - &app_data, - ) - .await?; + let response = + service::map::update(map_update_json.0, map_id.into_inner(), user_info.id, &pool).await?; Ok(HttpResponse::Ok().json(response)) } /// Endpoint for updating the [´Geometry`] of a [`Map`](crate::model::entity::Map). @@ -140,20 +135,20 @@ pub async fn update_geometry( map_update_geometry_json: Json, map_id: Path, user_info: UserInfo, - app_data: Data, + pool: SharedPool, + broadcaster: SharedBroadcaster, ) -> Result { let map_id_inner = map_id.into_inner(); - let response = service::map::update_geomtery( + let response = service::map::update_geometry( map_update_geometry_json.0.clone(), map_id_inner, user_info.id, - &app_data, + &pool, ) .await?; - app_data - .broadcaster + broadcaster .broadcast( map_id_inner, Action::UpdateMapGeometry(UpdateMapGeometryActionPayload::new( diff --git a/backend/src/controller/map_collaborators.rs b/backend/src/controller/map_collaborators.rs index ee7236258..0e977325a 100644 --- a/backend/src/controller/map_collaborators.rs +++ b/backend/src/controller/map_collaborators.rs @@ -1,18 +1,53 @@ use actix_web::{ - post, - web::{Data, Json, Path}, + get, post, + web::{Json, Path}, HttpResponse, Result, }; use crate::{ - config::{auth::user_info::UserInfo, data::AppDataInner}, + config::{ + auth::user_info::UserInfo, + data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, + }, model::dto::NewMapCollaboratorDto, - service, + service::map_collaborator, }; + +/// Endpoint for getting all [`MapCollaborator`](crate::model::entity::MapCollaborator)s of a map. +/// +/// # Errors +/// * If the connection to the database could not be established. +/// * If the connection to the Keycloak API could not be established. +#[utoipa::path( + context_path = "/api/maps/{map_id}/collaborators", + params( + ("map_id" = i32, Path, description = "The id of the map on which to collaborate"), + ), + responses( + (status = 200, description = "The collaborators of this map", body = Vec), + ), + security( + ("oauth2" = []) + ) +)] +#[get("")] +pub async fn find( + map_id: Path, + pool: SharedPool, + keycloak_api: SharedKeycloakApi, + http_client: SharedHttpClient, +) -> Result { + let response = + map_collaborator::get_all(map_id.into_inner(), &pool, &keycloak_api, &http_client).await?; + + Ok(HttpResponse::Ok().json(response)) +} + /// Endpoint for creating a new [`MapCollaborator`](crate::model::entity::MapCollaborator). /// /// # Errors /// * If the connection to the database could not be established. +/// * If the connection to the Keycloak API could not be established. #[utoipa::path( context_path = "/api/maps/{map_id}/collaborators", params( @@ -31,13 +66,17 @@ pub async fn create( json: Json, map_id: Path, user_info: UserInfo, - app_data: Data, + pool: SharedPool, + keycloak_api: SharedKeycloakApi, + http_client: SharedHttpClient, ) -> Result { - let response = service::map_collaborator::create( + let response = map_collaborator::create( json.into_inner(), map_id.into_inner(), user_info.id, - &app_data, + &pool, + &keycloak_api, + &http_client, ) .await?; diff --git a/backend/src/controller/plant_layer.rs b/backend/src/controller/plant_layer.rs index b0aedc271..62870f2cf 100644 --- a/backend/src/controller/plant_layer.rs +++ b/backend/src/controller/plant_layer.rs @@ -2,12 +2,12 @@ use actix_web::{ get, - web::{Data, Path, Query}, + web::{Path, Query}, HttpResponse, Result, }; use crate::{ - config::data::AppDataInner, + config::data::SharedPool, model::dto::{HeatMapQueryParams, RelationSearchParameters}, service::plant_layer, }; @@ -75,10 +75,10 @@ use crate::{ pub async fn heatmap( query_params: Query, map_id: Path, - app_data: Data, + pool: SharedPool, ) -> Result { let response = - plant_layer::heatmap(map_id.into_inner(), query_params.into_inner(), &app_data).await?; + plant_layer::heatmap(map_id.into_inner(), query_params.into_inner(), &pool).await?; Ok(HttpResponse::Ok().content_type("image/png").body(response)) } @@ -102,8 +102,8 @@ pub async fn heatmap( #[get("/relations")] pub async fn find_relations( search_query: Query, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = plant_layer::find_relations(search_query.into_inner(), &app_data).await?; + let response = plant_layer::find_relations(search_query.into_inner(), &pool).await?; Ok(HttpResponse::Ok().json(response)) } diff --git a/backend/src/controller/plantings.rs b/backend/src/controller/plantings.rs index 8b12c669b..ab80b195c 100644 --- a/backend/src/controller/plantings.rs +++ b/backend/src/controller/plantings.rs @@ -2,12 +2,13 @@ use actix_web::{ delete, get, patch, post, - web::{Data, Json, Path, Query}, + web::{Json, Path, Query}, HttpResponse, Result, }; +use crate::config::data::{SharedBroadcaster, SharedPool}; use crate::{ - config::{auth::user_info::UserInfo, data::AppDataInner}, + config::auth::user_info::UserInfo, model::dto::{ actions::Action, core::ActionDtoWrapper, @@ -41,9 +42,9 @@ pub async fn find( // So clients need to provide the map_id and it is checked. _map_id: Path, search_params: Query, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = plantings::find(search_params.into_inner(), &app_data).await?; + let response = plantings::find(search_params.into_inner(), &pool).await?; Ok(HttpResponse::Ok().json(response)) } @@ -68,17 +69,17 @@ pub async fn find( pub async fn create( path: Path, new_plantings: Json>>, - app_data: Data, user_info: UserInfo, + pool: SharedPool, + broadcaster: SharedBroadcaster, ) -> Result { let map_id = path.into_inner(); let ActionDtoWrapper { action_id, dto } = new_plantings.into_inner(); - let created_plantings = plantings::create(dto, &app_data).await?; + let created_plantings = plantings::create(dto, &pool).await?; - app_data - .broadcaster + broadcaster .broadcast( map_id, Action::new_create_planting_action(&created_plantings, user_info.id, action_id), @@ -109,14 +110,15 @@ pub async fn create( pub async fn update( path: Path, update_planting: Json>, - app_data: Data, user_info: UserInfo, + pool: SharedPool, + broadcaster: SharedBroadcaster, ) -> Result { let map_id = path.into_inner(); let ActionDtoWrapper { action_id, dto } = update_planting.into_inner(); - let updated_plantings = plantings::update(dto.clone(), &app_data).await?; + let updated_plantings = plantings::update(dto.clone(), &pool).await?; let action = match &dto { UpdatePlantingDto::Transform(dto) => { @@ -136,7 +138,7 @@ pub async fn update( } }; - app_data.broadcaster.broadcast(map_id, action).await; + broadcaster.broadcast(map_id, action).await; Ok(HttpResponse::Ok().json(updated_plantings)) } @@ -162,17 +164,17 @@ pub async fn update( pub async fn delete( path: Path, delete_planting: Json>>, - app_data: Data, user_info: UserInfo, + pool: SharedPool, + broadcaster: SharedBroadcaster, ) -> Result { let map_id = path.into_inner(); let ActionDtoWrapper { action_id, dto } = delete_planting.into_inner(); - plantings::delete_by_ids(dto.clone(), &app_data).await?; + plantings::delete_by_ids(dto.clone(), &pool).await?; - app_data - .broadcaster + broadcaster .broadcast( map_id, Action::new_delete_planting_action(&dto, user_info.id, action_id), diff --git a/backend/src/controller/plants.rs b/backend/src/controller/plants.rs index 4a36bb76c..3ebacfca7 100644 --- a/backend/src/controller/plants.rs +++ b/backend/src/controller/plants.rs @@ -1,19 +1,20 @@ //! `Plants` endpoints. -use crate::config::data::AppDataInner; -use crate::model::dto::{PageParameters, PlantsSearchParameters}; -use crate::service::plants; - use actix_web::{ get, - web::{Data, Path, Query}, + web::{Path, Query}, HttpResponse, Result, }; +use crate::{ + config::data::SharedPool, + model::dto::{PageParameters, PlantsSearchParameters}, + service::plants, +}; + /// Endpoint for fetching or searching [`PlantsSummaryDto`](crate::model::dto::PlantsSummaryDto). /// Search parameters are taken from the URLs query string (e.g. .../api/plants?name=example&per_page=5). /// If no page parameters are provided, the first page is returned. -/// If no page parameters are provided, the first page is returned. /// /// # Errors /// * If the connection to the database could not be established. @@ -34,14 +35,9 @@ use actix_web::{ pub async fn find( search_query: Query, page_query: Query, - app_data: Data, + pool: SharedPool, ) -> Result { - let payload = plants::find( - search_query.into_inner(), - page_query.into_inner(), - &app_data, - ) - .await?; + let payload = plants::find(search_query.into_inner(), page_query.into_inner(), &pool).await?; Ok(HttpResponse::Ok().json(payload)) } @@ -59,7 +55,7 @@ pub async fn find( ) )] #[get("/{id}")] -pub async fn find_by_id(id: Path, app_data: Data) -> Result { - let response = plants::find_by_id(*id, &app_data).await?; +pub async fn find_by_id(id: Path, pool: SharedPool) -> Result { + let response = plants::find_by_id(*id, &pool).await?; Ok(HttpResponse::Ok().json(response)) } diff --git a/backend/src/controller/seed.rs b/backend/src/controller/seed.rs index d6f7d8f85..14476b547 100644 --- a/backend/src/controller/seed.rs +++ b/backend/src/controller/seed.rs @@ -3,17 +3,20 @@ use actix_web::web::Query; use actix_web::{ delete, get, patch, post, put, - web::{Data, Json, Path}, + web::{Json, Path}, HttpResponse, Result, }; use uuid::Uuid; -use crate::config::auth::user_info::UserInfo; -use crate::config::data::AppDataInner; -use crate::model::dto::actions::Action; -use crate::model::dto::actions::UpdatePlantingAdditionalNamePayload; -use crate::model::dto::{PageParameters, SeedSearchParameters}; -use crate::{model::dto::ArchiveSeedDto, model::dto::NewSeedDto, service}; +use crate::config::data::{SharedBroadcaster, SharedPool}; +use crate::{ + config::auth::user_info::UserInfo, + model::dto::{ + actions::{Action, UpdatePlantingAdditionalNamePayload}, + ArchiveSeedDto, NewSeedDto, PageParameters, SeedSearchParameters, + }, + service, +}; /// Endpoint for fetching all [`SeedDto`](crate::model::dto::SeedDto). /// If no page parameters are provided, the first page is returned. @@ -41,14 +44,14 @@ use crate::{model::dto::ArchiveSeedDto, model::dto::NewSeedDto, service}; pub async fn find( search_query: Query, page_query: Query, - app_data: Data, user_info: UserInfo, + pool: SharedPool, ) -> Result { let response = service::seed::find( search_query.into_inner(), page_query.into_inner(), user_info.id, - &app_data, + &pool, ) .await?; Ok(HttpResponse::Ok().json(response)) @@ -70,10 +73,10 @@ pub async fn find( #[get("/{id}")] pub async fn find_by_id( id: Path, - app_data: Data, user_info: UserInfo, + pool: SharedPool, ) -> Result { - let response = service::seed::find_by_id(*id, user_info.id, &app_data).await?; + let response = service::seed::find_by_id(*id, user_info.id, &pool).await?; Ok(HttpResponse::Ok().json(response)) } @@ -94,10 +97,10 @@ pub async fn find_by_id( #[post("")] pub async fn create( new_seed_json: Json, - app_data: Data, user_info: UserInfo, + pool: SharedPool, ) -> Result { - let response = service::seed::create(new_seed_json.0, user_info.id, &app_data).await?; + let response = service::seed::create(new_seed_json.0, user_info.id, &pool).await?; Ok(HttpResponse::Created().json(response)) } @@ -117,10 +120,10 @@ pub async fn create( #[delete("/{id}")] pub async fn delete_by_id( path: Path, - app_data: Data, user_info: UserInfo, + pool: SharedPool, ) -> Result { - service::seed::delete_by_id(*path, user_info.id, &app_data).await?; + service::seed::delete_by_id(*path, user_info.id, &pool).await?; Ok(HttpResponse::Ok().json("")) } @@ -133,14 +136,14 @@ pub async fn edit_by_id( id: Path, edit_seed_json: Json, user_info: UserInfo, - app_data: Data, + pool: SharedPool, + broadcaster: SharedBroadcaster, ) -> Result { - let response = service::seed::edit(*id, user_info.id, edit_seed_json.0, &app_data).await?; - let affected_plantings = service::plantings::find_by_seed_id(*id, &app_data); + let response = service::seed::edit(*id, user_info.id, edit_seed_json.0, &pool).await?; + let affected_plantings = service::plantings::find_by_seed_id(*id, &pool); for planting in affected_plantings.await? { - app_data - .broadcaster + broadcaster .broadcast_all_maps(Action::UpdatePlantingAdditionalName( UpdatePlantingAdditionalNamePayload::new( &planting, @@ -165,9 +168,8 @@ pub async fn archive( id: Path, archive_seed_json: Json, user_info: UserInfo, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = - service::seed::archive(*id, user_info.id, archive_seed_json.0, &app_data).await?; + let response = service::seed::archive(*id, user_info.id, archive_seed_json.0, &pool).await?; Ok(HttpResponse::Accepted().json(response)) } diff --git a/backend/src/controller/sse.rs b/backend/src/controller/sse.rs index 5e132ea30..af4dc7e9d 100644 --- a/backend/src/controller/sse.rs +++ b/backend/src/controller/sse.rs @@ -1,20 +1,15 @@ //! Server-Sent Events controller -use actix_web::{ - get, - web::{Data, Query}, - Responder, -}; +use actix_web::{get, web::Query, Responder}; -use crate::config::data::AppDataInner; -use crate::model::dto::ConnectToMapQueryParams; +use crate::{config::data::SharedBroadcaster, model::dto::ConnectToMapQueryParams}; /// Create a new SSE client. #[get("")] pub async fn connect_to_map( query: Query, - state: Data, + broadcaster: SharedBroadcaster, ) -> impl Responder { let query = query.into_inner(); - state.broadcaster.new_client(query.map_id).await + broadcaster.new_client(query.map_id).await } diff --git a/backend/src/controller/users.rs b/backend/src/controller/users.rs index 896986b32..a80ef5663 100644 --- a/backend/src/controller/users.rs +++ b/backend/src/controller/users.rs @@ -1,13 +1,12 @@ //! `Users` endpoints. -use actix_web::{ - get, post, - web::{Data, Json}, - HttpResponse, Result, -}; +use actix_web::{get, post, web::Json, HttpResponse, Result}; use crate::{ - config::{auth::user_info::UserInfo, data::AppDataInner}, + config::{ + auth::user_info::UserInfo, + data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, + }, model::dto::UsersDto, service, }; @@ -23,8 +22,11 @@ use crate::{ ) )] #[get("")] -pub async fn find(app_data: Data) -> Result { - let response = service::users::find(&app_data).await?; +pub async fn find( + keycloak_api: SharedKeycloakApi, + http_client: SharedHttpClient, +) -> Result { + let response = service::users::find(&keycloak_api, &http_client).await?; Ok(HttpResponse::Ok().json(response)) } @@ -45,8 +47,8 @@ pub async fn find(app_data: Data) -> Result { pub async fn create( user_info: UserInfo, user_data_json: Json, - app_data: Data, + pool: SharedPool, ) -> Result { - let response = service::users::create(user_info.id, user_data_json.0, &app_data).await?; + let response = service::users::create(user_info.id, user_data_json.0, &pool).await?; Ok(HttpResponse::Created().json(response)) } diff --git a/backend/src/db/cronjobs.rs b/backend/src/db/cronjobs.rs index a6a8a7d8d..a0463c555 100644 --- a/backend/src/db/cronjobs.rs +++ b/backend/src/db/cronjobs.rs @@ -1,13 +1,14 @@ //! Scheduled tasks for the database. +use crate::db::connection::Pool; use chrono::{Days, Utc}; use diesel::{debug_query, pg::Pg, QueryDsl}; use diesel::{BoolExpressionMethods, ExpressionMethods}; use diesel_async::RunQueryDsl; use log::debug; +use std::sync::Arc; use std::time::Duration; -use super::connection::Pool; use crate::schema::maps; /// How often the deleted maps are cleaned up in seconds. @@ -15,7 +16,7 @@ const CLEANUP_MAPS_INTERVAL: u64 = 60 * 60 * 24; /// Permanently remove deleted maps older than 30 days from the database. /// Runs every [`CLEANUP_MAPS_INTERVAL`] seconds. -pub async fn cleanup_maps(pool: Pool) -> ! { +pub async fn cleanup_maps(pool: Arc) -> ! { loop { tokio::time::sleep(Duration::from_secs(CLEANUP_MAPS_INTERVAL)).await; diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 23329ee5c..176bd72c2 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -1,5 +1,6 @@ //! This module contains the implementation of the client for the keycloak admin API. +use std::sync::Arc; use std::time::Instant; use actix_web::cookie::time::Duration; @@ -36,7 +37,7 @@ pub struct Api { base_url: Url, /// Cached access token (needs to be thread safe). /// Might be expired, in which case it will be refreshed. - auth_data: Mutex>, + auth_data: Arc>>, } /// Helper struct to cache the access token and its expiration time. @@ -73,7 +74,7 @@ impl Api { username: config.keycloak_username.clone(), password: config.keycloak_password.clone(), base_url, - auth_data: Mutex::new(None), + auth_data: Arc::new(Mutex::new(None)), } } @@ -101,7 +102,6 @@ impl Api { let x = stream::iter(user_ids) .map(|id| { let client = client.clone(); - // TODO: we probably need to move from this OO style to a more functional style let api = self.clone(); tokio::spawn(async move { api.get_user_by_id(&client, id).await }) }) @@ -111,7 +111,7 @@ impl Api { .map(|res| match res { Ok(Ok(user)) => Ok(user), Ok(Err(e)) => Err(e), - Err(e) => return Err(KeycloakApiError::Other(e.to_string())), + Err(e) => Err(KeycloakApiError::Other(e.to_string())), }) .collect::>() .await @@ -138,7 +138,7 @@ impl Api { /// Executes a get request authenticated with the access token. async fn get(&self, client: &reqwest::Client, path: &str) -> Result { - let url = reqwest::Url::parse(&format!("{}{}", self.base_url, path))?; + let url = Url::parse(&format!("{}{}", self.base_url, path))?; let mut request = reqwest::Request::new(reqwest::Method::GET, url); let token = self.get_or_refresh_access_token(client).await?; diff --git a/backend/src/main.rs b/backend/src/main.rs index 821c27405..750a5b9d4 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -51,9 +51,13 @@ use actix_cors::Cors; use actix_web::{http, middleware::Logger, App, HttpServer}; +use std::sync::Arc; -use config::{api_doc, auth::Config, routes}; -use db::{connection::Pool, cronjobs::cleanup_maps}; +use crate::db::connection::Pool; +use crate::{ + config::{api_doc, auth::Config, routes}, + db::cronjobs::cleanup_maps, +}; pub mod config; pub mod controller; @@ -87,13 +91,17 @@ async fn main() -> std::io::Result<()> { config.bind_address.1 ); - let data = config::data::init(&config); - start_cronjobs(data.pool.clone()); + let data_init = config::data::init(&config); + let pool = data_init.pool.clone().into_inner(); + start_cronjobs(pool); HttpServer::new(move || { App::new() .wrap(cors_configuration()) - .app_data(data.clone()) + .app_data(data_init.pool.clone()) + .app_data(data_init.broadcaster.clone()) + .app_data(data_init.http_client.clone()) + .app_data(data_init.keycloak_api.clone()) .configure(routes::config) .configure(api_doc::config) .wrap(Logger::default()) @@ -122,6 +130,6 @@ fn cors_configuration() -> Cors { } /// Start all scheduled jobs that get run in the backend. -fn start_cronjobs(pool: Pool) { +fn start_cronjobs(pool: Arc) { tokio::spawn(cleanup_maps(pool)); } diff --git a/backend/src/model/entity/map_collaborator_impl.rs b/backend/src/model/entity/map_collaborator_impl.rs index 368db087a..21ea0394d 100644 --- a/backend/src/model/entity/map_collaborator_impl.rs +++ b/backend/src/model/entity/map_collaborator_impl.rs @@ -1,4 +1,4 @@ -use diesel::{debug_query, pg::Pg, QueryResult}; +use diesel::{debug_query, pg::Pg, ExpressionMethods, QueryDsl, QueryResult}; use diesel_async::{AsyncPgConnection, RunQueryDsl}; use log::debug; @@ -17,4 +17,13 @@ impl MapCollaborator { debug!("{}", debug_query::(&query)); query.get_result::(conn).await.map(Into::into) } + + pub async fn find_by_map_id( + map_id: i32, + conn: &mut AsyncPgConnection, + ) -> QueryResult> { + let query = map_collaborators::table.filter(map_collaborators::map_id.eq(map_id)); + debug!("{}", debug_query::(&query)); + query.get_results::(conn).await + } } diff --git a/backend/src/service/base_layer_images.rs b/backend/src/service/base_layer_images.rs index 8b287829f..6c75b7f53 100644 --- a/backend/src/service/base_layer_images.rs +++ b/backend/src/service/base_layer_images.rs @@ -1,22 +1,25 @@ //! Service layer for images on the base layer. -use actix_web::web::Data; use uuid::Uuid; -use crate::config::data::AppDataInner; -use crate::error::ServiceError; -use crate::model::dto::{BaseLayerImageDto, UpdateBaseLayerImageDto}; -use crate::model::entity::BaseLayerImages; +use crate::{ + config::data::SharedPool, + error::ServiceError, + model::{ + dto::{BaseLayerImageDto, UpdateBaseLayerImageDto}, + entity::BaseLayerImages, + }, +}; /// Fetch all base layer images for the layer from the database. /// /// # Errors /// If the connection to the database could not be established. pub async fn find( - app_data: &Data, + pool: &SharedPool, layer_id: i32, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = BaseLayerImages::find(&mut conn, layer_id).await?; Ok(result) } @@ -27,9 +30,9 @@ pub async fn find( /// If the connection to the database could not be established. pub async fn create( dto: BaseLayerImageDto, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = BaseLayerImages::create(dto, &mut conn).await?; Ok(result) } @@ -41,9 +44,9 @@ pub async fn create( pub async fn update( id: Uuid, dto: UpdateBaseLayerImageDto, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = BaseLayerImages::update(id, dto, &mut conn).await?; Ok(result) } @@ -52,8 +55,8 @@ pub async fn update( /// /// # Errors /// If the connection to the database could not be established. -pub async fn delete_by_id(id: Uuid, app_data: &Data) -> Result<(), ServiceError> { - let mut conn = app_data.pool.get().await?; +pub async fn delete_by_id(id: Uuid, pool: &SharedPool) -> Result<(), ServiceError> { + let mut conn = pool.get().await?; let _ = BaseLayerImages::delete_by_id(id, &mut conn).await?; Ok(()) } diff --git a/backend/src/service/blossoms.rs b/backend/src/service/blossoms.rs index 0223620b0..938d4a826 100644 --- a/backend/src/service/blossoms.rs +++ b/backend/src/service/blossoms.rs @@ -1,10 +1,9 @@ //! Service layer for blossoms. -use actix_web::web::Data; use uuid::Uuid; use crate::{ - config::data::AppDataInner, + config::data::SharedPool, error::ServiceError, model::{dto::GainedBlossomsDto, entity::GainedBlossoms}, }; @@ -16,9 +15,9 @@ use crate::{ pub async fn gain( gained_blossom: GainedBlossomsDto, user_id: Uuid, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = GainedBlossoms::create(gained_blossom, user_id, &mut conn).await?; Ok(result) } diff --git a/backend/src/service/guided_tours.rs b/backend/src/service/guided_tours.rs index cf2845b86..420ab628b 100644 --- a/backend/src/service/guided_tours.rs +++ b/backend/src/service/guided_tours.rs @@ -1,10 +1,9 @@ //! Service layer for guided tours. -use actix_web::web::Data; use uuid::Uuid; use crate::{ - config::data::AppDataInner, + config::data::SharedPool, error::ServiceError, model::{ dto::{GuidedToursDto, UpdateGuidedToursDto}, @@ -16,11 +15,8 @@ use crate::{ /// /// # Errors /// If the connection to the database could not be established. -pub async fn setup( - user_id: Uuid, - app_data: &Data, -) -> Result { - let mut conn = app_data.pool.get().await?; +pub async fn setup(user_id: Uuid, pool: &SharedPool) -> Result { + let mut conn = pool.get().await?; let result = GuidedTours::setup(user_id, &mut conn).await?; Ok(result) } @@ -32,9 +28,9 @@ pub async fn setup( /// If the connection to the database could not be established. pub async fn find_by_user( user_id: Uuid, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = GuidedTours::find_by_user(user_id, &mut conn).await; match result { Ok(result) => Ok(result), @@ -50,9 +46,9 @@ pub async fn find_by_user( pub async fn update( status_update: UpdateGuidedToursDto, user_id: Uuid, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = GuidedTours::update(status_update, user_id, &mut conn).await?; Ok(result) } diff --git a/backend/src/service/layer.rs b/backend/src/service/layer.rs index 065554d93..366e64d4f 100644 --- a/backend/src/service/layer.rs +++ b/backend/src/service/layer.rs @@ -1,13 +1,10 @@ //! Service layer for layers. -use actix_web::web::Data; - -use crate::config::data::AppDataInner; -use crate::model::dto::LayerSearchParameters; use crate::{ + config::data::SharedPool, error::ServiceError, model::{ - dto::{LayerDto, NewLayerDto}, + dto::{LayerDto, LayerSearchParameters, NewLayerDto}, entity::Layer, }, }; @@ -18,9 +15,9 @@ use crate::{ /// If the connection to the database could not be established. pub async fn find( search_parameters: LayerSearchParameters, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Layer::find(search_parameters, &mut conn).await?; Ok(result) } @@ -29,8 +26,8 @@ pub async fn find( /// /// # Errors /// If the connection to the database could not be established. -pub async fn find_by_id(id: i32, app_data: &Data) -> Result { - let mut conn = app_data.pool.get().await?; +pub async fn find_by_id(id: i32, pool: &SharedPool) -> Result { + let mut conn = pool.get().await?; let result = Layer::find_by_id(id, &mut conn).await?; Ok(result) } @@ -39,11 +36,8 @@ pub async fn find_by_id(id: i32, app_data: &Data) -> Result, -) -> Result { - let mut conn = app_data.pool.get().await?; +pub async fn create(new_layer: NewLayerDto, pool: &SharedPool) -> Result { + let mut conn = pool.get().await?; let result = Layer::create(new_layer, &mut conn).await?; Ok(result) } @@ -52,8 +46,8 @@ pub async fn create( /// /// # Errors /// If the connection to the database could not be established. -pub async fn delete_by_id(id: i32, app_data: &Data) -> Result<(), ServiceError> { - let mut conn = app_data.pool.get().await?; +pub async fn delete_by_id(id: i32, pool: &SharedPool) -> Result<(), ServiceError> { + let mut conn = pool.get().await?; let _ = Layer::delete_by_id(id, &mut conn).await?; Ok(()) } diff --git a/backend/src/service/map.rs b/backend/src/service/map.rs index bcb02683e..c9dc13fb1 100644 --- a/backend/src/service/map.rs +++ b/backend/src/service/map.rs @@ -1,22 +1,19 @@ //! Service layer for maps. use actix_http::StatusCode; -use actix_web::web::Data; use postgis_diesel::types::{Point, Polygon}; use uuid::Uuid; -use crate::config::data::AppDataInner; -use crate::model::dto::{ - BaseLayerImageDto, MapSearchParameters, Page, UpdateMapDto, UpdateMapGeometryDto, -}; -use crate::model::dto::{NewLayerDto, PageParameters}; -use crate::model::entity::{BaseLayerImages, Layer}; -use crate::model::r#enum::layer_type::LayerType; use crate::{ + config::data::SharedPool, error::ServiceError, model::{ - dto::{MapDto, NewMapDto}, - entity::Map, + dto::{ + BaseLayerImageDto, MapDto, MapSearchParameters, NewLayerDto, NewMapDto, Page, + PageParameters, UpdateMapDto, UpdateMapGeometryDto, + }, + entity::{BaseLayerImages, Layer, Map}, + r#enum::layer_type::LayerType, }, }; @@ -30,9 +27,9 @@ const LAYER_TYPES: [LayerType; 2] = [LayerType::Base, LayerType::Plants]; pub async fn find( search_parameters: MapSearchParameters, page_parameters: PageParameters, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Map::find(search_parameters, page_parameters, &mut conn).await?; Ok(result) } @@ -41,8 +38,8 @@ pub async fn find( /// /// # Errors /// If the connection to the database could not be established. -pub async fn find_by_id(id: i32, app_data: &Data) -> Result { - let mut conn = app_data.pool.get().await?; +pub async fn find_by_id(id: i32, pool: &SharedPool) -> Result { + let mut conn = pool.get().await?; let result = Map::find_by_id(id, &mut conn).await?; Ok(result) } @@ -54,9 +51,9 @@ pub async fn find_by_id(id: i32, app_data: &Data) -> Result, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let geometry_validation_result = is_valid_map_geometry(&new_map.geometry); if let Some(error) = geometry_validation_result { @@ -105,9 +102,9 @@ pub async fn update( map_update: UpdateMapDto, id: i32, user_id: Uuid, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let map = Map::find_by_id(id, &mut conn).await?; if map.owner_id != user_id { return Err(ServiceError { @@ -119,19 +116,19 @@ pub async fn update( Ok(result) } -/// Update a maps gemoetry in the database. +/// Update a maps geometry in the database. /// Checks if the map is owned by the requesting user. /// /// # Errors /// * If the connection to the database could not be established. /// * If the requesting user is not the owner of the map. -pub async fn update_geomtery( +pub async fn update_geometry( map_update_geometry: UpdateMapGeometryDto, id: i32, user_id: Uuid, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let map = Map::find_by_id(id, &mut conn).await?; if map.owner_id != user_id { return Err(ServiceError { diff --git a/backend/src/service/map_collaborator.rs b/backend/src/service/map_collaborator.rs index 04f59bcf2..114a85f04 100644 --- a/backend/src/service/map_collaborator.rs +++ b/backend/src/service/map_collaborator.rs @@ -1,9 +1,8 @@ use actix_http::StatusCode; -use actix_web::web::Data; use uuid::Uuid; use crate::{ - config::data::AppDataInner, + config::data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, error::ServiceError, model::{ dto::{MapCollaboratorDto, NewMapCollaboratorDto}, @@ -12,11 +11,43 @@ use crate::{ service::users, }; +/// Todo +/// +/// # Errors +pub async fn get_all( + map_id: i32, + pool: &SharedPool, + keycloak_api: &SharedKeycloakApi, + http_client: &SharedHttpClient, +) -> Result, ServiceError> { + let mut conn = pool.get().await?; + // Check if map exists + let _ = Map::find_by_id(map_id, &mut conn).await?; + + let collaborators = MapCollaborator::find_by_map_id(map_id, &mut conn).await?; + + let collaborator_ids = collaborators.iter().map(|c| c.user_id).collect::>(); + let users = users::find_by_ids(collaborator_ids, keycloak_api, http_client).await?; + + let dtos = collaborators + .into_iter() + .zip(users.into_iter()) + .map(|(c, u)| MapCollaboratorDto::from((c, u))) + .collect::>(); + + Ok(dtos) +} + +/// Todo +/// +/// # Errors pub async fn create( new_map_collaborator: NewMapCollaboratorDto, map_id: i32, user_id: Uuid, - app_data: &Data, + pool: &SharedPool, + keycloak_api: &SharedKeycloakApi, + http_client: &SharedHttpClient, ) -> Result { if new_map_collaborator.map_id != map_id { return Err(ServiceError::new( @@ -31,10 +62,9 @@ pub async fn create( )); } - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let map = Map::find_by_id(new_map_collaborator.map_id, &mut conn).await?; - let collaborator = users::find_by_id(new_map_collaborator.user_id, app_data).await?; if map.owner_id != user_id { return Err(ServiceError::new( @@ -43,7 +73,19 @@ pub async fn create( )); } - let result = MapCollaborator::create(new_map_collaborator, &mut conn).await?; + let current_collaborators = MapCollaborator::find_by_map_id(map_id, &mut conn).await?; + + if current_collaborators.len() >= 30 { + return Err(ServiceError::new( + StatusCode::BAD_REQUEST, + "A map can have at most 30 collaborators.".to_owned(), + )); + } + + let collaborator_user = + users::find_by_id(new_map_collaborator.user_id, keycloak_api, http_client).await?; + + let collaborator = MapCollaborator::create(new_map_collaborator, &mut conn).await?; - Ok(MapCollaboratorDto::from((result, collaborator))) + Ok(MapCollaboratorDto::from((collaborator, collaborator_user))) } diff --git a/backend/src/service/plant_layer.rs b/backend/src/service/plant_layer.rs index e1d67b2a7..5706d2369 100644 --- a/backend/src/service/plant_layer.rs +++ b/backend/src/service/plant_layer.rs @@ -3,11 +3,10 @@ use std::io::Cursor; use actix_http::StatusCode; -use actix_web::web::Data; use image::{ImageBuffer, Rgb}; use crate::{ - config::data::AppDataInner, + config::data::SharedPool, error::ServiceError, model::{ dto::{HeatMapQueryParams, RelationSearchParameters, RelationsDto}, @@ -27,9 +26,9 @@ use crate::{ pub async fn heatmap( map_id: i32, query_params: HeatMapQueryParams, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = plant_layer::heatmap( map_id, query_params.layer_id, @@ -78,9 +77,9 @@ fn matrix_to_image(matrix: &Vec>) -> Result, ServiceError> { /// * If the SQL query failed. pub async fn find_relations( search_query: RelationSearchParameters, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = plant_layer::find_relations(search_query, &mut conn).await?; Ok(result) } diff --git a/backend/src/service/plantings.rs b/backend/src/service/plantings.rs index 86ca42a68..38f01e31e 100644 --- a/backend/src/service/plantings.rs +++ b/backend/src/service/plantings.rs @@ -1,17 +1,22 @@ //! Service layer for plantings. use actix_http::StatusCode; -use actix_web::web::Data; use chrono::Days; -use crate::config::data::AppDataInner; -use crate::error::ServiceError; -use crate::model::dto::core::TimelinePage; -use crate::model::dto::plantings::{ - DeletePlantingDto, NewPlantingDto, PlantingDto, PlantingSearchParameters, UpdatePlantingDto, +use crate::{ + config::data::SharedPool, + error::ServiceError, + model::{ + dto::{ + core::TimelinePage, + plantings::{ + DeletePlantingDto, NewPlantingDto, PlantingDto, PlantingSearchParameters, + UpdatePlantingDto, + }, + }, + entity::{plantings::Planting, plantings_impl::FindPlantingsParameters}, + }, }; -use crate::model::entity::plantings::Planting; -use crate::model::entity::plantings_impl::FindPlantingsParameters; /// Time offset in days for loading plantings in the timeline. pub const TIME_LINE_LOADING_OFFSET_DAYS: u64 = 356; @@ -22,9 +27,9 @@ pub const TIME_LINE_LOADING_OFFSET_DAYS: u64 = 356; /// If the connection to the database could not be established. pub async fn find( search_parameters: PlantingSearchParameters, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let from = search_parameters .relative_to_date @@ -68,9 +73,9 @@ pub async fn find( /// If the connection to the database could not be established. pub async fn find_by_seed_id( seed_id: i32, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Planting::find_by_seed_id(seed_id, &mut conn).await?; Ok(result) } @@ -81,9 +86,9 @@ pub async fn find_by_seed_id( /// If the connection to the database could not be established. pub async fn create( dtos: Vec, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Planting::create(dtos, &mut conn).await?; Ok(result) } @@ -94,9 +99,9 @@ pub async fn create( /// If the connection to the database could not be established. pub async fn update( dto: UpdatePlantingDto, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Planting::update(dto, &mut conn).await?; Ok(result) } @@ -107,9 +112,9 @@ pub async fn update( /// If the connection to the database could not be established. pub async fn delete_by_ids( dtos: Vec, - app_data: &Data, + pool: &SharedPool, ) -> Result<(), ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let _ = Planting::delete_by_ids(dtos, &mut conn).await?; Ok(()) } diff --git a/backend/src/service/plants.rs b/backend/src/service/plants.rs index f4b807cb5..931d189e7 100644 --- a/backend/src/service/plants.rs +++ b/backend/src/service/plants.rs @@ -1,14 +1,12 @@ //! Service layer for plants. -use actix_web::web::Data; - -use crate::config::data::AppDataInner; -use crate::error::ServiceError; -use crate::model::dto::Page; -use crate::model::dto::PageParameters; -use crate::model::{ - dto::{PlantsSearchParameters, PlantsSummaryDto}, - entity::Plants, +use crate::{ + config::data::SharedPool, + error::ServiceError, + model::{ + dto::{Page, PageParameters, PlantsSearchParameters, PlantsSummaryDto}, + entity::Plants, + }, }; /// Search plants from in the database. @@ -18,9 +16,9 @@ use crate::model::{ pub async fn find( search_parameters: PlantsSearchParameters, page_parameters: PageParameters, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = match &search_parameters.name { // Empty search queries should be treated like nonexistent queries. Some(query) if !query.is_empty() => { @@ -36,11 +34,8 @@ pub async fn find( /// /// # Errors /// If the connection to the database could not be established. -pub async fn find_by_id( - id: i32, - app_data: &Data, -) -> Result { - let mut conn = app_data.pool.get().await?; +pub async fn find_by_id(id: i32, pool: &SharedPool) -> Result { + let mut conn = pool.get().await?; let result = Plants::find_by_id(id, &mut conn).await?; Ok(result) } diff --git a/backend/src/service/seed.rs b/backend/src/service/seed.rs index 0282d472c..87315cf77 100644 --- a/backend/src/service/seed.rs +++ b/backend/src/service/seed.rs @@ -1,17 +1,13 @@ //! Service layer for seeds. -use actix_web::web::Data; -use uuid::Uuid; - use chrono::Utc; +use uuid::Uuid; -use crate::config::data::AppDataInner; -use crate::model::dto::PageParameters; -use crate::model::dto::{Page, SeedSearchParameters}; use crate::{ + config::data::SharedPool, error::ServiceError, model::{ - dto::{ArchiveSeedDto, NewSeedDto, SeedDto}, + dto::{ArchiveSeedDto, NewSeedDto, Page, PageParameters, SeedDto, SeedSearchParameters}, entity::Seed, }, }; @@ -29,9 +25,9 @@ pub async fn find( search_parameters: SeedSearchParameters, page_parameters: PageParameters, user_id: Uuid, - app_data: &Data, + pool: &SharedPool, ) -> Result, ServiceError> { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Seed::find(search_parameters, user_id, page_parameters, &mut conn).await?; Ok(result) } @@ -43,9 +39,9 @@ pub async fn find( pub async fn find_by_id( id: i32, user_id: Uuid, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Seed::find_by_id(id, user_id, &mut conn).await?; Ok(result) } @@ -57,9 +53,9 @@ pub async fn find_by_id( pub async fn create( new_seed: NewSeedDto, user_id: Uuid, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let seed_trimmed_name = NewSeedDto { name: new_seed.name.trim().to_owned(), @@ -78,9 +74,9 @@ pub async fn edit( id: i32, user_id: Uuid, new_seed: NewSeedDto, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Seed::edit(id, user_id, new_seed, &mut conn).await?; Ok(result) } @@ -89,12 +85,8 @@ pub async fn edit( /// /// # Errors /// If the connection to the database could not be established. -pub async fn delete_by_id( - id: i32, - user_id: Uuid, - app_data: &Data, -) -> Result<(), ServiceError> { - let mut conn = app_data.pool.get().await?; +pub async fn delete_by_id(id: i32, user_id: Uuid, pool: &SharedPool) -> Result<(), ServiceError> { + let mut conn = pool.get().await?; let _ = Seed::delete_by_id(id, user_id, &mut conn).await?; Ok(()) } @@ -107,12 +99,12 @@ pub async fn archive( id: i32, user_id: Uuid, archive_seed: ArchiveSeedDto, - app_data: &Data, + pool: &SharedPool, ) -> Result { // Retrieve the seed before getting the db connection to avoid deadlocks when // fetching two database connections at the same time. - let current_seed = find_by_id(id, user_id, app_data).await?; - let mut conn = app_data.pool.get().await?; + let current_seed = find_by_id(id, user_id, pool).await?; + let mut conn = pool.get().await?; let current_naive_date_time = archive_seed.archived.then(|| Utc::now().naive_utc()); diff --git a/backend/src/service/users.rs b/backend/src/service/users.rs index fb800c4cf..f3c88656a 100644 --- a/backend/src/service/users.rs +++ b/backend/src/service/users.rs @@ -1,13 +1,12 @@ //! Service layer for user data. -use actix_web::web::Data; use reqwest::StatusCode; use uuid::Uuid; -use crate::keycloak_api::dtos::UserDto; use crate::{ - config::data::AppDataInner, + config::data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, error::ServiceError, + keycloak_api::dtos::UserDto, model::{dto::UsersDto, entity::Users}, }; @@ -18,18 +17,37 @@ use crate::{ pub async fn create( user_id: Uuid, user_data: UsersDto, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; let result = Users::create(user_data, user_id, &mut conn).await?; Ok(result) } /// Get all users. -pub async fn find(app_data: &Data) -> Result, ServiceError> { - let users = app_data - .keycloak_api - .get_users(&app_data.http_client) +pub async fn find( + keycloak_api: &SharedKeycloakApi, + http_client: &SharedHttpClient, +) -> Result, ServiceError> { + let users = keycloak_api.get_users(&http_client).await.map_err(|e| { + log::error!("Error getting user data from Keycloak API: {e}"); + ServiceError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Error getting user data from Keycloak API".to_owned(), + ) + })?; + + Ok(users) +} + +/// Get a user by its id. +pub async fn find_by_id( + user_id: Uuid, + keycloak_api: &SharedKeycloakApi, + http_client: &SharedHttpClient, +) -> Result { + let user = keycloak_api + .get_user_by_id(&http_client, user_id) .await .map_err(|e| { log::error!("Error getting user data from Keycloak API: {e}"); @@ -39,17 +57,17 @@ pub async fn find(app_data: &Data) -> Result, Service ) })?; - Ok(users) + Ok(user) } -/// Get a user by its id. -pub async fn find_by_id( - user_id: Uuid, - app_data: &Data, -) -> Result { - let user = app_data - .keycloak_api - .get_user_by_id(&app_data.http_client, user_id) +/// Get users by their ids. +pub async fn find_by_ids( + user_ids: Vec, + keycloak_api: &SharedKeycloakApi, + http_client: &SharedHttpClient, +) -> Result, ServiceError> { + let users = keycloak_api + .get_users_by_ids(&http_client, user_ids) .await .map_err(|e| { log::error!("Error getting user data from Keycloak API: {e}"); @@ -59,5 +77,5 @@ pub async fn find_by_id( ) })?; - Ok(user) + Ok(users) } diff --git a/backend/src/test/util.rs b/backend/src/test/util.rs index 279f49084..86c74c04d 100644 --- a/backend/src/test/util.rs +++ b/backend/src/test/util.rs @@ -15,11 +15,11 @@ use diesel_async::{ }; use dotenvy::dotenv; -use crate::error::ServiceError; -use crate::sse::broadcaster::Broadcaster; use crate::{ - config::{app, data::AppDataInner, routes}, + config::{app, routes}, + error::ServiceError, keycloak_api, + sse::broadcaster::Broadcaster, }; use self::token::{generate_token, generate_token_for_user}; @@ -100,14 +100,12 @@ async fn init_test_app_impl( ) -> impl Service, Error = Error> { test::init_service( App::new() - .app_data(Data::new(AppDataInner { - pool, - broadcaster: Broadcaster::new(), - keycloak_api: keycloak_api::api::Api::new( - &app::Config::from_env().expect("Error loading configuration"), - ), - http_client: reqwest::Client::new(), - })) + .app_data(Data::new(pool)) + .app_data(Data::new(Broadcaster::new())) + .app_data(Data::new(keycloak_api::api::Api::new( + &app::Config::from_env().expect("Error loading configuration"), + ))) + .app_data(Data::new(reqwest::Client::new())) .configure(routes::config), ) .await From 846c6d1e7fce22571384c4d5ccc559e5fa8e5ffa Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 10 Feb 2024 10:13:10 +0100 Subject: [PATCH 11/41] chore: fix some warnings, improve docs --- backend/src/config/auth/claims.rs | 6 +++--- backend/src/config/data.rs | 10 +++++----- backend/src/model/entity/map_collaborator_impl.rs | 10 +++++++++- backend/src/service/map_collaborator.rs | 12 ++++++++++-- backend/src/service/users.rs | 15 ++++++++++++--- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/backend/src/config/auth/claims.rs b/backend/src/config/auth/claims.rs index 3edd1381f..0a8b82469 100644 --- a/backend/src/config/auth/claims.rs +++ b/backend/src/config/auth/claims.rs @@ -66,19 +66,19 @@ mod test { fn test_simple_token_succeeds() { let jwk = init_auth(); let token = generate_token(jwk, 300); - assert!(Claims::validate(&token).is_ok()) + assert!(Claims::validate(&token).is_ok()); } #[test] fn test_expired_token_fails() { let jwk = init_auth(); let token = generate_token(jwk, -300); - assert!(Claims::validate(&token).is_err()) + assert!(Claims::validate(&token).is_err()); } #[test] fn test_invalid_token_fails() { let _ = init_auth(); - assert!(Claims::validate("not a token").is_err()) + assert!(Claims::validate("not a token").is_err()); } } diff --git a/backend/src/config/data.rs b/backend/src/config/data.rs index 4d8d7a0be..b1f115b55 100644 --- a/backend/src/config/data.rs +++ b/backend/src/config/data.rs @@ -19,8 +19,8 @@ pub type SharedKeycloakApi = Data; /// Helper-Type - Pooled HTTP client. pub type SharedHttpClient = Data; -/// Data-structure holding the initialized shared data across the application. -pub struct DataInit { +/// Data-structure holding the initialized shared data. +pub struct SharedInit { /// Connection pool to the database. pub pool: SharedPool, /// Server-Sent Events broadcaster. @@ -36,11 +36,11 @@ pub struct DataInit { /// # Panics /// If the database pool can not be initialized. #[must_use] -pub fn init(config: &Config) -> DataInit { - DataInit { +pub fn init(config: &Config) -> SharedInit { + SharedInit { pool: Data::new(connection::init_pool(&config.database_url)), broadcaster: Data::new(Broadcaster::new()), - keycloak_api: Data::new(keycloak_api::api::Api::new(&config)), + keycloak_api: Data::new(keycloak_api::api::Api::new(config)), http_client: Data::new(reqwest::Client::new()), } } diff --git a/backend/src/model/entity/map_collaborator_impl.rs b/backend/src/model/entity/map_collaborator_impl.rs index 21ea0394d..d89ac8f5d 100644 --- a/backend/src/model/entity/map_collaborator_impl.rs +++ b/backend/src/model/entity/map_collaborator_impl.rs @@ -7,17 +7,25 @@ use crate::{model::dto::NewMapCollaboratorDto, schema::map_collaborators}; use super::MapCollaborator; impl MapCollaborator { + /// Create a new map collaborator. + /// + /// # Errors + /// * Unknown, diesel doesn't say why it might error. pub async fn create( new_map_collaborator: NewMapCollaboratorDto, conn: &mut AsyncPgConnection, ) -> QueryResult { - let new_map_collaborator = MapCollaborator::from(new_map_collaborator); + let new_map_collaborator = Self::from(new_map_collaborator); let query = diesel::insert_into(map_collaborators::table).values(&new_map_collaborator); debug!("{}", debug_query::(&query)); query.get_result::(conn).await.map(Into::into) } + /// Find all map collaborators of a map. + /// + /// # Errors + /// * Unknown, diesel doesn't say why it might error. pub async fn find_by_map_id( map_id: i32, conn: &mut AsyncPgConnection, diff --git a/backend/src/service/map_collaborator.rs b/backend/src/service/map_collaborator.rs index 114a85f04..8561ec11f 100644 --- a/backend/src/service/map_collaborator.rs +++ b/backend/src/service/map_collaborator.rs @@ -11,9 +11,11 @@ use crate::{ service::users, }; -/// Todo +/// Get all collaborators for a map. /// /// # Errors +/// * If the connection to the database could not be established. +/// * If the connection to the Keycloak API could not be established. pub async fn get_all( map_id: i32, pool: &SharedPool, @@ -38,9 +40,15 @@ pub async fn get_all( Ok(dtos) } -/// Todo +/// Create a new collaborator for a map. /// /// # Errors +/// * If the path and body `map_id` do not match. +/// * If the user tries to add themselves as a collaborator. +/// * If the user is not the owner of the map. +/// * If the map already has 30 collaborators. +/// * If the connection to the database could not be established. +/// * If the connection to the Keycloak API could not be established. pub async fn create( new_map_collaborator: NewMapCollaboratorDto, map_id: i32, diff --git a/backend/src/service/users.rs b/backend/src/service/users.rs index f3c88656a..61a52f799 100644 --- a/backend/src/service/users.rs +++ b/backend/src/service/users.rs @@ -25,11 +25,14 @@ pub async fn create( } /// Get all users. +/// +/// # Errors +/// * If the connection to the Keycloak API could not be established. pub async fn find( keycloak_api: &SharedKeycloakApi, http_client: &SharedHttpClient, ) -> Result, ServiceError> { - let users = keycloak_api.get_users(&http_client).await.map_err(|e| { + let users = keycloak_api.get_users(http_client).await.map_err(|e| { log::error!("Error getting user data from Keycloak API: {e}"); ServiceError::new( StatusCode::INTERNAL_SERVER_ERROR, @@ -41,13 +44,16 @@ pub async fn find( } /// Get a user by its id. +/// +/// # Errors +/// * If the connection to the Keycloak API could not be established. pub async fn find_by_id( user_id: Uuid, keycloak_api: &SharedKeycloakApi, http_client: &SharedHttpClient, ) -> Result { let user = keycloak_api - .get_user_by_id(&http_client, user_id) + .get_user_by_id(http_client, user_id) .await .map_err(|e| { log::error!("Error getting user data from Keycloak API: {e}"); @@ -61,13 +67,16 @@ pub async fn find_by_id( } /// Get users by their ids. +/// +/// # Errors +/// * If the connection to the Keycloak API could not be established. pub async fn find_by_ids( user_ids: Vec, keycloak_api: &SharedKeycloakApi, http_client: &SharedHttpClient, ) -> Result, ServiceError> { let users = keycloak_api - .get_users_by_ids(&http_client, user_ids) + .get_users_by_ids(http_client, user_ids) .await .map_err(|e| { log::error!("Error getting user data from Keycloak API: {e}"); From dcf537be4d922507b475ead38caecb9f1cac48a2 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 10 Feb 2024 10:42:56 +0100 Subject: [PATCH 12/41] feat: search for users by their username --- backend/src/config/api_doc.rs | 15 ++++++++---- backend/src/controller/users.rs | 23 +++++++++++++++---- backend/src/keycloak_api/api.rs | 15 ++++++++++++ backend/src/keycloak_api/dtos.rs | 3 ++- backend/src/keycloak_api/mod.rs | 2 ++ backend/src/model/dto.rs | 14 ++++++++--- .../src/model/dto/map_collaborator_impl.rs | 2 +- backend/src/service/users.rs | 23 +++++++++++++++++++ 8 files changed, 83 insertions(+), 14 deletions(-) diff --git a/backend/src/config/api_doc.rs b/backend/src/config/api_doc.rs index 6031890f3..172bf4fd4 100644 --- a/backend/src/config/api_doc.rs +++ b/backend/src/config/api_doc.rs @@ -13,6 +13,7 @@ use crate::{ base_layer_image, blossoms, config, guided_tours, layers, map, map_collaborators, plant_layer, plantings, plants, seed, timeline, users, }, + keycloak_api, model::{ dto::{ core::{ @@ -32,8 +33,9 @@ use crate::{ UpdateMapDto, UsersDto, }, r#enum::{ - privacy_option::PrivacyOption, quality::Quality, quantity::Quantity, - relation_type::RelationType, + experience::Experience, membership::Membership, privacy_option::PrivacyOption, + quality::Quality, quantity::Quantity, relation_type::RelationType, + salutation::Salutation, }, }, }; @@ -195,11 +197,16 @@ struct PlantingsApiDoc; #[derive(OpenApi)] #[openapi( paths( - users::create + users::create, + users::find ), components( schemas( - UsersDto + keycloak_api::UserDto, + UsersDto, + Experience, + Membership, + Salutation ) ), modifiers(&SecurityAddon) diff --git a/backend/src/controller/users.rs b/backend/src/controller/users.rs index a80ef5663..2d2450514 100644 --- a/backend/src/controller/users.rs +++ b/backend/src/controller/users.rs @@ -1,21 +1,31 @@ //! `Users` endpoints. -use actix_web::{get, post, web::Json, HttpResponse, Result}; +use actix_web::{ + get, post, + web::{Json, Query}, + HttpResponse, Result, +}; use crate::{ config::{ auth::user_info::UserInfo, data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, }, - model::dto::UsersDto, + model::dto::{UserSearchParameters, UsersDto}, service, }; -/// Endpoint for getting users. +/// Endpoint for searching users. +/// +/// # Errors +/// * If the connection to the keycloak API could not be established. #[utoipa::path( context_path = "/api/users", + params( + UserSearchParameters, + ), responses( - (status = 200, description = "Find users", body = Vec) + (status = 200, description = "Users matching the username search", body = Vec) ), security( ("oauth2" = []) @@ -23,10 +33,13 @@ use crate::{ )] #[get("")] pub async fn find( + search_query: Query, keycloak_api: SharedKeycloakApi, http_client: SharedHttpClient, ) -> Result { - let response = service::users::find(&keycloak_api, &http_client).await?; + let response = + service::users::search_by_username(&search_query.username, &keycloak_api, &http_client) + .await?; Ok(HttpResponse::Ok().json(response)) } diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 176bd72c2..534b47537 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -88,6 +88,21 @@ impl Api { self.get::>(client, "/users").await } + /// Search for users by their username. + /// + /// # Errors + /// - If the url cannot be parsed. + /// - If the authorization header cannot be created. + /// - If the request fails or the response cannot be deserialized. + pub async fn search_users_by_username( + &self, + username: &str, + client: &reqwest::Client, + ) -> Result> { + self.get::>(client, &format!("/users?username={username}")) + .await + } + /// Gets all users given their ids from the Keycloak API. /// /// # Errors diff --git a/backend/src/keycloak_api/dtos.rs b/backend/src/keycloak_api/dtos.rs index eb99d224c..5dbfe5fb2 100644 --- a/backend/src/keycloak_api/dtos.rs +++ b/backend/src/keycloak_api/dtos.rs @@ -1,10 +1,11 @@ //! Dto types for keycloak admin API. use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use uuid::Uuid; /// Dto for a user. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserDto { /// The user's ID. pub id: Uuid, diff --git a/backend/src/keycloak_api/mod.rs b/backend/src/keycloak_api/mod.rs index f4016f628..4f56d89ba 100644 --- a/backend/src/keycloak_api/mod.rs +++ b/backend/src/keycloak_api/mod.rs @@ -3,3 +3,5 @@ pub mod api; pub mod dtos; pub mod errors; + +pub use dtos::UserDto; diff --git a/backend/src/model/dto.rs b/backend/src/model/dto.rs index 10c7dca59..5950df9d0 100644 --- a/backend/src/model/dto.rs +++ b/backend/src/model/dto.rs @@ -519,7 +519,7 @@ pub struct GainedBlossomsDto { } #[typeshare] -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Debug, Serialize, ToSchema)] /// Information on user collaborating on a map. pub struct MapCollaboratorDto { /// The id of the map. @@ -531,7 +531,7 @@ pub struct MapCollaboratorDto { } #[typeshare] -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema)] /// The information of a map collaborator necessary for its creation. pub struct NewMapCollaboratorDto { /// The id of the map. @@ -541,9 +541,17 @@ pub struct NewMapCollaboratorDto { } #[typeshare] -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, IntoParams)] /// Query params for searching map collaborators. pub struct MapCollaboratorSearchParameters { /// The id of the map. pub map_id: i32, } + +#[typeshare] +#[derive(Debug, Deserialize, IntoParams)] +/// Query params for searching users. +pub struct UserSearchParameters { + /// The name of the user to search for. + pub username: String, +} diff --git a/backend/src/model/dto/map_collaborator_impl.rs b/backend/src/model/dto/map_collaborator_impl.rs index 0274aebb5..63ba27163 100644 --- a/backend/src/model/dto/map_collaborator_impl.rs +++ b/backend/src/model/dto/map_collaborator_impl.rs @@ -1,4 +1,4 @@ -use crate::{keycloak_api::dtos::UserDto, model::entity::MapCollaborator}; +use crate::{keycloak_api::UserDto, model::entity::MapCollaborator}; use super::MapCollaboratorDto; diff --git a/backend/src/service/users.rs b/backend/src/service/users.rs index 61a52f799..4fa6d8fd9 100644 --- a/backend/src/service/users.rs +++ b/backend/src/service/users.rs @@ -88,3 +88,26 @@ pub async fn find_by_ids( Ok(users) } + +/// Search for users by their username. +/// +/// # Errors +/// * If the connection to the Keycloak API could not be established. +pub async fn search_by_username( + username: &str, + keycloak_api: &SharedKeycloakApi, + http_client: &SharedHttpClient, +) -> Result, ServiceError> { + let users = keycloak_api + .search_users_by_username(username, http_client) + .await + .map_err(|e| { + log::error!("Error getting user data from Keycloak API: {e}"); + ServiceError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Error getting user data from Keycloak API".to_owned(), + ) + })?; + + Ok(users) +} From 5bd4dc4a631ccf92ed9ef3e44ed41cdf8c3c5c39 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 10 Feb 2024 11:20:14 +0100 Subject: [PATCH 13/41] refactor: improvements --- backend/src/config/auth/user_info.rs | 40 +++++++++++++++------------- backend/src/keycloak_api/api.rs | 7 ++--- backend/src/keycloak_api/errors.rs | 6 +++++ 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/backend/src/config/auth/user_info.rs b/backend/src/config/auth/user_info.rs index e76538cdf..07c136051 100644 --- a/backend/src/config/auth/user_info.rs +++ b/backend/src/config/auth/user_info.rs @@ -28,6 +28,27 @@ pub enum Role { Member, } +impl UserInfo { + /// Checks if the user is a member. + #[must_use] + pub fn is_member(&self) -> bool { + self.roles.iter().any(|role| matches!(role, Role::Member)) + } +} + +impl Role { + /// Convert a role from a string. + #[must_use] + pub fn from_string(str: &str) -> Option { + match str { + "member" => Some(Self::Member), + _ => None, + } + } +} + +// Trait implementations + impl From for UserInfo { fn from(value: Claims) -> Self { Self { @@ -37,21 +58,12 @@ impl From for UserInfo { .realm_access .roles .into_iter() - .filter_map(map_realm_access_role) + .filter_map(|s| Role::from_string(&s)) .collect::>(), } } } -/// Maps a role from the [`super::claims::RealmAccess`] to a [`Role`]. -#[allow(clippy::needless_pass_by_value)] // The function signature is required by `filter_map`. -fn map_realm_access_role(role: String) -> Option { - match role.as_str() { - "member" => Some(Role::Member), - _ => None, - } -} - impl FromRequest for UserInfo { type Future = Ready>; type Error = ServiceError; @@ -74,11 +86,3 @@ impl FromRequest for UserInfo { }) } } - -impl UserInfo { - /// Checks if the user is a member. - #[must_use] - pub fn is_member(&self) -> bool { - self.roles.iter().any(|role| matches!(role, Role::Member)) - } -} diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 534b47537..6acf23324 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -160,13 +160,14 @@ impl Api { let token_header = HeaderValue::from_str(&format!("Bearer {}", token.secret()))?; request.headers_mut().append("Authorization", token_header); - let res = client.execute(request).await?.json::().await?; + // There is a `json` method, but then we can not log the response for debugging easily. + let response = client.execute(request).await?; + let response_text = response.text().await?; - Ok(res) + Ok(serde_json::from_str(&response_text)?) } /// Gets the access token or refreshes it if it is expired. - #[allow(clippy::unwrap_used)] async fn get_or_refresh_access_token(&self, client: &reqwest::Client) -> Result { let mut guard = self.auth_data.lock().await; diff --git a/backend/src/keycloak_api/errors.rs b/backend/src/keycloak_api/errors.rs index 445371bac..1688245af 100644 --- a/backend/src/keycloak_api/errors.rs +++ b/backend/src/keycloak_api/errors.rs @@ -51,3 +51,9 @@ where Self::Other(value.to_string()) } } + +impl From for KeycloakApiError { + fn from(err: serde_json::Error) -> Self { + Self::Other(err.to_string()) + } +} From 6527d5c868a38ef17171990af55413be2ade97fe Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 10 Feb 2024 11:28:41 +0100 Subject: [PATCH 14/41] chore: add changelog --- doc/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.md b/doc/changelog.md index 953592427..df1b6c684 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -86,7 +86,7 @@ Syntax: `- short text describing the change _(Your Name)_` - Up the rust version to 1.74 _(4ydan)_ - Display a rectangle for area of plantings _(Paul)_ - Refactor the planting api to batch processing _(Paul, Jannis)_ -- _()_ +- Add map collaborator api _(Paul)_ - Area of plants resizing. Rename width & height of plantings to `size_x` & `size_y`. Remove `scale` from plantings. _(Paul)_ - Refactor transformer into a separate store _(Paul)_ - Replace old date picker with new timeline component using sliders to select date _(Daniel Steinkogler)_ From 937f0c7b52498a2ae5c68d540ca1ee8df561b422 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 10 Feb 2024 12:08:26 +0100 Subject: [PATCH 15/41] feat: delete collaborators from a map --- backend/src/config/api_doc.rs | 12 +++-- backend/src/config/routes.rs | 3 +- backend/src/controller/map_collaborators.rs | 37 +++++++++++++-- backend/src/model/dto.rs | 16 +++++-- .../src/model/dto/map_collaborator_impl.rs | 2 +- .../model/dto/new_map_collaborator_impl.rs | 7 +-- .../src/model/entity/map_collaborator_impl.rs | 26 ++++++++-- backend/src/service/map_collaborator.rs | 47 ++++++++++++++----- 8 files changed, 118 insertions(+), 32 deletions(-) diff --git a/backend/src/config/api_doc.rs b/backend/src/config/api_doc.rs index 172bf4fd4..7ccfe0f45 100644 --- a/backend/src/config/api_doc.rs +++ b/backend/src/config/api_doc.rs @@ -26,11 +26,11 @@ use crate::{ UpdateRemoveDatePlantingDto, }, timeline::{TimelineDto, TimelineEntryDto}, - BaseLayerImageDto, ConfigDto, Coordinates, GainedBlossomsDto, GuidedToursDto, LayerDto, - MapCollaboratorDto, MapDto, NewLayerDto, NewMapCollaboratorDto, NewMapDto, NewSeedDto, - PageLayerDto, PageMapDto, PagePlantsSummaryDto, PageSeedDto, PlantsSummaryDto, - RelationDto, RelationsDto, SeedDto, UpdateBaseLayerImageDto, UpdateGuidedToursDto, - UpdateMapDto, UsersDto, + BaseLayerImageDto, ConfigDto, Coordinates, DeleteMapCollaboratorDto, GainedBlossomsDto, + GuidedToursDto, LayerDto, MapCollaboratorDto, MapDto, NewLayerDto, + NewMapCollaboratorDto, NewMapDto, NewSeedDto, PageLayerDto, PageMapDto, + PagePlantsSummaryDto, PageSeedDto, PlantsSummaryDto, RelationDto, RelationsDto, + SeedDto, UpdateBaseLayerImageDto, UpdateGuidedToursDto, UpdateMapDto, UsersDto, }, r#enum::{ experience::Experience, membership::Membership, privacy_option::PrivacyOption, @@ -268,11 +268,13 @@ struct TimelineApiDoc; paths( map_collaborators::create, map_collaborators::find, + map_collaborators::delete, ), components( schemas( NewMapCollaboratorDto, MapCollaboratorDto, + DeleteMapCollaboratorDto, ) ), modifiers(&SecurityAddon) diff --git a/backend/src/config/routes.rs b/backend/src/config/routes.rs index 79d5d067f..db3127349 100644 --- a/backend/src/config/routes.rs +++ b/backend/src/config/routes.rs @@ -72,7 +72,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service( web::scope("/collaborators") .service(map_collaborators::create) - .service(map_collaborators::find), + .service(map_collaborators::find) + .service(map_collaborators::delete), ), ), ) diff --git a/backend/src/controller/map_collaborators.rs b/backend/src/controller/map_collaborators.rs index 0e977325a..eccb10ac6 100644 --- a/backend/src/controller/map_collaborators.rs +++ b/backend/src/controller/map_collaborators.rs @@ -1,5 +1,5 @@ use actix_web::{ - get, post, + delete, get, post, web::{Json, Path}, HttpResponse, Result, }; @@ -9,7 +9,7 @@ use crate::{ auth::user_info::UserInfo, data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, }, - model::dto::NewMapCollaboratorDto, + model::dto::{DeleteMapCollaboratorDto, NewMapCollaboratorDto}, service::map_collaborator, }; @@ -71,8 +71,7 @@ pub async fn create( http_client: SharedHttpClient, ) -> Result { let response = map_collaborator::create( - json.into_inner(), - map_id.into_inner(), + (map_id.into_inner(), json.into_inner()), user_info.id, &pool, &keycloak_api, @@ -82,3 +81,33 @@ pub async fn create( Ok(HttpResponse::Created().json(response)) } + +/// Endpoint for deleting a collaborator from a map. +/// +/// # Errors +/// * If the user is not the owner of the map. +/// * If the connection to the database could not be established. +#[utoipa::path( + context_path = "/api/maps/{map_id}/collaborators", + params( + ("map_id" = i32, Path, description = "The id of the map on which to collaborate"), + ), + request_body = DeleteMapCollaboratorDto, + responses( + (status = 204, description = "The collaborator was removed from the map"), + ), + security( + ("oauth2" = []) + ) +)] +#[delete("")] +pub async fn delete( + map_id: Path, + dto: Json, + user_info: UserInfo, + pool: SharedPool, +) -> Result { + map_collaborator::delete((map_id.into_inner(), dto.into_inner()), user_info.id, &pool).await?; + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/backend/src/model/dto.rs b/backend/src/model/dto.rs index 5950df9d0..434ddce47 100644 --- a/backend/src/model/dto.rs +++ b/backend/src/model/dto.rs @@ -520,6 +520,7 @@ pub struct GainedBlossomsDto { #[typeshare] #[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] /// Information on user collaborating on a map. pub struct MapCollaboratorDto { /// The id of the map. @@ -527,21 +528,29 @@ pub struct MapCollaboratorDto { /// The id of the collaborator. pub user_id: Uuid, /// The user name of the collaborator. - pub user_name: String, + pub username: String, } #[typeshare] #[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] /// The information of a map collaborator necessary for its creation. pub struct NewMapCollaboratorDto { - /// The id of the map. - pub map_id: i32, + /// The id of the collaborator. + pub user_id: Uuid, +} + +#[typeshare] +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DeleteMapCollaboratorDto { /// The id of the collaborator. pub user_id: Uuid, } #[typeshare] #[derive(Debug, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] /// Query params for searching map collaborators. pub struct MapCollaboratorSearchParameters { /// The id of the map. @@ -550,6 +559,7 @@ pub struct MapCollaboratorSearchParameters { #[typeshare] #[derive(Debug, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] /// Query params for searching users. pub struct UserSearchParameters { /// The name of the user to search for. diff --git a/backend/src/model/dto/map_collaborator_impl.rs b/backend/src/model/dto/map_collaborator_impl.rs index 63ba27163..8fff09d5a 100644 --- a/backend/src/model/dto/map_collaborator_impl.rs +++ b/backend/src/model/dto/map_collaborator_impl.rs @@ -7,7 +7,7 @@ impl From<(MapCollaborator, UserDto)> for MapCollaboratorDto { Self { map_id: value.0.map_id, user_id: value.0.user_id, - user_name: value.1.username, + username: value.1.username, } } } diff --git a/backend/src/model/dto/new_map_collaborator_impl.rs b/backend/src/model/dto/new_map_collaborator_impl.rs index 0680d2dde..0526aa5c1 100644 --- a/backend/src/model/dto/new_map_collaborator_impl.rs +++ b/backend/src/model/dto/new_map_collaborator_impl.rs @@ -4,10 +4,11 @@ use crate::model::entity::MapCollaborator; use super::NewMapCollaboratorDto; -impl From for MapCollaborator { - fn from(new_map_collaborator: NewMapCollaboratorDto) -> Self { +impl From<(i32, NewMapCollaboratorDto)> for MapCollaborator { + fn from(map_and_collaborator: (i32, NewMapCollaboratorDto)) -> Self { + let (map_id, new_map_collaborator) = map_and_collaborator; Self { - map_id: new_map_collaborator.map_id, + map_id, user_id: new_map_collaborator.user_id, created_at: Utc::now().naive_utc(), } diff --git a/backend/src/model/entity/map_collaborator_impl.rs b/backend/src/model/entity/map_collaborator_impl.rs index d89ac8f5d..ee99faafa 100644 --- a/backend/src/model/entity/map_collaborator_impl.rs +++ b/backend/src/model/entity/map_collaborator_impl.rs @@ -2,7 +2,10 @@ use diesel::{debug_query, pg::Pg, ExpressionMethods, QueryDsl, QueryResult}; use diesel_async::{AsyncPgConnection, RunQueryDsl}; use log::debug; -use crate::{model::dto::NewMapCollaboratorDto, schema::map_collaborators}; +use crate::{ + model::dto::{DeleteMapCollaboratorDto, NewMapCollaboratorDto}, + schema::map_collaborators, +}; use super::MapCollaborator; @@ -12,10 +15,10 @@ impl MapCollaborator { /// # Errors /// * Unknown, diesel doesn't say why it might error. pub async fn create( - new_map_collaborator: NewMapCollaboratorDto, + map_and_collaborator: (i32, NewMapCollaboratorDto), conn: &mut AsyncPgConnection, ) -> QueryResult { - let new_map_collaborator = Self::from(new_map_collaborator); + let new_map_collaborator = Self::from(map_and_collaborator); let query = diesel::insert_into(map_collaborators::table).values(&new_map_collaborator); debug!("{}", debug_query::(&query)); @@ -34,4 +37,21 @@ impl MapCollaborator { debug!("{}", debug_query::(&query)); query.get_results::(conn).await } + + /// Delete a collaborator of a map. + /// + /// # Errors + /// * Unknown, diesel doesn't say why it might error. + pub async fn delete( + map_and_dto: (i32, DeleteMapCollaboratorDto), + conn: &mut AsyncPgConnection, + ) -> QueryResult<()> { + let (map_id, dto) = map_and_dto; + + let query = diesel::delete(map_collaborators::table.find((map_id, dto.user_id))); + debug!("{}", debug_query::(&query)); + query.execute(conn).await?; + + Ok(()) + } } diff --git a/backend/src/service/map_collaborator.rs b/backend/src/service/map_collaborator.rs index 8561ec11f..f0497b6f5 100644 --- a/backend/src/service/map_collaborator.rs +++ b/backend/src/service/map_collaborator.rs @@ -5,7 +5,7 @@ use crate::{ config::data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, error::ServiceError, model::{ - dto::{MapCollaboratorDto, NewMapCollaboratorDto}, + dto::{DeleteMapCollaboratorDto, MapCollaboratorDto, NewMapCollaboratorDto}, entity::{Map, MapCollaborator}, }, service::users, @@ -43,26 +43,20 @@ pub async fn get_all( /// Create a new collaborator for a map. /// /// # Errors -/// * If the path and body `map_id` do not match. /// * If the user tries to add themselves as a collaborator. /// * If the user is not the owner of the map. /// * If the map already has 30 collaborators. /// * If the connection to the database could not be established. /// * If the connection to the Keycloak API could not be established. pub async fn create( - new_map_collaborator: NewMapCollaboratorDto, - map_id: i32, + map_and_collaborator: (i32, NewMapCollaboratorDto), user_id: Uuid, pool: &SharedPool, keycloak_api: &SharedKeycloakApi, http_client: &SharedHttpClient, ) -> Result { - if new_map_collaborator.map_id != map_id { - return Err(ServiceError::new( - StatusCode::BAD_REQUEST, - "Path and body map_id do not match".to_owned(), - )); - } + let (map_id, ref new_map_collaborator) = map_and_collaborator; + if new_map_collaborator.user_id == user_id { return Err(ServiceError::new( StatusCode::BAD_REQUEST, @@ -72,7 +66,7 @@ pub async fn create( let mut conn = pool.get().await?; - let map = Map::find_by_id(new_map_collaborator.map_id, &mut conn).await?; + let map = Map::find_by_id(map_id, &mut conn).await?; if map.owner_id != user_id { return Err(ServiceError::new( @@ -93,7 +87,36 @@ pub async fn create( let collaborator_user = users::find_by_id(new_map_collaborator.user_id, keycloak_api, http_client).await?; - let collaborator = MapCollaborator::create(new_map_collaborator, &mut conn).await?; + let collaborator = MapCollaborator::create(map_and_collaborator, &mut conn).await?; Ok(MapCollaboratorDto::from((collaborator, collaborator_user))) } + +/// Remove a collaborator from a map. +/// +/// # Errors +/// * If the user is not the owner of the map. +/// * If the connection to the database could not be established. +/// * If the connection to the Keycloak API could not be established. +pub async fn delete( + map_and_dto: (i32, DeleteMapCollaboratorDto), + user_id: Uuid, + pool: &SharedPool, +) -> Result<(), ServiceError> { + let (map_id, _) = map_and_dto; + + let mut conn = pool.get().await?; + + let map = Map::find_by_id(map_id, &mut conn).await?; + + if map.owner_id != user_id { + return Err(ServiceError::new( + StatusCode::FORBIDDEN, + "You are not the owner of this map.".to_owned(), + )); + } + + MapCollaborator::delete(map_and_dto, &mut conn).await?; + + Ok(()) +} From e26ca387bc8709cfc6816429378484742df905ce Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 10 Feb 2024 18:00:02 +0100 Subject: [PATCH 16/41] chore: update docs --- backend/.env.sample | 8 +++++++- ci/Jenkinsfile | 6 +++++- doc/backend/01setup.md | 6 +++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/backend/.env.sample b/backend/.env.sample index bf8386cd4..56dd2909a 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -4,8 +4,14 @@ BIND_ADDRESS_HOST=127.0.0.1 BIND_ADDRESS_PORT=8080 # OAuth2 (other URLs will be fetched from this URL) -AUTH_DISCOVERY_URI=https://auth.permaplant.net/realms/PermaplanT/.well-known/openid-configuration +AUTH_HOST=https://auth.permaplant.net AUTH_CLIENT_ID=localhost +# Keycloak API (will be used to fetch user info) +KEYCLOAK_CLIENT_ID=admin-cli +KEYCLOAK_CLIENT_SECRET=configured-in-keycloak +KEYCLOAK_USERNAME=configured-in-keycloak +KEYCLOAK_PASSWORD=configured-in-keycloak + # Logging config (will be used by env_logger) RUST_LOG='backend=debug,actix_web=debug' diff --git a/ci/Jenkinsfile b/ci/Jenkinsfile index 866ceb26b..1a4dba278 100644 --- a/ci/Jenkinsfile +++ b/ci/Jenkinsfile @@ -60,8 +60,12 @@ def runDockerPostgresSidecar(String command, List stashsrc = [], List Date: Sat, 10 Feb 2024 18:06:51 +0100 Subject: [PATCH 17/41] fix: tests --- ci/Jenkinsfile | 10 +++++----- doc/changelog.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ci/Jenkinsfile b/ci/Jenkinsfile index 1a4dba278..561baced9 100644 --- a/ci/Jenkinsfile +++ b/ci/Jenkinsfile @@ -60,12 +60,12 @@ def runDockerPostgresSidecar(String command, List stashsrc = [], List Date: Sat, 10 Feb 2024 18:15:27 +0100 Subject: [PATCH 18/41] chore: fix doc links --- backend/src/config/api_doc.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/src/config/api_doc.rs b/backend/src/config/api_doc.rs index 7ccfe0f45..a9801de6f 100644 --- a/backend/src/config/api_doc.rs +++ b/backend/src/config/api_doc.rs @@ -40,12 +40,12 @@ use crate::{ }, }; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`config`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@config`] endpoints. #[derive(OpenApi)] #[openapi(paths(config::get), components(schemas(ConfigDto)))] struct ConfigApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`seed`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@seed`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -67,7 +67,7 @@ struct ConfigApiDoc; )] struct SeedApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`plants`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@plants`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -84,7 +84,7 @@ struct SeedApiDoc; )] struct PlantsApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`map`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@map`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -107,7 +107,7 @@ struct PlantsApiDoc; )] struct MapApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`layers`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@layers`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -127,7 +127,7 @@ struct MapApiDoc; )] struct LayerApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`plant_layer`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@plant_layer`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -145,7 +145,7 @@ struct LayerApiDoc; )] struct PlantLayerApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`base_layer_image`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@base_layer_image`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -164,7 +164,7 @@ struct PlantLayerApiDoc; )] struct BaseLayerImagesApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`plantings`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@plantings`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -193,7 +193,7 @@ struct BaseLayerImagesApiDoc; )] struct PlantingsApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`users`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@users`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -213,7 +213,7 @@ struct PlantingsApiDoc; )] struct UsersApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`guided_tours`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@guided_tours`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -231,7 +231,7 @@ struct UsersApiDoc; )] struct GuidedToursApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`blossoms`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@blossoms`] endpoints. #[derive(OpenApi)] #[openapi( paths( @@ -262,7 +262,7 @@ struct BlossomsApiDoc; )] struct TimelineApiDoc; -/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`map_collaborators`] endpoints. +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all [`mod@map_collaborators`] endpoints. #[derive(OpenApi)] #[openapi( paths( From 907b099a258ed75559c58c76b35cf88c81bd384c Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 10 Feb 2024 19:38:57 +0100 Subject: [PATCH 19/41] fix: tests --- backend/src/config/auth/claims.rs | 12 +++++--- backend/src/config/auth/user_info.rs | 14 +++++---- backend/src/test/auth.rs | 4 +-- backend/src/test/base_layer_image.rs | 8 +++--- backend/src/test/blossoms.rs | 2 +- backend/src/test/config.rs | 2 +- backend/src/test/guided_tours.rs | 6 ++-- backend/src/test/layers.rs | 10 +++---- backend/src/test/map.rs | 12 ++++---- backend/src/test/pagination.rs | 2 +- backend/src/test/plant.rs | 6 ++-- backend/src/test/plant_layer.rs | 6 ++-- backend/src/test/plant_layer_heatmap.rs | 10 +++---- backend/src/test/plantings.rs | 16 +++++------ backend/src/test/seed.rs | 22 +++++++------- backend/src/test/users.rs | 2 +- backend/src/test/util.rs | 11 +++++-- backend/src/test/util/token.rs | 38 ++++++++++++------------- 18 files changed, 98 insertions(+), 85 deletions(-) diff --git a/backend/src/config/auth/claims.rs b/backend/src/config/auth/claims.rs index 0a8b82469..192a8f199 100644 --- a/backend/src/config/auth/claims.rs +++ b/backend/src/config/auth/claims.rs @@ -17,7 +17,7 @@ pub struct Claims { /// The OAuth2 scope pub scope: String, /// Realm roles - pub realm_access: RealmAccess, + pub realm_access: Option, } #[derive(Debug, Clone, Deserialize)] @@ -50,7 +50,11 @@ impl Claims { .map_err(|err| ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; let claims = decode(token, decoding_key, &Validation::new(header.alg)) - .map_err(|_| ServiceError::new(StatusCode::UNAUTHORIZED, "invalid token".to_owned()))? + .map_err(|err| { + let reason = err.to_string(); + log::error!("Error decoding claims: {}", reason); + ServiceError::new(StatusCode::UNAUTHORIZED, "invalid token".to_owned()) + })? .claims; Ok(claims) } @@ -65,14 +69,14 @@ mod test { #[test] fn test_simple_token_succeeds() { let jwk = init_auth(); - let token = generate_token(jwk, 300); + let token = generate_token(&jwk, 300); assert!(Claims::validate(&token).is_ok()); } #[test] fn test_expired_token_fails() { let jwk = init_auth(); - let token = generate_token(jwk, -300); + let token = generate_token(&jwk, -300); assert!(Claims::validate(&token).is_err()); } diff --git a/backend/src/config/auth/user_info.rs b/backend/src/config/auth/user_info.rs index 07c136051..d42db9147 100644 --- a/backend/src/config/auth/user_info.rs +++ b/backend/src/config/auth/user_info.rs @@ -51,15 +51,19 @@ impl Role { impl From for UserInfo { fn from(value: Claims) -> Self { - Self { - id: value.sub, - scopes: value.scope.split(' ').map(str::to_owned).collect(), - roles: value - .realm_access + let roles = match value.realm_access { + Some(realm_access) => realm_access .roles .into_iter() .filter_map(|s| Role::from_string(&s)) .collect::>(), + None => Vec::new(), + }; + + Self { + id: value.sub, + scopes: value.scope.split(' ').map(str::to_owned).collect(), + roles, } } } diff --git a/backend/src/test/auth.rs b/backend/src/test/auth.rs index 6a4e03ade..968c6a2bf 100644 --- a/backend/src/test/auth.rs +++ b/backend/src/test/auth.rs @@ -7,7 +7,7 @@ use actix_web::{ }; use diesel_async::scoped_futures::ScopedFutureExt; -#[actix_rt::test] +#[actix_web::test] async fn test_with_token_succeeds() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -21,7 +21,7 @@ async fn test_with_token_succeeds() { assert_eq!(resp.status(), StatusCode::OK); } -#[actix_rt::test] +#[actix_web::test] async fn test_missing_token_fails() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (_, app) = init_test_app(pool.clone()).await; diff --git a/backend/src/test/base_layer_image.rs b/backend/src/test/base_layer_image.rs index 162b4608b..236ce65ed 100644 --- a/backend/src/test/base_layer_image.rs +++ b/backend/src/test/base_layer_image.rs @@ -75,7 +75,7 @@ async fn initial_db_values( Ok(()) } -#[actix_rt::test] +#[actix_web::test] async fn test_find_succeeds() { let pool = init_test_database(|conn| { initial_db_values(conn, small_rectangle_with_non_0_xmin()).scope_boxed() @@ -99,7 +99,7 @@ async fn test_find_succeeds() { assert_eq!(results.len(), 1); } -#[actix_rt::test] +#[actix_web::test] async fn test_create_succeeds() { let pool = init_test_database(|conn| { initial_db_values(conn, small_rectangle_with_non_0_xmin()).scope_boxed() @@ -124,7 +124,7 @@ async fn test_create_succeeds() { assert_eq!(resp.status(), StatusCode::CREATED); } -#[actix_rt::test] +#[actix_web::test] async fn test_update_succeeds() { let pool = init_test_database(|conn| { initial_db_values(conn, small_rectangle_with_non_0_xmin()).scope_boxed() @@ -151,7 +151,7 @@ async fn test_update_succeeds() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } -#[actix_rt::test] +#[actix_web::test] async fn test_delete_by_id_succeeds() { let pool = init_test_database(|conn| { initial_db_values(conn, small_rectangle_with_non_0_xmin()).scope_boxed() diff --git a/backend/src/test/blossoms.rs b/backend/src/test/blossoms.rs index 0ef27b4fc..6fdf2c9b7 100644 --- a/backend/src/test/blossoms.rs +++ b/backend/src/test/blossoms.rs @@ -10,7 +10,7 @@ use crate::model::dto::GainedBlossomsDto; use super::util::{init_test_app, init_test_database}; -#[actix_rt::test] +#[actix_web::test] async fn test_can_gain_blossom() { let pool = init_test_database(|conn| { async { diff --git a/backend/src/test/config.rs b/backend/src/test/config.rs index 02fc9b9e4..ea540838d 100644 --- a/backend/src/test/config.rs +++ b/backend/src/test/config.rs @@ -5,7 +5,7 @@ use actix_web::{http::StatusCode, test, App}; use crate::{config::routes, model::dto::ConfigDto, test::util::jwks::init_auth}; -#[actix_rt::test] +#[actix_web::test] async fn test_search_plants_succeeds() { // Has to be done way as static variables are shared in tests and /api/config requires static vars init_auth(); diff --git a/backend/src/test/guided_tours.rs b/backend/src/test/guided_tours.rs index 40486a7dc..ff7cc0928 100644 --- a/backend/src/test/guided_tours.rs +++ b/backend/src/test/guided_tours.rs @@ -10,7 +10,7 @@ use crate::model::dto::{GuidedToursDto, UpdateGuidedToursDto}; use super::util::{init_test_app, init_test_app_for_user, init_test_database}; -#[actix_rt::test] +#[actix_web::test] async fn test_can_setup_status_object() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -23,7 +23,7 @@ async fn test_can_setup_status_object() { assert_eq!(resp.status(), StatusCode::CREATED); } -#[actix_rt::test] +#[actix_web::test] async fn test_can_find_status_object() { let user_id = Uuid::new_v4(); let pool = init_test_database(|conn| { @@ -50,7 +50,7 @@ async fn test_can_find_status_object() { assert_eq!(resp.status(), StatusCode::OK); } -#[actix_rt::test] +#[actix_web::test] async fn test_can_update_status_object() { let user_id = Uuid::new_v4(); let pool = init_test_database(|conn| { diff --git a/backend/src/test/layers.rs b/backend/src/test/layers.rs index 3e596271b..b0bdd207f 100644 --- a/backend/src/test/layers.rs +++ b/backend/src/test/layers.rs @@ -61,7 +61,7 @@ async fn initial_db_values(conn: &mut AsyncPgConnection) -> Result<(), ServiceEr Ok(()) } -#[actix_rt::test] +#[actix_web::test] async fn test_find_layers_succeeds() { let pool = init_test_database(|conn| initial_db_values(conn).scope_boxed()).await; let (token, app) = init_test_app(pool).await; @@ -82,7 +82,7 @@ async fn test_find_layers_succeeds() { assert_eq!(results.len(), 2); } -#[actix_rt::test] +#[actix_web::test] async fn test_find_layer_by_id_succeeds() { let pool = init_test_database(|conn| initial_db_values(conn).scope_boxed()).await; let (token, app) = init_test_app(pool).await; @@ -106,7 +106,7 @@ async fn test_find_layer_by_id_succeeds() { assert_eq!(dto.id, -1); } -#[actix_rt::test] +#[actix_web::test] async fn test_create_layer_succeeds() { let pool = init_test_database(|conn| initial_db_values(conn).scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -126,7 +126,7 @@ async fn test_create_layer_succeeds() { assert_eq!(resp.status(), StatusCode::CREATED); } -#[actix_rt::test] +#[actix_web::test] async fn test_create_layer_with_invalid_map_id_fails() { let pool = init_test_database(|conn| initial_db_values(conn).scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -146,7 +146,7 @@ async fn test_create_layer_with_invalid_map_id_fails() { assert_eq!(resp.status(), StatusCode::CONFLICT); } -#[actix_rt::test] +#[actix_web::test] async fn test_delete_by_id_succeeds() { let pool = init_test_database(|conn| initial_db_values(conn).scope_boxed()).await; let (token, app) = init_test_app(pool).await; diff --git a/backend/src/test/map.rs b/backend/src/test/map.rs index 2b5fc0cc9..00fefcdc7 100644 --- a/backend/src/test/map.rs +++ b/backend/src/test/map.rs @@ -18,7 +18,7 @@ use diesel::ExpressionMethods; use diesel_async::{scoped_futures::ScopedFutureExt, RunQueryDsl}; use uuid::Uuid; -#[actix_rt::test] +#[actix_web::test] async fn test_can_search_maps() { let pool = init_test_database(|conn| { async { @@ -88,7 +88,7 @@ async fn test_can_search_maps() { assert!(page.results.len() == 1); } -#[actix_rt::test] +#[actix_web::test] async fn test_can_find_map_by_id() { let pool = init_test_database(|conn| { async { @@ -125,7 +125,7 @@ async fn test_can_find_map_by_id() { assert_eq!(resp.status(), StatusCode::OK); } -#[actix_rt::test] +#[actix_web::test] async fn test_can_create_map() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -165,7 +165,7 @@ async fn test_can_create_map() { assert_eq!(resp.status(), StatusCode::OK); } -#[actix_rt::test] +#[actix_web::test] async fn test_update_fails_for_not_owner() { let pool = init_test_database(|conn| { async { @@ -209,7 +209,7 @@ async fn test_update_fails_for_not_owner() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); } -#[actix_rt::test] +#[actix_web::test] async fn test_can_update_map() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -255,7 +255,7 @@ async fn test_can_update_map() { let updated_map: MapDto = test::read_body_json(resp).await; assert_ne!(updated_map.name, map.name) } -#[actix_rt::test] +#[actix_web::test] async fn test_can_update_map_geometry() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; diff --git a/backend/src/test/pagination.rs b/backend/src/test/pagination.rs index b6dbff76b..2dd34b83b 100644 --- a/backend/src/test/pagination.rs +++ b/backend/src/test/pagination.rs @@ -15,7 +15,7 @@ use diesel::ExpressionMethods; use diesel_async::{scoped_futures::ScopedFutureExt, RunQueryDsl}; use uuid::uuid; -#[actix_rt::test] +#[actix_web::test] async fn test_seeds_pagination_succeeds() { let user_id = uuid!("00000000-0000-0000-0000-000000000000"); let pool = init_test_database(|conn| { diff --git a/backend/src/test/plant.rs b/backend/src/test/plant.rs index 196741949..2377b8d08 100644 --- a/backend/src/test/plant.rs +++ b/backend/src/test/plant.rs @@ -12,7 +12,7 @@ use actix_web::{ use diesel::ExpressionMethods; use diesel_async::{scoped_futures::ScopedFutureExt, RunQueryDsl}; -#[actix_rt::test] +#[actix_web::test] async fn test_get_all_plants_succeeds() { let pool = init_test_database(|conn| { async { @@ -61,7 +61,7 @@ async fn test_get_all_plants_succeeds() { assert!(page.results.contains(&test_plant)); } -#[actix_rt::test] +#[actix_web::test] async fn test_get_one_plant_succeeds() { let pool = init_test_database(|conn| { async { @@ -110,7 +110,7 @@ async fn test_get_one_plant_succeeds() { assert_eq!(dto, test_plant); } -#[actix_rt::test] +#[actix_web::test] async fn test_search_plants_succeeds() { let pool = init_test_database(|conn| { async { diff --git a/backend/src/test/plant_layer.rs b/backend/src/test/plant_layer.rs index 3299e7c1f..4bf0932ef 100644 --- a/backend/src/test/plant_layer.rs +++ b/backend/src/test/plant_layer.rs @@ -12,7 +12,7 @@ use crate::{ test::util::{init_test_app, init_test_database}, }; -#[actix_rt::test] +#[actix_web::test] async fn test_plants_relations_include_the_other_plant_in_the_relation() { let pool = init_test_database(|conn| { async { @@ -79,7 +79,7 @@ async fn test_plants_relations_include_the_other_plant_in_the_relation() { assert!(dto.relations.iter().any(|r| r.id == -2)); } -#[actix_rt::test] +#[actix_web::test] async fn test_plants_relations_can_be_related_to_themselves() { let pool = init_test_database(|conn| { async { @@ -141,7 +141,7 @@ async fn test_plants_relations_can_be_related_to_themselves() { assert!(dto.relations.iter().any(|r| r.id == -3)); } -#[actix_rt::test] +#[actix_web::test] async fn test_plants_relations_are_distinct() { let pool = init_test_database(|conn| { async { diff --git a/backend/src/test/plant_layer_heatmap.rs b/backend/src/test/plant_layer_heatmap.rs index 61bbcae97..c021e4654 100644 --- a/backend/src/test/plant_layer_heatmap.rs +++ b/backend/src/test/plant_layer_heatmap.rs @@ -69,7 +69,7 @@ async fn initial_db_values( Ok(()) } -#[actix_rt::test] +#[actix_web::test] async fn test_generate_heatmap_succeeds() { let pool = init_test_database(|conn| initial_db_values(conn, tall_rectangle()).scope_boxed()).await; @@ -88,7 +88,7 @@ async fn test_generate_heatmap_succeeds() { ); } -#[actix_rt::test] +#[actix_web::test] async fn test_check_heatmap_dimensionality_succeeds() { let pool = init_test_database(|conn| initial_db_values(conn, small_rectangle()).scope_boxed()).await; @@ -115,7 +115,7 @@ async fn test_check_heatmap_dimensionality_succeeds() { ); // smaller by factor of 10 because of granularity } -#[actix_rt::test] +#[actix_web::test] async fn test_check_heatmap_non_0_xmin_succeeds() { let pool = init_test_database(|conn| { initial_db_values(conn, small_rectangle_with_non_0_xmin()).scope_boxed() @@ -146,7 +146,7 @@ async fn test_check_heatmap_non_0_xmin_succeeds() { /// Test with a map geometry that excludes a corner. /// The missing corner should be colored entirely in grey, as you cannot put plants there. -#[actix_rt::test] +#[actix_web::test] async fn test_heatmap_with_missing_corner_succeeds() { let pool = init_test_database(|conn| { initial_db_values(conn, rectangle_with_missing_bottom_left_corner()).scope_boxed() @@ -185,7 +185,7 @@ async fn test_heatmap_with_missing_corner_succeeds() { assert_eq!([64, 191, 64], bottom_right_pixel.0); } -#[actix_rt::test] +#[actix_web::test] async fn test_missing_entities_fails() { let pool = init_test_database(|conn| { initial_db_values(conn, rectangle_with_missing_bottom_left_corner()).scope_boxed() diff --git a/backend/src/test/plantings.rs b/backend/src/test/plantings.rs index ad22d3894..1e107c74c 100644 --- a/backend/src/test/plantings.rs +++ b/backend/src/test/plantings.rs @@ -24,7 +24,7 @@ use crate::{ use crate::test::util::{init_test_app, init_test_database}; -#[actix_rt::test] +#[actix_web::test] async fn test_can_search_plantings() { let pool = init_test_database(|conn| { async { @@ -96,7 +96,7 @@ async fn test_can_search_plantings() { } } -#[actix_rt::test] +#[actix_web::test] async fn test_create_fails_with_invalid_layer() { let pool = init_test_database(|conn| { async { @@ -148,7 +148,7 @@ async fn test_create_fails_with_invalid_layer() { assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } -#[actix_rt::test] +#[actix_web::test] async fn test_can_create_plantings() { let pool = init_test_database(|conn| { async { @@ -197,7 +197,7 @@ async fn test_can_create_plantings() { assert_eq!(resp.status(), StatusCode::CREATED); } -#[actix_rt::test] +#[actix_web::test] async fn test_can_update_plantings() { let planting_id1 = Uuid::new_v4(); let planting_id2 = Uuid::new_v4(); @@ -267,7 +267,7 @@ async fn test_can_update_plantings() { assert_eq!(plantings.get(1).map(|p| p.y), Some(20)); } -#[actix_rt::test] +#[actix_web::test] async fn test_can_delete_planting() { let planting_id = Uuid::new_v4(); let pool = init_test_database(|conn| { @@ -324,7 +324,7 @@ async fn test_can_delete_planting() { } } -#[actix_rt::test] +#[actix_web::test] async fn test_removed_planting_outside_loading_offset_is_not_in_timeline() { let planting_id = Uuid::new_v4(); let remove_date = NaiveDate::from_ymd_opt(2022, 1, 1).expect("date is valid"); @@ -374,7 +374,7 @@ async fn test_removed_planting_outside_loading_offset_is_not_in_timeline() { assert_eq!(page.results.len(), 0); } -#[actix_rt::test] +#[actix_web::test] async fn test_removed_planting_inside_loading_offset_is_in_timeline() { let planting_id = Uuid::new_v4(); let remove_date = NaiveDate::from_ymd_opt(2022, 1, 1).expect("date is valid"); @@ -422,7 +422,7 @@ async fn test_removed_planting_inside_loading_offset_is_in_timeline() { assert_eq!(page.results.len(), 1); } -#[actix_rt::test] +#[actix_web::test] async fn test_added_planting_outside_loading_offset_is_not_in_timeline() { let planting_id = Uuid::new_v4(); let current_date = NaiveDate::from_ymd_opt(2022, 1, 1).expect("date is valid"); diff --git a/backend/src/test/seed.rs b/backend/src/test/seed.rs index 2095a9b5c..52515758a 100644 --- a/backend/src/test/seed.rs +++ b/backend/src/test/seed.rs @@ -20,7 +20,7 @@ use diesel::ExpressionMethods; use diesel_async::{scoped_futures::ScopedFutureExt, RunQueryDsl}; use uuid::uuid; -#[actix_rt::test] +#[actix_web::test] async fn test_find_two_seeds_succeeds() { let user_id = uuid!("00000000-0000-0000-0000-000000000000"); let pool = init_test_database(|conn| { @@ -98,7 +98,7 @@ async fn test_find_two_seeds_succeeds() { assert_eq!(seed_dto2.use_by, NaiveDate::from_ymd_opt(2023, 01, 01)); } -#[actix_rt::test] +#[actix_web::test] async fn test_search_seeds_succeeds() { let user_id = uuid!("00000000-0000-0000-0000-000000000000"); let pool = init_test_database(|conn| { @@ -166,7 +166,7 @@ async fn test_search_seeds_succeeds() { assert_eq!(seed_dto.quantity, Quantity::Enough); } -#[actix_rt::test] +#[actix_web::test] async fn test_find_by_id_succeeds() { let user_id = uuid!("00000000-0000-0000-0000-000000000000"); let pool = init_test_database(|conn| { @@ -231,7 +231,7 @@ async fn test_find_by_id_succeeds() { assert_eq!(seed_dto.quantity, Quantity::Enough); } -#[actix_rt::test] +#[actix_web::test] async fn test_find_by_non_existing_id_fails() { let user_id = uuid!("00000000-0000-0000-0000-000000000000"); let pool = init_test_database(|conn| { @@ -283,7 +283,7 @@ async fn test_find_by_non_existing_id_fails() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } -#[actix_rt::test] +#[actix_web::test] async fn test_create_seed_fails_with_invalid_quantity() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -305,7 +305,7 @@ async fn test_create_seed_fails_with_invalid_quantity() { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } -#[actix_rt::test] +#[actix_web::test] async fn test_create_seed_fails_with_invalid_tags() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -327,7 +327,7 @@ async fn test_create_seed_fails_with_invalid_tags() { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } -#[actix_rt::test] +#[actix_web::test] async fn test_create_seed_fails_with_invalid_quality() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; @@ -350,7 +350,7 @@ async fn test_create_seed_fails_with_invalid_quality() { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } -#[actix_rt::test] +#[actix_web::test] async fn test_create_seed_ok() { let pool = init_test_database(|conn| { async { @@ -403,7 +403,7 @@ async fn test_create_seed_ok() { assert_eq!(resp.status(), StatusCode::OK); } -#[actix_rt::test] +#[actix_web::test] async fn test_delete_by_id_succeeds() { let user_id = uuid!("00000000-0000-0000-0000-000000000000"); let pool = init_test_database(|conn| { @@ -434,7 +434,7 @@ async fn test_delete_by_id_succeeds() { assert_eq!(resp.status(), StatusCode::OK); } -#[actix_rt::test] +#[actix_web::test] async fn test_delete_by_non_existing_id_succeeds() { let user_id = uuid!("00000000-0000-0000-0000-000000000000"); let pool = init_test_database(|conn| { @@ -465,7 +465,7 @@ async fn test_delete_by_non_existing_id_succeeds() { assert_eq!(resp.status(), StatusCode::OK); } -#[actix_rt::test] +#[actix_web::test] async fn test_archive_seed_succeeds() { let user_id = uuid!("00000000-0000-0000-0000-000000000000"); let pool = init_test_database(|conn| { diff --git a/backend/src/test/users.rs b/backend/src/test/users.rs index 88570a30f..b5b3982e4 100644 --- a/backend/src/test/users.rs +++ b/backend/src/test/users.rs @@ -8,7 +8,7 @@ use crate::model::{dto::UsersDto, r#enum::salutation::Salutation}; use super::util::{init_test_app, init_test_database}; -#[actix_rt::test] +#[actix_web::test] async fn test_can_create_user_data() { let pool = init_test_database(|_| async { Ok(()) }.scope_boxed()).await; let (token, app) = init_test_app(pool.clone()).await; diff --git a/backend/src/test/util.rs b/backend/src/test/util.rs index 86c74c04d..fca3c6c7a 100644 --- a/backend/src/test/util.rs +++ b/backend/src/test/util.rs @@ -34,6 +34,11 @@ pub mod token; /// All transactions are run inside [`AsyncConnection::begin_test_transaction`]. /// /// The pool is limited to 1 connection. +/// +/// # Panics +/// - If the configuration cannot be loaded. +/// - If the pool cannot be initialized. +/// - If the test transaction cannot be started. pub async fn init_test_database<'a, F>(init_database: F) -> Pool where F: for<'r> FnOnce( @@ -60,7 +65,7 @@ where .await .expect("Failed to begin test transaction"); - conn.transaction(|conn| init_database(conn)) + conn.transaction(|c| init_database(c)) .await .expect("Failed to initialize test database"); @@ -113,10 +118,10 @@ async fn init_test_app_impl( fn setup_auth() -> String { let jwk = jwks::init_auth(); - generate_token(jwk, 300) + generate_token(&jwk, 300) } fn setup_auth_for_user(user_id: uuid::Uuid) -> String { let jwk = jwks::init_auth(); - generate_token_for_user(jwk, 300, user_id) + generate_token_for_user(&jwk, 300, user_id) } diff --git a/backend/src/test/util/token.rs b/backend/src/test/util/token.rs index 240f7c54d..64b8bb754 100644 --- a/backend/src/test/util/token.rs +++ b/backend/src/test/util/token.rs @@ -1,36 +1,36 @@ use std::time::{SystemTime, UNIX_EPOCH}; use jsonwebkey::JsonWebKey; +use jsonwebtoken::{EncodingKey, Header}; use serde::Serialize; use uuid::Uuid; /// Generate a token using the jwk (see [`super::init_jwks::init_jwks`]) and an offset. /// /// The offset is added to the current time (meaning -300 would be expired, 300 is valid) -pub fn generate_token(jwk: JsonWebKey, exp_offset: i64) -> String { - let mut header = jsonwebtoken::Header::new(jwk.algorithm.unwrap().into()); - header.kid = Some(jwk.key_id.clone().unwrap()); +pub fn generate_token(jwk: &JsonWebKey, exp_offset: i64) -> String { + let (header, mut claims, key) = get_header_claims_key(jwk); + + claims = claims.with_exp_offset(exp_offset); - jsonwebtoken::encode( - &header, - &TokenClaims::new().with_exp_offset(exp_offset), - &jwk.key.to_encoding_key(), - ) - .unwrap() + jsonwebtoken::encode(&header, &claims, &key).unwrap() } -pub fn generate_token_for_user(jwk: JsonWebKey, exp_offset: i64, user_id: Uuid) -> String { +pub fn generate_token_for_user(jwk: &JsonWebKey, exp_offset: i64, user_id: Uuid) -> String { + let (header, mut claims, key) = get_header_claims_key(jwk); + + claims = claims.with_exp_offset(exp_offset).with_sub(user_id); + + jsonwebtoken::encode(&header, &claims, &key).unwrap() +} + +fn get_header_claims_key(jwk: &JsonWebKey) -> (Header, TokenClaims, EncodingKey) { let mut header = jsonwebtoken::Header::new(jwk.algorithm.unwrap().into()); - header.kid = Some(jwk.key_id.clone().unwrap()); + header.kid = Some(jwk.key_id.clone().expect("No key id")); + + let claims = TokenClaims::new(); - jsonwebtoken::encode( - &header, - &TokenClaims::new() - .with_exp_offset(exp_offset) - .with_sub(user_id), - &jwk.key.to_encoding_key(), - ) - .unwrap() + (header, claims, jwk.key.to_encoding_key()) } #[derive(Debug, Clone, Serialize)] From 3673c89deeb797cc658a130fba74eb556a267ebd Mon Sep 17 00:00:00 2001 From: Bushuo Date: Wed, 14 Feb 2024 23:38:38 +0100 Subject: [PATCH 20/41] =?UTF-8?q?refactor:=20yeet=20oath2=20lib,=20yank=20?= =?UTF-8?q?secrecy=20lib,=20make=20auth=20lit=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Cargo.lock | 147 ++--------------------------- backend/Cargo.toml | 4 +- backend/src/config/app.rs | 48 ++++------ backend/src/keycloak_api/api.rs | 114 +++++++++------------- backend/src/keycloak_api/errors.rs | 16 +--- 5 files changed, 76 insertions(+), 253 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1e13d86e6..153009927 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -394,13 +394,14 @@ dependencies = [ "jsonwebkey", "jsonwebtoken", "log", - "oauth2", "postgis_diesel", "reqwest", + "secrecy", "serde", "serde_json", "tokio", "typeshare", + "url", "utoipa", "utoipa-swagger-ui", "uuid", @@ -1109,10 +1110,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -1255,19 +1254,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" -dependencies = [ - "http", - "hyper", - "rustls", - "tokio", - "tokio-rustls", -] - [[package]] name = "hyper-tls" version = "0.5.0" @@ -1445,7 +1431,7 @@ checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" dependencies = [ "base64 0.21.0", "pem", - "ring 0.16.20", + "ring", "serde", "serde_json", "simple_asn1", @@ -1658,26 +1644,6 @@ dependencies = [ "libc", ] -[[package]] -name = "oauth2" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" -dependencies = [ - "base64 0.13.1", - "chrono", - "getrandom", - "http", - "rand", - "reqwest", - "serde", - "serde_json", - "serde_path_to_error", - "sha2 0.10.6", - "thiserror", - "url", -] - [[package]] name = "once_cell" version = "1.17.1" @@ -2036,7 +2002,6 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -2046,20 +2011,16 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", - "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", "winreg", ] @@ -2078,26 +2039,12 @@ dependencies = [ "cc", "libc", "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", + "spin", + "untrusted", "web-sys", "winapi", ] -[[package]] -name = "ring" -version = "0.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babe80d5c16becf6594aa32ad2be8fe08498e7ae60b77de8df700e67f191d7e" -dependencies = [ - "cc", - "getrandom", - "libc", - "spin 0.9.8", - "untrusted 0.9.0", - "windows-sys 0.48.0", -] - [[package]] name = "rust-embed" version = "6.6.1" @@ -2156,27 +2103,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log", - "ring 0.16.20", - "sct", - "webpki", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.0", -] - [[package]] name = "ryu" version = "1.0.13" @@ -2224,13 +2150,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" [[package]] -name = "sct" -version = "0.7.1" +name = "secrecy" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" dependencies = [ - "ring 0.17.3", - "untrusted 0.9.0", + "serde", + "zeroize", ] [[package]] @@ -2306,16 +2232,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd154a240de39fdebcf5775d2675c204d7c13cf39a4c697be6493c8e734337c" -dependencies = [ - "itoa", - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2456,12 +2372,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - [[package]] name = "spki" version = "0.4.1" @@ -2657,17 +2567,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tokio-rustls" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" -dependencies = [ - "rustls", - "tokio", - "webpki", -] - [[package]] name = "tokio-util" version = "0.7.7" @@ -2797,12 +2696,6 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.0" @@ -2812,7 +2705,6 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", - "serde", ] [[package]] @@ -2982,25 +2874,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" -dependencies = [ - "ring 0.17.3", - "untrusted 0.9.0", -] - -[[package]] -name = "webpki-roots" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" -dependencies = [ - "webpki", -] - [[package]] name = "winapi" version = "0.3.9" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8427c690f..60f38939d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -31,7 +31,6 @@ actix-web-grants = "3.0.1" actix-web-httpauth = "0.8.0" reqwest = { version = "0.11.17", features = ["json"] } jsonwebtoken = "8.3.0" -oauth2 = "4.4.2" # Data diesel = { version = "2.0.2", features = [ @@ -57,7 +56,8 @@ env_logger = "0.10.0" futures = "0.3.28" futures-util = "0.3.30" image = { version = "0.24.6", default-features = false, features = ["png"] } - +secrecy = { version = "0.8", features = ["serde"] } +url = "2.5.0" [dev-dependencies] jsonwebkey = { version = "0.3.5", features = ["generate", "jwt-convert"] } diff --git a/backend/src/config/app.rs b/backend/src/config/app.rs index 71317e6e7..daf64a498 100644 --- a/backend/src/config/app.rs +++ b/backend/src/config/app.rs @@ -3,8 +3,8 @@ use std::env; use dotenvy::dotenv; -use oauth2::{ClientId, ClientSecret, ResourceOwnerPassword, ResourceOwnerUsername}; use reqwest::Url; +use secrecy::Secret; /// Environment variables that are required for the configuration of the server. pub struct EnvVars { @@ -13,10 +13,8 @@ pub struct EnvVars { pub database_url: &'static str, pub auth_host: &'static str, pub auth_client_id: &'static str, - pub keycloak_client_id: &'static str, - pub keycloak_client_secret: &'static str, - pub keycloak_username: &'static str, - pub keycloak_password: &'static str, + pub auth_admin_client_id: &'static str, + pub auth_admin_client_secret: &'static str, } /// Holds the names of all required environment variables @@ -26,10 +24,8 @@ const ENV_VARS: EnvVars = EnvVars { database_url: "DATABASE_URL", auth_host: "AUTH_HOST", auth_client_id: "AUTH_CLIENT_ID", - keycloak_client_id: "KEYCLOAK_CLIENT_ID", - keycloak_client_secret: "KEYCLOAK_CLIENT_SECRET", - keycloak_username: "KEYCLOAK_USERNAME", - keycloak_password: "KEYCLOAK_PASSWORD", + auth_admin_client_id: "AUTH_ADMIN_CLIENT_ID", + auth_admin_client_secret: "AUTH_ADMIN_CLIENT_SECRET", }; /// Configuration data for the server. @@ -45,16 +41,12 @@ pub struct Config { /// The `client_id` the frontend should use to log in its users. pub client_id: String, - /// The URI of the auth server used to acquire a token for the admin API. - pub keycloak_auth_uri: Url, + /// The URI of the auth server used to acquire a token. + pub auth_token_uri: Url, /// The `client_id` the backend uses to communicate with the auth server. - pub keycloak_client_id: ClientId, + pub auth_admin_client_id: String, /// The `client_secret` the backend uses to communicate with the auth server. - pub keycloak_client_secret: ClientSecret, - /// The `username` the backend uses to communicate with the auth server. - pub keycloak_username: ResourceOwnerUsername, - /// The `password` the backend uses to communicate with the auth server. - pub keycloak_password: ResourceOwnerPassword, + pub auth_admin_client_secret: Secret, } impl Config { @@ -80,32 +72,26 @@ impl Config { let auth_discovery_uri = format!("{auth_host}/realms/PermaplanT/.well-known/openid-configuration"); - let keycloak_auth_uri = format!("{auth_host}/realms/master/protocol/openid-connect/token") + let auth_token_uri = format!("{auth_host}/realms/master/protocol/openid-connect/token") .parse::() .map_err(|e| e.to_string())?; let client_id = env::var(ENV_VARS.auth_client_id).map_err(|_| env_error(ENV_VARS.auth_client_id))?; - let keycloak_client_id = env::var(ENV_VARS.keycloak_client_id) - .map_err(|_| env_error(ENV_VARS.keycloak_client_id))?; - let keycloak_client_secret = env::var(ENV_VARS.keycloak_client_secret) - .map_err(|_| env_error(ENV_VARS.keycloak_client_secret))?; - let keycloak_username = env::var(ENV_VARS.keycloak_username) - .map_err(|_| env_error(ENV_VARS.keycloak_username))?; - let keycloak_password = env::var(ENV_VARS.keycloak_password) - .map_err(|_| env_error(ENV_VARS.keycloak_password))?; + let keycloak_client_id = env::var(ENV_VARS.auth_admin_client_id) + .map_err(|_| env_error(ENV_VARS.auth_admin_client_id))?; + let keycloak_client_secret = env::var(ENV_VARS.auth_admin_client_secret) + .map_err(|_| env_error(ENV_VARS.auth_admin_client_secret))?; Ok(Self { bind_address: (host, port), database_url, auth_discovery_uri, client_id, - keycloak_auth_uri, - keycloak_client_id: ClientId::new(keycloak_client_id), - keycloak_client_secret: ClientSecret::new(keycloak_client_secret), - keycloak_username: ResourceOwnerUsername::new(keycloak_username), - keycloak_password: ResourceOwnerPassword::new(keycloak_password), + auth_token_uri, + auth_admin_client_id: keycloak_client_id, + auth_admin_client_secret: Secret::new(keycloak_client_secret), }) } } diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 6acf23324..0b7f7ff27 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -5,14 +5,9 @@ use std::time::Instant; use actix_web::cookie::time::Duration; use futures_util::{stream, StreamExt}; -use oauth2::basic::BasicClient; -use oauth2::reqwest::Error; -use oauth2::{ - AccessToken, AuthUrl, HttpRequest, HttpResponse, ResourceOwnerPassword, ResourceOwnerUsername, - TokenResponse, TokenUrl, -}; use reqwest::header::HeaderValue; use reqwest::Url; +use secrecy::{ExposeSecret, Secret}; use serde::de::DeserializeOwned; use tokio::sync::Mutex; @@ -27,28 +22,37 @@ type Result = std::result::Result; /// The keycloak admin API. #[derive(Clone)] pub struct Api { - /// Oauth2 client for auth with Keycloak. - oauth_api: BasicClient, - /// Username for auth with Keycloak. - username: ResourceOwnerUsername, - /// Password for auth with Keycloak. - password: ResourceOwnerPassword, - /// Base url for the Keycloak admin API. + /// Base url for the Keycloak admin REST API. base_url: Url, /// Cached access token (needs to be thread safe). /// Might be expired, in which case it will be refreshed. auth_data: Arc>>, + /// Url for requesting the access token from the auth server. + token_url: Url, + /// The client id for the oauth2 client. + client_id: String, + /// The client secret for the oauth2 client. + client_secret: Secret, } /// Helper struct to cache the access token and its expiration time. #[derive(Clone)] struct AuthData { /// The access token. - access_token: AccessToken, - /// The time when the access token expires. + access_token: Secret, + /// Timestamp the token expires. expires_at: Instant, } +/// Helper struct to deserialize the token response. +#[derive(serde::Deserialize)] +struct TokenResponse { + /// The access token. + pub access_token: Secret, + /// Timestamp the token expires. + pub expires_in: i64, +} + impl Api { /// Creates a new Keycloak API. /// @@ -57,23 +61,18 @@ impl Api { #[allow(clippy::expect_used)] #[must_use] pub fn new(config: &Config) -> Self { - let auth_url = AuthUrl::from_url(config.keycloak_auth_uri.clone()); - let token_url = TokenUrl::from_url(config.keycloak_auth_uri.clone()); - let mut base_url = to_base_url(token_url.url().clone()); + let token_url = config.auth_token_uri.clone(); + let mut base_url = to_base_url(token_url.clone()); base_url.set_path("admin/realms/PermaplanT"); - let oauth_api = BasicClient::new( - config.keycloak_client_id.clone(), - Some(config.keycloak_client_secret.clone()), - auth_url, - Some(token_url), - ); + let client_secret = config.auth_admin_client_secret.clone(); + let client_id = config.auth_admin_client_id.clone(); Self { - oauth_api, - username: config.keycloak_username.clone(), - password: config.keycloak_password.clone(), base_url, + token_url, + client_id, + client_secret, auth_data: Arc::new(Mutex::new(None)), } } @@ -157,7 +156,8 @@ impl Api { let mut request = reqwest::Request::new(reqwest::Method::GET, url); let token = self.get_or_refresh_access_token(client).await?; - let token_header = HeaderValue::from_str(&format!("Bearer {}", token.secret()))?; + let token_header = + HeaderValue::from_str(&format!("Bearer {}", token.expose_secret().as_str()))?; request.headers_mut().append("Authorization", token_header); // There is a `json` method, but then we can not log the response for debugging easily. @@ -168,7 +168,10 @@ impl Api { } /// Gets the access token or refreshes it if it is expired. - async fn get_or_refresh_access_token(&self, client: &reqwest::Client) -> Result { + async fn get_or_refresh_access_token( + &self, + client: &reqwest::Client, + ) -> Result> { let mut guard = self.auth_data.lock().await; match &*guard { @@ -189,21 +192,21 @@ impl Api { /// Refresh the access token. async fn refresh_access_token(&self, client: &reqwest::Client) -> Result { - let token_result = self - .oauth_api - .exchange_password(&self.username, &self.password) - .request_async(|req| send_token_request(client, req)) + let token_response = client + .post(self.token_url.clone()) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", &self.client_id), + ("client_secret", self.client_secret.expose_secret().as_str()), + ]) + .send() + .await? + .json::() .await?; - let access_token = token_result.access_token().clone(); - let expires_at = token_result.expires_in().map_or_else( - || { - Err(KeycloakApiError::Other( - "No expires_in in token response".to_owned(), - )) - }, - |expires_in| Ok(Instant::now() + expires_in - Duration::seconds(5)), - )?; + let access_token = token_response.access_token.clone(); + let expires_at = + Instant::now() + Duration::seconds(token_response.expires_in) - Duration::seconds(5); Ok(AuthData { access_token, @@ -212,33 +215,6 @@ impl Api { } } -/// Helper function to send a token request. -/// -/// Basically a copy of [`oauth2::reqwest::async_http_client`]. -/// Uses a client instance instead of a new client. -/// This enables connection pooling. See: -async fn send_token_request( - client: &reqwest::Client, - request: HttpRequest, -) -> std::result::Result> { - let request_builder = client - .request(request.method, request.url.as_str()) - .headers(request.headers) - .body(request.body); - let req = request_builder.build().map_err(Error::Reqwest)?; - - let response = client.execute(req).await.map_err(Error::Reqwest)?; - - let status_code = response.status(); - let headers = response.headers().to_owned(); - let chunks = response.bytes().await.map_err(Error::Reqwest)?; - Ok(HttpResponse { - status_code, - headers, - body: chunks.to_vec(), - }) -} - /// Helper function to create a base URL. /// /// # Panics diff --git a/backend/src/keycloak_api/errors.rs b/backend/src/keycloak_api/errors.rs index 1688245af..eb9d91cf7 100644 --- a/backend/src/keycloak_api/errors.rs +++ b/backend/src/keycloak_api/errors.rs @@ -5,8 +5,6 @@ use std::{ fmt::{self, Display, Formatter}, }; -use oauth2::ErrorResponse; - #[derive(Debug, Clone)] pub enum KeycloakApiError { Reqwest(String), @@ -30,8 +28,8 @@ impl From for KeycloakApiError { } } -impl From for KeycloakApiError { - fn from(err: oauth2::url::ParseError) -> Self { +impl From for KeycloakApiError { + fn from(err: url::ParseError) -> Self { Self::Other(err.to_string()) } } @@ -42,16 +40,6 @@ impl From for KeycloakApiError { } } -impl From> for KeycloakApiError -where - RE: Error, - T: ErrorResponse, -{ - fn from(value: oauth2::RequestTokenError) -> Self { - Self::Other(value.to_string()) - } -} - impl From for KeycloakApiError { fn from(err: serde_json::Error) -> Self { Self::Other(err.to_string()) From f45998e694850b65eaa51417ed5ddb398e503505 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Thu, 15 Feb 2024 20:27:23 +0100 Subject: [PATCH 21/41] chore: improve load testing docs, adapt env.sample --- backend/.env.sample | 8 +++----- backend/load-testing/README.md | 2 -- benchmarks/load-testing/README.md | 14 ++++++++++++++ .../load-testing/src/get-users.js | 4 ++-- .../load-testing/src/oauth/keycloak.js | 0 5 files changed, 19 insertions(+), 9 deletions(-) delete mode 100644 backend/load-testing/README.md create mode 100644 benchmarks/load-testing/README.md rename {backend => benchmarks}/load-testing/src/get-users.js (85%) rename {backend => benchmarks}/load-testing/src/oauth/keycloak.js (100%) diff --git a/backend/.env.sample b/backend/.env.sample index 56dd2909a..2532de8a9 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -7,11 +7,9 @@ BIND_ADDRESS_PORT=8080 AUTH_HOST=https://auth.permaplant.net AUTH_CLIENT_ID=localhost -# Keycloak API (will be used to fetch user info) -KEYCLOAK_CLIENT_ID=admin-cli -KEYCLOAK_CLIENT_SECRET=configured-in-keycloak -KEYCLOAK_USERNAME=configured-in-keycloak -KEYCLOAK_PASSWORD=configured-in-keycloak +# Keycloak API Credentials (will be used to fetch user info) +AUTH_ADMIN_CLIENT_ID=configured-in-keycloak +AUTH_ADMIN_CLIENT_SECRET=configured-in-keycloak # Logging config (will be used by env_logger) RUST_LOG='backend=debug,actix_web=debug' diff --git a/backend/load-testing/README.md b/backend/load-testing/README.md deleted file mode 100644 index 3d80822a3..000000000 --- a/backend/load-testing/README.md +++ /dev/null @@ -1,2 +0,0 @@ -This directory contains the load testing scripts for the backend. -The tool used is [k6](https://k6.io/). diff --git a/benchmarks/load-testing/README.md b/benchmarks/load-testing/README.md new file mode 100644 index 000000000..07272da13 --- /dev/null +++ b/benchmarks/load-testing/README.md @@ -0,0 +1,14 @@ +This directory contains the load testing scripts for the backend. +The tool used is [k6](https://k6.io/). + +## Installation + +Follow the guide in the [k6 installation docs](https://grafana.com/docs/k6/latest/get-started/installation/) depending on your operating system. + +## Running + +Replace the values in auth with your own PermaplanT credentials. +Navigate into the `./benchmarks/load-testing` directory. +Run `k6 run src/get-users.js` or any other script in the directory. + +For a more detailed guide look into the [docs](https://grafana.com/docs/k6/latest/). diff --git a/backend/load-testing/src/get-users.js b/benchmarks/load-testing/src/get-users.js similarity index 85% rename from backend/load-testing/src/get-users.js rename to benchmarks/load-testing/src/get-users.js index f9867b6b1..9a11685e7 100644 --- a/backend/load-testing/src/get-users.js +++ b/benchmarks/load-testing/src/get-users.js @@ -17,6 +17,7 @@ export function setup() { return authenticate( "http://localhost:8081/realms/PermaplanT/protocol/openid-connect/token", "PermaplanT", + // You should replace these values with your own. "test", "test" ); @@ -30,7 +31,6 @@ export default function (data) { }, }; - let response = http.get("http://localhost:8080/api/users", params); - console.log(response); + let response = http.get("http://localhost:8080/api/users?username=", params); sleep(1); } diff --git a/backend/load-testing/src/oauth/keycloak.js b/benchmarks/load-testing/src/oauth/keycloak.js similarity index 100% rename from backend/load-testing/src/oauth/keycloak.js rename to benchmarks/load-testing/src/oauth/keycloak.js From 32934575ff8554e71f50b301fe9a4145147cd340 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Thu, 15 Feb 2024 20:36:00 +0100 Subject: [PATCH 22/41] chore: add docs for keycloak admin rest api setup --- doc/setups/keycloak/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/setups/keycloak/README.md b/doc/setups/keycloak/README.md index edd359112..9836bf8be 100644 --- a/doc/setups/keycloak/README.md +++ b/doc/setups/keycloak/README.md @@ -31,3 +31,17 @@ Create a second client `swagger-ui` with `Root URL = http://localhost:8080/doc/a Go to `Users` and create a user `test`. Click `Credentials` and set password to `test`. + +## Setup the Keycloak Admin REST API (for collaboration & users endpoints): + +Make sure you are on the `master` realm. +Click on `Clients` -> `Create client`. +Set the `Client ID` to `permaplant-server`. +Click `Next`. +Switch on `Client authentication`. +Under `Authentication flow` uncheck everything but `Service account roles`. +Click `Next` and `Save`. +Under `Clients` chose the newly created `permaplant-server` client. +Under `Service account roles` assign the role `admin`. +From `Credentials` copy the `Client secret` into the related `AUTH_ADMIN_CLIENT_SECRET` environment variable. +Copy `permaplant-server` into the related `AUTH_ADMIN_CLIENT_ID` environment variable. From b5856770b737f8e6145562484192411eed23cc5e Mon Sep 17 00:00:00 2001 From: Bushuo Date: Thu, 15 Feb 2024 21:32:37 +0100 Subject: [PATCH 23/41] wip: pagination of user api --- backend/src/controller/users.rs | 13 +++++-- backend/src/keycloak_api/api.rs | 60 ++++++++++++++++++++---------- backend/src/keycloak_api/errors.rs | 4 ++ backend/src/service/users.rs | 29 ++++----------- 4 files changed, 61 insertions(+), 45 deletions(-) diff --git a/backend/src/controller/users.rs b/backend/src/controller/users.rs index 2d2450514..befaf80a6 100644 --- a/backend/src/controller/users.rs +++ b/backend/src/controller/users.rs @@ -11,7 +11,7 @@ use crate::{ auth::user_info::UserInfo, data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, }, - model::dto::{UserSearchParameters, UsersDto}, + model::dto::{PageParameters, UserSearchParameters, UsersDto}, service, }; @@ -34,12 +34,17 @@ use crate::{ #[get("")] pub async fn find( search_query: Query, + pagination_query: Query, keycloak_api: SharedKeycloakApi, http_client: SharedHttpClient, ) -> Result { - let response = - service::users::search_by_username(&search_query.username, &keycloak_api, &http_client) - .await?; + let response = service::users::search_by_username( + &search_query, + &pagination_query, + &keycloak_api, + &http_client, + ) + .await?; Ok(HttpResponse::Ok().json(response)) } diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 0b7f7ff27..a01943e32 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -1,4 +1,6 @@ //! This module contains the implementation of the client for the keycloak admin API. +//! Find the documentation here of the keycloak admin API here: +//! use std::sync::Arc; use std::time::Instant; @@ -13,12 +15,21 @@ use tokio::sync::Mutex; use crate::config::app::Config; use crate::keycloak_api::dtos::UserDto; +use crate::model::dto::{PageParameters, UserSearchParameters}; use super::errors::KeycloakApiError; /// Helper type for results. type Result = std::result::Result; +/// The default number of rows returned from a paginated request. +pub const DEFAULT_PER_PAGE: i32 = 10; +/// The minimum value for page number in a paginated request. +/// Pages start at 1. Using a lower value would lead to nonsensical queries. +pub const MIN_PAGE: i32 = 1; +/// The minimum number of rows returned from a paginated query. +pub const MIN_PER_PAGE: i32 = 1; + /// The keycloak admin API. #[derive(Clone)] pub struct Api { @@ -77,16 +88,6 @@ impl Api { } } - /// Gets all users from the Keycloak API. - /// - /// # Errors - /// - If the url cannot be parsed. - /// - If the authorization header cannot be created. - /// - If the request fails or the response cannot be deserialized. - pub async fn get_users(&self, client: &reqwest::Client) -> Result> { - self.get::>(client, "/users").await - } - /// Search for users by their username. /// /// # Errors @@ -95,11 +96,23 @@ impl Api { /// - If the request fails or the response cannot be deserialized. pub async fn search_users_by_username( &self, - username: &str, + search_params: &UserSearchParameters, + pagination: &PageParameters, client: &reqwest::Client, ) -> Result> { - self.get::>(client, &format!("/users?username={username}")) - .await + let mut url = self.make_url("/users"); + + url.query_pairs_mut() + .append_pair("username", &search_params.username) + .append_pair( + "first", + &format!("{}", &pagination.page.unwrap_or(MIN_PAGE)), + ) + .append_pair( + "max", + &format!("{}", &pagination.per_page.unwrap_or(MIN_PAGE)), + ); + self.get::>(client, url).await } /// Gets all users given their ids from the Keycloak API. @@ -146,14 +159,12 @@ impl Api { client: &reqwest::Client, user_id: uuid::Uuid, ) -> Result { - self.get::(client, &format!("/users/{user_id}")) - .await + let url = self.make_url(&format!("/users/{user_id}")); + self.get::(client, url).await } /// Executes a get request authenticated with the access token. - async fn get(&self, client: &reqwest::Client, path: &str) -> Result { - let url = Url::parse(&format!("{}{}", self.base_url, path))?; - + async fn get(&self, client: &reqwest::Client, url: Url) -> Result { let mut request = reqwest::Request::new(reqwest::Method::GET, url); let token = self.get_or_refresh_access_token(client).await?; let token_header = @@ -162,8 +173,12 @@ impl Api { // There is a `json` method, but then we can not log the response for debugging easily. let response = client.execute(request).await?; - let response_text = response.text().await?; + if let Err(err) = response.error_for_status_ref() { + return Err(KeycloakApiError::Reqwest(err.to_string())); + } + + let response_text = response.text().await?; Ok(serde_json::from_str(&response_text)?) } @@ -213,6 +228,13 @@ impl Api { expires_at, }) } + + /// Creates a URL from the base URL and the given path. + fn make_url(&self, path: &str) -> url::Url { + let mut url = self.base_url.clone(); + url.set_path(&format!("{}{}", self.base_url.path(), path)); + url + } } /// Helper function to create a base URL. diff --git a/backend/src/keycloak_api/errors.rs b/backend/src/keycloak_api/errors.rs index eb9d91cf7..ae4a61aad 100644 --- a/backend/src/keycloak_api/errors.rs +++ b/backend/src/keycloak_api/errors.rs @@ -24,24 +24,28 @@ impl Error for KeycloakApiError {} impl From for KeycloakApiError { fn from(err: reqwest::Error) -> Self { + log::debug!("Reqwest error: {err}"); Self::Reqwest(err.to_string()) } } impl From for KeycloakApiError { fn from(err: url::ParseError) -> Self { + log::debug!("ParseError error: {err}"); Self::Other(err.to_string()) } } impl From for KeycloakApiError { fn from(err: actix_http::header::InvalidHeaderValue) -> Self { + log::debug!("InvalidHeaderValue error: {err}"); Self::Other(err.to_string()) } } impl From for KeycloakApiError { fn from(err: serde_json::Error) -> Self { + log::debug!("serde_json error: {err}"); Self::Other(err.to_string()) } } diff --git a/backend/src/service/users.rs b/backend/src/service/users.rs index 4fa6d8fd9..dfab37dc2 100644 --- a/backend/src/service/users.rs +++ b/backend/src/service/users.rs @@ -7,7 +7,10 @@ use crate::{ config::data::{SharedHttpClient, SharedKeycloakApi, SharedPool}, error::ServiceError, keycloak_api::dtos::UserDto, - model::{dto::UsersDto, entity::Users}, + model::{ + dto::{PageParameters, UserSearchParameters, UsersDto}, + entity::Users, + }, }; /// Create a user data entry for a new user. @@ -24,25 +27,6 @@ pub async fn create( Ok(result) } -/// Get all users. -/// -/// # Errors -/// * If the connection to the Keycloak API could not be established. -pub async fn find( - keycloak_api: &SharedKeycloakApi, - http_client: &SharedHttpClient, -) -> Result, ServiceError> { - let users = keycloak_api.get_users(http_client).await.map_err(|e| { - log::error!("Error getting user data from Keycloak API: {e}"); - ServiceError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "Error getting user data from Keycloak API".to_owned(), - ) - })?; - - Ok(users) -} - /// Get a user by its id. /// /// # Errors @@ -94,12 +78,13 @@ pub async fn find_by_ids( /// # Errors /// * If the connection to the Keycloak API could not be established. pub async fn search_by_username( - username: &str, + search_params: &UserSearchParameters, + pagination: &PageParameters, keycloak_api: &SharedKeycloakApi, http_client: &SharedHttpClient, ) -> Result, ServiceError> { let users = keycloak_api - .search_users_by_username(username, http_client) + .search_users_by_username(search_params, pagination, http_client) .await .map_err(|e| { log::error!("Error getting user data from Keycloak API: {e}"); From da1886c9f99335d1210f08f135ed664ac2ad527e Mon Sep 17 00:00:00 2001 From: Bushuo Date: Thu, 15 Feb 2024 22:58:45 +0100 Subject: [PATCH 24/41] feat: pagination for user_search --- backend/src/controller/users.rs | 2 ++ backend/src/keycloak_api/api.rs | 28 +++++++++++++++++--------- backend/src/service/users.rs | 10 ++++++++- doc/setups/keycloak/docker-compose.yml | 25 ++++++++++++----------- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/backend/src/controller/users.rs b/backend/src/controller/users.rs index befaf80a6..13e8827f6 100644 --- a/backend/src/controller/users.rs +++ b/backend/src/controller/users.rs @@ -35,12 +35,14 @@ use crate::{ pub async fn find( search_query: Query, pagination_query: Query, + user_info: UserInfo, keycloak_api: SharedKeycloakApi, http_client: SharedHttpClient, ) -> Result { let response = service::users::search_by_username( &search_query, &pagination_query, + user_info.id, &keycloak_api, &http_client, ) diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index a01943e32..742a674cc 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -100,18 +100,28 @@ impl Api { pagination: &PageParameters, client: &reqwest::Client, ) -> Result> { - let mut url = self.make_url("/users"); + let page = pagination + .page + .map_or(MIN_PAGE, |v| if v < MIN_PAGE { MIN_PAGE } else { v }); + let per_page = pagination.per_page.map_or(DEFAULT_PER_PAGE, |v| { + if v < MIN_PER_PAGE { + MIN_PER_PAGE + } else { + v + } + }); + let first = (page - 1) * per_page; + + let mut url = self.make_url("/users"); url.query_pairs_mut() .append_pair("username", &search_params.username) - .append_pair( - "first", - &format!("{}", &pagination.page.unwrap_or(MIN_PAGE)), - ) - .append_pair( - "max", - &format!("{}", &pagination.per_page.unwrap_or(MIN_PAGE)), - ); + .append_pair("first", &format!("{first}")) + .append_pair("max", &format!("{per_page}")) + // optimize the response by only requesting the brief representation + .append_pair("briefRepresentation", "true") + // only request enabled users + .append_pair("enabled", "true"); self.get::>(client, url).await } diff --git a/backend/src/service/users.rs b/backend/src/service/users.rs index dfab37dc2..3f6836c38 100644 --- a/backend/src/service/users.rs +++ b/backend/src/service/users.rs @@ -80,6 +80,7 @@ pub async fn find_by_ids( pub async fn search_by_username( search_params: &UserSearchParameters, pagination: &PageParameters, + user_id: Uuid, keycloak_api: &SharedKeycloakApi, http_client: &SharedHttpClient, ) -> Result, ServiceError> { @@ -94,5 +95,12 @@ pub async fn search_by_username( ) })?; - Ok(users) + // Filter out the user making the request + let filtered_users = users + .iter() + .filter(|user| user.id != user_id) + .cloned() + .collect::>(); + + Ok(filtered_users) } diff --git a/doc/setups/keycloak/docker-compose.yml b/doc/setups/keycloak/docker-compose.yml index 0af0a7c04..c63038d66 100644 --- a/doc/setups/keycloak/docker-compose.yml +++ b/doc/setups/keycloak/docker-compose.yml @@ -1,24 +1,25 @@ version: "2" services: - postgresql: - image: docker.io/bitnami/postgresql:11 - environment: - # ALLOW_EMPTY_PASSWORD is recommended only for development. - - ALLOW_EMPTY_PASSWORD=yes - - POSTGRESQL_USERNAME=bn_keycloak - - POSTGRESQL_DATABASE=bitnami_keycloak - - KEYCLOAK_ADMIN_PASSWORD=admin - - KEYCLOAK_ADMIN_USER=admin - volumes: - - "postgresql_data:/bitnami/postgresql" - keycloak: image: docker.io/bitnami/keycloak:21 + environment: + KEYCLOAK_ADMIN_USER: admin + KEYCLOAK_ADMIN_PASSWORD: admin depends_on: - postgresql ports: - "8081:8080" + postgresql: + image: docker.io/bitnami/postgresql:11 + environment: + # ALLOW_EMPTY_PASSWORD is recommended only for development. + ALLOW_EMPTY_PASSWORD: yes + POSTGRESQL_USERNAME: bn_keycloak + POSTGRESQL_DATABASE: bitnami_keycloak + volumes: + - "postgresql_data:/bitnami/postgresql" + volumes: postgresql_data: driver: local From 19252ce2f68a74f3e9faedb957768c43972f4537 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Thu, 15 Feb 2024 23:53:09 +0100 Subject: [PATCH 25/41] wip: polymorphic keycloak api --- backend/Cargo.lock | 5 ++- backend/Cargo.toml | 1 + backend/src/config/data.rs | 16 +++++++- backend/src/keycloak_api/api.rs | 64 +++++++++++++++--------------- backend/src/keycloak_api/mod.rs | 1 + backend/src/keycloak_api/traits.rs | 28 +++++++++++++ 6 files changed, 80 insertions(+), 35 deletions(-) create mode 100644 backend/src/keycloak_api/traits.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 153009927..90f30b645 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -353,9 +353,9 @@ checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" [[package]] name = "async-trait" -version = "0.1.68" +version = "0.1.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", @@ -381,6 +381,7 @@ dependencies = [ "actix-web-grants", "actix-web-httpauth", "actix-web-lab", + "async-trait", "chrono", "derive_more", "diesel", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 60f38939d..e55205239 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -58,6 +58,7 @@ futures-util = "0.3.30" image = { version = "0.24.6", default-features = false, features = ["png"] } secrecy = { version = "0.8", features = ["serde"] } url = "2.5.0" +async-trait = "0.1.77" [dev-dependencies] jsonwebkey = { version = "0.3.5", features = ["generate", "jwt-convert"] } diff --git a/backend/src/config/data.rs b/backend/src/config/data.rs index b1f115b55..d27da7aec 100644 --- a/backend/src/config/data.rs +++ b/backend/src/config/data.rs @@ -1,5 +1,7 @@ //! Configurations for shared data that is available to all controllers. +use std::sync::Arc; + use actix_web::web::Data; use crate::config::app::Config; @@ -14,7 +16,7 @@ pub type SharedPool = Data; pub type SharedBroadcaster = Data; /// Helper-Type - Keycloak admin API. -pub type SharedKeycloakApi = Data; +pub type SharedKeycloakApi = Data; /// Helper-Type - Pooled HTTP client. pub type SharedHttpClient = Data; @@ -37,10 +39,20 @@ pub struct SharedInit { /// If the database pool can not be initialized. #[must_use] pub fn init(config: &Config) -> SharedInit { + let api = Data::from(create_api(config)); + SharedInit { + keycloak_api: api, pool: Data::new(connection::init_pool(&config.database_url)), broadcaster: Data::new(Broadcaster::new()), - keycloak_api: Data::new(keycloak_api::api::Api::new(config)), http_client: Data::new(reqwest::Client::new()), } } + +/// Creates a new Keycloak API. +#[must_use] +pub fn create_api( + config: &Config, +) -> Arc { + Arc::new(keycloak_api::api::Api::new(config)) +} diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 742a674cc..99dc5983e 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::time::Instant; use actix_web::cookie::time::Duration; +use async_trait::async_trait; use futures_util::{stream, StreamExt}; use reqwest::header::HeaderValue; use reqwest::Url; @@ -17,10 +18,8 @@ use crate::config::app::Config; use crate::keycloak_api::dtos::UserDto; use crate::model::dto::{PageParameters, UserSearchParameters}; -use super::errors::KeycloakApiError; - -/// Helper type for results. -type Result = std::result::Result; +use super::traits::KeycloakApi; +use super::{errors::KeycloakApiError, traits::Result}; /// The default number of rows returned from a paginated request. pub const DEFAULT_PER_PAGE: i32 = 10; @@ -64,37 +63,15 @@ struct TokenResponse { pub expires_in: i64, } -impl Api { - /// Creates a new Keycloak API. - /// - /// # Panics - /// If the config does not contain a valid keycloak auth URI. - #[allow(clippy::expect_used)] - #[must_use] - pub fn new(config: &Config) -> Self { - let token_url = config.auth_token_uri.clone(); - let mut base_url = to_base_url(token_url.clone()); - base_url.set_path("admin/realms/PermaplanT"); - - let client_secret = config.auth_admin_client_secret.clone(); - let client_id = config.auth_admin_client_id.clone(); - - Self { - base_url, - token_url, - client_id, - client_secret, - auth_data: Arc::new(Mutex::new(None)), - } - } - +#[async_trait] +impl KeycloakApi for Api { /// Search for users by their username. /// /// # Errors /// - If the url cannot be parsed. /// - If the authorization header cannot be created. /// - If the request fails or the response cannot be deserialized. - pub async fn search_users_by_username( + async fn search_users_by_username( &self, search_params: &UserSearchParameters, pagination: &PageParameters, @@ -131,7 +108,7 @@ impl Api { /// - If the url cannot be parsed. /// - If the authorization header cannot be created. /// - If the request fails or the response cannot be deserialized. - pub async fn get_users_by_ids( + async fn get_users_by_ids( &self, client: &reqwest::Client, user_ids: Vec, @@ -164,7 +141,7 @@ impl Api { /// - If the url cannot be parsed. /// - If the authorization header cannot be created. /// - If the request fails or the response cannot be deserialized. - pub async fn get_user_by_id( + async fn get_user_by_id( &self, client: &reqwest::Client, user_id: uuid::Uuid, @@ -172,6 +149,31 @@ impl Api { let url = self.make_url(&format!("/users/{user_id}")); self.get::(client, url).await } +} + +impl Api { + /// Creates a new Keycloak API. + /// + /// # Panics + /// If the config does not contain a valid keycloak auth URI. + #[allow(clippy::expect_used)] + #[must_use] + pub fn new(config: &Config) -> Self { + let token_url = config.auth_token_uri.clone(); + let mut base_url = to_base_url(token_url.clone()); + base_url.set_path("admin/realms/PermaplanT"); + + let client_secret = config.auth_admin_client_secret.clone(); + let client_id = config.auth_admin_client_id.clone(); + + Self { + base_url, + token_url, + client_id, + client_secret, + auth_data: Arc::new(Mutex::new(None)), + } + } /// Executes a get request authenticated with the access token. async fn get(&self, client: &reqwest::Client, url: Url) -> Result { diff --git a/backend/src/keycloak_api/mod.rs b/backend/src/keycloak_api/mod.rs index 4f56d89ba..c16b953ad 100644 --- a/backend/src/keycloak_api/mod.rs +++ b/backend/src/keycloak_api/mod.rs @@ -3,5 +3,6 @@ pub mod api; pub mod dtos; pub mod errors; +pub mod traits; pub use dtos::UserDto; diff --git a/backend/src/keycloak_api/traits.rs b/backend/src/keycloak_api/traits.rs new file mode 100644 index 000000000..3bd1d5376 --- /dev/null +++ b/backend/src/keycloak_api/traits.rs @@ -0,0 +1,28 @@ +use async_trait::async_trait; + +use crate::model::dto::{PageParameters, UserSearchParameters}; + +use super::{errors::KeycloakApiError, UserDto}; + +/// Helper type for results. +pub type Result = std::result::Result; + +#[async_trait] +pub trait KeycloakApi { + async fn search_users_by_username( + &self, + search_params: &UserSearchParameters, + pagination: &PageParameters, + client: &reqwest::Client, + ) -> Result>; + async fn get_users_by_ids( + &self, + client: &reqwest::Client, + user_ids: Vec, + ) -> Result>; + async fn get_user_by_id( + &self, + client: &reqwest::Client, + user_id: uuid::Uuid, + ) -> Result; +} From 5e84d6f6d83fc892df8b4ada0407723fbae4f4b5 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Fri, 16 Feb 2024 20:59:42 +0100 Subject: [PATCH 26/41] feat: mock api if keycloak env is not set --- backend/.env.sample | 1 + backend/src/config/app.rs | 15 +++++----- backend/src/config/data.rs | 15 +++++++++- backend/src/keycloak_api/api.rs | 36 ++++++++--------------- backend/src/keycloak_api/mock_api.rs | 44 ++++++++++++++++++++++++++++ backend/src/keycloak_api/mod.rs | 1 + backend/src/keycloak_api/traits.rs | 22 ++++++++++++++ 7 files changed, 101 insertions(+), 33 deletions(-) create mode 100644 backend/src/keycloak_api/mock_api.rs diff --git a/backend/.env.sample b/backend/.env.sample index 2532de8a9..a9b255d8b 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -8,6 +8,7 @@ AUTH_HOST=https://auth.permaplant.net AUTH_CLIENT_ID=localhost # Keycloak API Credentials (will be used to fetch user info) +# If you do not want to setup keycloak locally, leave these empty, the api will return dummy data AUTH_ADMIN_CLIENT_ID=configured-in-keycloak AUTH_ADMIN_CLIENT_SECRET=configured-in-keycloak diff --git a/backend/src/config/app.rs b/backend/src/config/app.rs index daf64a498..20e47dd58 100644 --- a/backend/src/config/app.rs +++ b/backend/src/config/app.rs @@ -44,9 +44,9 @@ pub struct Config { /// The URI of the auth server used to acquire a token. pub auth_token_uri: Url, /// The `client_id` the backend uses to communicate with the auth server. - pub auth_admin_client_id: String, + pub auth_admin_client_id: Option, /// The `client_secret` the backend uses to communicate with the auth server. - pub auth_admin_client_secret: Secret, + pub auth_admin_client_secret: Option>, } impl Config { @@ -79,10 +79,9 @@ impl Config { let client_id = env::var(ENV_VARS.auth_client_id).map_err(|_| env_error(ENV_VARS.auth_client_id))?; - let keycloak_client_id = env::var(ENV_VARS.auth_admin_client_id) - .map_err(|_| env_error(ENV_VARS.auth_admin_client_id))?; - let keycloak_client_secret = env::var(ENV_VARS.auth_admin_client_secret) - .map_err(|_| env_error(ENV_VARS.auth_admin_client_secret))?; + let auth_admin_client_id = env::var(ENV_VARS.auth_admin_client_id).ok(); + let auth_admin_client_secret = env::var(ENV_VARS.auth_admin_client_secret) + .map_or_else(|_| None, |client_secret| Some(Secret::new(client_secret))); Ok(Self { bind_address: (host, port), @@ -90,8 +89,8 @@ impl Config { auth_discovery_uri, client_id, auth_token_uri, - auth_admin_client_id: keycloak_client_id, - auth_admin_client_secret: Secret::new(keycloak_client_secret), + auth_admin_client_id, + auth_admin_client_secret, }) } } diff --git a/backend/src/config/data.rs b/backend/src/config/data.rs index d27da7aec..e552e0e77 100644 --- a/backend/src/config/data.rs +++ b/backend/src/config/data.rs @@ -50,9 +50,22 @@ pub fn init(config: &Config) -> SharedInit { } /// Creates a new Keycloak API. +/// If the admin client ID and secret are not set, a mock API is created. #[must_use] pub fn create_api( config: &Config, ) -> Arc { - Arc::new(keycloak_api::api::Api::new(config)) + if let (Some(client_id), Some(client_secret)) = ( + config.auth_admin_client_id.clone(), + config.auth_admin_client_secret.clone(), + ) { + Arc::new(keycloak_api::api::Api::new(keycloak_api::api::Config { + token_url: config.auth_token_uri.clone(), + client_id, + client_secret, + })) + } else { + log::info!("Using mock Keycloak API"); + Arc::new(keycloak_api::mock_api::MockApi) + } } diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 99dc5983e..2591d940b 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -14,7 +14,6 @@ use secrecy::{ExposeSecret, Secret}; use serde::de::DeserializeOwned; use tokio::sync::Mutex; -use crate::config::app::Config; use crate::keycloak_api::dtos::UserDto; use crate::model::dto::{PageParameters, UserSearchParameters}; @@ -63,14 +62,14 @@ struct TokenResponse { pub expires_in: i64, } +pub struct Config { + pub token_url: Url, + pub client_id: String, + pub client_secret: Secret, +} + #[async_trait] impl KeycloakApi for Api { - /// Search for users by their username. - /// - /// # Errors - /// - If the url cannot be parsed. - /// - If the authorization header cannot be created. - /// - If the request fails or the response cannot be deserialized. async fn search_users_by_username( &self, search_params: &UserSearchParameters, @@ -102,12 +101,6 @@ impl KeycloakApi for Api { self.get::>(client, url).await } - /// Gets all users given their ids from the Keycloak API. - /// - /// # Errors - /// - If the url cannot be parsed. - /// - If the authorization header cannot be created. - /// - If the request fails or the response cannot be deserialized. async fn get_users_by_ids( &self, client: &reqwest::Client, @@ -135,12 +128,6 @@ impl KeycloakApi for Api { Ok(y) } - /// Gets a user by its id from the Keycloak API. - /// - /// # Errors - /// - If the url cannot be parsed. - /// - If the authorization header cannot be created. - /// - If the request fails or the response cannot be deserialized. async fn get_user_by_id( &self, client: &reqwest::Client, @@ -158,14 +145,15 @@ impl Api { /// If the config does not contain a valid keycloak auth URI. #[allow(clippy::expect_used)] #[must_use] - pub fn new(config: &Config) -> Self { - let token_url = config.auth_token_uri.clone(); + pub fn new(config: Config) -> Self { + let Config { + token_url, + client_id, + client_secret, + } = config; let mut base_url = to_base_url(token_url.clone()); base_url.set_path("admin/realms/PermaplanT"); - let client_secret = config.auth_admin_client_secret.clone(); - let client_id = config.auth_admin_client_id.clone(); - Self { base_url, token_url, diff --git a/backend/src/keycloak_api/mock_api.rs b/backend/src/keycloak_api/mock_api.rs new file mode 100644 index 000000000..bb1983129 --- /dev/null +++ b/backend/src/keycloak_api/mock_api.rs @@ -0,0 +1,44 @@ +//! Mock implementation for the keycloak admin API. +//! It is used for when the environment variables are not set which are needed to connect to the real keycloak API. + +use async_trait::async_trait; + +use crate::model::dto::{PageParameters, UserSearchParameters}; + +use super::{ + traits::{KeycloakApi, Result}, + UserDto, +}; + +pub struct MockApi; + +#[async_trait] +impl KeycloakApi for MockApi { + async fn search_users_by_username( + &self, + _search_params: &UserSearchParameters, + _pagination: &PageParameters, + _client: &reqwest::Client, + ) -> Result> { + Ok(vec![]) + } + + async fn get_users_by_ids( + &self, + _client: &reqwest::Client, + _user_ids: Vec, + ) -> Result> { + Ok(vec![]) + } + + async fn get_user_by_id( + &self, + _client: &reqwest::Client, + user_id: uuid::Uuid, + ) -> Result { + Ok(UserDto { + id: user_id, + username: "mock_user".to_owned(), + }) + } +} diff --git a/backend/src/keycloak_api/mod.rs b/backend/src/keycloak_api/mod.rs index c16b953ad..037e3bd21 100644 --- a/backend/src/keycloak_api/mod.rs +++ b/backend/src/keycloak_api/mod.rs @@ -3,6 +3,7 @@ pub mod api; pub mod dtos; pub mod errors; +pub mod mock_api; pub mod traits; pub use dtos::UserDto; diff --git a/backend/src/keycloak_api/traits.rs b/backend/src/keycloak_api/traits.rs index 3bd1d5376..b26b2df48 100644 --- a/backend/src/keycloak_api/traits.rs +++ b/backend/src/keycloak_api/traits.rs @@ -1,3 +1,5 @@ +//! Traits for the Keycloak API. + use async_trait::async_trait; use crate::model::dto::{PageParameters, UserSearchParameters}; @@ -9,17 +11,37 @@ pub type Result = std::result::Result; #[async_trait] pub trait KeycloakApi { + /// Search for users by their username. + /// + /// # Errors + /// - If the url cannot be parsed. + /// - If the authorization header cannot be created. + /// - If the request fails or the response cannot be deserialized. async fn search_users_by_username( &self, search_params: &UserSearchParameters, pagination: &PageParameters, client: &reqwest::Client, ) -> Result>; + + /// Gets all users given their ids from the Keycloak API. + /// + /// # Errors + /// - If the url cannot be parsed. + /// - If the authorization header cannot be created. + /// - If the request fails or the response cannot be deserialized. async fn get_users_by_ids( &self, client: &reqwest::Client, user_ids: Vec, ) -> Result>; + + /// Gets a user by its id from the Keycloak API. + /// + /// # Errors + /// - If the url cannot be parsed. + /// - If the authorization header cannot be created. + /// - If the request fails or the response cannot be deserialized. async fn get_user_by_id( &self, client: &reqwest::Client, From 1ce1aff4f07b4059cf0c4877dfc2552ae5316109 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Fri, 16 Feb 2024 21:04:51 +0100 Subject: [PATCH 27/41] chore: rename owner to creator in comments --- backend/src/controller/map_collaborators.rs | 2 +- backend/src/service/map_collaborator.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/controller/map_collaborators.rs b/backend/src/controller/map_collaborators.rs index eccb10ac6..685ebda12 100644 --- a/backend/src/controller/map_collaborators.rs +++ b/backend/src/controller/map_collaborators.rs @@ -85,7 +85,7 @@ pub async fn create( /// Endpoint for deleting a collaborator from a map. /// /// # Errors -/// * If the user is not the owner of the map. +/// * If the user is not the creator of the map. /// * If the connection to the database could not be established. #[utoipa::path( context_path = "/api/maps/{map_id}/collaborators", diff --git a/backend/src/service/map_collaborator.rs b/backend/src/service/map_collaborator.rs index f0497b6f5..0ee499474 100644 --- a/backend/src/service/map_collaborator.rs +++ b/backend/src/service/map_collaborator.rs @@ -44,7 +44,7 @@ pub async fn get_all( /// /// # Errors /// * If the user tries to add themselves as a collaborator. -/// * If the user is not the owner of the map. +/// * If the user is not the creator of the map. /// * If the map already has 30 collaborators. /// * If the connection to the database could not be established. /// * If the connection to the Keycloak API could not be established. @@ -95,7 +95,7 @@ pub async fn create( /// Remove a collaborator from a map. /// /// # Errors -/// * If the user is not the owner of the map. +/// * If the user is not the creator of the map. /// * If the connection to the database could not be established. /// * If the connection to the Keycloak API could not be established. pub async fn delete( @@ -112,7 +112,7 @@ pub async fn delete( if map.owner_id != user_id { return Err(ServiceError::new( StatusCode::FORBIDDEN, - "You are not the owner of this map.".to_owned(), + "You are not the creator of this map.".to_owned(), )); } From 32446ff847499f2a417ac37afa194138c9602c48 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Fri, 16 Feb 2024 21:11:14 +0100 Subject: [PATCH 28/41] tests: fix keycloak api initialization --- backend/src/test/util.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/test/util.rs b/backend/src/test/util.rs index fca3c6c7a..5742fba54 100644 --- a/backend/src/test/util.rs +++ b/backend/src/test/util.rs @@ -18,7 +18,6 @@ use dotenvy::dotenv; use crate::{ config::{app, routes}, error::ServiceError, - keycloak_api, sse::broadcaster::Broadcaster, }; @@ -103,13 +102,15 @@ pub async fn init_test_app_for_user( async fn init_test_app_impl( pool: Pool, ) -> impl Service, Error = Error> { + let keycloak_api = crate::config::data::create_api( + &app::Config::from_env().expect("Error loading configuration"), + ); + test::init_service( App::new() .app_data(Data::new(pool)) .app_data(Data::new(Broadcaster::new())) - .app_data(Data::new(keycloak_api::api::Api::new( - &app::Config::from_env().expect("Error loading configuration"), - ))) + .app_data(Data::from(keycloak_api)) .app_data(Data::new(reqwest::Client::new())) .configure(routes::config), ) From 60ebcad0922d1ed83897bb3fbe9bc89e113a6afa Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 17 Feb 2024 23:09:18 +0100 Subject: [PATCH 29/41] chore: fix remaining occurrences of AUTH_DISCOVERY_URL --- .devcontainer/.env | 2 +- doc/backend/01setup.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/.env b/.devcontainer/.env index 5b08e5348..d95037908 100644 --- a/.devcontainer/.env +++ b/.devcontainer/.env @@ -8,5 +8,5 @@ BIND_ADDRESS_PORT=8080 VITE_BASE_API_URL=http://localhost:8080 VITE_NEXTCLOUD_URI=https://cloud.permaplant.net # OAuth2 (other URLs will be fetched from this URL) -AUTH_DISCOVERY_URI=https://auth.permaplant.net/realms/PermaplanT/.well-known/openid-configuration +AUTH_HOST=https://auth.permaplant.net AUTH_CLIENT_ID=localhost diff --git a/doc/backend/01setup.md b/doc/backend/01setup.md index 0211d019f..fce0d3d6f 100644 --- a/doc/backend/01setup.md +++ b/doc/backend/01setup.md @@ -62,7 +62,7 @@ You can do one of the following two steps, the first one being the simpler one, - To use the preconfigured Keycloak instance simply copy the newest version of `.env.sample` to `.env` - To use the local Keycloak variant follow the steps in [Keycloak Setup](../setups/keycloak/README.md) You then also have to change following two env variables in `.env` - - `AUTH_DISCOVERY_URI=http://localhost:8081/realms/PermaplanT/.well-known/openid-configuration` + - `AUTH_HOST=http://localhost:8081` - `AUTH_CLIENT_ID=PermaplanT` 6. run From 8fe651c5b204e9e6f3be23184cab6173b823cf43 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sat, 17 Feb 2024 23:44:41 +0100 Subject: [PATCH 30/41] refactor: fix bad variable names --- backend/src/keycloak_api/api.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index 2591d940b..f5e49c99d 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -106,7 +106,7 @@ impl KeycloakApi for Api { client: &reqwest::Client, user_ids: Vec, ) -> Result> { - let x = stream::iter(user_ids) + let future_stream = stream::iter(user_ids) .map(|id| { let client = client.clone(); let api = self.clone(); @@ -114,7 +114,7 @@ impl KeycloakApi for Api { }) .buffer_unordered(10); - let y = x + let users = future_stream .map(|res| match res { Ok(Ok(user)) => Ok(user), Ok(Err(e)) => Err(e), @@ -125,7 +125,7 @@ impl KeycloakApi for Api { .into_iter() .collect::, _>>()?; - Ok(y) + Ok(users) } async fn get_user_by_id( From eb9ad6370752bdb0aff238d65f107de6e9d3b84f Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sun, 18 Feb 2024 00:53:51 +0100 Subject: [PATCH 31/41] fix: collaborators not matching user_ids --- backend/src/keycloak_api/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/keycloak_api/api.rs b/backend/src/keycloak_api/api.rs index f5e49c99d..85669a6b2 100644 --- a/backend/src/keycloak_api/api.rs +++ b/backend/src/keycloak_api/api.rs @@ -112,7 +112,7 @@ impl KeycloakApi for Api { let api = self.clone(); tokio::spawn(async move { api.get_user_by_id(&client, id).await }) }) - .buffer_unordered(10); + .buffered(10); // buffered, because we want the users in the order of the ids let users = future_stream .map(|res| match res { From e66a7d640f1e9fe330fb616e813e918a9551f9f0 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sun, 18 Feb 2024 00:54:41 +0100 Subject: [PATCH 32/41] fix: implement transaction according to diesel_async readme --- backend/src/model/entity/plantings_impl.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/model/entity/plantings_impl.rs b/backend/src/model/entity/plantings_impl.rs index 4364470c9..d000390f1 100644 --- a/backend/src/model/entity/plantings_impl.rs +++ b/backend/src/model/entity/plantings_impl.rs @@ -1,13 +1,15 @@ //! Contains the implementation of [`Planting`]. +use std::future::Future; + use chrono::NaiveDate; use diesel::pg::Pg; use diesel::{ debug_query, BoolExpressionMethods, ExpressionMethods, NullableExpressionMethods, QueryDsl, QueryResult, }; +use diesel_async::scoped_futures::ScopedFutureExt; use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl}; -use futures_util::Future; use log::debug; use uuid::Uuid; @@ -155,13 +157,14 @@ impl Planting { let result = conn .transaction(|transaction| { - Box::pin(async { + async move { let futures = Self::do_update(planting_updates, transaction); let results = futures_util::future::try_join_all(futures).await?; Ok(results) as QueryResult> - }) + } + .scope_boxed() }) .await?; From de11fd457af4f3f36dcf63201e08706f0d31306b Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sun, 18 Feb 2024 11:17:57 +0100 Subject: [PATCH 33/41] refactor: pull out magic number into constant --- backend/src/service/map_collaborator.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/service/map_collaborator.rs b/backend/src/service/map_collaborator.rs index 0ee499474..1f123c3c6 100644 --- a/backend/src/service/map_collaborator.rs +++ b/backend/src/service/map_collaborator.rs @@ -11,6 +11,9 @@ use crate::{ service::users, }; +/// The maximum number of collaborators a map can have. +const MAX_COLLABORATORS: usize = 30; + /// Get all collaborators for a map. /// /// # Errors @@ -77,10 +80,10 @@ pub async fn create( let current_collaborators = MapCollaborator::find_by_map_id(map_id, &mut conn).await?; - if current_collaborators.len() >= 30 { + if current_collaborators.len() >= MAX_COLLABORATORS { return Err(ServiceError::new( StatusCode::BAD_REQUEST, - "A map can have at most 30 collaborators.".to_owned(), + format!("A map can have at most {MAX_COLLABORATORS} collaborators."), )); } From 7555d8f2b569d56f273681c528a8e3ac40d4e471 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Mon, 26 Feb 2024 19:46:51 +0100 Subject: [PATCH 34/41] fix: remove unused variables --- ci/Jenkinsfile | 2 -- doc/backend/01setup.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/ci/Jenkinsfile b/ci/Jenkinsfile index 561baced9..0fbc14ba5 100644 --- a/ci/Jenkinsfile +++ b/ci/Jenkinsfile @@ -64,8 +64,6 @@ def runDockerPostgresSidecar(String command, List stashsrc = [], List Date: Sat, 2 Mar 2024 14:56:17 +0100 Subject: [PATCH 35/41] fix: timeline bcs rebase --- backend/src/controller/timeline.rs | 13 +++++++------ backend/src/service/timeline.rs | 8 +++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/backend/src/controller/timeline.rs b/backend/src/controller/timeline.rs index d49293135..622e4f536 100644 --- a/backend/src/controller/timeline.rs +++ b/backend/src/controller/timeline.rs @@ -1,13 +1,14 @@ //! `Users` endpoints. -use crate::config::data::AppDataInner; -use crate::model::dto::timeline::TimelineParameters; -use crate::service; + use actix_web::{ get, - web::{Data, Path, Query}, + web::{Path, Query}, HttpResponse, Result, }; +use crate::service; +use crate::{config::data::SharedPool, model::dto::timeline::TimelineParameters}; + /// Get calculated timeline data for a given map from dates start to end. /// The timeline contains all additions and removals of `Plantings` aggregated /// by years, months, and also on the actual dates. It only contains years, months @@ -33,12 +34,12 @@ use actix_web::{ pub async fn get_timeline( map_id: Path, parameters: Query, - app_data: Data, + pool: SharedPool, ) -> Result { let params = parameters.into_inner(); if params.start > params.end { return Ok(HttpResponse::UnprocessableEntity().body("Start must be smaller than end")); } - let dto = service::timeline::calculate(map_id.into_inner(), params, &app_data).await?; + let dto = service::timeline::calculate(map_id.into_inner(), params, &pool).await?; Ok(HttpResponse::Ok().json(dto)) } diff --git a/backend/src/service/timeline.rs b/backend/src/service/timeline.rs index d36de98a3..8b1ed21f7 100644 --- a/backend/src/service/timeline.rs +++ b/backend/src/service/timeline.rs @@ -1,7 +1,5 @@ -use actix_web::web::Data; - use crate::{ - config::data::AppDataInner, + config::data::SharedPool, error::ServiceError, model::{ dto::timeline::{TimelineDto, TimelineParameters}, @@ -17,9 +15,9 @@ use crate::{ pub async fn calculate( map_id: i32, params: TimelineParameters, - app_data: &Data, + pool: &SharedPool, ) -> Result { - let mut conn = app_data.pool.get().await?; + let mut conn = pool.get().await?; // Check if the map exists Map::find_by_id(map_id, &mut conn).await?; let result = timeline::calculate(map_id, params, &mut conn).await?; From 2f63f2b0ee9a43a03634dbaa838e5a4d0e7721e6 Mon Sep 17 00:00:00 2001 From: Bushuo Date: Sun, 11 Feb 2024 23:46:42 +0100 Subject: [PATCH 36/41] refactor: map form into one form component --- e2e/pages/maps/create.py | 14 +- e2e/pages/maps/edit.py | 7 +- frontend/package-lock.json | 862 +----------------- frontend/package.json | 2 + frontend/src/Providers.tsx | 23 +- frontend/src/components/Button/IconButton.tsx | 24 +- frontend/src/components/Form/SelectMenu.tsx | 10 +- .../src/components/Form/SimpleFormInput.tsx | 8 +- .../components/Form/SimpleFormTextArea.tsx | 11 +- .../components/Form/ZodValidatedFromInput.tsx | 55 ++ frontend/src/config/i18n/de/common.json | 3 +- frontend/src/config/i18n/en/common.json | 1 + frontend/src/config/i18n/en/maps.json | 11 + .../features/maps/routes/MapCreateForm.tsx | 251 ----- .../src/features/maps/routes/MapEditForm.tsx | 277 ------ frontend/src/features/maps/routes/MapForm.tsx | 240 +++++ .../src/features/maps/routes/MapFormRoute.tsx | 129 +++ frontend/src/routes/index.ts | 7 +- frontend/src/svg/icons/map-pin.svg | 7 + frontend/src/utils/cn.ts | 25 + frontend/src/utils/translated-enums.ts | 12 +- 21 files changed, 585 insertions(+), 1394 deletions(-) create mode 100644 frontend/src/components/Form/ZodValidatedFromInput.tsx delete mode 100644 frontend/src/features/maps/routes/MapCreateForm.tsx delete mode 100644 frontend/src/features/maps/routes/MapEditForm.tsx create mode 100644 frontend/src/features/maps/routes/MapForm.tsx create mode 100644 frontend/src/features/maps/routes/MapFormRoute.tsx create mode 100644 frontend/src/svg/icons/map-pin.svg create mode 100644 frontend/src/utils/cn.ts diff --git a/e2e/pages/maps/create.py b/e2e/pages/maps/create.py index 4ed0dd905..b131d3e9c 100644 --- a/e2e/pages/maps/create.py +++ b/e2e/pages/maps/create.py @@ -11,12 +11,12 @@ class MapCreatePage(AbstractPage): def __init__(self, page: Page): self._page = page - self._name = page.get_by_placeholder("Name *") - self._description = page.get_by_placeholder("Description") - self._longitude = page.get_by_placeholder("Longitude") - self._latitude = page.get_by_placeholder("Latitude") + self._name = page.get_by_label("Name") + self._description = page.get_by_label("Description") + self._longitude = page.get_by_label("Longitude") + self._latitude = page.get_by_label("Latitude") self._create_button = page.get_by_role("button", name="Create") - self._privacy_select = page.get_by_test_id("map-create-form__select-privacy") + self._privacy_select = page.get_by_test_id("select-menu__Privacy-select") def try_create_a_map( self, @@ -62,7 +62,9 @@ def fill_name(self, name: str): self._name.fill(name) def select_privacy(self, privacy: str): - self._privacy_select.select_option(privacy) + """Selects the maps privacy.""" + self._privacy_select.click() + self._page.get_by_role("option").get_by_text(privacy, exact=True).click() def fill_description(self, description: str): self._description.fill(description) diff --git a/e2e/pages/maps/edit.py b/e2e/pages/maps/edit.py index 376e5efde..7e225fba4 100644 --- a/e2e/pages/maps/edit.py +++ b/e2e/pages/maps/edit.py @@ -11,12 +11,12 @@ class MapEditPage(AbstractPage): def __init__(self, page: Page): self._page = page - self._name = page.get_by_label("Name *") + self._name = page.get_by_label("Name") self._description = page.get_by_label("Description") self._longitude = page.get_by_label("Longitude") self._latitude = page.get_by_label("Latitude") self._save_button = page.get_by_role("button", name="Save") - self._privacy = page.locator("select") + self._privacy_select = page.get_by_test_id("select-menu__Privacy-select") def fill_name(self, name): """Fills out the map name field.""" @@ -24,7 +24,8 @@ def fill_name(self, name): def select_privacy(self, privacy: str): """Selects the maps privacy.""" - self._privacy.select_option(privacy) + self._privacy_select.click() + self._page._page.get_by_role("option").get_by_text(privacy, exact=True).click() def fill_description(self, description: str): """Fills out the map description field.""" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd900401d..b6e92b5de 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@tanstack/react-query-devtools": "^4.29.19", "@uiw/react-md-editor": "^3.24.1", "axios": "^1.2.3", + "clsx": "^2.1.0", "framer-motion": "^8.5.2", "i18next": "^22.4.14", "i18next-browser-languagedetector": "^7.0.1", @@ -37,6 +38,7 @@ "react-shepherd": "^4.2.0", "react-toastify": "^9.1.2", "react-tooltip": "^5.22.0", + "tailwind-merge": "^2.2.1", "typewriter-effect": "^2.19.0", "uuid": "^9.0.0", "webdav": "^5.2.2", @@ -2085,10 +2087,11 @@ "license": "MIT" }, "node_modules/@babel/runtime": { - "version": "7.21.0", - "license": "MIT", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", + "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -2439,54 +2442,6 @@ "version": "0.3.0", "license": "MIT" }, - "node_modules/@esbuild/android-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", - "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", - "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", - "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.17.19", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", @@ -2503,294 +2458,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", - "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", - "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", - "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", - "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", - "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", - "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", - "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", - "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", - "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", - "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", - "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", - "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", - "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", - "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", - "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", - "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", - "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", - "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "dev": true, @@ -3709,32 +3376,6 @@ "node": ">= 8.0.0" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.6.1.tgz", - "integrity": "sha512-0WQ0ouLejaUCRsL93GD4uft3rOmB8qoQMU05Kb8CmMtMBe7XUDLAltxVZI1q6byNqEtU7N1ZX1Vw5lIpgulLQA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.6.1.tgz", - "integrity": "sha512-1TKm25Rn20vr5aTGGZqo6E4mzPicCUD79k17EgTLAsXc1zysyi4xXKACfUbwyANEPAEIxkzwue6JZ+stYzWUTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.6.1.tgz", @@ -3748,123 +3389,6 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.6.1.tgz", - "integrity": "sha512-LoSU9Xu56isrkV2jLldcKspJ7sSXmZWkAxg7sW/RfF7GS4F5/v4EiqKSMCFbZtDu2Nc1gxxFdQdKwkKS4rwxNg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.6.1.tgz", - "integrity": "sha512-EfI3hzYAy5vFNDqpXsNxXcgRDcFHUWSx5nnRSCKwXuQlI5J9dD84g2Usw81n3FLBNsGCegKGwwTVsSKK9cooSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.6.1.tgz", - "integrity": "sha512-9lhc4UZstsegbNLhH0Zu6TqvDfmhGzuCWtcTFXY10VjLLUe4Mr0Ye2L3rrtHaDd/J5+tFMEuo5LTCSCMXWfUKw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.6.1.tgz", - "integrity": "sha512-FfoOK1yP5ksX3wwZ4Zk1NgyGHZyuRhf99j64I5oEmirV8EFT7+OhUZEnP+x17lcP/QHJNWGsoJwrz4PJ9fBEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.6.1.tgz", - "integrity": "sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.6.1.tgz", - "integrity": "sha512-RkJVNVRM+piYy87HrKmhbexCHg3A6Z6MU0W9GHnJwBQNBeyhCJG9KDce4SAMdicQnpURggSvtbGo9xAWOfSvIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.6.1.tgz", - "integrity": "sha512-v2FVT6xfnnmTe3W9bJXl6r5KwJglMK/iRlkKiIFfO6ysKs0rDgz7Cwwf3tjldxQUrHL9INT/1r4VA0n9L/F1vQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.6.1.tgz", - "integrity": "sha512-YEeOjxRyEjqcWphH9dyLbzgkF8wZSKAKUkldRY6dgNR5oKs2LZazqGB41cWJ4Iqqcy9/zqYgmzBkRoVz3Q9MLw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.6.1.tgz", - "integrity": "sha512-0zfTlFAIhgz8V2G8STq8toAjsYYA6eci1hnXuyOTUFnymrtJwnS6uGKiv3v5UrPZkBlamLvrLV2iiaeqCKzb0A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@seznam/compose-react-refs": { "version": "1.0.6", "license": "ISC" @@ -8399,8 +7923,9 @@ } }, "node_modules/clsx": { - "version": "1.2.1", - "license": "MIT", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", "engines": { "node": ">=6" } @@ -16020,6 +15545,14 @@ "react-dom": ">= 16.3.0" } }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/react-element-to-jsx-string": { "version": "15.0.0", "dev": true, @@ -16367,6 +15900,14 @@ "react-dom": ">=16" } }, + "node_modules/react-toastify/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/react-tooltip": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.22.0.tgz", @@ -16618,8 +16159,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.11", - "license": "MIT" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regenerator-transform": { "version": "0.15.1", @@ -18116,6 +17658,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/tailwind-merge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.1.tgz", + "integrity": "sha512-o+2GTLkthfa5YUt4JxPfzMIpQzZ3adD1vLVkvKE1Twl9UAhGsEbIZhHHZVRttyW177S8PDJI3bTQNaebyofK3Q==", + "dependencies": { + "@babel/runtime": "^7.23.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", @@ -19536,54 +19090,6 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.8.tgz", - "integrity": "sha512-31E2lxlGM1KEfivQl8Yf5aYU/mflz9g06H6S15ITUFQueMFtFjESRMoDSkvMo8thYvLBax+VKTPlpnx+sPicOA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.8.tgz", - "integrity": "sha512-B8JbS61bEunhfx8kasogFENgQfr/dIp+ggYXwTqdbMAgGDhRa3AaPpQMuQU0rNxDLECj6FhDzk1cF9WHMVwrtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.8.tgz", - "integrity": "sha512-rdqqYfRIn4jWOp+lzQttYMa2Xar3OK9Yt2fhOhzFXqg0rVWEfSclJvZq5fZslnz6ypHvVf3CT7qyf0A5pM682A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/vite/node_modules/@esbuild/darwin-arm64": { "version": "0.19.8", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.8.tgz", @@ -19600,294 +19106,6 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.8.tgz", - "integrity": "sha512-3sur80OT9YdeZwIVgERAysAbwncom7b4bCI2XKLjMfPymTud7e/oY4y+ci1XVp5TfQp/bppn7xLw1n/oSQY3/Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.8.tgz", - "integrity": "sha512-WAnPJSDattvS/XtPCTj1tPoTxERjcTpH6HsMr6ujTT+X6rylVe8ggxk8pVxzf5U1wh5sPODpawNicF5ta/9Tmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.8.tgz", - "integrity": "sha512-ICvZyOplIjmmhjd6mxi+zxSdpPTKFfyPPQMQTK/w+8eNK6WV01AjIztJALDtwNNfFhfZLux0tZLC+U9nSyA5Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.8.tgz", - "integrity": "sha512-H4vmI5PYqSvosPaTJuEppU9oz1dq2A7Mr2vyg5TF9Ga+3+MGgBdGzcyBP7qK9MrwFQZlvNyJrvz6GuCaj3OukQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.8.tgz", - "integrity": "sha512-z1zMZivxDLHWnyGOctT9JP70h0beY54xDDDJt4VpTX+iwA77IFsE1vCXWmprajJGa+ZYSqkSbRQ4eyLCpCmiCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.8.tgz", - "integrity": "sha512-1a8suQiFJmZz1khm/rDglOc8lavtzEMRo0v6WhPgxkrjcU0LkHj+TwBrALwoz/OtMExvsqbbMI0ChyelKabSvQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.8.tgz", - "integrity": "sha512-fHZWS2JJxnXt1uYJsDv9+b60WCc2RlvVAy1F76qOLtXRO+H4mjt3Tr6MJ5l7Q78X8KgCFudnTuiQRBhULUyBKQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.8.tgz", - "integrity": "sha512-Wy/z0EL5qZYLX66dVnEg9riiwls5IYnziwuju2oUiuxVc+/edvqXa04qNtbrs0Ukatg5HEzqT94Zs7J207dN5Q==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.8.tgz", - "integrity": "sha512-ETaW6245wK23YIEufhMQ3HSeHO7NgsLx8gygBVldRHKhOlD1oNeNy/P67mIh1zPn2Hr2HLieQrt6tWrVwuqrxg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.8.tgz", - "integrity": "sha512-T2DRQk55SgoleTP+DtPlMrxi/5r9AeFgkhkZ/B0ap99zmxtxdOixOMI570VjdRCs9pE4Wdkz7JYrsPvsl7eESg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.8.tgz", - "integrity": "sha512-NPxbdmmo3Bk7mbNeHmcCd7R7fptJaczPYBaELk6NcXxy7HLNyWwCyDJ/Xx+/YcNH7Im5dHdx9gZ5xIwyliQCbg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.8.tgz", - "integrity": "sha512-lytMAVOM3b1gPypL2TRmZ5rnXl7+6IIk8uB3eLsV1JwcizuolblXRrc5ShPrO9ls/b+RTp+E6gbsuLWHWi2zGg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.8.tgz", - "integrity": "sha512-hvWVo2VsXz/8NVt1UhLzxwAfo5sioj92uo0bCfLibB0xlOmimU/DeAEsQILlBQvkhrGjamP0/el5HU76HAitGw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.8.tgz", - "integrity": "sha512-/7Y7u77rdvmGTxR83PgaSvSBJCC2L3Kb1M/+dmSIvRvQPXXCuC97QAwMugBNG0yGcbEGfFBH7ojPzAOxfGNkwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.8.tgz", - "integrity": "sha512-9Lc4s7Oi98GqFA4HzA/W2JHIYfnXbUYgekUP/Sm4BG9sfLjyv6GKKHKKVs83SMicBF2JwAX6A1PuOLMqpD001w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.8.tgz", - "integrity": "sha512-rq6WzBGjSzihI9deW3fC2Gqiak68+b7qo5/3kmB6Gvbh/NYPA0sJhrnp7wgV4bNwjqM+R2AApXGxMO7ZoGhIJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.8.tgz", - "integrity": "sha512-AIAbverbg5jMvJznYiGhrd3sumfwWs8572mIJL5NQjJa06P8KfCPWZQ0NwZbPQnbQi9OWSZhFVSUWjjIrn4hSw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.8.tgz", - "integrity": "sha512-bfZ0cQ1uZs2PqpulNL5j/3w+GDhP36k1K5c38QdQg+Swy51jFZWWeIkteNsufkQxp986wnqRRsb/bHbY1WQ7TA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/vite/node_modules/esbuild": { "version": "0.19.8", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6768dd47a..22e77cf41 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@tanstack/react-query-devtools": "^4.29.19", "@uiw/react-md-editor": "^3.24.1", "axios": "^1.2.3", + "clsx": "^2.1.0", "framer-motion": "^8.5.2", "i18next": "^22.4.14", "i18next-browser-languagedetector": "^7.0.1", @@ -46,6 +47,7 @@ "react-shepherd": "^4.2.0", "react-toastify": "^9.1.2", "react-tooltip": "^5.22.0", + "tailwind-merge": "^2.2.1", "typewriter-effect": "^2.19.0", "uuid": "^9.0.0", "webdav": "^5.2.2", diff --git a/frontend/src/Providers.tsx b/frontend/src/Providers.tsx index c58c30e4c..b358a61a5 100644 --- a/frontend/src/Providers.tsx +++ b/frontend/src/Providers.tsx @@ -1,6 +1,8 @@ +import createCache from '@emotion/cache'; +import { CacheProvider } from '@emotion/react'; import { QueryCache, QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { ReactNode } from 'react'; +import { ReactNode, useMemo } from 'react'; import { AuthProvider } from 'react-oidc-context'; import { BrowserRouter } from 'react-router-dom'; import { onError } from '@/config/react_query'; @@ -44,6 +46,23 @@ const onSigninCallback = (): void => { window.history.replaceState({}, document.title, window.location.pathname); }; +// https://react-select.com/styles#the-classnames-prop +// https://github.com/JedWatson/react-select/blob/master/storybook/stories/ClassNamesWithTailwind.stories.tsx +// This ensures that Emotion's styles are inserted before Tailwind's styles so that Tailwind classes have precedence over Emotion +function EmotionCacheProvider({ children }: { children: ReactNode }) { + const cache = useMemo( + () => + createCache({ + key: 'with-tailwind', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know it's there + insertionPoint: document.querySelector('title')!, + }), + [], + ); + + return {children}; +} + function AuthProviderWrapper({ children }: ProviderProps) { const { data } = useQuery({ queryFn: getOidcConfig, @@ -62,7 +81,7 @@ export function Providers({ children }: ProviderProps) { - {children} + {children} diff --git a/frontend/src/components/Button/IconButton.tsx b/frontend/src/components/Button/IconButton.tsx index ef64445af..e248e20ea 100644 --- a/frontend/src/components/Button/IconButton.tsx +++ b/frontend/src/components/Button/IconButton.tsx @@ -1,3 +1,5 @@ +import { cn } from '@/utils/cn'; + interface IconButtonProps extends React.ButtonHTMLAttributes { /** The variant specifies the look of the button. */ variant?: ButtonVariant; @@ -30,25 +32,29 @@ export default function IconButton({ isToolboxIcon = false, ...props }: IconButtonProps) { - const defaultIconStyles = + const defaultIconButtonStyles = 'inline-flex h-6 w-6 justify-center rounded-lg items-center text-sm font-medium focus:outline-none focus:ring-1 focus:ring-secondary-100 dark:focus:ring-secondary-500 focus:border-0 dark:focus:border-0 stroke-neutral-800 dark:stroke-neutral-800-dark fill-neutral-800 dark:fill-neutral-800-dark' + ' disabled:stroke-neutral-500 dark:disabled:stroke-neutral-500-dark disabled:fill-neutral-500 dark:disabled:fill-neutral-500-dark disabled:cursor-not-allowed' + ' ' + variantStyles[variant]; - const activeIcon = renderAsActive - ? 'fill-primary-500 dark:fill-primary-400 stroke-primary-500 dark:stroke-primary-400' - : ''; - const toolboxIcon = isToolboxIcon - ? 'mx-1 my-2 first-of-type:ml-2 last-of-type:mr-2 h-8 w-8 p-1 border border-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:hover:stroke-primary-400 active:stroke-primary-400 active:fill-primary-400' - : ''; - const additionalClasses = props.className ? props.className : ''; + const activeStyles = + 'fill-primary-500 dark:fill-primary-400 stroke-primary-500 dark:stroke-primary-400'; + const toolboxStyles = + 'mx-1 my-2 first-of-type:ml-2 last-of-type:mr-2 h-8 w-8 p-1 border border-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:hover:stroke-primary-400 active:stroke-primary-400 active:fill-primary-400'; return ( ); } diff --git a/frontend/src/components/Form/SelectMenu.tsx b/frontend/src/components/Form/SelectMenu.tsx index 5e36c3573..2c1b127cc 100644 --- a/frontend/src/components/Form/SelectMenu.tsx +++ b/frontend/src/components/Form/SelectMenu.tsx @@ -4,8 +4,7 @@ import Select, { ActionMeta, ClassNamesConfig, GroupBase, - MultiValue, - SingleValue, + OnChangeValue, StylesConfig, } from 'react-select'; import filterObject from '../../utils/filterObject'; @@ -40,10 +39,9 @@ export interface SelectMenuProps< /** Text that is displayed in place of the content if no option has been selected. */ placeholder?: string; /** Callback that is invoked every time a new option is selected. */ - handleOptionsChange?: ( - option: SingleValue