diff --git a/Cargo.lock b/Cargo.lock index dd21e77..18ce169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,6 +549,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -2265,6 +2266,23 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nix" version = "0.29.0" @@ -2414,6 +2432,7 @@ dependencies = [ "sea-orm", "serde", "serde_json", + "sha2 0.10.9", "thiserror", "time", "tokio", diff --git a/Cargo.toml b/Cargo.toml index dd856ea..a0ff582 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,795 @@ members = [".", "migration", "event_bus"] [dependencies] tokio = { version = "1.53.1", features = ["full"] } -axum = { version = "0.8", features = ["ws"] } +.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +axum = { version = "0.8", features = ["ws", "multipart"] } axum-extra = { version = "0.12.6", features = ["cookie"] } config = "0.15.25" sea-orm = { version = "2.0.0", features = ["sqlx-sqlite", "sqlx-postgres", "sqlx-mysql", "runtime-tokio", "with-chrono", "with-uuid", "with-json", "schema-sync"] } @@ -41,3 +829,4 @@ anyhow = "1.0.104" futures-util = "0.3" form_urlencoded = "1.2.2" time = "0.3.54" +sha2 = "0.10" diff --git a/event_bus/benches/event_bus_throughput.rs b/event_bus/benches/event_bus_throughput.rs index d308759..32b5bb9 100644 --- a/event_bus/benches/event_bus_throughput.rs +++ b/event_bus/benches/event_bus_throughput.rs @@ -1,10 +1,10 @@ use std::sync::{ - atomic::{AtomicU64, Ordering}, Arc, + atomic::{AtomicU64, Ordering}, }; use std::time::Instant; -use criterion::{criterion_group, criterion_main, Criterion, Throughput}; +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; use event_bus::EventBus; use tokio::runtime::Runtime; diff --git a/event_bus/src/tests.rs b/event_bus/src/tests.rs index 4efabe8..ad76154 100644 --- a/event_bus/src/tests.rs +++ b/event_bus/src/tests.rs @@ -1,6 +1,6 @@ -use crate::{match_event, EventBus}; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use crate::{EventBus, match_event}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; #[derive(Clone, Debug, PartialEq)] struct User { diff --git a/media/.gitkeep b/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migration/src/m20220101_000001_create_table.rs b/migration/src/m20220101_000001_create_table.rs index 9ec88a9..537382c 100644 --- a/migration/src/m20220101_000001_create_table.rs +++ b/migration/src/m20220101_000001_create_table.rs @@ -515,10 +515,7 @@ impl MigrationTrait for Migration { .foreign_key( ForeignKey::create() .name("fk_channel_user_read_state_user") - .from( - Alias::new("channel_user_read_state"), - Alias::new("user_id"), - ) + .from(Alias::new("channel_user_read_state"), Alias::new("user_id")) .to(Alias::new("user"), Alias::new("id")) .on_delete(ForeignKeyAction::Cascade), ) @@ -644,6 +641,111 @@ impl MigrationTrait for Migration { // Permissions // --------------------------------------------------------------------- + manager + .create_table( + Table::create() + .table(Alias::new("emoji")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("server_id")).uuid().null()) + .col(ColumnDef::new(Alias::new("emoji_type")).string().not_null()) + .col(ColumnDef::new(Alias::new("unicode_sequence")).text().null()) + .col(ColumnDef::new(Alias::new("file_path")).text().null()) + .col(ColumnDef::new(Alias::new("mime_type")).string().null()) + .col(ColumnDef::new(Alias::new("file_size")).big_integer().null()) + .col( + ColumnDef::new(Alias::new("is_animated")) + .boolean() + .not_null() + .default(false), + ) + .col(ColumnDef::new(Alias::new("sha256")).string().null()) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_emoji_server") + .from(Alias::new("emoji"), Alias::new("server_id")) + .to(Alias::new("server"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_emoji_server_id") + .table(Alias::new("emoji")) + .col(Alias::new("server_id")) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(Alias::new("emoji_alias")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("emoji_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("alias")).string().not_null()) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_emoji_alias_emoji") + .from(Alias::new("emoji_alias"), Alias::new("emoji_id")) + .to(Alias::new("emoji"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("uq_emoji_alias_emoji") + .table(Alias::new("emoji_alias")) + .col(Alias::new("emoji_id")) + .col(Alias::new("alias")) + .unique() + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -908,6 +1010,8 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { let tables = [ "computed_permission", + "emoji_alias", + "emoji", "channel_user_read_state", "channel_user_permission", "channel_role_permission", diff --git a/src/auth/password.rs b/src/auth/password.rs index 981bc65..0135730 100644 --- a/src/auth/password.rs +++ b/src/auth/password.rs @@ -1,6 +1,6 @@ use argon2::{ - password_hash::{phc::PasswordHash, PasswordHasher, PasswordVerifier}, Algorithm, Argon2, Params, - Version, + Algorithm, Argon2, Params, Version, + password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}, }; /// Hache un password avec Argon2id diff --git a/src/auth/token.rs b/src/auth/token.rs index 3235c93..bbd7455 100644 --- a/src/auth/token.rs +++ b/src/auth/token.rs @@ -1,4 +1,4 @@ -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; diff --git a/src/config.rs b/src/config.rs index 30e0abb..e8b49b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,6 +60,9 @@ url = "sqlite://oxspeak.db" # MySQL # url = "mysql://user:passwd@localhost:3306/db_name" +[media] +root = "media" + [jwt] secret = "changeme" # Duration in seconds @@ -72,6 +75,26 @@ pub struct AppConfig { pub network: NetworkConfig, pub database: DatabaseConfig, pub jwt: JwtConfig, + #[serde(default)] + pub media: MediaConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MediaConfig { + #[serde(default = "default_media_root")] + pub root: String, +} + +impl Default for MediaConfig { + fn default() -> Self { + Self { + root: default_media_root(), + } + } +} + +fn default_media_root() -> String { + "media".to_string() } #[derive(Debug, Clone, Deserialize)] diff --git a/src/core/mod.rs b/src/core/mod.rs index 3712199..bc8f109 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -69,7 +69,9 @@ impl App { let services = Arc::new(Services::new(repositories.clone(), event_bus.clone())); services.permission_sync.start_listen_event().await; services.realtime_registry.initialize(&repositories).await?; - services.realtime_registry.start_listening(repositories.clone(), event_bus.clone()); + services + .realtime_registry + .start_listening(repositories.clone(), event_bus.clone()); let gateway = Arc::new(GatewayManager::new(services.clone())); gateway.start(event_bus.clone()); diff --git a/src/database/mod.rs b/src/database/mod.rs index 410e680..895bf90 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +1,3 @@ -pub mod database; - -pub use database::Database; \ No newline at end of file +pub mod database; + +pub use database::Database; diff --git a/src/domain/dto/emoji.rs b/src/domain/dto/emoji.rs new file mode 100644 index 0000000..fee3c0f --- /dev/null +++ b/src/domain/dto/emoji.rs @@ -0,0 +1,42 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +#[derive(Debug, Deserialize, IntoParams, ToSchema)] +pub struct EmojiQueryParams { + pub server_id: Option, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct CreateEmojiRequest { + pub server_id: Option, + pub emoji_type: String, + pub unicode_sequence: Option, + pub aliases: Vec, + pub mime_type: Option, + pub is_animated: Option, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct UpdateEmojiRequest { + pub aliases: Option>, + pub server_id: Option, + pub unicode_sequence: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct EmojiResponse { + pub id: Uuid, + pub server_id: Option, + pub emoji_type: String, + pub unicode_sequence: Option, + pub aliases: Vec, + pub asset_url: Option, + pub mime_type: Option, + pub file_size: Option, + pub is_animated: bool, + pub sha256: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/domain/dto/mod.rs b/src/domain/dto/mod.rs index 806ee0a..b0ba6a3 100644 --- a/src/domain/dto/mod.rs +++ b/src/domain/dto/mod.rs @@ -1,9 +1,10 @@ -pub mod auth; pub mod attachment; +pub mod auth; +pub mod category; +pub mod channel; +pub mod core; +pub mod emoji; pub mod message; pub mod role; pub mod server; -pub mod core; pub mod user; -pub mod channel; -pub mod category; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index e83c008..31c8295 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,2 +1,2 @@ -pub mod events; pub mod dto; +pub mod events; diff --git a/src/http/error.rs b/src/http/error.rs index 99c067c..29308c6 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -1,6 +1,6 @@ +use axum::Json; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use axum::Json; use sea_orm::DbErr; use serde::Serialize; use serde_json::json; diff --git a/src/http/metrics.rs b/src/http/metrics.rs index bf27364..d468e7d 100644 --- a/src/http/metrics.rs +++ b/src/http/metrics.rs @@ -19,8 +19,8 @@ //! | `panics_caught` | Panics interceptés par `CatchPanicLayer` | //! | `latency_ms_total` | Latence cumulée en ms (pour moyenne glissante) | -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use crate::metrics::{Metrics, MetricsSnapshot}; diff --git a/src/http/middleware.rs b/src/http/middleware.rs index 46658ab..5044dea 100644 --- a/src/http/middleware.rs +++ b/src/http/middleware.rs @@ -1,14 +1,14 @@ use axum::{ body::{Body, HttpBody}, extract::State, - http::{header, Request}, + http::{Request, header}, middleware::Next, response::{IntoResponse, Response}, }; use axum_extra::extract::CookieJar; use std::sync::Arc; use std::time::Instant; -use tracing::{debug, info, Instrument}; +use tracing::{Instrument, debug, info}; use uuid::Uuid; use super::context::{CurrentUser, RequestContext}; diff --git a/src/http/mod.rs b/src/http/mod.rs index d8be93d..437bfcd 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -5,10 +5,10 @@ pub mod context; pub mod error; pub mod metrics; pub mod middleware; +pub mod permissions; pub mod server; pub mod validation; -pub mod permissions; -pub use permissions::{RequireServerPermission, RequireChannelPermission}; +pub use permissions::{RequireChannelPermission, RequireServerPermission}; pub type OxRouter = Router; diff --git a/src/http/server.rs b/src/http/server.rs index 52a715c..c2e6b30 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -7,8 +7,8 @@ use std::net::SocketAddr; use std::sync::Arc; -use axum::middleware as axum_middleware; use axum::Router; +use axum::middleware as axum_middleware; use tokio::net::TcpListener; use tokio::sync::broadcast; use tower_http::catch_panic::CatchPanicLayer; diff --git a/src/http/validation.rs b/src/http/validation.rs index 270ce70..5e68169 100644 --- a/src/http/validation.rs +++ b/src/http/validation.rs @@ -1,11 +1,11 @@ use axum::{ + Json, extract::{FromRequest, Request}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; -use serde::de::DeserializeOwned; use serde::Serialize; +use serde::de::DeserializeOwned; use std::collections::HashMap; use validator::Validate; diff --git a/src/models/attachment.rs b/src/models/attachment.rs index 6ffe5ac..d6a7c96 100644 --- a/src/models/attachment.rs +++ b/src/models/attachment.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +use sea_orm::Set; use sea_orm::entity::prelude::*; use sea_orm::prelude::async_trait::async_trait; -use sea_orm::Set; #[sea_orm::model] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] diff --git a/src/models/channel_user.rs b/src/models/channel_user.rs index d3b6d94..ada70fc 100644 --- a/src/models/channel_user.rs +++ b/src/models/channel_user.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +use sea_orm::Set; use sea_orm::entity::prelude::*; use sea_orm::prelude::async_trait::async_trait; -use sea_orm::Set; #[sea_orm::model] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] diff --git a/src/models/emoji.rs b/src/models/emoji.rs new file mode 100644 index 0000000..9309d1c --- /dev/null +++ b/src/models/emoji.rs @@ -0,0 +1,34 @@ +use sea_orm::Set; +use sea_orm::entity::prelude::*; +use sea_orm::prelude::async_trait::async_trait; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "emoji")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub server_id: Option, + pub emoji_type: String, + pub unicode_sequence: Option, + pub file_path: Option, + pub mime_type: Option, + pub file_size: Option, + pub is_animated: bool, + pub sha256: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(has_many)] + pub aliases: HasMany, +} + +#[async_trait] +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + id: Set(Uuid::new_v4()), + is_animated: Set(false), + ..Default::default() + } + } +} diff --git a/src/models/emoji_alias.rs b/src/models/emoji_alias.rs new file mode 100644 index 0000000..9bfc9a0 --- /dev/null +++ b/src/models/emoji_alias.rs @@ -0,0 +1,27 @@ +use sea_orm::Set; +use sea_orm::entity::prelude::*; +use sea_orm::prelude::async_trait::async_trait; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "emoji_alias")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub emoji_id: Uuid, + pub alias: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(belongs_to, from = "emoji_id", to = "id", on_delete = "Cascade")] + pub emoji: HasOne, +} + +#[async_trait] +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + id: Set(Uuid::new_v4()), + ..Default::default() + } + } +} diff --git a/src/models/message.rs b/src/models/message.rs index 030ff14..66196d2 100644 --- a/src/models/message.rs +++ b/src/models/message.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +use sea_orm::Set; use sea_orm::entity::prelude::*; use sea_orm::prelude::async_trait::async_trait; -use sea_orm::Set; #[sea_orm::model] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] diff --git a/src/models/mod.rs b/src/models/mod.rs index e496f73..6cf5c93 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -7,9 +7,11 @@ pub mod category; pub mod channel; pub mod channel_role_permission; pub mod channel_user; -pub mod channel_user_read_state; pub mod channel_user_permission; +pub mod channel_user_read_state; pub mod computed_permission; +pub mod emoji; +pub mod emoji_alias; pub mod message; pub mod role; pub mod role_user; diff --git a/src/models/prelude.rs b/src/models/prelude.rs index 7114d8d..dce8714 100644 --- a/src/models/prelude.rs +++ b/src/models/prelude.rs @@ -6,6 +6,8 @@ pub use super::channel::Entity as Channel; pub use super::channel_user::Entity as ChannelUser; pub use super::channel_user_read_state::Entity as ChannelUserReadState; pub use super::computed_permission::Entity as ComputedPermission; +pub use super::emoji::Entity as Emoji; +pub use super::emoji_alias::Entity as EmojiAlias; pub use super::message::Entity as Message; pub use super::role::Entity as Group; pub use super::role_user::Entity as GroupMember; diff --git a/src/models/role.rs b/src/models/role.rs index c7ad6a7..5fffab1 100644 --- a/src/models/role.rs +++ b/src/models/role.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity. +use sea_orm::Set; use sea_orm::entity::prelude::*; use sea_orm::prelude::async_trait::async_trait; -use sea_orm::Set; #[sea_orm::model] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] diff --git a/src/models/server.rs b/src/models/server.rs index 1e19bfd..cd7d3bc 100644 --- a/src/models/server.rs +++ b/src/models/server.rs @@ -1,36 +1,36 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 - -use sea_orm::entity::prelude::*; -use sea_orm::prelude::async_trait::async_trait; -use sea_orm::Set; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] -#[sea_orm(table_name = "server")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub name: String, - pub password: Option, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - pub is_default: bool, - pub owner_id: Option, - #[sea_orm(has_many)] - pub categories: HasMany, - #[sea_orm(has_many)] - pub channels: HasMany, - #[sea_orm(has_many)] - pub server_users: HasMany, -} - -#[async_trait] -impl ActiveModelBehavior for ActiveModel { - fn new() -> Self { - Self { - id: Set(Uuid::new_v4()), - is_default: Set(false), - ..ActiveModelTrait::default() - } - } -} +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::Set; +use sea_orm::entity::prelude::*; +use sea_orm::prelude::async_trait::async_trait; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "server")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: String, + pub password: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub is_default: bool, + pub owner_id: Option, + #[sea_orm(has_many)] + pub categories: HasMany, + #[sea_orm(has_many)] + pub channels: HasMany, + #[sea_orm(has_many)] + pub server_users: HasMany, +} + +#[async_trait] +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + id: Set(Uuid::new_v4()), + is_default: Set(false), + ..ActiveModelTrait::default() + } + } +} diff --git a/src/models/server_user.rs b/src/models/server_user.rs index ad3f603..d7272f2 100644 --- a/src/models/server_user.rs +++ b/src/models/server_user.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +use sea_orm::Set; use sea_orm::entity::prelude::*; use sea_orm::prelude::async_trait::async_trait; -use sea_orm::Set; #[sea_orm::model] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] diff --git a/src/models/user.rs b/src/models/user.rs index 49b8ca1..4c8135d 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +use sea_orm::Set; use sea_orm::entity::prelude::*; use sea_orm::prelude::async_trait::async_trait; -use sea_orm::Set; #[sea_orm::model] #[sea_orm(model_ex_attrs(derive(Debug)))] diff --git a/src/repositories/emoji.rs b/src/repositories/emoji.rs new file mode 100644 index 0000000..49b1d8a --- /dev/null +++ b/src/repositories/emoji.rs @@ -0,0 +1,100 @@ +use crate::models::{emoji, emoji_alias}; +use crate::repositories::{AnyResult, RepositoryContext}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QueryOrder, Set, +}; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct EmojiRepository { + pub context: Arc, +} + +impl EmojiRepository { + pub async fn get_by_id(&self, id: Uuid) -> AnyResult> { + Ok(emoji::Entity::find_by_id(id).one(&self.context.db).await?) + } + + pub async fn list(&self, server_id: Option) -> AnyResult> { + let mut query = emoji::Entity::find(); + if let Some(server_id) = server_id { + query = query.filter( + emoji::Column::ServerId + .is_null() + .or(emoji::Column::ServerId.eq(server_id)), + ); + } else { + query = query.filter(emoji::Column::ServerId.is_null()); + } + Ok(query + .order_by_asc(emoji::Column::CreatedAt) + .all(&self.context.db) + .await?) + } + + pub async fn list_exact_scope(&self, server_id: Option) -> AnyResult> { + let mut query = emoji::Entity::find(); + query = match server_id { + Some(id) => query.filter(emoji::Column::ServerId.eq(id)), + None => query.filter(emoji::Column::ServerId.is_null()), + }; + Ok(query.all(&self.context.db).await?) + } + + pub async fn aliases(&self, emoji_id: Uuid) -> AnyResult> { + Ok(emoji_alias::Entity::find() + .filter(emoji_alias::Column::EmojiId.eq(emoji_id)) + .all(&self.context.db) + .await?) + } + + pub async fn find_alias( + &self, + alias: &str, + server_id: Option, + ) -> AnyResult> { + let rows = self.list(server_id).await?; + for model in rows { + if self + .aliases(model.id) + .await? + .iter() + .any(|a| a.alias == alias) + { + return Ok(Some(model)); + } + } + Ok(None) + } + + pub async fn create(&self, model: emoji::ActiveModel) -> AnyResult { + Ok(model.insert(&self.context.db).await?) + } + pub async fn update(&self, model: emoji::ActiveModel) -> AnyResult { + Ok(model.update(&self.context.db).await?) + } + pub async fn delete(&self, id: Uuid) -> AnyResult { + Ok(emoji::Entity::delete_by_id(id) + .exec(&self.context.db) + .await? + .rows_affected + > 0) + } + pub async fn add_alias(&self, emoji_id: Uuid, alias: String) -> AnyResult { + Ok((emoji_alias::ActiveModel { + emoji_id: Set(emoji_id), + alias: Set(alias), + ..Default::default() + }) + .insert(&self.context.db) + .await?) + } + pub async fn clear_aliases(&self, emoji_id: Uuid) -> AnyResult<()> { + emoji_alias::Entity::delete_many() + .filter(emoji_alias::Column::EmojiId.eq(emoji_id)) + .exec(&self.context.db) + .await?; + Ok(()) + } +} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index b0a7c10..f403773 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -3,6 +3,7 @@ pub type AnyResult = anyhow::Result; use crate::repositories::category::CategoryRepository; use crate::repositories::channel::ChannelRepository; use crate::repositories::computed_permission::ComputedPermissionRepository; +use crate::repositories::emoji::EmojiRepository; use crate::repositories::message::MessageRepository; use crate::repositories::read_state::ReadStateRepository; use crate::repositories::role::RoleRepository; @@ -16,6 +17,7 @@ use std::sync::Arc; mod category; mod channel; mod computed_permission; +pub mod emoji; mod message; mod read_state; mod role; @@ -42,6 +44,7 @@ pub struct Repositories { pub computed_permission: ComputedPermissionRepository, pub server_item_order: ServerItemOrderRepository, pub server_tree: ServerTreeRepository, + pub emoji: EmojiRepository, } impl Repositories { @@ -79,6 +82,7 @@ impl Repositories { server_tree: ServerTreeRepository { context: context.clone(), }, + emoji: EmojiRepository { context }, } } } diff --git a/src/repositories/read_state.rs b/src/repositories/read_state.rs index a7066a3..42cede1 100644 --- a/src/repositories/read_state.rs +++ b/src/repositories/read_state.rs @@ -94,10 +94,7 @@ impl ReadStateRepository { Ok(counts) } - pub async fn unread_counts_by_server( - &self, - user_id: Uuid, - ) -> AnyResult> { + pub async fn unread_counts_by_server(&self, user_id: Uuid) -> AnyResult> { let channels = channel::Entity::find() .select_only() .column(channel::Column::Id) @@ -109,7 +106,9 @@ impl ReadStateRepository { let channel_to_server: HashMap = channels .into_iter() - .filter_map(|(channel_id, server_id)| server_id.map(|server_id| (channel_id, server_id))) + .filter_map(|(channel_id, server_id)| { + server_id.map(|server_id| (channel_id, server_id)) + }) .collect(); if channel_to_server.is_empty() { return Ok(HashMap::new()); diff --git a/src/repositories/server.rs b/src/repositories/server.rs index 6268b08..9783ded 100644 --- a/src/repositories/server.rs +++ b/src/repositories/server.rs @@ -1,6 +1,5 @@ use super::{AnyResult, RepositoryContext}; -use crate::models::{role, server, server_role_permission, server_user, server_user_permission, -}; +use crate::models::{role, server, server_role_permission, server_user, server_user_permission}; use sea_orm::prelude::*; use sea_orm::{ActiveModelTrait, QuerySelect, Set}; @@ -84,7 +83,11 @@ impl ServerRepository { Ok(true) } - pub async fn get_user(&self, server_id: Uuid, user_id: Uuid) -> AnyResult> { + pub async fn get_user( + &self, + server_id: Uuid, + user_id: Uuid, + ) -> AnyResult> { Ok(server_user::Entity::find() .filter(server_user::Column::ServerId.eq(server_id)) .filter(server_user::Column::UserId.eq(user_id)) diff --git a/src/repositories/server_tree.rs b/src/repositories/server_tree.rs index c7ef3f1..5e73714 100644 --- a/src/repositories/server_tree.rs +++ b/src/repositories/server_tree.rs @@ -1,4 +1,6 @@ -use crate::models::{category, channel, channel_user_read_state, computed_permission, message, server_item_order}; +use crate::models::{ + category, channel, channel_user_read_state, computed_permission, message, server_item_order, +}; use crate::permissions::ChannelPermission; use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData}; use crate::repositories::{AnyResult, RepositoryContext}; diff --git a/src/routes/attachment/routes.rs b/src/routes/attachment/routes.rs index 860aad3..88f6fb6 100644 --- a/src/routes/attachment/routes.rs +++ b/src/routes/attachment/routes.rs @@ -1,4 +1,4 @@ -use axum::{routing::get, Router}; +use axum::{Router, routing::get}; use super::handlers; diff --git a/src/routes/auth/handlers.rs b/src/routes/auth/handlers.rs index 5fc4bcf..dff0ceb 100644 --- a/src/routes/auth/handlers.rs +++ b/src/routes/auth/handlers.rs @@ -1,13 +1,13 @@ -use crate::domain::dto::auth::{LoginRequest, LoginResponse, MeResponse}; use crate::auth::token::create_jwt; use crate::core::AppState; +use crate::domain::dto::auth::{LoginRequest, LoginResponse, MeResponse}; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; use crate::routes::user::mapper::user_model_to_user_response; -use axum::extract::State; use axum::Json; -use axum_extra::extract::cookie::{Cookie, SameSite}; +use axum::extract::State; use axum_extra::extract::CookieJar; +use axum_extra::extract::cookie::{Cookie, SameSite}; use sea_orm::ActiveModelBehavior; #[utoipa::path( diff --git a/src/routes/auth/routes.rs b/src/routes/auth/routes.rs index 2eecbf7..c84f9d0 100644 --- a/src/routes/auth/routes.rs +++ b/src/routes/auth/routes.rs @@ -1,7 +1,7 @@ use crate::http::OxRouter; use crate::routes::auth::handlers; -use axum::routing::{get, post}; use axum::Router; +use axum::routing::{get, post}; pub fn router() -> OxRouter { Router::new() diff --git a/src/routes/category/handlers.rs b/src/routes/category/handlers.rs index 6a7f954..53bd107 100644 --- a/src/routes/category/handlers.rs +++ b/src/routes/category/handlers.rs @@ -1,14 +1,14 @@ use crate::core::state::AppState; -use crate::http::context::Superuser; -use crate::http::error::HTTPError; use crate::domain::dto::category::{ CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest, }; +use crate::http::context::Superuser; +use crate::http::error::HTTPError; use crate::routes::category::mapper; use axum::{ + Json, extract::{Path, Query, State}, http::StatusCode, - Json, }; use uuid::Uuid; @@ -29,7 +29,11 @@ pub async fn get_all( State(state): State, Query(filters): Query, ) -> Result>, HTTPError> { - let categories = state.repositories.category.filter(filters.server_id).await?; + let categories = state + .repositories + .category + .filter(filters.server_id) + .await?; Ok(Json( categories .into_iter() @@ -94,7 +98,11 @@ pub async fn create( .await? .ok_or(HTTPError::BadRequest("Server not found".to_string()))?; - let category = state.services.category.create_category(payload.server_id, payload.name).await?; + let category = state + .services + .category + .create_category(payload.server_id, payload.name) + .await?; Ok(( StatusCode::CREATED, Json(mapper::category_model_to_category_response(category)), @@ -133,7 +141,11 @@ pub async fn update( .await? .ok_or(HTTPError::NotFound)?; - let category = state.services.category.update_category(id, payload.name).await?; + let category = state + .services + .category + .update_category(id, payload.name) + .await?; Ok(Json(mapper::category_model_to_category_response(category))) } diff --git a/src/routes/category/routes.rs b/src/routes/category/routes.rs index ac0cdde..47566e8 100644 --- a/src/routes/category/routes.rs +++ b/src/routes/category/routes.rs @@ -1,5 +1,5 @@ use crate::core::state::AppState; -use axum::{routing::get, Router}; +use axum::{Router, routing::get}; use super::handlers; diff --git a/src/routes/channel/handlers.rs b/src/routes/channel/handlers.rs index e658895..7750292 100644 --- a/src/routes/channel/handlers.rs +++ b/src/routes/channel/handlers.rs @@ -1,12 +1,11 @@ use crate::core::state::AppState; +use crate::domain::dto::channel::{ + ChannelPermissionsResponse, ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse, + ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse, + SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest, +}; use crate::http::context::{CurrentUser, Superuser}; use crate::http::error::HTTPError; -use crate::domain::dto::channel::{ - ChannelQueryParams, ChannelResponse, ChannelPermissionsResponse, ChannelRolePermissionResponse, - ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse, - SetChannelPermissionRequest, SetReadStateRequest, - UpdateChannelRequest, -}; use crate::routes::channel::mapper; use axum::{ Json, @@ -55,8 +54,17 @@ pub async fn get_read_state( State(state): State, Path(channel_id): Path, ) -> Result, HTTPError> { - state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?; - let read_state = state.repositories.read_state.get(channel_id, user.id).await?; + state + .repositories + .channel + .get_by_id(channel_id) + .await? + .ok_or(HTTPError::NotFound)?; + let read_state = state + .repositories + .read_state + .get(channel_id, user.id) + .await?; let unread_count = state .repositories .read_state @@ -68,7 +76,9 @@ pub async fn get_read_state( Ok(Json(ReadStateResponse { channel_id, - last_read_message_id: read_state.as_ref().and_then(|value| value.last_read_message_id), + last_read_message_id: read_state + .as_ref() + .and_then(|value| value.last_read_message_id), updated_at: read_state.map(|value| value.updated_at), unread_count, })) @@ -89,7 +99,12 @@ pub async fn set_read_state( Path(channel_id): Path, Json(payload): Json, ) -> Result, HTTPError> { - state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?; + state + .repositories + .channel + .get_by_id(channel_id) + .await? + .ok_or(HTTPError::NotFound)?; if let Some(message_id) = payload.last_read_message_id { let message = state @@ -167,7 +182,12 @@ pub async fn list_permissions( State(state): State, Path(channel_id): Path, ) -> Result, HTTPError> { - state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?; + state + .repositories + .channel + .get_by_id(channel_id) + .await? + .ok_or(HTTPError::NotFound)?; let (users, roles) = tokio::try_join!( state.repositories.channel.list_user_permissions(channel_id), state.repositories.channel.list_role_permissions(channel_id), diff --git a/src/routes/channel/mapper.rs b/src/routes/channel/mapper.rs index 0f2a185..384baba 100644 --- a/src/routes/channel/mapper.rs +++ b/src/routes/channel/mapper.rs @@ -77,8 +77,14 @@ pub fn channel_permissions_to_response( roles: Vec, ) -> ChannelPermissionsResponse { ChannelPermissionsResponse { - users: users.into_iter().map(channel_user_permission_to_response).collect(), - roles: roles.into_iter().map(channel_role_permission_to_response).collect(), + users: users + .into_iter() + .map(channel_user_permission_to_response) + .collect(), + roles: roles + .into_iter() + .map(channel_role_permission_to_response) + .collect(), } } diff --git a/src/routes/channel/routes.rs b/src/routes/channel/routes.rs index e875760..cad4d57 100644 --- a/src/routes/channel/routes.rs +++ b/src/routes/channel/routes.rs @@ -1,6 +1,6 @@ use super::handlers; use crate::core::state::AppState; -use axum::{routing::get, Router}; +use axum::{Router, routing::get}; pub fn router() -> Router { Router::new() diff --git a/src/routes/core/handlers.rs b/src/routes/core/handlers.rs index 88e4549..50e0a7e 100644 --- a/src/routes/core/handlers.rs +++ b/src/routes/core/handlers.rs @@ -1,7 +1,7 @@ use crate::core::AppState; +use crate::domain::dto::core::JoinRequest; use crate::http::error::HTTPError; use crate::http::validation::ValidatedJson; -use crate::domain::dto::core::JoinRequest; use crate::routes::core::mapper::join_request_to_user_am; use axum::extract::State; use axum::http::StatusCode; diff --git a/src/routes/core/mapper.rs b/src/routes/core/mapper.rs index bbbf5d2..ded875a 100644 --- a/src/routes/core/mapper.rs +++ b/src/routes/core/mapper.rs @@ -1,6 +1,6 @@ use crate::auth::password::hash_password; -use crate::models::user; use crate::domain::dto::core::JoinRequest; +use crate::models::user; use anyhow::Result as AnyResult; use sea_orm::Set; use uuid::Uuid; diff --git a/src/routes/core/routes.rs b/src/routes/core/routes.rs index 142e678..1939d0c 100644 --- a/src/routes/core/routes.rs +++ b/src/routes/core/routes.rs @@ -1,6 +1,6 @@ use super::handlers; use crate::http::OxRouter; -use axum::{routing::post, Router}; +use axum::{Router, routing::post}; pub fn router() -> OxRouter { Router::new().route("/join", post(handlers::join)) diff --git a/src/routes/emoji/handlers.rs b/src/routes/emoji/handlers.rs new file mode 100644 index 0000000..17ea8c6 --- /dev/null +++ b/src/routes/emoji/handlers.rs @@ -0,0 +1,274 @@ +use crate::{ + core::state::AppState, + domain::dto::emoji::{EmojiQueryParams, UpdateEmojiRequest}, + http::error::HTTPError, + models::emoji, + routes::emoji::mapper, + services::emoji::EmojiService, +}; +use axum::{ + Json, + body::Body, + extract::{Multipart, Path, Query, State}, + http::{StatusCode, header}, + response::Response, +}; +use sea_orm::{EntityTrait, Set}; +use std::path::PathBuf; +use uuid::Uuid; + +fn normalize_type(value: &str) -> Result { + if value == "unicode" || value == "custom" { + Ok(value.to_string()) + } else { + Err(HTTPError::BadRequest( + "emoji_type must be unicode or custom".into(), + )) + } +} + +#[utoipa::path(get, path = "/emojis", params(EmojiQueryParams), responses((status = 200, body = [crate::domain::dto::emoji::EmojiResponse])), tag = "Emojis")] +pub async fn get_all( + State(state): State, + Query(query): Query, +) -> Result>, HTTPError> { + let mut result = Vec::new(); + for emoji in state.repositories.emoji.list(query.server_id).await? { + let aliases = state.repositories.emoji.aliases(emoji.id).await?; + result.push(mapper::response(emoji, mapper::aliases(aliases))); + } + Ok(Json(result)) +} + +#[utoipa::path(get, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] +pub async fn get_by_id( + State(state): State, + Path(id): Path, +) -> Result, HTTPError> { + let emoji = state + .repositories + .emoji + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + let aliases = state.repositories.emoji.aliases(id).await?; + Ok(Json(mapper::response(emoji, mapper::aliases(aliases)))) +} + +#[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] +pub async fn create( + State(state): State, + mut multipart: Multipart, +) -> Result<(StatusCode, Json), HTTPError> { + let mut server_id = None; + let mut emoji_type = None; + let mut unicode_sequence = None; + let mut aliases = Vec::new(); + let mut file = None; + let mut mime = None; + let mut animated = false; + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| HTTPError::BadRequest(e.to_string()))? + { + let name = field.name().unwrap_or_default().to_string(); + if name == "file" { + mime = field.content_type().map(str::to_string); + file = Some( + field + .bytes() + .await + .map_err(|e| HTTPError::BadRequest(e.to_string()))?, + ); + continue; + } + let value = field + .text() + .await + .map_err(|e| HTTPError::BadRequest(e.to_string()))?; + match name.as_str() { + "server_id" => { + server_id = Some( + value + .parse() + .map_err(|_| HTTPError::BadRequest("Invalid server_id".into()))?, + ) + } + "emoji_type" => emoji_type = Some(value), + "unicode_sequence" => unicode_sequence = Some(value), + "alias" => aliases.push(value), + "aliases" => aliases.extend(value.split(',').map(str::to_string)), + "is_animated" => animated = value.parse().unwrap_or(false), + _ => {} + } + } + let emoji_type = normalize_type(emoji_type.as_deref().unwrap_or("custom"))?; + if emoji_type == "unicode" && unicode_sequence.is_none() { + return Err(HTTPError::BadRequest("unicode_sequence is required".into())); + } + if emoji_type == "custom" && file.is_none() { + return Err(HTTPError::BadRequest( + "file is required for custom emojis".into(), + )); + } + if let Some(server_id) = server_id { + state + .repositories + .server + .get_by_id(server_id) + .await? + .ok_or(HTTPError::BadRequest("Server not found".into()))?; + } + let id = Uuid::new_v4(); + let mut path = None; + let mut sha = None; + let mut size = None; + if let Some(bytes) = file { + size = Some(bytes.len() as i64); + let detected = detect_mime(&bytes) + .ok_or_else(|| HTTPError::BadRequest("Unsupported or invalid image format".into()))?; + mime = Some(detected); + let (p, h) = + EmojiService::save_asset(std::path::Path::new(&state.config.media.root), id, &bytes) + .await?; + path = Some(p); + sha = Some(h); + } + let model = emoji::ActiveModel { + id: Set(id), + server_id: Set(server_id), + emoji_type: Set(emoji_type), + unicode_sequence: Set(unicode_sequence), + file_path: Set(path), + mime_type: Set(mime), + file_size: Set(size), + is_animated: Set(animated), + sha256: Set(sha), + ..Default::default() + }; + let created = state.services.emoji.create(model, aliases).await?; + let aliases = state.repositories.emoji.aliases(created.id).await?; + Ok(( + StatusCode::CREATED, + Json(mapper::response(created, mapper::aliases(aliases))), + )) +} + +#[utoipa::path(put, path = "/emojis/{id}", request_body = UpdateEmojiRequest, params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] +pub async fn update( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, HTTPError> { + let existing = state + .repositories + .emoji + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + let target_server_id = payload.server_id.or(existing.server_id); + let aliases_to_validate = match &payload.aliases { + Some(aliases) => aliases.clone(), + None => state + .repositories + .emoji + .aliases(id) + .await? + .into_iter() + .map(|a| a.alias) + .collect(), + }; + state + .services + .emoji + .aliases_available(&aliases_to_validate, target_server_id, Some(id)) + .await?; + let mut active: emoji::ActiveModel = existing.into(); + if let Some(server_id) = payload.server_id { + state + .repositories + .server + .get_by_id(server_id) + .await? + .ok_or(HTTPError::BadRequest("Server not found".into()))?; + active.server_id = Set(Some(server_id)); + } + if payload.unicode_sequence.is_some() { + active.unicode_sequence = Set(payload.unicode_sequence); + } + let updated = state.repositories.emoji.update(active).await?; + if let Some(ref aliases) = payload.aliases { + state + .services + .emoji + .aliases_available(aliases, updated.server_id, Some(id)) + .await?; + state + .services + .emoji + .replace_aliases(id, aliases.clone()) + .await?; + } + let aliases = state.repositories.emoji.aliases(id).await?; + Ok(Json(mapper::response(updated, mapper::aliases(aliases)))) +} + +fn detect_mime(bytes: &[u8]) -> Option { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png".into()) + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif".into()) + } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some("image/webp".into()) + } else { + None + } +} + +#[utoipa::path(delete, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 204)), tag = "Emojis")] +pub async fn delete( + State(state): State, + Path(id): Path, +) -> Result { + let model = state + .repositories + .emoji + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + let deleted = state.repositories.emoji.delete(id).await?; + if deleted { + EmojiService::remove_asset( + std::path::Path::new(&state.config.media.root), + model.file_path.as_deref(), + ) + .await; + Ok(StatusCode::NO_CONTENT) + } else { + Err(HTTPError::NotFound) + } +} + +pub async fn asset( + State(state): State, + Path(id): Path, +) -> Result { + let model = state + .repositories + .emoji + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + let path = model.file_path.ok_or(HTTPError::NotFound)?; + let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(path)) + .await + .map_err(|_| HTTPError::NotFound)?; + let mut response = Response::new(Body::from(bytes)); + if let Some(mime) = model.mime_type { + if let Ok(value) = mime.parse() { + response.headers_mut().insert(header::CONTENT_TYPE, value); + } + } + Ok(response) +} diff --git a/src/routes/emoji/mapper.rs b/src/routes/emoji/mapper.rs new file mode 100644 index 0000000..ce2d30c --- /dev/null +++ b/src/routes/emoji/mapper.rs @@ -0,0 +1,25 @@ +use crate::{domain::dto::emoji::EmojiResponse, models::emoji}; + +pub fn response(model: emoji::Model, aliases: Vec) -> EmojiResponse { + EmojiResponse { + id: model.id, + server_id: model.server_id, + emoji_type: model.emoji_type, + unicode_sequence: model.unicode_sequence, + aliases, + asset_url: model + .file_path + .as_ref() + .map(|_| format!("/api/emojis/{}/asset", model.id)), + mime_type: model.mime_type, + file_size: model.file_size, + is_animated: model.is_animated, + sha256: model.sha256, + created_at: model.created_at, + updated_at: model.updated_at, + } +} + +pub fn aliases(models: Vec) -> Vec { + models.into_iter().map(|a| a.alias).collect() +} diff --git a/src/routes/emoji/mod.rs b/src/routes/emoji/mod.rs new file mode 100644 index 0000000..c4754f1 --- /dev/null +++ b/src/routes/emoji/mod.rs @@ -0,0 +1,3 @@ +pub mod handlers; +pub mod mapper; +pub mod routes; diff --git a/src/routes/emoji/routes.rs b/src/routes/emoji/routes.rs new file mode 100644 index 0000000..61c8eed --- /dev/null +++ b/src/routes/emoji/routes.rs @@ -0,0 +1,15 @@ +use super::handlers; +use crate::core::state::AppState; +use axum::{Router, routing::get}; + +pub fn router() -> Router { + Router::new() + .route("/emojis", get(handlers::get_all).post(handlers::create)) + .route( + "/emojis/{id}", + get(handlers::get_by_id) + .put(handlers::update) + .delete(handlers::delete), + ) + .route("/emojis/{id}/asset", get(handlers::asset)) +} diff --git a/src/routes/gateway/mod.rs b/src/routes/gateway/mod.rs index ac91991..501ac2b 100644 --- a/src/routes/gateway/mod.rs +++ b/src/routes/gateway/mod.rs @@ -6,6 +6,7 @@ use crate::routes::category::mapper::category_model_to_category_response; use crate::routes::channel::mapper::channel_model_to_channel_response; use crate::routes::message::mapper::message_model_to_message_response_with_server_id; use crate::routes::server::mapper::server_model_to_server_response; +use crate::services::Services; use axum::extract::ws::Message; use event_bus::EventBus; use events::GatewayEvent; @@ -15,7 +16,6 @@ use std::sync::Arc; use tokio::sync::mpsc; use tokio::task::JoinHandle; use uuid::Uuid; -use crate::services::Services; pub mod events; pub mod handlers; @@ -44,7 +44,10 @@ pub struct GatewayClient { impl GatewayManager { pub fn new(services: Arc) -> Self { - Self { clients: RwLock::new(HashMap::new()), services } + Self { + clients: RwLock::new(HashMap::new()), + services, + } } /// Démarre les routeurs centraux des événements de messages. pub fn start(self: &Arc, event_bus: Arc) { @@ -95,7 +98,6 @@ impl GatewayManager { pub(crate) fn remove_client(&self, gateway_client: &GatewayClient) { let key = gateway_client.key(); self.clients.write().remove(&key); - } fn broadcast_message( @@ -113,7 +115,10 @@ impl GatewayManager { return; }; - let users = self.services.realtime_registry.users_for_channel(channel_id); + let users = self + .services + .realtime_registry + .users_for_channel(channel_id); let clients = self.clients.read(); for (key, client) in clients.iter() { if users.contains(&key.user_id) { diff --git a/src/routes/gateway/routes.rs b/src/routes/gateway/routes.rs index 8f05510..c79ff59 100644 --- a/src/routes/gateway/routes.rs +++ b/src/routes/gateway/routes.rs @@ -1,7 +1,7 @@ use super::handlers; use crate::core::AppState; -use axum::routing::get; use axum::Router; +use axum::routing::get; pub fn router() -> Router { Router::new().route("/gateway", get(handlers::ws_handler)) diff --git a/src/routes/message/routes.rs b/src/routes/message/routes.rs index 3b20153..56cdaef 100644 --- a/src/routes/message/routes.rs +++ b/src/routes/message/routes.rs @@ -1,6 +1,6 @@ use super::handlers; use crate::core::state::AppState; -use axum::{routing::get, Router}; +use axum::{Router, routing::get}; pub fn router() -> Router { Router::new() diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 85e84f4..f4aabdd 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -9,6 +9,7 @@ pub mod auth; pub mod category; pub mod channel; pub mod core; +pub mod emoji; pub mod gateway; pub mod message; pub mod openapi; @@ -25,6 +26,7 @@ pub fn router() -> OxRouter { .merge(role::routes::router()) .merge(message::routes::router()) .merge(user::routes::router()) + .merge(emoji::routes::router()) .layer(axum_middleware::from_fn(middleware::require_auth)); // Routes publiques (ou gérant leur propre auth) diff --git a/src/routes/openapi.rs b/src/routes/openapi.rs index 86c4818..1a1c054 100644 --- a/src/routes/openapi.rs +++ b/src/routes/openapi.rs @@ -42,6 +42,11 @@ use utoipa::{Modify, OpenApi}; message::handlers::update, message::handlers::delete, core::handlers::join, + emoji::handlers::get_all, + emoji::handlers::get_by_id, + emoji::handlers::create, + emoji::handlers::update, + emoji::handlers::delete, ), components( schemas( @@ -70,6 +75,10 @@ use utoipa::{Modify, OpenApi}; crate::domain::dto::message::UpdateMessageRequest, crate::domain::dto::core::JoinRequest, ChannelType, + crate::domain::dto::emoji::EmojiResponse, + crate::domain::dto::emoji::CreateEmojiRequest, + crate::domain::dto::emoji::UpdateEmojiRequest, + crate::domain::dto::emoji::EmojiQueryParams, ) ), modifiers(&SecurityAddon, &ApiPrefixAddon), @@ -82,6 +91,7 @@ use utoipa::{Modify, OpenApi}; (name = "roles", description = "Gestion des rolees"), (name = "Messages", description = "Gestion des messages"), (name = "Core", description = "Endpoints de base (enregistrement, etc.)"), + (name = "Emojis", description = "Gestion des emojis Unicode et personnalisés"), ) )] pub struct ApiDoc; diff --git a/src/routes/role/handlers.rs b/src/routes/role/handlers.rs index e5d6c01..c498154 100644 --- a/src/routes/role/handlers.rs +++ b/src/routes/role/handlers.rs @@ -1,12 +1,18 @@ use crate::core::state::AppState; -use crate::domain::dto::role::{CreateRoleRequest, RoleQueryParams, RoleResponse, UpdateRoleRequest}; +use crate::domain::dto::role::{ + CreateRoleRequest, RoleQueryParams, RoleResponse, UpdateRoleRequest, +}; use crate::domain::dto::user::UserResponse; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; use crate::permissions::ServerPermission; use crate::routes::role::mapper; use crate::routes::user::mapper as user_mapper; -use axum::{Json, extract::{Path, Query, State}, http::StatusCode}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; use uuid::Uuid; async fn require_permission( @@ -44,7 +50,12 @@ pub async fn get_all( None => state.repositories.role.get_all().await?, }; - Ok(Json(roles.into_iter().map(mapper::role_model_to_role_response).collect())) + Ok(Json( + roles + .into_iter() + .map(mapper::role_model_to_role_response) + .collect(), + )) } #[utoipa::path(get, path = "/roles/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles")] @@ -52,7 +63,12 @@ pub async fn get_by_id( State(state): State, Path(id): Path, ) -> Result, HTTPError> { - let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?; + let role = state + .repositories + .role + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; Ok(Json(mapper::role_model_to_role_response(role))) } @@ -62,12 +78,29 @@ pub async fn create( State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json), HTTPError> { - state.repositories.server.get_by_id(payload.server_id).await? + state + .repositories + .server + .get_by_id(payload.server_id) + .await? .ok_or(HTTPError::BadRequest("Server not found".to_string()))?; - require_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_ROLES).await?; + require_permission( + &state, + &user, + payload.server_id, + ServerPermission::MANAGE_ROLES, + ) + .await?; - let role = state.services.role.create_role(mapper::create_request_to_am(payload)).await?; - Ok((StatusCode::CREATED, Json(mapper::role_model_to_role_response(role)))) + let role = state + .services + .role + .create_role(mapper::create_request_to_am(payload)) + .await?; + Ok(( + StatusCode::CREATED, + Json(mapper::role_model_to_role_response(role)), + )) } #[utoipa::path(put, path = "/roles/{id}", request_body = UpdateRoleRequest, responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles", security(("bearerAuth" = [])))] @@ -77,10 +110,29 @@ pub async fn update( Path(id): Path, Json(payload): Json, ) -> Result, HTTPError> { - let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?; - require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_ROLES).await?; + let role = state + .repositories + .role + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + require_permission( + &state, + &user, + role.server_id, + ServerPermission::MANAGE_ROLES, + ) + .await?; - let role = state.services.role.update_role(mapper::update_request_to_am(role.id, role.server_id, payload)).await?; + let role = state + .services + .role + .update_role(mapper::update_request_to_am( + role.id, + role.server_id, + payload, + )) + .await?; Ok(Json(mapper::role_model_to_role_response(role))) } @@ -90,10 +142,25 @@ pub async fn delete( State(state): State, Path(id): Path, ) -> Result { - let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?; - require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_ROLES).await?; + let role = state + .repositories + .role + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + require_permission( + &state, + &user, + role.server_id, + ServerPermission::MANAGE_ROLES, + ) + .await?; - if state.services.role.delete_role(id).await? { Ok(StatusCode::NO_CONTENT) } else { Err(HTTPError::NotFound) } + if state.services.role.delete_role(id).await? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(HTTPError::NotFound) + } } pub async fn get_members( @@ -101,10 +168,26 @@ pub async fn get_members( State(state): State, Path(id): Path, ) -> Result>, HTTPError> { - let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?; - require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?; + let role = state + .repositories + .role + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + require_permission( + &state, + &user, + role.server_id, + ServerPermission::MANAGE_MEMBERS, + ) + .await?; let members = state.repositories.role.get_members(id).await?; - Ok(Json(members.into_iter().map(user_mapper::user_model_to_user_response).collect())) + Ok(Json( + members + .into_iter() + .map(user_mapper::user_model_to_user_response) + .collect(), + )) } pub async fn add_member( @@ -112,11 +195,32 @@ pub async fn add_member( State(state): State, Path((id, user_id)): Path<(Uuid, Uuid)>, ) -> Result { - let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?; - require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?; - state.repositories.server.get_user(role.server_id, user_id).await? - .ok_or(HTTPError::BadRequest("User is not a member of this server".to_string()))?; - state.services.role.add_member(id, user_id, role.server_id).await?; + let role = state + .repositories + .role + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + require_permission( + &state, + &user, + role.server_id, + ServerPermission::MANAGE_MEMBERS, + ) + .await?; + state + .repositories + .server + .get_user(role.server_id, user_id) + .await? + .ok_or(HTTPError::BadRequest( + "User is not a member of this server".to_string(), + ))?; + state + .services + .role + .add_member(id, user_id, role.server_id) + .await?; Ok(StatusCode::NO_CONTENT) } @@ -125,7 +229,27 @@ pub async fn remove_member( State(state): State, Path((id, user_id)): Path<(Uuid, Uuid)>, ) -> Result { - let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?; - require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?; - if state.services.role.remove_member(id, user_id, role.server_id).await? { Ok(StatusCode::NO_CONTENT) } else { Err(HTTPError::NotFound) } + let role = state + .repositories + .role + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + require_permission( + &state, + &user, + role.server_id, + ServerPermission::MANAGE_MEMBERS, + ) + .await?; + if state + .services + .role + .remove_member(id, user_id, role.server_id) + .await? + { + Ok(StatusCode::NO_CONTENT) + } else { + Err(HTTPError::NotFound) + } } diff --git a/src/routes/role/mapper.rs b/src/routes/role/mapper.rs index 1b98efa..1ef0456 100644 --- a/src/routes/role/mapper.rs +++ b/src/routes/role/mapper.rs @@ -1,5 +1,5 @@ -use crate::models::role; use crate::domain::dto::role::{CreateRoleRequest, RoleResponse, UpdateRoleRequest}; +use crate::models::role; use sea_orm::Set; use uuid::Uuid; diff --git a/src/routes/role/routes.rs b/src/routes/role/routes.rs index 308401c..f932031 100644 --- a/src/routes/role/routes.rs +++ b/src/routes/role/routes.rs @@ -1,5 +1,8 @@ use crate::core::state::AppState; -use axum::{routing::{get, put}, Router}; +use axum::{ + Router, + routing::{get, put}, +}; use super::handlers; @@ -13,5 +16,8 @@ pub fn router() -> Router { .delete(handlers::delete), ) .route("/roles/{id}/members", get(handlers::get_members)) - .route("/roles/{id}/members/{user_id}", put(handlers::add_member).delete(handlers::remove_member)) + .route( + "/roles/{id}/members/{user_id}", + put(handlers::add_member).delete(handlers::remove_member), + ) } diff --git a/src/routes/server/handlers.rs b/src/routes/server/handlers.rs index cdd1146..a69aa39 100644 --- a/src/routes/server/handlers.rs +++ b/src/routes/server/handlers.rs @@ -32,7 +32,11 @@ async fn require_server_permission( .map(|value| ServerPermission::from_bits_truncate(value.permissions as u64)) .unwrap_or_default(); - if granted.contains(permission) { Ok(()) } else { Err(HTTPError::Forbidden) } + if granted.contains(permission) { + Ok(()) + } else { + Err(HTTPError::Forbidden) + } } /// Liste tous les serveurs @@ -241,9 +245,23 @@ pub async fn list_user_permissions( State(state): State, Path(server_id): Path, ) -> Result>, HTTPError> { - state.repositories.server.get_by_id(server_id).await?.ok_or(HTTPError::NotFound)?; - let permissions = state.repositories.server.get_user_permissions(server_id).await?; - Ok(Json(permissions.into_iter().map(mapper::server_user_permission_to_response).collect())) + state + .repositories + .server + .get_by_id(server_id) + .await? + .ok_or(HTTPError::NotFound)?; + let permissions = state + .repositories + .server + .get_user_permissions(server_id) + .await?; + Ok(Json( + permissions + .into_iter() + .map(mapper::server_user_permission_to_response) + .collect(), + )) } /// Définit ou remplace les permissions directes d'un utilisateur sur un serveur. @@ -268,8 +286,14 @@ pub async fn set_user_permission( Json(payload): Json, ) -> Result, HTTPError> { require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?; - state.repositories.server.get_user(server_id, user_id).await? - .ok_or(HTTPError::BadRequest("User is not a member of this server".to_string()))?; + state + .repositories + .server + .get_user(server_id, user_id) + .await? + .ok_or(HTTPError::BadRequest( + "User is not a member of this server".to_string(), + ))?; state .repositories .server diff --git a/src/routes/server/mapper.rs b/src/routes/server/mapper.rs index 0d99205..bbccefc 100644 --- a/src/routes/server/mapper.rs +++ b/src/routes/server/mapper.rs @@ -130,7 +130,10 @@ pub fn build_server_tree( .into_iter() .map(|c| { let chan_perm_bits = c.permissions.map(|p| p.bits()).unwrap_or(0); - let mut response = channel_model_to_channel_response_with_permission(c.channel, Some(chan_perm_bits)); + let mut response = channel_model_to_channel_response_with_permission( + c.channel, + Some(chan_perm_bits), + ); response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0)); response }) diff --git a/src/routes/user/mapper.rs b/src/routes/user/mapper.rs index cff0937..140de39 100644 --- a/src/routes/user/mapper.rs +++ b/src/routes/user/mapper.rs @@ -1,6 +1,6 @@ use crate::auth::password::hash_password; -use crate::models::user; use crate::domain::dto::user::{CreateUserRequest, UpdateUserRequest, UserResponse}; +use crate::models::user; use anyhow::Result as AnyResult; use sea_orm::{NotSet, Set}; use uuid::Uuid; diff --git a/src/routes/user/routes.rs b/src/routes/user/routes.rs index 5136212..cc02820 100644 --- a/src/routes/user/routes.rs +++ b/src/routes/user/routes.rs @@ -1,5 +1,5 @@ use crate::core::state::AppState; -use axum::{routing::get, Router}; +use axum::{Router, routing::get}; use super::handlers; diff --git a/src/services/channel.rs b/src/services/channel.rs index 4661a75..1b661ad 100644 --- a/src/services/channel.rs +++ b/src/services/channel.rs @@ -4,9 +4,7 @@ use crate::models::{channel, role}; use crate::permissions::PermissionSet; use crate::services::ServicesContext; use crate::services::permission::PermissionService; -use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait, -}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/services/emoji.rs b/src/services/emoji.rs new file mode 100644 index 0000000..b3fa0ee --- /dev/null +++ b/src/services/emoji.rs @@ -0,0 +1,150 @@ +use crate::http::error::HTTPError; +use crate::models::emoji; +use crate::services::ServicesContext; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; +use sha2::{Digest, Sha256}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::fs; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct EmojiService { + context: Arc, +} + +impl EmojiService { + pub fn new(context: Arc) -> Self { + Self { context } + } + pub fn normalize_alias(alias: &str) -> Result { + let alias = alias.trim().trim_matches(':').to_lowercase(); + if alias.is_empty() + || alias.len() > 64 + || !alias + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '+') + { + return Err(HTTPError::BadRequest("Invalid emoji alias".into())); + } + Ok(alias) + } + pub async fn aliases_available( + &self, + aliases: &[String], + server_id: Option, + except: Option, + ) -> Result<(), HTTPError> { + let normalized = aliases + .iter() + .map(|a| Self::normalize_alias(a)) + .collect::, _>>()?; + let mut unique = std::collections::HashSet::new(); + if normalized.iter().any(|alias| !unique.insert(alias)) { + return Err(HTTPError::BadRequest("Duplicate emoji alias".into())); + } + let scoped = self + .context + .repositories + .emoji + .list_exact_scope(server_id) + .await?; + for model in scoped { + if Some(model.id) == except { + continue; + } + for alias in self.context.repositories.emoji.aliases(model.id).await? { + if normalized.iter().any(|candidate| candidate == &alias.alias) { + return Err(HTTPError::BadRequest( + "Emoji alias already exists in this scope".into(), + )); + } + } + } + Ok(()) + } + pub async fn create( + &self, + model: emoji::ActiveModel, + aliases: Vec, + ) -> Result { + let db = &self.context.repositories.emoji.context.db; + let aliases = aliases + .into_iter() + .map(|a| Self::normalize_alias(&a)) + .collect::, _>>()?; + let server_id = match &model.server_id { + sea_orm::ActiveValue::Set(value) => *value, + _ => None, + }; + self.aliases_available(&aliases, server_id, None).await?; + let result = db + .transaction::<_, emoji::Model, anyhow::Error>(|txn| { + Box::pin(async move { + let model = model.insert(txn).await?; + for alias in aliases { + crate::models::emoji_alias::ActiveModel { + emoji_id: Set(model.id), + alias: Set(alias), + ..Default::default() + } + .insert(txn) + .await?; + } + Ok(model) + }) + }) + .await + .map_err(|e| HTTPError::Internal(anyhow::anyhow!(e)))?; + Ok(result) + } + pub async fn replace_aliases(&self, id: Uuid, aliases: Vec) -> Result<(), HTTPError> { + let aliases = aliases + .into_iter() + .map(|a| Self::normalize_alias(&a)) + .collect::, _>>()?; + let db = &self.context.repositories.emoji.context.db; + let txn = db.begin().await?; + crate::models::emoji_alias::Entity::delete_many() + .filter(crate::models::emoji_alias::Column::EmojiId.eq(id)) + .exec(&txn) + .await?; + for alias in aliases { + crate::models::emoji_alias::ActiveModel { + emoji_id: Set(id), + alias: Set(alias), + ..Default::default() + } + .insert(&txn) + .await?; + } + txn.commit().await?; + Ok(()) + } + pub fn hash(data: &[u8]) -> String { + format!("{:x}", Sha256::digest(data)) + } + pub async fn save_asset( + root: &Path, + id: Uuid, + data: &[u8], + ) -> Result<(String, String), HTTPError> { + let dir = root.join("emoji"); + fs::create_dir_all(&dir) + .await + .map_err(|e| HTTPError::InternalServerError(e.to_string()))?; + let relative = PathBuf::from("emoji").join(id.to_string()); + let path = root.join(&relative); + fs::write(&path, data) + .await + .map_err(|e| HTTPError::InternalServerError(e.to_string()))?; + Ok((relative.to_string_lossy().into_owned(), Self::hash(data))) + } + pub async fn remove_asset(root: &Path, path: Option<&str>) { + if let Some(path) = path { + let _ = fs::remove_file(root.join(path)).await; + } + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 4f21b09..ff49d3a 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,6 +1,7 @@ use crate::repositories::Repositories; use crate::services::category::CategoryService; use crate::services::channel::ChannelService; +use crate::services::emoji::EmojiService; use crate::services::message::MessageService; use crate::services::permission::PermissionService; use crate::services::permission_sync::PermissionSyncService; @@ -13,6 +14,7 @@ use std::sync::{Arc, OnceLock}; pub mod category; pub mod channel; +pub mod emoji; pub mod message; mod permission; pub mod permission_sync; @@ -41,6 +43,7 @@ pub struct Services { pub user: Arc, pub role: Arc, pub permission: Arc, + pub emoji: Arc, } impl Services { @@ -60,6 +63,7 @@ impl Services { let user = Arc::new(UserService::new(service_context.clone())); let role = Arc::new(RoleService::new(service_context.clone())); let permission = Arc::new(PermissionService::new(service_context.clone())); + let emoji = Arc::new(EmojiService::new(service_context.clone())); let services = Self { realtime_registry, @@ -72,6 +76,7 @@ impl Services { user, role, permission, + emoji, }; let _ = service_context.services.set(services.clone()); services diff --git a/src/services/realtime_registry.rs b/src/services/realtime_registry.rs index 0e0d49c..51d4ab6 100644 --- a/src/services/realtime_registry.rs +++ b/src/services/realtime_registry.rs @@ -27,8 +27,14 @@ impl RealtimeRegistry { { continue; } - channel_users.entry(permission.resource_id).or_default().insert(permission.user_id); - user_channels.entry(permission.user_id).or_default().insert(permission.resource_id); + channel_users + .entry(permission.resource_id) + .or_default() + .insert(permission.user_id); + user_channels + .entry(permission.user_id) + .or_default() + .insert(permission.resource_id); } *self.channel_users.write() = channel_users; @@ -37,20 +43,32 @@ impl RealtimeRegistry { } pub fn users_for_channel(&self, channel_id: Uuid) -> HashSet { - self.channel_users.read().get(&channel_id).cloned().unwrap_or_default() + self.channel_users + .read() + .get(&channel_id) + .cloned() + .unwrap_or_default() } pub fn set_user_channels(&self, user_id: Uuid, channels: impl IntoIterator) { let channels: HashSet<_> = channels.into_iter().collect(); - let old = self.user_channels.write().insert(user_id, channels.clone()).unwrap_or_default(); + let old = self + .user_channels + .write() + .insert(user_id, channels.clone()) + .unwrap_or_default(); let mut by_channel = self.channel_users.write(); for channel_id in old.difference(&channels) { if let Some(users) = by_channel.get_mut(channel_id) { users.remove(&user_id); - if users.is_empty() { by_channel.remove(channel_id); } + if users.is_empty() { + by_channel.remove(channel_id); + } } } - for channel_id in channels { by_channel.entry(channel_id).or_default().insert(user_id); } + for channel_id in channels { + by_channel.entry(channel_id).or_default().insert(user_id); + } } pub fn remove_user(&self, user_id: Uuid) { @@ -59,7 +77,9 @@ impl RealtimeRegistry { for channel_id in channels { if let Some(users) = by_channel.get_mut(&channel_id) { users.remove(&user_id); - if users.is_empty() { by_channel.remove(&channel_id); } + if users.is_empty() { + by_channel.remove(&channel_id); + } } } } @@ -71,33 +91,69 @@ impl RealtimeRegistry { for user_id in users { if let Some(channels) = by_user.get_mut(&user_id) { channels.remove(&channel_id); - if channels.is_empty() { by_user.remove(&user_id); } + if channels.is_empty() { + by_user.remove(&user_id); + } } } } } - pub fn start_listening(self: &Arc, repositories: Arc, event_bus: Arc) { + pub fn start_listening( + self: &Arc, + repositories: Arc, + event_bus: Arc, + ) { let registry = Arc::clone(self); - event_bus.on_async_with("channel_user_permission_updated", repositories.clone(), move |repositories, (_channel_id, user_id, _permissions): (Uuid, Uuid, u64)| { - let registry = Arc::clone(®istry); - async move { - match repositories.computed_permission.get_all().await { - Ok(all) => registry.set_user_channels(user_id, all.into_iter().filter(|p| p.user_id == user_id && p.scope_type == PermissionScopeType::Channel && ChannelPermission::from_bits_retain(p.permissions as u64).contains(ChannelPermission::READ_CHANNEL)).map(|p| p.resource_id)), - Err(error) => tracing::error!(%user_id, ?error, "Unable to refresh realtime registry"), + event_bus.on_async_with( + "channel_user_permission_updated", + repositories.clone(), + move |repositories, (_channel_id, user_id, _permissions): (Uuid, Uuid, u64)| { + let registry = Arc::clone(®istry); + async move { + match repositories.computed_permission.get_all().await { + Ok(all) => registry.set_user_channels( + user_id, + all.into_iter() + .filter(|p| { + p.user_id == user_id + && p.scope_type == PermissionScopeType::Channel + && ChannelPermission::from_bits_retain(p.permissions as u64) + .contains(ChannelPermission::READ_CHANNEL) + }) + .map(|p| p.resource_id), + ), + Err(error) => { + tracing::error!(%user_id, ?error, "Unable to refresh realtime registry") + } + } } - } - }); + }, + ); let registry = Arc::clone(self); let repositories = repositories.clone(); - event_bus.on_async_with("server_user_permission_updated", repositories, move |repositories, (_server_id, user_id): (Uuid, Uuid)| { - let registry = Arc::clone(®istry); - async move { - if let Ok(all) = repositories.computed_permission.get_all().await { - registry.set_user_channels(user_id, all.into_iter().filter(|p| p.user_id == user_id && p.scope_type == PermissionScopeType::Channel && ChannelPermission::from_bits_retain(p.permissions as u64).contains(ChannelPermission::READ_CHANNEL)).map(|p| p.resource_id)); + event_bus.on_async_with( + "server_user_permission_updated", + repositories, + move |repositories, (_server_id, user_id): (Uuid, Uuid)| { + let registry = Arc::clone(®istry); + async move { + if let Ok(all) = repositories.computed_permission.get_all().await { + registry.set_user_channels( + user_id, + all.into_iter() + .filter(|p| { + p.user_id == user_id + && p.scope_type == PermissionScopeType::Channel + && ChannelPermission::from_bits_retain(p.permissions as u64) + .contains(ChannelPermission::READ_CHANNEL) + }) + .map(|p| p.resource_id), + ); + } } - } - }); + }, + ); } } diff --git a/src/services/role.rs b/src/services/role.rs index d26b1f5..2b82f12 100644 --- a/src/services/role.rs +++ b/src/services/role.rs @@ -1,5 +1,5 @@ -use crate::services::ServicesContext; use crate::models::role; +use crate::services::ServicesContext; use sea_orm::{ActiveModelTrait, EntityTrait, TransactionTrait}; use std::sync::Arc; use uuid::Uuid; @@ -56,9 +56,7 @@ impl RoleService { let txn = db.begin().await?; - let res = role::Entity::delete_by_id(id) - .exec(&txn) - .await?; + let res = role::Entity::delete_by_id(id).exec(&txn).await?; let deleted = res.rows_affected > 0; @@ -71,8 +69,18 @@ impl RoleService { Ok(deleted) } - pub async fn add_member(&self, role_id: Uuid, user_id: Uuid, server_id: Uuid) -> Result { - let added = self.service_context.repositories.role.add_member(role_id, user_id).await?; + pub async fn add_member( + &self, + role_id: Uuid, + user_id: Uuid, + server_id: Uuid, + ) -> Result { + let added = self + .service_context + .repositories + .role + .add_member(role_id, user_id) + .await?; if added { self.service_context .event_bus @@ -81,8 +89,18 @@ impl RoleService { Ok(added) } - pub async fn remove_member(&self, role_id: Uuid, user_id: Uuid, server_id: Uuid) -> Result { - let removed = self.service_context.repositories.role.remove_member(role_id, user_id).await?; + pub async fn remove_member( + &self, + role_id: Uuid, + user_id: Uuid, + server_id: Uuid, + ) -> Result { + let removed = self + .service_context + .repositories + .role + .remove_member(role_id, user_id) + .await?; if removed { self.service_context .event_bus diff --git a/src/services/server.rs b/src/services/server.rs index b66ed32..6fab1a2 100644 --- a/src/services/server.rs +++ b/src/services/server.rs @@ -1,7 +1,10 @@ +use crate::models::{role, server}; use crate::repositories::Repositories; use crate::services::ServicesContext; -use crate::models::{role, server}; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, QueryOrder, TransactionTrait, Set}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, + TransactionTrait, +}; use std::sync::Arc; use uuid::Uuid; @@ -82,9 +85,7 @@ impl ServerService { let txn = db.begin().await?; - let res = server::Entity::delete_by_id(id) - .exec(&txn) - .await?; + let res = server::Entity::delete_by_id(id).exec(&txn).await?; let deleted = res.rows_affected > 0; diff --git a/src/services/user.rs b/src/services/user.rs index 72eee80..373a6d5 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -1,7 +1,9 @@ -use crate::services::ServicesContext; -use crate::models::{role, user}; use crate::auth::password; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, TransactionTrait, Set}; +use crate::models::{role, user}; +use crate::services::ServicesContext; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set, TransactionTrait, +}; use std::sync::Arc; use uuid::Uuid; @@ -65,10 +67,11 @@ impl UserService { let mut active = user_model.into_active_model(); let password_to_hash = password_str.clone(); - let hashed = tokio::task::spawn_blocking(move || password::hash_password(&password_to_hash)) - .await - .map_err(|e| anyhow::anyhow!("Join error: {}", e))? - .map_err(|e| anyhow::anyhow!("Password hashing failed: {}", e))?; + let hashed = + tokio::task::spawn_blocking(move || password::hash_password(&password_to_hash)) + .await + .map_err(|e| anyhow::anyhow!("Join error: {}", e))? + .map_err(|e| anyhow::anyhow!("Password hashing failed: {}", e))?; active.password = Set(hashed); @@ -87,9 +90,7 @@ impl UserService { let txn = db.begin().await?; - let res = user::Entity::delete_by_id(id) - .exec(&txn) - .await?; + let res = user::Entity::delete_by_id(id).exec(&txn).await?; let deleted = res.rows_affected > 0; diff --git a/src/udp/metrics.rs b/src/udp/metrics.rs index 0fbb18f..b5e6a02 100644 --- a/src/udp/metrics.rs +++ b/src/udp/metrics.rs @@ -1,244 +1,244 @@ -//! Métrologie du serveur UDP. -//! -//! Ce module expose : -//! - [`UdpMetrics`] : compteurs atomiques lock-free (pas de contention dans la -//! boucle de routage). -//! - [`UdpMetricsSnapshot`] : lecture cohérente de tous les compteurs à un -//! instant T, utilisable pour calculer des deltas. -//! - [`UdpRates`] : taux moyens par seconde calculés entre deux snapshots. -//! - [`spawn_reporter`] : tâche tokio de reporting périodique via `tracing`. -//! -//! # Métriques collectées -//! -//! | Compteur | Description | -//! |--------------------|-----------------------------------------------| -//! | `packets_received` | Datagrammes reçus | -//! | `bytes_received` | Octets reçus (payload uniquement) | -//! | `packets_sent` | Datagrammes retransmis vers des abonnés | -//! | `bytes_sent` | Octets retransmis | -//! | `packets_dropped` | Paquets ignorés (canal sans abonnés) | -//! | `send_errors` | Échecs `send_to` | -//! | `recv_errors` | Échecs `recv_from` (avant erreur fatale) | -//! -//! Chaque métrique est également disponible en taux moyen par seconde via -//! [`UdpMetricsSnapshot::rates_since`]. - -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use crate::metrics::{Metrics, MetricsSnapshot}; - -// ── Compteurs ──────────────────────────────────────────────────────────────── - -/// Compteurs atomiques du serveur UDP. -/// -/// Partagé via [`Arc`] entre la boucle de routage et le reporter périodique. -/// Tous les accès utilisent [`Ordering::Relaxed`] : on accepte que les lectures -/// voient des valeurs légèrement décalées entre compteurs (suffisant pour de la -/// métrologie), ce qui évite tout overhead de synchronisation. -#[derive(Debug, Default)] -pub struct UdpMetrics { - /// Nombre total de datagrammes reçus. - pub packets_received: AtomicU64, - /// Volume total d'octets reçus (payload des datagrammes). - pub bytes_received: AtomicU64, - /// Nombre total de datagrammes retransmis (somme sur tous les abonnés). - pub packets_sent: AtomicU64, - /// Volume total d'octets retransmis. - pub bytes_sent: AtomicU64, - /// Paquets ignorés car le canal ne possède aucun abonné. - pub packets_dropped: AtomicU64, - /// Nombre d'erreurs `send_to` (non fatales). - pub send_errors: AtomicU64, - /// Nombre d'erreurs `recv_from` enregistrées avant arrêt du serveur. - pub recv_errors: AtomicU64, -} - -impl UdpMetrics { - /// Crée un jeu de métriques vide enroulé dans un [`Arc`]. - pub fn new() -> Arc { - Arc::new(Self::default()) - } - - /// Enregistre la réception d'un datagramme de `bytes` octets. - #[inline] - pub fn inc_received(&self, bytes: u64) { - self.packets_received.fetch_add(1, Ordering::Relaxed); - self.bytes_received.fetch_add(bytes, Ordering::Relaxed); - } - - /// Enregistre l'émission d'un datagramme de `bytes` octets vers un client. - #[inline] - pub fn inc_sent(&self, bytes: u64) { - self.packets_sent.fetch_add(1, Ordering::Relaxed); - self.bytes_sent.fetch_add(bytes, Ordering::Relaxed); - } - - /// Enregistre un paquet ignoré (canal sans abonnés). - #[inline] - pub fn inc_dropped(&self) { - self.packets_dropped.fetch_add(1, Ordering::Relaxed); - } - - /// Enregistre un échec `send_to` non fatal. - #[inline] - pub fn inc_send_error(&self) { - self.send_errors.fetch_add(1, Ordering::Relaxed); - } - - /// Enregistre un échec `recv_from`. - #[inline] - pub fn inc_recv_error(&self) { - self.recv_errors.fetch_add(1, Ordering::Relaxed); - } - - /// Prend un instantané cohérent de tous les compteurs. - pub fn snapshot(&self) -> UdpMetricsSnapshot { - UdpMetricsSnapshot { - taken_at: Instant::now(), - packets_received: self.packets_received.load(Ordering::Relaxed), - bytes_received: self.bytes_received.load(Ordering::Relaxed), - packets_sent: self.packets_sent.load(Ordering::Relaxed), - bytes_sent: self.bytes_sent.load(Ordering::Relaxed), - packets_dropped: self.packets_dropped.load(Ordering::Relaxed), - send_errors: self.send_errors.load(Ordering::Relaxed), - recv_errors: self.recv_errors.load(Ordering::Relaxed), - } - } -} - -impl Metrics for UdpMetrics { - type Snapshot = UdpMetricsSnapshot; - - fn snapshot(&self) -> UdpMetricsSnapshot { - self.snapshot() - } -} - -// ── Snapshot ───────────────────────────────────────────────────────────────── - -/// Lecture cohérente de l'ensemble des compteurs à un instant T. -/// -/// Permet de calculer des deltas et des taux entre deux points dans le temps -/// sans bloquer la boucle de routage. -#[derive(Debug, Clone, Copy)] -pub struct UdpMetricsSnapshot { - pub taken_at: Instant, - pub packets_received: u64, - pub bytes_received: u64, - pub packets_sent: u64, - pub bytes_sent: u64, - pub packets_dropped: u64, - pub send_errors: u64, - pub recv_errors: u64, -} - -impl UdpMetricsSnapshot { - /// Calcule les taux moyens par seconde depuis un snapshot précédent. - pub fn rates_since(&self, previous: &Self) -> UdpRates { - let secs = self - .taken_at - .duration_since(previous.taken_at) - .as_secs_f64() - .max(f64::EPSILON); - - UdpRates { - packets_received_per_sec: self - .packets_received - .saturating_sub(previous.packets_received) - as f64 - / secs, - bytes_received_per_sec: self.bytes_received.saturating_sub(previous.bytes_received) - as f64 - / secs, - packets_sent_per_sec: self.packets_sent.saturating_sub(previous.packets_sent) as f64 - / secs, - bytes_sent_per_sec: self.bytes_sent.saturating_sub(previous.bytes_sent) as f64 / secs, - packets_dropped_per_sec: self - .packets_dropped - .saturating_sub(previous.packets_dropped) - as f64 - / secs, - } - } -} - -impl MetricsSnapshot for UdpMetricsSnapshot { - fn taken_at(&self) -> Instant { - self.taken_at - } -} - -// ── Taux ───────────────────────────────────────────────────────────────────── - -/// Taux moyens par seconde calculés entre deux [`UdpMetricsSnapshot`]. -#[derive(Debug, Clone, Copy)] -pub struct UdpRates { - /// Paquets reçus par seconde. - pub packets_received_per_sec: f64, - /// Octets reçus par seconde. - pub bytes_received_per_sec: f64, - /// Paquets envoyés par seconde. - pub packets_sent_per_sec: f64, - /// Octets envoyés par seconde. - pub bytes_sent_per_sec: f64, - /// Paquets ignorés par seconde. - pub packets_dropped_per_sec: f64, -} - -// ── Reporter périodique ─────────────────────────────────────────────────────── - -/// Lance une tâche Tokio qui logue les métriques toutes les `interval`. -/// -/// Chaque rapport inclut les compteurs cumulatifs **et** les taux moyens sur -/// la fenêtre écoulée depuis le rapport précédent. -/// -/// # Exemple -/// ```no_run -/// use std::time::Duration; -/// use std::sync::Arc; -/// use oxspeak_server_lib::udp::metrics::{UdpMetrics, spawn_reporter}; -/// -/// #[tokio::main] -/// async fn main() { -/// let metrics = UdpMetrics::new(); -/// spawn_reporter(Arc::clone(&metrics), Duration::from_secs(5)); -/// } -/// ``` -pub fn spawn_reporter(metrics: Arc, interval: Duration) { - tokio::spawn(async move { - let mut ticker = tokio::time::interval(interval); - // Le premier tick est immédiat ; on le consomme pour démarrer à t=0. - ticker.tick().await; - - let mut prev_snapshot = metrics.snapshot(); - - loop { - ticker.tick().await; - - let current = metrics.snapshot(); - let rates = current.rates_since(&prev_snapshot); - - tracing::info!( - // ── Cumulatifs ── - pkts_rx = current.packets_received, - bytes_rx = current.bytes_received, - pkts_tx = current.packets_sent, - bytes_tx = current.bytes_sent, - pkts_dropped = current.packets_dropped, - send_errors = current.send_errors, - recv_errors = current.recv_errors, - // ── Taux / s ── - pkts_rx_s = format!("{:.1}", rates.packets_received_per_sec), - bytes_rx_s = format!("{:.0}", rates.bytes_received_per_sec), - pkts_tx_s = format!("{:.1}", rates.packets_sent_per_sec), - bytes_tx_s = format!("{:.0}", rates.bytes_sent_per_sec), - pkts_dropped_s = format!("{:.1}", rates.packets_dropped_per_sec), - "UDP metrics" - ); - - prev_snapshot = current; - } - }); -} +//! Métrologie du serveur UDP. +//! +//! Ce module expose : +//! - [`UdpMetrics`] : compteurs atomiques lock-free (pas de contention dans la +//! boucle de routage). +//! - [`UdpMetricsSnapshot`] : lecture cohérente de tous les compteurs à un +//! instant T, utilisable pour calculer des deltas. +//! - [`UdpRates`] : taux moyens par seconde calculés entre deux snapshots. +//! - [`spawn_reporter`] : tâche tokio de reporting périodique via `tracing`. +//! +//! # Métriques collectées +//! +//! | Compteur | Description | +//! |--------------------|-----------------------------------------------| +//! | `packets_received` | Datagrammes reçus | +//! | `bytes_received` | Octets reçus (payload uniquement) | +//! | `packets_sent` | Datagrammes retransmis vers des abonnés | +//! | `bytes_sent` | Octets retransmis | +//! | `packets_dropped` | Paquets ignorés (canal sans abonnés) | +//! | `send_errors` | Échecs `send_to` | +//! | `recv_errors` | Échecs `recv_from` (avant erreur fatale) | +//! +//! Chaque métrique est également disponible en taux moyen par seconde via +//! [`UdpMetricsSnapshot::rates_since`]. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use crate::metrics::{Metrics, MetricsSnapshot}; + +// ── Compteurs ──────────────────────────────────────────────────────────────── + +/// Compteurs atomiques du serveur UDP. +/// +/// Partagé via [`Arc`] entre la boucle de routage et le reporter périodique. +/// Tous les accès utilisent [`Ordering::Relaxed`] : on accepte que les lectures +/// voient des valeurs légèrement décalées entre compteurs (suffisant pour de la +/// métrologie), ce qui évite tout overhead de synchronisation. +#[derive(Debug, Default)] +pub struct UdpMetrics { + /// Nombre total de datagrammes reçus. + pub packets_received: AtomicU64, + /// Volume total d'octets reçus (payload des datagrammes). + pub bytes_received: AtomicU64, + /// Nombre total de datagrammes retransmis (somme sur tous les abonnés). + pub packets_sent: AtomicU64, + /// Volume total d'octets retransmis. + pub bytes_sent: AtomicU64, + /// Paquets ignorés car le canal ne possède aucun abonné. + pub packets_dropped: AtomicU64, + /// Nombre d'erreurs `send_to` (non fatales). + pub send_errors: AtomicU64, + /// Nombre d'erreurs `recv_from` enregistrées avant arrêt du serveur. + pub recv_errors: AtomicU64, +} + +impl UdpMetrics { + /// Crée un jeu de métriques vide enroulé dans un [`Arc`]. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Enregistre la réception d'un datagramme de `bytes` octets. + #[inline] + pub fn inc_received(&self, bytes: u64) { + self.packets_received.fetch_add(1, Ordering::Relaxed); + self.bytes_received.fetch_add(bytes, Ordering::Relaxed); + } + + /// Enregistre l'émission d'un datagramme de `bytes` octets vers un client. + #[inline] + pub fn inc_sent(&self, bytes: u64) { + self.packets_sent.fetch_add(1, Ordering::Relaxed); + self.bytes_sent.fetch_add(bytes, Ordering::Relaxed); + } + + /// Enregistre un paquet ignoré (canal sans abonnés). + #[inline] + pub fn inc_dropped(&self) { + self.packets_dropped.fetch_add(1, Ordering::Relaxed); + } + + /// Enregistre un échec `send_to` non fatal. + #[inline] + pub fn inc_send_error(&self) { + self.send_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Enregistre un échec `recv_from`. + #[inline] + pub fn inc_recv_error(&self) { + self.recv_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Prend un instantané cohérent de tous les compteurs. + pub fn snapshot(&self) -> UdpMetricsSnapshot { + UdpMetricsSnapshot { + taken_at: Instant::now(), + packets_received: self.packets_received.load(Ordering::Relaxed), + bytes_received: self.bytes_received.load(Ordering::Relaxed), + packets_sent: self.packets_sent.load(Ordering::Relaxed), + bytes_sent: self.bytes_sent.load(Ordering::Relaxed), + packets_dropped: self.packets_dropped.load(Ordering::Relaxed), + send_errors: self.send_errors.load(Ordering::Relaxed), + recv_errors: self.recv_errors.load(Ordering::Relaxed), + } + } +} + +impl Metrics for UdpMetrics { + type Snapshot = UdpMetricsSnapshot; + + fn snapshot(&self) -> UdpMetricsSnapshot { + self.snapshot() + } +} + +// ── Snapshot ───────────────────────────────────────────────────────────────── + +/// Lecture cohérente de l'ensemble des compteurs à un instant T. +/// +/// Permet de calculer des deltas et des taux entre deux points dans le temps +/// sans bloquer la boucle de routage. +#[derive(Debug, Clone, Copy)] +pub struct UdpMetricsSnapshot { + pub taken_at: Instant, + pub packets_received: u64, + pub bytes_received: u64, + pub packets_sent: u64, + pub bytes_sent: u64, + pub packets_dropped: u64, + pub send_errors: u64, + pub recv_errors: u64, +} + +impl UdpMetricsSnapshot { + /// Calcule les taux moyens par seconde depuis un snapshot précédent. + pub fn rates_since(&self, previous: &Self) -> UdpRates { + let secs = self + .taken_at + .duration_since(previous.taken_at) + .as_secs_f64() + .max(f64::EPSILON); + + UdpRates { + packets_received_per_sec: self + .packets_received + .saturating_sub(previous.packets_received) + as f64 + / secs, + bytes_received_per_sec: self.bytes_received.saturating_sub(previous.bytes_received) + as f64 + / secs, + packets_sent_per_sec: self.packets_sent.saturating_sub(previous.packets_sent) as f64 + / secs, + bytes_sent_per_sec: self.bytes_sent.saturating_sub(previous.bytes_sent) as f64 / secs, + packets_dropped_per_sec: self + .packets_dropped + .saturating_sub(previous.packets_dropped) + as f64 + / secs, + } + } +} + +impl MetricsSnapshot for UdpMetricsSnapshot { + fn taken_at(&self) -> Instant { + self.taken_at + } +} + +// ── Taux ───────────────────────────────────────────────────────────────────── + +/// Taux moyens par seconde calculés entre deux [`UdpMetricsSnapshot`]. +#[derive(Debug, Clone, Copy)] +pub struct UdpRates { + /// Paquets reçus par seconde. + pub packets_received_per_sec: f64, + /// Octets reçus par seconde. + pub bytes_received_per_sec: f64, + /// Paquets envoyés par seconde. + pub packets_sent_per_sec: f64, + /// Octets envoyés par seconde. + pub bytes_sent_per_sec: f64, + /// Paquets ignorés par seconde. + pub packets_dropped_per_sec: f64, +} + +// ── Reporter périodique ─────────────────────────────────────────────────────── + +/// Lance une tâche Tokio qui logue les métriques toutes les `interval`. +/// +/// Chaque rapport inclut les compteurs cumulatifs **et** les taux moyens sur +/// la fenêtre écoulée depuis le rapport précédent. +/// +/// # Exemple +/// ```no_run +/// use std::time::Duration; +/// use std::sync::Arc; +/// use oxspeak_server_lib::udp::metrics::{UdpMetrics, spawn_reporter}; +/// +/// #[tokio::main] +/// async fn main() { +/// let metrics = UdpMetrics::new(); +/// spawn_reporter(Arc::clone(&metrics), Duration::from_secs(5)); +/// } +/// ``` +pub fn spawn_reporter(metrics: Arc, interval: Duration) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // Le premier tick est immédiat ; on le consomme pour démarrer à t=0. + ticker.tick().await; + + let mut prev_snapshot = metrics.snapshot(); + + loop { + ticker.tick().await; + + let current = metrics.snapshot(); + let rates = current.rates_since(&prev_snapshot); + + tracing::info!( + // ── Cumulatifs ── + pkts_rx = current.packets_received, + bytes_rx = current.bytes_received, + pkts_tx = current.packets_sent, + bytes_tx = current.bytes_sent, + pkts_dropped = current.packets_dropped, + send_errors = current.send_errors, + recv_errors = current.recv_errors, + // ── Taux / s ── + pkts_rx_s = format!("{:.1}", rates.packets_received_per_sec), + bytes_rx_s = format!("{:.0}", rates.bytes_received_per_sec), + pkts_tx_s = format!("{:.1}", rates.packets_sent_per_sec), + bytes_tx_s = format!("{:.0}", rates.bytes_sent_per_sec), + pkts_dropped_s = format!("{:.1}", rates.packets_dropped_per_sec), + "UDP metrics" + ); + + prev_snapshot = current; + } + }); +}