Files
oxspeak_server/src/routes/message/handlers.rs
T
2026-09-24 21:10:11 +02:00

519 lines
14 KiB
Rust

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<AppState>,
Query(filters): Query<MessageQueryParams>,
) -> Result<Json<MessagePageResponse>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<MessageResponse>, 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<AppState>,
Json(payload): Json<CreateMessageRequest>,
) -> Result<(StatusCode, Json<MessageResponse>), 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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateMessageRequest>,
) -> Result<Json<MessageResponse>, 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<AppState>,
Path(message_id): Path<Uuid>,
Json(payload): Json<CreateReactionRequest>,
) -> Result<(StatusCode, Json<ReactionResponse>), 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<AppState>,
Path((message_id, emoji_id)): Path<(Uuid, Uuid)>,
Query(query): Query<DeleteReactionQuery>,
) -> Result<StatusCode, 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,
)
.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<AppState>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> {
// 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)
}
}