diff --git a/backend/.env.example b/backend/.env.example index a0749d5..8281540 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,8 +2,7 @@ PORT=8080 DATABASE_URL=postgres://hubbit:hubbit@localhost/hubbit REDIS_URL=redis://127.0.0.1:6379 -GAMMA_PUBLIC_URL=http://localhost:8081 -GAMMA_INTERNAL_URL=http://localhost:8081 +GAMMA_URL=http://localhost:8081 GAMMA_API_KEY=hubbit GAMMA_CLIENT_ID=hubbit GAMMA_CLIENT_SECRET=hubbit diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2ee38a3..ff83316 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -74,7 +74,7 @@ dependencies = [ "encoding_rs", "flate2", "futures-core", - "h2", + "h2 0.3.26", "http 0.2.12", "httparse", "httpdate", @@ -597,6 +597,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.3.0" @@ -616,13 +622,14 @@ dependencies = [ "dotenvy", "env_logger", "futures", + "gamma_rust_client", "lazy_static", "log", "mobc", "mobc-redis", "once_cell", "rand", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "slab", @@ -1163,6 +1170,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1278,6 +1300,19 @@ dependencies = [ "slab", ] +[[package]] +name = "gamma_rust_client" +version = "0.1.0" +source = "git+https://github.com/viddem/gamma.git?rev=1235842562c9f3b50a68582dea13f66fbe93cf5e#1235842562c9f3b50a68582dea13f66fbe93cf5e" +dependencies = [ + "rand", + "reqwest 0.12.5", + "serde", + "thiserror", + "urlencoding", + "uuid", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1334,6 +1369,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.1.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "handlebars" version = "5.1.2" @@ -1454,6 +1508,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.8.0" @@ -1482,9 +1559,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -1496,6 +1573,26 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.5", + "http 1.1.0", + "http-body 1.0.0", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -1504,10 +1601,63 @@ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", "http 0.2.12", - "hyper", - "rustls", + "hyper 0.14.29", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.3.1", + "hyper-util", + "rustls 0.23.10", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.3.1", + "hyper-util", + "native-tls", "tokio", - "tokio-rustls", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "hyper 1.3.1", + "pin-project-lite", + "socket2 0.5.7", + "tokio", + "tower", + "tower-service", + "tracing", ] [[package]] @@ -1796,6 +1946,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -1900,6 +2067,50 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "overload" version = "0.1.1" @@ -2001,6 +2212,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -2212,11 +2443,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", - "http-body", - "hyper", - "hyper-rustls", + "http-body 0.4.6", + "hyper 0.14.29", + "hyper-rustls 0.24.2", "ipnet", "js-sys", "log", @@ -2224,22 +2455,65 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-pemfile", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "webpki-roots", - "winreg", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.5", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.3.1", + "hyper-rustls 0.27.2", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 2.1.2", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.52.0", ] [[package]] @@ -2313,10 +2587,23 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.102.4", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2326,6 +2613,22 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64 0.22.1", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -2336,6 +2639,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.102.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.17" @@ -2348,6 +2662,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2364,6 +2687,29 @@ dependencies = [ "untrusted", ] +[[package]] +name = "security-framework" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +dependencies = [ + "bitflags 2.5.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.23" @@ -2581,8 +2927,8 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rustls", - "rustls-pemfile", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", "serde", "serde_json", "sha2", @@ -2824,6 +3170,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + [[package]] name = "system-configuration" version = "0.5.1" @@ -2963,13 +3315,34 @@ dependencies = [ "syn 2.0.66", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls 0.23.10", + "rustls-pki-types", "tokio", ] @@ -3014,6 +3387,27 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + [[package]] name = "tower-service" version = "0.3.2" @@ -3504,6 +3898,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "zerocopy" version = "0.7.34" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a4f02e0..8b39c8f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -43,3 +43,4 @@ tokio = { version = "1.36.0", features = [ "time", ] } uuid = { version = "1.7.0", features = ["serde"] } +gamma_rust_client = { git = "https://github.com/viddem/gamma.git", rev = "1235842562c9f3b50a68582dea13f66fbe93cf5e" } diff --git a/backend/src/config.rs b/backend/src/config.rs index 4571c29..e5d03f6 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -1,19 +1,16 @@ use std::{env, str::FromStr}; +use gamma_rust_client::config::GammaConfig; + #[derive(Clone, Debug)] pub struct Config { pub port: u16, pub db_url: String, pub redis_url: String, - pub gamma_public_url: String, - pub gamma_internal_url: String, - pub gamma_api_key: String, - pub gamma_client_id: String, - pub gamma_client_secret: String, - pub gamma_redirect_uri: String, pub cookie_secret: String, pub cookie_secure: bool, pub group_whitelist: Vec, + pub gamma_config: GammaConfig, } impl Config { @@ -22,12 +19,6 @@ impl Config { port: try_read_var("PORT")?, db_url: try_read_var("DATABASE_URL")?, redis_url: try_read_var("REDIS_URL")?, - gamma_public_url: try_read_var("GAMMA_PUBLIC_URL")?, - gamma_internal_url: try_read_var("GAMMA_INTERNAL_URL")?, - gamma_api_key: try_read_var("GAMMA_API_KEY")?, - gamma_client_id: try_read_var("GAMMA_CLIENT_ID")?, - gamma_client_secret: try_read_var("GAMMA_CLIENT_SECRET")?, - gamma_redirect_uri: try_read_var("GAMMA_REIDRECT_URI")?, cookie_secret: try_read_var("COOKIE_SECRET")?, cookie_secure: try_read_var("COOKIE_SECURE")?, group_whitelist: try_read_var::("GROUP_WHITELIST") @@ -36,6 +27,14 @@ impl Config { .map(|str| str.trim().to_string()) .filter(|str| !str.is_empty()) .collect(), + gamma_config: GammaConfig { + gamma_client_id: try_read_var("GAMMA_CLIENT_ID")?, + gamma_client_secret: try_read_var("GAMMA_CLIENT_SECRET")?, + gamma_redirect_uri: try_read_var("GAMMA_REDIRECT_URI")?, + gamma_url: try_read_var("GAMMA_URL")?, // TODO Probably update this name + scopes: "openid profile".into(), + gamma_api_key: try_read_var("GAMMA_API_KEY")?, + }, }; if conf.group_whitelist.is_empty() { diff --git a/backend/src/error.rs b/backend/src/error.rs index 3425be4..a626568 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -1,4 +1,5 @@ use actix_web::ResponseError; +use gamma_rust_client::error::GammaError; #[derive(Debug, thiserror::Error)] pub enum HubbitError { @@ -16,6 +17,8 @@ pub enum HubbitError { SqlxError(#[from] sqlx::Error), #[error("Io error")] IoError(#[from] std::io::Error), + #[error("Gamma error")] + GammaError(#[from] GammaError), #[error("Entity not found")] NotFound, } diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 6ee9dd5..738cd0c 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -3,8 +3,8 @@ use actix_web::{ web::{self, ServiceConfig}, HttpResponse, }; +use gamma_rust_client::oauth::{GammaAccessToken, GammaState}; use log::{error, warn}; -use rand::{distributions::Alphanumeric, thread_rng, Rng}; use serde::{Deserialize, Serialize}; use crate::config::Config; @@ -19,11 +19,12 @@ async fn gamma_init_flow( session: Session, query: web::Query, ) -> HttpResponse { - if let Ok(Some(access_token)) = session.get::("gamma_access_token") { - if crate::utils::gamma::get_current_user(&config, &access_token) - .await - .is_ok() - { + if let Ok(Some(access_token)) = session.get::("gamma_access_token") { + if let Err(err) = access_token.get_current_user(&config.gamma_config).await { + warn!("[Gamma auth flow] Failed to get current user with the access token, err: {err:?}"); + session.remove("gamma_access_token"); + } else { + // The user is already authenticated. let url = query.from.clone().unwrap_or_else(|| "/".to_string()); return HttpResponse::TemporaryRedirect() .append_header(("Location", url)) @@ -31,12 +32,15 @@ async fn gamma_init_flow( } }; - let state: String = thread_rng() - .sample_iter(&Alphanumeric) - .take(32) - .map(char::from) - .collect(); - match session.insert("gamma_state", &state) { + let gamma_init = match gamma_rust_client::oauth::gamma_init_auth(&config.gamma_config) { + Ok(init) => init, + Err(err) => { + error!("[Gamma auth] Could not setup gamma auth initialization, err: {err:?}"); + return HttpResponse::InternalServerError().finish(); + } + }; + + match session.insert("gamma_state", gamma_init.state) { Ok(_) => {} Err(_) => { error!("[Gamma auth] Could not set gamma_state key in cookie"); @@ -57,14 +61,8 @@ async fn gamma_init_flow( } } - let scope = "openid%20profile"; - - let url = format!( - "{}/oauth2/authorize?response_type=code&client_id={}&state={}&scope={scope}&redirect_uri={}", - config.gamma_public_url, config.gamma_client_id, state, config.gamma_redirect_uri - ); HttpResponse::TemporaryRedirect() - .append_header(("Location", url)) + .append_header(("Location", gamma_init.redirect_to)) .finish() } @@ -79,7 +77,7 @@ async fn gamma_callback( session: Session, query: web::Query, ) -> HttpResponse { - let saved_state = match session.get::("gamma_state") { + let saved_state = match session.get::("gamma_state") { Ok(Some(saved_state)) => saved_state, _ => { warn!("[Gamma auth] Could not retrieve gamma_state"); @@ -87,25 +85,24 @@ async fn gamma_callback( } }; - if query.state != saved_state { - warn!("[Gamma auth] State mismatch"); - return HttpResponse::BadRequest().finish(); - } - - let token_response = match crate::utils::gamma::oauth2_token(&config, &query.code).await { - Ok(token_response) => token_response, + let token = match saved_state + .gamma_callback_params( + &config.gamma_config, + query.state.clone(), + query.code.clone(), + ) + .await + { + Ok(t) => t, Err(err) => { - error!("[Gamma auth] Could not get gamma access token, err {err:?}"); + error!("[Gamma auth] Failed to exchange code for auth token, err: {err:?}"); return HttpResponse::BadRequest().finish(); } }; - match session.insert("gamma_access_token", token_response.access_token) { - Ok(_) => {} - Err(_) => { - error!("[Gamma auth] Could not set gamma_acess_token key in cookie"); - return HttpResponse::InternalServerError().finish(); - } + if let Err(_) = session.insert("gamma_access_token", token) { + error!("[Gamma auth] Could not set gamma_acess_token key in cookie"); + return HttpResponse::InternalServerError().finish(); } let from = match session.get::("gamma_from") { diff --git a/backend/src/handlers/graphql.rs b/backend/src/handlers/graphql.rs index 51e01b1..e9819d3 100644 --- a/backend/src/handlers/graphql.rs +++ b/backend/src/handlers/graphql.rs @@ -6,8 +6,9 @@ use actix_web::{ }; use async_graphql::http::{playground_source, GraphQLPlaygroundConfig}; use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse, GraphQLSubscription}; +use gamma_rust_client::oauth::GammaAccessToken; -use crate::{config::Config, schema::HubbitSchema}; +use crate::{config::Config, models::AuthorizedUser, schema::HubbitSchema}; async fn playground() -> Result { Ok( @@ -26,9 +27,9 @@ async fn graphql( config: web::Data, ) -> GraphQLResponse { let mut request = gql_request.into_inner(); - if let Ok(Some(access_token)) = session.get::("gamma_access_token") { - if let Ok(user) = crate::utils::gamma::get_current_user(&config, &access_token).await { - request = request.data(user); + if let Ok(Some(access_token)) = session.get::("gamma_access_token") { + if let Ok(user) = access_token.get_current_user(&config.gamma_config).await { + request = request.data(AuthorizedUser::from(user)); } }; @@ -43,8 +44,9 @@ async fn graphql_ws( payload: web::Payload, ) -> Result { let mut authenticated = false; - if let Ok(Some(access_token)) = session.get::("gamma_access_token") { - if crate::utils::gamma::get_current_user(&config, &access_token) + if let Ok(Some(access_token)) = session.get::("gamma_access_token") { + if access_token + .get_current_user(&config.gamma_config) .await .is_ok() { diff --git a/backend/src/models.rs b/backend/src/models.rs index 6988bd7..49dc14a 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -99,17 +99,41 @@ impl From for i32 { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct GammaUser { - #[serde(rename = "sub")] pub id: Uuid, pub cid: String, - #[serde(rename = "nickname")] pub nick: String, - #[serde(rename = "given_name")] pub first_name: String, - #[serde(rename = "family_name")] pub last_name: String, - #[serde(rename = "picture")] - pub avatar_url: String, + pub groups: Vec, +} + +impl GammaUser { + pub fn from_user_and_groups( + user: gamma_rust_client::api::GammaUser, + groups: Vec, + ) -> Self { + Self { + id: user.id, + cid: user.cid, + nick: user.nick, + first_name: user.first_name, + last_name: user.last_name, + groups: groups.into_iter().map(|g| g.into()).collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizedUser { + pub user_id: Uuid, +} + +impl From for AuthorizedUser { + fn from(value: gamma_rust_client::oauth::GammaOpenIDUser) -> Self { + Self { + user_id: value.user_id, + } + } } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -122,6 +146,18 @@ pub struct GammaGroup { pub post: GammaGroupPost, } +impl From for GammaGroup { + fn from(value: gamma_rust_client::api::GammaUserGroup) -> Self { + Self { + id: value.id, + name: value.name, + pretty_name: value.pretty_name, + super_group: value.super_group.into(), + post: value.post.into(), + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GammaSuperGroup { @@ -134,20 +170,58 @@ pub struct GammaSuperGroup { pub en_description: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +impl From for GammaSuperGroup { + fn from(value: gamma_rust_client::api::GammaSuperGroup) -> Self { + Self { + id: value.id, + name: value.name, + pretty_name: value.pretty_name, + group_type: value.group_type.into(), + sv_description: value.sv_description, + en_description: value.en_description, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(rename = "camelCase")] pub enum GammaGroupType { Society, Functionaries, Committee, Alumni, + Other(String), +} + +impl From for GammaGroupType { + fn from(value: gamma_rust_client::api::GammaSuperGroupType) -> Self { + match value { + gamma_rust_client::api::GammaSuperGroupType::Alumni => Self::Alumni, + gamma_rust_client::api::GammaSuperGroupType::Committee => Self::Committee, + gamma_rust_client::api::GammaSuperGroupType::Society => Self::Society, + gamma_rust_client::api::GammaSuperGroupType::Functionaries => Self::Functionaries, + gamma_rust_client::api::GammaSuperGroupType::Admin => Self::Other("Admin".into()), + gamma_rust_client::api::GammaSuperGroupType::Other(v) => Self::Other(v), + } + } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GammaGroupPost { pub id: Uuid, - pub version: u32, + pub version: i32, pub sv_name: String, pub en_name: String, } + +impl From for GammaGroupPost { + fn from(value: gamma_rust_client::api::GammaPost) -> Self { + Self { + id: value.id, + version: value.version, + sv_name: value.sv_name, + en_name: value.en_name, + } + } +} diff --git a/backend/src/repositories/user.rs b/backend/src/repositories/user.rs index 6c33fe8..05d7a00 100644 --- a/backend/src/repositories/user.rs +++ b/backend/src/repositories/user.rs @@ -1,4 +1,5 @@ -use reqwest::{header::AUTHORIZATION, Client}; +use gamma_rust_client::{api::GammaClient, error::GammaError}; +use uuid::Uuid; use crate::{ config::Config, @@ -8,32 +9,55 @@ use crate::{ #[derive(Clone)] pub struct UserRepository { - config: Config, + gamma_client: GammaClient, } impl UserRepository { pub fn new(config: Config) -> Self { - Self { config } + let gamma_client = GammaClient::new(&config.gamma_config); + Self { gamma_client } } - pub async fn get(&self, id: String) -> HubbitResult { - let client = Client::new(); - let res = client - .get(&format!( - "{}/api/users/{}", - self.config.gamma_internal_url, id - )) - .header( - AUTHORIZATION, - format!("pre-shared {}", self.config.gamma_api_key), - ) - .send() - .await?; - if res.status() == 404 { - return Err(HubbitError::NotFound); + pub async fn get(&self, id: &Uuid) -> HubbitResult { + let user = self.gamma_client.get_user(id).await.map_err(|err| { + if matches!(err, GammaError::NotFoundResponse { .. }) { + return HubbitError::NotFound; + } + + HubbitError::GammaError(err) + })?; + + let groups = self.gamma_client.get_groups_for_user(&user.id).await?; + + Ok(GammaUser::from_user_and_groups(user, groups)) + } + + pub async fn get_by_cid(&self, cid: &str) -> HubbitResult { + log::warn!( + "Retrieving user by cid ('{cid}'), this is quite expensive and should be avoided if possible" + ); + + let user = self + .gamma_client + .get_users() + .await? + .into_iter() + .filter(|user| user.cid.as_str() == cid) + .next() + .ok_or(HubbitError::NotFound)?; + + let groups = self.gamma_client.get_groups_for_user(&user.id).await?; + + Ok(GammaUser::from_user_and_groups(user, groups)) + } + + pub async fn get_all(&self) -> HubbitResult> { + let mut mapped = vec![]; + for user in self.gamma_client.get_users().await?.into_iter() { + let groups = self.gamma_client.get_groups_for_user(&user.id).await?; + mapped.push(GammaUser::from_user_and_groups(user, groups)); } - let body = res.text().await?; - Ok(serde_json::from_str(&body)?) + Ok(mapped) } } diff --git a/backend/src/schema/device.rs b/backend/src/schema/device.rs index 82f2ff7..40d238d 100644 --- a/backend/src/schema/device.rs +++ b/backend/src/schema/device.rs @@ -3,7 +3,7 @@ use log::error; use uuid::Uuid; use crate::{ - models::GammaUser, + models::{AuthorizedUser, GammaUser}, repositories::{ device::{CreateDevice, DeviceRepository, UpdateDevice}, session::SessionRepository, @@ -76,11 +76,14 @@ impl DeviceMutation { } let device_repo = context.data_unchecked::(); - let auth_user = context.data_unchecked::(); - let current_devices = device_repo.get_for_user(auth_user.id).await.map_err(|e| { - error!("[Schema error] {:?}", e); - HubbitSchemaError::InternalError - })?; + let auth_user = context.data_unchecked::(); + let current_devices = device_repo + .get_for_user(auth_user.user_id) + .await + .map_err(|e| { + error!("[Schema error] {:?}", e); + HubbitSchemaError::InternalError + })?; let mut devices_to_create = Vec::new(); let mut devices_to_update = Vec::new(); @@ -130,7 +133,7 @@ impl DeviceMutation { .create(CreateDevice { address: device.address, name: device.name, - user_id: auth_user.id, + user_id: auth_user.user_id, }) .await .map_err(|e| { @@ -139,10 +142,13 @@ impl DeviceMutation { })?; } - let current_devices = device_repo.get_for_user(auth_user.id).await.map_err(|e| { - error!("[Schema error] {:?}", e); - HubbitSchemaError::InternalError - })?; + let current_devices = device_repo + .get_for_user(auth_user.user_id) + .await + .map_err(|e| { + error!("[Schema error] {:?}", e); + HubbitSchemaError::InternalError + })?; Ok( current_devices diff --git a/backend/src/schema/me.rs b/backend/src/schema/me.rs index 55e72c6..c1c6681 100644 --- a/backend/src/schema/me.rs +++ b/backend/src/schema/me.rs @@ -1,6 +1,6 @@ use async_graphql::{Context, Object}; -use crate::models::GammaUser; +use crate::models::AuthorizedUser; use super::{user::User, AuthGuard}; @@ -11,7 +11,7 @@ pub struct MeQuery; impl MeQuery { #[graphql(guard = AuthGuard)] pub async fn me(&self, context: &Context<'_>) -> User { - let user = context.data_unchecked::(); - User { id: user.id } + let user = context.data_unchecked::(); + User { id: user.user_id } } } diff --git a/backend/src/schema/mod.rs b/backend/src/schema/mod.rs index 66bcd5f..f18d2d6 100644 --- a/backend/src/schema/mod.rs +++ b/backend/src/schema/mod.rs @@ -10,7 +10,7 @@ use async_graphql::{Context, ErrorExtensions, Guard, MergedObject, Result, Schem use futures::StreamExt; use crate::{ - broker::SimpleBroker, event::UserEvent, models::GammaUser, + broker::SimpleBroker, event::UserEvent, models::AuthorizedUser, repositories::user_session::UserSessionRepository, }; @@ -108,7 +108,7 @@ pub struct AuthGuard; impl Guard for AuthGuard { async fn check(&self, context: &Context<'_>) -> Result<()> { - if context.data_opt::().is_some() { + if context.data_opt::().is_some() { Ok(()) } else { Err(HubbitSchemaError::NotLoggedIn.extend()) diff --git a/backend/src/schema/user.rs b/backend/src/schema/user.rs index 6ec4801..1d233a8 100644 --- a/backend/src/schema/user.rs +++ b/backend/src/schema/user.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - models::{GammaUser, UserSession}, + models::{AuthorizedUser, GammaGroupType, GammaUser, UserSession}, repositories::{device::DeviceRepository, user_session::UserSessionRepository}, services::{hour_stats::HourStatsService, user::UserService}, utils::{MAX_DATETIME, MIN_DATETIME}, @@ -69,6 +69,7 @@ impl User { } async fn cid(&self, context: &Context<'_>) -> HubbitSchemaResult { + log::info!("Retrieving CID for user {}", self.id); let user_service = context.data_unchecked::(); let user = user_service.get_by_id(self.id, false).await.map_err(|e| { error!("[Schema error] {:?}", e); @@ -104,15 +105,6 @@ impl User { Ok(user.last_name) } - async fn avatar_url(&self, context: &Context<'_>) -> HubbitSchemaResult { - let user_service = context.data_unchecked::(); - let user = user_service.get_by_id(self.id, false).await.map_err(|e| { - error!("[Schema error] {:?}", e); - HubbitSchemaError::InternalError - })?; - Ok(user.avatar_url) - } - async fn groups(&self, context: &Context<'_>) -> HubbitSchemaResult> { let user_service = context.data_unchecked::(); let config = context.data_unchecked::(); @@ -120,11 +112,12 @@ impl User { error!("[Schema error] {:?}", e); HubbitSchemaError::InternalError })?; + let mut groups = user .groups .into_iter() .filter(|group| { - group.active + group.super_group.group_type != GammaGroupType::Alumni && (config.group_whitelist.is_empty() || config.group_whitelist.contains(&group.super_group.name)) }) @@ -151,7 +144,7 @@ impl User { } async fn recent_sessions(&self, context: &Context<'_>) -> HubbitSchemaResult> { - let user = context.data_unchecked::(); + let user = context.data_unchecked::(); let user_session_repo = context.data_unchecked::(); let sessions = user_session_repo @@ -161,7 +154,7 @@ impl User { error!("[Schema error] {:?}", e); HubbitSchemaError::InternalError })?; - let sessions_limit = if user.id == self.id { 10 } else { 1 }; + let sessions_limit = if user.user_id == self.id { 10 } else { 1 }; Ok( sessions .iter() @@ -291,10 +284,11 @@ impl User { } pub async fn devices(&self, context: &Context<'_>) -> HubbitSchemaResult> { - let auth_user = context - .data::() - .map_err(|_| HubbitSchemaError::NotLoggedIn)?; - if self.id != auth_user.id { + let auth_user = context.data::().map_err(|err| { + error!("Failed to retrieve current user for devices, err: {err:?}"); + HubbitSchemaError::NotLoggedIn + })?; + if self.id != auth_user.user_id { return Err(HubbitSchemaError::NotAuthorized); } diff --git a/backend/src/services/user.rs b/backend/src/services/user.rs index d6f0fec..6206d30 100644 --- a/backend/src/services/user.rs +++ b/backend/src/services/user.rs @@ -65,16 +65,16 @@ impl UserService { self.store_user_in_cache(user_entry.user.clone()).await; let redis_pool = self.redis_pool.clone(); let user_repo = self.user_repo.clone(); - tokio::spawn(async move { - Self::fetch_and_store_user_redis(user_repo, redis_pool, id.to_string()).await - }); + tokio::spawn( + async move { Self::fetch_and_store_user_redis(user_repo, redis_pool, &id).await }, + ); return Ok(user_entry.user); } } // If in neither local cache or redis, fetch the user - self.fetch_and_store_user(id.to_string()).await + self.fetch_and_store_user(&id).await } pub async fn get_by_cid(&self, cid: String) -> HubbitResult { @@ -82,7 +82,10 @@ impl UserService { if let Ok(id) = redis_get::(self.redis_pool.clone(), &key).await { self.get_by_id(id, false).await } else { - self.fetch_and_store_user(cid).await + log::info!( + "Retrieving user by cid ('{cid}'), this is quite expensive and should be avoided if possible" + ); + self.fetch_and_store_user_by_cid(cid).await } } @@ -121,7 +124,7 @@ impl UserService { let redis_pool = self.redis_pool.clone(); let user_repo = self.user_repo.clone(); tokio::spawn(async move { - Self::fetch_and_store_user_redis(user_repo, redis_pool, id.to_string()).await + Self::fetch_and_store_user_redis(user_repo, redis_pool, &id).await }); } } else { @@ -136,7 +139,7 @@ impl UserService { let user_repo = self.user_repo.clone(); let redis_pool = self.redis_pool.clone(); async move { - match user_repo.get(id.to_string()).await { + match user_repo.get(id).await { Ok(user) => { let user_entry = UserEntry { user: user.clone(), @@ -163,17 +166,30 @@ impl UserService { Ok(users) } - async fn fetch_and_store_user(&self, id: String) -> HubbitResult { + async fn fetch_and_store_user(&self, id: &Uuid) -> HubbitResult { + log::info!("Fetching user from auth service, id: {id:?}"); let user = Self::fetch_and_store_user_redis(self.user_repo.clone(), self.redis_pool.clone(), id).await?; self.store_user_in_cache(user.clone()).await; Ok(user) } + async fn fetch_and_store_user_by_cid(&self, cid: String) -> HubbitResult { + log::info!("Fetching user from auth service by cid: {cid:?}"); + let user = Self::fetch_and_store_user_by_cid_redis( + self.user_repo.clone(), + self.redis_pool.clone(), + cid.as_str(), + ) + .await?; + self.store_user_in_cache(user.clone()).await; + Ok(user) + } + async fn fetch_and_store_user_redis( user_repo: UserRepository, redis_pool: RedisPool, - id: String, + id: &Uuid, ) -> HubbitResult { let user = user_repo.get(id).await?; let user_entry = UserEntry { @@ -184,6 +200,20 @@ impl UserService { Ok(user) } + async fn fetch_and_store_user_by_cid_redis( + user_repo: UserRepository, + redis_pool: RedisPool, + cid: &str, + ) -> HubbitResult { + let user = user_repo.get_by_cid(cid).await?; + let user_entry = UserEntry { + user: user.clone(), + updated_at: Local::now(), + }; + tokio::spawn(async move { Self::store_user_redis(redis_pool, user_entry).await }); + Ok(user) + } + async fn store_user_redis(redis_pool: RedisPool, user_entry: UserEntry) -> HubbitResult<()> { let id_key = format!("user:id:{}", user_entry.user.id); let cid_key = format!("user:cid:{}", user_entry.user.cid); diff --git a/backend/src/utils/gamma.rs b/backend/src/utils/gamma.rs deleted file mode 100644 index 0639652..0000000 --- a/backend/src/utils/gamma.rs +++ /dev/null @@ -1,55 +0,0 @@ -use reqwest::Client; -use serde::{Deserialize, Serialize}; - -use crate::{config::Config, error::HubbitResult, models::GammaUser}; - -#[derive(Debug, Deserialize)] -pub struct GammaTokenResponse { - pub access_token: String, -} - -#[derive(Debug, Serialize)] -struct GammaTokenRequest { - client_id: String, - client_secret: String, - code: String, - redirect_uri: String, - grant_type: String, -} - -pub async fn oauth2_token(config: &Config, code: &str) -> HubbitResult { - let client = Client::new(); - - let url = format!("{}/oauth2/token", config.gamma_internal_url); - - let body_str = client - .post(&url) - .form(&GammaTokenRequest { - client_id: config.gamma_client_id.clone(), - client_secret: config.gamma_client_secret.clone(), - code: code.into(), - redirect_uri: config.gamma_redirect_uri.clone(), - grant_type: "authorization_code".into(), - }) - .header("accept", "application/json") - .send() - .await? - .text() - .await?; - - Ok(serde_json::from_str(&body_str)?) -} - -pub async fn get_current_user(config: &Config, access_token: &str) -> HubbitResult { - let client = Client::new(); - let url = format!("{}/oauth2/userinfo", config.gamma_internal_url); - let body_str = client - .get(&url) - .bearer_auth(access_token) - .send() - .await? - .text() - .await?; - - Ok(serde_json::from_str(&body_str)?) -} diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs index fb643d0..fe90413 100644 --- a/backend/src/utils/mod.rs +++ b/backend/src/utils/mod.rs @@ -1,5 +1,3 @@ -pub mod gamma; - use chrono::{DateTime, Local, TimeZone}; use lazy_static::lazy_static; diff --git a/frontend/schema.gql b/frontend/schema.gql index d90a17e..de722f7 100644 --- a/frontend/schema.gql +++ b/frontend/schema.gql @@ -1,8 +1,9 @@ type ActiveSession { - user: User! - startTime: DateTime! + user: User! + startTime: DateTime! } + """ Implement the DateTime scalar @@ -11,122 +12,126 @@ The input/output is a string in RFC3339 format. scalar DateTime type Device { - id: UUID! - address: String! - name: String! - isActive: Boolean! + id: UUID! + address: String! + name: String! + isActive: Boolean! } input DeviceInput { - address: String! - name: String! + address: String! + name: String! } + type Group { - name: String! - prettyName: String! + name: String! + prettyName: String! } + + type MutationRoot { - setDevices(data: SetDevicesInput!): [Device!]! + setDevices(data: SetDevicesInput!): [Device!]! } enum Period { - SUMMER - LP1 - LP2 - LP3 - LP4 + SUMMER + LP1 + LP2 + LP3 + LP4 } type QueryRoot { - currentSessions: [ActiveSession!]! - statsAlltime: [Stat!]! - statsStudyYear(input: StatsStudyYearInput): StatsStudyYearPayload! - statsStudyPeriod(input: StatsStudyPeriodInput): StatsStudyPeriodPayload! - statsMonth(input: StatsMonthInput): StatsMonthPayload! - statsWeek(input: StatsWeekInput): StatsWeekPayload! - statsDay(input: StatsDayInput): StatsDayPayload! - me: User! - user(input: UserUniqueInput!): User! + currentSessions: [ActiveSession!]! + statsAlltime: [Stat!]! + statsStudyYear(input: StatsStudyYearInput): StatsStudyYearPayload! + statsStudyPeriod(input: StatsStudyPeriodInput): StatsStudyPeriodPayload! + statsMonth(input: StatsMonthInput): StatsMonthPayload! + statsWeek(input: StatsWeekInput): StatsWeekPayload! + statsDay(input: StatsDayInput): StatsDayPayload! + me: User! + user(input: UserUniqueInput!): User! } type Session { - startTime: DateTime! - endTime: DateTime! + startTime: DateTime! + endTime: DateTime! } input SetDevicesInput { - devices: [DeviceInput!]! + devices: [DeviceInput!]! } type Stat { - user: User! - durationSeconds: Int! - currentPosition: Int! - prevPosition: Int + user: User! + durationSeconds: Int! + currentPosition: Int! + prevPosition: Int } input StatsDayInput { - year: Int! - month: Int! - day: Int! + year: Int! + month: Int! + day: Int! } type StatsDayPayload { - stats: [Stat!]! - curr: YearMonthDay! - next: YearMonthDay! - prev: YearMonthDay! + stats: [Stat!]! + curr: YearMonthDay! + next: YearMonthDay! + prev: YearMonthDay! } input StatsMonthInput { - year: Int! - month: Int! + year: Int! + month: Int! } type StatsMonthPayload { - stats: [Stat!]! - curr: YearMonth! - next: YearMonth! - prev: YearMonth! + stats: [Stat!]! + curr: YearMonth! + next: YearMonth! + prev: YearMonth! } input StatsStudyPeriodInput { - year: Int! - period: Period! + year: Int! + period: Period! } type StatsStudyPeriodPayload { - stats: [Stat!]! - year: Int! - period: Period! + stats: [Stat!]! + year: Int! + period: Period! } input StatsStudyYearInput { - year: Int! + year: Int! } type StatsStudyYearPayload { - stats: [Stat!]! - year: Int! + stats: [Stat!]! + year: Int! } input StatsWeekInput { - year: Int! - week: Int! + year: Int! + week: Int! } type StatsWeekPayload { - stats: [Stat!]! - curr: YearWeek! - next: YearWeek! - prev: YearWeek! + stats: [Stat!]! + curr: YearWeek! + next: YearWeek! + prev: YearWeek! } + type SubscriptionRoot { - userJoin: ActiveSession! - userLeave: User! + userJoin: ActiveSession! + userLeave: User! } """ @@ -142,47 +147,50 @@ entities without requiring a central allocating authority. scalar UUID type User { - id: UUID! - cid: String! - nick: String! - firstName: String! - lastName: String! - avatarUrl: String! - groups: [Group!]! - hourStats: [Int!]! - recentSessions: [Session!]! - longestSession: Session - totalTimeSeconds: Int! - averageTimePerDay: Int! - timeTodaySeconds: Int! - devices: [Device!]! - currAlltimePosition: Int - currStudyYearPosition: Int + id: UUID! + cid: String! + nick: String! + firstName: String! + lastName: String! + groups: [Group!]! + hourStats: [Int!]! + recentSessions: [Session!]! + longestSession: Session + totalTimeSeconds: Int! + averageTimePerDay: Int! + timeTodaySeconds: Int! + devices: [Device!]! + currAlltimePosition: Int + currStudyYearPosition: Int } input UserUniqueInput { - id: UUID - cid: String + id: UUID + cid: String } type YearMonth { - year: Int! - month: Int! + year: Int! + month: Int! } type YearMonthDay { - year: Int! - month: Int! - day: Int! + year: Int! + month: Int! + day: Int! } type YearWeek { - year: Int! - week: Int! + year: Int! + week: Int! } +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @specifiedBy(url: String!) on SCALAR schema { - query: QueryRoot - mutation: MutationRoot - subscription: SubscriptionRoot + query: QueryRoot + mutation: MutationRoot + subscription: SubscriptionRoot } +