519 lines
14 KiB
Rust
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)
|
|
}
|
|
}
|