From 132057217d61d284c031c2fbbc71724238492943 Mon Sep 17 00:00:00 2001 From: Nell Date: Fri, 15 May 2026 19:35:06 +0200 Subject: [PATCH] pre-metrics --- Cargo.lock | 205 ++++++++++++++---- Cargo.toml | 9 +- config.toml | 1 + event_bus/src/bus.rs | 1 + .../src/m20220101_000001_create_table.rs | 6 + src/{utils => auth}/mod.rs | 1 + src/{utils => auth}/password.rs | 0 src/auth/token.rs | 48 ++++ src/core/mod.rs | 107 ++++++++- src/core/state.rs | 7 + src/database/database.rs | 9 +- src/http/context.rs | 68 ++++++ src/http/error.rs | 97 +++++++++ src/http/middleware.rs | 69 ++++++ src/http/mod.rs | 10 + src/http/server.rs | 173 +++++++++++++-- src/http/validation.rs | 63 ++++++ src/lib.rs | 3 +- src/main.rs | 2 +- src/models/server.rs | 2 + src/repositories/category.rs | 48 ++-- src/repositories/channel.rs | 18 +- src/repositories/group.rs | 22 +- src/repositories/message.rs | 28 ++- src/repositories/mod.rs | 13 +- src/repositories/server.rs | 65 +++++- src/repositories/user.rs | 61 +++--- src/routes/auth/domain.rs | 1 + src/routes/auth/dto.rs | 19 ++ src/routes/auth/handlers.rs | 57 +++++ src/routes/auth/mapper.rs | 1 + src/routes/auth/mod.rs | 6 + src/routes/auth/routes.rs | 16 ++ src/routes/auth/service.rs | 1 + src/routes/core/domain.rs | 1 + src/routes/core/dto.rs | 14 ++ src/routes/core/handlers.rs | 36 +++ src/routes/core/mapper.rs | 14 ++ src/routes/core/mod.rs | 6 + src/routes/core/routes.rs | 7 + src/routes/core/service.rs | 1 + src/routes/mod.rs | 21 +- src/udp/server.rs | 7 +- 43 files changed, 1172 insertions(+), 172 deletions(-) rename src/{utils => auth}/mod.rs (54%) rename src/{utils => auth}/password.rs (100%) create mode 100644 src/auth/token.rs create mode 100644 src/http/context.rs create mode 100644 src/http/error.rs create mode 100644 src/http/middleware.rs create mode 100644 src/http/validation.rs create mode 100644 src/routes/auth/domain.rs create mode 100644 src/routes/auth/dto.rs create mode 100644 src/routes/auth/handlers.rs create mode 100644 src/routes/auth/mapper.rs create mode 100644 src/routes/auth/mod.rs create mode 100644 src/routes/auth/routes.rs create mode 100644 src/routes/auth/service.rs create mode 100644 src/routes/core/domain.rs create mode 100644 src/routes/core/dto.rs create mode 100644 src/routes/core/handlers.rs create mode 100644 src/routes/core/mapper.rs create mode 100644 src/routes/core/mod.rs create mode 100644 src/routes/core/routes.rs create mode 100644 src/routes/core/service.rs diff --git a/Cargo.lock b/Cargo.lock index c4dd26c..3fcf694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,9 +154,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4754a624e5ae42081f464514be454b39711daae0458906dacde5f4c632f33a8" +checksum = "3bd47f2a6ddc39244bd722a27ee5da66c03369d087b9e024eafdb03e98b98ea7" dependencies = [ "arrow-arith", "arrow-array", @@ -172,9 +172,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7b3141e0ec5145a22d8694ea8b6d6f69305971c4fa1c1a13ef0195aef2d678b" +checksum = "7c7bbd679c5418b8639b92be01f361d60013c4906574b578b77b63c78356594c" dependencies = [ "arrow-array", "arrow-buffer", @@ -186,9 +186,9 @@ dependencies = [ [[package]] name = "arrow-array" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8955af33b25f3b175ee10af580577280b4bd01f7e823d94c7cdef7cf8c9aef" +checksum = "c8a4ab47b3f3eac60f7fd31b81e9028fda018607bcc63451aca4f2b755269862" dependencies = [ "ahash 0.8.12", "arrow-buffer", @@ -204,9 +204,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c697ddca96183182f35b3a18e50b9110b11e916d7b7799cbfd4d34662f2c56c2" +checksum = "0d18b89b4c4f4811d0858175e79541fe98e33e18db3b011708bc287b1240593f" dependencies = [ "bytes", "half", @@ -216,9 +216,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "646bbb821e86fd57189c10b4fcdaa941deaf4181924917b0daa92735baa6ada5" +checksum = "722b5c41dd1d14d0a879a1bce92c6fe33f546101bb2acce57a209825edd075b3" dependencies = [ "arrow-array", "arrow-buffer", @@ -237,9 +237,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fdd994a9d28e6365aa78e15da3f3950c0fdcea6b963a12fa1c391afb637b304" +checksum = "c1683705c63dcf0d18972759eda48489028cbbff67af7d6bef2c6b7b74ab778a" dependencies = [ "arrow-buffer", "arrow-schema", @@ -250,9 +250,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d8f1870e03d4cbed632959498bcc84083b5a24bded52905ae1695bd29da45b" +checksum = "082342947d4e5a2bcccf029a0a0397e21cb3bb8421edd9571d34fb5dd2670256" dependencies = [ "arrow-array", "arrow-buffer", @@ -263,9 +263,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18228633bad92bff92a95746bbeb16e5fc318e8382b75619dec26db79e4de4c0" +checksum = "e3a931b520a2a5e22033e01a6f2486b4cdc26f9106b759abeebc320f125e94d7" dependencies = [ "arrow-array", "arrow-buffer", @@ -276,15 +276,15 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" +checksum = "e4cf0d4a6609679e03002167a61074a21d7b1ad9ea65e462b2c0a97f8a3b2bc6" [[package]] name = "arrow-select" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68bf3e3efbd1278f770d67e5dc410257300b161b93baedb3aae836144edcaf4b" +checksum = "0b320d86a9806923663bb0fd9baa65ecaba81cb0cd77ff8c1768b9716b4ef891" dependencies = [ "ahash 0.8.12", "arrow-array", @@ -296,9 +296,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e968097061b3c0e9fe3079cf2e703e487890700546b5b0647f60fca1b5a8d8" +checksum = "b493e99162e5764077e7823e50ba284858d365922631c7aaefe9487b1abd02c2" dependencies = [ "arrow-array", "arrow-buffer", @@ -739,8 +739,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -834,9 +836,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.22" +version = "0.15.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" dependencies = [ "async-trait", "convert_case", @@ -1050,6 +1052,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.117", ] @@ -1285,6 +1288,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1484,7 +1493,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1492,12 +1501,15 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1508,6 +1520,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.4.1" @@ -1606,9 +1627,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -1794,7 +1815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1872,6 +1893,23 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -2252,11 +2290,15 @@ dependencies = [ name = "oxspeak_server" version = "0.1.0" dependencies = [ + "anyhow", "argon2", + "async-trait", "axum", "bitflags", + "chrono", "config", "event_bus", + "jsonwebtoken", "log", "migration", "parking_lot", @@ -2266,10 +2308,13 @@ dependencies = [ "thiserror", "tokio", "toml", + "tower", + "tower-http", "tracing", "tracing-subscriber", "utoipa", "uuid", + "validator", ] [[package]] @@ -2327,6 +2372,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3243,6 +3298,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -3318,7 +3385,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap", "log", "memchr", @@ -3786,6 +3853,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3979,6 +4064,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4320,9 +4435,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -4438,13 +4553,13 @@ dependencies = [ [[package]] name = "yaml-rust2" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.11.0", ] [[package]] @@ -4498,9 +4613,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -4522,6 +4637,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 8bf1dcb..155dbb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,4 +28,11 @@ thiserror = "2" utoipa = { version = "5", features = ["uuid"] } log = "0.4" bitflags = "2.11.1" -argon2 = { version = "0.6.0-rc.8", features = ["password-hash"] } \ No newline at end of file +argon2 = { version = "0.6.0-rc.8", features = ["password-hash"] } +jsonwebtoken = "10.3.0" +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["catch-panic", "cors", "trace"] } +chrono = "0.4.44" +validator = { version = "0.20.0", features = ["derive"] } +async-trait = "0.1.89" +anyhow = "1.0.102" \ No newline at end of file diff --git a/config.toml b/config.toml index 663b8be..a0a6c89 100644 --- a/config.toml +++ b/config.toml @@ -12,6 +12,7 @@ udp_port = 8080 # DSN for database # SQLite url = "sqlite://oxspeak.db" +#url = "sqlite::memory:" # PostgreSQL # url = "postgresql://user:passwd@localhost:5432/db_name" # MySQL diff --git a/event_bus/src/bus.rs b/event_bus/src/bus.rs index 2176f34..e584bc3 100644 --- a/event_bus/src/bus.rs +++ b/event_bus/src/bus.rs @@ -85,6 +85,7 @@ const DEFAULT_CAPACITY: usize = 64; /// # tokio::time::sleep(std::time::Duration::from_millis(10)).await; /// # }); /// ``` +#[derive(Debug)] pub struct EventBus { /// Channels indexed by exact topic. channels: RwLock>>, diff --git a/migration/src/m20220101_000001_create_table.rs b/migration/src/m20220101_000001_create_table.rs index 7b8a763..ff2215a 100644 --- a/migration/src/m20220101_000001_create_table.rs +++ b/migration/src/m20220101_000001_create_table.rs @@ -45,6 +45,12 @@ impl MigrationTrait for Migration { .not_null() .default(0), ) + .col( + ColumnDef::new(Alias::new("is_default")) + .boolean() + .not_null() + .default(false), + ) .to_owned(), ) .await?; diff --git a/src/utils/mod.rs b/src/auth/mod.rs similarity index 54% rename from src/utils/mod.rs rename to src/auth/mod.rs index c72e4b9..f87b187 100644 --- a/src/utils/mod.rs +++ b/src/auth/mod.rs @@ -1 +1,2 @@ pub mod password; +pub mod token; diff --git a/src/utils/password.rs b/src/auth/password.rs similarity index 100% rename from src/utils/password.rs rename to src/auth/password.rs diff --git a/src/auth/token.rs b/src/auth/token.rs new file mode 100644 index 0000000..a9b71f1 --- /dev/null +++ b/src/auth/token.rs @@ -0,0 +1,48 @@ +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub user_id: Uuid, // User ID + pub expire_at: usize, // Expiration time + pub created_at: usize, // Issued at + pub username: String, +} + +pub fn create_jwt( + user_id: Uuid, + username: &str, + secret: &str, + expiration_seconds: u64, +) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + + let claims = Claims { + user_id: user_id, + expire_at: (now + expiration_seconds) as usize, + created_at: now as usize, + username: username.to_string(), + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_ref()), + ) +} + +pub fn verify_jwt(token: &str, secret: &str) -> Result { + let validation = Validation::default(); + let token_data = decode::( + token, + &DecodingKey::from_secret(secret.as_ref()), + &validation, + )?; + + Ok(token_data.claims) +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 6f081f0..1399df2 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,9 +2,14 @@ pub mod state; use crate::config::AppConfig; use crate::database::Database; +use crate::http::server::HttpServer; +use crate::repositories::Repositories; +use crate::udp::server::UdpServer; +use event_bus::EventBus; use migration::{Migrator, MigratorTrait}; pub use state::AppState; use std::sync::Arc; +use uuid::Uuid; pub struct App { pub state: AppState, @@ -12,14 +17,53 @@ pub struct App { impl App { pub async fn build(config: AppConfig) -> Result> { + // Initialize database connection let db_manager = Database::init(&config.database.url).await?; let db = db_manager.get_connection().clone(); + // Run database migrations Migrator::up(&db, None).await?; + // Initialize EventBus + let event_bus = Arc::new(EventBus::with_capacity(1024)); + + // Initialize shared repositories + let repositories = Repositories::new(db.clone(), event_bus.clone()); + + // Init one server if no one exist + let default_server = match repositories.server.get_default().await? { + Some(server) => server, + None => { + let new_server = repositories + .server + .create_with_args("default".to_string(), true) + .await?; + tracing::info!("Initialized default server"); + new_server + } + }; + + // Init Token if no user exist + let init_token = if repositories.user.count().await? == 0 { + let token = Uuid::new_v4(); + println!("+------------------------------------------------------------+"); + println!("| NO USER FOUND IN DATABASE |"); + println!("| Use the following token to create the first admin user: |"); + println!("| |"); + println!("| TOKEN: {} |", token); + println!("| |"); + println!("+------------------------------------------------------------+"); + Some(token) + } else { + None + }; + let state = AppState { db, config: Arc::new(config), + repositories, + init_token, + default_server: Arc::new(default_server), }; Ok(Self { state }) @@ -28,6 +72,67 @@ impl App { pub async fn run(self) -> Result<(), Box> { tracing::info!("Starting services..."); - tokio::select! {} + let config = self.state.config.clone(); + + // Initialize HTTP Server + let (http_server, http_shutdown_tx) = HttpServer::new(&config.network, self.state.clone()); + + // Initialize UDP service + let (udp_server, udp_shutdown_tx) = UdpServer::new(&config.network); + + // On lance les serveurs dans des tâches séparées + let mut http_handle = tokio::spawn(http_server.run()); + let mut udp_handle = tokio::spawn(udp_server.run()); + + // On arbitre : soit un signal arrive, soit une tâche se termine (erreur/crash) + tokio::select! { + res = &mut http_handle => { + tracing::error!("HTTP server stopped unexpectedly: {:?}", res); + } + res = &mut udp_handle => { + tracing::error!("UDP server stopped unexpectedly: {:?}", res); + } + _ = Self::shutdown_signal() => { + tracing::info!("Shutdown signal received, initiating graceful shutdown..."); + } + } + + // Dans tous les cas (Ctrl-C ou crash d'un service), on demande l'arrêt global + let _ = http_shutdown_tx.send(()); + let _ = udp_shutdown_tx.send(()); + + // On attend que tout le monde ait fini de nettoyer + // (Note: join! supporte les handles déjà terminés ou annulés) + let _ = tokio::join!(http_handle, udp_handle); + + tracing::info!("Closed the runtime application."); + + Ok(()) + } + + async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + tracing::info!("Shutdown signal received"); } } diff --git a/src/core/state.rs b/src/core/state.rs index a92f449..bc61b7e 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -1,4 +1,6 @@ use crate::config::AppConfig; +use crate::models::server; +use crate::repositories::Repositories; use sea_orm::DatabaseConnection; use std::sync::Arc; @@ -6,4 +8,9 @@ use std::sync::Arc; pub struct AppState { pub db: DatabaseConnection, pub config: Arc, + pub repositories: Repositories, + pub init_token: Option, + pub default_server: Arc, } + +impl AppState {} diff --git a/src/database/database.rs b/src/database/database.rs index 8ea4649..8b8bdca 100644 --- a/src/database/database.rs +++ b/src/database/database.rs @@ -1,7 +1,6 @@ use sea_orm::{ConnectOptions, Database as SeaDatabase, DatabaseConnection, DbErr}; use std::time::Duration; - #[derive(Clone)] pub struct Database { pub connection: DatabaseConnection, @@ -9,7 +8,13 @@ pub struct Database { impl Database { pub async fn init(dsn: &str) -> Result { - let mut opt = ConnectOptions::new(dsn); + let mut final_dsn = dsn.to_string(); + if dsn.starts_with("sqlite:") && !dsn.contains('?') && dsn != "sqlite::memory:" { + final_dsn = format!("{}?mode=rwc", dsn); + } + println!("{}", final_dsn); + + let mut opt = ConnectOptions::new(final_dsn); opt.max_connections(100) .min_connections(5) .connect_timeout(Duration::from_secs(8)) diff --git a/src/http/context.rs b/src/http/context.rs new file mode 100644 index 0000000..4eb73e2 --- /dev/null +++ b/src/http/context.rs @@ -0,0 +1,68 @@ +use super::error::HTTPError; +use crate::models::user; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use std::ops::Deref; +use std::time::Instant; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct RequestContext { + pub request_id: Uuid, + pub started_at: Instant, + pub method: axum::http::Method, + pub uri: axum::http::Uri, + pub user: Option, +} + +/// Représente l'utilisateur actuellement authentifié, enveloppant le modèle de base de données. +/// +/// **Philosophie (vs Django) :** +/// Au lieu de passer une `request` entière, on demande `user: CurrentUser` dans la signature +/// de la vue. C'est un **contrat** : si l'utilisateur n'est pas là, la vue n'est pas appelée (401). +/// +/// **Usage :** +/// ```rust +/// pub async fn ma_vue(user: CurrentUser) { +/// if user.is_superuser { ... } +/// } +/// ``` +#[derive(Clone, Debug)] +pub struct CurrentUser(pub user::Model); + +impl Deref for CurrentUser { + type Target = user::Model; + + /// Permet d'accéder aux champs du modèle (`user.username`, etc.) directement + /// sur l'objet `CurrentUser`, sans avoir à faire `user.0.username`. + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Le "Moteur" derrière la magie des signatures de fonction d'Axum. +/// +/// Implémenter ce trait permet à Axum d'extraire automatiquement l'utilisateur +/// depuis les extensions de la requête (injectées par le middleware). +impl FromRequestParts for CurrentUser +where + S: Send + Sync, +{ + type Rejection = HTTPError; + + /// Cette méthode est appelée par Axum AVANT d'exécuter votre vue. + /// 1. On cherche le `RequestContext` dans les extensions. + /// 2. On vérifie si un utilisateur y est présent. + /// 3. Si oui, on le retourne (succès). + /// 4. Si non, on retourne une erreur 401 (rejet de la requête). + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // On récupère le contexte injecté par le middleware + let context = parts + .extensions + .get::() + .ok_or(HTTPError::Unauthorized)?; + + // On retourne l'utilisateur cloné s'il existe, sinon on rejette avec Unauthorized + context.user.clone().ok_or(HTTPError::Unauthorized) + } +} diff --git a/src/http/error.rs b/src/http/error.rs new file mode 100644 index 0000000..06a6214 --- /dev/null +++ b/src/http/error.rs @@ -0,0 +1,97 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use sea_orm::DbErr; +use serde::Serialize; +use serde_json::json; +use std::collections::HashMap; +use thiserror::Error; + +#[derive(Debug, Serialize)] +pub struct ValidationErrorResponse { + pub errors: HashMap>, +} + +#[derive(Debug, Error)] +pub enum HTTPError { + #[error("Database error: {0}")] + Database(#[from] DbErr), + + #[error("Resource not found")] + NotFound, + + #[error("Validation failed")] + UnprocessableEntity(HashMap>), + + #[error("Bad request: {0}")] + BadRequest(String), + + #[error("Internal server error: {0}")] + InternalServerError(String), + + #[error("Unauthorized")] + Unauthorized, + + #[error("Forbidden")] + Forbidden, + + #[error("Invalid UUID: {0}")] + UuidError(#[from] uuid::Error), + + #[error("Internal server error: {0}")] + Internal(#[from] anyhow::Error), +} + +// Implémentation pour Axum : transformer AppError en réponse HTTP +impl IntoResponse for HTTPError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + HTTPError::Database(err) => { + eprintln!("Database error: {:?}", err); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error") + } + HTTPError::NotFound => (StatusCode::NOT_FOUND, "Resource not found"), + HTTPError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"), + HTTPError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"), + HTTPError::UuidError(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Invalid UUID: {}", err) })), + ) + .into_response(); + } + HTTPError::UnprocessableEntity(errors) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(ValidationErrorResponse { errors }), + ) + .into_response(); + } + HTTPError::BadRequest(msg) => { + return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response(); + } + HTTPError::InternalServerError(msg) => { + eprintln!("Internal error: {}", msg); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": msg })), + ) + .into_response(); + } + HTTPError::Internal(err) => { + tracing::error!(error = ?err, "An unexpected error occurred"); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") + } + }; + + (status, Json(json!({ "error": error_message }))).into_response() + } +} + +impl HTTPError { + pub fn validation_error(field: &str, message: &str) -> Self { + let mut errors = HashMap::new(); + errors.insert(field.to_string(), vec![message.to_string()]); + HTTPError::UnprocessableEntity(errors) + } +} diff --git a/src/http/middleware.rs b/src/http/middleware.rs new file mode 100644 index 0000000..d83fb44 --- /dev/null +++ b/src/http/middleware.rs @@ -0,0 +1,69 @@ +use axum::{extract::State, http::Request, middleware::Next, response::Response}; +use std::time::Instant; +use tracing::info; +use uuid::Uuid; + +use super::context::{CurrentUser, RequestContext}; +use crate::auth::token::verify_jwt; +use crate::core::AppState; + +pub async fn context_middleware( + State(app_state): State, + mut req: Request, + next: Next, +) -> Response { + let request_id = Uuid::new_v4(); + let started_at = Instant::now(); + + // Infos "type Django request" + let method = req.method().clone(); + let uri = req.uri().clone(); + + // Authentification par JWT + let user: Option = match req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|auth_header| { + if auth_header.starts_with("Bearer ") { + Some(&auth_header[7..]) + } else { + None + } + }) + .and_then(|token| verify_jwt(token, &app_state.config.jwt.secret).ok()) + { + Some(claims) => app_state + .repositories + .user + .get_by_id(claims.user_id) + .await + .ok() + .flatten() + .map(CurrentUser), + None => None, + }; + + let user_id = user.as_ref().map(|u| u.id); + + // Injecte le contexte dans la requête (espace de stockage partagé) + // C'est ce qui permettra aux extracteurs comme 'CurrentUser' de retrouver ces données plus tard. + req.extensions_mut().insert(RequestContext { + request_id, + started_at, + method: method.clone(), + uri: uri.clone(), + user, + }); + + info!( + request_id = %request_id, + user_id = ?user_id, + method = %method, + uri = %uri, + "Incoming request" + ); + + // Passe la requête au reste de la stack + next.run(req).await +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 74f47ad..33acea8 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1 +1,11 @@ +use crate::core::AppState; +use axum::Router; + +pub mod context; +pub mod error; +pub mod metrics; +mod middleware; pub mod server; +pub mod validation; + +pub type OxRouter = Router; diff --git a/src/http/server.rs b/src/http/server.rs index a92ca9c..79a6ee5 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -1,23 +1,168 @@ -use std::net::SocketAddr; +//! Serveur HTTP axum. +//! +//! [`HttpServer`] encapsule la configuration réseau, l'état applicatif, +//! les métriques et les layers Tower. Il est construit dans [`crate::core::App`] +//! et démarré via [`HttpServer::run`]. +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::middleware as axum_middleware; use axum::Router; use tokio::net::TcpListener; +use tokio::sync::broadcast; +use tower_http::catch_panic::CatchPanicLayer; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; -use crate::config::AppConfig; +use crate::config::{AppConfig, NetworkConfig}; +use crate::core::AppState; +use crate::http::OxRouter; use crate::routes; -pub async fn start(config: &AppConfig) -> Result<(), Box> { - let addr = SocketAddr::new( - std::net::IpAddr::V4(config.network.host), - config.network.tcp_port, - ); +use super::metrics::{self, HttpMetrics}; +use super::middleware::context_middleware; - let app: Router = routes::router(); +// ── Erreurs ─────────────────────────────────────────────────────────────────── - let listener = TcpListener::bind(addr).await?; - tracing::info!(%addr, "HTTP server listening"); - - axum::serve(listener, app).await?; - - Ok(()) +/// Erreurs pouvant survenir pendant l'opération du serveur HTTP. +#[derive(Debug, thiserror::Error)] +pub enum HttpServerError { + #[error("failed to bind TCP listener to {addr}: {source}")] + Bind { + addr: SocketAddr, + #[source] + source: std::io::Error, + }, + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +// ── Struct ──────────────────────────────────────────────────────────────────── + +/// Serveur HTTP asynchrone basé sur axum. +/// +/// - Injecte [`AppState`] via le middleware (les handlers l'obtiendront via +/// `State` quand ils seront implémentés ; `.with_state()` sera +/// ajouté en conséquence). +/// - Applique [`context_middleware`] (authentification JWT + contexte de +/// requête) sur l'ensemble du router. +/// - Empile les layers Tower : [`CatchPanicLayer`], [`CorsLayer`] (permissif), +/// [`TraceLayer`]. +/// - Collecte des métriques via [`HttpMetrics`] et les reporte périodiquement. +/// - Supporte un shutdown gracieux via un [`broadcast::Sender`]. +/// +/// # Exemple +/// ```no_run +/// use oxspeak_server_lib::config::AppConfig; +/// use oxspeak_server_lib::core::{App, AppState}; +/// use oxspeak_server_lib::http::server::HttpServer; +/// +/// #[tokio::main] +/// async fn main() { +/// let config = AppConfig::load().unwrap(); +/// // AppState construit via App::build(config) +/// } +/// ``` +pub struct HttpServer { + bind_addr: SocketAddr, + app_state: AppState, + metrics: Arc, + shutdown_rx: broadcast::Receiver<()>, +} + +impl HttpServer { + /// Construit un [`HttpServer`] depuis la configuration et l'état applicatif. + /// + /// Retourne le serveur et un [`broadcast::Sender`] pour déclencher le + /// shutdown gracieux depuis l'extérieur. + pub fn new( + network_config: &NetworkConfig, + app_state: AppState, + ) -> (Self, broadcast::Sender<()>) { + let bind_addr = SocketAddr::new(network_config.host.into(), network_config.tcp_port); + let metrics = HttpMetrics::new(); + let (shutdown_tx, shutdown_rx) = broadcast::channel(1); + + ( + Self { + bind_addr, + app_state, + metrics, + shutdown_rx, + }, + shutdown_tx, + ) + } + + /// Retourne une référence aux métriques du serveur. + pub fn metrics(&self) -> &Arc { + &self.metrics + } + + /// Bind le listener TCP et démarre la boucle de service. + /// + /// La future se résout lorsqu'un signal de shutdown est reçu ou qu'une + /// erreur I/O fatale survient. + pub async fn run(mut self) -> Result<(), HttpServerError> { + // Lance le reporter de métriques toutes les 30 secondes + metrics::spawn_reporter(self.metrics.clone(), Duration::from_secs(30)); + + let metrics = self.metrics.clone(); + let app_state = self.app_state.clone(); + + // Construit le router avec state + middleware + layers Tower. + // + // Ordre d'application en axum : chaque `.layer()` enveloppe le service + // courant — le dernier appel devient la couche la plus externe. + // + // CatchPanicLayer (outermost — intercepte tout panic en dessous) + // └─ CorsLayer (répond aux preflight avant auth) + // └─ TraceLayer (span tracing sur la durée totale) + // └─ context_middleware (JWT + RequestContext + métriques) + // └─ routes (handlers) + let app: Router = routes::router() + .with_state(app_state.clone()) + // Innermost : auth JWT, injection contexte, métriques + .layer(axum_middleware::from_fn_with_state( + app_state.clone(), + move |state, req, next| { + let metrics = metrics.clone(); + async move { + metrics.inc_request(); + let started = std::time::Instant::now(); + let response = context_middleware(state, req, next).await; + let latency = started.elapsed(); + metrics.record_response(response.status().as_u16(), latency); + response + } + }, + )) + // Spans tracing par requête (method, uri, status, latency) + .layer(TraceLayer::new_for_http()) + // CORS permissif (à affiner en production) + .layer(CorsLayer::permissive()) + // Outermost : intercepte les panics et retourne une 500 propre + .layer(CatchPanicLayer::new()); + + let listener = + TcpListener::bind(self.bind_addr) + .await + .map_err(|source| HttpServerError::Bind { + addr: self.bind_addr, + source, + })?; + + tracing::info!(addr = %self.bind_addr, "HTTP server listening"); + + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = self.shutdown_rx.recv().await; + tracing::info!("HTTP server shutting down"); + }) + .await?; + + Ok(()) + } } diff --git a/src/http/validation.rs b/src/http/validation.rs new file mode 100644 index 0000000..270ce70 --- /dev/null +++ b/src/http/validation.rs @@ -0,0 +1,63 @@ +use axum::{ + extract::{FromRequest, Request}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::collections::HashMap; +use validator::Validate; + +#[derive(Debug, Serialize)] +pub struct ValidationErrorResponse { + pub errors: HashMap>, +} + +/// Extracteur qui valide le JSON entrant à l'aide de la crate `validator`. +/// Si la validation échoue (JSON malformé ou règles métier violées), +/// il retourne une erreur 400 avec le détail par champ. +pub struct ValidatedJson(pub T); + +impl FromRequest for ValidatedJson +where + S: Send + Sync, + T: DeserializeOwned + Validate + 'static, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + // 1. Tente d'extraire le JSON. + // Si le JSON est syntaxiquement invalide, on retourne l'erreur d'Axum directement. + let Json(value) = Json::::from_request(req, state) + .await + .map_err(|rejection| rejection.into_response())?; + + // 2. Exécute la validation définie par #[derive(Validate)] sur le DTO. + value.validate().map_err(|errors| { + let mut error_map = HashMap::new(); + + for (field, field_errors) in errors.field_errors() { + let messages: Vec = field_errors + .iter() + .map(|e| { + e.message + .as_ref() + .map(|m| m.to_string()) + .unwrap_or_else(|| e.code.to_string()) + }) + .collect(); + error_map.insert(field.to_string(), messages); + } + + // Retourne un format JSON structuré : {"errors": {"field": ["msg"]}} + ( + StatusCode::BAD_REQUEST, + Json(ValidationErrorResponse { errors: error_map }), + ) + .into_response() + })?; + + Ok(ValidatedJson(value)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 36247a3..021f37e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,4 +8,5 @@ pub mod permissions; pub mod repositories; pub mod routes; pub mod udp; -pub mod utils; + +pub mod auth; diff --git a/src/main.rs b/src/main.rs index fcc900c..a492dc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ async fn main() -> Result<(), Box> { // 3. Build & Run let app = App::build(config).await?; - app.run().await; + app.run().await?; Ok(()) } diff --git a/src/models/server.rs b/src/models/server.rs index 130cd0e..16474fa 100644 --- a/src/models/server.rs +++ b/src/models/server.rs @@ -14,6 +14,7 @@ pub struct Model { pub password: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, + pub is_default: bool, /// Permissions serveur par défaut (stockées en i64, lues en u64) pub default_server_permissions: i64, @@ -55,6 +56,7 @@ impl ActiveModelBehavior for ActiveModel { let default_perm = PermissionSet::DEFAULT; Self { id: Set(Uuid::new_v4()), + is_default: Set(false), default_server_permissions: Set(default_perm.server.bits() as i64), default_channel_permissions: Set(default_perm.channel.bits() as i64), default_voice_permissions: Set(default_perm.voice.bits() as i64), diff --git a/src/repositories/category.rs b/src/repositories/category.rs index 1c2137b..4922e22 100644 --- a/src/repositories/category.rs +++ b/src/repositories/category.rs @@ -1,46 +1,52 @@ -use std::sync::Arc; -use sea_orm::{ - ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, -}; use crate::models::category; -use crate::repositories::RepositoryContext; +use crate::repositories::{AnyResult, RepositoryContext}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; +use std::sync::Arc; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct CategoryRepository { - pub context: Arc + pub context: Arc, } impl CategoryRepository { - pub async fn get_by_id(&self, id: uuid::Uuid) -> Result, DbErr> { - category::Entity::find_by_id(id).one(&self.context.db).await + pub async fn get_by_id(&self, id: uuid::Uuid) -> AnyResult> { + Ok(category::Entity::find_by_id(id) + .one(&self.context.db) + .await?) } - pub async fn get_all(&self) -> Result, DbErr> { - category::Entity::find().all(&self.context.db).await + pub async fn get_all(&self) -> AnyResult> { + Ok(category::Entity::find().all(&self.context.db).await?) } - pub async fn get_by_server_id(&self, server_id: uuid::Uuid) -> Result, DbErr> { - category::Entity::find() + pub async fn get_by_server_id(&self, server_id: uuid::Uuid) -> AnyResult> { + Ok(category::Entity::find() .filter(category::Column::ServerId.eq(server_id)) .all(&self.context.db) - .await + .await?) } - pub async fn update(&self, active: category::ActiveModel) -> Result { + pub async fn update(&self, active: category::ActiveModel) -> AnyResult { let category = active.update(&self.context.db).await?; - self.context.events.emit("Category_updated", category.clone()); + self.context + .events + .emit("Category_updated", category.clone()); Ok(category) } - pub async fn create(&self, active: category::ActiveModel) -> Result { + pub async fn create(&self, active: category::ActiveModel) -> AnyResult { let category = active.insert(&self.context.db).await?; - self.context.events.emit("Category_created", category.clone()); + self.context + .events + .emit("Category_created", category.clone()); Ok(category) } - pub async fn delete(&self, id: uuid::Uuid) -> Result<(), DbErr> { - category::Entity::delete_by_id(id).exec(&self.context.db).await?; + pub async fn delete(&self, id: uuid::Uuid) -> AnyResult<()> { + category::Entity::delete_by_id(id) + .exec(&self.context.db) + .await?; self.context.events.emit("Category_deleted", id); Ok(()) } -} \ No newline at end of file +} diff --git a/src/repositories/channel.rs b/src/repositories/channel.rs index f35047b..1696de4 100644 --- a/src/repositories/channel.rs +++ b/src/repositories/channel.rs @@ -1,31 +1,33 @@ use crate::models::channel; -use crate::repositories::RepositoryContext; -use sea_orm::{ActiveModelTrait, DbErr, EntityTrait}; +use crate::repositories::{AnyResult, RepositoryContext}; +use sea_orm::{ActiveModelTrait, EntityTrait}; use std::sync::Arc; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ChannelRepository { pub context: Arc, } impl ChannelRepository { - pub async fn get_by_id(&self, id: uuid::Uuid) -> Result, DbErr> { - channel::Entity::find_by_id(id).one(&self.context.db).await + pub async fn get_by_id(&self, id: uuid::Uuid) -> AnyResult> { + Ok(channel::Entity::find_by_id(id) + .one(&self.context.db) + .await?) } - pub async fn update(&self, active: channel::ActiveModel) -> Result { + pub async fn update(&self, active: channel::ActiveModel) -> AnyResult { let channel = active.update(&self.context.db).await?; self.context.events.emit("channel_updated", channel.clone()); Ok(channel) } - pub async fn create(&self, active: channel::ActiveModel) -> Result { + pub async fn create(&self, active: channel::ActiveModel) -> AnyResult { let channel = active.insert(&self.context.db).await?; self.context.events.emit("channel_created", channel.clone()); Ok(channel) } - pub async fn delete(&self, id: uuid::Uuid) -> Result<(), DbErr> { + pub async fn delete(&self, id: uuid::Uuid) -> AnyResult<()> { channel::Entity::delete_by_id(id) .exec(&self.context.db) .await?; diff --git a/src/repositories/group.rs b/src/repositories/group.rs index d602842..e358465 100644 --- a/src/repositories/group.rs +++ b/src/repositories/group.rs @@ -1,39 +1,39 @@ use crate::models::group; -use crate::repositories::RepositoryContext; -use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter}; +use crate::repositories::{AnyResult, RepositoryContext}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; use std::sync::Arc; use uuid::Uuid; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct GroupRepository { pub context: Arc, } impl GroupRepository { - pub async fn get_all_by_server(&self, server_id: Uuid) -> Result, DbErr> { - group::Entity::find() + pub async fn get_all_by_server(&self, server_id: Uuid) -> AnyResult> { + Ok(group::Entity::find() .filter(group::Column::ServerId.eq(server_id)) .all(&self.context.db) - .await + .await?) } - pub async fn get_by_id(&self, id: Uuid) -> Result, DbErr> { - group::Entity::find_by_id(id).one(&self.context.db).await + pub async fn get_by_id(&self, id: Uuid) -> AnyResult> { + Ok(group::Entity::find_by_id(id).one(&self.context.db).await?) } - pub async fn create(&self, active: group::ActiveModel) -> Result { + pub async fn create(&self, active: group::ActiveModel) -> AnyResult { let group = active.insert(&self.context.db).await?; self.context.events.emit("group_created", group.clone()); Ok(group) } - pub async fn update(&self, active: group::ActiveModel) -> Result { + pub async fn update(&self, active: group::ActiveModel) -> AnyResult { let group = active.update(&self.context.db).await?; self.context.events.emit("group_updated", group.clone()); Ok(group) } - pub async fn delete(&self, id: Uuid) -> Result { + pub async fn delete(&self, id: Uuid) -> AnyResult { let res = group::Entity::delete_by_id(id) .exec(&self.context.db) .await?; diff --git a/src/repositories/message.rs b/src/repositories/message.rs index 7b8dd6c..d839f2c 100644 --- a/src/repositories/message.rs +++ b/src/repositories/message.rs @@ -1,33 +1,37 @@ -use std::sync::Arc; -use sea_orm::{DbErr, EntityTrait, ActiveModelTrait}; use crate::models::message; -use crate::repositories::RepositoryContext; +use crate::repositories::{AnyResult, RepositoryContext}; +use sea_orm::{ActiveModelTrait, EntityTrait}; +use std::sync::Arc; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct MessageRepository { - pub context: Arc + pub context: Arc, } impl MessageRepository { - pub async fn get_by_id(&self, id: uuid::Uuid) -> Result, DbErr> { - message::Entity::find_by_id(id).one(&self.context.db).await + pub async fn get_by_id(&self, id: uuid::Uuid) -> AnyResult> { + Ok(message::Entity::find_by_id(id) + .one(&self.context.db) + .await?) } - pub async fn update(&self, active: message::ActiveModel) -> Result { + pub async fn update(&self, active: message::ActiveModel) -> AnyResult { let message = active.update(&self.context.db).await?; self.context.events.emit("message_updated", message.clone()); Ok(message) } - pub async fn create(&self, active: message::ActiveModel) -> Result { + pub async fn create(&self, active: message::ActiveModel) -> AnyResult { let message = active.insert(&self.context.db).await?; self.context.events.emit("message_created", message.clone()); Ok(message) } - pub async fn delete(&self, id: uuid::Uuid) -> Result<(), DbErr> { - message::Entity::delete_by_id(id).exec(&self.context.db).await?; + pub async fn delete(&self, id: uuid::Uuid) -> AnyResult<()> { + message::Entity::delete_by_id(id) + .exec(&self.context.db) + .await?; self.context.events.emit("message_deleted", id); Ok(()) } -} \ No newline at end of file +} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index 4a044ce..749b2cc 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -1,3 +1,5 @@ +pub type AnyResult = anyhow::Result; + use crate::repositories::category::CategoryRepository; use crate::repositories::channel::ChannelRepository; use crate::repositories::group::GroupRepository; @@ -16,13 +18,13 @@ mod server; pub mod types; mod user; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct RepositoryContext { db: DatabaseConnection, events: Arc, } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct Repositories { pub server: ServerRepository, pub category: CategoryRepository, @@ -33,11 +35,8 @@ pub struct Repositories { } impl Repositories { - pub fn new(db: &DatabaseConnection, events: Arc) -> Self { - let context = Arc::new(RepositoryContext { - db: db.clone(), - events, - }); + pub fn new(db: DatabaseConnection, events: Arc) -> Self { + let context = Arc::new(RepositoryContext { db, events }); Self { server: ServerRepository { diff --git a/src/repositories/server.rs b/src/repositories/server.rs index 9b1be9f..1458da7 100644 --- a/src/repositories/server.rs +++ b/src/repositories/server.rs @@ -1,32 +1,40 @@ use super::types::{ServerExplorerItem, ServerTree}; -use super::RepositoryContext; -use crate::models::{category, channel, group, server}; -use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set}; +use super::{AnyResult, RepositoryContext}; +use crate::models::{category, channel, group, server, server_user}; +use sea_orm::prelude::*; +use sea_orm::{ActiveModelTrait, Set}; use std::sync::Arc; use uuid::Uuid; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ServerRepository { pub context: Arc, } impl ServerRepository { - pub async fn get_all(&self) -> Result, DbErr> { - server::Entity::find().all(&self.context.db).await + pub async fn get_all(&self) -> AnyResult> { + Ok(server::Entity::find().all(&self.context.db).await?) } - pub async fn get_by_id(&self, id: uuid::Uuid) -> Result, DbErr> { - server::Entity::find_by_id(id).one(&self.context.db).await + pub async fn get_by_id(&self, id: Uuid) -> AnyResult> { + Ok(server::Entity::find_by_id(id).one(&self.context.db).await?) } - pub async fn update(&self, active: server::ActiveModel) -> Result { + pub async fn get_default(&self) -> AnyResult> { + Ok(server::Entity::find() + .filter(server::Column::IsDefault.eq(true)) + .one(&self.context.db) + .await?) + } + + pub async fn update(&self, active: server::ActiveModel) -> AnyResult { let server = active.update(&self.context.db).await?; self.context.events.emit("server_updated", server.clone()); Ok(server) } - pub async fn create(&self, active: server::ActiveModel) -> Result { + pub async fn create(&self, active: server::ActiveModel) -> AnyResult { let server = active.insert(&self.context.db).await?; // Créer le groupe par défaut pour le serveur @@ -42,18 +50,51 @@ impl ServerRepository { Ok(server) } - pub async fn delete(&self, id: uuid::Uuid) -> Result { + pub async fn create_with_args( + &self, + name: String, + is_default: bool, + ) -> AnyResult { + let active = server::ActiveModel { + name: Set(name), + is_default: Set(is_default), + ..Default::default() + }; + self.create(active).await + } + + pub async fn add_user(&self, server_id: Uuid, user_id: Uuid) -> AnyResult { + let res = server_user::ActiveModel { + server_id: Set(server_id), + user_id: Set(user_id), + ..Default::default() + } + .insert(&self.context.db) + .await?; + + self.context + .events + .emit("server_user_created", (server_id, user_id)); + Ok(true) + } + + pub async fn delete(&self, id: Uuid) -> AnyResult { let res = server::Entity::delete_by_id(id) .exec(&self.context.db) .await?; self.context.events.emit("server_deleted", id); Ok(res.rows_affected > 0) } + + pub async fn count(&self) -> AnyResult { + let res = server::Entity::find().count(&self.context.db).await?; + Ok(res as usize) + } } // Helpers impl ServerRepository { - pub async fn get_tree(&self, server_id: Uuid) -> Result { + pub async fn get_tree(&self, server_id: Uuid) -> AnyResult { // 1. Récupération des catégories avec leurs channels let categories_with_channels = category::Entity::find() .filter(category::Column::ServerId.eq(server_id)) diff --git a/src/repositories/user.rs b/src/repositories/user.rs index 0dafe5b..d166e60 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -1,75 +1,70 @@ +use crate::auth::password; use crate::models::user; -use crate::repositories::RepositoryContext; -use crate::utils::password; +use crate::repositories::{AnyResult, RepositoryContext}; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, IntoActiveModel, PaginatorTrait, - QueryFilter, Set, + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, Set, }; use std::sync::Arc; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct UserRepository { pub context: Arc, } impl UserRepository { - pub async fn get_all(&self) -> Result, DbErr> { - user::Entity::find().all(&self.context.db).await + pub async fn get_all(&self) -> AnyResult> { + Ok(user::Entity::find().all(&self.context.db).await?) } - pub async fn count(&self) -> Result { - user::Entity::find().count(&self.context.db).await + pub async fn count(&self) -> AnyResult { + Ok(user::Entity::find().count(&self.context.db).await?) } - pub async fn get_by_id(&self, id: uuid::Uuid) -> Result, DbErr> { - user::Entity::find_by_id(id).one(&self.context.db).await + pub async fn get_by_id(&self, id: uuid::Uuid) -> AnyResult> { + Ok(user::Entity::find_by_id(id).one(&self.context.db).await?) } - pub async fn get_by_username(&self, username: String) -> Result, DbErr> { - user::Entity::find() + pub async fn get_by_username(&self, username: String) -> AnyResult> { + Ok(user::Entity::find() .filter(user::Column::Username.eq(username)) .one(&self.context.db) - .await + .await?) } - pub async fn check_password( - &self, - username: String, - password: String, - ) -> Result { - let user = self.get_by_username(username).await?; + pub async fn check_password(&self, username: &str, password: &str) -> AnyResult { + let user = self.get_by_username(username.to_string()).await?; if let Some(user) = user { - let password_ok = password::verify_password(password.as_str(), user.password.as_str()) - .map_err(|_| DbErr::Custom("Password hashing failed".to_string()))?; + let password_ok = password::verify_password(password, user.password.as_str()) + .map_err(|e| anyhow::anyhow!("Password hashing failed: {}", e))?; if password_ok { return Ok(user); } } - Err(DbErr::Custom("Invalid username or password".to_string())) + Err(anyhow::anyhow!("Invalid username or password")) } - pub async fn update(&self, active: user::ActiveModel) -> Result { + pub async fn update(&self, active: user::ActiveModel) -> AnyResult { let user = active.update(&self.context.db).await?; self.context.events.emit("user_updated", user.clone()); Ok(user) } - pub async fn create(&self, active: user::ActiveModel) -> Result { + pub async fn create(&self, active: user::ActiveModel) -> AnyResult { let user = active.insert(&self.context.db).await?; self.context.events.emit("user_created", user.clone()); Ok(user) } - pub async fn set_password(&self, id: uuid::Uuid, password: String) -> Result<(), DbErr> { + pub async fn set_password(&self, id: uuid::Uuid, password: String) -> AnyResult<()> { let user = self .get_by_id(id) .await? - .ok_or_else(|| DbErr::Custom("User not found".to_string()))?; + .ok_or_else(|| anyhow::anyhow!("User not found"))?; let mut active = user.into_active_model(); let password = password::hash_password(&password) - .map_err(|_| DbErr::Custom("Password hashing failed".to_string()))?; + .map_err(|e| anyhow::anyhow!("Password hashing failed: {}", e))?; active.password = Set(password); let user = self.update(active).await?; @@ -78,11 +73,19 @@ impl UserRepository { Ok(()) } - pub async fn delete(&self, id: uuid::Uuid) -> Result<(), DbErr> { + pub async fn delete(&self, id: uuid::Uuid) -> AnyResult<()> { user::Entity::delete_by_id(id) .exec(&self.context.db) .await?; self.context.events.emit("user_deleted", id); Ok(()) } + + pub async fn username_exists(&self, username: &str) -> AnyResult { + Ok(user::Entity::find() + .filter(user::Column::Username.eq(username)) + .count(&self.context.db) + .await? + > 0) + } } diff --git a/src/routes/auth/domain.rs b/src/routes/auth/domain.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/routes/auth/domain.rs @@ -0,0 +1 @@ + diff --git a/src/routes/auth/dto.rs b/src/routes/auth/dto.rs new file mode 100644 index 0000000..515c113 --- /dev/null +++ b/src/routes/auth/dto.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Deserialize, ToSchema)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Serialize, ToSchema)] +pub struct LoginResponse { + pub token: String, + pub username: String, +} + +#[derive(Serialize, ToSchema)] +pub struct CheckResponse { + pub authenticated: bool, +} diff --git a/src/routes/auth/handlers.rs b/src/routes/auth/handlers.rs new file mode 100644 index 0000000..dbf5571 --- /dev/null +++ b/src/routes/auth/handlers.rs @@ -0,0 +1,57 @@ +use super::dto::{CheckResponse, LoginRequest, LoginResponse}; +use crate::auth::token::create_jwt; +use crate::core::AppState; +use crate::http::context::CurrentUser; +use crate::http::error::HTTPError; +use axum::extract::State; +use axum::Json; + +#[utoipa::path( + post, + path = "/api/auth/login", + responses( + (status = 200, description = "Login successful", body = LoginResponse), + (status = 401, description = "Unauthorized") + ) +)] +pub async fn login_user_pw( + State(state): State, + Json(payload): Json, +) -> Result, HTTPError> { + let user = state + .repositories + .user + .check_password(&payload.username, &payload.password) + .await + .map_err(|_| HTTPError::Unauthorized)?; + + let token = create_jwt( + user.id, + &user.username, + &state.config.jwt.secret, + state.config.jwt.duration, + ) + .map_err(|_| HTTPError::InternalServerError("Failed to create JWT token".to_string()))?; + + Ok(Json(LoginResponse { + username: user.username, + token, + })) +} + +#[utoipa::path( + post, + path = "/api/auth/check", + responses( + (status = 200, description = "Login successful", body = LoginResponse), + (status = 401, description = "Unauthorized") + ) +)] +pub async fn check( + State(state): State, + user: CurrentUser, +) -> Result, HTTPError> { + Ok(Json(CheckResponse { + authenticated: true, + })) +} diff --git a/src/routes/auth/mapper.rs b/src/routes/auth/mapper.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/routes/auth/mapper.rs @@ -0,0 +1 @@ + diff --git a/src/routes/auth/mod.rs b/src/routes/auth/mod.rs new file mode 100644 index 0000000..9c36fc8 --- /dev/null +++ b/src/routes/auth/mod.rs @@ -0,0 +1,6 @@ +pub mod domain; +pub mod dto; +pub mod handlers; +pub mod mapper; +pub mod routes; +pub mod service; diff --git a/src/routes/auth/routes.rs b/src/routes/auth/routes.rs new file mode 100644 index 0000000..0a6aa15 --- /dev/null +++ b/src/routes/auth/routes.rs @@ -0,0 +1,16 @@ +use crate::http::OxRouter; +use crate::routes::auth::handlers; +use axum::routing::post; +use axum::Router; + +pub fn router() -> OxRouter { + Router::new().route("/login", post(handlers::login_user_pw)) + + // .route("/categorys", get(handlers::get_all).post(handlers::create)) + // .route( + // "/categorys/:id", + // get(handlers::get_by_id) + // .put(handlers::update) + // .delete(handlers::delete), + // ) +} diff --git a/src/routes/auth/service.rs b/src/routes/auth/service.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/routes/auth/service.rs @@ -0,0 +1 @@ + diff --git a/src/routes/core/domain.rs b/src/routes/core/domain.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/routes/core/domain.rs @@ -0,0 +1 @@ + diff --git a/src/routes/core/dto.rs b/src/routes/core/dto.rs new file mode 100644 index 0000000..15e7a79 --- /dev/null +++ b/src/routes/core/dto.rs @@ -0,0 +1,14 @@ +use serde::Deserialize; +use utoipa::ToSchema; +use validator::Validate; + +#[derive(Deserialize, Validate, ToSchema)] +pub struct JoinRequest { + #[validate(length(min = 3, message = "Username must be at least 3 characters long"))] + pub username: String, + #[validate(length(min = 8, message = "Password must be at least 8 characters long"))] + pub password: String, + #[validate(must_match(other = "password", message = "Passwords do not match"))] + pub password_valid: String, + pub super_admin_token: Option, +} diff --git a/src/routes/core/handlers.rs b/src/routes/core/handlers.rs new file mode 100644 index 0000000..92f9a28 --- /dev/null +++ b/src/routes/core/handlers.rs @@ -0,0 +1,36 @@ +use crate::core::AppState; +use crate::http::error::HTTPError; +use crate::http::validation::ValidatedJson; +use crate::routes::core::dto::JoinRequest; +use crate::routes::core::mapper::join_request_to_user_am; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +pub async fn join( + State(state): State, + ValidatedJson(payload): ValidatedJson, +) -> Result { + let user_exists = state + .repositories + .user + .username_exists(&payload.username) + .await?; + + if user_exists { + return Err(HTTPError::validation_error( + "username", + "Username already exists", + )); + }; + + let user_am = join_request_to_user_am(payload)?; + let user = state.repositories.user.create(user_am).await?; + state + .repositories + .server + .add_user(state.default_server.id, user.id) + .await?; + + Ok(StatusCode::CREATED) +} diff --git a/src/routes/core/mapper.rs b/src/routes/core/mapper.rs new file mode 100644 index 0000000..9ff4c60 --- /dev/null +++ b/src/routes/core/mapper.rs @@ -0,0 +1,14 @@ +use crate::auth::password::hash_password; +use crate::models::user; +use crate::routes::core::dto::JoinRequest; +use anyhow::Result as AnyResult; +use sea_orm::Set; + +pub fn join_request_to_user_am(payload: JoinRequest) -> AnyResult { + Ok(user::ActiveModel { + id: Default::default(), + username: Set(payload.username), + password: Set(hash_password(&payload.password)?), + ..Default::default() + }) +} diff --git a/src/routes/core/mod.rs b/src/routes/core/mod.rs new file mode 100644 index 0000000..9c36fc8 --- /dev/null +++ b/src/routes/core/mod.rs @@ -0,0 +1,6 @@ +pub mod domain; +pub mod dto; +pub mod handlers; +pub mod mapper; +pub mod routes; +pub mod service; diff --git a/src/routes/core/routes.rs b/src/routes/core/routes.rs new file mode 100644 index 0000000..e866824 --- /dev/null +++ b/src/routes/core/routes.rs @@ -0,0 +1,7 @@ +use super::handlers; +use crate::http::OxRouter; +use axum::{routing::get, Router}; + +pub fn router() -> OxRouter { + Router::new().route("/join", get(handlers::join)) +} diff --git a/src/routes/core/service.rs b/src/routes/core/service.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/routes/core/service.rs @@ -0,0 +1 @@ + diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 80dfbb2..fda49ca 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,20 +1,23 @@ +use crate::http::OxRouter; use axum::Router; pub mod attachment; +pub mod auth; pub mod category; pub mod channel; +pub mod core; pub mod group; pub mod message; pub mod server; pub mod user; -pub fn router() -> Router { - Router::new() - .merge(user::routes::router()) - .merge(server::routes::router()) - .merge(channel::routes::router()) - .merge(message::routes::router()) - .merge(group::routes::router()) - .merge(category::routes::router()) - .merge(attachment::routes::router()) +pub fn router() -> OxRouter { + Router::new().merge(auth::routes::router()) + // .merge(user::routes::router()) + // .merge(server::routes::router()) + // .merge(channel::routes::router()) + // .merge(message::routes::router()) + // .merge(group::routes::router()) + // .merge(category::routes::router()) + // .merge(attachment::routes::router()) } diff --git a/src/udp/server.rs b/src/udp/server.rs index f09d2ed..fade881 100644 --- a/src/udp/server.rs +++ b/src/udp/server.rs @@ -67,14 +67,11 @@ pub struct UdpServer { impl UdpServer { /// Crée un nouveau [`UdpServer`] depuis la configuration réseau globale. /// - /// `metrics` est un [`Arc`] vers le jeu de métriques à alimenter. - /// Conservez-en un clone si vous souhaitez l'observer depuis l'extérieur - /// (reporter, API de santé, etc.). - /// /// Retourne le serveur et un [`broadcast::Sender`] pour déclencher le /// shutdown gracieux. - pub fn new(network: NetworkConfig, metrics: Arc) -> (Self, broadcast::Sender<()>) { + pub fn new(network: &NetworkConfig) -> (Self, broadcast::Sender<()>) { let bind_addr = SocketAddr::new(network.host.into(), network.udp_port); + let metrics = UdpMetrics::new(); let (shutdown_tx, shutdown_rx) = broadcast::channel(1); ( Self {