diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..64491872 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +[alias] +minimal = "run --no-default-features" +mails = "run --no-default-features --features mails" +otel = "run --no-default-features --features otel" +reports = "run --no-default-features --features reports" +mails-otel = "run --no-default-features --features mails,otel" +full = "run --no-default-features --features full" diff --git a/.env.example b/.env.example index c56115e2..603c6d29 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ OTLP_STREAM=discord-analytics #Tokens DISCORD_TOKEN=ASUPERSECRETTOKEN JWT_SECRET=ASUPERSECRETSECRET +ENABLE_REGISTRATIONS=true # Linked Roles CLIENT_SECRET=ASUPERSECRETSECRET diff --git a/Cargo.lock b/Cargo.lock index 1524a4d5..241365dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,25 +44,33 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", + "base64", "bitflags", + "brotli", "bytes", "bytestring", "derive_more", "encoding_rs", + "flate2", "foldhash", "futures-core", + "h2 0.3.27", "http 0.2.12", "httparse", "httpdate", "itoa", "language-tags", + "local-channel", "mime", "percent-encoding", "pin-project-lite", + "rand 0.9.2", + "sha1", "smallvec", "tokio", "tokio-util", "tracing", + "zstd", ] [[package]] @@ -72,7 +80,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -84,6 +92,7 @@ dependencies = [ "bytestring", "cfg-if", "http 0.2.12", + "regex", "regex-lite", "serde", "tracing", @@ -154,6 +163,7 @@ dependencies = [ "bytes", "bytestring", "cfg-if", + "cookie", "derive_more", "encoding_rs", "foldhash", @@ -166,6 +176,7 @@ dependencies = [ "mime", "once_cell", "pin-project-lite", + "regex", "regex-lite", "serde", "serde_json", @@ -186,7 +197,23 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn", + "syn 2.0.116", +] + +[[package]] +name = "actix-ws" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf53c3cdd63dd6f289980b430238f9a2f6d19f8bce8e418272e08d3da43f0f" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "bytestring", + "futures-core", + "futures-sink", + "tokio", + "tokio-util", ] [[package]] @@ -243,9 +270,114 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "apistos" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d760d9d963332b6912446c1b3a7cd1800b4f4707a164fbdc9f0bf91400b62d53" +dependencies = [ + "actix-service", + "actix-web", + "apistos-core", + "apistos-gen", + "apistos-models", + "apistos-plugins", + "apistos-schemars", + "futures-util", + "indexmap", + "log", + "md5 0.7.0", + "once_cell", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "apistos-core" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "9d3de18e37d05bf6c17471257accb808fe9a77043f26a61b261ed2b4d8419134" +dependencies = [ + "actix-web", + "apistos-models", + "apistos-schemars", + "pin-project", +] + +[[package]] +name = "apistos-gen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f72df2c61318d7b8442d0207c91fa731deec67fbb67fbc3a918a668641a21481" +dependencies = [ + "actix-web", + "convert_case 0.8.0", + "darling 0.20.11", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "apistos-models" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81d2b38beb4ed2bd3029b31adf1e83dd8c632bfba6f7ce0e70981e2028929d33" +dependencies = [ + "apistos-schemars", + "indexmap", + "serde", + "serde_json", +] + +[[package]] +name = "apistos-plugins" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b97bf08fc55a42ba3f039f407e064b79056f793e7b21f6f2d3831131955c7b" +dependencies = [ + "actix-web", +] + +[[package]] +name = "apistos-schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510e32cfe546c42348cc465d716905cd2c2f95435fbae406edfe8c65dae147c5" +dependencies = [ + "apistos-schemars_derive", + "chrono", + "dyn-clone", + "rust_decimal", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "apistos-schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f12354f23e2b02b8e439acc9e8da7c3e0081ec410d2fb8bbf976f54a67d5b8c" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.116", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "async-trait" @@ -255,7 +387,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -444,9 +576,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -499,6 +631,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -508,6 +649,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -633,7 +785,17 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] @@ -642,8 +804,22 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.116", ] [[package]] @@ -657,7 +833,18 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.116", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.116", ] [[package]] @@ -666,9 +853,9 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -705,7 +892,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -716,7 +903,7 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -734,11 +921,11 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.116", "unicode-xid", ] @@ -760,7 +947,10 @@ version = "3.0.0" dependencies = [ "actix-cors", "actix-web", + "actix-ws", "anyhow", + "apistos", + "apistos-schemars", "chrono", "dotenvy", "futures", @@ -790,7 +980,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -805,6 +995,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -888,7 +1084,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -980,6 +1176,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1027,7 +1224,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -1048,6 +1245,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1121,6 +1319,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -1297,7 +1514,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", + "h2 0.4.13", "http 1.4.0", "http-body", "httparse", @@ -1651,6 +1868,17 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + [[package]] name = "local-waker" version = "0.1.4" @@ -1687,7 +1915,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -1701,7 +1929,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -1712,7 +1940,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -1723,7 +1951,16 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn", + "syn 2.0.116", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", ] [[package]] @@ -1736,6 +1973,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "md5" version = "0.8.0" @@ -1866,7 +2109,7 @@ dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -2122,7 +2365,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -2201,7 +2444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.116", ] [[package]] @@ -2213,6 +2456,30 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2242,14 +2509,14 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] name = "quick-xml" -version = "0.39.1" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd58c6a1fc307e1092aa0bb23d204ca4d1f021764142cd0424dccc84d2d5d106" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", "serde", @@ -2499,7 +2766,7 @@ dependencies = [ "base64", "bytes", "futures-core", - "h2", + "h2 0.4.13", "http 1.4.0", "http-body", "http-body-util", @@ -2516,6 +2783,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -2530,9 +2798,9 @@ dependencies = [ [[package]] name = "reqx" -version = "0.1.20" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9a396559e0954a5a3736b39dcdb47a0b34d3298b9e39df0cff4d9bac3cf41e5" +checksum = "0a12032538346e10d36cdd72222430958f3fbab34f53d923da45f10077546066" dependencies = [ "brotli", "bytes", @@ -2545,7 +2813,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "md5", + "md5 0.8.0", "rand 0.10.0", "rustls", "rustls-native-certs", @@ -2614,6 +2882,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "num-traits", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2730,9 +3008,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "s3" -version = "0.1.15" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04afd783b81fd46d27ebee561620e6f892ec0a3e4ffd5fb3b16fa51c3d331a54" +checksum = "ce3d2be7cf19dc5c58d039aa8405eb21d7feacefbc84f7d9277303b6831e9fd8" dependencies = [ "base64", "bytes", @@ -2858,7 +3136,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", ] [[package]] @@ -2902,10 +3191,10 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3054,6 +3343,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.116" @@ -3082,7 +3381,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3129,7 +3428,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3140,7 +3439,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3219,9 +3518,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -3242,7 +3541,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3276,6 +3575,7 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -3390,7 +3690,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3408,9 +3708,13 @@ version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "thread_local", + "tracing", "tracing-core", ] @@ -3437,7 +3741,7 @@ checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3651,7 +3955,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.116", "wasm-bindgen-shared", ] @@ -3772,7 +4076,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3783,7 +4087,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -4138,7 +4442,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.116", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4154,7 +4458,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.116", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4230,7 +4534,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", "synstructure", ] @@ -4251,7 +4555,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -4271,7 +4575,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", "synstructure", ] @@ -4311,7 +4615,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 65d6245e..71539a8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,51 +8,87 @@ readme = "README.md" homepage = "https://discordanalytics.xyz" repository = "https://github.com/DiscordAnalytics/api" +[features] +default = ["full"] +mails = [] +otel = [ + "dep:opentelemetry-appender-tracing", + "dep:opentelemetry-otlp", + "dep:opentelemetry_sdk", +] +reports = ["dep:s3", "mails"] +full = ["mails", "otel", "reports"] + [lib] name = "api" path = "src/lib.rs" [dependencies] actix-cors = { version = "0.7.1", default-features = false } -actix-web = { version = "4.12.1", default-features = false, features = [ +actix-web = { version = "4.13.0", default-features = false, features = [ "macros", ] } -anyhow = { version = "1.0.101", default-features = false, features = ["std"] } -chrono = { version = "0.4.43", default-features = false, features = [ +actix-ws = { version = "0.4.0", default-features = false } +anyhow = { version = "1.0.102", default-features = false, features = ["std"] } +apistos = { version = "0.6.0", default-features = false, features = ["query"] } +chrono = { version = "0.4.44", default-features = false, features = [ "clock", "serde", ] } dotenvy = { version = "0.15.7", default-features = false } -futures = { version = "0.3.32", default-features = false, features = ["alloc"] } +futures = { version = "0.3.32", default-features = false, features = [ + "alloc", + "executor", +] } hex = { version = "0.4.3", default-features = false, features = ["alloc"] } -jsonwebtoken = { version = "10.3.0", default-features = false, features = ["rust_crypto"] } +jsonwebtoken = { version = "10.3.0", default-features = false, features = [ + "rust_crypto", +] } mongodb = { version = "3.5.1", default-features = false, features = [ "bson-3", "compat-3-3-0", "dns-resolver", "rustls-tls", ] } +opentelemetry-appender-tracing = { version = "0.31.1", optional = true, default-features = false } +opentelemetry-otlp = { version = "0.31.0", optional = true, default-features = false, features = [ + "logs", + "http-proto", + "reqwest-rustls", +] } +opentelemetry_sdk = { version = "0.31.0", optional = true, default-features = false, features = [ + "logs", +] } regex = { version = "1.12.3", default-features = false, features = ["std"] } -reqwest = { version = "0.13.2", default-features = false, features = ["json", "http2", "rustls"] } +reqwest = { version = "0.13.2", default-features = false, features = [ + "form", + "http2", + "json", + "rustls", +] } ring = { version = "0.17.14", default-features = false, features = ["alloc"] } -s3 = { version = "0.1.15", default-features = false, features = [ +s3 = { version = "0.1.21", optional = true, default-features = false, features = [ "async", "rustls", "providers", ] } +schemars = { package = "apistos-schemars", version = "0.8.22", default-features = false, features = [ + "derive", + "schemars_derive", +] } serde = { version = "1.0.228", default-features = false, features = ["derive"] } serde_json = { version = "1.0.149", default-features = false } -opentelemetry-appender-tracing = { version = "0.31.1", default-features = false } -opentelemetry-otlp = { version = "0.31.0", default-features = false, features = ["logs", "http-proto", "reqwest-rustls"] } -opentelemetry_sdk = { version = "0.31.0", default-features = false, features = ["logs"] } -tokio = { version = "1.49.0", default-features = false, features = [ +tokio = { version = "1.50.0", default-features = false, features = [ "rt-multi-thread", "macros", ] } tracing = { version = "0.1.44", default-features = false, features = ["std"] } -tracing-actix-web = { version = "0.7.21", default-features = false, features = ["emit_event_on_error"] } +tracing-actix-web = { version = "0.7.21", default-features = false, features = [ + "emit_event_on_error", +] } tracing-subscriber = { version = "0.3.22", default-features = false, features = [ "ansi", + "env-filter", "fmt", "std", ] } diff --git a/README.md b/README.md index b8bbe523..576bf316 100644 --- a/README.md +++ b/README.md @@ -1 +1,156 @@ # DiscordAnalytics API + +Official REST API for [DiscordAnalytics](https://discordanalytics.xyz) — a platform for tracking and analyzing Discord bot statistics, votes, users, and teams. + +Built with [Rust](https://www.rust-lang.org/), [Actix-Web](https://actix.rs/), and [MongoDB](https://www.mongodb.com/). + +--- + +## Table of Contents + +- [Requirements](#requirements) +- [Getting Started](#getting-started) +- [Environment Variables](#environment-variables) +- [Running the API](#running-the-api) +- [Architecture](#architecture) +- [Authentication](#authentication) + +--- + +## Requirements + +- [Rust](https://www.rust-lang.org/tools/install) +- [MongoDB](https://www.mongodb.com/) instance +- A Discord application (for OAuth2) +- An SMTP server (optional, for email notifications) +- A Cloudflare R2 bucket (optional, for file storage) +- An OpenTelemetry collector (optional, for telemetry data) + +--- + +## Getting Started + +**1. Clone the repository** + +```sh +git clone https://github.com/DiscordAnalytics/api.git +cd api +``` + +**2. Copy the environment file and fill in the values** + +```sh +cp .env.example .env +``` + +**3. Install dependencies and build** + +```sh +cargo build +``` + +--- + +## Environment Variables + +| Variable | Required | Default | Description | +|-----------------------------|----------|-------------------|--------------------------------------------------------------------------| +| `PORT` | No | `3001` | Port the API will listen on | +| `API_URL` | No | `0.0.0.0:{PORT}` | Public URL of the API | +| `CLIENT_URL` | **Yes** | — | URL of the frontend client (used for CORS and OAuth redirects) | +| `ADMINS` | No | — | Comma-separated list of Discord user IDs with admin privileges | +| `DATABASE_URL` | **Yes** | — | MongoDB connection string | +| `OTLP_ENDPOINT` | No | — | OpenTelemetry collector endpoint (all three OTLP vars must be set) | +| `OTLP_TOKEN` | No | — | OpenTelemetry authentication token | +| `OTLP_STREAM` | No | — | OpenTelemetry stream name | +| `DISCORD_TOKEN` | **Yes** | — | Discord bot token | +| `JWT_SECRET` | **Yes** | — | Secret used to sign JWT tokens | +| `ENABLE_REGISTRATIONS` | No | `true` | Whether new user registrations are allowed (`true` or `1` to enable) | +| `CLIENT_SECRET` | **Yes** | — | Discord OAuth2 client secret | +| `CLIENT_ID` | **Yes** | — | Discord OAuth2 client ID | +| `SMTP` | No | — | SMTP server address | +| `SMTP_MAIL` | No | — | Sender email address | +| `SMTP_USER` | No | — | SMTP username | +| `SMTP_PASSWORD` | No | — | SMTP password | +| `R2_BUCKET_NAME` | No | — | Cloudflare R2 bucket name | +| `R2_ACCOUNT_ID` | No | — | Cloudflare account ID | +| `R2_PUBLIC_BUCKET_ENDPOINT` | No | — | Public URL of the R2 bucket | +| `CLOUDFLARE_ID` | No | — | Cloudflare API ID | +| `CLOUDFLARE_TOKEN` | No | — | Cloudflare API token | + +> **Note 1:** Required when using the `otel` feature, if any one of `OTLP_ENDPOINT`, `OTLP_TOKEN`, or `OTLP_STREAM` is set, all three must be provided. +> **Note 2:** Required when using the `mails` feature, if any of the `SMTP` variables are set, all of them must be provided to enable email notifications. +> **Note 3:** Required when using the `reports` feature, if any of the `R2` or `CLOUDFLARE` variables are set, all of them must be provided to enable Cloudflare R2 integration. + +--- + +## Running the API + +**Development** + +The following `cargo` commands run the API with different feature sets. You can also combine features as needed (e.g. `cargo run --features "mails otel"`). + +```sh +cargo minimal # Runs the api without any features +cargo mails # Runs the API with email notifications enabled +cargo otel # Runs the API with OpenTelemetry enabled +cargo reports # Runs the API with the reports feature enabled +cargo mails-otel # Runs the API with both email notifications and OpenTelemetry enabled +cargo full # Runs the API with all features enabled +``` + +**Production** + +```sh +cargo build --release +./target/release/discord-analytics-api +``` + +The API will start on `http://0.0.0.0:3001` by default. The OpenAPI specification is available at `/openapi.json`. + +--- + +## Architecture + +``` +src/ +├── api/ +│ ├── middleware/ # Auth middleware and request extractors +│ └── routes/ # Route handlers grouped by resource +│ ├── achievements/ +│ ├── auth/ # OAuth2 callback, token refresh, session management +│ ├── bots/ # Bot CRUD operations +│ ├── health/ # Health check endpoint +│ ├── invitations/ # Team invitations +│ ├── stats/ # Global statistics +│ ├── users/ # User CRUD and management +│ └── websocket/ # WebSocket endpoint +├── config/ # Environment configuration +├── domain/ +│ ├── auth/ # JWT, token generation, auth context +│ ├── error.rs # API error types +│ └── models/ # MongoDB document models +├── managers/ +│ ├── chat.rs # WebSocket chat server +│ └── webhook.rs # Vote webhook delivery manager +├── openapi/ # OpenAPI spec builder +├── repository/ # MongoDB repository layer +├── services/ # Business logic layer +└── utils/ # Constants, logger, Discord utilities +``` + +--- + +## Authentication + +The API uses three types of authentication, passed via the `Authorization` header: + +| Type | Header format | Description | +|---------|---------------------------|-------------------------------------------------| +| `Admin` | `Admin `| Admin user (must be listed in `ADMINS` env var) | +| `User` | `User ` | Authenticated dashboard user | +| `Bot` | `Bot ` | A registered Discord bot | + +Access tokens expire after **30 minutes**. Refresh tokens expire after **30 days** and can be exchanged for a new access token at `POST /auth/refresh`. + +--- diff --git a/src/api/middleware/auth.rs b/src/api/middleware/auth.rs index c6bffdf9..31bf4262 100644 --- a/src/api/middleware/auth.rs +++ b/src/api/middleware/auth.rs @@ -6,11 +6,11 @@ use actix_web::{ http::header::{self, HeaderValue}, }; use futures::future::LocalBoxFuture; -use tracing::warn; +use tracing::{info, warn}; use crate::{ - domain::auth::{AuthContext, AuthType, Authorization, decode_jwt}, - utils::logger::LogCode, + domain::auth::{AuthContext, AuthType, Authorization, decode_access_token}, + utils::{discord::is_valid_snowflake, logger::LogCode}, }; pub struct AuthMiddleware; @@ -53,17 +53,33 @@ where .headers() .get("Authorization") .and_then(|h| h.to_str().ok()); - if let Some(auth_header) = auth_header { - if let Some(authorization) = Authorization::parse(auth_header) { - if matches!(authorization.auth_type, AuthType::Admin | AuthType::User) { - match decode_jwt(&authorization.token) { + if let Some(auth_header) = auth_header + && let Some(authorization) = Authorization::parse(auth_header) + { + match authorization.auth_type { + AuthType::Admin | AuthType::User => { + match decode_access_token(&authorization.token) { Ok(claims) => { + let sub = claims.sub; + let sid = claims.sid; + + info!( + code = %LogCode::Auth, + session_id = %sid, + "Decoded JWT for user_id: {} with auth type: {:?}", + sub, + authorization.auth_type + ); + + let new_auth = format!("{} {}", authorization.auth_type, sub); + let auth_context = AuthContext::new(authorization.auth_type) - .with_user_id(claims.sub.clone()); + .with_user_id(sub) + .with_session_id(sid) + .with_token(authorization.token.clone()); req.extensions_mut().insert(auth_context); - let new_auth = format!("{} {}", authorization.auth_type, claims.sub); if let Ok(header_value) = HeaderValue::from_str(&new_auth) { req.headers_mut() .insert(header::AUTHORIZATION, header_value); @@ -77,7 +93,20 @@ where ); } } - } else { + } + AuthType::Bot => { + let bot_id = extract_bot_id_from_path(req.path()); + + let mut auth_context = AuthContext::new(authorization.auth_type) + .with_token(authorization.token.clone()); + + if let Some(bot_id) = bot_id { + auth_context = auth_context.with_bot_id(bot_id); + } + + req.extensions_mut().insert(auth_context); + } + _ => { let auth_context = AuthContext::new(authorization.auth_type); req.extensions_mut().insert(auth_context); } @@ -91,3 +120,16 @@ where }) } } + +fn extract_bot_id_from_path(path: &str) -> Option { + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + for (i, segment) in segments.iter().enumerate() { + if *segment == "bots" && i + 1 < segments.len() { + let potential_id = segments[i + 1]; + if is_valid_snowflake(potential_id) { + return Some(potential_id.to_string()); + } + } + } + None +} diff --git a/src/api/middleware/extractors.rs b/src/api/middleware/extractors.rs new file mode 100644 index 00000000..431cb53b --- /dev/null +++ b/src/api/middleware/extractors.rs @@ -0,0 +1,155 @@ +use actix_web::{ + Error, FromRequest, HttpMessage, HttpRequest, + dev::Payload, + error::{ErrorBadRequest, ErrorForbidden, ErrorInternalServerError, ErrorUnauthorized}, + web::Data, +}; +use apistos::{ApiComponent, ApiSecurity}; +use futures::{ + StreamExt as _, + future::{LocalBoxFuture, Ready, ready}, +}; +use schemars::JsonSchema; + +use crate::{ + domain::{auth::AuthContext, error::ApiError}, + services::Services, + utils::discord::is_valid_snowflake, +}; + +#[derive(Clone, JsonSchema, ApiSecurity)] +#[openapi_security(scheme(security_type(api_key( + name = "Authorization", + api_key_in = "header" +))))] +pub struct Authenticated(pub AuthContext); + +impl FromRequest for Authenticated { + type Error = Error; + type Future = Ready>; + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + match req.extensions().get::() { + Some(context) => { + let ctx = context.clone(); + + if ctx.is_admin() { + let services = match req.app_data::>() { + Some(services) => services, + None => { + return ready(Err(ErrorInternalServerError(ApiError::InternalError( + "Services not available".to_string(), + )))); + } + }; + + if let Some(user_id) = ctx.user_id.as_deref() { + if !services.auth.is_admin(user_id) { + return ready(Err(ErrorForbidden(ApiError::Forbidden))); + } + } else { + return ready(Err(ErrorUnauthorized(ApiError::Unauthorized))); + } + } + + ready(Ok(Authenticated(ctx))) + } + None => ready(Err(ErrorUnauthorized(ApiError::Unauthorized))), + } + } +} + +#[derive(Clone, JsonSchema, ApiSecurity)] +#[openapi_security(scheme(security_type(api_key( + name = "Authorization", + api_key_in = "header" +))))] +pub struct OptionalAuth(pub Option); + +impl FromRequest for OptionalAuth { + type Error = Error; + type Future = Ready>; + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let context = req.extensions().get::().cloned(); + ready(Ok(OptionalAuth(context))) + } +} + +#[derive(Clone, JsonSchema, ApiSecurity)] +#[openapi_security(scheme(security_type(api_key( + name = "Authorization", + api_key_in = "header" +))))] +pub struct RequireAdmin(pub AuthContext); + +impl FromRequest for RequireAdmin { + type Error = Error; + type Future = Ready>; + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let ctx = match req.extensions().get::() { + Some(ctx) => ctx.clone(), + None => return ready(Err(ErrorUnauthorized(ApiError::Unauthorized))), + }; + + let services = match req.app_data::>() { + Some(services) => services, + None => { + return ready(Err(ErrorInternalServerError(ApiError::InternalError( + "Services not available".to_string(), + )))); + } + }; + + let is_admin = match ctx.user_id.as_deref() { + Some(user_id) => ctx.is_admin() && services.auth.is_admin(user_id), + None => false, + }; + + if is_admin { + ready(Ok(RequireAdmin(ctx))) + } else { + ready(Err(ErrorForbidden(ApiError::Forbidden))) + } + } +} + +#[derive(Clone, JsonSchema, ApiComponent)] +pub struct Snowflake(pub String); + +impl FromRequest for Snowflake { + type Error = Error; + type Future = Ready>; + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let id = req.match_info().get("id"); + if let Some(id) = id + && is_valid_snowflake(id) + { + ready(Ok(Snowflake(id.to_string()))) + } else { + ready(Err(ErrorBadRequest(ApiError::InvalidId))) + } + } +} + +#[derive(Clone, JsonSchema, ApiComponent)] +pub struct RawBody(pub Vec); + +impl FromRequest for RawBody { + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + fn from_request(_: &HttpRequest, payload: &mut Payload) -> Self::Future { + let mut payload = payload.take(); + Box::pin(async move { + let mut bytes = Vec::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk?; + bytes.extend_from_slice(&chunk); + } + Ok(RawBody(bytes)) + }) + } +} diff --git a/src/api/middleware/mod.rs b/src/api/middleware/mod.rs index 1ea11947..649fe50e 100644 --- a/src/api/middleware/mod.rs +++ b/src/api/middleware/mod.rs @@ -1,3 +1,5 @@ mod auth; +mod extractors; pub use auth::AuthMiddleware; +pub use extractors::{Authenticated, OptionalAuth, RawBody, RequireAdmin, Snowflake}; diff --git a/src/api/mod.rs b/src/api/mod.rs index 8a2cf7a5..1fc64d64 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1 +1,2 @@ pub mod middleware; +pub mod routes; diff --git a/src/api/routes/achievements/mod.rs b/src/api/routes/achievements/mod.rs new file mode 100644 index 00000000..657d319e --- /dev/null +++ b/src/api/routes/achievements/mod.rs @@ -0,0 +1,41 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, get}, +}; +use tracing::info; + +use crate::{ + domain::error::ApiResult, openapi::schemas::AchievementResponse, repository::Repositories, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get all achievements", + description = "Retrieve a list of all achievements in the system", + tag = "Achievements" +)] +async fn get_achievements(repos: Data) -> ApiResult>> { + info!( + code = %LogCode::Request, + "Fetching all achievements", + ); + + let achievements = repos.achievements.find_all_shared().await?; + + let achievement_reponses = achievements + .into_iter() + .map(AchievementResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + "All achievements fetched successfully", + ); + + Ok(Json(achievement_reponses)) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/achievements", get().to(get_achievements)); +} diff --git a/src/api/routes/articles/article/mod.rs b/src/api/routes/articles/article/mod.rs new file mode 100644 index 00000000..14eaf5bd --- /dev/null +++ b/src/api/routes/articles/article/mod.rs @@ -0,0 +1,232 @@ +use actix_web::web::{Data, Json, Path}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, get, patch, post, resource, scope}, +}; +use tracing::info; + +use crate::{ + api::middleware::{OptionalAuth, RequireAdmin}, + domain::error::{ApiError, ApiResult}, + openapi::schemas::{ArticleAuthor, ArticleDeleteResponse, ArticleRequest, ArticleResponse}, + repository::{BlogArticleUpdate, Repositories}, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get an article", + description = "Retrieve an article by its ID", + tag = "Articles" +)] +async fn get_article( + auth: OptionalAuth, + repos: Data, + article_id: Path, +) -> ApiResult> { + let article_id = article_id.into_inner(); + + info!( + code = %LogCode::Request, + article_id = %article_id, + "Fetching article", + ); + + let article = repos + .blog_articles + .find_by_id(&article_id) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Article with ID {} not found", article_id)))?; + + let is_admin = auth.0.as_ref().is_some_and(|ctx| ctx.is_admin()); + + if article.is_draft && !is_admin { + return Err(ApiError::NotFound(format!( + "Article with ID {} not found", + article_id + ))); + } + + let author = repos + .users + .find_by_id(&article.author_id) + .await? + .map(|user| ArticleAuthor { + avatar: user.avatar, + username: user.username, + }); + + Ok(Json(ArticleResponse::from_article(article, author)?)) +} + +#[api_operation( + summary = "Publish an article", + description = "Publish a new article with the provided information", + tag = "Articles", + skip +)] +async fn publish_article( + _admin: RequireAdmin, + repos: Data, + article_id: Path, +) -> ApiResult> { + let article_id = article_id.into_inner(); + + info!( + code = %LogCode::Request, + article_id = %article_id, + "Publishing article", + ); + + let article = repos + .blog_articles + .find_by_id(&article_id) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Article with ID {} not found", article_id)))?; + + if !article.is_draft { + return Err(ApiError::AlreadyPublished); + } + + let update = BlogArticleUpdate::new().with_is_draft(false); + + let update_result = repos.blog_articles.update(&article_id, update).await?; + + let updated_article = update_result.ok_or_else(|| { + ApiError::DatabaseError(format!( + "Article with ID {} not found after update", + article_id + )) + })?; + + let author = repos + .users + .find_by_id(&updated_article.author_id) + .await? + .map(|user| ArticleAuthor { + avatar: user.avatar, + username: user.username, + }); + + info!( + code = %LogCode::Request, + article_id = %article_id, + "Article published successfully", + ); + + Ok(Json(ArticleResponse::from_article( + updated_article, + author, + )?)) +} + +#[api_operation( + summary = "Update an article", + description = "Update an existing article with the provided information", + tag = "Articles", + skip +)] +async fn update_article( + _admin: RequireAdmin, + repos: Data, + body: Json, + article_id: Path, +) -> ApiResult> { + let article_id = article_id.into_inner(); + + info!( + code = %LogCode::Request, + article_id = %article_id, + "Updating article", + ); + + let article_request = body.into_inner(); + + let mut update = BlogArticleUpdate::new() + .with_content(&article_request.content) + .with_description(&article_request.description) + .with_tags(article_request.tags) + .with_title(&article_request.title) + .with_updated_at_to_now(); + + if let Some(cover) = article_request.cover { + update = update.with_cover(&cover); + } + + let update_result = repos.blog_articles.update(&article_id, update).await?; + + let updated_article = update_result.ok_or_else(|| { + ApiError::DatabaseError(format!( + "Article with ID {} not found after update", + article_id + )) + })?; + + let author = repos + .users + .find_by_id(&updated_article.author_id) + .await? + .map(|user| ArticleAuthor { + avatar: user.avatar, + username: user.username, + }); + + info!( + code = %LogCode::Request, + article_id = %article_id, + "Article updated successfully", + ); + + Ok(Json(ArticleResponse::from_article( + updated_article, + author, + )?)) +} + +#[api_operation( + summary = "Delete an article", + description = "Delete an existing article by its ID", + tag = "Articles", + skip +)] +async fn delete_article( + _admin: RequireAdmin, + repos: Data, + article_id: Path, +) -> ApiResult> { + let article_id = article_id.into_inner(); + + info!( + code = %LogCode::Request, + article_id = %article_id, + "Deleting article", + ); + + let delete_result = repos.blog_articles.delete(&article_id).await?; + + if delete_result.deleted_count == 0 { + return Err(ApiError::NotFound(format!( + "Article with ID {} not found", + article_id + ))); + } + + info!( + code = %LogCode::Request, + article_id = %article_id, + "Article deleted successfully", + ); + + Ok(Json(ArticleDeleteResponse { success: true })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/{article_id}").service( + resource("") + .route(get().to(get_article)) + .route(post().to(publish_article)) + .route(patch().to(update_article)) + .route(delete().to(delete_article)), + ), + ); +} diff --git a/src/api/routes/articles/mod.rs b/src/api/routes/articles/mod.rs new file mode 100644 index 00000000..729bdcc7 --- /dev/null +++ b/src/api/routes/articles/mod.rs @@ -0,0 +1,122 @@ +mod article; + +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, get, post, resource, scope}, +}; +use tracing::info; + +use crate::{ + api::middleware::{OptionalAuth, RequireAdmin}, + domain::{ + error::{ApiError, ApiResult}, + models::BlogArticle, + }, + openapi::schemas::{ArticleAuthor, ArticleRequest, ArticleResponse}, + repository::Repositories, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get all articles", + description = "Retrieve a list of all articles in the system", + tag = "Articles" +)] +async fn get_articles( + auth: OptionalAuth, + repos: Data, +) -> ApiResult>> { + info!( + code = %LogCode::Request, + "Fetching all articles", + ); + + let articles = if let Some(ctx) = &auth.0 + && ctx.is_admin() + { + repos.blog_articles.find_all().await? + } else { + repos.blog_articles.find_all_published().await? + }; + + let article_reponses = articles + .into_iter() + .map(|a| { + ArticleResponse::from_article(a, None).map(|mut r| { + r.content = None; + r + }) + }) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + "All articles fetched successfully", + ); + + Ok(Json(article_reponses)) +} + +#[api_operation( + summary = "Create a new article", + description = "Create a new article with the provided information", + tag = "Articles", + skip +)] +async fn create_article( + auth: RequireAdmin, + repos: Data, + body: Json, +) -> ApiResult> { + info!( + code = %LogCode::Request, + "Creating a new article", + ); + + let user_id = auth.0.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + + let author = repos + .users + .find_by_id(user_id) + .await? + .map(|user| ArticleAuthor { + avatar: user.avatar, + username: user.username, + }); + + let article_request = &body.into_inner(); + + let mut new_article = BlogArticle::new( + user_id, + &article_request.content, + &article_request.description, + article_request.tags.clone(), + &article_request.title, + )?; + + if let Some(cover) = &article_request.cover { + new_article = new_article.with_cover(cover); + } + + repos.blog_articles.insert(&new_article).await?; + + info!( + code = %LogCode::Request, + "Article created successfully", + ); + + Ok(Json(ArticleResponse::from_article(new_article, author)?)) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/articles") + .service( + resource("") + .route(get().to(get_articles)) + .route(post().to(create_article)), + ) + .configure(article::configure), + ); +} diff --git a/src/api/routes/auth/config/mod.rs b/src/api/routes/auth/config/mod.rs new file mode 100644 index 00000000..52805996 --- /dev/null +++ b/src/api/routes/auth/config/mod.rs @@ -0,0 +1,29 @@ +use actix_web::web::Json; +use apistos::{ + api_operation, + web::{ServiceConfig, get}, +}; + +use crate::{app_env, domain::error::ApiResult, openapi::schemas::AuthConfigResponse}; + +#[api_operation( + summary = "Get authentication configuration", + description = "Returns necessary configuration for client-side authentication, such as the OAuth client ID.", + tag = "Auth" +)] +async fn get_auth_config() -> ApiResult> { + let auth_scopes = [ + "identify", + #[cfg(feature = "mails")] + "email", + ]; + + Ok(Json(AuthConfigResponse { + client_id: app_env!().client_id.clone(), + scopes: auth_scopes.iter().map(|s| s.to_string()).collect(), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/config", get().to(get_auth_config)); +} diff --git a/src/api/routes/auth/linkedroles/mod.rs b/src/api/routes/auth/linkedroles/mod.rs new file mode 100644 index 00000000..14996854 --- /dev/null +++ b/src/api/routes/auth/linkedroles/mod.rs @@ -0,0 +1,167 @@ +use actix_web::{ + HttpRequest, Responder, + cookie::{Cookie, time::Duration}, + web::{Data, Query, Redirect}, +}; +use apistos::{ + api_operation, + web::{ServiceConfig, get}, +}; +use mongodb::bson::Uuid; +use tracing::{error, info}; + +use crate::{ + app_env, openapi::schemas::LinkedRolesQuery, repository::Repositories, services::Services, + utils::logger::LogCode, +}; + +fn connection_url(state: &str) -> String { + format!( + "https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}/api/auth/linkedroles&response_type=code&state={}&scope=role_connections.write%20identify&prompt=consent", + app_env!().client_id, + app_env!().api_url, + state + ) +} + +#[api_operation( + summary = "Get linked roles for the authenticated user", + description = "Retrieves the linked roles associated with the authenticated user based on their OAuth credentials.", + tag = "Auth", + skip +)] +async fn oauth_callback( + req: HttpRequest, + services: Data, + repos: Data, + query: Query, +) -> impl Responder { + info!( + code = %LogCode::Auth, + message = "Getting linked roles for the authenticated user." + ); + + if query.code.is_none() || req.cookie("clientState").is_none() { + let state = Uuid::new().to_string(); + let cookie = Cookie::build("clientState", state.clone()) + .max_age(Duration::minutes(5)) + .http_only(true) + .finish(); + + return Redirect::to(connection_url(&state)) + .temporary() + .customize() + .append_header(("Set-Cookie", cookie.to_string())); + } + + let code = query.code.as_deref().unwrap(); + let discord_state = query.state.as_deref().unwrap_or_default(); + let client_state = req.cookie("clientState").unwrap(); + + if discord_state != client_state.value() { + error!( + code = %LogCode::Auth, + message = "State mismatch in linked roles OAuth callback.", + discord_state = %discord_state, + client_state = %client_state.value() + ); + return Redirect::to(format!( + "{}/auth?error=invalide_state", + app_env!().client_url + )) + .temporary() + .customize(); + } + + let token_response = match services.discord.exchange_linked_roles_code(code).await { + Ok(token) => token, + Err(e) => { + error!( + code = %LogCode::Auth, + error = %e, + message = "Failed to exchange linked roles OAuth code." + ); + return Redirect::to(format!( + "{}/auth?error=token_exchange_failed", + app_env!().client_url + )) + .temporary() + .customize(); + } + }; + + let discord_user = match services + .discord + .get_user(&token_response.token_type, &token_response.access_token) + .await + { + Ok(user) => user, + Err(e) => { + error!( + code = %LogCode::Auth, + error = %e, + message = "Failed to fetch Discord user after token exchange." + ); + return Redirect::to(format!( + "{}/auth?error=fetch_user_failed", + app_env!().client_url + )) + .temporary() + .customize(); + } + }; + + let bot_count = match repos.bots.count_by_user_id(&discord_user.id).await { + Ok(count) => count as i32, + Err(e) => { + error!( + code = %LogCode::Auth, + error = %e, + message = "Failed to count bots for user after fetching Discord user." + ); + return Redirect::to(format!( + "{}/auth?error=bot_count_failed", + app_env!().client_url + )) + .temporary() + .customize(); + } + }; + + if let Err(e) = services + .discord + .update_role_connection( + &token_response.token_type, + &token_response.access_token, + bot_count, + ) + .await + { + error!( + code = %LogCode::Auth, + error = %e, + message = "Failed to update Discord role connection with bot count." + ); + return Redirect::to(format!( + "{}/auth?error=update_role_connection_failed", + app_env!().client_url + )) + .temporary() + .customize(); + } + + info!( + code = %LogCode::Auth, + message = "Successfully updated Discord role connection with bot count.", + user_id = %discord_user.id, + bot_count = bot_count + ); + + Redirect::to(format!("{}/auth/linked", app_env!().client_url)) + .temporary() + .customize() +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/linkedroles", get().to(oauth_callback)); +} diff --git a/src/api/routes/auth/mod.rs b/src/api/routes/auth/mod.rs new file mode 100644 index 00000000..9ca8f247 --- /dev/null +++ b/src/api/routes/auth/mod.rs @@ -0,0 +1,336 @@ +mod config; +mod linkedroles; +mod refresh; +mod sessions; + +use actix_web::{ + HttpRequest, + web::{Data, Query}, +}; +use apistos::{ + api_operation, + web::{Redirect, ServiceConfig, get, resource, scope}, +}; +use mongodb::bson::{DateTime, Uuid}; +use tracing::{error, info, warn}; + +use crate::{ + app_env, + domain::{ + auth::{generate_access_token, generate_refresh_token, hash_refresh_token}, + models::{GlobalStats, Session, User}, + }, + openapi::schemas::AuthCallbackQuery, + repository::{GlobalStatsUpdate, Repositories, UserUpdate}, + services::Services, + utils::{ + constants::{ACCESS_TOKEN_LIFETIME, MAX_BOTS_PER_USER}, + discord::get_user_creation_date, + logger::LogCode, + }, +}; + +#[api_operation( + summary = "OAuth callback endpoint", + description = "Handles Discord OAuth callback and processes the authorization code.", + tag = "Auth", + skip +)] +async fn oauth_callback( + req: HttpRequest, + services: Data, + repos: Data, + query: Query, +) -> Redirect { + info!( + code = %LogCode::Auth, + "OAuth callback received" + ); + + let token_response = match services + .discord + .exchange_code(&query.code, &query.redirection, &query.scopes) + .await + { + Ok(token) => token, + Err(e) => { + error!( + code = %LogCode::Auth, + error = %e, + "Failed to exchange OAuth code" + ); + return Redirect::to(format!( + "{}/auth?error=token_exchange_failed", + app_env!().client_url + )) + .temporary(); + } + }; + + let discord_user = match services + .discord + .get_user(&token_response.token_type, &token_response.access_token) + .await + { + Ok(user) => user, + Err(e) => { + error!( + code = %LogCode::Auth, + error = %e, + "Failed to fetch Discord user info" + ); + return Redirect::to(format!( + "{}/auth?error=fetch_user_failed", + app_env!().client_url + )) + .temporary(); + } + }; + + if discord_user.discriminator != "0" { + warn!( + code = %LogCode::Auth, + user_id = %discord_user.id, + "User with old username system attempted login" + ); + return Redirect::to(format!( + "{}/auth?error=unsupported_user", + app_env!().client_url + )); + } + + let existing_user = repos + .users + .find_by_id(&discord_user.id) + .await + .ok() + .flatten(); + + let user_id = discord_user.id.clone(); + + match existing_user { + Some(db_user) => { + if db_user.suspended { + warn!( + code = %LogCode::Auth, + user_id = %user_id, + "Banned user attempted login" + ); + return Redirect::to(format!( + "{}/auth?error=suspended_user", + app_env!().client_url + )) + .permanent(); + } + + let mut user_update = UserUpdate::new(); + if let Some(avatar) = discord_user.avatar.as_deref() { + user_update = user_update.with_avatar(avatar.to_string()); + } + if let Some(decoration) = discord_user + .avatar_decoration_data + .as_ref() + .and_then(|data| data.asset.as_deref()) + { + user_update = user_update.with_avatar_decoration(decoration.to_string()); + } + if let Some(mail) = discord_user.email.as_deref() { + user_update = user_update.with_mail(mail.to_string()); + } + user_update = user_update.with_username(discord_user.username.clone()); + + if let Err(e) = repos.users.update(&user_id, user_update).await { + error!( + code = %LogCode::Auth, + user_id = %user_id, + error = %e, + "Failed to update existing user" + ); + } + + info!( + code = %LogCode::Auth, + user_id = %user_id, + "Existing user logged in successfully" + ); + } + None => { + if !app_env!().enable_registrations { + warn!( + code = %LogCode::Auth, + user_id = %user_id, + "Registration attempt while registrations are disabled" + ); + return Redirect::to(format!( + "{}/auth?error=registrations_disabled", + app_env!().client_url + )) + .temporary(); + } + + let user_created_at = get_user_creation_date(&user_id).unwrap_or_else(DateTime::now); + + let new_user = User { + avatar: discord_user.avatar.clone(), + avatar_decoration: discord_user + .avatar_decoration_data + .and_then(|data| data.asset), + suspended: false, + bots_limit: MAX_BOTS_PER_USER, + created_at: user_created_at, + joined_at: DateTime::now(), + mail: discord_user.email.unwrap_or_default(), + username: discord_user.username.clone(), + user_id: user_id.clone(), + }; + + if let Err(e) = repos.users.insert(&new_user).await { + error!( + code = %LogCode::Auth, + user_id = %user_id, + error = %e, + "Failed to create new user" + ); + return Redirect::to(format!( + "{}/auth?error=registration_failed", + app_env!().client_url + )) + .temporary(); + } + + let current_date = DateTime::now(); + let start_of_hour = DateTime::from_millis( + current_date.timestamp_millis() - (current_date.timestamp_millis() % 3600000), + ); + + match repos.global_stats.find_one(&start_of_hour).await { + Ok(Some(stats)) => { + let updated_stats = + GlobalStatsUpdate::new().with_user_count(stats.user_count + 1); + if let Err(e) = repos + .global_stats + .update(&start_of_hour, updated_stats) + .await + { + error!( + code = %LogCode::Auth, + error = %e, + "Failed to update global stats for new user registration" + ); + } + } + _ => { + let total_bots = repos.bots.count_bots().await.unwrap_or(0) as i32; + let total_users = repos.users.count_users().await.unwrap_or(0) as i32; + let new_stats = GlobalStats { + bot_count: total_bots, + date: start_of_hour, + registered_bots: 0, + user_count: total_users, + }; + if let Err(e) = repos.global_stats.insert(&new_stats).await { + error!( + code = %LogCode::Auth, + error = %e, + "Failed to create global stats for new user registration" + ); + } + } + } + + info!( + code = %LogCode::Auth, + user_id = %user_id, + "New user registered successfully" + ); + } + } + + let session_id = Uuid::new().to_string(); + let refresh_token = match generate_refresh_token(&user_id, &session_id) { + Ok(token) => token, + Err(e) => { + error!( + code = %LogCode::Auth, + user_id = %user_id, + error = %e, + "Failed to generate refresh token" + ); + return Redirect::to(format!( + "{}/auth?error=token_generation_failed", + app_env!().client_url + )) + .temporary(); + } + }; + let refresh_token_hash = hash_refresh_token(&refresh_token); + + let mut session = Session::new(user_id.clone(), refresh_token_hash, session_id); + + if let Some(user_agent) = req.headers().get("User-Agent") + && let Ok(ua) = user_agent.to_str() + { + session = session.with_user_agent(ua.to_string()); + } + if let Some(ip) = req.peer_addr() { + session = session.with_ip(ip.ip().to_string()); + } + + if let Err(e) = repos.sessions.insert(&session).await { + error!( + code = %LogCode::Auth, + user_id = %user_id, + error = %e, + "Failed to create session for user" + ); + return Redirect::to(format!( + "{}/auth?error=session_creation_failed", + app_env!().client_url + )) + .temporary(); + } + + let access_token = match generate_access_token(&user_id, &session.session_id) { + Ok(token) => token, + Err(e) => { + error!( + code = %LogCode::Auth, + user_id = %user_id, + error = %e, + "Failed to generate access token" + ); + return Redirect::to(format!( + "{}/auth?error=token_generation_failed", + app_env!().client_url + )) + .temporary(); + } + }; + + info!( + code = %LogCode::Auth, + user_id = %user_id, + "User authenticated successfully, redirecting with tokens" + ); + + Redirect::to(format!( + "{}/auth?code=ok&accessToken={}&refreshToken={}&expiresIn={}&id={}", + app_env!().client_url, + access_token, + refresh_token, + ACCESS_TOKEN_LIFETIME, + user_id, + )) + .temporary() +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/auth") + .service(resource("").route(get().to(oauth_callback))) + .configure(config::configure) + .configure(linkedroles::configure) + .configure(refresh::configure) + .configure(sessions::configure), + ); +} diff --git a/src/api/routes/auth/refresh/mod.rs b/src/api/routes/auth/refresh/mod.rs new file mode 100644 index 00000000..bd474b55 --- /dev/null +++ b/src/api/routes/auth/refresh/mod.rs @@ -0,0 +1,77 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, post}, +}; +use tracing::info; + +use crate::{ + domain::{ + auth::{decode_refresh_token, generate_access_token, hash_refresh_token}, + error::{ApiError, ApiResult}, + }, + openapi::schemas::{RefreshTokenRequest, TokenResponse}, + repository::Repositories, + utils::{constants::ACCESS_TOKEN_LIFETIME, logger::LogCode}, +}; + +#[api_operation( + summary = "Refresh access token", + description = "Refreshes the access token using a valid refresh token.", + tag = "Auth", + skip +)] +async fn refresh_token( + repos: Data, + body: Json, +) -> ApiResult> { + info!( + code = %LogCode::Auth, + "Refreshing access token", + ); + + let refresh_claims = + decode_refresh_token(&body.refresh_token).map_err(|_| ApiError::InvalidToken)?; + + let token_hash = hash_refresh_token(&body.refresh_token); + + let session = repos + .sessions + .find_by_id(&refresh_claims.sid) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Auth, + session_id = %refresh_claims.sid, + user_id = %refresh_claims.sub, + "Session not found", + ); + ApiError::InvalidToken + })?; + + if !session.active || session.is_expired() || session.refresh_token_hash != token_hash { + return Err(ApiError::InvalidToken); + } + + repos.sessions.update_last_used(&session.session_id).await?; + + let access_token = generate_access_token(&session.user_id, &session.session_id) + .map_err(|_| ApiError::TokenGenerationFailed)?; + + info!( + code = %LogCode::Auth, + session_id = %session.session_id, + user_id = %session.user_id, + "Access token refreshed successfully", + ); + + Ok(Json(TokenResponse { + access_token, + expires_in: ACCESS_TOKEN_LIFETIME, + refresh_token: body.refresh_token.clone(), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/refresh", post().to(refresh_token)); +} diff --git a/src/api/routes/auth/sessions/mod.rs b/src/api/routes/auth/sessions/mod.rs new file mode 100644 index 00000000..c74f9d5d --- /dev/null +++ b/src/api/routes/auth/sessions/mod.rs @@ -0,0 +1,100 @@ +mod session; + +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, get, resource, scope}, +}; +use serde_json::{Value, json}; +use tracing::info; + +use crate::{ + api::middleware::Authenticated, + domain::error::{ApiError, ApiResult}, + openapi::schemas::SessionResponse, + repository::Repositories, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "List active sessions", + description = "Fetch a list of all active sessions for the authenticated user.", + tag = "Auth", + skip +)] +async fn list_sessions( + auth: Authenticated, + repos: Data, +) -> ApiResult>> { + let user_id = auth.0.user_id.ok_or(ApiError::Unauthorized)?; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Listing sessions", + ); + + let sessions = repos.sessions.find_by_user_id(&user_id).await?; + + let session_responses = sessions + .into_iter() + .map(SessionResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Listed sessions", + ); + + Ok(Json(session_responses)) +} + +#[api_operation( + summary = "Revoke all sessions", + description = "Revoke all active sessions for the authenticated user, except the current session.", + tag = "Auth", + skip +)] +async fn revoke_all_sessions( + auth: Authenticated, + repos: Data, +) -> ApiResult> { + let user_id = auth.0.user_id.ok_or(ApiError::Unauthorized)?; + let current_session_id = auth.0.session_id.ok_or(ApiError::Unauthorized)?; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Revoking all sessions", + ); + + let sessions = repos.sessions.find_by_user_id(&user_id).await?; + for session in sessions { + if session.session_id != current_session_id { + repos.sessions.revoke(&session.session_id).await?; + } + } + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Revoked all sessions", + ); + + Ok(Json(json!({ + "message": "All other sessions revoked successfully" + }))) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/sessions") + .service( + resource("") + .route(get().to(list_sessions)) + .route(delete().to(revoke_all_sessions)), + ) + .configure(session::configure), + ); +} diff --git a/src/api/routes/auth/sessions/session/mod.rs b/src/api/routes/auth/sessions/session/mod.rs new file mode 100644 index 00000000..96259503 --- /dev/null +++ b/src/api/routes/auth/sessions/session/mod.rs @@ -0,0 +1,62 @@ +use actix_web::web::{Data, Json, Path}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete}, +}; +use serde_json::{Value, json}; +use tracing::info; + +use crate::{ + api::middleware::Authenticated, + domain::error::{ApiError, ApiResult}, + repository::Repositories, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Revoke a session", + description = "Revokes a specific session, effectively logging out the user from that session.", + tag = "Auth", + skip +)] +async fn revoke_session( + auth: Authenticated, + repos: Data, + session_id: Path, +) -> ApiResult> { + let user_id = auth.0.user_id.ok_or(ApiError::Unauthorized)?; + + info!( + code = %LogCode::Auth, + session_id = %session_id, + user_id = %user_id, + "Revoking session" + ); + + let session = repos + .sessions + .find_by_id(&session_id) + .await? + .ok_or(ApiError::NotFound("Session not found".to_string()))?; + + if session.user_id != user_id { + return Err(ApiError::Forbidden); + } + + repos.sessions.revoke(&session_id).await?; + + info!( + code = %LogCode::Auth, + session_id = %session_id, + user_id = %user_id, + "Session revoked successfully" + ); + + Ok(Json(json!({ + "message": "Session revoked successfully" + }))) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/{session_id}", delete().to(revoke_session)); +} diff --git a/src/api/routes/bots/bot/achievements/mod.rs b/src/api/routes/bots/bot/achievements/mod.rs new file mode 100644 index 00000000..902e63cb --- /dev/null +++ b/src/api/routes/bots/bot/achievements/mod.rs @@ -0,0 +1,491 @@ +mod reset; + +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, get, patch, post, resource, scope}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::{ + error::{ApiError, ApiResult}, + models::Achievement, + }, + openapi::schemas::{ + AchievementCreationPayload, AchievementResponse, AchievementUpdatePayload, + DeleteAchievementQuery, MessageResponse, + }, + repository::{AchievementUpdate, Repositories}, + services::Services, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get bot achievements", + description = "Retrieve a list of achievements for a specific bot", + tag = "Achievements" +)] +async fn get_bot_achievements( + auth: Authenticated, + services: Data, + repos: Data, + id: Snowflake, +) -> ApiResult>> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Fetching achievements for bot", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for bot achievements", + ); + } else if ctx.is_bot() && ctx.token.as_deref() != Some(&bot.token) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to access achievements of another bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_has_bot_access(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to bot achievements", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for bot achievements", + ); + return Err(ApiError::Forbidden); + } + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Attempt to access achievements of suspended bot", + ); + return Err(ApiError::BotSuspended); + } + + let achievements = repos.achievements.find_by_bot_id(&bot_id).await?; + + let achievement_reponses = achievements + .into_iter() + .map(AchievementResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "All bot achievements fetched successfully", + ); + + Ok(Json(achievement_reponses)) +} + +#[api_operation( + summary = "Create an achievement", + description = "Create a new achievement for a bot", + tag = "Achievements" +)] +async fn create_achievement( + auth: Authenticated, + repos: Data, + id: Snowflake, + payload: Json, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Attempting to create achievement", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found for achievement creation", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for creating achievement", + ); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to create bot achievement", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for creating bot achievement", + ); + return Err(ApiError::Forbidden); + } + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Attempt to create achievement for suspended bot", + ); + return Err(ApiError::BotSuspended); + } + + let payload = payload.into_inner(); + let mut achievement = Achievement::new( + &bot_id, + &payload.description, + &payload.title, + payload.editable, + payload.objective, + ); + if let Some(description_i18n) = payload.description_i18n { + achievement = achievement.with_description_i18n(&description_i18n); + } + if let Some(from) = payload.from { + repos.achievements.find_by_id(&from).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + from_id = %from, + "Referenced 'from' achievement not found for creation", + ); + ApiError::NotFound(format!( + "Referenced 'from' achievement with ID {} not found", + from + )) + })?; + + achievement = achievement.with_from(&from); + + let used_by_count = repos + .achievements + .count_used_by(&from) + .await? + .saturating_add(1); + let update = AchievementUpdate::new().with_used_by(used_by_count as i64); + repos.achievements.update(&from, update).await?; + } + if let Some(title_i18n) = payload.title_i18n { + achievement = achievement.with_title_i18n(&title_i18n); + } + let insert_result = repos.achievements.insert(&achievement).await?; + + let result = repos + .achievements + .find_by_id(&insert_result.inserted_id.to_string()) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %insert_result.inserted_id, + "Achievement not found after creation", + ); + ApiError::DatabaseError(format!( + "Achievement with ID {} not found after creation", + insert_result.inserted_id + )) + })?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %insert_result.inserted_id, + "Achievement created successfully", + ); + + Ok(Json(AchievementResponse::try_from(result)?)) +} + +#[api_operation( + summary = "Update an achievement", + description = "Update an existing achievement for a bot", + tag = "Achievements" +)] +async fn update_achievement( + auth: Authenticated, + repos: Data, + id: Snowflake, + payload: Json, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %payload.id, + "Attempting to update achievement", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %payload.id, + "Bot not found for achievement update", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + achievement_id = %payload.id, + "Admin access granted for updating achievement", + ); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + achievement_id = %payload.id, + user_id = %user_id, + "User does not have access to update bot achievement", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + achievement_id = %payload.id, + "Access denied for updating bot achievement", + ); + return Err(ApiError::Forbidden); + } + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + achievement_id = %payload.id, + "Attempt to update achievement of suspended bot", + ); + return Err(ApiError::BotSuspended); + } + + repos + .achievements + .find_by_id(&payload.id) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %payload.id, + "Achievement not found for update", + ); + ApiError::NotFound(format!("Achievement with ID {} not found", payload.id)) + })?; + + let payload = payload.into_inner(); + let mut updates = AchievementUpdate::new() + .with_description(payload.description) + .with_title(payload.title); + if let Some(lang) = payload.lang { + updates = updates.with_lang(lang); + } + if let Some(shared) = payload.shared { + updates = updates.with_shared(shared); + } + let update_result = repos + .achievements + .update(&payload.id, updates) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %payload.id, + "Achievement not found for update after update attempt", + ); + ApiError::DatabaseError(format!("Achievement with ID {} not found", payload.id)) + })?; + + Ok(Json(AchievementResponse::try_from(update_result)?)) +} + +#[api_operation( + summary = "Delete an achievement", + description = "Delete an existing achievement for a bot", + tag = "Achievements" +)] +async fn delete_achievement( + auth: Authenticated, + repos: Data, + id: Snowflake, + query: Json, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %query.id, + "Attempting to delete achievement", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found for achievement deletion", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + achievement_id = %query.id, + "Admin access granted for deleting achievement", + ); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + achievement_id = %query.id, + user_id = %user_id, + "User does not have access to delete bot achievement", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + achievement_id = %query.id, + "Access denied for deleting bot achievement", + ); + return Err(ApiError::Forbidden); + } + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + achievement_id = %query.id, + "Attempt to delete achievement of suspended bot", + ); + return Err(ApiError::BotSuspended); + } + + let achievement = repos + .achievements + .find_by_id(&query.id) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %query.id, + "Achievement not found for deletion", + ); + ApiError::NotFound(format!("Achievement with ID {} not found", query.id)) + })?; + + if !achievement.editable { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + achievement_id = %query.id, + "Attempt to delete non-editable achievement", + ); + return Err(ApiError::Forbidden); + } + + if let Some(from) = achievement.from { + let used_by_count = repos + .achievements + .count_used_by(&query.id) + .await? + .saturating_sub(1); + let update = AchievementUpdate::new().with_used_by(used_by_count as i64); + repos.achievements.update(&from, update).await?; + } + + repos.achievements.delete_by_id(&query.id).await?; + repos + .achievements + .update_many(&query.id, AchievementUpdate::new().with_from(None)) + .await?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + achievement_id = %query.id, + "Achievement deleted successfully", + ); + + Ok(Json(MessageResponse { + message: format!("Achievement with ID {} deleted successfully", query.id), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/achievements") + .service( + resource("") + .route(get().to(get_bot_achievements)) + .route(post().to(create_achievement)) + .route(patch().to(update_achievement)) + .route(delete().to(delete_achievement)), + ) + .configure(reset::configure), + ); +} diff --git a/src/api/routes/bots/bot/achievements/reset/mod.rs b/src/api/routes/bots/bot/achievements/reset/mod.rs new file mode 100644 index 00000000..7e700ca2 --- /dev/null +++ b/src/api/routes/bots/bot/achievements/reset/mod.rs @@ -0,0 +1,103 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, post}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::{ + error::{ApiError, ApiResult}, + models::Achievement, + }, + openapi::schemas::MessageResponse, + repository::Repositories, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Reset Bot Achievements", + description = "Reset all achievements for a specific bot. This will remove all existing achievements and allow you to start fresh. This operation is irreversible, so use with caution.", + tag = "Achievements" +)] +async fn reset_achievements( + auth: Authenticated, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Attempting to reset achievements for bot", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found for achievements reset", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for achievements reset", + ); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to reset achievements for this bot", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for achievements reset", + ); + return Err(ApiError::Forbidden); + } + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Attempt to reset achievements for suspended bot", + ); + return Err(ApiError::BotSuspended); + } + + repos.achievements.delete_by_bot_id(&bot_id).await?; + let default_achievements = Achievement::defaults(&bot_id); + repos + .achievements + .insert_many(&default_achievements) + .await?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Successfully reset achievements for bot", + ); + + Ok(Json(MessageResponse { + message: "Achievements reset successfully".to_string(), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/reset", post().to(reset_achievements)); +} diff --git a/src/api/routes/bots/bot/events/event/mod.rs b/src/api/routes/bots/bot/events/event/mod.rs new file mode 100644 index 00000000..eaa3a3cd --- /dev/null +++ b/src/api/routes/bots/bot/events/event/mod.rs @@ -0,0 +1,349 @@ +use actix_web::web::{Data, Json, Path}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, get, patch, resource, scope}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::error::{ApiError, ApiResult}, + openapi::schemas::{CustomEventBody, CustomEventResponse, MessageResponse}, + repository::{CustomEventUpdate, Repositories}, + services::Services, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get Custom Event", + description = "Retrieve details of a specific custom event associated with the authenticated bot. This endpoint allows you to view the event key and graph name of a particular custom event. Use this information to manage and organize your bot's custom events effectively.", + tag = "Bots" +)] +async fn get_event( + auth: Authenticated, + services: Data, + repos: Data, + id: Snowflake, + event_key: Path, +) -> ApiResult> { + let bot_id = id.0; + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot", + ); + return Err(ApiError::BotSuspended); + } + + let event_key = event_key.into_inner(); + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + event_key = %event_key, + "Admin access granted for retrieving custom event", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + event_key = %event_key, + "Bot access denied for retrieving custom event", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_has_bot_access(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + bot_id = %bot_id, + event_key = %event_key, + "User access denied for retrieving custom event", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Unauthorized, + bot_id = %bot_id, + event_key = %event_key, + "Unauthenticated access attempt for retrieving custom event", + ); + return Err(ApiError::Unauthorized); + } + + let event = repos + .custom_events + .find_by_bot_id_and_event_key(&bot_id, &event_key) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + event_key = %event_key, + "Custom event not found", + ); + ApiError::NotFound(format!( + "Custom event with key {} for bot ID {} not found", + event_key, bot_id + )) + })?; + + Ok(Json(CustomEventResponse::from(event))) +} + +#[api_operation( + summary = "Update Custom Event", + description = "Update the details of a specific custom event associated with the authenticated bot. This endpoint allows you to modify the event key and graph name of a particular custom event. Use this functionality to keep your bot's custom events up-to-date and organized effectively.", + tag = "Bots" +)] +async fn update_event( + auth: Authenticated, + services: Data, + repos: Data, + body: Json, + id: Snowflake, + event_key: Path, +) -> ApiResult> { + let bot_id = id.0; + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot team", + ); + return Err(ApiError::BotSuspended); + } + + let event_key = event_key.into_inner(); + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + event_key = %event_key, + "Admin access granted for updating custom event", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + event_key = %event_key, + "Bot access denied for updating custom event", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_has_bot_access(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + bot_id = %bot_id, + event_key = %event_key, + "User access denied for updating custom event", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Unauthorized, + bot_id = %bot_id, + event_key = %event_key, + "Unauthenticated access attempt for updating custom event", + ); + return Err(ApiError::Unauthorized); + } + + repos + .custom_events + .find_by_bot_id_and_event_key(&bot_id, &event_key) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + event_key = %event_key, + "Custom event not found", + ); + ApiError::NotFound(format!( + "Custom event with key {} for bot ID {} not found", + event_key, bot_id + )) + })?; + + let body = body.into_inner(); + + let updates = CustomEventUpdate::new() + .with_event_key(&body.event_key) + .with_graph_name(&body.graph_name); + + let update_result = repos + .custom_events + .update(&bot_id, &event_key, updates) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + event_key = %event_key, + "Custom event not found for update", + ); + ApiError::DatabaseError(format!( + "Custom event with key {} for bot ID {} not found during update", + event_key, bot_id + )) + })?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + event_key = %event_key, + "Custom event updated successfully", + ); + + Ok(Json(CustomEventResponse::from(update_result))) +} + +#[api_operation( + summary = "Delete Custom Event", + description = "Delete a specific custom event associated with the authenticated bot. This endpoint allows you to remove a particular custom event from your bot's configuration. Use this functionality to manage and organize your bot's custom events effectively, ensuring that only relevant events are retained.", + tag = "Bots" +)] +async fn delete_event( + auth: Authenticated, + services: Data, + repos: Data, + id: Snowflake, + event_key: Path, +) -> ApiResult> { + let bot_id = id.0; + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot team", + ); + return Err(ApiError::BotSuspended); + } + + let event_key = event_key.into_inner(); + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + event_key = %event_key, + "Admin access granted for deleting custom event", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + event_key = %event_key, + "Bot access denied for deleting custom event", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_has_bot_access(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + bot_id = %bot_id, + event_key = %event_key, + "User access denied for deleting custom event", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Unauthorized, + bot_id = %bot_id, + event_key = %event_key, + "Unauthenticated access attempt for deleting custom event", + ); + return Err(ApiError::Unauthorized); + } + + let result = repos + .custom_events + .delete_by_event_key(&bot_id, &event_key) + .await?; + + if result.deleted_count == 0 { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + event_key = %event_key, + "Custom event not found for deletion", + ); + return Err(ApiError::NotFound(format!( + "Custom event with key {} for bot ID {} not found", + event_key, bot_id + ))); + } + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + event_key = %event_key, + "Custom event deleted successfully", + ); + + Ok(Json(MessageResponse { + message: format!( + "Custom event with key {} for bot ID {} deleted successfully", + event_key, bot_id + ), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/{event_key}").service( + resource("") + .route(get().to(get_event)) + .route(patch().to(update_event)) + .route(delete().to(delete_event)), + ), + ); +} diff --git a/src/api/routes/bots/bot/events/mod.rs b/src/api/routes/bots/bot/events/mod.rs new file mode 100644 index 00000000..84076038 --- /dev/null +++ b/src/api/routes/bots/bot/events/mod.rs @@ -0,0 +1,221 @@ +mod event; + +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, get, post, resource, scope}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::{ + error::{ApiError, ApiResult}, + models::CustomEvent, + }, + openapi::schemas::{CustomEventBody, CustomEventResponse}, + repository::Repositories, + services::Services, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get All Custom Events", + description = "Retrieve a list of all custom events associated with the authenticated bot. This endpoint allows you to view all the custom events that have been created for your bot, including their event keys and associated graph names. Use this information to manage and organize your bot's custom events effectively.", + tag = "Bots" +)] +async fn get_all_events( + auth: Authenticated, + services: Data, + repos: Data, + id: Snowflake, +) -> ApiResult>> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Retrieving all custom events for bot", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot team", + ); + return Err(ApiError::BotSuspended); + } + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for retrieving all custom events", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to retrieve custom events for a different bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_has_bot_access(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User attempting to retrieve custom events for a bot they don't have access to", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Unauthenticated request attempting to retrieve custom events", + ); + return Err(ApiError::Forbidden); + } + + let events = repos.custom_events.find_by_bot_id(&bot_id).await?; + + let event_responses = events.into_iter().map(CustomEventResponse::from).collect(); + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Successfully retrieved all custom events for bot", + ); + + Ok(Json(event_responses)) +} + +#[api_operation( + summary = "Create Custom Event", + description = "Create a new custom event for the authenticated bot. This endpoint allows you to define a new custom event by providing an event key and an associated graph name. Custom events can be used to trigger specific actions or workflows within your bot, enabling you to create more dynamic and interactive experiences for your users.", + tag = "Bots" +)] +async fn create_event( + auth: Authenticated, + services: Data, + repos: Data, + event: Json, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot team", + ); + return Err(ApiError::BotSuspended); + } + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for creating custom event", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to create custom event for a different bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_has_bot_access(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User attempting to create custom event for a bot they don't have access to", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Unauthenticated request attempting to create custom event", + ); + return Err(ApiError::Forbidden); + } + + if repos + .custom_events + .find_by_bot_id_and_event_key(&bot_id, &event.event_key) + .await? + .is_some() + { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + event_key = %event.event_key, + "Custom event with the same event key already exists", + ); + return Err(ApiError::AlreadyExists(format!( + "Custom event with event key '{}' already exists for this bot", + event.event_key + ))); + } + + let new_event = CustomEvent { + bot_id: bot_id.clone(), + default_value: event.default_value, + event_key: event.event_key.clone(), + graph_name: event.graph_name.clone(), + }; + + repos.custom_events.insert(&new_event).await?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + event_key = %event.event_key, + "Custom event created successfully", + ); + + Ok(Json(CustomEventResponse::from(new_event))) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/events") + .service( + resource("") + .route(get().to(get_all_events)) + .route(post().to(create_event)), + ) + .configure(event::configure), + ); +} diff --git a/src/api/routes/bots/bot/mod.rs b/src/api/routes/bots/bot/mod.rs new file mode 100644 index 00000000..8e8d8690 --- /dev/null +++ b/src/api/routes/bots/bot/mod.rs @@ -0,0 +1,428 @@ +mod achievements; +mod events; +mod settings; +mod stats; +mod suspend; +mod team; +mod token; + +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, get, patch, post, resource, scope}, +}; +use mongodb::bson::DateTime; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::{ + auth::generate_bot_token, + error::{ApiError, ApiResult}, + models::{Achievement, AchievementType, Bot}, + }, + openapi::schemas::{BotCreationBody, BotResponse, BotUpdateBody, MessageResponse}, + repository::{BotUpdate, Repositories}, + services::Services, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get bot details", + description = "Fetch detailed information about a specific bot registered in the Discord Analytics API", + tag = "Bots" +)] +async fn get_bot( + auth: Authenticated, + services: Data, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Fetching details for bot", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for bot details", + ); + } else if ctx.is_bot() && ctx.token.as_deref() != Some(&bot.token) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to access details of another bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_has_bot_access(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to bot details", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for bot details", + ); + return Err(ApiError::Forbidden); + } + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot details fetched successfully", + ); + + Ok(Json(BotResponse::try_from(bot)?)) +} + +#[api_operation( + summary = "Create a new bot", + description = "Register a new bot in the Discord Analytics API. This endpoint generates a unique token for the bot, which is required for authentication in future requests.", + tag = "Bots" +)] +async fn post_bot( + auth: Authenticated, + services: Data, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Attempting to create bot", + ); + + let ctx = &auth.0; + + if !ctx.is_admin() && !ctx.is_user() { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + auth_type = ?ctx.auth_type, + "Unauthorized bot creation attempt", + ); + return Err(ApiError::Forbidden); + } + + let body_data = body.into_inner(); + + if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if user_id != body_data.user_id { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User ID in request does not match authenticated user", + ); + return Err(ApiError::Forbidden); + } + } + + if repos.bots.find_by_id(&bot_id).await?.is_some() { + warn!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot with this ID already exists", + ); + return Err(ApiError::AlreadyExists(format!( + "Bot with ID {} already exists", + bot_id + ))); + } + + if services + .users + .has_reached_bots_limit(&body_data.user_id) + .await? + { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %body_data.user_id, + "User has reached bots limit and cannot create more", + ); + return Err(ApiError::Forbidden); + } + + let bot_details = services.discord.get_bot(&bot_id).await?; + if !bot_details.bot { + warn!( + code = %LogCode::Request, + bot_id = %bot_id, + "User ID provided is not a bot according to Discord API", + ); + return Err(ApiError::NotFound(format!( + "User ID {} is not a bot according to Discord API", + bot_id + ))); + } + + let token = generate_bot_token(&bot_id)?; + let bot = Bot::new( + &bot_id, + &body_data.user_id, + token, + &bot_details.username, + bot_details.avatar.as_deref(), + ); + + repos.bots.insert(&bot).await?; + + let default_achievements = Achievement::defaults(&bot_id); + repos + .achievements + .insert_many(&default_achievements) + .await?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot created successfully", + ); + + Ok(Json(BotResponse::try_from(bot)?)) +} + +#[api_operation( + summary = "Update bot details", + description = "Update specific details of a bot registered in the Discord Analytics API. Only certain fields can be updated.", + tag = "Bots" +)] +async fn patch_bot( + auth: Authenticated, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Attempting to update bot", + ); + + let ctx = &auth.0; + + if !(ctx.is_admin() || ctx.is_bot() && ctx.bot_id.as_deref() == Some(bot_id.as_str())) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + auth_type = ?ctx.auth_type, + "Unauthorized bot update attempt", + ); + return Err(ApiError::Forbidden); + } + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found for update", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot update", + ); + return Err(ApiError::BotSuspended); + } + + if ctx.is_bot() { + let auth_token = ctx.token.as_deref().ok_or(ApiError::InvalidToken)?; + if bot.token() != auth_token { + warn!( + code = %LogCode::InvalidToken, + bot_id = %bot_id, + "Bot token mismatch during update", + ); + return Err(ApiError::InvalidToken); + } + } + + let update_data = body.into_inner(); + + let mut update = BotUpdate::new(); + if let Some(avatar) = update_data.avatar { + update = update.with_avatar(avatar); + } + if let Some(framework) = update_data.framework { + update = update.with_framework(framework); + } + if let Some(team) = update_data.team { + update = update.with_team(team); + } + if let Some(username) = update_data.username { + update = update.with_username(username); + } + if let Some(version) = update_data.version { + update = update.with_version(version); + } + + let update_result = repos.bots.update(&bot_id, update).await?; + + let updated_bot = update_result.ok_or_else(|| { + warn!( + code = %LogCode::DbError, + bot_id = %bot_id, + "Bot not found after update", + ); + ApiError::DatabaseError(format!("Bot with ID {} not found after update", bot_id)) + })?; + + let achievements = repos.achievements.find_unachieved_by_bot(&bot_id).await?; + for mut achievement in achievements { + if achievement.objective.achievement_type == AchievementType::BotConfigured { + achievement.current = Some(achievement.objective.value); + achievement.achieved_on = Some(DateTime::now()); + repos + .achievements + .update_progress( + &bot_id, + &achievement + .id + .ok_or_else(|| anyhow::anyhow!("Achievement ID missing"))? + .to_string(), + achievement.current, + achievement.achieved_on, + ) + .await?; + } + } + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot update successful", + ); + + Ok(Json(BotResponse::try_from(updated_bot)?)) +} + +#[api_operation( + summary = "Delete a bot", + description = "Delete a specific bot from the Discord Analytics API. This action is irreversible.", + tag = "Bots" +)] +async fn delete_bot( + auth: Authenticated, + services: Data, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Attempting to delete bot", + ); + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for bot deletion", + ); + } else if ctx.is_bot() { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to delete a bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_owns_bot(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not own bot and cannot delete", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for bot deletion", + ); + return Err(ApiError::Forbidden); + } + + repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found for deletion", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + services.bots.delete_bot(&bot_id).await?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot successfully deleted", + ); + + Ok(Json(MessageResponse { + message: format!("Bot with ID {} has been deleted", bot_id), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/{id}") + .service( + resource("") + .route(get().to(get_bot)) + .route(post().to(post_bot)) + .route(patch().to(patch_bot)) + .route(delete().to(delete_bot)), + ) + .configure(achievements::configure) + .configure(events::configure) + .configure(settings::configure) + .configure(stats::configure) + .configure(suspend::configure) + .configure(team::configure) + .configure(token::configure), + ); +} diff --git a/src/api/routes/bots/bot/settings/mod.rs b/src/api/routes/bots/bot/settings/mod.rs new file mode 100644 index 00000000..e5af2c85 --- /dev/null +++ b/src/api/routes/bots/bot/settings/mod.rs @@ -0,0 +1,113 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, patch}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::error::{ApiError, ApiResult}, + openapi::schemas::{BotSettingsPayload, MessageResponse}, + repository::{BotUpdate, Repositories}, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Update bot settings", + description = "Update the settings of a bot. Only the owner of the bot or team members with the appropriate permissions can perform this action.", + tag = "Bots" +)] +async fn update_settings( + auth: Authenticated, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Attempting to update bot settings", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "User is admin, proceeding with settings update", + ); + } else if ctx.is_bot() && ctx.token.as_deref() != Some(&bot.token) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempted to update settings of a different bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to bot details", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for bot details", + ); + return Err(ApiError::Forbidden); + } + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot update", + ); + return Err(ApiError::BotSuspended); + } + + let body = body.into_inner(); + + let update = BotUpdate::new().with_advanced_stats(body.advanced_stats); + repos.bots.update(&bot_id, update).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found during update", + ); + ApiError::DatabaseError(format!("Bot with ID {} not found after update", bot_id)) + })?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot settings updated successfully", + ); + + Ok(Json(MessageResponse { + message: "Bot settings updated successfully".to_string(), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/settings", patch().to(update_settings)); +} diff --git a/src/api/routes/bots/bot/stats/mod.rs b/src/api/routes/bots/bot/stats/mod.rs new file mode 100644 index 00000000..92ba39e8 --- /dev/null +++ b/src/api/routes/bots/bot/stats/mod.rs @@ -0,0 +1,449 @@ +use std::collections::HashMap; + +use actix_web::web::{Data, Json, Query}; +use apistos::{ + api_operation, + web::{ServiceConfig, get, post, resource, scope}, +}; +use chrono::{Duration, Utc}; +use mongodb::bson::DateTime; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::{ + error::{ApiError, ApiResult}, + models::AchievementType, + }, + openapi::schemas::{ + BotStatsBody, BotStatsContent, BotStatsQuery, BotStatsResponse, MessageResponse, + NormalizedStatsBody, VoteResponse, + }, + repository::{BotStatsUpdate, Repositories}, + services::Services, + utils::{constants::MAX_DATE_RANGE, logger::LogCode}, +}; + +#[api_operation( + summary = "Get bot stats", + description = "Get the stats of a bot within a specified date range.", + tag = "Stats" +)] +async fn get_stats( + auth: Authenticated, + services: Data, + repos: Data, + query: Query, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + let from = DateTime::parse_rfc3339_str(format!("{}T00:00:00Z", query.from).as_str()).map_err( + |_| { + warn!( + code = %LogCode::Request, + bot_id = %bot_id, + from = %query.from, + "Invalid 'from' date format", + ); + ApiError::InvalidInput("Invalid 'from' date format. Expected YYYY-MM-DD.".to_string()) + }, + )?; + let to = + DateTime::parse_rfc3339_str(format!("{}T23:59:59Z", query.to).as_str()).map_err(|_| { + warn!( + code = %LogCode::Request, + bot_id = %bot_id, + to = %query.to, + "Invalid 'to' date format", + ); + ApiError::InvalidInput("Invalid 'to' date format. Expected YYYY-MM-DD.".to_string()) + })?; + + if from > to { + warn!( + code = %LogCode::Request, + bot_id = %bot_id, + from = %query.from, + to = %query.to, + "Invalid date range: 'from' date is after 'to' date", + ); + return Err(ApiError::InvalidInput( + "'from' date must be before 'to' date".to_string(), + )); + } + + if to.timestamp_millis() - from.timestamp_millis() > MAX_DATE_RANGE * 1000 { + warn!( + code = %LogCode::Request, + bot_id = %bot_id, + from = %query.from, + to = %query.to, + "Invalid date range: range exceeds maximum allowed", + ); + return Err(ApiError::InvalidInput(format!( + "Date range cannot exceed {} seconds", + MAX_DATE_RANGE + ))); + } + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + from = %query.from, + to = %query.to, + "Fetching bot stats for date range", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot team", + ); + return Err(ApiError::BotSuspended); + } + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for bot stats", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to access stats of another bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !services.auth.user_has_bot_access(user_id, &bot_id).await? { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to bot stats", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for bot stats", + ); + return Err(ApiError::Forbidden); + } + + let stats = repos + .bot_stats + .find_from_date_range(&bot_id, &from, &to) + .await?; + + let stat_responses = stats + .into_iter() + .map(BotStatsContent::try_from) + .collect::, _>>()?; + + let votes = repos + .votes + .find_from_date_range(&bot_id, &from, &to) + .await?; + + let vote_responses = votes + .into_iter() + .map(VoteResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + count = stat_responses.len(), + "Fetched bot stats for date range", + ); + + Ok(Json(BotStatsResponse { + stats: stat_responses, + votes: vote_responses, + })) +} + +#[api_operation( + summary = "Post bot stats", + description = "Submit bot stats for a specific date.", + tag = "Stats" +)] +async fn post_stats( + auth: Authenticated, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Posting bot stats", + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot team", + ); + return Err(ApiError::BotSuspended); + } + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for posting bot stats", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to post stats for another bot", + ); + return Err(ApiError::Forbidden); + } else if !ctx.is_admin() && !ctx.is_bot() { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for posting bot stats", + ); + return Err(ApiError::Forbidden); + } + + let current_date = DateTime::now(); + let start_of_hour = DateTime::from_millis( + current_date.timestamp_millis() - (current_date.timestamp_millis() % 3600000), + ); + + let body = match body.into_inner() { + BotStatsBody::New(new_body) => { + NormalizedStatsBody::from_new(new_body, &bot_id, &start_of_hour) + } + BotStatsBody::Old(old_body) => { + NormalizedStatsBody::from_old(old_body, &bot_id, &start_of_hour) + } + }; + + let new_stats = match repos + .bot_stats + .find_by_date(&bot_id, &start_of_hour) + .await? + { + Some(existing_stats) => { + let mut updates = BotStatsUpdate::new() + .with_guild_count(body.guild_count) + .with_user_count(body.user_count); + + if body.added_guilds != 0 { + updates = updates.with_added_guilds(body.added_guilds); + } + + if let Some(custom_events) = body.custom_events { + let bot_events = repos + .custom_events + .find_by_bot_id(&bot_id) + .await? + .into_iter() + .map(|event| (event.event_key, event.default_value)) + .collect::>(); + + let existing_events = existing_stats.custom_events.unwrap_or_default(); + + for (event_key, count) in custom_events { + if let Some(default_value) = bot_events.get(&event_key) { + let new_count = if existing_events.contains_key(&event_key) { + count + } else { + default_value.unwrap_or(count) + }; + + updates = updates.with_custom_event(&event_key, new_count); + } + } + } + + if let Some(guilds) = body.guilds { + updates = updates.with_guilds(&guilds); + } + + let guilds_locales: Vec<(&str, i32)> = body + .guild_locales + .iter() + .map(|locale_stat| (locale_stat.locale.as_str(), locale_stat.number)) + .collect(); + updates = updates.with_guild_locales(&guilds_locales); + + updates = body + .guild_members + .into_iter() + .fold(updates, |u, (bucket, count)| { + u.with_guild_member(&bucket, count) + }); + + updates = updates.with_interactions(&body.interactions); + + let interactions_locales: Vec<(&str, i32)> = body + .interactions_locales + .iter() + .map(|locale_stat| (locale_stat.locale.as_str(), locale_stat.number)) + .collect(); + updates = updates.with_interactions_locales(&interactions_locales); + + if body.removed_guilds != 0 { + updates = updates.with_removed_guilds(body.removed_guilds); + } + + if let Some(user_install_count) = body.user_install_count { + updates = updates.with_user_install_count(user_install_count); + } + + if let Some(users_types) = body.users_type { + updates = users_types + .into_iter() + .fold(updates, |u, (user_type, count)| { + u.with_user_type(&user_type, count) + }); + } + + repos + .bot_stats + .update(&bot_id, &start_of_hour, updates) + .await? + .ok_or_else(|| { + warn!( + code = %LogCode::Database, + bot_id = %bot_id, + "Failed to update existing bot stats", + ); + ApiError::DatabaseError("Failed to update bot stats".to_string()) + })? + } + None => { + let new_stats = body.into_stats(); + repos.bot_stats.insert(&new_stats).await?; + new_stats + } + }; + + let achievements = repos.achievements.find_unachieved_by_bot(&bot_id).await?; + for mut achievement in achievements { + let new_current = match achievement.objective.achievement_type { + AchievementType::FrenchPercentage => { + let (total, french_count) = new_stats.interactions_locales.iter().fold( + (0i64, 0i64), + |(total, french), locale_stat| { + let n = locale_stat.number as i64; + ( + total + n, + french + if locale_stat.locale == "fr" { n } else { 0 }, + ) + }, + ); + if total > 0 { + let percentage = ((french_count as f64 / total as f64) * 100.0).round() as i64; + Some(percentage) + } else { + None + } + } + AchievementType::GuildCount => Some(new_stats.guild_count as i64), + AchievementType::InteractionAverageWeek => { + let one_month_ago = Utc::now() - Duration::days(30); + let dt_month_ago = DateTime::from_millis(one_month_ago.timestamp_millis()); + let month_stats = repos + .bot_stats + .find_from_date_range(&bot_id, &dt_month_ago, ¤t_date) + .await?; + + let total_interactions: i64 = month_stats + .iter() + .flat_map(|stats| stats.interactions.iter()) + .map(|interaction| interaction.number as i64) + .sum(); + + if total_interactions > 0 { + const WEEKS_IN_RANGE: f64 = 30.0 / 7.0; + Some((total_interactions as f64 / WEEKS_IN_RANGE).round() as i64) + } else { + None + } + } + AchievementType::JoinedDa => { + Some(current_date.timestamp_millis() - bot.watched_since.timestamp_millis()) + } + AchievementType::UserCount => Some(new_stats.user_count as i64), + AchievementType::UsersLocales => Some(new_stats.interactions_locales.len() as i64), + _ => achievement.current, + }; + + achievement.current = new_current; + if new_current.unwrap_or(0) >= achievement.objective.value { + achievement.achieved_on = Some(DateTime::now()); + } + + repos + .achievements + .update_progress( + &bot_id, + &achievement + .id + .ok_or_else(|| anyhow::anyhow!("Achievement ID missing"))? + .to_string(), + achievement.current, + achievement.achieved_on, + ) + .await?; + } + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Posted bot stats", + ); + + Ok(Json(MessageResponse { + message: "Bot stats updated successfully".to_string(), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/stats").service( + resource("") + .route(get().to(get_stats)) + .route(post().to(post_stats)), + ), + ); +} diff --git a/src/api/routes/bots/bot/suspend/mod.rs b/src/api/routes/bots/bot/suspend/mod.rs new file mode 100644 index 00000000..4b4417b5 --- /dev/null +++ b/src/api/routes/bots/bot/suspend/mod.rs @@ -0,0 +1,131 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, post, scope}, +}; +use tracing::info; + +use crate::{ + api::middleware::{RequireAdmin, Snowflake}, + domain::error::{ApiError, ApiResult}, + openapi::schemas::{BotSuspendRequest, MessageResponse}, + repository::{BotUpdate, Repositories}, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Suspend a bot", + description = "Suspends a bot, preventing it from accessing the API. The bot's owner will still be able to access their account, but the bot itself will be disabled. Only administrators can perform this action.", + tag = "Bots", + skip +)] +async fn suspend_bot( + _admin: RequireAdmin, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + reason = %body.reason, + "Received request to suspend bot" + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot is already suspended", + ); + return Err(ApiError::BotSuspended); + } + + let reason = body.reason.trim(); + + let bot_update = BotUpdate::new().with_suspended(true); + + repos.bots.update(&bot_id, bot_update).await?; + + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + reason = %reason, + "Bot has been suspended" + ); + + Ok(Json(MessageResponse { + message: format!("Bot {} has been suspended for reason: {}", bot_id, reason), + })) +} + +#[api_operation( + summary = "Unsuspend a bot", + description = "Unsuspends a bot, allowing it to access the API again. Only administrators can perform this action.", + tag = "Bots", + skip +)] +async fn unsuspend_bot( + _admin: RequireAdmin, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Received request to unsuspend bot" + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if !bot.suspended { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot is not suspended", + ); + return Err(ApiError::BotUnsuspended); + } + + let bot_update = BotUpdate::new().with_suspended(false); + + repos.bots.update(&bot_id, bot_update).await?; + + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Bot has been unsuspended" + ); + + Ok(Json(MessageResponse { + message: format!("Bot {} has been unsuspended", bot_id), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/suspend") + .route("", post().to(suspend_bot)) + .route("", delete().to(unsuspend_bot)), + ); +} diff --git a/src/api/routes/bots/bot/team/mod.rs b/src/api/routes/bots/bot/team/mod.rs new file mode 100644 index 00000000..48e6067d --- /dev/null +++ b/src/api/routes/bots/bot/team/mod.rs @@ -0,0 +1,370 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, get, post, resource, scope}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::{ + error::{ApiError, ApiResult}, + models::TeamInvitation, + }, + openapi::schemas::{MessageResponse, TeamRequestBody, TeamResponse}, + repository::{BotUpdate, Repositories}, + services::Services, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get the team of a bot", + description = "Get the team of a bot", + tag = "Bots" +)] +async fn get_team( + auth: Authenticated, + repos: Data, + id: Snowflake, +) -> ApiResult>> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Fetching team for bot" + ); + + let ctx = &auth.0; + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for suspended bot team", + ); + return Err(ApiError::BotSuspended); + } + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted to fetch team for bot", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot access denied to fetch team for another bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to bot team", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for bot team", + ); + return Err(ApiError::Forbidden); + } + + let mut team = Vec::new(); + + for user_id in bot.team { + let mut response = TeamResponse { + avatar: None, + invitation_id: None, + pending_invitation: false, + registered: false, + user_id: user_id.clone(), + username: None, + }; + + if let Some(user) = repos.users.find_by_id(&user_id).await? { + response.avatar = user.avatar; + response.username = Some(user.username); + response.registered = true; + } + + if let Some(invitation) = repos + .team_invitations + .find_by_bot_and_user(&bot_id, &user_id) + .await? + { + response.invitation_id = Some(invitation.invitation_id); + response.pending_invitation = !invitation.accepted; + } + + team.push(response); + } + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + team_size = team.len(), + "Fetched team for bot", + ); + + Ok(Json(team)) +} + +#[api_operation( + summary = "Add a user to the team of a bot", + description = "Add a user to the team of a bot", + tag = "Bots" +)] +async fn add_to_team( + auth: Authenticated, + services: Data, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + user_id = %body.user_id, + "Adding user to bot team" + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %body.user_id, + "Access denied to add user to suspended bot team", + ); + return Err(ApiError::BotSuspended); + } + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + user_id = %body.user_id, + "Admin access granted to add user to bot team", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %body.user_id, + "Bot access denied to add user to another bot team", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to add user to bot team", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %body.user_id, + "Access denied to add user to bot team", + ); + return Err(ApiError::Forbidden); + } + + if services + .auth + .user_has_bot_access(&body.user_id, &bot_id) + .await? + { + warn!( + code = %LogCode::Conflict, + bot_id = %bot_id, + user_id = %body.user_id, + "User is already a member of the bot team", + ); + return Err(ApiError::Conflict(format!( + "User with ID {} is already a member of the bot team", + body.user_id + ))); + } + + let update = BotUpdate::new().with_team_member(&body.user_id); + repos.bots.update(&bot_id, update).await?; + + let invitation = TeamInvitation::new(&bot_id, &body.user_id); + repos.team_invitations.insert(&invitation).await?; + + let response = TeamResponse { + avatar: None, + invitation_id: Some(invitation.invitation_id), + pending_invitation: true, + registered: false, + user_id: body.user_id.clone(), + username: None, + }; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + user_id = %body.user_id, + "Added user to bot team", + ); + + Ok(Json(response)) +} + +#[api_operation( + summary = "Remove a user from the team of a bot", + description = "Remove a user from the team of a bot", + tag = "Bots" +)] +async fn delete_from_team( + auth: Authenticated, + services: Data, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + user_id = %body.user_id, + "Removing user from bot team" + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + user_id = %body.user_id, + "Admin access granted to remove user from bot team", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %body.user_id, + "Bot access denied to remove user from another bot team", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User does not have access to remove user from bot team", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %body.user_id, + "Access denied to remove user from bot team", + ); + return Err(ApiError::Forbidden); + } + + if !services + .auth + .user_has_bot_access(&body.user_id, &bot_id) + .await? + { + warn!( + code = %LogCode::Conflict, + bot_id = %bot_id, + user_id = %body.user_id, + "User is not a member of the bot team", + ); + return Err(ApiError::Conflict(format!( + "User with ID {} is not a member of the bot team", + body.user_id + ))); + } + + repos + .bots + .remove_user_from_team(&bot_id, &body.user_id) + .await?; + + repos + .team_invitations + .delete_by_bot_and_user(&bot_id, &body.user_id) + .await?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + user_id = %body.user_id, + "Removed user from bot team", + ); + + Ok(Json(MessageResponse { + message: format!( + "User with ID {} has been removed from the bot team", + body.user_id + ), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/team").service( + resource("") + .route(get().to(get_team)) + .route(post().to(add_to_team)) + .route(delete().to(delete_from_team)), + ), + ); +} diff --git a/src/api/routes/bots/bot/token/mod.rs b/src/api/routes/bots/bot/token/mod.rs new file mode 100644 index 00000000..809cc63c --- /dev/null +++ b/src/api/routes/bots/bot/token/mod.rs @@ -0,0 +1,183 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, get, patch, resource, scope}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::{ + auth::generate_bot_token, + error::{ApiError, ApiResult}, + }, + openapi::schemas::BotTokenResponse, + repository::{BotUpdate, Repositories}, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get Bot Token", + description = "Retrieve the current authentication token for a bot. This endpoint is intended for testing purposes and should not be used in production environments. The token returned by this endpoint is the same as the one generated during bot creation or last refresh. For security reasons, it is recommended to use the token provided at bot creation time and to rotate it using the refresh endpoint if needed.", + tag = "Bots" +)] +async fn get_token( + auth: Authenticated, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Retrieving bot token" + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + warn!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found for token retrieval", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for token retrieval", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to retrieve token for a different bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User tried to retrieve token for a bot they don't have access to", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for token retrieval.", + ); + return Err(ApiError::Forbidden); + } + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot token retrieved successfully", + ); + + Ok(Json(BotTokenResponse { token: bot.token })) +} + +#[api_operation( + summary = "Refresh Bot Token", + description = "Refresh the authentication token for a bot. This endpoint is used when a bot's token has been compromised or needs to be rotated for security reasons. The old token will be invalidated, and a new token will be generated and returned in the response. Ensure to update your bot's configuration with the new token to maintain uninterrupted service.", + tag = "Bots" +)] +async fn refresh_token( + auth: Authenticated, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let bot_id = id.0; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Refreshing bot token" + ); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + warn!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot not found for token refresh", + ); + ApiError::NotFound(format!("Bot with ID {} not found", bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + bot_id = %bot_id, + "Admin access granted for token refresh", + ); + } else if ctx.is_bot() && ctx.bot_id.as_deref() != Some(&bot_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Bot attempting to refresh token for a different bot", + ); + return Err(ApiError::Forbidden); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_owner(user_id) { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + user_id = %user_id, + "User tried to refresh token for a bot they don't have access to", + ); + return Err(ApiError::Forbidden); + } + } else { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for token refresh.", + ); + return Err(ApiError::Forbidden); + } + + if bot.suspended { + warn!( + code = %LogCode::Forbidden, + bot_id = %bot_id, + "Access denied for token refresh of suspended bot", + ); + return Err(ApiError::BotSuspended); + } + + let new_token = generate_bot_token(&bot_id)?; + let bot_update = BotUpdate::new().with_token(new_token.clone()); + + repos.bots.update(&bot_id, bot_update).await?; + + info!( + code = %LogCode::Request, + bot_id = %bot_id, + "Bot token refreshed successfully", + ); + + Ok(Json(BotTokenResponse { token: new_token })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/token").service( + resource("") + .route(get().to(get_token)) + .route(patch().to(refresh_token)), + ), + ); +} diff --git a/src/api/routes/bots/mod.rs b/src/api/routes/bots/mod.rs new file mode 100644 index 00000000..34137adf --- /dev/null +++ b/src/api/routes/bots/mod.rs @@ -0,0 +1,52 @@ +mod bot; + +use actix_web::web::{Data, Json}; +use anyhow::Result; +use apistos::{ + api_operation, + web::{ServiceConfig, get, resource, scope}, +}; +use tracing::info; + +use crate::{ + api::middleware::RequireAdmin, domain::error::ApiResult, openapi::schemas::BotResponse, + repository::Repositories, utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get all bots", + description = "Fetch a list of all bots registered in the Discord Analytics API", + tag = "Bots", + skip +)] +async fn get_all_bots( + _admin: RequireAdmin, + repos: Data, +) -> ApiResult>> { + info!( + code = %LogCode::Request, + "Fetching all bots", + ); + + let bots = repos.bots.find_all().await?; + + let bot_responses = bots + .into_iter() + .map(BotResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + "All bots fetched successfully", + ); + + Ok(Json(bot_responses)) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/bots") + .service(resource("").route(get().to(get_all_bots))) + .configure(bot::configure), + ); +} diff --git a/src/api/routes/health/mod.rs b/src/api/routes/health/mod.rs new file mode 100644 index 00000000..1fecc902 --- /dev/null +++ b/src/api/routes/health/mod.rs @@ -0,0 +1,31 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, get}, +}; + +use crate::{domain::error::ApiResult, openapi::schemas::HealthResponse, repository::Repositories}; + +#[api_operation( + summary = "Get API health status", + description = "Check the health status of the Discord Analytics API", + tag = "Health" +)] +async fn get_health(repos: Data) -> ApiResult> { + let repos_status = repos.ping().await.is_ok(); + Ok(Json(HealthResponse { + status: (if repos_status { "healthy" } else { "degraded" }).to_string(), + service: "Discord Analytics API".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + environment: (if cfg!(debug_assertions) { + "development" + } else { + "production" + }) + .to_string(), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/health", get().to(get_health)); +} diff --git a/src/api/routes/integrations/mod.rs b/src/api/routes/integrations/mod.rs new file mode 100644 index 00000000..188143e7 --- /dev/null +++ b/src/api/routes/integrations/mod.rs @@ -0,0 +1,53 @@ +mod providers; + +use actix_web::web::{Data, Json, Path}; +use apistos::{ + api_operation, + web::{ServiceConfig, post}, +}; +use serde_json::{Value, from_slice}; + +use self::providers::{IntegrationResponse, IntegrationResult, handle_provider}; + +use crate::{ + api::middleware::RawBody, + domain::error::{ApiError, ApiResult}, + repository::Repositories, + services::Services, +}; + +#[api_operation( + summary = "Handle incoming integration requests from providers", + description = "This endpoint receives integration requests from various providers, processes the payload, and returns the necessary information for setting up webhooks or other integration features. The provider is specified in the URL path, and the payload format may vary based on the provider. The endpoint also verifies the authenticity of the request using provider-specific methods to ensure that only legitimate integration requests are processed.", + tag = "Webhooks" +)] +async fn vote_integration( + services: Data, + repos: Data, + raw_body: RawBody, + path: Path, +) -> ApiResult> { + let provider = path.into_inner(); + + let body_value = match from_slice::(&raw_body.0) { + Ok(v) => v, + Err(e) => { + return Err(ApiError::InvalidInput(format!( + "Failed to parse JSON body: {}", + e + ))); + } + }; + + match handle_provider(&provider, body_value, services, repos).await? { + IntegrationResponse::Accepted(integration_result) => Ok(Json(integration_result)), + IntegrationResponse::Ignored => Err(ApiError::InvalidInput(format!( + "Unsupported integration provider: {}", + provider + ))), + } +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/integrations/{provider}", post().to(vote_integration)); +} diff --git a/src/api/routes/integrations/providers.rs b/src/api/routes/integrations/providers.rs new file mode 100644 index 00000000..392fe0f3 --- /dev/null +++ b/src/api/routes/integrations/providers.rs @@ -0,0 +1,176 @@ +use actix_web::web::Data; +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::{Value, from_value}; +use tracing::{info, warn}; + +use crate::{ + app_env, + domain::{ + auth::generate_bot_token, + error::{ApiError, ApiResult}, + models::{Bot, WebhookConfig}, + }, + openapi::schemas::TopGGIntegrationPayload, + repository::{BotUpdate, Repositories}, + services::Services, + utils::logger::LogCode, +}; + +#[derive(Serialize, ApiComponent, JsonSchema)] +pub struct IntegrationResult { + pub webhook_url: String, + pub routes: Vec<&'static str>, +} + +pub enum IntegrationResponse { + Accepted(IntegrationResult), + Ignored, +} + +pub async fn handle_provider( + provider: &str, + body: Value, + services: Data, + repos: Data, +) -> ApiResult { + match provider { + "topgg" => handle_topgg_integration(body, services, repos).await, + _ => Ok(IntegrationResponse::Ignored), + } +} + +async fn handle_topgg_integration( + body: Value, + services: Data, + repos: Data, +) -> ApiResult { + let payload = match from_value::(body) { + Ok(p) => p, + Err(e) => { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + error = %e, + "Failed to parse TopGG integration payload" + ); + return Err(ApiError::InvalidInput("Invalid TopGG payload".to_string())); + } + }; + + if payload.type_ == "integration.delete" { + return Ok(IntegrationResponse::Ignored); + } + + let project = payload.data.project.ok_or_else(|| { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + "Received TopGG integration payload without project information" + ); + ApiError::InvalidInput("Missing project information in TopGG payload".to_string()) + })?; + + if project.platform != "discord" { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + platform = %project.platform, + "Received TopGG integration for unsupported platform" + ); + return Ok(IntegrationResponse::Ignored); + } + + if project.type_ != "bot" { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + project_type = %project.type_, + "Received TopGG integration for unsupported project type" + ); + return Ok(IntegrationResponse::Ignored); + } + + if payload.type_ != "integration.create" { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + event_type = %payload.type_, + "Received unsupported TopGG integration event type" + ); + return Ok(IntegrationResponse::Ignored); + } + + if repos.bots.find_by_id(&project.platform_id).await?.is_none() { + let bot_id = &project.platform_id; + let user = payload.data.user.ok_or_else(|| { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + "Received TopGG integration payload without user information for new bot" + ); + ApiError::InvalidInput("Missing user information in TopGG payload".to_string()) + })?; + let token = generate_bot_token(bot_id).map_err(|e| { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + bot_id = %bot_id, + error = %e, + "Failed to generate bot token for new TopGG integration" + ); + ApiError::InternalError("Failed to generate bot token".to_string()) + })?; + let bot_details = services.discord.get_bot(bot_id).await.map_err(|e| { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + bot_id = %bot_id, + error = %e, + "Failed to fetch bot details from Discord for new TopGG integration" + ); + ApiError::InternalError("Failed to fetch bot details".to_string()) + })?; + let new_bot = Bot::new( + bot_id, + &user.platform_id, + token, + &bot_details.username, + bot_details.avatar.as_deref(), + ); + repos.bots.insert(&new_bot).await.map_err(|e| { + warn!( + code = %LogCode::Webhook, + provider = "topgg", + bot_id = %bot_id, + error = %e, + "Failed to insert new bot from TopGG integration into database" + ); + ApiError::InternalError("Failed to create bot".to_string()) + })?; + } + + let update = BotUpdate::new().with_webhook_config( + "topgg", + WebhookConfig { + connection_id: Some(payload.data.connection_id), + webhook_secret: payload.data.webhook_secret, + }, + None, + ); + + repos.bots.update(&project.platform_id, update).await?; + + info!( + code = %LogCode::Webhook, + provider = "topgg", + bot_id = %project.platform_id, + "Successfully processed TopGG integration event" + ); + + Ok(IntegrationResponse::Accepted(IntegrationResult { + webhook_url: format!("{}/webhooks/topgg", app_env!().api_url), + routes: vec!["vote.create"], + })) +} diff --git a/src/api/routes/invitations/invitation/mod.rs b/src/api/routes/invitations/invitation/mod.rs new file mode 100644 index 00000000..97600af7 --- /dev/null +++ b/src/api/routes/invitations/invitation/mod.rs @@ -0,0 +1,108 @@ +use actix_web::web::{Data, Json, Path}; +use apistos::{ + api_operation, + web::{ServiceConfig, get}, +}; +use tracing::info; + +use crate::{ + domain::error::{ApiError, ApiResult}, + openapi::schemas::InvitationResponse, + repository::Repositories, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get an invitation", + description = "Fetch details of a specific invitation using its ID", + tag = "Invitations" +)] +async fn get_invitation( + repos: Data, + id: Path, +) -> ApiResult> { + let invitation_id = &id.into_inner(); + + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Fetching details for invitation", + ); + + let invitation = repos + .team_invitations + .find_by_id(invitation_id) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Invitation not found", + ); + ApiError::NotFound(format!("Invitation with ID {} not found", invitation_id)) + })?; + + if invitation.accepted { + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Invitation already accepted", + ); + return Err(ApiError::InvitationAlreadyAccepted); + } + + if invitation.is_expired() { + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Invitation expired", + ); + return Err(ApiError::InvitationExpired); + } + + let bot = repos + .bots + .find_by_id(&invitation.bot_id) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %invitation.bot_id, + "Bot not found for invitation", + ); + ApiError::NotFound(format!("Bot with ID {} not found", invitation.bot_id)) + })?; + + let owner = repos + .users + .find_by_id(&bot.owner_id) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + owner_id = %bot.owner_id, + "Owner not found for bot in invitation", + ); + ApiError::NotFound(format!("Owner with ID {} not found", bot.owner_id)) + })?; + + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + bot_id = %bot.bot_id, + owner_id = %owner.user_id, + "Successfully fetched invitation details", + ); + + Ok(Json(InvitationResponse { + invitation: invitation.try_into()?, + bot_username: bot.username, + bot_avatar: bot.avatar, + owner_username: owner.username, + owner_avatar: owner.avatar, + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/{invitation_id}", get().to(get_invitation)); +} diff --git a/src/api/routes/invitations/mod.rs b/src/api/routes/invitations/mod.rs new file mode 100644 index 00000000..c5fa607f --- /dev/null +++ b/src/api/routes/invitations/mod.rs @@ -0,0 +1,183 @@ +mod invitation; + +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, get, post, resource, scope}, +}; +use tracing::info; + +use crate::{ + api::middleware::{Authenticated, RequireAdmin}, + domain::error::{ApiError, ApiResult}, + openapi::schemas::{InvitationAcceptBody, InvitationAcceptResponse, TeamInvitationResponse}, + repository::Repositories, + services::Services, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get all invitations", + description = "Retrieve a list of team invitations", + tag = "Invitations", + skip +)] +async fn get_invitations( + _admin: RequireAdmin, + repos: Data, +) -> ApiResult>> { + info!( + code = %LogCode::Request, + "Fetching all team invitations", + ); + + let invitations = repos.team_invitations.find_all().await?; + + let invitation_responses = invitations + .into_iter() + .map(TeamInvitationResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + "All team invitations fetched successfully", + ); + + Ok(Json(invitation_responses)) +} + +#[api_operation( + summary = "Accept or reject an invitation", + description = "Accept or reject a team invitation using its ID", + tag = "Invitations" +)] +async fn post_invitation( + auth: Authenticated, + services: Data, + repos: Data, + body: Json, +) -> ApiResult> { + let invitation_id = &body.invitation_id; + + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Processing invitation acceptance/rejection", + ); + + let invitation = repos + .team_invitations + .find_by_id(invitation_id) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Invitation not found", + ); + ApiError::NotFound(format!("Invitation with ID {} not found", invitation_id)) + })?; + + let bot = repos + .bots + .find_by_id(&invitation.bot_id) + .await? + .ok_or_else(|| { + info!( + code = %LogCode::Request, + bot_id = %invitation.bot_id, + "Bot not found for", + ); + ApiError::NotFound(format!("Bot with ID {} not found", invitation.bot_id)) + })?; + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + invitation_id = %invitation_id, + "Admin user processing invitation", + ); + } else if ctx.is_user() { + let user_id = ctx.user_id.as_deref().ok_or(ApiError::Unauthorized)?; + if !bot.is_team_member(user_id) { + info!( + code = %LogCode::Forbidden, + invitation_id = %invitation_id, + user_id = %user_id, + "User does not have access to process invitation", + ); + return Err(ApiError::Forbidden); + } + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + user_id = %user_id, + "User processing invitation", + ); + } else { + info!( + code = %LogCode::Forbidden, + invitation_id = %invitation_id, + "Unauthorized context attempting to process invitation", + ); + return Err(ApiError::Forbidden); + } + + if invitation.accepted { + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Invitation already accepted, cannot process", + ); + return Err(ApiError::InvitationAlreadyAccepted); + } + + if invitation.is_expired() { + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Invitation expired, cannot process", + ); + return Err(ApiError::InvitationExpired); + } + + if body.accept { + repos + .team_invitations + .accept_invitation(invitation_id) + .await?; + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Invitation accepted successfully", + ); + } else { + services + .invitations + .reject_invitation(invitation_id, &invitation.bot_id, &invitation.user_id) + .await?; + info!( + code = %LogCode::Request, + invitation_id = %invitation_id, + "Invitation rejected successfully", + ); + } + + Ok(Json(InvitationAcceptResponse { + accepted: body.accept, + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/invitations") + .service( + resource("") + .route(get().to(get_invitations)) + .route(post().to(post_invitation)), + ) + .configure(invitation::configure), + ); +} diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs new file mode 100644 index 00000000..523f1fd4 --- /dev/null +++ b/src/api/routes/mod.rs @@ -0,0 +1,27 @@ +mod achievements; +mod articles; +mod auth; +mod bots; +mod health; +mod integrations; +mod invitations; +mod stats; +mod users; +mod webhooks; +mod websocket; + +use apistos::web::ServiceConfig; + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.configure(achievements::configure) + .configure(articles::configure) + .configure(auth::configure) + .configure(bots::configure) + .configure(health::configure) + .configure(integrations::configure) + .configure(invitations::configure) + .configure(stats::configure) + .configure(users::configure) + .configure(webhooks::configure) + .configure(websocket::configure); +} diff --git a/src/api/routes/stats/mod.rs b/src/api/routes/stats/mod.rs new file mode 100644 index 00000000..ed55537a --- /dev/null +++ b/src/api/routes/stats/mod.rs @@ -0,0 +1,60 @@ +use actix_web::web::{Data, Json, Query}; +use anyhow::Result; +use apistos::{ + api_operation, + web::{ServiceConfig, get, resource, scope}, +}; +use mongodb::bson::DateTime; +use tracing::info; + +use crate::{ + api::middleware::RequireAdmin, + domain::error::ApiResult, + openapi::schemas::{StatResponse, StatsQuery}, + repository::Repositories, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get global stats for a date range", + description = "Fetch global statistics for all bots and users in the Discord Analytics API for a specified date range.", + tag = "Stats", + skip +)] +async fn get_stats( + _admin: RequireAdmin, + repos: Data, + query: Query, +) -> ApiResult>> { + info!( + code = %LogCode::Request, + start = %query.start, + end = %query.end, + "Fetching global stats for date range", + ); + + let start = DateTime::parse_rfc3339_str(format!("{}T00:00:00Z", query.start).as_str())?; + let end = DateTime::parse_rfc3339_str(format!("{}T23:59:59Z", query.end).as_str())?; + + let stats = repos + .global_stats + .find_from_date_range(&start, &end) + .await?; + + let stat_responses = stats + .into_iter() + .map(StatResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + count = stat_responses.len(), + "Fetched global stats for date range", + ); + + Ok(Json(stat_responses)) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service(scope("/stats").service(resource("").route(get().to(get_stats)))); +} diff --git a/src/api/routes/users/mod.rs b/src/api/routes/users/mod.rs new file mode 100644 index 00000000..eec8536d --- /dev/null +++ b/src/api/routes/users/mod.rs @@ -0,0 +1,52 @@ +mod user; + +use actix_web::web::{Data, Json}; +use anyhow::Result; +use apistos::{ + api_operation, + web::{ServiceConfig, get, resource, scope}, +}; +use tracing::info; + +use crate::{ + api::middleware::RequireAdmin, domain::error::ApiResult, openapi::schemas::UserResponse, + repository::Repositories, utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get all users", + description = "Fetch a list of all users registered in the Discord Analytics API", + tag = "Users", + skip +)] +async fn get_all_users( + _admin: RequireAdmin, + repos: Data, +) -> ApiResult>> { + info!( + code = %LogCode::Request, + "Fetching all users", + ); + + let users = repos.users.find_all().await?; + + let user_responses = users + .into_iter() + .map(UserResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + "All users fetched successfully", + ); + + Ok(Json(user_responses)) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/users") + .service(resource("").route(get().to(get_all_users))) + .configure(user::configure), + ); +} diff --git a/src/api/routes/users/user/bots/mod.rs b/src/api/routes/users/user/bots/mod.rs new file mode 100644 index 00000000..33d42258 --- /dev/null +++ b/src/api/routes/users/user/bots/mod.rs @@ -0,0 +1,88 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, get}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, Snowflake}, + domain::error::{ApiError, ApiResult}, + openapi::schemas::{BotResponse, UserBotsResponse}, + repository::Repositories, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get user's bots", + description = "Fetch a list of bots owned by the authenticated user", + tag = "Users" +)] +async fn get_user_bots( + auth: Authenticated, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let user_id = id.0; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Received request to fetch user's bots" + ); + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + user_id = %user_id, + "Admin access granted for user bots" + ); + } else if ctx.is_user() && ctx.user_id.as_deref() != Some(&user_id) { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + "User attempted to access another user's details" + ); + return Err(ApiError::Forbidden); + } else if !ctx.is_user() { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + "Unauthenticated access attempt to user details" + ); + return Err(ApiError::Forbidden); + } + + let user_bots = repos.bots.find_by_user_id(&user_id).await?; + + let owned_bots = user_bots + .iter() + .filter(|b| b.owner_id == user_id) + .cloned() + .map(BotResponse::try_from) + .collect::, _>>()?; + let team_bots = user_bots + .into_iter() + .filter(|b| b.team.contains(&user_id)) + .map(BotResponse::try_from) + .collect::, _>>()?; + + info!( + code = %LogCode::Request, + user_id = %user_id, + owned_bots_count = owned_bots.len(), + team_bots_count = team_bots.len(), + "Fetched user's bots" + ); + + Ok(Json(UserBotsResponse { + owned_bots, + team_bots, + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.route("/bots", get().to(get_user_bots)); +} diff --git a/src/api/routes/users/user/mod.rs b/src/api/routes/users/user/mod.rs new file mode 100644 index 00000000..b772c4cd --- /dev/null +++ b/src/api/routes/users/user/mod.rs @@ -0,0 +1,202 @@ +mod bots; +mod suspend; + +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, get, patch, resource, scope}, +}; +use tracing::{info, warn}; + +use crate::{ + api::middleware::{Authenticated, RequireAdmin, Snowflake}, + domain::error::{ApiError, ApiResult}, + openapi::schemas::{MessageResponse, UserResponse, UserUpdateRequest}, + repository::{Repositories, UserUpdate}, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Get user details", + description = "Fetch detailed information about a specific user registered in the Discord Analytics API", + tag = "Users" +)] +async fn get_user( + auth: Authenticated, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let user_id = id.0; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Received request to fetch user details" + ); + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + user_id = %user_id, + "Admin access granted for user details" + ); + } else if ctx.is_user() && ctx.user_id.as_deref() != Some(&user_id) { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + "User attempted to access another user's details" + ); + return Err(ApiError::Forbidden); + } else if !ctx.is_user() { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + "Unauthenticated access attempt to user details" + ); + return Err(ApiError::Forbidden); + } + + let user = repos.users.find_by_id(&user_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + user_id = %user_id, + "User not found" + ); + ApiError::NotFound(format!("User with ID {} not found", user_id)) + })?; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Fetched details for user" + ); + + Ok(Json(UserResponse::try_from(user)?)) +} + +#[api_operation( + summary = "Update user details", + description = "Update information for a specific user registered in the Discord Analytics API", + tag = "Users", + skip +)] +async fn update_user( + _admin: RequireAdmin, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let user_id = id.0; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Received request to update user details" + ); + + let bots_limit = body.bots_limit; + + let user_update = UserUpdate::new().with_bots_limit(bots_limit); + + let update_result = repos.users.update(&user_id, user_update).await?; + + let updated_user = update_result.ok_or_else(|| { + warn!( + code = %LogCode::Request, + user_id = %user_id, + "User not found after update" + ); + ApiError::DatabaseError(format!("User with ID {} not found after update", user_id)) + })?; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "User details updated" + ); + + Ok(Json(UserResponse::try_from(updated_user)?)) +} + +#[api_operation( + summary = "Delete a user", + description = "Delete a specific user from the Discord Analytics API", + tag = "Users" +)] +async fn delete_user( + auth: Authenticated, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let user_id = id.0; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Received request to delete user account" + ); + + let ctx = &auth.0; + + if ctx.is_admin() { + info!( + code = %LogCode::AdminAction, + user_id = %user_id, + "Admin access granted for user deletion" + ); + } else if ctx.is_user() && ctx.user_id.as_deref() != Some(&user_id) { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + "User attempted to delete another user's account" + ); + return Err(ApiError::Forbidden); + } else if !ctx.is_user() { + warn!( + code = %LogCode::Forbidden, + user_id = %user_id, + "Unauthenticated access attempt to delete user account" + ); + return Err(ApiError::Forbidden); + } + + let result = repos.users.delete_by_id(&user_id).await?; + + if result.deleted_count == 0 { + info!( + code = %LogCode::Request, + user_id = %user_id, + "User not found for deletion" + ); + return Err(ApiError::NotFound(format!( + "User with ID {} not found", + user_id + ))); + } + + info!( + code = %LogCode::Request, + user_id = %user_id, + "User account deleted" + ); + + Ok(Json(MessageResponse { + message: format!("User with ID {} has been deleted", user_id), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/{id}") + .service( + resource("") + .route(get().to(get_user)) + .route(patch().to(update_user)) + .route(delete().to(delete_user)), + ) + .configure(bots::configure) + .configure(suspend::configure), + ); +} diff --git a/src/api/routes/users/user/suspend/mod.rs b/src/api/routes/users/user/suspend/mod.rs new file mode 100644 index 00000000..6cfdf5ac --- /dev/null +++ b/src/api/routes/users/user/suspend/mod.rs @@ -0,0 +1,132 @@ +use actix_web::web::{Data, Json}; +use apistos::{ + api_operation, + web::{ServiceConfig, delete, post, scope}, +}; +use tracing::info; + +use crate::{ + api::middleware::{RequireAdmin, Snowflake}, + domain::error::{ApiError, ApiResult}, + openapi::schemas::{MessageResponse, UserSuspendRequest}, + repository::{Repositories, UserUpdate}, + utils::logger::LogCode, +}; + +#[api_operation( + summary = "Suspend a user", + description = "Suspends a user, preventing them from accessing their account and using the API. Only administrators can perform this action.", + tag = "Users", + skip +)] +async fn suspend_user( + _admin: RequireAdmin, + repos: Data, + body: Json, + id: Snowflake, +) -> ApiResult> { + let user_id = id.0; + + info!( + code = %LogCode::Request, + user_id = %user_id, + reason = %body.reason, + "Received request to suspend user" + ); + + let user = repos.users.find_by_id(&user_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + user_id = %user_id, + "User not found", + ); + ApiError::NotFound(format!("User with ID {} not found", user_id)) + })?; + + if user.suspended { + info!( + code = %LogCode::Forbidden, + user_id = %user_id, + "User is already suspended", + ); + return Err(ApiError::UserSuspended); + } + + let reason = body.reason.trim(); + + let user_update = UserUpdate::new().with_suspended(true); + + repos.users.update(&user_id, user_update).await?; + repos.sessions.revoke_all_for_user(&user_id).await?; + + info!( + code = %LogCode::AdminAction, + user_id = %user_id, + reason = %reason, + "User has been suspended" + ); + + Ok(Json(MessageResponse { + message: format!("User {} has been suspended for reason: {}", user_id, reason), + })) +} + +#[api_operation( + summary = "Unsuspend a user", + description = "Unsuspends a user, restoring their access to their account and the API. Only administrators can perform this action.", + tag = "Users", + skip +)] +async fn unsuspend_user( + _admin: RequireAdmin, + repos: Data, + id: Snowflake, +) -> ApiResult> { + let user_id = id.0; + + info!( + code = %LogCode::Request, + user_id = %user_id, + "Received request to unsuspend user" + ); + + let user = repos.users.find_by_id(&user_id).await?.ok_or_else(|| { + info!( + code = %LogCode::Request, + user_id = %user_id, + "User not found", + ); + ApiError::NotFound(format!("User with ID {} not found", user_id)) + })?; + + if !user.suspended { + info!( + code = %LogCode::Forbidden, + user_id = %user_id, + "User is not suspended", + ); + return Err(ApiError::UserUnsuspended); + } + + let user_update = UserUpdate::new().with_suspended(false); + + repos.users.update(&user_id, user_update).await?; + + info!( + code = %LogCode::AdminAction, + user_id = %user_id, + "User has been unsuspended" + ); + + Ok(Json(MessageResponse { + message: format!("User {} has been unsuspended", user_id), + })) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service( + scope("/suspend") + .route("", post().to(suspend_user)) + .route("", delete().to(unsuspend_user)), + ); +} diff --git a/src/api/routes/webhooks/mod.rs b/src/api/routes/webhooks/mod.rs new file mode 100644 index 00000000..e4234720 --- /dev/null +++ b/src/api/routes/webhooks/mod.rs @@ -0,0 +1,227 @@ +mod providers; + +use std::sync::Arc; + +use actix_web::{ + HttpRequest, HttpResponse, + web::{Data, Json, Path}, +}; +use apistos::{ + api_operation, + web::{ServiceConfig, post, resource, scope}, +}; +use serde_json::{Value, from_slice}; +use tokio::sync::Mutex; +use tracing::{info, warn}; + +use self::providers::{ProviderResponse, handle_provider}; + +use crate::{ + api::middleware::RawBody, + domain::error::{ApiError, ApiResult}, + managers::VotesWebhooksManager, + openapi::schemas::MessageResponse, + repository::Repositories, + services::Services, + utils::logger::LogCode, +}; + +fn extract_bot_id_from_payload(provider: &str, body: &Value) -> Option { + match provider { + "topgg" => body + .get("data")? + .get("project")? + .get("platform_id")? + .as_str() + .map(String::from), + "dblist" | "discordlist" => body.get("bot_id")?.as_str().map(String::from), + "discordscom" | "botlistme" => body.get("bot")?.as_str().map(String::from), + _ => None, + } +} + +#[api_operation( + summary = "Handle incoming webhooks from vote providers", + description = "This endpoint receives webhooks from various vote providers, processes the payload, and updates the vote counts accordingly. The provider is specified in the URL path, and the payload format may vary based on the provider. The endpoint also verifies the authenticity of the webhook using provider-specific methods to ensure that only legitimate webhooks are processed.", + tag = "Webhooks" +)] +async fn vote_webhook( + req: HttpRequest, + services: Data, + repos: Data, + webhook_manager: Data>>, + path: Path, + body: RawBody, +) -> ApiResult> { + let provider = path.into_inner(); + + let body_bytes = &body.0; + + let body_value = from_slice::(body_bytes).map_err(|e| { + warn!( + code = %LogCode::Webhook, + provider = %provider, + error = %e, + "Failed to parse JSON body in webhook" + ); + ApiError::WebhookError("Invalid JSON body".to_string()) + })?; + + info!( + code = %LogCode::Webhook, + provider = %provider, + body = ?body_value, + "Received webhook with body" + ); + + let bot_id = extract_bot_id_from_payload(&provider, &body_value).ok_or_else(|| { + warn!( + code = %LogCode::Webhook, + provider = %provider, + "Failed to extract bot ID from webhook payload" + ); + ApiError::WebhookError("Missing bot ID in payload".to_string()) + })?; + + let headers = req.headers(); + + let authorization = headers.get("Authorization").and_then(|h| h.to_str().ok()); + + let bot = repos.bots.find_by_id(&bot_id).await?.ok_or_else(|| { + warn!( + code = %LogCode::Webhook, + provider = %provider, + bot_id = %bot_id, + "Received webhook for non-existent bot" + ); + ApiError::NotFound("Bot not found".to_string()) + })?; + + let response = handle_provider( + &provider, + body_value.clone(), + body_bytes, + authorization, + &bot, + headers, + ) + .await?; + + match response { + ProviderResponse::Vote(vote_result) => { + services + .webhooks + .record_vote( + &bot_id, + &vote_result.voter_id, + &provider, + vote_result.vote_count, + ) + .await?; + + if provider != "test" { + let _ = services + .webhooks + .trigger_webhook_notification( + &bot, + &vote_result.voter_id, + &provider, + body_value, + &webhook_manager, + ) + .await; + } + + info!( + code = %LogCode::Webhook, + provider = %provider, + bot_id = %bot_id, + voter_id = %vote_result.voter_id, + vote_count = vote_result.vote_count, + "Processed vote webhook successfully" + ); + + Ok(Json(MessageResponse { + message: "Vote processed successfully".to_string(), + })) + } + ProviderResponse::TestWebhook => { + info!( + code = %LogCode::Webhook, + provider = %provider, + bot_id = %bot_id, + "Received test webhook, ignoring vote processing" + ); + + return Ok(Json(MessageResponse { + message: "Test webhook received".to_string(), + })); + } + ProviderResponse::Ignored => { + info!( + code = %LogCode::Webhook, + provider = %provider, + bot_id = %bot_id, + "Webhook ignored after processing" + ); + + Ok(Json(MessageResponse { + message: "Webhook ignored".to_string(), + })) + } + } +} + +#[api_operation( + summary = "Legacy webhook endpoint", + description = "This endpoint is a legacy webhook handler that is now deprecated. It was previously used to receive webhooks from vote providers, but it has been replaced by the new /webhooks/{provider} endpoint. This endpoint will return a message indicating that it is deprecated and should not be used for new integrations.", + tag = "Webhooks", + deprecated +)] +async fn legacy_vote_webhook( + req: HttpRequest, + services: Data, + repos: Data, + webhook_manager: Data>>, + path: Path<(String, String)>, + body: RawBody, +) -> ApiResult { + let (bot_id, provider) = path.into_inner(); + + warn!( + code = %LogCode::Webhook, + provider = %provider, + bot_id = %bot_id, + "Received webhook on legacy endpoint, this endpoint is deprecated and should not be used" + ); + + let mut body_with_bot_id = from_slice::(&body.0).unwrap_or(Value::Null); + if let Value::Object(ref mut map) = body_with_bot_id { + map.insert("bot_id".to_string(), Value::String(bot_id.clone())); + } + + let result = vote_webhook( + req, + services, + repos, + webhook_manager, + Path::from(provider.clone()), + RawBody(body_with_bot_id.to_string().into_bytes()), + ) + .await?; + + Ok(HttpResponse::Ok() + .insert_header(( + "X-Deprecation-Warning", + "This endpoint is deprecated, please use POST /webhooks/{provider} instead", + )) + .insert_header(("X-New-Endpoint", format!("/webhooks/{}", provider))) + .json(result.into_inner())) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service(scope("/webhooks").service(resource("/{provider}").route(post().to(vote_webhook)))) + .service( + resource("/bots/{id}/votes/webhooks/{provider}").route(post().to(legacy_vote_webhook)), + ); +} diff --git a/src/api/routes/webhooks/providers.rs b/src/api/routes/webhooks/providers.rs new file mode 100644 index 00000000..38942a61 --- /dev/null +++ b/src/api/routes/webhooks/providers.rs @@ -0,0 +1,353 @@ +use std::str::from_utf8; + +use actix_web::http::header::HeaderMap; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; +use ring::hmac::{HMAC_SHA256, Key, sign}; +use serde_json::{Value, from_value}; +use tracing::info; + +use crate::{ + domain::{ + error::{ApiError, ApiResult}, + models::Bot, + }, + openapi::schemas::{ + BotListMePayload, DBListPayload, DiscordListPayload, DiscordPlacePayload, + DiscordsComPayload, TopGGPayload, + }, + utils::logger::LogCode, +}; + +pub struct VoteResult { + pub vote_count: i32, + pub voter_id: String, +} + +pub enum ProviderResponse { + Vote(VoteResult), + TestWebhook, + Ignored, +} + +struct TopGGSignature { + timestamp: String, + signature: String, +} + +fn extract_discordlist_payload(secret: &str, body_bytes: &[u8]) -> Option { + let body_str = from_utf8(body_bytes).ok()?; + let token_data = decode::( + body_str, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::new(Algorithm::HS256), + ) + .ok()?; + Some(token_data.claims) +} + +fn extract_topgg_signature(headers: &HeaderMap) -> Option { + let signature_str = headers.get("x-topgg-signature")?.to_str().ok()?; + let (t_part, v1_part) = signature_str.split_once(',')?; + let timestamp = t_part.trim().strip_prefix("t=")?; + let signature = v1_part.trim().strip_prefix("v1=")?; + + Some(TopGGSignature { + timestamp: timestamp.to_string(), + signature: signature.to_string(), + }) +} + +fn compute_topgg_signature(secret: &str, timestamp: &str, body: &[u8]) -> String { + let mac = Key::new(HMAC_SHA256, secret.as_bytes()); + let mut data = Vec::with_capacity(timestamp.len() + 1 + body.len()); + data.extend_from_slice(timestamp.as_bytes()); + data.push(b'.'); + data.extend_from_slice(body); + let signature = sign(&mac, &data); + hex::encode(signature.as_ref()) +} + +fn verify_topgg_signature(signature: &str, computed_signature: &str) -> bool { + signature == computed_signature +} + +pub async fn handle_provider( + provider: &str, + body: Value, + body_bytes: &[u8], + authorization: Option<&str>, + bot: &Bot, + headers: &HeaderMap, +) -> ApiResult { + match provider { + "botlistme" => handle_botlistme(body, bot, authorization).await, + "dblist" => handle_dblist(body, bot, authorization).await, + "discordlist" => handle_discordlist(body_bytes, bot).await, + "discordplace" => handle_discordplace(body, bot, authorization).await, + "discordscom" => handle_discordscom(body, bot, authorization).await, + "topgg" => handle_topgg(body, body_bytes, bot, headers).await, + _ => { + info!( + code = %LogCode::Webhook, + provider = %provider, + bot_id = %bot.bot_id, + "Received webhook from unsupported provider, ignoring" + ); + Ok(ProviderResponse::Ignored) + } + } +} + +async fn handle_botlistme( + body: Value, + bot: &Bot, + authorization: Option<&str>, +) -> ApiResult { + let webhook_config = bot + .webhooks_config + .webhooks + .get("botlistme") + .ok_or_else(|| { + ApiError::WebhookError( + "Bot does not have webhook configured for botlist.me".to_string(), + ) + })?; + + let webhook_secret = match &webhook_config.webhook_secret { + Some(secret) if !secret.is_empty() => secret, + _ => return Ok(ProviderResponse::Ignored), + }; + + if let Some(auth) = authorization + && auth != webhook_secret + { + return Ok(ProviderResponse::Ignored); + } + + let payload = from_value::(body) + .map_err(|_| ApiError::InvalidInput("Invalid botlist.me payload".to_string()))?; + + if payload.bot != bot.bot_id { + return Ok(ProviderResponse::Ignored); + } + + match payload.vote_type.as_str() { + "Test" => Ok(ProviderResponse::TestWebhook), + "Upvote" => Ok(ProviderResponse::Vote(VoteResult { + vote_count: 1, + voter_id: payload.user, + })), + _ => Ok(ProviderResponse::Ignored), + } +} + +async fn handle_dblist( + body: Value, + bot: &Bot, + authorization: Option<&str>, +) -> ApiResult { + let webhook_config = bot.webhooks_config.webhooks.get("dblist").ok_or_else(|| { + ApiError::WebhookError("Bot does not have webhook configured for dblist".to_string()) + })?; + + let webhook_secret = match &webhook_config.webhook_secret { + Some(secret) if !secret.is_empty() => secret, + _ => return Ok(ProviderResponse::Ignored), + }; + + if let Some(auth) = authorization + && auth != webhook_secret + { + return Ok(ProviderResponse::Ignored); + } + + let payload = from_value::(body) + .map_err(|_| ApiError::InvalidInput("Invalid DBList payload".to_string()))?; + + if payload.bot_id != bot.bot_id { + return Ok(ProviderResponse::Ignored); + } + + match payload.promotable_bot { + Some(_) => Ok(ProviderResponse::TestWebhook), + None => Ok(ProviderResponse::Vote(VoteResult { + vote_count: 1, + voter_id: payload.id, + })), + } +} + +async fn handle_discordlist(body_bytes: &[u8], bot: &Bot) -> ApiResult { + let webhook_config = bot + .webhooks_config + .webhooks + .get("discordlist") + .ok_or_else(|| { + ApiError::WebhookError( + "Bot does not have webhook configured for discordlist".to_string(), + ) + })?; + + let webhook_secret = match &webhook_config.webhook_secret { + Some(secret) if !secret.is_empty() => secret, + _ => return Ok(ProviderResponse::Ignored), + }; + + let payload = extract_discordlist_payload(webhook_secret, body_bytes).ok_or_else(|| { + ApiError::InvalidInput("Invalid DiscordList payload or signature".to_string()) + })?; + + if payload.bot_id != bot.bot_id { + return Ok(ProviderResponse::Ignored); + } + + match payload.is_test { + true => Ok(ProviderResponse::TestWebhook), + false => Ok(ProviderResponse::Vote(VoteResult { + vote_count: 1, + voter_id: payload.user_id, + })), + } +} + +async fn handle_discordplace( + body: Value, + bot: &Bot, + authorization: Option<&str>, +) -> ApiResult { + let webhook_config = bot + .webhooks_config + .webhooks + .get("discordplace") + .ok_or_else(|| { + ApiError::WebhookError( + "Bot does not have webhook configured for discord.place".to_string(), + ) + })?; + + let webhook_secret = match &webhook_config.webhook_secret { + Some(secret) if !secret.is_empty() => secret, + _ => return Ok(ProviderResponse::Ignored), + }; + + if let Some(auth) = authorization + && auth != webhook_secret + { + return Ok(ProviderResponse::Ignored); + } + + let payload = from_value::(body) + .map_err(|_| ApiError::InvalidInput("Invalid discord.place payload".to_string()))?; + + if payload.bot != bot.bot_id { + return Ok(ProviderResponse::Ignored); + } + + if payload.test { + return Ok(ProviderResponse::TestWebhook); + } + + Ok(ProviderResponse::Vote(VoteResult { + vote_count: 1, + voter_id: payload.user, + })) +} + +async fn handle_discordscom( + body: Value, + bot: &Bot, + authorization: Option<&str>, +) -> ApiResult { + let webhook_config = bot + .webhooks_config + .webhooks + .get("discordscom") + .ok_or_else(|| { + ApiError::WebhookError( + "Bot does not have webhook configured for discords.com".to_string(), + ) + })?; + + let webhook_secret = match &webhook_config.webhook_secret { + Some(secret) if !secret.is_empty() => secret, + _ => return Ok(ProviderResponse::Ignored), + }; + + if let Some(auth) = authorization + && auth != webhook_secret + { + return Ok(ProviderResponse::Ignored); + } + + let payload = from_value::(body) + .map_err(|_| ApiError::InvalidInput("Invalid discords.com payload".to_string()))?; + + if payload.bot != bot.bot_id { + return Ok(ProviderResponse::Ignored); + } + + match payload.type_.as_str() { + "test" => Ok(ProviderResponse::TestWebhook), + "premium_vote" => Ok(ProviderResponse::Vote(VoteResult { + vote_count: 2, + voter_id: payload.user, + })), + "vote" => Ok(ProviderResponse::Vote(VoteResult { + vote_count: 1, + voter_id: payload.user, + })), + _ => Ok(ProviderResponse::Ignored), + } +} + +async fn handle_topgg( + body: Value, + body_bytes: &[u8], + bot: &Bot, + headers: &HeaderMap, +) -> ApiResult { + let webhook_config = bot.webhooks_config.webhooks.get("topgg").ok_or_else(|| { + ApiError::WebhookError("Bot does not have webhook configured for top.gg".to_string()) + })?; + + let webhook_secret = match &webhook_config.webhook_secret { + Some(secret) if !secret.is_empty() => secret, + _ => return Ok(ProviderResponse::Ignored), + }; + + let signature = extract_topgg_signature(headers).ok_or_else(|| { + ApiError::WebhookError("Missing or invalid TopGG signature header".to_string()) + })?; + let computed_signature = + compute_topgg_signature(webhook_secret, &signature.timestamp, body_bytes); + + if !verify_topgg_signature(&signature.signature, &computed_signature) { + return Ok(ProviderResponse::Ignored); + } + + let payload = from_value::(body) + .map_err(|_| ApiError::InvalidInput("Invalid TopGG payload".to_string()))?; + + let project = payload.data.project; + + if &project.type_ != "bot" { + return Ok(ProviderResponse::Ignored); + } + + if &project.platform != "discord" { + return Ok(ProviderResponse::Ignored); + } + + if project.platform_id != bot.bot_id { + return Ok(ProviderResponse::Ignored); + } + + match payload.type_.as_str() { + "vote.create" => Ok(ProviderResponse::Vote(VoteResult { + vote_count: payload.data.weight.unwrap_or(1), + voter_id: payload.data.user.platform_id, + })), + "webhook.test" => Ok(ProviderResponse::TestWebhook), + _ => Ok(ProviderResponse::Ignored), + } +} diff --git a/src/api/routes/websocket/mod.rs b/src/api/routes/websocket/mod.rs new file mode 100644 index 00000000..d43d2b19 --- /dev/null +++ b/src/api/routes/websocket/mod.rs @@ -0,0 +1,122 @@ +use std::{pin::pin, time::Instant}; + +use actix_web::{ + HttpRequest, HttpResponse, + web::{Data, Payload}, +}; +use actix_ws::{AggregatedMessage, MessageStream, Session, handle}; +use apistos::{ + api_operation, + web::{ServiceConfig, get, resource, scope}, +}; +use futures::{ + StreamExt as _, + future::{Either, select}, +}; +use tokio::{sync::mpsc, task::spawn_local, time::interval}; +use tracing::error; + +use crate::{ + domain::error::ApiResult, + managers::ChatServerHandle, + utils::{ + constants::{CLIENT_TIMEOUT, HEARTBEAT_INTERVAL}, + logger::LogCode, + }, +}; + +async fn handle_chat_ws( + chat_server: ChatServerHandle, + mut session: Session, + stream: MessageStream, +) { + let mut last_hb = Instant::now(); + let mut interval = interval(HEARTBEAT_INTERVAL); + + let (conn_tx, mut conn_rx) = mpsc::unbounded_channel(); + + let conn_id = chat_server.connect(conn_tx).await; + + let stream = stream + .max_frame_size(128 * 1024) + .aggregate_continuations() + .max_continuation_size(2 * 1024 * 1024); + + let mut stream = pin!(stream); + + let close_reason = loop { + let tick = pin!(interval.tick()); + let msg_rx = pin!(conn_rx.recv()); + let messages = pin!(select(stream.next(), msg_rx)); + + match select(messages, tick).await { + Either::Left((Either::Left((Some(Ok(msg)), _)), _)) => match msg { + AggregatedMessage::Ping(bytes) => { + last_hb = Instant::now(); + session.pong(&bytes).await.expect("Failed to send pong"); + } + AggregatedMessage::Pong(_) => { + last_hb = Instant::now(); + } + AggregatedMessage::Close(reason) => break reason, + _ => break None, + }, + Either::Left((Either::Left((Some(Err(err)), _)), _)) => { + error!( + code = %LogCode::Websocket, + error = %err, + "Error reading WebSocket message" + ); + break None; + } + // Stream ended + Either::Left((Either::Left((None, _)), _)) => break None, + // Send outgoing messages + Either::Left((Either::Right((Some(chat_msg), _)), _)) => { + session + .text(chat_msg) + .await + .expect("Failed to send chat message"); + } + // Channel closed + Either::Left((Either::Right((None, _)), _)) => break None, + // Heartbeat tick + Either::Right((_inst, _)) => { + if Instant::now().duration_since(last_hb) > CLIENT_TIMEOUT { + error!( + code = %LogCode::Websocket, + conn_id = %conn_id, + "Client timeout" + ); + break None; + } + let _ = session.ping(b"").await; + } + }; + }; + + chat_server.disconnect(conn_id).await; + let _ = session.close(close_reason).await; +} + +#[api_operation( + summary = "WebSocket endpoint for lost connections", + description = "Establishes a WebSocket connection to receive real-time visitor count updates for the /lost page", + tag = "WebSocket", + skip +)] +async fn get_lost( + req: HttpRequest, + body: Payload, + chat_server: Data, +) -> ApiResult { + let (response, session, stream) = handle(&req, body)?; + + spawn_local(handle_chat_ws((**chat_server).clone(), session, stream)); + + Ok(response) +} + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service(scope("/websocket").service(resource("/lost").route(get().to(get_lost)))); +} diff --git a/src/config/env.rs b/src/config/env.rs index 914162e4..e657a9d8 100644 --- a/src/config/env.rs +++ b/src/config/env.rs @@ -1,6 +1,6 @@ -use std::{env, sync::OnceLock}; +use std::{env, net::Ipv4Addr, sync::OnceLock}; -use anyhow::{Error, Result, anyhow}; +use anyhow::{Error, Result}; use dotenvy::dotenv; #[derive(Debug)] @@ -14,30 +14,43 @@ pub struct EnvConfig { // Database pub database_url: String, - // OpenTelemetry - pub otlp_endpoint: Option, - pub otlp_token: Option, - pub otlp_stream: Option, - // Tokens pub discord_token: String, pub jwt_secret: String, + pub enable_registrations: bool, // Linked Roles pub client_secret: String, pub client_id: String, + // OpenTelemetry + #[cfg(feature = "otel")] + pub otlp_endpoint: String, + #[cfg(feature = "otel")] + pub otlp_token: String, + #[cfg(feature = "otel")] + pub otlp_stream: String, + // Mail + #[cfg(feature = "mails")] pub smtp: String, + #[cfg(feature = "mails")] pub smtp_mail: String, + #[cfg(feature = "mails")] pub smtp_user: String, + #[cfg(feature = "mails")] pub smtp_password: String, // R2 + #[cfg(feature = "reports")] pub r2_bucket_name: String, + #[cfg(feature = "reports")] pub r2_account_id: String, + #[cfg(feature = "reports")] pub r2_public_bucket_endpoint: String, + #[cfg(feature = "reports")] pub cloudflare_id: String, + #[cfg(feature = "reports")] pub cloudflare_token: String, } @@ -59,7 +72,8 @@ pub fn init_env() -> Result<&'static EnvConfig> { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(3001); - let api_url = env::var("API_URL").unwrap_or_else(|_| format!("http://localhost:{}", port)); + let api_url = + env::var("API_URL").unwrap_or_else(|_| format!("{}:{}", Ipv4Addr::UNSPECIFIED, port)); let client_url = get_var("CLIENT_URL")?; let admins = env::var("ADMINS") .unwrap_or_default() @@ -70,35 +84,40 @@ pub fn init_env() -> Result<&'static EnvConfig> { let database_url = get_var("DATABASE_URL")?; - let (otlp_endpoint, otlp_token, otlp_stream) = match ( - get_var("OTLP_ENDPOINT"), - get_var("OTLP_TOKEN"), - get_var("OTLP_STREAM"), - ) { - (Ok(endpoint), Ok(token), Ok(stream)) => (Some(endpoint), Some(token), Some(stream)), - (Err(_), Err(_), Err(_)) => (None, None, None), - _ => { - return Err(anyhow!( - "One of these env vars are missing: OTLP_ENDPOINT, OTLP_TOKEN or OTLP_STREAM" - )); - } - }; - let discord_token = get_var("DISCORD_TOKEN")?; let jwt_secret = get_var("JWT_SECRET")?; + let enable_registrations = env::var("ENABLE_REGISTRATIONS") + .map(|v| v == "true" || v == "1") + .unwrap_or(true); let client_secret = get_var("CLIENT_SECRET")?; let client_id = get_var("CLIENT_ID")?; + #[cfg(feature = "otel")] + let otlp_endpoint = get_var("OTLP_ENDPOINT")?; + #[cfg(feature = "otel")] + let otlp_token = get_var("OTLP_TOKEN")?; + #[cfg(feature = "otel")] + let otlp_stream = get_var("OTLP_STREAM")?; + + #[cfg(feature = "mails")] let smtp = get_var("SMTP")?; + #[cfg(feature = "mails")] let smtp_mail = get_var("SMTP_MAIL")?; + #[cfg(feature = "mails")] let smtp_user = get_var("SMTP_USER")?; + #[cfg(feature = "mails")] let smtp_password = get_var("SMTP_PASSWORD")?; + #[cfg(feature = "reports")] let r2_bucket_name = get_var("R2_BUCKET_NAME")?; + #[cfg(feature = "reports")] let r2_account_id = get_var("R2_ACCOUNT_ID")?; + #[cfg(feature = "reports")] let r2_public_bucket_endpoint = get_var("R2_PUBLIC_BUCKET_ENDPOINT")?; + #[cfg(feature = "reports")] let cloudflare_id = get_var("CLOUDFLARE_ID")?; + #[cfg(feature = "reports")] let cloudflare_token = get_var("CLOUDFLARE_TOKEN")?; Ok(ENV.get_or_init(|| EnvConfig { @@ -107,21 +126,34 @@ pub fn init_env() -> Result<&'static EnvConfig> { client_url, admins, database_url, - otlp_endpoint, - otlp_token, - otlp_stream, discord_token, jwt_secret, + enable_registrations, client_secret, client_id, + #[cfg(feature = "otel")] + otlp_endpoint, + #[cfg(feature = "otel")] + otlp_token, + #[cfg(feature = "otel")] + otlp_stream, + #[cfg(feature = "mails")] smtp, + #[cfg(feature = "mails")] smtp_mail, + #[cfg(feature = "mails")] smtp_user, + #[cfg(feature = "mails")] smtp_password, + #[cfg(feature = "reports")] r2_bucket_name, + #[cfg(feature = "reports")] r2_account_id, + #[cfg(feature = "reports")] r2_public_bucket_endpoint, + #[cfg(feature = "reports")] cloudflare_id, + #[cfg(feature = "reports")] cloudflare_token, })) } diff --git a/src/domain/auth/mod.rs b/src/domain/auth/mod.rs index 3c5a0666..92b05f61 100644 --- a/src/domain/auth/mod.rs +++ b/src/domain/auth/mod.rs @@ -1,5 +1,8 @@ mod token; mod types; -pub use token::{Claims, decode_jwt, generate_bot_token, generate_jwt}; +pub use token::{ + Claims, decode_access_token, decode_refresh_token, generate_access_token, generate_bot_token, + generate_refresh_token, hash_refresh_token, +}; pub use types::{AuthContext, AuthType, Authorization}; diff --git a/src/domain/auth/token.rs b/src/domain/auth/token.rs index 5076b556..d83750fe 100644 --- a/src/domain/auth/token.rs +++ b/src/domain/auth/token.rs @@ -1,48 +1,89 @@ use anyhow::Result; use chrono::{Duration, Utc}; -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use ring::{ - aead, + aead, digest, rand::{SecureRandom, SystemRandom}, }; use serde::{Deserialize, Serialize}; -use crate::app_env; +use crate::{ + app_env, + utils::constants::{ACCESS_TOKEN_LIFETIME, REFRESH_TOKEN_LIFETIME}, +}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct Claims { - pub sub: String, - pub exp: usize, + pub sub: String, // user ID + pub sid: String, // session ID + pub iat: i64, // issued at + pub exp: usize, // expiration time +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RefreshClaims { + pub sub: String, // user ID + pub sid: String, // session ID + pub iat: i64, // issued at + pub exp: usize, // expiration time } -impl Claims { - pub fn new(user_id: String, exp_hours: u64) -> Self { - let exp = (Utc::now() + Duration::hours(exp_hours as i64)).timestamp() as usize; +pub fn generate_access_token(user_id: &str, session_id: &str) -> Result { + let now = Utc::now(); + let exp = now + Duration::seconds(ACCESS_TOKEN_LIFETIME); + + let claims = Claims { + sub: user_id.to_string(), + sid: session_id.to_string(), + iat: now.timestamp(), + exp: exp.timestamp() as usize, + }; - Self { sub: user_id, exp } - } + let encoding_key = EncodingKey::from_secret(app_env!().jwt_secret.as_bytes()); + encode(&Header::default(), &claims, &encoding_key) + .map_err(|e| anyhow::anyhow!("Failed to encode JWT: {:?}", e)) } -pub fn generate_jwt(user_id: &str) -> Result { - let claims = Claims::new(user_id.to_string(), 24 * 7); +pub fn generate_refresh_token(user_id: &str, session_id: &str) -> Result { + let now = Utc::now(); + let exp = now + Duration::seconds(REFRESH_TOKEN_LIFETIME); - let header = Header::default(); - let encoding_key = EncodingKey::from_secret(app_env!().jwt_secret.as_ref()); + let claims = RefreshClaims { + sub: user_id.to_string(), + sid: session_id.to_string(), + iat: now.timestamp(), + exp: exp.timestamp() as usize, + }; - jsonwebtoken::encode(&header, &claims, &encoding_key) - .map_err(|e| anyhow::anyhow!("Failed to generate JWT: {:?}", e)) + let encoding_key = EncodingKey::from_secret(app_env!().jwt_secret.as_bytes()); + encode(&Header::default(), &claims, &encoding_key) + .map_err(|e| anyhow::anyhow!("Failed to encode JWT: {:?}", e)) +} + +pub fn hash_refresh_token(token: &str) -> String { + let hash = digest::digest(&digest::SHA256, token.as_bytes()); + hex::encode(hash.as_ref()) +} + +pub fn decode_access_token(token: &str) -> Result { + let decoding_key = DecodingKey::from_secret(app_env!().jwt_secret.as_ref()); + let validation = Validation::default(); + + decode::(token, &decoding_key, &validation) + .map(|data| data.claims) + .map_err(|e| anyhow::anyhow!("Failed to decode JWT: {:?}", e)) } -pub fn decode_jwt(token: &str) -> Result { +pub fn decode_refresh_token(token: &str) -> Result { let decoding_key = DecodingKey::from_secret(app_env!().jwt_secret.as_ref()); let validation = Validation::default(); - jsonwebtoken::decode::(token, &decoding_key, &validation) + decode::(token, &decoding_key, &validation) .map(|data| data.claims) .map_err(|e| anyhow::anyhow!("Failed to decode JWT: {:?}", e)) } -pub fn generate_bot_token(user_id: &str) -> Result { +pub fn generate_bot_token(bot_id: &str) -> Result { let rng = SystemRandom::new(); let mut key = [0u8; 32]; rng.fill(&mut key) @@ -56,8 +97,8 @@ pub fn generate_bot_token(user_id: &str) -> Result { .map_err(|e| anyhow::anyhow!("Failed to create unbound key: {:?}", e))?; let sealing_key = aead::LessSafeKey::new(key); - let mut in_out = format!("token:{}", user_id).as_bytes().to_vec(); - in_out.extend_from_slice(&[0u8; 16]); // space for tag + let mut in_out = format!("token:{}", bot_id).as_bytes().to_vec(); + in_out.extend_from_slice(&[0u8; 16]); sealing_key .seal_in_place_append_tag( @@ -65,7 +106,7 @@ pub fn generate_bot_token(user_id: &str) -> Result { aead::Aad::empty(), &mut in_out, ) - .unwrap(); + .map_err(|e| anyhow::anyhow!("Failed to seal token: {:?}", e))?; Ok(hex::encode(&in_out)) } diff --git a/src/domain/auth/types.rs b/src/domain/auth/types.rs index bd6f5c94..55b70ba4 100644 --- a/src/domain/auth/types.rs +++ b/src/domain/auth/types.rs @@ -1,6 +1,9 @@ use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +use apistos::ApiComponent; +use schemars::JsonSchema; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, JsonSchema, ApiComponent)] pub enum AuthType { Admin, Bot, @@ -9,7 +12,7 @@ pub enum AuthType { } impl AuthType { - pub fn from_str(s: &str) -> Self { + pub fn parse_str(s: &str) -> Self { match s { "Admin" => AuthType::Admin, "Bot" => AuthType::Bot, @@ -18,7 +21,7 @@ impl AuthType { } } - pub fn as_str(&self) -> &'static str { + pub fn to_str(&self) -> &'static str { match self { AuthType::Admin => "Admin", AuthType::Bot => "Bot", @@ -30,7 +33,7 @@ impl AuthType { impl fmt::Display for AuthType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) + write!(f, "{}", self.to_str()) } } @@ -49,17 +52,19 @@ impl Authorization { } Some(Self { - auth_type: AuthType::from_str(parts[0]), + auth_type: AuthType::parse_str(parts[0]), token: parts[1].to_string(), }) } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, JsonSchema, ApiComponent)] pub struct AuthContext { pub auth_type: AuthType, pub user_id: Option, pub bot_id: Option, + pub session_id: Option, + pub token: Option, } impl AuthContext { @@ -68,6 +73,8 @@ impl AuthContext { auth_type, user_id: None, bot_id: None, + session_id: None, + token: None, } } @@ -81,6 +88,16 @@ impl AuthContext { self } + pub fn with_session_id(mut self, session_id: String) -> Self { + self.session_id = Some(session_id); + self + } + + pub fn with_token(mut self, token: String) -> Self { + self.token = Some(token); + self + } + pub fn is_admin(&self) -> bool { self.auth_type == AuthType::Admin } diff --git a/src/domain/error.rs b/src/domain/error.rs new file mode 100644 index 00000000..66e01b73 --- /dev/null +++ b/src/domain/error.rs @@ -0,0 +1,168 @@ +use std::fmt; + +use actix_web::{Error as ActixError, HttpResponse, ResponseError, http::StatusCode}; +use anyhow::Error as AnyError; +use apistos::ApiErrorComponent; +use mongodb::{bson::error::Error as BsonError, error::Error as MongoError}; +use reqwest::Error as ReqwestError; +use serde::{Deserialize, Serialize}; + +#[allow(clippy::duplicated_attributes)] +#[derive(Debug, Clone, Serialize, Deserialize, ApiErrorComponent)] +#[openapi_error( + status( + code = 400, + description = "Bad request due to invalid input or validation errors" + ), + status( + code = 401, + description = "Unauthorized access due to missing or invalid authentication" + ), + status( + code = 403, + description = "Forbidden access due to insufficient permissions" + ), + status(code = 404, description = "Resource not found"), + status(code = 409, description = "Conflict due to resource already existing"), + status(code = 429, description = "Too many requests due to rate limiting"), + status( + code = 500, + description = "Internal server error due to unexpected conditions" + ) +)] +pub enum ApiError { + // Database errors + DatabaseError(String), + NotFound(String), + AlreadyExists(String), + + // Auth errors + Unauthorized, + Forbidden, + InvalidToken, + TokenGenerationFailed, + MissingAuth, + + // Invitation errors + InvitationExpired, + InvitationAlreadyAccepted, + + /// Article errors + AlreadyPublished, + + // Validation errors + InvalidId, + InvalidInput(String), + ValidationError(String), + + // Business logic errors + BotSuspended, + BotUnsuspended, + UserSuspended, + UserUnsuspended, + LimitExceeded, + Conflict(String), + + // External service errors + StorageError(String), + WebhookError(String), + + // Generic + InternalError(String), +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ApiError::DatabaseError(msg) => write!(f, "Database error: {}", msg), + ApiError::NotFound(resource) => write!(f, "{} not found", resource), + ApiError::AlreadyExists(resource) => write!(f, "{} already exists", resource), + ApiError::Unauthorized => write!(f, "Unauthorized"), + ApiError::Forbidden => write!(f, "Forbidden"), + ApiError::InvalidToken => write!(f, "Invalid authentication token"), + ApiError::TokenGenerationFailed => write!(f, "Failed to generate authentication token"), + ApiError::MissingAuth => write!(f, "Authentication required"), + ApiError::InvitationExpired => write!(f, "Invitation has expired"), + ApiError::InvitationAlreadyAccepted => { + write!(f, "Invitation has already been accepted") + } + ApiError::AlreadyPublished => write!(f, "Article is already published"), + ApiError::InvalidId => write!(f, "Invalid ID format"), + ApiError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), + ApiError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + ApiError::BotSuspended => write!(f, "Bot is suspended"), + ApiError::BotUnsuspended => write!(f, "Bot is not suspended"), + ApiError::UserSuspended => write!(f, "User is suspended"), + ApiError::UserUnsuspended => write!(f, "User is not suspended"), + ApiError::LimitExceeded => write!(f, "Rate limit exceeded"), + ApiError::Conflict(msg) => write!(f, "Conflict: {}", msg), + ApiError::StorageError(msg) => write!(f, "Storage error: {}", msg), + ApiError::WebhookError(msg) => write!(f, "Webhook error: {}", msg), + _ => write!(f, "{:?}", self), + } + } +} + +impl ResponseError for ApiError { + fn status_code(&self) -> StatusCode { + match self { + ApiError::NotFound(_) => StatusCode::NOT_FOUND, + ApiError::Unauthorized | ApiError::InvalidToken | ApiError::MissingAuth => { + StatusCode::UNAUTHORIZED + } + ApiError::Forbidden => StatusCode::FORBIDDEN, + ApiError::InvalidId + | ApiError::InvalidInput(_) + | ApiError::ValidationError(_) + | ApiError::InvitationExpired + | ApiError::InvitationAlreadyAccepted + | ApiError::BotSuspended + | ApiError::BotUnsuspended + | ApiError::UserSuspended + | ApiError::UserUnsuspended + | ApiError::AlreadyPublished => StatusCode::BAD_REQUEST, + ApiError::AlreadyExists(_) | ApiError::Conflict(_) => StatusCode::CONFLICT, + ApiError::LimitExceeded => StatusCode::TOO_MANY_REQUESTS, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(serde_json::json!({ + "error": self.to_string(), + "status": self.status_code().as_u16(), + })) + } +} + +impl From for ApiError { + fn from(err: MongoError) -> Self { + ApiError::DatabaseError(err.to_string()) + } +} + +impl From for ApiError { + fn from(err: BsonError) -> Self { + ApiError::DatabaseError(err.to_string()) + } +} + +impl From for ApiError { + fn from(err: ActixError) -> Self { + ApiError::InternalError(err.to_string()) + } +} + +impl From for ApiError { + fn from(err: ReqwestError) -> Self { + ApiError::InternalError(err.to_string()) + } +} + +impl From for ApiError { + fn from(err: AnyError) -> Self { + ApiError::InternalError(err.to_string()) + } +} + +pub type ApiResult = Result; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index a954b4db..72b754ac 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,2 +1,3 @@ pub mod auth; +pub mod error; pub mod models; diff --git a/src/domain/models/achievement.rs b/src/domain/models/achievement.rs index 35bfbbe3..4b895325 100644 --- a/src/domain/models/achievement.rs +++ b/src/domain/models/achievement.rs @@ -1,7 +1,10 @@ +use apistos::ApiComponent; use mongodb::bson::{DateTime, oid::ObjectId}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct Achievement { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, @@ -14,75 +17,50 @@ pub struct Achievement { pub from: Option, pub lang: Option, pub objective: AchievementObjective, - pub shared: Option, + pub shared: bool, pub title: String, pub title_i18n: Option, - pub used_by: Option, + pub used_by: i64, } impl Achievement { - pub fn with_achieved_on(mut self, achieved_on: Option) -> Self { - self.achieved_on = achieved_on; - self - } - - pub fn with_bot_id(mut self, bot_id: String) -> Self { - self.bot_id = bot_id; - self - } - - pub fn with_current(mut self, current: Option) -> Self { - self.current = current; - self - } - - pub fn with_description(mut self, description: String) -> Self { - self.description = description; - self - } - - pub fn with_description_i18n(mut self, description_i18n: Option) -> Self { - self.description_i18n = description_i18n; - self - } - - pub fn with_editable(mut self, editable: bool) -> Self { - self.editable = editable; - self - } - - pub fn with_from(mut self, from: Option) -> Self { - self.from = from; - self - } - - pub fn with_lang(mut self, lang: Option) -> Self { - self.lang = lang; - self - } - - pub fn with_objective(mut self, objective: AchievementObjective) -> Self { - self.objective = objective; - self - } - - pub fn with_shared(mut self, shared: Option) -> Self { - self.shared = shared; - self + pub fn new( + bot_id: &str, + description: &str, + title: &str, + editable: bool, + objective: AchievementObjective, + ) -> Self { + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: None, + description: description.to_owned(), + description_i18n: None, + editable, + from: None, + lang: None, + objective, + shared: false, + title: title.to_owned(), + title_i18n: None, + used_by: 0, + } } - pub fn with_title(mut self, title: String) -> Self { - self.title = title; + pub fn with_description_i18n(mut self, description_i18n: &str) -> Self { + self.description_i18n = Some(description_i18n.to_owned()); self } - pub fn with_title_i18n(mut self, title_i18n: Option) -> Self { - self.title_i18n = title_i18n; + pub fn with_from(mut self, from: &str) -> Self { + self.from = Some(from.to_owned()); self } - pub fn with_used_by(mut self, used_by: Option) -> Self { - self.used_by = used_by; + pub fn with_title_i18n(mut self, title_i18n: &str) -> Self { + self.title_i18n = Some(title_i18n.to_owned()); self } @@ -92,35 +70,464 @@ impl Achievement { None => false, } } + + pub fn defaults(bot_id: &str) -> Vec { + vec![ + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Be on 75 servers.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.1.description" + .to_owned(), + ), + editable: false, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::GuildCount, + value: 75, + }, + shared: true, + title: "#RoadToCertification".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.1.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have an average of at least 10 interactions over the 7 days." + .to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.2.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::InteractionAverageWeek, + value: 50, + }, + shared: true, + title: "Get started with interactions".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.2.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have at least 10% of French-speaking users.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.3.description" + .to_owned(), + ), + editable: false, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::FrenchPercentage, + value: 10, + }, + shared: true, + title: "French power 🥖".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.3.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Be on 300 servers.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.4.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::GuildCount, + value: 300, + }, + shared: true, + title: "Medium bot".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.4.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Use Discord Analytics for at least a year.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.5.description" + .to_owned(), + ), + editable: false, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::JoinedDa, + value: 31556952000, + }, + shared: true, + title: "Old member".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.5.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have users who speak at least 15 different languages.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.6.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::UsersLocales, + value: 15, + }, + shared: true, + title: "Diversified users".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.6.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Be on 1k servers.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.7.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::GuildCount, + value: 1000, + }, + shared: true, + title: "Big bot".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.7.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have an average of at least 1k interactions over the 7 days." + .to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.8.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::InteractionAverageWeek, + value: 1000, + }, + shared: true, + title: "In search of popularity".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.8.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have at least 100k users.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.9.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::UserCount, + value: 100000, + }, + shared: true, + title: "Oh! Big servers".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.9.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have at least 1M users.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.10.description" + .to_owned(), + ), + from: Some("DiscordAnalytics".to_owned()), + lang: None, + editable: true, + objective: AchievementObjective { + achievement_type: AchievementType::UserCount, + value: 1000000, + }, + shared: true, + title: "Big servers + {username} = ♥".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.10.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Be on 10k servers.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.11.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::GuildCount, + value: 10000, + }, + shared: true, + title: "Huge bot".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.11.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have an average of at least 20k interactions over the 7 days." + .to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.12.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::InteractionAverageWeek, + value: 20000, + }, + shared: true, + title: "Too popular for you".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.12.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have an average of at least 50k interactions over the 7 days." + .to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.13.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::UserCount, + value: 50000, + }, + shared: true, + title: "Explosion of interactions".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.13.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have at least 10k users.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.14.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::UserCount, + value: 10000, + }, + shared: true, + title: "Just a little for me".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.14.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Finish Discord Analytics' configuration.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.15.description" + .to_owned(), + ), + editable: false, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::BotConfigured, + value: 1, + }, + shared: true, + title: "All the stats for me".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.15.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Use Discord Analytics for at least a month.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.16.description" + .to_owned(), + ), + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::JoinedDa, + value: 2629746000, + }, + shared: true, + title: "Oh! That's nice".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.16.title".to_owned(), + ), + editable: true, + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Be on 10 servers.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.17.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::GuildCount, + value: 10, + }, + shared: true, + title: "Bot on the move".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.17.title".to_owned(), + ), + used_by: 0, + }, + Self { + id: None, + achieved_on: None, + bot_id: bot_id.to_owned(), + current: Some(0), + description: "Have received at least 20 votes over the 30 days.".to_owned(), + description_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.18.description" + .to_owned(), + ), + editable: true, + from: Some("DiscordAnalytics".to_owned()), + lang: None, + objective: AchievementObjective { + achievement_type: AchievementType::VotesCount, + value: 20, + }, + shared: true, + title: "Votes operation".to_owned(), + title_i18n: Some( + "pages.dashboard.bots.achievements.default_achievements.18.title".to_owned(), + ), + used_by: 0, + }, + ] + } } -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ApiComponent, JsonSchema)] pub struct AchievementObjective { - pub value: i64, #[serde(rename = "type")] pub achievement_type: AchievementType, + pub value: i64, } -impl AchievementObjective { - pub fn with_value(mut self, value: i64) -> Self { - self.value = value; - self - } - - pub fn with_achievement_type(mut self, achievement_type: AchievementType) -> Self { - self.achievement_type = achievement_type; - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ApiComponent, JsonSchema)] pub enum AchievementType { + BotConfigured, + FrenchPercentage, GuildCount, InteractionAverageWeek, - FrenchPercentage, JoinedDa, - UsersLocales, UserCount, - BotConfigured, + UsersLocales, VotesCount, } diff --git a/src/domain/models/blog_article.rs b/src/domain/models/blog_article.rs index d37dd2f2..cd3a95a3 100644 --- a/src/domain/models/blog_article.rs +++ b/src/domain/models/blog_article.rs @@ -1,73 +1,59 @@ +use anyhow::Result; use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct BlogArticle { - #[serde(rename = "authorId")] pub author_id: String, - #[serde(rename = "articleId")] pub article_id: String, pub content: String, pub cover: Option, - #[serde(rename = "createdAt")] pub created_at: DateTime, pub description: String, - #[serde(rename = "isDraft")] pub is_draft: bool, pub tags: Vec, pub title: String, - #[serde(rename = "updatedAt")] pub updated_at: Option, } impl BlogArticle { - pub fn with_author_id(mut self, author_id: String) -> Self { - self.author_id = author_id; - self - } - - pub fn with_article_id(mut self, article_id: String) -> Self { - self.article_id = article_id; - self - } - - pub fn with_content(mut self, content: String) -> Self { - self.content = content; - self - } - - pub fn with_cover(mut self, cover: Option) -> Self { - self.cover = cover; - self - } - - pub fn with_created_at(mut self, created_at: DateTime) -> Self { - self.created_at = created_at; - self - } - - pub fn with_description(mut self, description: String) -> Self { - self.description = description; - self - } - - pub fn with_is_draft(mut self, is_draft: bool) -> Self { - self.is_draft = is_draft; - self - } - - pub fn with_tags(mut self, tags: Vec) -> Self { - self.tags = tags; - self - } - - pub fn with_title(mut self, title: String) -> Self { - self.title = title; - self - } - - pub fn with_updated_at(mut self, updated_at: Option) -> Self { - self.updated_at = updated_at; - self + pub fn new( + author_id: &str, + content: &str, + description: &str, + tags: Vec, + title: &str, + ) -> Result { + Ok(Self { + author_id: author_id.to_string(), + article_id: Self::generate_article_id(title)?, + content: content.to_string(), + cover: None, + created_at: DateTime::now(), + description: description.to_string(), + is_draft: true, + tags, + title: title.to_string(), + updated_at: None, + }) + } + + pub fn with_cover(mut self, cover: &str) -> Self { + self.cover = Some(cover.to_string()); + self + } + + pub fn generate_article_id(title: &str) -> Result { + let timestamp = DateTime::now().try_to_rfc3339_string()?; + let sanitized_title = title + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace()) + .map(|c| if c.is_whitespace() { '-' } else { c }) + .collect::(); + + let article_id = format!("{}-{}", timestamp, sanitized_title); + Ok(article_id.chars().take(100).collect()) } } diff --git a/src/domain/models/bot.rs b/src/domain/models/bot.rs index 964d3f38..b49b4309 100644 --- a/src/domain/models/bot.rs +++ b/src/domain/models/bot.rs @@ -1,112 +1,61 @@ +use std::collections::HashMap; + +use apistos::ApiComponent; use mongodb::bson::DateTime; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct Bot { - #[serde(rename = "advancedStats")] pub advanced_stats: bool, pub avatar: Option, - #[serde(rename = "botId")] pub bot_id: String, pub framework: Option, - pub goals_limit: Option, + pub goals_limit: i32, pub language: Option, - #[serde(rename = "lastPush")] pub last_push: Option, - #[serde(rename = "ownerId")] pub owner_id: String, pub suspended: bool, pub team: Vec, pub(crate) token: String, pub username: String, pub version: Option, - #[serde(rename = "votesWebhookUrl")] - pub votes_webhook_url: Option, - #[serde(rename = "warnLevel")] - pub warn_level: Option, - #[serde(rename = "watchedSince")] - pub watched_since: Option, + pub warn_level: i32, + pub watched_since: DateTime, + pub webhooks_config: WebhooksConfig, } impl Bot { - pub fn with_advanced_stats(mut self, advanced_stats: bool) -> Self { - self.advanced_stats = advanced_stats; - self - } - - pub fn with_avatar(mut self, avatar: Option) -> Self { - self.avatar = avatar; - self - } - - pub fn with_bot_id(mut self, bot_id: String) -> Self { - self.bot_id = bot_id; - self - } - - pub fn with_framework(mut self, framework: Framework) -> Self { - self.framework = Some(framework.into()); - self - } - - pub fn with_goals_limit(mut self, goals_limit: Option) -> Self { - self.goals_limit = goals_limit; - self - } - - pub fn with_language(mut self, language: Language) -> Self { - self.language = Some(language.into()); - self - } - - pub fn with_last_push(mut self, last_push: Option) -> Self { - self.last_push = last_push; - self - } - - pub fn with_owner_id(mut self, owner_id: String) -> Self { - self.owner_id = owner_id; - self - } - - pub fn with_suspended(mut self, suspended: bool) -> Self { - self.suspended = suspended; - self - } - - pub fn with_team(mut self, team: Vec) -> Self { - self.team = team; - self - } - - pub fn with_token(mut self, token: String) -> Self { - self.token = token; - self - } - - pub fn with_username(mut self, username: String) -> Self { - self.username = username; - self - } - - pub fn with_version(mut self, version: Option) -> Self { - self.version = version; - self - } - - pub fn with_votes_webhook_url(mut self, votes_webhook_url: Option) -> Self { - self.votes_webhook_url = votes_webhook_url; - self - } - - pub fn with_warn_level(mut self, warn_level: Option) -> Self { - self.warn_level = warn_level; - self + pub fn new( + bot_id: &str, + owner_id: &str, + token: String, + username: &str, + avatar: Option<&str>, + ) -> Self { + Self { + advanced_stats: false, + avatar: avatar.map(|s| s.to_string()), + bot_id: bot_id.to_string(), + framework: None, + goals_limit: 30, + language: None, + last_push: None, + owner_id: owner_id.to_string(), + suspended: false, + team: Vec::new(), + token, + username: username.to_string(), + version: None, + warn_level: 0, + watched_since: DateTime::now(), + webhooks_config: WebhooksConfig::default(), + } } - pub fn with_watched_since(mut self, watched_since: Option) -> Self { - self.watched_since = watched_since; - self + pub fn token(self) -> String { + self.token } pub fn is_owner(&self, user_id: &str) -> bool { @@ -120,18 +69,6 @@ impl Bot { pub fn has_access(&self, user_id: &str) -> bool { self.is_owner(user_id) || self.is_team_member(user_id) } - - pub fn add_team_member(mut self, user_id: String) -> Self { - if !self.team.contains(&user_id) { - self.team.push(user_id); - } - self - } - - pub fn remove_team_member(mut self, user_id: &str) -> Self { - self.team.retain(|id| id != user_id); - self - } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -146,7 +83,7 @@ pub enum Framework { } impl Framework { - pub fn from_str(lib: &str) -> Framework { + pub fn parse_str(lib: &str) -> Framework { match lib { "discord.py" => Framework::DiscordPy, "pycord" => Framework::PyCord, @@ -158,7 +95,7 @@ impl Framework { } } - pub fn as_str(&self) -> &str { + pub fn to_str(&self) -> &str { match self { Framework::DiscordPy => "discord.py", Framework::PyCord => "pycord", @@ -173,7 +110,7 @@ impl Framework { impl From for String { fn from(framework: Framework) -> Self { - framework.as_str().to_string() + framework.to_str().to_string() } } @@ -185,7 +122,7 @@ pub enum Language { } impl Language { - pub fn from_str(lang: &str) -> Language { + pub fn parse_str(lang: &str) -> Language { match lang { "python" => Language::Python, "javascript" => Language::JavaScript, @@ -193,7 +130,7 @@ impl Language { } } - pub fn as_str(&self) -> &str { + pub fn to_str(&self) -> &str { match self { Language::Python => "python", Language::JavaScript => "javascript", @@ -204,6 +141,23 @@ impl Language { impl From for String { fn from(language: Language) -> Self { - language.as_str().to_string() + language.to_str().to_string() } } + +#[derive( + Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, ApiComponent, JsonSchema, +)] +#[serde(rename_all = "camelCase")] +pub struct WebhooksConfig { + pub webhook_url: Option, + #[serde(flatten)] + pub webhooks: HashMap, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct WebhookConfig { + pub connection_id: Option, + pub webhook_secret: Option, +} diff --git a/src/domain/models/bot_stats.rs b/src/domain/models/bot_stats.rs index 414c8b35..12a812c5 100644 --- a/src/domain/models/bot_stats.rs +++ b/src/domain/models/bot_stats.rs @@ -1,109 +1,32 @@ -use std::collections::HashMap; +use std::{collections::HashMap, vec::IntoIter}; +use apistos::ApiComponent; use mongodb::bson::DateTime; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct BotStats { - #[serde(rename = "addedGuilds")] pub added_guilds: i32, - #[serde(rename = "botId")] pub bot_id: String, pub custom_events: Option>, pub date: DateTime, pub guilds: Option>, - #[serde(rename = "guildCount")] pub guild_count: i32, - #[serde(rename = "guildLocales")] pub guild_locales: Vec, - #[serde(rename = "guildMembers")] pub guild_members: GuildMembers, pub interactions: Vec, - #[serde(rename = "interactionsLocales")] pub interactions_locales: Vec, - #[serde(rename = "removedGuilds")] pub removed_guilds: i32, - #[serde(rename = "userCount")] pub user_count: i32, pub user_install_count: Option, pub users_type: Option, } -impl BotStats { - pub fn with_added_guilds(mut self, added_guilds: i32) -> Self { - self.added_guilds = added_guilds; - self - } - - pub fn with_bot_id(mut self, bot_id: String) -> Self { - self.bot_id = bot_id; - self - } - - pub fn with_custom_events(mut self, custom_events: HashMap) -> Self { - self.custom_events = Some(custom_events); - self - } - - pub fn with_date(mut self, date: DateTime) -> Self { - self.date = date; - self - } - - pub fn with_guilds(mut self, guilds: Vec) -> Self { - self.guilds = Some(guilds); - self - } - - pub fn with_guild_count(mut self, guild_count: i32) -> Self { - self.guild_count = guild_count; - self - } - - pub fn with_guild_locales(mut self, guild_locales: Vec) -> Self { - self.guild_locales = guild_locales; - self - } - - pub fn with_guild_members(mut self, guild_members: GuildMembers) -> Self { - self.guild_members = guild_members; - self - } - - pub fn with_interactions(mut self, interactions: Vec) -> Self { - self.interactions = interactions; - self - } - - pub fn with_interactions_locales(mut self, interactions_locales: Vec) -> Self { - self.interactions_locales = interactions_locales; - self - } - - pub fn with_removed_guilds(mut self, removed_guilds: i32) -> Self { - self.removed_guilds = removed_guilds; - self - } - - pub fn with_user_count(mut self, user_count: i32) -> Self { - self.user_count = user_count; - self - } - - pub fn with_user_install_count(mut self, user_install_count: i32) -> Self { - self.user_install_count = Some(user_install_count); - self - } - - pub fn with_users_type(mut self, users_type: UserType) -> Self { - self.users_type = Some(users_type); - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct Guild { - #[serde(rename = "guildId")] pub guild_id: String, pub icon: Option, pub interactions: i32, @@ -111,34 +34,7 @@ pub struct Guild { pub name: String, } -impl Guild { - pub fn with_guild_id(mut self, guild_id: String) -> Self { - self.guild_id = guild_id; - self - } - - pub fn with_icon(mut self, icon: Option) -> Self { - self.icon = icon; - self - } - - pub fn with_interactions(mut self, interactions: i32) -> Self { - self.interactions = interactions; - self - } - - pub fn with_members(mut self, members: i32) -> Self { - self.members = members; - self - } - - pub fn with_name(mut self, name: String) -> Self { - self.name = name; - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ApiComponent, JsonSchema)] pub struct GuildMembers { pub little: i32, pub medium: i32, @@ -146,29 +42,23 @@ pub struct GuildMembers { pub huge: i32, } -impl GuildMembers { - pub fn with_little(mut self, little: i32) -> Self { - self.little = little; - self - } - - pub fn with_medium(mut self, medium: i32) -> Self { - self.medium = medium; - self - } - - pub fn with_big(mut self, big: i32) -> Self { - self.big = big; - self - } +impl IntoIterator for GuildMembers { + type Item = (String, i32); + type IntoIter = IntoIter; - pub fn with_huge(mut self, huge: i32) -> Self { - self.huge = huge; - self + fn into_iter(self) -> Self::IntoIter { + vec![ + ("little".to_string(), self.little), + ("medium".to_string(), self.medium), + ("big".to_string(), self.big), + ("huge".to_string(), self.huge), + ] + .into_iter() } } -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct Interaction { pub command_type: Option, pub name: String, @@ -177,47 +67,14 @@ pub struct Interaction { pub type_: i32, } -impl Interaction { - pub fn with_command_type(mut self, command_type: Option) -> Self { - self.command_type = command_type; - self - } - - pub fn with_name(mut self, name: String) -> Self { - self.name = name; - self - } - - pub fn with_number(mut self, number: i32) -> Self { - self.number = number; - self - } - - pub fn with_type(mut self, type_: i32) -> Self { - self.type_ = type_; - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ApiComponent, JsonSchema)] pub struct Locale { pub locale: String, pub number: i32, } -impl Locale { - pub fn with_locale(mut self, locale: String) -> Self { - self.locale = locale; - self - } - - pub fn with_number(mut self, number: i32) -> Self { - self.number = number; - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct UserType { pub admin: i32, pub moderator: i32, @@ -226,29 +83,18 @@ pub struct UserType { pub private_message: i32, } -impl UserType { - pub fn with_admin(mut self, admin: i32) -> Self { - self.admin = admin; - self - } - - pub fn with_moderator(mut self, moderator: i32) -> Self { - self.moderator = moderator; - self - } - - pub fn with_new_member(mut self, new_member: i32) -> Self { - self.new_member = new_member; - self - } - - pub fn with_other(mut self, other: i32) -> Self { - self.other = other; - self - } - - pub fn with_private_message(mut self, private_message: i32) -> Self { - self.private_message = private_message; - self +impl IntoIterator for UserType { + type Item = (String, i32); + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + ("admin".to_string(), self.admin), + ("moderator".to_string(), self.moderator), + ("newMember".to_string(), self.new_member), + ("other".to_string(), self.other), + ("privateMessage".to_string(), self.private_message), + ] + .into_iter() } } diff --git a/src/domain/models/custom_event.rs b/src/domain/models/custom_event.rs index 4e6bb7f2..c67a5305 100644 --- a/src/domain/models/custom_event.rs +++ b/src/domain/models/custom_event.rs @@ -1,25 +1,10 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct CustomEvent { pub bot_id: String, + pub default_value: Option, pub event_key: String, pub graph_name: String, } - -impl CustomEvent { - pub fn with_bot_id(mut self, bot_id: String) -> Self { - self.bot_id = bot_id; - self - } - - pub fn with_event_key(mut self, event_key: String) -> Self { - self.event_key = event_key; - self - } - - pub fn with_graph_name(mut self, graph_name: String) -> Self { - self.graph_name = graph_name; - self - } -} diff --git a/src/domain/models/global_stats.rs b/src/domain/models/global_stats.rs index a69076eb..5b757b78 100644 --- a/src/domain/models/global_stats.rs +++ b/src/domain/models/global_stats.rs @@ -2,41 +2,10 @@ use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct GlobalStats { - #[serde(rename = "botCount")] pub bot_count: i32, pub date: DateTime, - #[serde(rename = "logsEntryCount")] - pub logs_entry_count: i32, - #[serde(rename = "registeredBots")] pub registered_bots: i32, - #[serde(rename = "userCount")] pub user_count: i32, } - -impl GlobalStats { - pub fn with_bot_count(mut self, bot_count: i32) -> Self { - self.bot_count = bot_count; - self - } - - pub fn with_date(mut self, date: DateTime) -> Self { - self.date = date; - self - } - - pub fn with_logs_entry_count(mut self, logs_entry_count: i32) -> Self { - self.logs_entry_count = logs_entry_count; - self - } - - pub fn with_registered_bots(mut self, registered_bots: i32) -> Self { - self.registered_bots = registered_bots; - self - } - - pub fn with_user_count(mut self, user_count: i32) -> Self { - self.user_count = user_count; - self - } -} diff --git a/src/domain/models/mod.rs b/src/domain/models/mod.rs index bffc7ef1..54c40197 100644 --- a/src/domain/models/mod.rs +++ b/src/domain/models/mod.rs @@ -4,6 +4,7 @@ mod bot; mod bot_stats; mod custom_event; mod global_stats; +mod session; mod stats_report; mod team_invitation; mod user; @@ -12,10 +13,11 @@ mod webhook; pub use achievement::{Achievement, AchievementObjective, AchievementType}; pub use blog_article::BlogArticle; -pub use bot::{Bot, Framework, Language}; +pub use bot::{Bot, Framework, Language, WebhookConfig, WebhooksConfig}; pub use bot_stats::{BotStats, Guild, GuildMembers, Interaction, Locale, UserType}; pub use custom_event::CustomEvent; pub use global_stats::GlobalStats; +pub use session::Session; pub use stats_report::{StatsReport, StatsReportFrequency}; pub use team_invitation::TeamInvitation; pub use user::User; diff --git a/src/domain/models/session.rs b/src/domain/models/session.rs new file mode 100644 index 00000000..c370f7ea --- /dev/null +++ b/src/domain/models/session.rs @@ -0,0 +1,59 @@ +use mongodb::bson::DateTime; +use serde::{Deserialize, Serialize}; + +use crate::utils::constants::REFRESH_TOKEN_LIFETIME; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Session { + pub active: bool, + pub created_at: DateTime, + pub device_info: Option, + pub expires_at: DateTime, + pub ip_address: Option, + pub last_used_at: DateTime, + pub refresh_token_hash: String, + pub session_id: String, + pub user_agent: Option, + pub user_id: String, +} + +impl Session { + pub fn new(user_id: String, refresh_token_hash: String, session_id: String) -> Self { + let now = DateTime::now(); + let expires_at = + DateTime::from_millis(now.timestamp_millis() + (REFRESH_TOKEN_LIFETIME * 1000)); + + Self { + active: true, + created_at: now, + device_info: None, + expires_at, + ip_address: None, + last_used_at: now, + refresh_token_hash, + session_id, + user_agent: None, + user_id, + } + } + + pub fn with_device_info(mut self, device_info: String) -> Self { + self.device_info = Some(device_info); + self + } + + pub fn with_ip(mut self, ip: String) -> Self { + self.ip_address = Some(ip); + self + } + + pub fn with_user_agent(mut self, user_agent: String) -> Self { + self.user_agent = Some(user_agent); + self + } + + pub fn is_expired(&self) -> bool { + self.expires_at < DateTime::now() + } +} diff --git a/src/domain/models/stats_report.rs b/src/domain/models/stats_report.rs index 5fd6b884..3ccaa769 100644 --- a/src/domain/models/stats_report.rs +++ b/src/domain/models/stats_report.rs @@ -2,36 +2,15 @@ use mongodb::bson::oid::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct StatsReport { - #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, + #[serde(rename = "_id")] + pub id: ObjectId, pub bot_id: String, pub frequency: String, pub user_id: String, } -impl StatsReport { - pub fn with_id(mut self, id: ObjectId) -> Self { - self.id = Some(id); - self - } - - pub fn with_bot_id(mut self, bot_id: String) -> Self { - self.bot_id = bot_id; - self - } - - pub fn with_frequency(mut self, frequency: StatsReportFrequency) -> Self { - self.frequency = frequency.into(); - self - } - - pub fn with_user_id(mut self, user_id: String) -> Self { - self.user_id = user_id; - self - } -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum StatsReportFrequency { Weekly, diff --git a/src/domain/models/team_invitation.rs b/src/domain/models/team_invitation.rs index 02d16ed7..93cb2d18 100644 --- a/src/domain/models/team_invitation.rs +++ b/src/domain/models/team_invitation.rs @@ -1,7 +1,9 @@ -use mongodb::bson::DateTime; +use chrono::{Duration, Utc}; +use mongodb::bson::{DateTime, Uuid}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct TeamInvitation { pub accepted: bool, pub bot_id: String, @@ -11,28 +13,20 @@ pub struct TeamInvitation { } impl TeamInvitation { - pub fn with_accepted(mut self, accepted: bool) -> Self { - self.accepted = accepted; - self - } - - pub fn with_bot_id(mut self, bot_id: String) -> Self { - self.bot_id = bot_id; - self - } - - pub fn with_expiration(mut self, expiration: DateTime) -> Self { - self.expiration = expiration; - self - } + pub fn new(bot_id: &str, user_id: &str) -> Self { + let expiration = Utc::now() + Duration::days(7); + let datetime_expiration = DateTime::from_millis(expiration.timestamp_millis()); - pub fn with_invitation_id(mut self, invitation_id: String) -> Self { - self.invitation_id = invitation_id; - self + Self { + accepted: false, + bot_id: bot_id.to_string(), + expiration: datetime_expiration, + invitation_id: Uuid::new().to_string(), + user_id: user_id.to_string(), + } } - pub fn with_user_id(mut self, user_id: String) -> Self { - self.user_id = user_id; - self + pub fn is_expired(&self) -> bool { + self.expiration < DateTime::now() } } diff --git a/src/domain/models/user.rs b/src/domain/models/user.rs index 06974817..83963881 100644 --- a/src/domain/models/user.rs +++ b/src/domain/models/user.rs @@ -2,71 +2,15 @@ use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct User { - pub avatar: String, + pub avatar: Option, pub avatar_decoration: Option, - pub banned: bool, - #[serde(rename = "botsLimit")] pub bots_limit: i32, - #[serde(rename = "createdAt")] pub created_at: DateTime, - #[serde(rename = "joinedAt")] pub joined_at: DateTime, pub mail: String, - pub(crate) token: String, + pub suspended: bool, pub username: String, - #[serde(rename = "userId")] pub user_id: String, } - -impl User { - pub fn with_avatar(mut self, avatar: String) -> Self { - self.avatar = avatar; - self - } - - pub fn with_avatar_decoration(mut self, avatar_decoration: Option) -> Self { - self.avatar_decoration = avatar_decoration; - self - } - - pub fn with_banned(mut self, banned: bool) -> Self { - self.banned = banned; - self - } - - pub fn with_bots_limit(mut self, bots_limit: i32) -> Self { - self.bots_limit = bots_limit; - self - } - - pub fn with_created_at(mut self, created_at: DateTime) -> Self { - self.created_at = created_at; - self - } - - pub fn with_joined_at(mut self, joined_at: DateTime) -> Self { - self.joined_at = joined_at; - self - } - - pub fn with_mail(mut self, mail: String) -> Self { - self.mail = mail; - self - } - - pub fn with_token(mut self, token: String) -> Self { - self.token = token; - self - } - - pub fn with_username(mut self, username: String) -> Self { - self.username = username; - self - } - - pub fn with_user_id(mut self, user_id: String) -> Self { - self.user_id = user_id; - self - } -} diff --git a/src/domain/models/vote.rs b/src/domain/models/vote.rs index d1989d09..4a547b1d 100644 --- a/src/domain/models/vote.rs +++ b/src/domain/models/vote.rs @@ -1,33 +1,23 @@ +use std::collections::HashMap; + use mongodb::bson::DateTime; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct Vote { - #[serde(rename = "botId")] pub bot_id: String, - pub count: i32, pub date: DateTime, - pub provider: String, + #[serde(flatten)] + pub votes: HashMap, } impl Vote { - pub fn with_bot_id(mut self, bot_id: String) -> Self { - self.bot_id = bot_id; - self - } - - pub fn with_count(mut self, count: i32) -> Self { - self.count = count; - self - } - - pub fn with_date(mut self, date: DateTime) -> Self { - self.date = date; - self - } - - pub fn with_provider(mut self, provider: String) -> Self { - self.provider = provider; - self + pub fn new(bot_id: String, date: DateTime) -> Self { + Self { + bot_id, + date, + votes: HashMap::new(), + } } } diff --git a/src/domain/models/webhook.rs b/src/domain/models/webhook.rs index 38831d37..4de25a26 100644 --- a/src/domain/models/webhook.rs +++ b/src/domain/models/webhook.rs @@ -14,7 +14,7 @@ pub enum Provider { } impl Provider { - pub fn as_str(&self) -> &'static str { + pub fn to_str(&self) -> &'static str { match self { Provider::TopGG => "topgg", Provider::DiscordList => "discordlist", @@ -26,7 +26,7 @@ impl Provider { } } - pub fn from_str(provider: &str) -> Self { + pub fn parse_str(provider: &str) -> Self { match provider { "topgg" => Provider::TopGG, "discordlist" => Provider::DiscordList, @@ -40,6 +40,7 @@ impl Provider { } #[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct WebhookData { pub bot_id: String, pub voter_id: String, @@ -49,12 +50,13 @@ pub struct WebhookData { } #[derive(Clone, Serialize)] -pub struct WebhookSendData { - pub bot_id: String, - pub voter_id: String, - pub provider: String, +#[serde(rename_all = "camelCase")] +pub struct WebhookSendData<'a> { + pub bot_id: &'a str, + pub voter_id: &'a str, + pub provider: &'a str, pub date: DateTime, - pub raw_data: Option, + pub raw_data: Option<&'a Value>, pub content: Option, } diff --git a/src/lib.rs b/src/lib.rs index df1d3e20..033be482 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod config; pub mod domain; pub mod managers; +pub mod openapi; pub mod repository; pub mod services; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 9daca274..7083addf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,28 @@ -use std::sync::Arc; +use std::{net::Ipv4Addr, sync::Arc}; use actix_cors::Cors; -use actix_web::{App, HttpServer, dev::Service, http, web}; +use actix_web::{App, HttpServer, http, rt, web::Data}; use anyhow::Result; -use tokio::{sync::Mutex, try_join}; -use tracing::{Level, info}; +use apistos::app::OpenApiWrapper; +use tokio::{ + spawn, + sync::Mutex, + time::{Duration, interval}, + try_join, +}; +use tracing::{Level, error, info}; +use tracing_actix_web::TracingLogger; use api::{ - api::middleware::AuthMiddleware, + api::{middleware::AuthMiddleware, routes}, app_env, config::env::init_env, - managers::webhook::VotesWebhooksManager, + managers::{ChatServer, VotesWebhooksManager}, + openapi::build_spec, repository::Repositories, services::Services, utils::logger::{LogCode, Logger}, }; -use tracing_actix_web::TracingLogger; #[actix_web::main] async fn main() -> Result<()> { @@ -48,22 +55,70 @@ async fn main() -> Result<()> { "Repositories initialized", ); + let repos_clone = repos.clone(); + rt::spawn(async move { + let repos_clone = repos_clone.clone(); + let mut interval = interval(Duration::from_secs(3600)); + + loop { + interval.tick().await; + + match repos_clone.sessions.delete_expired().await { + Ok(deleted_count) => info!( + code = %LogCode::Server, + deleted_count = %deleted_count, + "Deleted expired sessions", + ), + Err(e) => error!( + code = %LogCode::Server, + error = %e, + "Failed to delete expired sessions" + ), + } + + match repos_clone + .team_invitations + .delete_expired_invitations() + .await + { + Ok(deleted_count) => info!( + code = %LogCode::Server, + deleted_count = %deleted_count, + "Deleted expired votes", + ), + Err(e) => error!( + code = %LogCode::Server, + error = %e, + "Failed to delete expired votes" + ), + } + } + }); + let services = Services::new(repos.clone()); info!( code = %LogCode::Server, "Services initialized", ); - let votes_webhooks_manager = web::Data::new(Arc::new(Mutex::new(VotesWebhooksManager::new()))); + let votes_webhooks_manager = Data::new(Arc::new(Mutex::new(VotesWebhooksManager::new()))); info!( code = %LogCode::Server, "VotesWebhooksManager initialized", ); + let (chat_server, chat_server_handle) = ChatServer::new(); + let chat_server = spawn(chat_server.run()); + let chat_server_handle = Data::new(chat_server_handle); + info!( + code = %LogCode::Server, + "ChatServer initialized", + ); + let http_server = HttpServer::new(move || { let cors = Cors::default() .allowed_origin(&app_env!().client_url) - .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "PATCH"]) + .allowed_methods(vec!["GET", "POST", "DELETE", "PATCH"]) .allowed_headers(vec![ http::header::AUTHORIZATION, http::header::ACCEPT, @@ -72,32 +127,21 @@ async fn main() -> Result<()> { .supports_credentials() .max_age(3600); + let spec = build_spec(); + App::new() - .app_data(web::Data::new(repos.clone())) - .app_data(web::Data::new(services.clone())) + .document(spec) + .app_data(Data::new(repos.clone())) + .app_data(Data::new(services.clone())) .app_data(votes_webhooks_manager.clone()) + .app_data(chat_server_handle.clone()) .wrap(TracingLogger::default()) .wrap(cors) .wrap(AuthMiddleware) - .wrap_fn(move |req, srv| { - let fut = srv.call(req); - Box::pin(async move { - let res = fut.await?; - - info!( - "[{}] {} {} {}", - LogCode::Request, - res.request().method(), - res.request().uri(), - res.status() - ); - - Ok(res) - }) - }) - .route("/", actix_web::web::get().to(|| async { "Hello, world!" })) + .configure(routes::configure) + .build("/openapi.json") }) - .bind(("0.0.0.0", app_env!().port))? + .bind((Ipv4Addr::UNSPECIFIED, app_env!().port))? .run(); info!( @@ -120,7 +164,7 @@ async fn main() -> Result<()> { app_env!().client_url, ); - try_join!(http_server)?; + try_join!(http_server, async { chat_server.await? })?; Ok(()) } diff --git a/src/managers/chat.rs b/src/managers/chat.rs new file mode 100644 index 00000000..240658a2 --- /dev/null +++ b/src/managers/chat.rs @@ -0,0 +1,154 @@ +use std::{ + collections::HashMap, + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use ring::rand::{SecureRandom, SystemRandom}; +use serde::Serialize; +use tokio::sync::{mpsc, oneshot}; +use tracing::info; + +use crate::utils::logger::LogCode; + +pub type ConnId = u64; +pub type Msg = String; + +fn generate_conn_id() -> ConnId { + let rng = SystemRandom::new(); + let mut bytes = [0u8; 8]; + rng.fill(&mut bytes) + .expect("Failed to generate random bytes"); + u64::from_le_bytes(bytes) +} + +enum Command { + Connect { + conn_tx: mpsc::UnboundedSender, + res_tx: oneshot::Sender, + }, + Disconnect { + conn_id: ConnId, + }, +} + +#[derive(Serialize)] +struct VisitorCount { + count: usize, +} + +pub struct ChatServer { + sessions: HashMap>, + visitor_count: Arc, + cmd_rx: mpsc::UnboundedReceiver, +} + +impl ChatServer { + pub fn new() -> (Self, ChatServerHandle) { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + + ( + Self { + sessions: HashMap::new(), + visitor_count: Arc::new(AtomicUsize::new(0)), + cmd_rx, + }, + ChatServerHandle { cmd_tx }, + ) + } + + async fn send_system_message(&self, msg: impl Into) { + let system_msg = msg.into(); + for session in self.sessions.values() { + let _ = session.send(system_msg.clone()); + } + } + + async fn connect(&mut self, conn_tx: mpsc::UnboundedSender) -> ConnId { + let conn_id = generate_conn_id(); + self.sessions.insert(conn_id, conn_tx); + + let count = self.visitor_count.fetch_add(1, Ordering::SeqCst) + 1; + + info!( + code = %LogCode::Websocket, + conn_id = %conn_id, + visitor_count = %count, + "New WebSocket connection established" + ); + + self.send_system_message( + &serde_json::to_string(&VisitorCount { count }) + .expect("Failed to serialize visitor count"), + ) + .await; + + conn_id + } + + async fn disconnect(&mut self, conn_id: ConnId) { + self.sessions.remove(&conn_id); + let visitor_count = self.visitor_count.fetch_sub(1, Ordering::SeqCst) - 1; + + info!( + code = %LogCode::Websocket, + conn_id = %conn_id, + visitor_count = %visitor_count, + "WebSocket connection closed" + ); + + self.send_system_message( + &serde_json::to_string(&VisitorCount { + count: visitor_count, + }) + .expect("Failed to serialize visitor count"), + ) + .await; + } + + pub async fn run(mut self) -> io::Result<()> { + info!( + code = %LogCode::Server, + "Chat server is running" + ); + + while let Some(cmd) = self.cmd_rx.recv().await { + match cmd { + Command::Connect { conn_tx, res_tx } => { + let conn_id = self.connect(conn_tx).await; + let _ = res_tx.send(conn_id); + } + Command::Disconnect { conn_id } => { + self.disconnect(conn_id).await; + } + } + } + + info!( + code = %LogCode::Server, + "Chat server is shutting down" + ); + + Ok(()) + } +} + +#[derive(Clone)] +pub struct ChatServerHandle { + cmd_tx: mpsc::UnboundedSender, +} + +impl ChatServerHandle { + pub async fn connect(&self, conn_tx: mpsc::UnboundedSender) -> ConnId { + let (res_tx, res_rx) = oneshot::channel(); + let _ = self.cmd_tx.send(Command::Connect { conn_tx, res_tx }); + res_rx.await.expect("Failed to receive connection ID") + } + + pub async fn disconnect(&self, conn_id: ConnId) { + let _ = self.cmd_tx.send(Command::Disconnect { conn_id }); + } +} diff --git a/src/managers/mod.rs b/src/managers/mod.rs index 25f2d33e..4ed3b519 100644 --- a/src/managers/mod.rs +++ b/src/managers/mod.rs @@ -1 +1,5 @@ -pub mod webhook; +mod chat; +mod webhook; + +pub use chat::{ChatServer, ChatServerHandle}; +pub use webhook::VotesWebhooksManager; diff --git a/src/managers/webhook.rs b/src/managers/webhook.rs index 87af845f..a384bc56 100644 --- a/src/managers/webhook.rs +++ b/src/managers/webhook.rs @@ -1,11 +1,17 @@ -use std::sync::OnceLock; +use std::{ + collections::HashMap, + sync::{Arc, OnceLock}, + time::Duration, +}; +use actix_web::rt; use anyhow::Result; use regex::Regex; use reqwest::{ Client, header::{HeaderMap, HeaderValue}, }; +use tokio::{sync::Mutex, time::sleep}; use tracing::info; use crate::{ @@ -16,18 +22,29 @@ use crate::{ static DISCORD_WEBHOOK_REGEX: OnceLock = OnceLock::new(); pub struct VotesWebhooksManager { - pub waitlist: Vec, + pub waitlist: HashMap, client: Client, } +impl Default for VotesWebhooksManager { + fn default() -> Self { + Self::new() + } +} + impl VotesWebhooksManager { pub fn new() -> Self { Self { - waitlist: Vec::new(), + waitlist: HashMap::new(), client: Client::new(), } } + pub fn queue_webhook(&mut self, webhook: Webhook) { + let key = Self::build_key(&webhook); + self.waitlist.insert(key, webhook); + } + fn is_discord_webhook(url: &str) -> bool { DISCORD_WEBHOOK_REGEX .get_or_init(|| { @@ -37,37 +54,14 @@ impl VotesWebhooksManager { .is_match(url) } - pub fn retry(&mut self, webhook: Webhook) { - if let Some(idx) = self.waitlist.iter().position(|w| { - w.data.bot_id == webhook.data.bot_id - && w.data.voter_id == webhook.data.voter_id - && w.data.provider == webhook.data.provider - && w.data.date == webhook.data.date - && w.data.raw_data == webhook.data.raw_data - }) { - if let Some(w) = self.waitlist.get_mut(idx) { - w.try_count += 1; - - if w.try_count > MAX_WEBHOOK_RETRIES { - self.waitlist.remove(idx); - } - } - } else { - self.waitlist.push(Webhook { - try_count: 1, - ..webhook - }); - } - } - - pub async fn send_webhook(&mut self, webhook: Webhook) -> Result<()> { + fn build_payload(webhook: &Webhook) -> Result<(WebhookSendData<'_>, HeaderMap)> { let mut headers = HeaderMap::new(); headers.insert( "Authorization", HeaderValue::from_str(&webhook.webhook_secret)?, ); - let provider_str = webhook.data.provider.as_str(); + let provider_str = webhook.data.provider.to_str(); let content = if Self::is_discord_webhook(&webhook.webhook_url) { match &webhook.data.provider { @@ -89,52 +83,96 @@ impl VotesWebhooksManager { None }; - let res = self - .client + let data = WebhookSendData { + bot_id: &webhook.data.bot_id, + voter_id: &webhook.data.voter_id, + provider: provider_str, + date: webhook.data.date, + raw_data: webhook.data.raw_data.as_ref(), + content, + }; + + Ok((data, headers)) + } + + fn build_key(webhook: &Webhook) -> String { + format!( + "{}:{}:{}", + webhook.webhook_url, + webhook.data.voter_id, + webhook.data.provider.to_str() + ) + } + + pub async fn send(manager: Arc>, webhook: Webhook) { + let (client, payload, headers) = { + let manager = manager.lock().await; + let (payload, headers) = + Self::build_payload(&webhook).expect("Failed to build webhook payload"); + (manager.client.clone(), payload, headers) + }; + + let result = client .post(&webhook.webhook_url) - .json(&WebhookSendData { - bot_id: webhook.data.bot_id.clone(), - voter_id: webhook.data.voter_id.clone(), - provider: provider_str.to_string(), - date: webhook.data.date, - raw_data: webhook.data.raw_data.clone(), - content, - }) + .json(&payload) .headers(headers) .send() .await; - match res { - Ok(res) => { - if res.status().is_success() { - info!( - code = %LogCode::Request, - "Vote webhook of bot {} for provider {} has been sent", - webhook.data.bot_id.as_str(), - webhook.data.provider.as_str() - ); - self.waitlist.retain(|w| *w != webhook); - } else { - info!( - code = %LogCode::Request, - "Vote webhook of bot {} for provider {} did not return a successful status code", - webhook.data.bot_id.as_str(), - webhook.data.provider.as_str() - ); - self.retry(webhook) - } + let mut mgr = manager.lock().await; + match result { + Ok(res) if res.status().is_success() => { + info!( + code = %LogCode::Request, + "Vote webhook of bot {} for provider {} has been sent", + webhook.data.bot_id.as_str(), + webhook.data.provider.to_str() + ); + let key = Self::build_key(&webhook); + mgr.waitlist.remove(&key); } - Err(_) => { + _ => { info!( code = %LogCode::Request, "Vote webhook of bot {} for provider {} has failed to be sent", webhook.data.bot_id.as_str(), - webhook.data.provider.as_str() + webhook.data.provider.to_str() ); - self.retry(webhook) + + if let Some(delay) = mgr.retry(&webhook) { + drop(mgr); + Self::schedule_retry(manager.clone(), webhook, delay); + } } } + } + + fn retry(&mut self, webhook: &Webhook) -> Option { + let key = Self::build_key(webhook); + + let entry = self.waitlist.entry(key).or_insert_with(|| { + let mut w = webhook.clone(); + w.try_count = 0; + w + }); + + entry.try_count = entry.try_count.saturating_add(1); + + if entry.try_count > MAX_WEBHOOK_RETRIES { + self.waitlist.remove(&Self::build_key(webhook)); + return None; + } + + let exp = (entry.try_count.min(6)) as u32; + let delay = Duration::from_secs(2u64.pow(exp)); + + Some(delay) + } - Ok(()) + fn schedule_retry(manager: Arc>, webhook: Webhook, delay: Duration) { + rt::spawn(async move { + sleep(delay).await; + Self::send(manager, webhook).await; + }); } } diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs new file mode 100644 index 00000000..6575e3b8 --- /dev/null +++ b/src/openapi/mod.rs @@ -0,0 +1,96 @@ +pub mod schemas; + +use apistos::{ + info::{Contact, Info}, + paths::ExternalDocumentation, + server::Server, + spec::Spec, + tag::Tag, +}; + +use crate::app_env; + +pub fn build_spec() -> Spec { + Spec { + info: Info { + title: "Discord Analytics API".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: Some( + "The Discord Analytics API allows developers to access various data and statistics related to Discord bots. It provides endpoints for retrieving bot information, achievements, and more. This API is designed to help developers integrate Discord Analytics features into their applications.".to_string(), + ), + terms_of_service: Some( + "https://discordanalytics.xyz/docs/legals/terms.html".to_string(), + ), + contact: Some(Contact { + name: Some("Discord Analytics".to_string()), + url: Some("https://discordanalytics.xyz".to_string()), + email: Some("contact@discordanalytics.xyz".to_string()), + ..Default::default() + }), + ..Default::default() + }, + servers: vec![Server { + url: app_env!().api_url.to_owned(), + description: Some("Base URL for the Discord Analytics API".to_string()), + ..Default::default() + }], + external_docs: Some(ExternalDocumentation { + description: Some("Discord Analytics Documentation".to_string()), + url: "https://discordanalytics.xyz/docs".to_string(), + ..Default::default() + }), + tags: vec![ + Tag { + name: "Achievements".to_string(), + description: Some("Endpoints for managing and retrieving achievements".to_string()), + ..Default::default() + }, + Tag { + name: "Articles".to_string(), + description: Some("Endpoints for managing and retrieving articles".to_string()), + ..Default::default() + }, + Tag { + name: "Bots".to_string(), + description: Some( + "Endpoints for managing and retrieving bot information".to_string(), + ), + ..Default::default() + }, + Tag { + name: "Health".to_string(), + description: Some("Endpoints related to API health and status".to_string()), + ..Default::default() + }, + Tag { + name: "Invitations".to_string(), + description: Some("Endpoints for managing and retrieving invitations".to_string()), + ..Default::default() + }, + Tag { + name: "Stats".to_string(), + description: Some("Endpoints for retrieving statistics".to_string()), + ..Default::default() + }, + Tag { + name: "Users".to_string(), + description: Some( + "Endpoints for managing and retrieving user information".to_string(), + ), + ..Default::default() + }, + Tag { + name: "Webhooks".to_string(), + description: Some("Endpoints for managing webhooks".to_string()), + ..Default::default() + }, + Tag { + name: "Websocket".to_string(), + description: Some("Endpoints related to WebSocket connections".to_string()), + ..Default::default() + }, + ], + + ..Default::default() + } +} diff --git a/src/openapi/schemas/achievement.rs b/src/openapi/schemas/achievement.rs new file mode 100644 index 00000000..55990856 --- /dev/null +++ b/src/openapi/schemas/achievement.rs @@ -0,0 +1,76 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::{Achievement, AchievementObjective}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AchievementResponse { + pub id: String, + pub achieved_on: Option, + pub current: Option, + pub description: String, + pub description_i18n: Option, + pub editable: bool, + pub lang: Option, + pub objective: AchievementObjective, + pub shared: bool, + pub title: String, + pub title_i18n: Option, + pub used_by: i64, +} + +impl TryFrom for AchievementResponse { + type Error = anyhow::Error; + + fn try_from(achievement: Achievement) -> Result { + Ok(Self { + id: achievement + .id + .ok_or_else(|| anyhow::anyhow!("Achievement ID is missing"))? + .to_string(), + achieved_on: achievement + .achieved_on + .map(|dt| dt.try_to_rfc3339_string()) + .transpose()?, + current: achievement.current, + description: achievement.description, + description_i18n: achievement.description_i18n, + editable: achievement.editable, + lang: achievement.lang, + objective: achievement.objective, + shared: achievement.shared, + title: achievement.title, + title_i18n: achievement.title_i18n, + used_by: achievement.used_by, + }) + } +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AchievementCreationPayload { + pub description: String, + pub description_i18n: Option, + pub editable: bool, + pub from: Option, + pub objective: AchievementObjective, + pub shared: Option, + pub title: String, + pub title_i18n: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct AchievementUpdatePayload { + pub id: String, + pub description: String, + pub lang: Option, + pub title: String, + pub shared: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct DeleteAchievementQuery { + pub id: String, +} diff --git a/src/openapi/schemas/article.rs b/src/openapi/schemas/article.rs new file mode 100644 index 00000000..6527e6b2 --- /dev/null +++ b/src/openapi/schemas/article.rs @@ -0,0 +1,65 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::BlogArticle; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ArticleResponse { + pub author: Option, + pub author_id: String, + pub article_id: String, + pub content: Option, + pub cover: Option, + pub created_at: String, + pub description: String, + pub is_draft: bool, + pub tags: Vec, + pub title: String, + pub updated_at: Option, +} + +impl ArticleResponse { + pub fn from_article( + article: BlogArticle, + author: Option, + ) -> anyhow::Result { + Ok(Self { + author, + author_id: article.author_id, + article_id: article.article_id, + content: Some(article.content), + cover: article.cover, + created_at: article.created_at.try_to_rfc3339_string()?, + description: article.description, + is_draft: article.is_draft, + tags: article.tags, + title: article.title, + updated_at: article + .updated_at + .map(|dt| dt.try_to_rfc3339_string()) + .transpose()?, + }) + } +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct ArticleAuthor { + pub avatar: Option, + pub username: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct ArticleRequest { + pub content: String, + pub cover: Option, + pub description: String, + pub tags: Vec, + pub title: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct ArticleDeleteResponse { + pub success: bool, +} diff --git a/src/openapi/schemas/auth.rs b/src/openapi/schemas/auth.rs new file mode 100644 index 00000000..621023e7 --- /dev/null +++ b/src/openapi/schemas/auth.rs @@ -0,0 +1,55 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AuthConfigResponse { + pub client_id: String, + pub scopes: Vec, +} + +#[derive(Debug, Clone, Deserialize, ApiComponent, JsonSchema)] +pub struct LinkedRolesQuery { + pub code: Option, + pub state: Option, +} + +#[derive(Debug, Clone, Deserialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AuthCallbackQuery { + pub code: String, + pub redirection: String, + pub scopes: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DiscordOAuthUser { + pub avatar: Option, + pub avatar_decoration_data: Option, + pub discriminator: String, + pub email: Option, + pub id: String, + pub username: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DiscordBot { + pub avatar: Option, + pub bot: bool, + pub username: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AvatarDecoration { + pub asset: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DiscordTokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, + pub refresh_token: String, + pub scope: String, +} diff --git a/src/openapi/schemas/bot.rs b/src/openapi/schemas/bot.rs new file mode 100644 index 00000000..6c3b6215 --- /dev/null +++ b/src/openapi/schemas/bot.rs @@ -0,0 +1,81 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::{Bot, WebhooksConfig}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BotResponse { + pub advanced_stats: bool, + pub avatar: Option, + pub bot_id: String, + pub framework: Option, + pub goals_limit: i32, + pub language: Option, + pub last_push: Option, + pub owner_id: String, + pub suspended: bool, + pub team: Vec, + pub username: String, + pub version: Option, + pub watched_since: String, + pub webhooks_config: WebhooksConfig, +} + +impl TryFrom for BotResponse { + type Error = anyhow::Error; + + fn try_from(bot: Bot) -> Result { + Ok(Self { + advanced_stats: bot.advanced_stats, + avatar: bot.avatar, + bot_id: bot.bot_id, + framework: bot.framework, + goals_limit: bot.goals_limit, + language: bot.language, + last_push: bot + .last_push + .map(|dt| dt.try_to_rfc3339_string()) + .transpose()?, + owner_id: bot.owner_id, + suspended: bot.suspended, + team: bot.team, + username: bot.username, + version: bot.version, + watched_since: bot.watched_since.try_to_rfc3339_string()?, + webhooks_config: bot.webhooks_config, + }) + } +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BotCreationBody { + pub user_id: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct BotUpdateBody { + pub avatar: Option, + pub framework: Option, + pub team: Option>, + pub username: Option, + pub version: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct BotSuspendRequest { + pub reason: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct BotTokenResponse { + pub token: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BotSettingsPayload { + pub advanced_stats: bool, +} diff --git a/src/openapi/schemas/bot_stat.rs b/src/openapi/schemas/bot_stat.rs new file mode 100644 index 00000000..e3f21beb --- /dev/null +++ b/src/openapi/schemas/bot_stat.rs @@ -0,0 +1,181 @@ +use std::collections::HashMap; + +use apistos::ApiComponent; +use mongodb::bson::DateTime; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{ + domain::models::{BotStats, Guild, GuildMembers, Interaction, Locale, UserType}, + openapi::schemas::VoteResponse, +}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct BotStatsResponse { + pub stats: Vec, + pub votes: Vec, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BotStatsContent { + pub added_guilds: i32, + pub custom_events: Option>, + pub date: String, + pub guilds: Option>, + pub guild_count: i32, + pub guild_locales: Vec, + pub guild_members: GuildMembers, + pub interactions: Vec, + pub interactions_locales: Vec, + pub removed_guilds: i32, + pub user_count: i32, + pub user_install_count: Option, + pub users_type: Option, +} + +impl TryFrom for BotStatsContent { + type Error = anyhow::Error; + + fn try_from(stats: BotStats) -> Result { + Ok(Self { + added_guilds: stats.added_guilds, + custom_events: stats.custom_events, + date: stats.date.try_to_rfc3339_string()?, + guilds: stats.guilds, + guild_count: stats.guild_count, + guild_locales: stats.guild_locales, + guild_members: stats.guild_members, + interactions: stats.interactions, + interactions_locales: stats.interactions_locales, + removed_guilds: stats.removed_guilds, + user_count: stats.user_count, + user_install_count: stats.user_install_count, + users_type: stats.users_type, + }) + } +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct BotStatsQuery { + pub from: String, + pub to: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(untagged)] +pub enum BotStatsBody { + New(BotStatsBodyNew), + Old(BotStatsBodyOld), +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BotStatsBodyOld { + pub added_guilds: i32, + pub custom_events: Option>, + pub guilds: i32, + pub guilds_locales: Vec, + pub guild_members: GuildMembers, + pub guilds_stats: Option>, + pub interactions: Vec, + pub locales: Vec, + pub removed_guilds: i32, + pub users: i32, + pub user_install_count: Option, + pub users_type: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BotStatsBodyNew { + pub added_guilds: i32, + pub custom_events: Option>, + pub guilds: Option>, + pub guild_count: i32, + pub guild_locales: Vec, + pub guild_members: GuildMembers, + pub interactions: Vec, + pub interactions_locales: Vec, + pub removed_guilds: i32, + pub user_count: i32, + pub user_install_count: Option, + pub users_type: Option, +} + +#[derive(Clone, Debug)] +pub struct NormalizedStatsBody { + pub added_guilds: i32, + pub bot_id: String, + pub custom_events: Option>, + pub date: DateTime, + pub guilds: Option>, + pub guild_count: i32, + pub guild_locales: Vec, + pub guild_members: GuildMembers, + pub interactions: Vec, + pub interactions_locales: Vec, + pub removed_guilds: i32, + pub user_count: i32, + pub user_install_count: Option, + pub users_type: Option, +} + +impl NormalizedStatsBody { + pub fn from_old(old: BotStatsBodyOld, bot_id: &str, date: &DateTime) -> Self { + Self { + added_guilds: old.added_guilds, + bot_id: bot_id.to_string(), + custom_events: old.custom_events, + date: *date, + guilds: old.guilds_stats, + guild_count: old.guilds, + guild_locales: old.guilds_locales, + guild_members: old.guild_members, + interactions: old.interactions, + interactions_locales: old.locales, + removed_guilds: old.removed_guilds, + user_count: old.users, + user_install_count: old.user_install_count, + users_type: old.users_type, + } + } + + pub fn from_new(new: BotStatsBodyNew, bot_id: &str, date: &DateTime) -> Self { + Self { + added_guilds: new.added_guilds, + bot_id: bot_id.to_string(), + custom_events: new.custom_events, + date: *date, + guilds: new.guilds, + guild_count: new.guild_count, + guild_locales: new.guild_locales, + guild_members: new.guild_members, + interactions: new.interactions, + interactions_locales: new.interactions_locales, + removed_guilds: new.removed_guilds, + user_count: new.user_count, + user_install_count: new.user_install_count, + users_type: new.users_type, + } + } + + pub fn into_stats(self) -> BotStats { + BotStats { + added_guilds: self.added_guilds, + bot_id: self.bot_id, + custom_events: self.custom_events, + date: self.date, + guilds: self.guilds, + guild_count: self.guild_count, + guild_locales: self.guild_locales, + guild_members: self.guild_members, + interactions: self.interactions, + interactions_locales: self.interactions_locales, + removed_guilds: self.removed_guilds, + user_count: self.user_count, + user_install_count: self.user_install_count, + users_type: self.users_type, + } + } +} diff --git a/src/openapi/schemas/custom_event.rs b/src/openapi/schemas/custom_event.rs new file mode 100644 index 00000000..74dc6e33 --- /dev/null +++ b/src/openapi/schemas/custom_event.rs @@ -0,0 +1,31 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::CustomEvent; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CustomEventResponse { + pub default_value: Option, + pub event_key: String, + pub graph_name: String, +} + +impl From for CustomEventResponse { + fn from(event: CustomEvent) -> Self { + Self { + default_value: event.default_value, + event_key: event.event_key, + graph_name: event.graph_name, + } + } +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CustomEventBody { + pub default_value: Option, + pub event_key: String, + pub graph_name: String, +} diff --git a/src/openapi/schemas/health.rs b/src/openapi/schemas/health.rs new file mode 100644 index 00000000..eb4eca64 --- /dev/null +++ b/src/openapi/schemas/health.rs @@ -0,0 +1,11 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct HealthResponse { + pub status: String, + pub service: String, + pub version: String, + pub environment: String, +} diff --git a/src/openapi/schemas/integrations.rs b/src/openapi/schemas/integrations.rs new file mode 100644 index 00000000..3ca3a9dc --- /dev/null +++ b/src/openapi/schemas/integrations.rs @@ -0,0 +1,35 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct TopGGIntegrationPayload { + pub data: TopGGIntegrationData, + #[serde(rename = "type")] + pub type_: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct TopGGIntegrationData { + pub connection_id: String, + pub project: Option, + pub user: Option, + pub webhook_secret: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct TopGGIntegrationProject { + pub id: String, + pub platform: String, + pub platform_id: String, + #[serde(rename = "type")] + pub type_: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct TopGGIntegrationUser { + pub avatar_url: String, + pub id: String, + pub name: String, + pub platform_id: String, +} diff --git a/src/openapi/schemas/invitation.rs b/src/openapi/schemas/invitation.rs new file mode 100644 index 00000000..3c4c4c43 --- /dev/null +++ b/src/openapi/schemas/invitation.rs @@ -0,0 +1,51 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::TeamInvitation; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct InvitationAcceptBody { + pub invitation_id: String, + pub accept: bool, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct InvitationResponse { + pub invitation: TeamInvitationResponse, + pub bot_username: String, + pub bot_avatar: Option, + pub owner_username: String, + pub owner_avatar: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct InvitationAcceptResponse { + pub accepted: bool, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TeamInvitationResponse { + accepted: bool, + bot_id: String, + expiration: String, + invitation_id: String, + user_id: String, +} + +impl TryFrom for TeamInvitationResponse { + type Error = anyhow::Error; + + fn try_from(invitation: TeamInvitation) -> Result { + Ok(Self { + accepted: invitation.accepted, + bot_id: invitation.bot_id, + expiration: invitation.expiration.try_to_rfc3339_string()?, + invitation_id: invitation.invitation_id, + user_id: invitation.user_id, + }) + } +} diff --git a/src/openapi/schemas/mod.rs b/src/openapi/schemas/mod.rs new file mode 100644 index 00000000..0e614583 --- /dev/null +++ b/src/openapi/schemas/mod.rs @@ -0,0 +1,56 @@ +mod achievement; +mod article; +mod auth; +mod bot; +mod bot_stat; +mod custom_event; +mod health; +mod integrations; +mod invitation; +mod session; +mod stat; +mod team; +mod user; +mod vote; +mod webhook; + +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub use achievement::{ + AchievementCreationPayload, AchievementResponse, AchievementUpdatePayload, + DeleteAchievementQuery, +}; +pub use article::{ArticleAuthor, ArticleDeleteResponse, ArticleRequest, ArticleResponse}; +pub use auth::{ + AuthCallbackQuery, AuthConfigResponse, AvatarDecoration, DiscordBot, DiscordOAuthUser, + DiscordTokenResponse, LinkedRolesQuery, +}; +pub use bot::{ + BotCreationBody, BotResponse, BotSettingsPayload, BotSuspendRequest, BotTokenResponse, + BotUpdateBody, +}; +pub use bot_stat::{ + BotStatsBody, BotStatsContent, BotStatsQuery, BotStatsResponse, NormalizedStatsBody, +}; +pub use custom_event::{CustomEventBody, CustomEventResponse}; +pub use health::HealthResponse; +pub use integrations::TopGGIntegrationPayload; +pub use invitation::{ + InvitationAcceptBody, InvitationAcceptResponse, InvitationResponse, TeamInvitationResponse, +}; +pub use session::{RefreshTokenRequest, SessionResponse, TokenResponse}; +pub use stat::{StatResponse, StatsQuery}; +pub use team::{TeamRequestBody, TeamResponse}; +pub use user::{UserBotsResponse, UserResponse, UserSuspendRequest, UserUpdateRequest}; +pub use vote::VoteResponse; +pub use webhook::{ + BotListMePayload, DBListPayload, DiscordListPayload, DiscordPlacePayload, DiscordsComPayload, + TopGGPayload, +}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct MessageResponse { + pub message: String, +} diff --git a/src/openapi/schemas/session.rs b/src/openapi/schemas/session.rs new file mode 100644 index 00000000..d99f434b --- /dev/null +++ b/src/openapi/schemas/session.rs @@ -0,0 +1,47 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::Session; + +#[derive(Debug, Clone, Serialize, Deserialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TokenResponse { + pub access_token: String, + pub expires_in: i64, + pub refresh_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionResponse { + pub active: bool, + pub created_at: String, + pub device_info: Option, + pub ip_address: Option, + pub last_used_at: String, + pub session_id: String, + pub user_agent: Option, +} + +impl TryFrom for SessionResponse { + type Error = anyhow::Error; + + fn try_from(session: Session) -> Result { + Ok(Self { + active: session.active, + created_at: session.created_at.try_to_rfc3339_string()?, + device_info: session.device_info, + ip_address: session.ip_address, + last_used_at: session.last_used_at.try_to_rfc3339_string()?, + session_id: session.session_id, + user_agent: session.user_agent, + }) + } +} diff --git a/src/openapi/schemas/stat.rs b/src/openapi/schemas/stat.rs new file mode 100644 index 00000000..48885254 --- /dev/null +++ b/src/openapi/schemas/stat.rs @@ -0,0 +1,33 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::GlobalStats; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StatResponse { + pub bot_count: i32, + pub date: String, + pub registered_bots: i32, + pub user_count: i32, +} + +impl TryFrom for StatResponse { + type Error = anyhow::Error; + + fn try_from(user: GlobalStats) -> Result { + Ok(Self { + bot_count: user.bot_count, + date: user.date.try_to_rfc3339_string()?, + registered_bots: user.registered_bots, + user_count: user.user_count, + }) + } +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct StatsQuery { + pub start: String, + pub end: String, +} diff --git a/src/openapi/schemas/team.rs b/src/openapi/schemas/team.rs new file mode 100644 index 00000000..b21fbc37 --- /dev/null +++ b/src/openapi/schemas/team.rs @@ -0,0 +1,20 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TeamResponse { + pub avatar: Option, + pub invitation_id: Option, + pub pending_invitation: bool, + pub registered: bool, + pub user_id: String, + pub username: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TeamRequestBody { + pub user_id: String, +} diff --git a/src/openapi/schemas/user.rs b/src/openapi/schemas/user.rs new file mode 100644 index 00000000..20aa35e3 --- /dev/null +++ b/src/openapi/schemas/user.rs @@ -0,0 +1,52 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{domain::models::User, openapi::schemas::BotResponse}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserResponse { + pub avatar: Option, + pub avatar_decoration: Option, + pub bots_limit: i32, + pub created_at: String, + pub joined_at: String, + pub suspended: bool, + pub username: String, + pub user_id: String, +} + +impl TryFrom for UserResponse { + type Error = anyhow::Error; + + fn try_from(user: User) -> Result { + Ok(Self { + avatar: user.avatar, + avatar_decoration: user.avatar_decoration, + bots_limit: user.bots_limit, + created_at: user.created_at.try_to_rfc3339_string()?, + joined_at: user.joined_at.try_to_rfc3339_string()?, + suspended: user.suspended, + username: user.username, + user_id: user.user_id, + }) + } +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct UserUpdateRequest { + pub bots_limit: i32, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserBotsResponse { + pub owned_bots: Vec, + pub team_bots: Vec, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct UserSuspendRequest { + pub reason: String, +} diff --git a/src/openapi/schemas/vote.rs b/src/openapi/schemas/vote.rs new file mode 100644 index 00000000..b5ae35a4 --- /dev/null +++ b/src/openapi/schemas/vote.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; + +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::Vote; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct VoteResponse { + pub bot_id: String, + pub date: String, + #[serde(flatten)] + pub votes: HashMap, +} + +impl TryFrom for VoteResponse { + type Error = anyhow::Error; + + fn try_from(vote: Vote) -> Result { + Ok(Self { + bot_id: vote.bot_id, + date: vote.date.try_to_rfc3339_string()?, + votes: vote.votes, + }) + } +} diff --git a/src/openapi/schemas/webhook.rs b/src/openapi/schemas/webhook.rs new file mode 100644 index 00000000..640f155f --- /dev/null +++ b/src/openapi/schemas/webhook.rs @@ -0,0 +1,69 @@ +use apistos::ApiComponent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct BotListMePayload { + pub bot: String, + pub user: String, + #[serde(rename = "type")] + pub vote_type: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct DBListPayload { + pub bot_id: String, + pub id: String, + pub promotable_bot: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DiscordListPayload { + pub bot_id: String, + pub is_test: bool, + pub user_id: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct DiscordPlacePayload { + pub bot: String, + pub test: bool, + pub user: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct DiscordsComPayload { + pub bot: String, + #[serde(rename = "type")] + pub type_: String, + pub user: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct TopGGPayload { + pub data: TopGGData, + #[serde(rename = "type")] + pub type_: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct TopGGData { + pub project: TopGGProject, + pub user: TopGGUser, + pub weight: Option, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct TopGGProject { + pub platform: String, + pub platform_id: String, + #[serde(rename = "type")] + pub type_: String, +} + +#[derive(Deserialize, Serialize, Clone, ApiComponent, JsonSchema)] +pub struct TopGGUser { + pub name: String, + pub platform_id: String, +} diff --git a/src/repository/achievements.rs b/src/repository/achievements.rs index 91e5e31a..e07a8fd8 100644 --- a/src/repository/achievements.rs +++ b/src/repository/achievements.rs @@ -1,52 +1,203 @@ +use std::str::FromStr; + use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{doc, serialize_to_document}, + bson::{Bson, DateTime, Document, doc, oid::ObjectId}, error::Result, + options::{FindOneAndUpdateOptions, ReturnDocument}, results::{DeleteResult, InsertOneResult, UpdateResult}, }; use crate::{domain::models::Achievement, utils::constants::ACHIEVEMENTS_COLLECTION}; +#[derive(Clone, Default)] +pub struct AchievementUpdate { + updates: Document, +} + +impl AchievementUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn with_description(mut self, description: String) -> Self { + self.merge_set(doc! { "description": description }); + self + } + + pub fn with_from(mut self, from: Option) -> Self { + self.merge_set(doc! { "from": from }); + self + } + + pub fn with_lang(mut self, lang: String) -> Self { + self.merge_set(doc! { "lang": lang }); + self + } + + pub fn with_shared(mut self, shared: bool) -> Self { + self.merge_set(doc! { "shared": shared }); + self + } + + pub fn with_title(mut self, title: String) -> Self { + self.merge_set(doc! { "title": title }); + self + } + + pub fn with_used_by(mut self, used_by: i64) -> Self { + self.merge_set(doc! { "usedBy": used_by }); + self + } + + fn merge_set(&mut self, doc: Document) { + let set_doc = self + .updates + .entry("$set") + .or_insert_with(|| Bson::Document(doc! {})); + + if let Bson::Document(existing) = set_doc { + existing.extend(doc); + } + } + + pub fn build(self) -> Document { + self.updates + } +} + #[derive(Clone)] pub struct AchievementsRepository { collection: Collection, } impl AchievementsRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(ACHIEVEMENTS_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == ACHIEVEMENTS_COLLECTION) + { + db.create_collection(ACHIEVEMENTS_COLLECTION).await?; } + + Ok(Self { + collection: db.collection(ACHIEVEMENTS_COLLECTION), + }) } - pub async fn find_all(&self) -> Result> { - let cursor = self.collection.find(doc! {}).await?; + pub async fn count_used_by(&self, achievement_id: &str) -> Result { + self.collection + .count_documents(doc! { "from": achievement_id }) + .await + } + + pub async fn find_all_shared(&self) -> Result> { + let cursor = self + .collection + .find(doc! { "shared": true, "from": null }) + .await?; cursor.try_collect().await } pub async fn find_by_id(&self, achievement_id: &str) -> Result> { self.collection - .find_one(doc! { "_id": achievement_id }) + .find_one(doc! { "_id": ObjectId::from_str(achievement_id)? }) .await } + pub async fn find_by_bot_id(&self, bot_id: &str) -> Result> { + let cursor = self.collection.find(doc! { "botId": bot_id }).await?; + cursor.try_collect().await + } + + pub async fn find_unachieved_by_bot(&self, bot_id: &str) -> Result> { + let cursor = self + .collection + .find(doc! { "botId": bot_id, "achievedOn": null }) + .await?; + cursor.try_collect().await + } + pub async fn insert(&self, achievement: &Achievement) -> Result { self.collection.insert_one(achievement).await } - pub async fn update(&self, achievement: &Achievement) -> Result { + pub async fn insert_many(&self, achievements: &[Achievement]) -> Result<()> { + if achievements.is_empty() { + return Ok(()); + } + + self.collection.insert_many(achievements).await?; + Ok(()) + } + + pub async fn update( + &self, + achievement_id: &str, + updated_achievement: AchievementUpdate, + ) -> Result> { + let updates = updated_achievement.build(); + + if updates.is_empty() { + return Ok(None); + } + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "_id": &achievement.id }, - doc! { "$set": serialize_to_document(achievement)? }, + .find_one_and_update(doc! { "_id": ObjectId::from_str(achievement_id)? }, updates) + .with_options(options) + .await + } + + pub async fn update_many( + &self, + from_achievement_id: &str, + updated_achievement: AchievementUpdate, + ) -> Result { + let updates = updated_achievement.build(); + + if updates.is_empty() { + return Ok(UpdateResult::default()); + } + + self.collection + .update_many(doc! { "from": from_achievement_id }, updates) + .await + } + + pub async fn update_progress( + &self, + bot_id: &str, + achievement_id: &str, + current: Option, + achieved_on: Option, + ) -> Result> { + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + + self.collection + .find_one_and_update( + doc! { "_id": ObjectId::from_str(achievement_id)?, "botId": bot_id }, + doc! { "$set": { "current": current, "achievedOn": achieved_on } }, ) + .with_options(options) .await } - pub async fn delete(&self, achievement_id: &str) -> Result { + pub async fn delete_by_id(&self, achievement_id: &str) -> Result { self.collection - .delete_one(doc! { "_id": achievement_id }) + .delete_one(doc! { "_id": ObjectId::from_str(achievement_id)? }) .await } + + pub async fn delete_by_bot_id(&self, bot_id: &str) -> Result { + self.collection.delete_many(doc! { "botId": bot_id }).await + } } diff --git a/src/repository/blog_articles.rs b/src/repository/blog_articles.rs index 8805e0bd..8d41daa2 100644 --- a/src/repository/blog_articles.rs +++ b/src/repository/blog_articles.rs @@ -1,23 +1,83 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{doc, serialize_to_document}, + bson::{DateTime, Document, doc}, error::Result, - results::{DeleteResult, InsertOneResult, UpdateResult}, + options::{FindOneAndUpdateOptions, ReturnDocument}, + results::{DeleteResult, InsertOneResult}, }; use crate::{domain::models::BlogArticle, utils::constants::BLOG_ARTICLES_COLLECTION}; +#[derive(Clone, Default)] +pub struct BlogArticleUpdate { + updates: Document, +} + +impl BlogArticleUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn with_content(mut self, content: &str) -> Self { + self.updates.insert("content", content); + self + } + + pub fn with_cover(mut self, cover: &str) -> Self { + self.updates.insert("cover", cover); + self + } + + pub fn with_description(mut self, description: &str) -> Self { + self.updates.insert("description", description); + self + } + + pub fn with_is_draft(mut self, is_draft: bool) -> Self { + self.updates.insert("isDraft", is_draft); + self + } + + pub fn with_tags(mut self, tags: Vec) -> Self { + self.updates.insert("tags", tags); + self + } + + pub fn with_title(mut self, title: &str) -> Self { + self.updates.insert("title", title); + self + } + + pub fn with_updated_at_to_now(mut self) -> Self { + self.updates.insert("updatedAt", DateTime::now()); + self + } + + pub fn build(self) -> Document { + self.updates + } +} + #[derive(Clone)] pub struct BlogArticlesRepository { collection: Collection, } impl BlogArticlesRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(BLOG_ARTICLES_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == BLOG_ARTICLES_COLLECTION) + { + db.create_collection(BLOG_ARTICLES_COLLECTION).await?; } + + Ok(Self { + collection: db.collection(BLOG_ARTICLES_COLLECTION), + }) } pub async fn find_all(&self) -> Result> { @@ -25,6 +85,11 @@ impl BlogArticlesRepository { cursor.try_collect().await } + pub async fn find_all_published(&self) -> Result> { + let cursor = self.collection.find(doc! { "isDraft": false }).await?; + cursor.try_collect().await + } + pub async fn find_by_id(&self, article_id: &str) -> Result> { self.collection .find_one(doc! { "articleId": article_id }) @@ -38,13 +103,26 @@ impl BlogArticlesRepository { pub async fn update( &self, article_id: &str, - updated_article: &BlogArticle, - ) -> Result { + updated_article: BlogArticleUpdate, + ) -> Result> { + let updates = updated_article.build(); + + if updates.is_empty() { + return Ok(None); + } + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "articleId": article_id }, - doc! { "$set": serialize_to_document(updated_article)? }, + .find_one_and_update( + doc! { + "articleId": article_id + }, + doc! { "$set": updates }, ) + .with_options(options) .await } diff --git a/src/repository/bot_stats.rs b/src/repository/bot_stats.rs index 0aed8517..6378b99d 100644 --- a/src/repository/bot_stats.rs +++ b/src/repository/bot_stats.rs @@ -1,12 +1,299 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{DateTime, doc, serialize_to_document}, + bson::{Bson, DateTime, Document, doc}, error::Result, - results::{DeleteResult, InsertOneResult, UpdateResult}, + options::{ + FindOneAndUpdateOptions, FindOptions, ReturnDocument, TimeseriesGranularity, + TimeseriesOptions, + }, + results::{DeleteResult, InsertOneResult}, }; -use crate::{domain::models::BotStats, utils::constants::BOT_STATS_COLLECTION}; +use crate::{ + domain::models::{BotStats, Guild, Interaction}, + utils::constants::BOT_STATS_COLLECTION, +}; + +#[derive(Clone, Default)] +pub struct BotStatsUpdate { + updates: Document, +} + +impl BotStatsUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn with_added_guilds(mut self, added_guilds: i32) -> Self { + self.merge_inc(doc! { "addedGuilds": added_guilds }); + self + } + + pub fn with_custom_event(mut self, event_key: &str, count: i32) -> Self { + self.merge_set(doc! { format!("customEvents.{}", event_key): count }); + self + } + + pub fn with_guilds(mut self, guilds: &[Guild]) -> Self { + if guilds.is_empty() { + return self; + } + + let new_guilds = guilds + .iter() + .map(|guild| { + doc! { + "guildId": &guild.guild_id, + "icon": &guild.icon, + "interactions": guild.interactions, + "members": guild.members, + "name": &guild.name, + } + }) + .collect::>(); + + let update_doc = doc! { + "guilds": { + "$let": { + "vars": { "guilds": { "$ifNull": [ "$guilds", [] ] } }, + "in": { + "$reduce": { + "input": new_guilds, + "initialValue": "$$guilds", + "in": { + "$let": { + "vars": { "existing": "$$value" }, + "in": { + "$cond": [ + { "$in": [ "$$this.guildId", "$$existing.guildId" ] }, + { + "$map": { + "input": "$$existing", + "as": "g", + "in": { + "$cond": [ + { "$eq": [ "$$g.guildId", "$$this.guildId" ] }, + { + "guildId": "$$g.guildId", + "name": "$$this.name", + "icon": "$$this.icon", + "members": "$$this.members", + "interactions": { "$add": [ "$$g.interactions", "$$this.interactions" ] } + }, + "$$g" + ] + } + } + }, + { + "$concatArrays": [ "$$existing", [ "$$this" ] ] + } + ] + } + } + } + } + } + } + } + }; + + self.merge_set(update_doc); + + self + } + + pub fn with_guild_count(mut self, guild_count: i32) -> Self { + self.merge_set(doc! { "guildCount": guild_count }); + self + } + + pub fn with_guild_locales(mut self, locales: &[(&str, i32)]) -> Self { + let update_doc = Self::build_locale_update("guildLocales", locales); + self.merge_set(update_doc); + self + } + + pub fn with_guild_member(mut self, bucket: &str, count: i32) -> Self { + self.merge_inc(doc! { format!("guildMembers.{}", bucket): count }); + self + } + + pub fn with_interactions(mut self, interactions: &[Interaction]) -> Self { + if interactions.is_empty() { + return self; + } + + let new_interactions = interactions + .iter() + .map(|interaction| { + doc! { + "commandType": interaction.command_type, + "name": &interaction.name, + "number": interaction.number, + "type": interaction.type_, + } + }) + .collect::>(); + + let update_doc = doc! { + "interactions": { + "$let": { + "vars": { "interactions": { "$ifNull": [ "$interactions", [] ] } }, + "in": { + "$reduce": { + "input": new_interactions, + "initialValue": "$$interactions", + "in": { + "$let": { + "vars": { "existing": "$$value" }, + "in": { + "$cond": [ + { "$in": [ "$$this.name", "$$existing.name" ] }, + { + "$map": { + "input": "$$existing", + "as": "i", + "in": { + "$cond": [ + { "$eq": [ "$$i.name", "$$this.name" ] }, + { + "commandType": "$$this.commandType", + "name": "$$i.name", + "number": { "$add": [ "$$i.number", "$$this.number" ] }, + "type": "$$i.type" + }, + "$$i" + ] + } + } + }, + { + "$concatArrays": [ "$$existing", [ "$$this" ] ] + } + ] + } + } + } + } + } + } + } + }; + + self.merge_set(update_doc); + + self + } + + pub fn with_interactions_locales(mut self, locales: &[(&str, i32)]) -> Self { + let update_doc = Self::build_locale_update("interactionsLocales", locales); + self.merge_set(update_doc); + self + } + + pub fn with_removed_guilds(mut self, removed_guilds: i32) -> Self { + self.merge_inc(doc! { "removedGuilds": removed_guilds }); + self + } + + pub fn with_user_count(mut self, user_count: i32) -> Self { + self.merge_set(doc! { "userCount": user_count }); + self + } + + pub fn with_user_install_count(mut self, user_install_count: i32) -> Self { + self.merge_set(doc! { "userInstallCount": user_install_count }); + self + } + + pub fn with_user_type(mut self, user_type: &str, count: i32) -> Self { + self.merge_inc(doc! { format!("usersType.{}", user_type): count }); + self + } + + fn build_locale_update(field: &str, updates: &[(&str, i32)]) -> Document { + let new_locales = updates + .iter() + .map(|(locale, number)| { + doc! { + "locale": locale, + "number": number, + } + }) + .collect::>(); + + doc! { + field: { + "$let": { + "vars": { "locales": { "$ifNull": [ format!("${field}"), [] ] } }, + "in": { + "$reduce": { + "input": new_locales, + "initialValue": "$$locales", + "in": { + "$let": { + "vars": { "existing": "$$value" }, + "in": { + "$cond": [ + { "$in": [ "$$this.locale", "$$existing.locale" ] }, + { + "$map": { + "input": "$$existing", + "as": "l", + "in": { + "$cond": [ + { "$eq": [ "$$l.locale", "$$this.locale" ] }, + { + "locale": "$$l.locale", + "number": { "$add": [ "$$l.number", "$$this.number" ] } + }, + "$$l" + ] + } + } + }, + { + "$concatArrays": [ "$$existing", [ "$$this" ] ] + } + ] + } + } + } + } + } + } + } + } + } + + fn merge_set(&mut self, doc: Document) { + let set_doc = self + .updates + .entry("$set") + .or_insert_with(|| Bson::Document(doc! {})); + + if let Bson::Document(existing) = set_doc { + existing.extend(doc); + } + } + + fn merge_inc(&mut self, doc: Document) { + let inc_doc = self + .updates + .entry("$inc") + .or_insert_with(|| Bson::Document(doc! {})); + + if let Bson::Document(existing) = inc_doc { + existing.extend(doc); + } + } + + pub fn build(self) -> Document { + self.updates + } +} #[derive(Clone)] pub struct BotStatsRepository { @@ -14,10 +301,26 @@ pub struct BotStatsRepository { } impl BotStatsRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(BOT_STATS_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == BOT_STATS_COLLECTION) + { + let ts_opts = TimeseriesOptions::builder() + .time_field("date") + .meta_field(Some("botId".to_owned())) + .granularity(Some(TimeseriesGranularity::Hours)) + .build(); + db.create_collection(BOT_STATS_COLLECTION) + .timeseries(ts_opts) + .await?; } + + Ok(Self { + collection: db.collection(BOT_STATS_COLLECTION), + }) } pub async fn find_by_bot_id(&self, bot_id: &str) -> Result> { @@ -25,15 +328,30 @@ impl BotStatsRepository { cursor.try_collect().await } - pub async fn find_by_date_range( + pub async fn find_by_date(&self, bot_id: &str, date: &DateTime) -> Result> { + self.collection + .find_one(doc! { "botId": bot_id, "date": date }) + .await + } + + pub async fn find_from_date_range( &self, bot_id: &str, - start_date: &DateTime, - end_date: &DateTime, + from: &DateTime, + to: &DateTime, ) -> Result> { + let options = FindOptions::builder().sort(doc! { "date": 1 }).build(); + let cursor = self .collection - .find(doc! { "botId": bot_id, "date": { "$gte": start_date, "$lte": end_date } }) + .find(doc! { + "botId": bot_id, + "date": { + "$gte": from, + "$lte": to + } + }) + .with_options(options) .await?; cursor.try_collect().await } @@ -42,18 +360,29 @@ impl BotStatsRepository { self.collection.insert_one(bot_stats).await } - pub async fn update(&self, bot_stats: &BotStats) -> Result { + pub async fn update( + &self, + bot_id: &str, + date: &DateTime, + updated_bot_stats: BotStatsUpdate, + ) -> Result> { + let updates = updated_bot_stats.build(); + + if updates.is_empty() { + return Ok(None); + } + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "botId": &bot_stats.bot_id, "date": &bot_stats.date }, - doc! { "$set": serialize_to_document(bot_stats)? }, - ) + .find_one_and_update(doc! { "botId": bot_id, "date": date }, updates) + .with_options(options) .await } - pub async fn delete(&self, bot_id: &str, date: &DateTime) -> Result { - self.collection - .delete_one(doc! { "botId": bot_id, "date": date }) - .await + pub async fn delete_by_bot_id(&self, bot_id: &str) -> Result { + self.collection.delete_many(doc! { "botId": bot_id }).await } } diff --git a/src/repository/bots.rs b/src/repository/bots.rs index 62341acf..98a514a2 100644 --- a/src/repository/bots.rs +++ b/src/repository/bots.rs @@ -1,12 +1,106 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{doc, serialize_to_document}, + bson::{Bson, Document, doc}, error::Result, - results::{DeleteResult, InsertOneResult, UpdateResult}, + options::{FindOneAndUpdateOptions, ReturnDocument}, + results::{DeleteResult, InsertOneResult}, }; -use crate::{domain::models::Bot, utils::constants::BOTS_COLLECTION}; +use crate::{ + domain::models::{Bot, WebhookConfig}, + utils::constants::BOTS_COLLECTION, +}; + +#[derive(Clone, Default)] +pub struct BotUpdate { + updates: Document, +} + +impl BotUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn with_advanced_stats(mut self, advanced_stats: bool) -> Self { + self.merge_set(doc! { "advancedStats": advanced_stats }); + self + } + + pub fn with_avatar(mut self, avatar: String) -> Self { + self.merge_set(doc! { "avatar": avatar }); + self + } + + pub fn with_framework(mut self, framework: String) -> Self { + self.merge_set(doc! { "framework": framework }); + self + } + + pub fn with_suspended(mut self, suspended: bool) -> Self { + self.merge_set(doc! { "suspended": suspended }); + self + } + + pub fn with_team(mut self, team: Vec) -> Self { + self.merge_set(doc! { "team": team }); + self + } + + pub fn with_team_member(mut self, user_id: &str) -> Self { + self.merge_set(doc! { "team": user_id }); + self + } + + pub fn with_token(mut self, token: String) -> Self { + self.merge_set(doc! { "token": token }); + self + } + + pub fn with_username(mut self, username: String) -> Self { + self.merge_set(doc! { "username": username }); + self + } + + pub fn with_version(mut self, version: String) -> Self { + self.merge_set(doc! { "version": version }); + self + } + + pub fn with_webhook_config( + mut self, + provider: &str, + config: WebhookConfig, + webhook_url: Option<&str>, + ) -> Self { + self.merge_set( + doc! { format!("webhooksConfig.webhooks.{}", provider): doc! { + "connectionId": config.connection_id, + "webhookSecret": config.webhook_secret, + }}, + ); + + if let Some(url) = webhook_url { + self.merge_set(doc! { "webhooksConfig.webhookUrl": url }); + } + self + } + + fn merge_set(&mut self, doc: Document) { + let set_doc = self + .updates + .entry("$set") + .or_insert_with(|| Bson::Document(doc! {})); + + if let Bson::Document(existing) = set_doc { + existing.extend(doc); + } + } + + pub fn build(self) -> Document { + self.updates + } +} #[derive(Clone)] pub struct BotsRepository { @@ -14,10 +108,19 @@ pub struct BotsRepository { } impl BotsRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(BOTS_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == BOTS_COLLECTION) + { + db.create_collection(BOTS_COLLECTION).await?; } + + Ok(Self { + collection: db.collection(BOTS_COLLECTION), + }) } pub async fn find_all(&self) -> Result> { @@ -25,25 +128,70 @@ impl BotsRepository { cursor.try_collect().await } + pub async fn count_bots(&self) -> Result { + self.collection.count_documents(doc! {}).await + } + pub async fn find_by_id(&self, bot_id: &str) -> Result> { self.collection.find_one(doc! { "botId": bot_id }).await } - pub async fn find_by_owner(&self, owner_id: &str) -> Result> { + pub async fn find_by_user_id(&self, user_id: &str) -> Result> { + let cursor = self + .collection + .find(doc! { + "$or": [ + { "ownerId": user_id }, + { "team": { "$in": [user_id] } } + ] + }) + .await?; + cursor.try_collect().await + } + + pub async fn find_by_owner_id(&self, owner_id: &str) -> Result> { let cursor = self.collection.find(doc! { "ownerId": owner_id }).await?; cursor.try_collect().await } + pub async fn count_by_user_id(&self, user_id: &str) -> Result { + self.collection + .count_documents(doc! { "ownerId": user_id }) + .await + } + pub async fn insert(&self, bot: &Bot) -> Result { self.collection.insert_one(bot).await } - pub async fn update(&self, updated_bot: &Bot) -> Result { + pub async fn update(&self, bot_id: &str, updated_bot: BotUpdate) -> Result> { + let updates = updated_bot.build(); + + if updates.is_empty() { + return Ok(None); + } + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + + self.collection + .find_one_and_update(doc! { "botId": bot_id }, updates) + .with_options(options) + .await + } + + pub async fn remove_user_from_team(&self, bot_id: &str, user_id: &str) -> Result> { + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "botId": &updated_bot.bot_id }, - doc! { "$set": serialize_to_document(updated_bot)? }, + .find_one_and_update( + doc! { "botId": bot_id }, + doc! { "$pull": { "team": user_id } }, ) + .with_options(options) .await } diff --git a/src/repository/connection.rs b/src/repository/connection.rs index 76b3ca61..2c00dc8a 100644 --- a/src/repository/connection.rs +++ b/src/repository/connection.rs @@ -1,4 +1,4 @@ -use mongodb::{Client, Database, error::Result}; +use mongodb::{Client, Database, bson::doc, error::Result}; use crate::{app_env, utils::constants::DB_NAME}; @@ -17,4 +17,9 @@ impl DbConnection { pub fn database(&self) -> &Database { &self.db } + + pub async fn ping(&self) -> Result<()> { + self.db.run_command(doc! {"ping": 1}).await?; + Ok(()) + } } diff --git a/src/repository/custom_events.rs b/src/repository/custom_events.rs index 6986471d..f2d344e8 100644 --- a/src/repository/custom_events.rs +++ b/src/repository/custom_events.rs @@ -1,46 +1,111 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{doc, serialize_to_document}, + bson::{Document, doc}, error::Result, - results::{DeleteResult, InsertOneResult, UpdateResult}, + options::{FindOneAndUpdateOptions, ReturnDocument}, + results::{DeleteResult, InsertOneResult}, }; use crate::{domain::models::CustomEvent, utils::constants::CUSTOM_EVENTS_COLLECTION}; +#[derive(Clone, Default)] +pub struct CustomEventUpdate { + updates: Document, +} + +impl CustomEventUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn with_event_key(mut self, event_key: &str) -> Self { + self.updates.insert("eventKey", event_key); + self + } + + pub fn with_graph_name(mut self, graph_name: &str) -> Self { + self.updates.insert("graphName", graph_name); + self + } + + pub fn build(self) -> Document { + self.updates + } +} + #[derive(Clone)] pub struct CustomEventsRepository { collection: Collection, } impl CustomEventsRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(CUSTOM_EVENTS_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == CUSTOM_EVENTS_COLLECTION) + { + db.create_collection(CUSTOM_EVENTS_COLLECTION).await?; } + + Ok(Self { + collection: db.collection(CUSTOM_EVENTS_COLLECTION), + }) } pub async fn find_by_bot_id(&self, bot_id: &str) -> Result> { - let cursor = self.collection.find(doc! { "bot_id": bot_id }).await?; + let cursor = self.collection.find(doc! { "botId": bot_id }).await?; cursor.try_collect().await } + pub async fn find_by_bot_id_and_event_key( + &self, + bot_id: &str, + event_key: &str, + ) -> Result> { + self.collection + .find_one(doc! { "botId": bot_id, "eventKey": event_key }) + .await + } + pub async fn insert(&self, custom_event: &CustomEvent) -> Result { self.collection.insert_one(custom_event).await } - pub async fn update(&self, custom_event: &CustomEvent) -> Result { + pub async fn update( + &self, + bot_id: &str, + event_key: &str, + updated_custom_event: CustomEventUpdate, + ) -> Result> { + let updates = updated_custom_event.build(); + + if updates.is_empty() { + return Ok(None); + } + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "bot_id": &custom_event.bot_id, "event_key": &custom_event.event_key }, - doc! { "$set": serialize_to_document(custom_event)? }, + .find_one_and_update( + doc! { "botId": bot_id, "eventKey": event_key }, + doc! { "$set": updates }, ) + .with_options(options) .await } - pub async fn delete(&self, bot_id: &str, event_key: &str) -> Result { + pub async fn delete_by_event_key(&self, bot_id: &str, event_key: &str) -> Result { self.collection - .delete_one(doc! { "bot_id": bot_id, "event_key": event_key }) + .delete_one(doc! { "botId": bot_id, "eventKey": event_key }) .await } + + pub async fn delete_by_bot_id(&self, bot_id: &str) -> Result { + self.collection.delete_many(doc! { "botId": bot_id }).await + } } diff --git a/src/repository/global_stats.rs b/src/repository/global_stats.rs index c496d7ee..04c551bc 100644 --- a/src/repository/global_stats.rs +++ b/src/repository/global_stats.rs @@ -1,54 +1,112 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{DateTime, doc, serialize_to_document}, + bson::{DateTime, Document, doc}, error::Result, - results::{DeleteResult, InsertOneResult, UpdateResult}, + options::{FindOneAndUpdateOptions, FindOptions, ReturnDocument}, + results::InsertOneResult, }; use crate::{domain::models::GlobalStats, utils::constants::GLOBAL_STATS_COLLECTION}; +#[derive(Clone, Default)] +pub struct GlobalStatsUpdate { + updates: Document, +} + +impl GlobalStatsUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn with_bot_count(mut self, bot_count: i32) -> Self { + self.updates.insert("botCount", bot_count); + self + } + + pub fn with_registered_bots(mut self, registered_bots: i32) -> Self { + self.updates.insert("registeredBots", registered_bots); + self + } + + pub fn with_user_count(mut self, user_count: i32) -> Self { + self.updates.insert("userCount", user_count); + self + } + + pub fn build(self) -> Document { + self.updates + } +} + #[derive(Clone)] pub struct GlobalStatsRepository { collection: Collection, } impl GlobalStatsRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(GLOBAL_STATS_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == GLOBAL_STATS_COLLECTION) + { + db.create_collection(GLOBAL_STATS_COLLECTION).await?; } + + Ok(Self { + collection: db.collection(GLOBAL_STATS_COLLECTION), + }) } - pub async fn find_all(&self) -> Result> { - let cursor = self.collection.find(doc! {}).await?; - cursor.try_collect().await + pub async fn find_one(&self, date: &DateTime) -> Result> { + self.collection.find_one(doc! { "date": date }).await } - pub async fn find_by_date_range( + pub async fn find_from_date_range( &self, start_date: &DateTime, end_date: &DateTime, - ) -> Result> { - self.collection - .find_one(doc! { "date": { "$gte": start_date, "$lte": end_date } }) - .await + ) -> Result> { + let options = FindOptions::builder().sort(doc! { "date": 1 }).build(); + + let cursor = self + .collection + .find(doc! { + "date": { + "$gte": start_date, + "$lte": end_date, + } + }) + .with_options(options) + .await?; + + cursor.try_collect().await } pub async fn insert(&self, global_stats: &GlobalStats) -> Result { self.collection.insert_one(global_stats).await } - pub async fn update(&self, global_stats: &GlobalStats) -> Result { + pub async fn update( + &self, + date: &DateTime, + updated_global_stats: GlobalStatsUpdate, + ) -> Result> { + let updates = updated_global_stats.build(); + + if updates.is_empty() { + return Ok(None); + } + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "date": &global_stats.date }, - doc! { "$set": serialize_to_document(global_stats)? }, - ) + .find_one_and_update(doc! { "date": date }, doc! { "$set": updates }) + .with_options(options) .await } - - pub async fn delete(&self, date: &DateTime) -> Result { - self.collection.delete_one(doc! { "date": date }).await - } } diff --git a/src/repository/mod.rs b/src/repository/mod.rs index c00f70d9..1135aede 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -5,12 +5,27 @@ mod bots; mod connection; mod custom_events; mod global_stats; +#[cfg(feature = "reports")] mod r2; +mod sessions; +#[cfg(feature = "reports")] mod stats_reports; mod team_invitations; mod users; mod votes; +pub use achievements::AchievementUpdate; +use anyhow::Result; +pub use blog_articles::BlogArticleUpdate; +pub use bot_stats::BotStatsUpdate; +pub use bots::BotUpdate; +pub use custom_events::CustomEventUpdate; +pub use global_stats::GlobalStatsUpdate; +#[cfg(feature = "reports")] +pub use stats_reports::StatsReportUpdate; +pub use team_invitations::TeamInvitationUpdate; +pub use users::UserUpdate; + #[derive(Clone)] pub struct Repositories { pub achievements: achievements::AchievementsRepository, @@ -18,8 +33,12 @@ pub struct Repositories { pub bots: bots::BotsRepository, pub bot_stats: bot_stats::BotStatsRepository, pub custom_events: custom_events::CustomEventsRepository, + database: connection::DbConnection, pub global_stats: global_stats::GlobalStatsRepository, + pub sessions: sessions::SessionsRepository, + #[cfg(feature = "reports")] pub r2: r2::R2Repository, + #[cfg(feature = "reports")] pub stats_reports: stats_reports::StatsReportsRepository, pub team_invitations: team_invitations::TeamInvitationsRepository, pub users: users::UsersRepository, @@ -27,22 +46,34 @@ pub struct Repositories { } impl Repositories { - pub async fn init() -> anyhow::Result { + pub async fn init() -> Result { let connection = connection::DbConnection::init().await?; let db = connection.database(); Ok(Self { - achievements: achievements::AchievementsRepository::new(db), - blog_articles: blog_articles::BlogArticlesRepository::new(db), - bots: bots::BotsRepository::new(db), - bot_stats: bot_stats::BotStatsRepository::new(db), - custom_events: custom_events::CustomEventsRepository::new(db), - global_stats: global_stats::GlobalStatsRepository::new(db), + achievements: achievements::AchievementsRepository::new(db).await?, + blog_articles: blog_articles::BlogArticlesRepository::new(db).await?, + bots: bots::BotsRepository::new(db).await?, + bot_stats: bot_stats::BotStatsRepository::new(db).await?, + custom_events: custom_events::CustomEventsRepository::new(db).await?, + database: connection.clone(), + global_stats: global_stats::GlobalStatsRepository::new(db).await?, + sessions: sessions::SessionsRepository::new(db).await?, + #[cfg(feature = "reports")] r2: r2::R2Repository::new()?, - stats_reports: stats_reports::StatsReportsRepository::new(db), - team_invitations: team_invitations::TeamInvitationsRepository::new(db), - users: users::UsersRepository::new(db), - votes: votes::VotesRepository::new(db), + #[cfg(feature = "reports")] + stats_reports: stats_reports::StatsReportsRepository::new(db).await?, + team_invitations: team_invitations::TeamInvitationsRepository::new(db).await?, + users: users::UsersRepository::new(db).await?, + votes: votes::VotesRepository::new(db).await?, }) } + + pub async fn ping(&self) -> Result<()> { + self.database.ping().await?; + #[cfg(feature = "reports")] + self.r2.ping().await?; + + Ok(()) + } } diff --git a/src/repository/r2.rs b/src/repository/r2.rs index 18ffae69..9ee0d71c 100644 --- a/src/repository/r2.rs +++ b/src/repository/r2.rs @@ -31,6 +31,11 @@ impl R2Repository { }) } + pub async fn ping(&self) -> Result<()> { + self.client.buckets().list().send().await?; + Ok(()) + } + pub async fn put_object(&self, key: &str, body: &[u8], content_type: &str) -> Result<()> { self.client .objects() diff --git a/src/repository/sessions.rs b/src/repository/sessions.rs new file mode 100644 index 00000000..816a3f1f --- /dev/null +++ b/src/repository/sessions.rs @@ -0,0 +1,90 @@ +use futures::stream::TryStreamExt as _; +use mongodb::{ + Collection, Database, + bson::{DateTime, doc}, + error::Result, + results::{InsertOneResult, UpdateResult}, +}; + +use crate::{domain::models::Session, utils::constants::SESSIONS_COLLECTION}; + +#[derive(Clone)] +pub struct SessionsRepository { + collection: Collection, +} + +impl SessionsRepository { + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == SESSIONS_COLLECTION) + { + db.create_collection(SESSIONS_COLLECTION).await?; + } + + Ok(Self { + collection: db.collection(SESSIONS_COLLECTION), + }) + } + + pub async fn insert(&self, session: &Session) -> Result { + self.collection.insert_one(session).await + } + + pub async fn find_by_id(&self, session_id: &str) -> Result> { + self.collection + .find_one(doc! { "sessionId": session_id }) + .await + } + + pub async fn find_by_user_id(&self, user_id: &str) -> Result> { + let cursor = self + .collection + .find(doc! { + "active": true, + "expiresAt": { "$gt": DateTime::now() }, + "userId": user_id, + }) + .await?; + cursor.try_collect().await + } + + pub async fn update_last_used(&self, session_id: &str) -> Result { + self.collection + .update_one( + doc! { "sessionId": session_id }, + doc! { "$set": { "lastUsedAt": DateTime::now() } }, + ) + .await + } + + pub async fn revoke(&self, session_id: &str) -> Result { + self.collection + .update_one( + doc! { "sessionId": session_id }, + doc! { "$set": { "active": false } }, + ) + .await + } + + pub async fn revoke_all_for_user(&self, user_id: &str) -> Result { + self.collection + .update_many( + doc! { "userId": user_id }, + doc! { "$set": { "active": false } }, + ) + .await + } + + pub async fn delete_expired(&self) -> Result { + let result = self + .collection + .delete_many(doc! { + "expiresAt": { "$lt": DateTime::now() }, + }) + .await?; + Ok(result.deleted_count) + } +} diff --git a/src/repository/stats_reports.rs b/src/repository/stats_reports.rs index 7a95879c..2fe7f249 100644 --- a/src/repository/stats_reports.rs +++ b/src/repository/stats_reports.rs @@ -1,23 +1,48 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{doc, serialize_to_document}, + bson::{Document, doc}, error::Result, - results::{DeleteResult, InsertOneResult, UpdateResult}, + options::{FindOneAndUpdateOptions, ReturnDocument}, + results::{DeleteResult, InsertOneResult}, }; use crate::{domain::models::StatsReport, utils::constants::STATS_REPORTS_COLLECTION}; +#[derive(Clone, Default)] +pub struct StatsReportUpdate { + updates: Document, +} + +impl StatsReportUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn build(self) -> Document { + self.updates + } +} + #[derive(Clone)] pub struct StatsReportsRepository { collection: Collection, } impl StatsReportsRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(STATS_REPORTS_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == STATS_REPORTS_COLLECTION) + { + db.create_collection(STATS_REPORTS_COLLECTION).await?; } + + Ok(Self { + collection: db.collection(STATS_REPORTS_COLLECTION), + }) } pub async fn find_all(&self) -> Result> { @@ -26,31 +51,63 @@ impl StatsReportsRepository { } pub async fn find_by_bot(&self, bot_id: &str) -> Result> { - let cursor = self.collection.find(doc! { "bot_id": bot_id }).await?; + let cursor = self.collection.find(doc! { "botId": bot_id }).await?; cursor.try_collect().await } pub async fn find_by_user(&self, user_id: &str) -> Result> { - let cursor = self.collection.find(doc! { "user_id": user_id }).await?; + let cursor = self.collection.find(doc! { "userId": user_id }).await?; cursor.try_collect().await } + pub async fn find_by_bot_and_user( + &self, + bot_id: &str, + user_id: &str, + ) -> Result> { + self.collection + .find_one(doc! { "botId": bot_id, "userId": user_id }) + .await + } + pub async fn insert(&self, stats_report: &StatsReport) -> Result { self.collection.insert_one(stats_report).await } - pub async fn update(&self, stats_report: &StatsReport) -> Result { + pub async fn update( + &self, + doc_id: &str, + updated_stats_report: StatsReportUpdate, + ) -> Result> { + let updates = updated_stats_report.build(); + + if updates.is_empty() { + return Ok(None); + } + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "_id": &stats_report.id }, - doc! { "$set": serialize_to_document(stats_report)? }, - ) + .find_one_and_update(doc! { "_id": doc_id }, doc! { "$set": updates }) + .with_options(options) .await } - pub async fn delete(&self, stats_report_id: &str) -> Result { + pub async fn delete_by_id(&self, stats_report_id: &str) -> Result { self.collection .delete_one(doc! { "_id": stats_report_id }) .await } + + pub async fn delete_by_bot_id(&self, bot_id: &str) -> Result { + self.collection.delete_many(doc! { "botId": bot_id }).await + } + + pub async fn delete_by_user_id(&self, user_id: &str) -> Result { + self.collection + .delete_many(doc! { "userId": user_id }) + .await + } } diff --git a/src/repository/team_invitations.rs b/src/repository/team_invitations.rs index bb88956e..28348fd9 100644 --- a/src/repository/team_invitations.rs +++ b/src/repository/team_invitations.rs @@ -1,23 +1,47 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{doc, serialize_to_document}, + bson::{DateTime, Document, doc}, error::Result, results::{DeleteResult, InsertOneResult, UpdateResult}, }; use crate::{domain::models::TeamInvitation, utils::constants::TEAM_INVITATIONS_COLLECTION}; +#[derive(Clone, Default)] +pub struct TeamInvitationUpdate { + updates: Document, +} + +impl TeamInvitationUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn build(self) -> Document { + self.updates + } +} + #[derive(Clone)] pub struct TeamInvitationsRepository { collection: Collection, } impl TeamInvitationsRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(TEAM_INVITATIONS_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == TEAM_INVITATIONS_COLLECTION) + { + db.create_collection(TEAM_INVITATIONS_COLLECTION).await?; } + + Ok(Self { + collection: db.collection(TEAM_INVITATIONS_COLLECTION), + }) } pub async fn find_all(&self) -> Result> { @@ -27,36 +51,64 @@ impl TeamInvitationsRepository { pub async fn find_by_id(&self, team_invitation_id: &str) -> Result> { self.collection - .find_one(doc! { "invitation_id": team_invitation_id }) + .find_one(doc! { "invitationId": team_invitation_id }) .await } - pub async fn find_by_bot(&self, bot_id: &str) -> Result> { - let cursor = self.collection.find(doc! { "bot_id": bot_id }).await?; - cursor.try_collect().await - } - - pub async fn find_by_user(&self, user_id: &str) -> Result> { - let cursor = self.collection.find(doc! { "user_id": user_id }).await?; - cursor.try_collect().await + pub async fn find_by_bot_and_user( + &self, + bot_id: &str, + user_id: &str, + ) -> Result> { + self.collection + .find_one(doc! { "botId": bot_id, "userId": user_id }) + .await } pub async fn insert(&self, team_invitation: &TeamInvitation) -> Result { self.collection.insert_one(team_invitation).await } - pub async fn update(&self, team_invitation: &TeamInvitation) -> Result { + pub async fn accept_invitation(&self, invitation_id: &str) -> Result { self.collection .update_one( - doc! { "invitation_id": &team_invitation.invitation_id }, - doc! { "$set": serialize_to_document(team_invitation)? }, + doc! { "invitationId": invitation_id }, + doc! { "$set": { "accepted": true } }, ) .await } - pub async fn delete(&self, team_invitation_id: &str) -> Result { + pub async fn delete_by_id(&self, team_invitation_id: &str) -> Result { + self.collection + .delete_one(doc! { "invitationId": team_invitation_id }) + .await + } + + pub async fn delete_by_bot_id(&self, bot_id: &str) -> Result { + self.collection.delete_many(doc! { "botId": bot_id }).await + } + + pub async fn delete_by_user_id(&self, user_id: &str) -> Result { + self.collection + .delete_many(doc! { "userId": user_id }) + .await + } + + pub async fn delete_by_bot_and_user( + &self, + bot_id: &str, + user_id: &str, + ) -> Result { self.collection - .delete_one(doc! { "invitation_id": team_invitation_id }) + .delete_one(doc! { "botId": bot_id, "userId": user_id }) .await } + + pub async fn delete_expired_invitations(&self) -> Result { + let result = self + .collection + .delete_many(doc! { "expiration": { "$lte": DateTime::now() } }) + .await?; + Ok(result.deleted_count) + } } diff --git a/src/repository/users.rs b/src/repository/users.rs index 87514006..f9b0120d 100644 --- a/src/repository/users.rs +++ b/src/repository/users.rs @@ -1,23 +1,78 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{doc, serialize_to_document}, + bson::{Document, doc}, error::Result, - results::{DeleteResult, InsertOneResult, UpdateResult}, + options::{FindOneAndUpdateOptions, ReturnDocument}, + results::{DeleteResult, InsertOneResult}, }; use crate::{domain::models::User, utils::constants::USERS_COLLECTION}; +#[derive(Clone, Default)] +pub struct UserUpdate { + updates: Document, +} + +impl UserUpdate { + pub fn new() -> Self { + Self::default() + } + + pub fn with_avatar(mut self, avatar: String) -> Self { + self.updates.insert("avatar", avatar); + self + } + + pub fn with_avatar_decoration(mut self, avatar_decoration: String) -> Self { + self.updates.insert("avatarDecoration", avatar_decoration); + self + } + + pub fn with_bots_limit(mut self, bots_limit: i32) -> Self { + self.updates.insert("botsLimit", bots_limit); + self + } + + pub fn with_mail(mut self, mail: String) -> Self { + self.updates.insert("mail", mail); + self + } + + pub fn with_suspended(mut self, suspended: bool) -> Self { + self.updates.insert("suspended", suspended); + self + } + + pub fn with_username(mut self, username: String) -> Self { + self.updates.insert("username", username); + self + } + + pub fn build(self) -> Document { + self.updates + } +} + #[derive(Clone)] pub struct UsersRepository { collection: Collection, } impl UsersRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(USERS_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == USERS_COLLECTION) + { + db.create_collection(USERS_COLLECTION).await?; } + + Ok(Self { + collection: db.collection(USERS_COLLECTION), + }) } pub async fn find_all(&self) -> Result> { @@ -25,28 +80,36 @@ impl UsersRepository { cursor.try_collect().await } - pub async fn find_by_id(&self, user_id: &str) -> Result> { - self.collection.find_one(doc! { "userId": user_id }).await + pub async fn count_users(&self) -> Result { + self.collection.count_documents(doc! {}).await } - pub async fn find_by_token(&self, token: &str) -> Result> { - self.collection.find_one(doc! { "token": token }).await + pub async fn find_by_id(&self, user_id: &str) -> Result> { + self.collection.find_one(doc! { "userId": user_id }).await } pub async fn insert(&self, user: &User) -> Result { self.collection.insert_one(user).await } - pub async fn update(&self, user: &User) -> Result { + pub async fn update(&self, user_id: &str, updated_user: UserUpdate) -> Result> { + let updates = updated_user.build(); + + if updates.is_empty() { + return Ok(None); + } + + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "userId": &user.user_id }, - doc! { "$set": serialize_to_document(user)? }, - ) + .find_one_and_update(doc! { "userId": user_id }, doc! { "$set": updates }) + .with_options(options) .await } - pub async fn delete(&self, user_id: &str) -> Result { + pub async fn delete_by_id(&self, user_id: &str) -> Result { self.collection.delete_one(doc! { "userId": user_id }).await } } diff --git a/src/repository/votes.rs b/src/repository/votes.rs index 89edff02..52e58296 100644 --- a/src/repository/votes.rs +++ b/src/repository/votes.rs @@ -1,9 +1,10 @@ use futures::stream::TryStreamExt as _; use mongodb::{ Collection, Database, - bson::{DateTime, doc, serialize_to_document}, + bson::{DateTime, doc}, error::Result, - results::{DeleteResult, InsertOneResult, UpdateResult}, + options::{FindOneAndUpdateOptions, ReturnDocument, TimeseriesGranularity, TimeseriesOptions}, + results::{DeleteResult, InsertOneResult}, }; use crate::{domain::models::Vote, utils::constants::VOTES_COLLECTION}; @@ -14,49 +15,82 @@ pub struct VotesRepository { } impl VotesRepository { - pub fn new(db: &Database) -> Self { - Self { - collection: db.collection(VOTES_COLLECTION), + pub async fn new(db: &Database) -> Result { + if !db + .list_collection_names() + .await? + .iter() + .any(|name| name == VOTES_COLLECTION) + { + let ts_opts = TimeseriesOptions::builder() + .time_field("date") + .meta_field(Some("botId".to_owned())) + .granularity(Some(TimeseriesGranularity::Hours)) + .build(); + db.create_collection(VOTES_COLLECTION) + .timeseries(ts_opts) + .await?; } - } - pub async fn find_all(&self) -> Result> { - let cursor = self.collection.find(doc! {}).await?; - cursor.try_collect().await + Ok(Self { + collection: db.collection(VOTES_COLLECTION), + }) } - pub async fn find_by_bot(&self, bot_id: &str) -> Result> { - let cursor = self.collection.find(doc! { "botId": bot_id }).await?; - cursor.try_collect().await + pub async fn find_by_date(&self, bot_id: &str, date: &DateTime) -> Result> { + self.collection + .find_one(doc! { "botId": bot_id, "date": date }) + .await } - pub async fn find_by_bot_and_date_range( + pub async fn find_from_date_range( &self, bot_id: &str, - start_date: &DateTime, - end_date: &DateTime, + from: &DateTime, + to: &DateTime, ) -> Result> { self.collection - .find_one(doc! { "botId": bot_id, "date": { "$gte": start_date, "$lte": end_date } }) + .find_one(doc! { "botId": bot_id, "date": { "$gte": from, "$lte": to } }) .await } + pub async fn count_votes_since(&self, bot_id: &str, since: &DateTime) -> Result { + let mut cursor = self + .collection + .find(doc! { "botId": bot_id, "date": { "$gte": since } }) + .await?; + let mut total = 0i64; + while let Some(vote) = cursor.try_next().await? { + total += vote.votes.values().map(|&count| count as i64).sum::(); + } + Ok(total) + } + pub async fn insert(&self, vote: &Vote) -> Result { self.collection.insert_one(vote).await } - pub async fn update(&self, vote: &Vote) -> Result { + pub async fn increment_count( + &self, + bot_id: &str, + date: &DateTime, + provider: &str, + increment_by: i32, + ) -> Result> { + let options = FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + self.collection - .update_one( - doc! { "botId": &vote.bot_id, "date": &vote.date }, - doc! { "$set": serialize_to_document(vote)? }, + .find_one_and_update( + doc! { "botId": bot_id, "date": date }, + doc! { "$inc": { provider: increment_by } }, ) + .with_options(options) .await } - pub async fn delete(&self, bot_id: &str, date: &DateTime) -> Result { - self.collection - .delete_one(doc! { "botId": bot_id, "date": date }) - .await + pub async fn delete_by_bot_id(&self, bot_id: &str) -> Result { + self.collection.delete_many(doc! { "botId": bot_id }).await } } diff --git a/src/services/auth.rs b/src/services/auth.rs index d15d8005..fff9eff3 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -27,21 +27,6 @@ impl AuthService { } } - pub async fn verify_user_token(&self, token: &str) -> Result { - let user = self.repos.users.find_by_token(token).await?; - match user { - Some(user) => { - let auth_type = if self.is_admin(&user.user_id) { - AuthType::Admin - } else { - AuthType::User - }; - Ok(AuthContext::new(auth_type).with_user_id(user.user_id)) - } - None => bail!("User not found or invalid token"), - } - } - pub fn is_admin(&self, user_id: &str) -> bool { app_env!().admins.iter().any(|admin_id| admin_id == user_id) } diff --git a/src/services/bots.rs b/src/services/bots.rs new file mode 100644 index 00000000..6d3522b6 --- /dev/null +++ b/src/services/bots.rs @@ -0,0 +1,27 @@ +use anyhow::Result; + +use crate::repository::Repositories; + +#[derive(Clone)] +pub struct BotsService { + repos: Repositories, +} + +impl BotsService { + pub fn new(repos: Repositories) -> Self { + Self { repos } + } + + pub async fn delete_bot(&self, bot_id: &str) -> Result<()> { + self.repos.bots.delete(bot_id).await?; + self.repos.bot_stats.delete_by_bot_id(bot_id).await?; + self.repos.votes.delete_by_bot_id(bot_id).await?; + self.repos.achievements.delete_by_bot_id(bot_id).await?; + self.repos.team_invitations.delete_by_bot_id(bot_id).await?; + #[cfg(feature = "reports")] + self.repos.stats_reports.delete_by_bot_id(bot_id).await?; + self.repos.custom_events.delete_by_bot_id(bot_id).await?; + + Ok(()) + } +} diff --git a/src/services/discord.rs b/src/services/discord.rs new file mode 100644 index 00000000..80aa661a --- /dev/null +++ b/src/services/discord.rs @@ -0,0 +1,176 @@ +use anyhow::{Result, anyhow}; +use reqwest::Client; +use tracing::error; + +use crate::{ + app_env, + openapi::schemas::{DiscordBot, DiscordOAuthUser, DiscordTokenResponse}, + utils::logger::LogCode, +}; + +#[derive(Clone)] +pub struct DiscordService { + client: Client, +} + +impl DiscordService { + pub fn new() -> Self { + Self { + client: Client::new(), + } + } + + pub async fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + scopes: &str, + ) -> Result { + let params = [ + ("client_id", app_env!().client_id.as_str()), + ("client_secret", app_env!().client_secret.as_str()), + ("grant_type", "authorization_code"), + ("redirect_uri", redirect_uri), + ("code", code), + ("scope", scopes), + ]; + + let response = self + .client + .post("https://discord.com/api/oauth2/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + error!( + code = %LogCode::Auth, + error = %error_text, + "Failed to exchange OAuth code" + ); + return Err(anyhow!( + "Discord OAuth token exchange failed: {}", + error_text + )); + } + + Ok(response.json().await?) + } + + pub async fn get_user(&self, token_type: &str, access_token: &str) -> Result { + let response = self + .client + .get("https://discord.com/api/users/@me") + .header("Authorization", format!("{} {}", token_type, access_token)) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + error!( + code = %LogCode::Auth, + error = %error_text, + "Failed to fetch Discord user" + ); + return Err(anyhow!("Failed to fetch Discord user: {}", error_text)); + } + + Ok(response.json().await?) + } + + pub async fn get_bot(&self, bot_id: &str) -> Result { + let response = self + .client + .get(format!("https://discord.com/api/users/{}", bot_id)) + .header("Authorization", format!("Bot {}", app_env!().discord_token)) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + error!( + code = %LogCode::Auth, + error = %error_text, + "Failed to fetch Discord bot" + ); + return Err(anyhow!("Failed to fetch Discord bot: {}", error_text)); + } + + Ok(response.json().await?) + } + + pub async fn exchange_linked_roles_code(&self, code: &str) -> Result { + let redirect_uri = format!("{}/auth/linkedroles", app_env!().api_url); + let params = [ + ("client_id", app_env!().client_id.as_str()), + ("client_secret", app_env!().client_secret.as_str()), + ("grant_type", "authorization_code"), + ("redirect_uri", redirect_uri.as_str()), + ("code", code), + ("scope", "role_connections.write identify"), + ]; + + let response = self + .client + .post("https://discord.com/api/oauth2/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + error!( + code = %LogCode::Auth, + error = %error_text, + "Failed to exchange linked roles OAuth code" + ); + return Err(anyhow!( + "Discord linked roles OAuth token exchange failed: {}", + error_text + )); + } + + Ok(response.json().await?) + } + + pub async fn update_role_connection( + &self, + token_type: &str, + access_token: &str, + bot_count: i32, + ) -> Result<()> { + let response = self + .client + .put(format!( + "https://discord.com/api/users/@me/applications/{}/role-connection", + app_env!().client_id + )) + .header("Authorization", format!("{} {}", token_type, access_token)) + .json(&serde_json::json!({ + "platform_name": "Discord Analytics", + "metadata": { + "botcount": bot_count.to_string() + } + })) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + error!( + code = %LogCode::Auth, + error = %error_text, + "Failed to update Discord role connection" + ); + return Err(anyhow!( + "Failed to update Discord role connection: {}", + error_text + )); + } + + Ok(()) + } +} diff --git a/src/services/invitations.rs b/src/services/invitations.rs new file mode 100644 index 00000000..170ec732 --- /dev/null +++ b/src/services/invitations.rs @@ -0,0 +1,31 @@ +use anyhow::Result; + +use crate::repository::Repositories; + +#[derive(Clone)] +pub struct InvitationsService { + repos: Repositories, +} + +impl InvitationsService { + pub fn new(repos: Repositories) -> Self { + Self { repos } + } + + pub async fn reject_invitation( + &self, + invitation_id: &str, + bot_id: &str, + user_id: &str, + ) -> Result<()> { + self.repos + .team_invitations + .delete_by_id(invitation_id) + .await?; + self.repos + .bots + .remove_user_from_team(bot_id, user_id) + .await?; + Ok(()) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index b07a8574..030ed5bb 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,16 +1,33 @@ use crate::repository::Repositories; mod auth; +mod bots; +mod discord; +mod invitations; +mod users; +mod webhooks; #[derive(Clone)] pub struct Services { pub auth: auth::AuthService, + pub bots: bots::BotsService, + pub discord: discord::DiscordService, + pub invitations: invitations::InvitationsService, + pub users: users::UsersService, + pub webhooks: webhooks::WebhooksService, } impl Services { pub fn new(repos: Repositories) -> Self { + let bots_service = bots::BotsService::new(repos.clone()); + Self { - auth: auth::AuthService::new(repos), + auth: auth::AuthService::new(repos.clone()), + bots: bots_service.clone(), + discord: discord::DiscordService::new(), + invitations: invitations::InvitationsService::new(repos.clone()), + users: users::UsersService::new(repos.clone(), &bots_service), + webhooks: webhooks::WebhooksService::new(repos.clone()), } } } diff --git a/src/services/users.rs b/src/services/users.rs new file mode 100644 index 00000000..e33aab69 --- /dev/null +++ b/src/services/users.rs @@ -0,0 +1,45 @@ +use anyhow::Result; + +use crate::{repository::Repositories, services::bots::BotsService}; + +#[derive(Clone)] +pub struct UsersService { + repos: Repositories, + bots_service: BotsService, +} + +impl UsersService { + pub fn new(repos: Repositories, bots_service: &BotsService) -> Self { + Self { + repos, + bots_service: bots_service.clone(), + } + } + + pub async fn has_reached_bots_limit(&self, user_id: &str) -> Result { + let user_details = self + .repos + .users + .find_by_id(user_id) + .await? + .ok_or_else(|| anyhow::anyhow!("User not found"))?; + let bot_count = self.repos.bots.count_by_user_id(user_id).await?; + Ok((bot_count as i32) >= user_details.bots_limit) + } + + pub async fn delete_user(&self, user_id: &str) -> Result<()> { + self.repos.users.delete_by_id(user_id).await?; + self.repos.sessions.revoke_all_for_user(user_id).await?; + for bot in self.repos.bots.find_by_owner_id(user_id).await? { + self.bots_service.delete_bot(&bot.bot_id).await?; + } + #[cfg(feature = "reports")] + self.repos.stats_reports.delete_by_user_id(user_id).await?; + self.repos + .team_invitations + .delete_by_user_id(user_id) + .await?; + + Ok(()) + } +} diff --git a/src/services/webhooks.rs b/src/services/webhooks.rs new file mode 100644 index 00000000..e33a48e9 --- /dev/null +++ b/src/services/webhooks.rs @@ -0,0 +1,224 @@ +use std::{collections::HashMap, sync::Arc}; + +use anyhow::Result; +use chrono::{Duration, Utc}; +use mongodb::bson::DateTime; +use serde_json::Value; +use tokio::sync::Mutex; +use tracing::info; + +use crate::{ + domain::models::{AchievementType, Bot, Provider, Vote, Webhook, WebhookData}, + managers::VotesWebhooksManager, + repository::Repositories, + utils::logger::LogCode, +}; + +#[derive(Clone)] +pub struct WebhooksService { + repos: Repositories, +} + +impl WebhooksService { + pub fn new(repos: Repositories) -> Self { + Self { repos } + } + + pub async fn record_vote( + &self, + bot_id: &str, + user_id: &str, + provider: &str, + vote_count: i32, + ) -> Result<()> { + let current_date = DateTime::now(); + + let start_of_hour = DateTime::from_millis( + current_date.timestamp_millis() - (current_date.timestamp_millis() % 3600000), + ); + + info!( + code = %LogCode::Webhook, + bot_id = %bot_id, + user_id = %user_id, + provider = %provider, + vote_count = %vote_count, + "Recording vote" + ); + + match self + .repos + .votes + .find_by_date(bot_id, &start_of_hour) + .await? + { + Some(_) => { + self.repos + .votes + .increment_count(bot_id, &start_of_hour, provider, vote_count) + .await?; + + info!( + code = %LogCode::Webhook, + bot_id = %bot_id, + provider = %provider, + vote_count = %vote_count, + "Vote count updated for existing record" + ); + } + None => { + let new_vote = Vote { + bot_id: bot_id.to_string(), + date: start_of_hour, + votes: HashMap::from([(provider.to_string(), vote_count as u32)]), + }; + self.repos.votes.insert(&new_vote).await?; + + info!( + code = %LogCode::Webhook, + bot_id = %bot_id, + provider = %provider, + vote_count = %vote_count, + "New vote record created" + ); + } + } + + self.check_vote_achievements(bot_id).await?; + + info!( + code = %LogCode::Webhook, + bot_id = %bot_id, + user_id = %user_id, + provider = %provider, + vote_count = %vote_count, + "Vote received" + ); + + Ok(()) + } + + async fn check_vote_achievements(&self, bot_id: &str) -> Result<()> { + let one_week_ago = + DateTime::from_millis((Utc::now() - Duration::days(7)).timestamp_millis()); + + let week_votes = self + .repos + .votes + .count_votes_since(bot_id, &one_week_ago) + .await?; + + info!( + code = %LogCode::Webhook, + bot_id = %bot_id, + week_votes = %week_votes, + "Checking vote achievements" + ); + + let achievements = self + .repos + .achievements + .find_unachieved_by_bot(bot_id) + .await?; + + for mut achievement in achievements { + if achievement.objective.achievement_type != AchievementType::VotesCount { + continue; + } + + achievement.current = Some(week_votes); + + if achievement.current.unwrap_or(0) >= achievement.objective.value { + achievement.achieved_on = Some(DateTime::now()); + info!( + code = %LogCode::Webhook, + bot_id = %bot_id, + achievement = ?achievement, + "Achievement unlocked" + ); + } + + self.repos + .achievements + .update_progress( + bot_id, + &achievement + .id + .ok_or_else(|| anyhow::anyhow!("Achievement ID missing"))? + .to_string(), + achievement.current, + achievement.achieved_on, + ) + .await?; + } + + Ok(()) + } + + pub async fn trigger_webhook_notification( + &self, + bot: &Bot, + voter_id: &str, + provider: &str, + raw_data: Value, + webhook_manager: &Arc>, + ) -> Result<()> { + let webhook_url = match &bot.webhooks_config.webhook_url { + Some(url) if !url.is_empty() => url.clone(), + _ => { + info!( + code = %LogCode::Webhook, + bot_id = %bot.bot_id, + provider = %provider, + "Webhook URL not configured, skipping notification" + ); + return Ok(()); + } + }; + if let Some(webhook_config) = bot.webhooks_config.webhooks.get(provider) { + let webhook_secret = match &webhook_config.webhook_secret { + Some(secret) if !secret.is_empty() => secret.clone(), + _ => { + info!( + code = %LogCode::Webhook, + bot_id = %bot.bot_id, + provider = %provider, + "Webhook secret not configured, skipping notification" + ); + return Ok(()); + } + }; + let webhook = Webhook { + webhook_url, + data: WebhookData { + bot_id: bot.bot_id.clone(), + date: Utc::now(), + provider: Provider::parse_str(provider), + raw_data: Some(raw_data), + voter_id: voter_id.to_string(), + }, + try_count: 0, + webhook_secret, + }; + + let mut manager = webhook_manager.lock().await; + manager.queue_webhook(webhook.clone()); + drop(manager); + + let manager_clone = webhook_manager.clone(); + tokio::spawn(async move { + VotesWebhooksManager::send(manager_clone, webhook).await; + }); + + info!( + code = %LogCode::Webhook, + bot_id = %bot.bot_id, + voter_id = %voter_id, + provider = %provider, + "Webhook queued and triggered" + ); + } + + Ok(()) + } +} diff --git a/src/utils/constants.rs b/src/utils/constants.rs index 7c91fc6f..0d41621c 100644 --- a/src/utils/constants.rs +++ b/src/utils/constants.rs @@ -1,16 +1,23 @@ use std::time::Duration; -pub const PUBLIC_ROUTES: [&str; 3] = ["/api/articles", "/api/articles/{id}", "/api/specs"]; +pub const PUBLIC_ROUTES: [&str; 2] = ["/articles", "/articles/{id}"]; pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); pub const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); +pub const MAX_DATE_RANGE: i64 = 365 * 24 * 60 * 60; + +pub const MAX_BOTS_PER_USER: i32 = 3; + pub const MAX_WEBHOOK_RETRIES: u8 = 15; pub const TAG_LEN: usize = 16; pub const DISCORD_EPOCH: i64 = 1420070400000; +pub const ACCESS_TOKEN_LIFETIME: i64 = 30 * 60; // 30 minutes +pub const REFRESH_TOKEN_LIFETIME: i64 = 30 * 24 * 60 * 60; // 30 days + #[cfg(debug_assertions)] pub const DB_NAME: &str = "api-dev"; #[cfg(not(debug_assertions))] @@ -21,6 +28,7 @@ pub const BOTS_COLLECTION: &str = "Bots"; pub const BOT_STATS_COLLECTION: &str = "BotStats"; pub const CUSTOM_EVENTS_COLLECTION: &str = "CustomEvents"; pub const GLOBAL_STATS_COLLECTION: &str = "GlobalStats"; +pub const SESSIONS_COLLECTION: &str = "Sessions"; pub const STATS_REPORTS_COLLECTION: &str = "StatsReports"; pub const TEAM_INVITATIONS_COLLECTION: &str = "TeamInvitations"; pub const USERS_COLLECTION: &str = "Users"; diff --git a/src/utils/discord.rs b/src/utils/discord.rs index 39a858bf..78e810c5 100644 --- a/src/utils/discord.rs +++ b/src/utils/discord.rs @@ -9,5 +9,8 @@ pub fn get_user_creation_date(id: &str) -> Option { } pub fn is_valid_snowflake(id: &str) -> bool { - id.parse::().is_ok() + match id.parse::() { + Ok(snowflake) => snowflake > 0 && snowflake < (1 << 63), + Err(_) => false, + } } diff --git a/src/utils/logger/codes.rs b/src/utils/logger/codes.rs index 58a35057..a1af2d4f 100644 --- a/src/utils/logger/codes.rs +++ b/src/utils/logger/codes.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::fmt::{Display, Formatter, Result}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LogCode { @@ -12,6 +12,10 @@ pub enum LogCode { Auth, /// Unauthorized access attempts Unauthorized, + /// Forbidden access attempts + Forbidden, + /// Invalid token or authentication failures + InvalidToken, /// Admin actions AdminAction, /// Bot-related events @@ -20,6 +24,8 @@ pub enum LogCode { BotExpiration, /// User-related events User, + /// Conflict events (e.g., duplicate entries) + Conflict, /// Achievement events Achievement, /// General information @@ -30,6 +36,10 @@ pub enum LogCode { DbError, /// Mail-related events Mail, + /// Webhooks events + Webhook, + /// Websocket events + Websocket, } impl LogCode { @@ -40,21 +50,26 @@ impl LogCode { LogCode::Database => "DB", LogCode::Auth => "AUTH", LogCode::Unauthorized => "UNAUTH", + LogCode::Forbidden => "FORBID", + LogCode::InvalidToken => "INV_TOKEN", LogCode::AdminAction => "ADMIN", LogCode::Bot => "BOT", LogCode::BotExpiration => "BOT_EXP", LogCode::User => "USER", + LogCode::Conflict => "CONFLICT", LogCode::Achievement => "ACHV", LogCode::Info => "INFO", LogCode::System => "SYS", LogCode::DbError => "DB_ERR", LogCode::Mail => "MAIL", + LogCode::Webhook => "WH", + LogCode::Websocket => "WS", } } } -impl fmt::Display for LogCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +impl Display for LogCode { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { write!(f, "{}", self.as_str()) } } diff --git a/src/utils/logger/mod.rs b/src/utils/logger/mod.rs index 5ccf53fb..dd99e6cd 100644 --- a/src/utils/logger/mod.rs +++ b/src/utils/logger/mod.rs @@ -1,14 +1,31 @@ mod codes; -use std::{collections::HashMap, io}; +#[cfg(feature = "otel")] +use std::collections::HashMap; +use std::io; use anyhow::Result; -use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; -use opentelemetry_otlp::{Protocol, WithExportConfig, WithHttpConfig, LogExporter}; -use opentelemetry_sdk::{Resource, logs::SdkLoggerProvider}; -use tracing::{Level, level_filters::LevelFilter}; -use tracing_subscriber::{fmt, layer::SubscriberExt, prelude::*, registry}; +use tracing::Level; +#[cfg(not(feature = "otel"))] +use tracing_subscriber::layer::Identity; +use tracing_subscriber::{ + filter::EnvFilter, + fmt::{format::FmtSpan, layer}, + layer::SubscriberExt, + prelude::*, + registry, +}; +#[cfg(feature = "otel")] +use { + opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge, + opentelemetry_otlp::{LogExporter, Protocol, WithExportConfig, WithHttpConfig}, + opentelemetry_sdk::{ + Resource, + logs::{SdkLogger, SdkLoggerProvider}, + }, +}; +#[cfg(feature = "otel")] use crate::app_env; pub struct Logger { @@ -16,6 +33,12 @@ pub struct Logger { dev_mode: bool, } +impl Default for Logger { + fn default() -> Self { + Self::new() + } +} + pub use codes::LogCode; impl Logger { @@ -32,44 +55,50 @@ impl Logger { } pub fn init(self) -> Result<()> { - let stdout_layer = fmt::layer() + let filter = EnvFilter::from_default_env().add_directive(self.level.into()); + + let stdout_layer = layer() .with_writer(io::stdout) .with_ansi(self.dev_mode) - .with_filter(LevelFilter::from_level(self.level)); - - if let (Some(endpoint), Some(token), Some(stream)) = ( - app_env!().otlp_endpoint.clone(), - app_env!().otlp_token.clone(), - app_env!().otlp_stream.clone(), - ) && !self.dev_mode - { - let mut headers = HashMap::new(); - headers.insert( - String::from("Authorization"), - String::from(format!("Basic {}", token)), - ); - headers.insert("stream-name".to_string(), stream); - - let exporter = LogExporter::builder() - .with_http() - .with_protocol(Protocol::HttpBinary) - .with_headers(headers) - .with_endpoint(format!("{}/v1/logs", endpoint)) - .build()?; - - let resource = Resource::builder().with_service_name("api").build(); - - let provider = SdkLoggerProvider::builder() - .with_batch_exporter(exporter) - .with_resource(resource) - .build(); - let otlp_layer = OpenTelemetryTracingBridge::new(&provider); - - registry().with(stdout_layer).with(otlp_layer).init(); - } else { - registry().with(stdout_layer).init() - } + .with_span_events(FmtSpan::CLOSE) + .with_filter(filter); + + #[cfg(feature = "otel")] + let otel_layer = (!self.dev_mode).then(|| self.otel_layer()).transpose()?; + #[cfg(not(feature = "otel"))] + let otel_layer: Option = None; + + registry().with(stdout_layer).with(otel_layer).init(); Ok(()) } + + #[cfg(feature = "otel")] + fn otel_layer(&self) -> Result> { + let env = app_env!(); + + let mut headers = HashMap::new(); + headers.insert( + String::from("Authorization"), + format!("Basic {}", env.otlp_token), + ); + headers.insert("stream-name".to_string(), env.otlp_stream.clone()); + + let exporter = LogExporter::builder() + .with_http() + .with_protocol(Protocol::HttpBinary) + .with_headers(headers) + .with_endpoint(format!("{}/v1/logs", env.otlp_endpoint)) + .build()?; + + let resource = Resource::builder().with_service_name("api").build(); + + let provider = SdkLoggerProvider::builder() + .with_batch_exporter(exporter) + .with_resource(resource) + .build(); + let otel_layer = OpenTelemetryTracingBridge::new(&provider); + + Ok(otel_layer) + } } diff --git a/tests/config/env.rs b/tests/config/env.rs deleted file mode 100644 index c75e277f..00000000 --- a/tests/config/env.rs +++ /dev/null @@ -1,15 +0,0 @@ -use api::config::env; - -#[tokio::test] -async fn test_env() { - let config = env::init_env().expect("Failed to initialize environment"); - assert!(!config.api_url.is_empty(), "API URL should not be empty"); - assert!( - !config.database_url.is_empty(), - "Database URL should not be empty" - ); - assert!( - !config.jwt_secret.is_empty(), - "JWT secret should not be empty" - ); -} diff --git a/tests/config/mod.rs b/tests/config/mod.rs deleted file mode 100644 index b94b8e43..00000000 --- a/tests/config/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod env; diff --git a/tests/mod.rs b/tests/mod.rs deleted file mode 100644 index 3d7bca44..00000000 --- a/tests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod config; -mod repository; diff --git a/tests/repository/mod.rs b/tests/repository/mod.rs deleted file mode 100644 index e69de29b..00000000