use crate::core::state::AppState; use crate::domain::dto::message::{ CreateMessageRequest, MessagePageResponse, MessageQueryParams, MessageResponse, UpdateMessageRequest, }; use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse}; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; use crate::http::permissions::check_channel_permission; use crate::permissions::ChannelPermission; use crate::routes::message::mapper; use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, }; use uuid::Uuid; pub(crate) async fn require_channel_permission( state: &AppState, channel_id: Uuid, user_id: Uuid, is_superuser: bool, required: ChannelPermission, ) -> Result<(), HTTPError> { let _ = is_superuser; if check_channel_permission(state, user_id, channel_id, required).await? { Ok(()) } else { Err(HTTPError::Forbidden) } } fn allows_channel_permission(granted: ChannelPermission, required: ChannelPermission) -> bool { granted.contains(required) } #[cfg(test)] mod tests { use super::*; #[test] fn message_permissions_require_every_requested_bit() { let granted = ChannelPermission::READ_CHANNEL | ChannelPermission::SEND_MESSAGE; assert!(allows_channel_permission( granted, ChannelPermission::READ_CHANNEL )); assert!(!allows_channel_permission( granted, ChannelPermission::SEND_MESSAGE | ChannelPermission::ATTACH_FILES )); assert!(!allows_channel_permission( granted, ChannelPermission::EDIT_OTHERS_MESSAGES )); assert!(!allows_channel_permission( ChannelPermission::empty(), ChannelPermission::READ_CHANNEL )); } } /// Liste une fenêtre paginée de messages #[utoipa::path( get, path = "/messages", responses( (status = 200, description = "Fenêtre de messages récupérée avec succès", body = MessagePageResponse), (status = 400, description = "Curseurs incompatibles ou canal manquant"), (status = 500, description = "Erreur interne du serveur") ), params( MessageQueryParams ), tag = "Messages" )] pub async fn get_all( user: CurrentUser, State(state): State, Query(filters): Query, ) -> Result, HTTPError> { if filters.before_id.is_some() && filters.after_id.is_some() { return Err(HTTPError::BadRequest( "before_id and after_id cannot be used together".to_string(), )); } let params = mapper::query_params_to_message_filter(filters); let channel_id = params .channel_id .ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?; require_channel_permission( &state, channel_id, user.id, user.is_superuser, ChannelPermission::READ_CHANNEL, ) .await?; let page = state.repositories.message.filter(params).await?; let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect(); let mut reactions = state .services .message_reaction .grouped_for_messages(&message_ids) .await?; let mut attachments = state .repositories .message .attachments_for_messages(&message_ids) .await?; let oldest_id = page.messages.first().map(|message| message.id); let newest_id = page.messages.last().map(|message| message.id); Ok(Json(MessagePageResponse { messages: page .messages .into_iter() .map(|message| { let groups = reactions.remove(&message.id).unwrap_or_default(); let files = attachments.remove(&message.id).unwrap_or_default(); mapper::message_model_to_message_response_with_data(message, None, groups, files) }) .collect(), oldest_id, newest_id, has_more_before: page.has_more_before, has_more_after: page.has_more_after, })) } /// Récupère un message par son ID #[utoipa::path( get, path = "/messages/{id}", responses( (status = 200, description = "Message trouvé", body = MessageResponse), (status = 404, description = "Message non trouvé"), (status = 500, description = "Erreur interne du serveur") ), params( ("id" = Uuid, Path, description = "ID du message") ), tag = "Messages" )] pub async fn get_by_id( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, HTTPError> { let message = state .repositories .message .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; require_channel_permission( &state, message.channel_id, user.id, user.is_superuser, ChannelPermission::READ_CHANNEL, ) .await?; let reactions = state .services .message_reaction .grouped_for_messages(&[id]) .await? .remove(&id) .unwrap_or_default(); let attachments = state .repositories .message .attachments_for_messages(&[id]) .await? .remove(&id) .unwrap_or_default(); Ok(Json(mapper::message_model_to_message_response_with_data( message, None, reactions, attachments, ))) } /// Crée un nouveau message #[utoipa::path( post, path = "/messages", request_body = CreateMessageRequest, responses( (status = 201, description = "Message créé avec succès", body = MessageResponse), (status = 400, description = "Données invalides (canal non trouvé)"), (status = 500, description = "Erreur interne du serveur") ), tag = "Messages", security( ("bearerAuth" = []) ) )] pub async fn create( user: CurrentUser, State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json), HTTPError> { // Vérifier que le canal existe let channel = state .repositories .channel .get_by_id(payload.channel_id) .await? .ok_or(HTTPError::BadRequest("Channel not found".to_string()))?; let mut required = ChannelPermission::SEND_MESSAGE; if !payload.file_ids.is_empty() { required |= ChannelPermission::ATTACH_FILES; } require_channel_permission(&state, channel.id, user.id, user.is_superuser, required).await?; if payload.content.trim().is_empty() && payload.file_ids.is_empty() { return Err(HTTPError::BadRequest( "content or at least one file is required".into(), )); } // Optionnel: vérifier reply_to_id if let Some(reply_id) = payload.reply_to_id { let parent = state .repositories .message .get_by_id(reply_id) .await? .ok_or(HTTPError::BadRequest( "Parent message not found".to_string(), ))?; if parent.channel_id != channel.id { return Err(HTTPError::BadRequest( "Parent message belongs to another channel".into(), )); } } let message = state .services .message .create_message_with_attachments( payload.channel_id, user.id, payload.content, payload.file_ids, payload.reply_to_id, ) .await .map_err(|error| HTTPError::BadRequest(error.to_string()))?; // L'auteur a forcément lu le message qu'il vient d'envoyer. state .repositories .read_state .set(message.channel_id, user.id, Some(message.id)) .await?; let attachments = state .repositories .message .attachments_for_messages(&[message.id]) .await? .remove(&message.id) .unwrap_or_default(); Ok(( StatusCode::CREATED, Json(mapper::message_model_to_message_response_with_data( message, channel.server_id, Vec::new(), attachments, )), )) } /// Met à jour un message existant #[utoipa::path( put, path = "/messages/{id}", request_body = UpdateMessageRequest, responses( (status = 200, description = "Message mis à jour avec succès", body = MessageResponse), (status = 403, description = "Interdit (pas l'auteur)"), (status = 404, description = "Message non trouvé"), (status = 500, description = "Erreur interne du serveur") ), params( ("id" = Uuid, Path, description = "ID du message") ), tag = "Messages", security( ("bearerAuth" = []) ) )] pub async fn update( user: CurrentUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> Result, HTTPError> { // Vérifier l'existence let message = state .repositories .message .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; // Vérifier que l'utilisateur est l'auteur let required = if message.user_id == user.id { ChannelPermission::EDIT_OWN_MESSAGE } else { ChannelPermission::EDIT_OTHERS_MESSAGES }; require_channel_permission( &state, message.channel_id, user.id, user.is_superuser, required, ) .await?; let message = state .services .message .update_message(id, payload.content) .await?; let reactions = state .services .message_reaction .grouped_for_messages(&[id]) .await? .remove(&id) .unwrap_or_default(); let attachments = state .repositories .message .attachments_for_messages(&[id]) .await? .remove(&id) .unwrap_or_default(); Ok(Json(mapper::message_model_to_message_response_with_data( message, None, reactions, attachments, ))) } #[utoipa::path( post, path = "/messages/{message_id}/reactions", request_body = CreateReactionRequest, responses( (status = 201, description = "Réaction ajoutée", body = ReactionResponse), (status = 200, description = "Réaction déjà présente", body = ReactionResponse), (status = 400, description = "Emoji ou ton invalide"), (status = 404, description = "Message non trouvé") ), params(("message_id" = Uuid, Path)), tag = "Messages", security(("bearerAuth" = [])) )] pub async fn add_reaction( user: CurrentUser, State(state): State, Path(message_id): Path, Json(payload): Json, ) -> Result<(StatusCode, Json), HTTPError> { let message = state .repositories .message .get_by_id(message_id) .await? .ok_or(HTTPError::NotFound)?; require_channel_permission( &state, message.channel_id, user.id, user.is_superuser, ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS, ) .await?; let (reaction, created) = state .services .message_reaction .add(message_id, user.id, payload.emoji_id, payload.skin_tone) .await?; Ok(( if created { StatusCode::CREATED } else { StatusCode::OK }, Json(mapper::reaction_model_to_response(reaction)), )) } #[utoipa::path( delete, path = "/messages/{message_id}/reactions/{emoji_id}", responses( (status = 204, description = "Réaction supprimée"), (status = 400, description = "Emoji ou ton invalide"), (status = 404, description = "Réaction non trouvée") ), params( ("message_id" = Uuid, Path), ("emoji_id" = Uuid, Path), DeleteReactionQuery ), tag = "Messages", security(("bearerAuth" = [])) )] pub async fn remove_reaction( user: CurrentUser, State(state): State, Path((message_id, emoji_id)): Path<(Uuid, Uuid)>, Query(query): Query, ) -> Result { let message = state .repositories .message .get_by_id(message_id) .await? .ok_or(HTTPError::NotFound)?; require_channel_permission( &state, message.channel_id, user.id, user.is_superuser, ChannelPermission::READ_CHANNEL, ) .await?; state .services .message_reaction .remove(message_id, user.id, emoji_id, query.skin_tone) .await?; Ok(StatusCode::NO_CONTENT) } /// Supprime un message #[utoipa::path( delete, path = "/messages/{id}", responses( (status = 204, description = "Message supprimé avec succès"), (status = 403, description = "Interdit (pas l'auteur ou admin)"), (status = 404, description = "Message non trouvé"), (status = 500, description = "Erreur interne du serveur") ), params( ("id" = Uuid, Path, description = "ID du message") ), tag = "Messages", security( ("bearerAuth" = []) ) )] pub async fn delete( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result { // Vérifier l'existence pour l'autorisation let message = state .repositories .message .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; let required = if message.user_id == user.id { ChannelPermission::DELETE_OWN_MESSAGE } else { ChannelPermission::DELETE_OTHERS_MESSAGES }; require_channel_permission( &state, message.channel_id, user.id, user.is_superuser, required, ) .await?; let attachment_ids: Vec<_> = state .repositories .message .attachments_for_messages(&[id]) .await? .remove(&id) .unwrap_or_default() .into_iter() .map(|item| item.file_path) .collect(); if state.services.message.delete_message(id).await? { for attachment_path in attachment_ids { let path = std::path::PathBuf::from(&state.config.media.root).join(attachment_path); let _ = tokio::fs::remove_file(path).await; } Ok(StatusCode::NO_CONTENT) } else { Err(HTTPError::NotFound) } }