Init
This commit is contained in:
32
README.md
32
README.md
@@ -1,3 +1,35 @@
|
||||
## Architecture & Philosophie
|
||||
|
||||
Ce projet s'inspire de la philosophie de Rust : **"Rendre les états illégaux impossibles à représenter"**.
|
||||
|
||||
Si vous venez de **Django**, voici les principales différences à garder en tête :
|
||||
|
||||
### 1. Extracteurs vs Objet Request
|
||||
|
||||
Contrairement à Django qui passe un objet `request` "fourre-tout" à chaque vue, ce projet utilise des **Extracteurs Axum
|
||||
** (dans la signature des fonctions).
|
||||
|
||||
- **Le "Contrat" par la signature :** Si une vue demande `user: CurrentUser`, Axum garantit que l'utilisateur est
|
||||
authentifié. Si ce n'est pas le cas, la vue n'est pas appelée et une erreur 401 est retournée automatiquement.
|
||||
- **Auto-documentation :** En regardant simplement la signature d'une fonction, on sait exactement ce dont elle a
|
||||
besoin (Base de données, Utilisateur, JSON, etc.).
|
||||
|
||||
### 2. Le Flux d'Authentification
|
||||
|
||||
1. **Middleware (`context_middleware`) :** Intercepte la requête, vérifie le JWT, récupère l'utilisateur complet en base
|
||||
de données, et injecte le tout dans `RequestContext`.
|
||||
2. **Extensions :** Le `RequestContext` est stocké dans les "extensions" de la requête (un espace de stockage de type
|
||||
Map).
|
||||
3. **Extracteurs :** L'extracteur `CurrentUser` récupère les données depuis les extensions et les rend disponibles dans
|
||||
la signature de votre vue.
|
||||
|
||||
### 3. Transparence (Deref)
|
||||
|
||||
L'objet `CurrentUser` enveloppe le modèle de base de données. Grâce à l'implémentation de `Deref`, vous pouvez accéder
|
||||
aux champs de l'utilisateur (ex: `user.is_superuser`) directement comme s'il s'agissait du modèle lui-même.
|
||||
|
||||
---
|
||||
|
||||
## TODO
|
||||
|
||||
Terminer le système d'authentification (login, changement de mot de passe...)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
@@ -150,7 +150,7 @@ impl MigrationTrait for Migration {
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("pub_key"))
|
||||
.text()
|
||||
.null()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(
|
||||
@@ -165,6 +165,12 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("is_superuser"))
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
@@ -284,7 +290,14 @@ impl MigrationTrait for Migration {
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("is_admin"))
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
// Indexes créés après
|
||||
.foreign_key(
|
||||
@@ -325,6 +338,12 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default("member".to_owned()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("permissions"))
|
||||
.big_integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("joined_at"))
|
||||
.timestamp_with_time_zone()
|
||||
@@ -501,10 +520,108 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create table `group`
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("group"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new("id").uuid().primary_key().not_null())
|
||||
.col(ColumnDef::new("server_id").uuid().not_null())
|
||||
.col(ColumnDef::new("name").string().not_null())
|
||||
.col(
|
||||
ColumnDef::new("permissions")
|
||||
.big_integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new("is_default")
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new("created_at")
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_group_server")
|
||||
.from(Alias::new("group"), Alias::new("server_id"))
|
||||
.to(Alias::new("server"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create table `group_member`
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("group_member"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new("group_id").uuid().not_null())
|
||||
.col(ColumnDef::new("user_id").uuid().not_null())
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.col(Alias::new("group_id"))
|
||||
.col(Alias::new("user_id")),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_group_member_group")
|
||||
.from(Alias::new("group_member"), Alias::new("group_id"))
|
||||
.to(Alias::new("group"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_group_member_user")
|
||||
.from(Alias::new("group_member"), Alias::new("user_id"))
|
||||
.to(Alias::new("user"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Index: idx_group_server_id
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_group_server_id")
|
||||
.table(Alias::new("group"))
|
||||
.col(Alias::new("server_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Index: idx_group_member_user_id
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_group_member_user_id")
|
||||
.table(Alias::new("group_member"))
|
||||
.col(Alias::new("user_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("group_member")).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("group")).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("channel_user")).to_owned())
|
||||
.await?;
|
||||
|
||||
@@ -6,6 +6,9 @@ use crate::hub::Clients;
|
||||
use crate::network::http::HTTPServer;
|
||||
use crate::network::udp::UDPServer;
|
||||
use crate::repositories::Repositories;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct App {
|
||||
config: Config,
|
||||
@@ -28,12 +31,33 @@ impl App {
|
||||
.expect("Failed to initialize database");
|
||||
let repositories = Repositories::new(db.get_connection(), event_bus.clone());
|
||||
|
||||
let init_token = if repositories
|
||||
.user
|
||||
.count()
|
||||
.await
|
||||
.expect("Failed to count users")
|
||||
== 0
|
||||
{
|
||||
let token = Uuid::new_v4().to_string();
|
||||
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: db.clone(),
|
||||
event_bus: event_bus.clone(),
|
||||
repositories: repositories.clone(),
|
||||
clients: Clients::new(),
|
||||
config: config.clone(),
|
||||
init_token: Arc::new(Mutex::new(init_token)),
|
||||
};
|
||||
|
||||
let udp_server = UDPServer::new(config.bind_addr());
|
||||
|
||||
@@ -4,6 +4,9 @@ use crate::event_bus::EventBus;
|
||||
use crate::hub::Clients;
|
||||
use crate::repositories::Repositories;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
@@ -11,6 +14,7 @@ pub struct AppState {
|
||||
pub repositories: Repositories,
|
||||
pub clients: Clients,
|
||||
pub config: Config,
|
||||
pub init_token: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
||||
52
src/interfaces/http/dto/group.rs
Normal file
52
src/interfaces/http/dto/group.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use crate::models::group;
|
||||
use sea_orm::Set;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct GroupResponse {
|
||||
pub id: Uuid,
|
||||
pub server_id: Uuid,
|
||||
pub name: String,
|
||||
pub permissions: i64,
|
||||
pub is_default: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl From<group::Model> for GroupResponse {
|
||||
fn from(model: group::Model) -> Self {
|
||||
Self {
|
||||
id: model.id,
|
||||
server_id: model.server_id,
|
||||
name: model.name,
|
||||
permissions: model.permissions,
|
||||
is_default: model.is_default,
|
||||
created_at: model.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub server_id: Uuid,
|
||||
pub name: String,
|
||||
pub permissions: i64,
|
||||
}
|
||||
|
||||
impl CreateGroupRequest {
|
||||
pub fn into_active_model(self) -> group::ActiveModel {
|
||||
group::ActiveModel {
|
||||
server_id: Set(self.server_id),
|
||||
name: Set(self.name),
|
||||
permissions: Set(self.permissions),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_to(self, mut am: group::ActiveModel) -> group::ActiveModel {
|
||||
am.name = Set(self.name);
|
||||
am.permissions = Set(self.permissions);
|
||||
am
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod category;
|
||||
pub mod channel;
|
||||
pub mod group;
|
||||
pub mod message;
|
||||
pub mod server;
|
||||
pub mod user;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct Model {
|
||||
pub channel_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub role: String,
|
||||
pub permissions: i64,
|
||||
pub joined_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
|
||||
53
src/models/group.rs
Normal file
53
src/models/group.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! `SeaORM` Entity.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::prelude::async_trait::async_trait;
|
||||
use sea_orm::Set;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "group")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub server_id: Uuid,
|
||||
pub name: String,
|
||||
pub permissions: i64,
|
||||
pub is_default: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::server::Entity",
|
||||
from = "Column::ServerId",
|
||||
to = "super::server::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Server,
|
||||
#[sea_orm(has_many = "super::group_member::Entity")]
|
||||
GroupMember,
|
||||
}
|
||||
|
||||
impl Related<super::server::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Server.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::group_member::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::GroupMember.def()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
id: Set(Uuid::new_v4()),
|
||||
..ActiveModelTrait::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/models/group_member.rs
Normal file
46
src/models/group_member.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! `SeaORM` Entity.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "group_member")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub group_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::group::Entity",
|
||||
from = "Column::GroupId",
|
||||
to = "super::group::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Group,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::group::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Group.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -6,6 +6,8 @@ pub mod attachment;
|
||||
pub mod category;
|
||||
pub mod channel;
|
||||
pub mod channel_user;
|
||||
pub mod group;
|
||||
pub mod group_member;
|
||||
pub mod message;
|
||||
pub mod server;
|
||||
pub mod server_user;
|
||||
|
||||
@@ -4,6 +4,8 @@ pub use super::attachment::Entity as Attachment;
|
||||
pub use super::category::Entity as Category;
|
||||
pub use super::channel::Entity as Channel;
|
||||
pub use super::channel_user::Entity as ChannelUser;
|
||||
pub use super::group::Entity as Group;
|
||||
pub use super::group_member::Entity as GroupMember;
|
||||
pub use super::message::Entity as Message;
|
||||
pub use super::server::Entity as Server;
|
||||
pub use super::server_user::Entity as ServerUser;
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct Model {
|
||||
pub username: Option<String>,
|
||||
pub joined_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct Model {
|
||||
pub pub_key: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub is_superuser: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
use crate::models::user;
|
||||
use crate::network::http::HTTPError;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use std::ops::Deref;
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestContext {
|
||||
pub request_id: Uuid,
|
||||
@@ -13,8 +15,54 @@ pub struct RequestContext {
|
||||
pub user: Option<CurrentUser>,
|
||||
}
|
||||
|
||||
/// 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 id: Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
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<S> FromRequestParts<S> 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<Self, Self::Rejection> {
|
||||
// On récupère le contexte injecté par le middleware
|
||||
let context = parts
|
||||
.extensions
|
||||
.get::<RequestContext>()
|
||||
.ok_or(HTTPError::Unauthorized)?;
|
||||
|
||||
// On retourne l'utilisateur cloné s'il existe, sinon on rejette avec Unauthorized
|
||||
context.user.clone().ok_or(HTTPError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub enum HTTPError {
|
||||
BadRequest(String),
|
||||
InternalServerError(String),
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
// Conversion automatique depuis DbErr (erreurs SeaORM)
|
||||
@@ -37,6 +38,7 @@ impl IntoResponse for HTTPError {
|
||||
}
|
||||
HTTPError::NotFound => (StatusCode::NOT_FOUND, "Resource not found"),
|
||||
HTTPError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
HTTPError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"),
|
||||
HTTPError::BadRequest(msg) => {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response();
|
||||
}
|
||||
|
||||
@@ -20,27 +20,34 @@ pub async fn context_middleware(
|
||||
let uri = req.uri().clone();
|
||||
|
||||
// Authentification par JWT
|
||||
let user: Option<CurrentUser> = {
|
||||
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())
|
||||
.map(|claims| CurrentUser {
|
||||
id: claims.user_id,
|
||||
username: claims.username,
|
||||
})
|
||||
let user: Option<CurrentUser> = 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
|
||||
// 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,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use crate::app::AppState;
|
||||
use crate::models::user;
|
||||
use crate::network::http::{AppRouter, HTTPError};
|
||||
use crate::utils::auth::create_jwt;
|
||||
use crate::utils::password::hash_password;
|
||||
use crate::utils::toolbox::ssh_generate_challenge;
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use axum::Json;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -12,6 +15,14 @@ pub fn setup_route() -> AppRouter {
|
||||
AppRouter::new()
|
||||
.route("/login", post(login))
|
||||
.route("/ssh-challenge", post(ssh_challenge))
|
||||
.route("/claim-admin", post(claim_admin))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ClaimAdminRequest {
|
||||
token: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -92,3 +103,82 @@ pub async fn ssh_challenge(
|
||||
|
||||
Ok(Json(SshChallengeResponse { challenge }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/claim-admin",
|
||||
responses(
|
||||
(status = 200, description = "Admin created successfully"),
|
||||
(status = 400, description = "Bad request (token mismatch or user already exists)"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn claim_admin(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ClaimAdminRequest>,
|
||||
) -> Result<Json<LoginResponse>, HTTPError> {
|
||||
let mut init_token = state.init_token.lock().await;
|
||||
|
||||
// Check if a token exists and matches
|
||||
let token_valid = match &*init_token {
|
||||
Some(t) => t == &payload.token,
|
||||
None => false,
|
||||
};
|
||||
|
||||
if !token_valid {
|
||||
return Err(HTTPError::BadRequest(
|
||||
"Invalid or expired initialization token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Double check if any user exists in database
|
||||
let user_count = state
|
||||
.repositories
|
||||
.user
|
||||
.count()
|
||||
.await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
|
||||
|
||||
if user_count > 0 {
|
||||
*init_token = None; // Invalidate token if users already exist
|
||||
return Err(HTTPError::BadRequest(
|
||||
"Users already exist in database".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create the admin user
|
||||
let hashed_password = hash_password(&payload.password)
|
||||
.map_err(|_| HTTPError::InternalServerError("Failed to hash password".to_string()))?;
|
||||
|
||||
let new_user = user::ActiveModel {
|
||||
username: Set(payload.username),
|
||||
password: Set(hashed_password),
|
||||
is_superuser: Set(true),
|
||||
pub_key: Set("".to_string()), // Default empty pub_key as it's required in model
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let user = state
|
||||
.repositories
|
||||
.user
|
||||
.create(new_user)
|
||||
.await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
|
||||
|
||||
// Invalidate the token
|
||||
*init_token = None;
|
||||
|
||||
// Generate JWT for the new admin
|
||||
let token = create_jwt(
|
||||
user.id,
|
||||
&user.username,
|
||||
&state.config.jwt.secret,
|
||||
state.config.jwt.expiration,
|
||||
)
|
||||
.map_err(|e| HTTPError::InternalServerError("Failed to generate token".to_string()))?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
username: user.username,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::app::AppState;
|
||||
use crate::interfaces::http::dto::category::{CategoryResponse, CreateCategoryRequest};
|
||||
use crate::models::category;
|
||||
use crate::network::http::RequestContext;
|
||||
use crate::network::http::context::CurrentUser;
|
||||
use crate::network::http::{AppRouter, HTTPError};
|
||||
use crate::utils::permissions::Permission;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Extension, Json};
|
||||
use axum::routing::get;
|
||||
use axum::Json;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel};
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
@@ -40,7 +41,6 @@ pub struct CategoryQuery {
|
||||
)]
|
||||
pub async fn category_list(
|
||||
State(app_state): State<AppState>,
|
||||
Extension(_ctx): Extension<RequestContext>,
|
||||
Query(query): Query<CategoryQuery>,
|
||||
) -> Result<Json<Vec<CategoryResponse>>, HTTPError> {
|
||||
let categories = if let Some(server_id) = query.server_id {
|
||||
@@ -88,14 +88,38 @@ pub async fn category_detail(
|
||||
path = "/api/category",
|
||||
request_body = CreateCategoryRequest,
|
||||
responses(
|
||||
(status = 200, description = "Category created", body = CategoryResponse)
|
||||
(status = 200, description = "Category created", body = CategoryResponse),
|
||||
(status = 403, description = "Forbidden")
|
||||
)
|
||||
)]
|
||||
pub async fn category_create(
|
||||
State(app_state): State<AppState>,
|
||||
Json(serializer): Json<CreateCategoryRequest>,
|
||||
user: CurrentUser,
|
||||
Json(payload): Json<CreateCategoryRequest>,
|
||||
) -> Result<Json<CategoryResponse>, HTTPError> {
|
||||
let active: category::ActiveModel = serializer.into();
|
||||
let is_admin = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, payload.server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, payload.server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageServer, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
let active: category::ActiveModel = payload.into();
|
||||
let category: category::Model = active.insert(app_state.db.get_connection()).await?;
|
||||
|
||||
Ok(Json(CategoryResponse::from(category)))
|
||||
@@ -107,6 +131,7 @@ pub async fn category_create(
|
||||
request_body = CreateCategoryRequest,
|
||||
responses(
|
||||
(status = 200, description = "Category updated", body = CategoryResponse),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Category not found")
|
||||
),
|
||||
params(
|
||||
@@ -115,18 +140,40 @@ pub async fn category_create(
|
||||
)]
|
||||
pub async fn category_update(
|
||||
State(app_state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(serializer): Json<CreateCategoryRequest>,
|
||||
Json(payload): Json<CreateCategoryRequest>,
|
||||
) -> Result<Json<CategoryResponse>, HTTPError> {
|
||||
let category = category::Entity::find_by_id(id)
|
||||
.one(app_state.db.get_connection())
|
||||
.await?
|
||||
.ok_or_else(|| HTTPError::NotFound)?;
|
||||
|
||||
let is_admin = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, category.server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, category.server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageServer, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
let active = category.into_active_model();
|
||||
|
||||
// todo : voir pour virer le into_active_model pour utiliser le dto
|
||||
let category: category::Model = serializer
|
||||
let category: category::Model = payload
|
||||
.apply_to(active)
|
||||
.update(app_state.db.get_connection())
|
||||
.await?;
|
||||
@@ -139,6 +186,7 @@ pub async fn category_update(
|
||||
path = "/api/category/{id}",
|
||||
responses(
|
||||
(status = 204, description = "Category deleted"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Category not found")
|
||||
),
|
||||
params(
|
||||
@@ -147,8 +195,36 @@ pub async fn category_update(
|
||||
)]
|
||||
pub async fn category_delete(
|
||||
State(app_state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
let category = category::Entity::find_by_id(id)
|
||||
.one(app_state.db.get_connection())
|
||||
.await?
|
||||
.ok_or_else(|| HTTPError::NotFound)?;
|
||||
|
||||
let is_admin = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, category.server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, category.server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageServer, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
let result = category::Entity::delete_by_id(id)
|
||||
.exec(app_state.db.get_connection())
|
||||
.await?;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::app::AppState;
|
||||
use crate::interfaces::http::dto::channel::{ChannelResponse, CreateChannelRequest};
|
||||
use crate::models::channel;
|
||||
use crate::network::http::context::CurrentUser;
|
||||
use crate::network::http::{AppRouter, HTTPError};
|
||||
use crate::utils::permissions::Permission;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::routing::get;
|
||||
use axum::Json;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel};
|
||||
use uuid::Uuid;
|
||||
@@ -67,13 +69,39 @@ pub async fn channel_detail(
|
||||
path = "/api/channel",
|
||||
request_body = CreateChannelRequest,
|
||||
responses(
|
||||
(status = 200, description = "Channel created", body = ChannelResponse)
|
||||
(status = 200, description = "Channel created", body = ChannelResponse),
|
||||
(status = 403, description = "Forbidden")
|
||||
)
|
||||
)]
|
||||
pub async fn channel_create(
|
||||
State(app_state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Json(dto): Json<CreateChannelRequest>,
|
||||
) -> Result<Json<ChannelResponse>, HTTPError> {
|
||||
if let Some(server_id) = dto.server_id {
|
||||
let is_admin = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageServer, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
let active: channel::ActiveModel = dto.into();
|
||||
let channel: channel::Model = active.insert(app_state.db.get_connection()).await?;
|
||||
|
||||
@@ -86,6 +114,7 @@ pub async fn channel_create(
|
||||
request_body = CreateChannelRequest,
|
||||
responses(
|
||||
(status = 200, description = "Channel updated", body = ChannelResponse),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Channel not found")
|
||||
),
|
||||
params(
|
||||
@@ -94,6 +123,7 @@ pub async fn channel_create(
|
||||
)]
|
||||
pub async fn channel_update(
|
||||
State(app_state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(dto): Json<CreateChannelRequest>,
|
||||
) -> Result<Json<ChannelResponse>, HTTPError> {
|
||||
@@ -102,6 +132,30 @@ pub async fn channel_update(
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if let Some(server_id) = channel.server_id {
|
||||
let is_admin = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageChannel, Some(id)) || ctx.has(Permission::ManageServer, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
let active = channel.into_active_model();
|
||||
|
||||
let channel: channel::Model = dto
|
||||
@@ -117,6 +171,7 @@ pub async fn channel_update(
|
||||
path = "/api/channel/{id}",
|
||||
responses(
|
||||
(status = 204, description = "Channel deleted"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Channel not found")
|
||||
),
|
||||
params(
|
||||
@@ -125,8 +180,38 @@ pub async fn channel_update(
|
||||
)]
|
||||
pub async fn channel_delete(
|
||||
State(app_state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
let channel = channel::Entity::find_by_id(id)
|
||||
.one(app_state.db.get_connection())
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if let Some(server_id) = channel.server_id {
|
||||
let is_admin = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageChannel, Some(id)) || ctx.has(Permission::ManageServer, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
let result = channel::Entity::delete_by_id(id)
|
||||
.exec(app_state.db.get_connection())
|
||||
.await?;
|
||||
|
||||
227
src/network/http/web/api/group.rs
Normal file
227
src/network/http/web/api/group.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use crate::app::AppState;
|
||||
use crate::interfaces::http::dto::group::{CreateGroupRequest, GroupResponse};
|
||||
use crate::network::http::context::CurrentUser;
|
||||
use crate::network::http::{AppRouter, HTTPError};
|
||||
use crate::utils::permissions::Permission;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::get;
|
||||
use axum::Json;
|
||||
use sea_orm::IntoActiveModel;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn setup_route() -> AppRouter {
|
||||
AppRouter::new()
|
||||
.route("/", get(group_list).post(group_create))
|
||||
.route(
|
||||
"/{id}",
|
||||
get(group_detail).put(group_update).delete(group_delete),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GroupListQuery {
|
||||
pub server_id: Uuid,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/group",
|
||||
responses(
|
||||
(status = 200, description = "List of groups", body = [GroupResponse])
|
||||
),
|
||||
params(
|
||||
("server_id" = Uuid, Query, description = "Server ID to list groups for")
|
||||
)
|
||||
)]
|
||||
pub async fn group_list(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<GroupListQuery>,
|
||||
) -> Result<Json<Vec<GroupResponse>>, HTTPError> {
|
||||
let groups = state
|
||||
.repositories
|
||||
.group
|
||||
.get_all_by_server(query.server_id)
|
||||
.await?;
|
||||
|
||||
Ok(Json(groups.into_iter().map(GroupResponse::from).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/group/{id}",
|
||||
responses(
|
||||
(status = 200, description = "Group details", body = GroupResponse),
|
||||
(status = 404, description = "Group not found")
|
||||
),
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Group ID")
|
||||
)
|
||||
)]
|
||||
pub async fn group_detail(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<GroupResponse>, HTTPError> {
|
||||
let group = state
|
||||
.repositories
|
||||
.group
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
Ok(Json(GroupResponse::from(group)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/group",
|
||||
request_body = CreateGroupRequest,
|
||||
responses(
|
||||
(status = 200, description = "Group created", body = GroupResponse),
|
||||
(status = 403, description = "Forbidden")
|
||||
)
|
||||
)]
|
||||
pub async fn group_create(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Json(payload): Json<CreateGroupRequest>,
|
||||
) -> Result<Json<GroupResponse>, HTTPError> {
|
||||
let is_admin = state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, payload.server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, payload.server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageRoles, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
let active = payload.into_active_model();
|
||||
let group = state.repositories.group.create(active).await?;
|
||||
|
||||
Ok(Json(GroupResponse::from(group)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/group/{id}",
|
||||
request_body = CreateGroupRequest,
|
||||
responses(
|
||||
(status = 200, description = "Group updated", body = GroupResponse),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Group not found")
|
||||
),
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Group ID")
|
||||
)
|
||||
)]
|
||||
pub async fn group_update(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<CreateGroupRequest>,
|
||||
) -> Result<Json<GroupResponse>, HTTPError> {
|
||||
let group = state
|
||||
.repositories
|
||||
.group
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
let is_admin = state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, group.server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, group.server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageRoles, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
let active = group.into_active_model();
|
||||
let group = state
|
||||
.repositories
|
||||
.group
|
||||
.update(payload.apply_to(active))
|
||||
.await?;
|
||||
|
||||
Ok(Json(GroupResponse::from(group)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/group/{id}",
|
||||
responses(
|
||||
(status = 204, description = "Group deleted"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Group not found")
|
||||
),
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Group ID")
|
||||
)
|
||||
)]
|
||||
pub async fn group_delete(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
let group = state
|
||||
.repositories
|
||||
.group
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
let is_admin = state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, group.server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
let has_permission = if is_admin || is_superuser {
|
||||
true
|
||||
} else {
|
||||
let ctx = state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, group.server_id)
|
||||
.await?;
|
||||
ctx.has(Permission::ManageRoles, None)
|
||||
};
|
||||
|
||||
if !has_permission {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
if state.repositories.group.delete(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(HTTPError::NotFound)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
use crate::app::AppState;
|
||||
use crate::interfaces::http::dto::message::{CreateMessageRequest, MessageResponse};
|
||||
use crate::models::message;
|
||||
use crate::network::http::{AppRouter, HTTPError, RequestContext};
|
||||
use crate::network::http::context::CurrentUser;
|
||||
use crate::network::http::{AppRouter, HTTPError};
|
||||
use crate::utils::permissions::Permission;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Extension, Json};
|
||||
use axum::routing::get;
|
||||
use axum::Json;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -67,7 +69,9 @@ pub async fn message_detail(
|
||||
path = "/api/message",
|
||||
request_body = CreateMessageRequest,
|
||||
responses(
|
||||
(status = 200, description = "Message created", body = MessageResponse)
|
||||
(status = 200, description = "Message created", body = MessageResponse),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Channel not found")
|
||||
),
|
||||
security(
|
||||
("jwt" = [])
|
||||
@@ -75,11 +79,35 @@ pub async fn message_detail(
|
||||
)]
|
||||
pub async fn message_create(
|
||||
State(app_state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
user: CurrentUser,
|
||||
Json(dto): Json<CreateMessageRequest>,
|
||||
) -> Result<Json<MessageResponse>, HTTPError> {
|
||||
let author_id = ctx.user.map(|u| u.id).unwrap_or_else(Uuid::new_v4);
|
||||
let active = dto.into_active_model(author_id);
|
||||
let channel = crate::models::channel::Entity::find_by_id(dto.channel_id)
|
||||
.one(app_state.db.get_connection())
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if let Some(server_id) = channel.server_id {
|
||||
let is_admin = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
if !is_admin && !is_superuser {
|
||||
let ctx = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, server_id)
|
||||
.await?;
|
||||
if !ctx.has(Permission::SendMessage, Some(channel.id)) {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let active = dto.into_active_model(user.id);
|
||||
let message: message::Model = active.insert(app_state.db.get_connection()).await?;
|
||||
|
||||
Ok(Json(MessageResponse::from(message)))
|
||||
@@ -91,6 +119,7 @@ pub async fn message_create(
|
||||
request_body = CreateMessageRequest,
|
||||
responses(
|
||||
(status = 200, description = "Message updated", body = MessageResponse),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Message not found")
|
||||
),
|
||||
params(
|
||||
@@ -99,6 +128,7 @@ pub async fn message_create(
|
||||
)]
|
||||
pub async fn message_update(
|
||||
State(app_state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(dto): Json<CreateMessageRequest>,
|
||||
) -> Result<Json<MessageResponse>, HTTPError> {
|
||||
@@ -107,6 +137,10 @@ pub async fn message_update(
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if message.user_id != user.id {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
let active = message.into_active_model();
|
||||
let message: message::Model = dto
|
||||
.apply_to(active)
|
||||
@@ -121,6 +155,7 @@ pub async fn message_update(
|
||||
path = "/api/message/{id}",
|
||||
responses(
|
||||
(status = 204, description = "Message deleted"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Message not found")
|
||||
),
|
||||
params(
|
||||
@@ -129,8 +164,44 @@ pub async fn message_update(
|
||||
)]
|
||||
pub async fn message_delete(
|
||||
State(app_state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
let message = message::Entity::find_by_id(id)
|
||||
.one(app_state.db.get_connection())
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if message.user_id != user.id {
|
||||
// Si ce n'est pas l'auteur, on regarde si c'est un modérateur
|
||||
let channel = crate::models::channel::Entity::find_by_id(message.channel_id)
|
||||
.one(app_state.db.get_connection())
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if let Some(server_id) = channel.server_id {
|
||||
let is_admin = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, server_id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
if !is_admin && !is_superuser {
|
||||
let ctx = app_state
|
||||
.repositories
|
||||
.permission
|
||||
.load_for_server_user(user.id, server_id)
|
||||
.await?;
|
||||
if !ctx.has(Permission::DeleteOthersMessage, Some(channel.id)) {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
let result = message::Entity::delete_by_id(id)
|
||||
.exec(app_state.db.get_connection())
|
||||
.await?;
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::network::http::AppRouter;
|
||||
mod auth;
|
||||
mod category;
|
||||
mod channel;
|
||||
mod group;
|
||||
mod message;
|
||||
pub mod openapi;
|
||||
mod server;
|
||||
@@ -12,6 +13,7 @@ pub fn setup_route() -> AppRouter {
|
||||
AppRouter::new()
|
||||
.nest("/category", category::setup_route())
|
||||
.nest("/channel", channel::setup_route())
|
||||
.nest("/group", group::setup_route())
|
||||
.nest("/message", message::setup_route())
|
||||
.nest("/server", server::setup_route())
|
||||
.nest("/user", user::setup_route())
|
||||
|
||||
87
src/network/http/web/api/openapi.rs
Normal file
87
src/network/http/web/api/openapi.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use crate::interfaces::http::dto::{
|
||||
auth as auth_dto, category as category_dto, channel as channel_dto, message as message_dto,
|
||||
server as server_dto, user as user_dto,
|
||||
};
|
||||
use crate::network::http::web::api::{auth, category, channel, message, server, user};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
auth::login,
|
||||
auth::ssh_challenge,
|
||||
auth::claim_admin,
|
||||
category::category_list,
|
||||
category::category_detail,
|
||||
category::category_create,
|
||||
category::category_update,
|
||||
category::category_delete,
|
||||
channel::channel_list,
|
||||
channel::channel_detail,
|
||||
channel::channel_create,
|
||||
channel::channel_update,
|
||||
channel::channel_delete,
|
||||
message::message_list,
|
||||
message::message_detail,
|
||||
message::message_create,
|
||||
message::message_update,
|
||||
message::message_delete,
|
||||
server::server_list,
|
||||
server::server_detail,
|
||||
server::server_create,
|
||||
server::server_update,
|
||||
server::server_delete,
|
||||
server::server_password,
|
||||
server::tree,
|
||||
user::get_me,
|
||||
user::user_list,
|
||||
user::user_detail,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
auth::LoginRequest,
|
||||
auth::LoginResponse,
|
||||
auth::SshChallengeRequest,
|
||||
auth::SshChallengeResponse,
|
||||
auth::ClaimAdminRequest,
|
||||
category_dto::CategoryResponse,
|
||||
category_dto::CreateCategoryRequest,
|
||||
category_dto::ListCategoryQuery,
|
||||
category::CategoryQuery,
|
||||
channel_dto::ChannelResponse,
|
||||
channel_dto::CreateChannelRequest,
|
||||
crate::models::channel::ChannelType,
|
||||
message_dto::MessageResponse,
|
||||
message_dto::CreateMessageRequest,
|
||||
server_dto::ServerResponse,
|
||||
server_dto::CreateServerRequest,
|
||||
server_dto::ServerTreeResponse,
|
||||
server_dto::TreeItemType,
|
||||
user_dto::UserResponse,
|
||||
user_dto::CreateUserRequest,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "ox-speak", description = "Ox Speak API")
|
||||
),
|
||||
modifiers(&SecurityAddon)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl utoipa::Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"jwt",
|
||||
utoipa::openapi::security::SecurityScheme::Http(
|
||||
utoipa::openapi::security::HttpBuilder::new()
|
||||
.scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
|
||||
.bearer_format("JWT")
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use crate::interfaces::http::dto::server::{
|
||||
CreateServerRequest, ServerResponse, ServerTreeResponse,
|
||||
};
|
||||
use crate::models::server;
|
||||
use crate::network::http::context::CurrentUser;
|
||||
use crate::network::http::{AppRouter, HTTPError};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
@@ -69,13 +70,18 @@ pub async fn server_detail(
|
||||
path = "/api/server",
|
||||
request_body = CreateServerRequest,
|
||||
responses(
|
||||
(status = 200, description = "Server created", body = ServerResponse)
|
||||
(status = 200, description = "Server created", body = ServerResponse),
|
||||
(status = 403, description = "Forbidden")
|
||||
)
|
||||
)]
|
||||
pub async fn server_create(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Json(serializer): Json<CreateServerRequest>,
|
||||
) -> Result<Json<ServerResponse>, HTTPError> {
|
||||
if !user.is_superuser {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
let active: server::ActiveModel = serializer.into();
|
||||
let server = state.repositories.server.create(active).await?;
|
||||
|
||||
@@ -88,6 +94,7 @@ pub async fn server_create(
|
||||
request_body = CreateServerRequest,
|
||||
responses(
|
||||
(status = 200, description = "Server updated", body = ServerResponse),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Server not found")
|
||||
),
|
||||
params(
|
||||
@@ -96,9 +103,21 @@ pub async fn server_create(
|
||||
)]
|
||||
pub async fn server_update(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(serializer): Json<CreateServerRequest>,
|
||||
) -> Result<Json<ServerResponse>, HTTPError> {
|
||||
let is_admin = state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
if !is_admin && !is_superuser {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
let am_server = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -121,6 +140,7 @@ pub async fn server_update(
|
||||
path = "/api/server/{id}",
|
||||
responses(
|
||||
(status = 204, description = "Server deleted"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Server not found")
|
||||
),
|
||||
params(
|
||||
@@ -129,8 +149,20 @@ pub async fn server_update(
|
||||
)]
|
||||
pub async fn server_delete(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
let is_admin = state
|
||||
.repositories
|
||||
.permission
|
||||
.is_server_admin(user.id, id)
|
||||
.await?;
|
||||
let is_superuser = user.is_superuser;
|
||||
|
||||
if !is_admin && !is_superuser {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
if state.repositories.server.delete(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::interfaces::http::dto::user::UserResponse;
|
||||
use crate::network::http::context::CurrentUser;
|
||||
use crate::network::http::{AppRouter, HTTPError};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Json};
|
||||
use axum::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn setup_route() -> AppRouter {
|
||||
@@ -24,14 +25,11 @@ pub fn setup_route() -> AppRouter {
|
||||
("jwt" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_me(
|
||||
Extension(ctx): Extension<crate::network::http::RequestContext>,
|
||||
) -> Result<Json<UserResponse>, HTTPError> {
|
||||
let user = ctx.user.ok_or(HTTPError::Unauthorized)?;
|
||||
pub async fn get_me(user: CurrentUser) -> Result<Json<UserResponse>, HTTPError> {
|
||||
Ok(Json(UserResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
pub_key: "".to_string(), // On peut laisser vide ou charger en DB si besoin
|
||||
username: user.username.clone(),
|
||||
pub_key: user.pub_key.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -39,13 +37,17 @@ pub async fn get_me(
|
||||
get,
|
||||
path = "/api/user",
|
||||
responses(
|
||||
(status = 200, description = "List of all users", body = [UserResponse])
|
||||
(status = 200, description = "List of all users", body = [UserResponse]),
|
||||
(status = 403, description = "Forbidden")
|
||||
)
|
||||
)]
|
||||
pub async fn user_list(
|
||||
State(app_state): State<AppState>,
|
||||
Extension(_ctx): Extension<crate::network::http::RequestContext>,
|
||||
user: CurrentUser,
|
||||
) -> Result<Json<Vec<UserResponse>>, HTTPError> {
|
||||
if !user.is_superuser {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
let users = app_state.repositories.user.get_all().await?;
|
||||
|
||||
Ok(Json(users.into_iter().map(UserResponse::from).collect()))
|
||||
|
||||
43
src/repositories/group.rs
Normal file
43
src/repositories/group.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::models::group;
|
||||
use crate::repositories::RepositoryContext;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupRepository {
|
||||
pub context: Arc<RepositoryContext>,
|
||||
}
|
||||
|
||||
impl GroupRepository {
|
||||
pub async fn get_all_by_server(&self, server_id: Uuid) -> Result<Vec<group::Model>, DbErr> {
|
||||
group::Entity::find()
|
||||
.filter(group::Column::ServerId.eq(server_id))
|
||||
.all(&self.context.db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_by_id(&self, id: Uuid) -> Result<Option<group::Model>, DbErr> {
|
||||
group::Entity::find_by_id(id).one(&self.context.db).await
|
||||
}
|
||||
|
||||
pub async fn create(&self, active: group::ActiveModel) -> Result<group::Model, DbErr> {
|
||||
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<group::Model, DbErr> {
|
||||
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<bool, DbErr> {
|
||||
let res = group::Entity::delete_by_id(id)
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
self.context.events.emit("group_deleted", id);
|
||||
Ok(res.rows_affected > 0)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use crate::event_bus::EventBus;
|
||||
use crate::repositories::category::CategoryRepository;
|
||||
use crate::repositories::channel::ChannelRepository;
|
||||
use crate::repositories::group::GroupRepository;
|
||||
use crate::repositories::message::MessageRepository;
|
||||
use crate::repositories::permission::PermissionRepository;
|
||||
use crate::repositories::server::ServerRepository;
|
||||
use crate::repositories::user::UserRepository;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod server;
|
||||
mod category;
|
||||
mod channel;
|
||||
mod group;
|
||||
mod message;
|
||||
mod user;
|
||||
mod permission;
|
||||
mod server;
|
||||
pub mod types;
|
||||
mod user;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RepositoryContext {
|
||||
@@ -25,20 +29,41 @@ pub struct Repositories {
|
||||
pub server: ServerRepository,
|
||||
pub category: CategoryRepository,
|
||||
pub channel: ChannelRepository,
|
||||
pub group: GroupRepository,
|
||||
pub message: MessageRepository,
|
||||
pub user: UserRepository,
|
||||
pub permission: PermissionRepository,
|
||||
}
|
||||
|
||||
impl Repositories {
|
||||
pub fn new(db: &DatabaseConnection, events: EventBus) -> Self {
|
||||
let context = Arc::new(RepositoryContext { db: db.clone(), events });
|
||||
let context = Arc::new(RepositoryContext {
|
||||
db: db.clone(),
|
||||
events,
|
||||
});
|
||||
|
||||
Self {
|
||||
server: ServerRepository {context: context.clone()},
|
||||
category: CategoryRepository {context: context.clone()},
|
||||
channel: ChannelRepository {context: context.clone()},
|
||||
message: MessageRepository {context: context.clone()},
|
||||
user: UserRepository {context: context.clone()},
|
||||
server: ServerRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
category: CategoryRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
channel: ChannelRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
group: GroupRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
message: MessageRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
user: UserRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
permission: PermissionRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
82
src/repositories/permission.rs
Normal file
82
src/repositories/permission.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use crate::models::{channel_user, group, group_member, server_user};
|
||||
use crate::repositories::RepositoryContext;
|
||||
use crate::utils::permissions::PermissionContext;
|
||||
use sea_orm::{ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PermissionRepository {
|
||||
pub context: Arc<RepositoryContext>,
|
||||
}
|
||||
|
||||
impl PermissionRepository {
|
||||
pub async fn is_server_admin(&self, user_id: Uuid, server_id: Uuid) -> Result<bool, DbErr> {
|
||||
let res = server_user::Entity::find()
|
||||
.filter(server_user::Column::UserId.eq(user_id))
|
||||
.filter(server_user::Column::ServerId.eq(server_id))
|
||||
.one(&self.context.db)
|
||||
.await?;
|
||||
|
||||
Ok(res.map(|su| su.is_admin).unwrap_or(false))
|
||||
}
|
||||
|
||||
pub async fn load_for_user(&self, user_id: Uuid) -> Result<PermissionContext, DbErr> {
|
||||
// Requête 1 : JOIN group_member → group filtre group_member.user_id = user_id
|
||||
// collecter les group.permissions: i64 dans un Vec<i64>
|
||||
let group_masks: Vec<i64> = group::Entity::find()
|
||||
.inner_join(group_member::Entity)
|
||||
.filter(group_member::Column::UserId.eq(user_id))
|
||||
.select_only()
|
||||
.column(group::Column::Permissions)
|
||||
.into_tuple()
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
// Requête 2 : SELECT channel_id, permissions FROM channel_user WHERE user_id = user_id
|
||||
// collecter en Vec<(Uuid, i64)>
|
||||
let channel_permissions: Vec<(Uuid, i64)> = channel_user::Entity::find()
|
||||
.filter(channel_user::Column::UserId.eq(user_id))
|
||||
.select_only()
|
||||
.column(channel_user::Column::ChannelId)
|
||||
.column(channel_user::Column::Permissions)
|
||||
.into_tuple()
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
Ok(PermissionContext::new(&group_masks, channel_permissions))
|
||||
}
|
||||
|
||||
pub async fn load_for_server_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
server_id: Uuid,
|
||||
) -> Result<PermissionContext, DbErr> {
|
||||
// Requête 1 : JOIN group_member → group filtre group_member.user_id = user_id ET group.server_id = server_id
|
||||
let group_masks: Vec<i64> = group::Entity::find()
|
||||
.inner_join(group_member::Entity)
|
||||
.filter(group_member::Column::UserId.eq(user_id))
|
||||
.filter(group::Column::ServerId.eq(server_id))
|
||||
.select_only()
|
||||
.column(group::Column::Permissions)
|
||||
.into_tuple()
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
// Requête 2 : SELECT channel_id, permissions FROM channel_user
|
||||
// JOIN channel ON channel_user.channel_id = channel.id
|
||||
// WHERE user_id = user_id AND channel.server_id = server_id
|
||||
let channel_permissions: Vec<(Uuid, i64)> = channel_user::Entity::find()
|
||||
.inner_join(crate::models::channel::Entity)
|
||||
.filter(channel_user::Column::UserId.eq(user_id))
|
||||
.filter(crate::models::channel::Column::ServerId.eq(server_id))
|
||||
.select_only()
|
||||
.column(channel_user::Column::ChannelId)
|
||||
.column(channel_user::Column::Permissions)
|
||||
.into_tuple()
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
Ok(PermissionContext::new(&group_masks, channel_permissions))
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
use sea_orm::{DbErr, EntityTrait, ActiveModelTrait, QueryFilter, ColumnTrait, QueryOrder};
|
||||
use uuid::Uuid;
|
||||
use crate::models::{category, channel, server};
|
||||
use super::RepositoryContext;
|
||||
use super::types::{ServerExplorerItem, ServerTree};
|
||||
use super::RepositoryContext;
|
||||
use crate::models::{category, channel, group, server};
|
||||
use crate::utils::permissions::Permission;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerRepository {
|
||||
pub context: Arc<RepositoryContext>
|
||||
pub context: Arc<RepositoryContext>,
|
||||
}
|
||||
|
||||
impl ServerRepository {
|
||||
pub async fn get_all(&self) -> Result<Vec<server::Model>, DbErr> {
|
||||
server::Entity::find().all(&self.context.db).await
|
||||
}
|
||||
|
||||
|
||||
pub async fn get_by_id(&self, id: uuid::Uuid) -> Result<Option<server::Model>, DbErr> {
|
||||
server::Entity::find_by_id(id).one(&self.context.db).await
|
||||
}
|
||||
@@ -27,12 +28,25 @@ impl ServerRepository {
|
||||
|
||||
pub async fn create(&self, active: server::ActiveModel) -> Result<server::Model, DbErr> {
|
||||
let server = active.insert(&self.context.db).await?;
|
||||
|
||||
// Créer le groupe par défaut pour le serveur
|
||||
let default_group = group::ActiveModel {
|
||||
server_id: Set(server.id),
|
||||
name: Set("Membres".to_string()),
|
||||
permissions: Set(Permission::default_permissions() as i64),
|
||||
is_default: Set(true),
|
||||
..Default::default()
|
||||
};
|
||||
default_group.insert(&self.context.db).await?;
|
||||
|
||||
self.context.events.emit("server_created", server.clone());
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: uuid::Uuid) -> Result<bool, DbErr> {
|
||||
let res = server::Entity::delete_by_id(id).exec(&self.context.db).await?;
|
||||
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)
|
||||
}
|
||||
@@ -61,7 +75,9 @@ impl ServerRepository {
|
||||
for (cat, mut channels) in categories_with_channels {
|
||||
// On trie les channels internes (obligatoire car SQL ne garantit aucun ordre ici)
|
||||
channels.sort_by(|a, b| {
|
||||
a.position.cmp(&b.position).then(a.created_at.cmp(&b.created_at))
|
||||
a.position
|
||||
.cmp(&b.position)
|
||||
.then(a.created_at.cmp(&b.created_at))
|
||||
});
|
||||
items.push(ServerExplorerItem::Category(cat, channels));
|
||||
}
|
||||
@@ -92,4 +108,4 @@ impl ServerRepository {
|
||||
|
||||
Ok(ServerTree { items })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ use crate::models::user;
|
||||
use crate::repositories::RepositoryContext;
|
||||
use crate::utils::password;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, IntoActiveModel, QueryFilter, Set,
|
||||
ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, IntoActiveModel, PaginatorTrait,
|
||||
QueryFilter, Set,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,6 +17,10 @@ impl UserRepository {
|
||||
user::Entity::find().all(&self.context.db).await
|
||||
}
|
||||
|
||||
pub async fn count(&self) -> Result<u64, DbErr> {
|
||||
user::Entity::find().count(&self.context.db).await
|
||||
}
|
||||
|
||||
pub async fn get_by_id(&self, id: uuid::Uuid) -> Result<Option<user::Model>, DbErr> {
|
||||
user::Entity::find_by_id(id).one(&self.context.db).await
|
||||
}
|
||||
@@ -50,7 +55,7 @@ impl UserRepository {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn create(&self, mut active: user::ActiveModel) -> Result<user::Model, DbErr> {
|
||||
pub async fn create(&self, active: user::ActiveModel) -> Result<user::Model, DbErr> {
|
||||
let user = active.insert(&self.context.db).await?;
|
||||
self.context.events.emit("user_created", user.clone());
|
||||
Ok(user)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod password;
|
||||
pub mod permissions;
|
||||
pub mod ssh_auth;
|
||||
pub mod toolbox;
|
||||
|
||||
183
src/utils/permissions.rs
Normal file
183
src/utils/permissions.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// # Système de Permissions par Bitmask
|
||||
///
|
||||
/// Un bitmask (masque de bits) est une manière compacte et efficace de stocker plusieurs
|
||||
/// valeurs booléennes dans un seul nombre entier.
|
||||
///
|
||||
/// ## Comment ça fonctionne ?
|
||||
/// Chaque permission est associée à une puissance de 2 (1, 2, 4, 8, 16, etc.), ce qui
|
||||
/// correspond à un bit unique dans la représentation binaire du nombre.
|
||||
///
|
||||
/// - `ReadChannel` (1 << 0) = `00000001` (1 en décimal)
|
||||
/// - `JoinChannel` (1 << 1) = `00000010` (2 en décimal)
|
||||
/// - `SendMessage` (1 << 2) = `00000100` (4 en décimal)
|
||||
///
|
||||
/// ## Opérations courantes :
|
||||
///
|
||||
/// ### 1. Combiner des permissions (OU binaire `|`)
|
||||
/// Pour donner à la fois `ReadChannel` et `SendMessage` :
|
||||
/// `let mask = Permission::ReadChannel as u64 | Permission::SendMessage as u64;`
|
||||
/// Résultat : `00000101` (5 en décimal)
|
||||
///
|
||||
/// ### 2. Vérifier une permission (ET binaire `&`)
|
||||
/// Pour savoir si un utilisateur a `SendMessage` dans son `mask` :
|
||||
/// `(mask & Permission::SendMessage as u64) != 0`
|
||||
///
|
||||
/// ### 3. Retirer une permission (NON `!`, ET `&`)
|
||||
/// `mask &= !(Permission::SendMessage as u64);`
|
||||
///
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[repr(u64)]
|
||||
pub enum Permission {
|
||||
/// Pouvoir voir le canal dans la liste et lire les messages
|
||||
ReadChannel = 1 << 0,
|
||||
/// Pouvoir rejoindre le canal vocal
|
||||
JoinChannel = 1 << 1,
|
||||
/// Pouvoir envoyer des messages
|
||||
SendMessage = 1 << 2,
|
||||
/// Pouvoir supprimer ses propres messages
|
||||
DeleteMessage = 1 << 3,
|
||||
/// Pouvoir supprimer les messages des autres (Modérateur)
|
||||
DeleteOthersMessage = 1 << 4,
|
||||
/// Pouvoir modifier les paramètres du canal
|
||||
ManageChannel = 1 << 5,
|
||||
/// Pouvoir gérer les groupes/permissions du serveur
|
||||
ManageRoles = 1 << 6,
|
||||
/// Pouvoir expulser des membres du serveur
|
||||
KickMember = 1 << 7,
|
||||
/// Pouvoir parler en vocal
|
||||
VoiceSpeak = 1 << 8,
|
||||
/// Pouvoir rendre muet les autres utilisateurs en vocal
|
||||
VoiceMuteOthers = 1 << 9,
|
||||
/// Pouvoir modifier les paramètres globaux du serveur
|
||||
ManageServer = 1 << 10,
|
||||
}
|
||||
|
||||
impl Permission {
|
||||
/// Retourne un bitmask contenant toutes les permissions "standard"
|
||||
/// (Tout sauf les permissions de gestion "Manage...").
|
||||
pub fn default_permissions() -> u64 {
|
||||
Permission::ReadChannel as u64
|
||||
| Permission::JoinChannel as u64
|
||||
| Permission::SendMessage as u64
|
||||
| Permission::DeleteMessage as u64
|
||||
| Permission::DeleteOthersMessage as u64
|
||||
| Permission::KickMember as u64
|
||||
| Permission::VoiceSpeak as u64
|
||||
| Permission::VoiceMuteOthers as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// Contexte de permissions calculé pour un utilisateur donné.
|
||||
///
|
||||
/// Ce contexte regroupe les permissions globales (issues de la fusion de tous les groupes
|
||||
/// de l'utilisateur) et les surcharges spécifiques par canal (overrides).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionContext {
|
||||
/// Union des masques de tous les groupes auxquels appartient l'utilisateur.
|
||||
pub global: u64,
|
||||
/// Masques spécifiques par canal (si présents, ils remplacent totalement le masque global
|
||||
/// pour ce canal spécifique).
|
||||
pub channel_overrides: HashMap<Uuid, u64>,
|
||||
}
|
||||
|
||||
impl PermissionContext {
|
||||
/// Crée un nouveau contexte à partir des masques bruts de la base de données.
|
||||
///
|
||||
/// - `group_masks` : Liste des permissions de chaque groupe de l'utilisateur.
|
||||
/// - `channel_permissions` : Liste de (ID Canal, Masque) pour les permissions spécifiques.
|
||||
pub fn new(group_masks: &[i64], channel_permissions: Vec<(Uuid, i64)>) -> Self {
|
||||
// On combine tous les groupes avec l'opérateur OR (|)
|
||||
let global = group_masks.iter().fold(0u64, |acc, &m| acc | m as u64);
|
||||
|
||||
let channel_overrides = channel_permissions
|
||||
.into_iter()
|
||||
.map(|(id, m)| (id, m as u64))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
global,
|
||||
channel_overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si une permission spécifique est accordée, optionnellement pour un canal précis.
|
||||
///
|
||||
/// Si `channel_id` est fourni et qu'une surcharge existe pour ce canal, la surcharge
|
||||
/// est utilisée. Sinon, on utilise les permissions globales du serveur.
|
||||
///
|
||||
/// ### Exemple d'utilisation :
|
||||
/// ```rust
|
||||
/// if ctx.has(Permission::SendMessage, Some(channel_id)) {
|
||||
/// // Autorisé !
|
||||
/// }
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn has(&self, permission: Permission, channel_id: Option<Uuid>) -> bool {
|
||||
let mask = if let Some(cid) = channel_id {
|
||||
self.channel_overrides
|
||||
.get(&cid)
|
||||
.copied()
|
||||
.unwrap_or(self.global)
|
||||
} else {
|
||||
self.global
|
||||
};
|
||||
|
||||
mask & (permission as u64) != 0
|
||||
}
|
||||
|
||||
/// Méthode utilitaire pour vérifier rapidement l'accès en lecture à un canal.
|
||||
pub fn can_see_channel(&self, channel_id: Uuid) -> bool {
|
||||
self.has(Permission::ReadChannel, Some(channel_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_permission_context() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
let other_channel_id = Uuid::new_v4();
|
||||
|
||||
// On imagine un utilisateur avec 2 groupes
|
||||
let role_masks = vec![
|
||||
Permission::ReadChannel as i64 | Permission::SendMessage as i64, // Groupe 1 : lecture + envoi
|
||||
Permission::VoiceSpeak as i64, // Groupe 2 : vocal
|
||||
];
|
||||
|
||||
// Et une permission spécifique sur un canal (Lecture + Envoi + Gestion)
|
||||
let channel_permissions = vec![(
|
||||
channel_id,
|
||||
Permission::ReadChannel as i64
|
||||
| Permission::SendMessage as i64
|
||||
| Permission::ManageChannel as i64,
|
||||
)];
|
||||
|
||||
let ctx = PermissionContext::new(&role_masks, channel_permissions);
|
||||
|
||||
// Test des permissions globales
|
||||
assert!(ctx.has(Permission::ReadChannel, None));
|
||||
assert!(ctx.has(Permission::SendMessage, None));
|
||||
assert!(ctx.has(Permission::VoiceSpeak, None));
|
||||
assert!(!ctx.has(Permission::ManageChannel, None));
|
||||
|
||||
// Test des surcharges par canal (L'override remplace le masque global)
|
||||
assert!(ctx.has(Permission::ReadChannel, Some(channel_id)));
|
||||
assert!(ctx.has(Permission::SendMessage, Some(channel_id)));
|
||||
assert!(ctx.has(Permission::ManageChannel, Some(channel_id)));
|
||||
assert!(!ctx.has(Permission::VoiceSpeak, Some(channel_id))); // Ici on perd le vocal car l'override remplace tout
|
||||
|
||||
// Test d'un autre canal (pas de surcharge, on retombe sur le global)
|
||||
assert!(ctx.has(Permission::ReadChannel, Some(other_channel_id)));
|
||||
assert!(ctx.has(Permission::VoiceSpeak, Some(other_channel_id)));
|
||||
assert!(!ctx.has(Permission::ManageChannel, Some(other_channel_id)));
|
||||
|
||||
// Test des fonctions sucre
|
||||
assert!(ctx.can_see_channel(channel_id));
|
||||
assert!(ctx.can_see_channel(other_channel_id));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user