This commit is contained in:
2026-03-09 01:30:12 +01:00
parent 74f4effd49
commit 50e1d4c25f
32 changed files with 1499 additions and 79 deletions

View File

@@ -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...)

View File

@@ -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?;

View File

@@ -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());

View File

@@ -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 {

View 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
}
}

View File

@@ -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;

View File

@@ -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
View 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()
}
}
}

View 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 {}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)]

View File

@@ -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)]

View File

@@ -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)
}
}

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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,
}))
}

View File

@@ -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?;

View File

@@ -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?;

View 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)
}
}

View File

@@ -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?;

View File

@@ -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())

View 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(),
),
)
}
}
}

View File

@@ -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 {

View File

@@ -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
View 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)
}
}

View File

@@ -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(),
},
}
}
}
}

View 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))
}
}

View File

@@ -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 })
}
}
}

View File

@@ -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)

View File

@@ -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
View 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));
}
}