init
This commit is contained in:
@@ -1,22 +1,109 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use crate::core::state::AppState;
|
||||
use crate::domain::dto::attachment::AttachmentUploadResponse;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::attachment;
|
||||
use crate::routes::attachment::mapper;
|
||||
use crate::routes::message::handlers::can_access;
|
||||
use crate::services::media::{self, PendingMediaFile};
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use chrono::{Duration, Utc};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_all() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
pub async fn cleanup_expired(state: &AppState) -> Result<(), HTTPError> {
|
||||
let cutoff = Utc::now() - Duration::hours(24);
|
||||
let pending = attachment::Entity::find()
|
||||
.filter(attachment::Column::MessageId.is_null())
|
||||
.filter(attachment::Column::CreatedAt.lt(cutoff))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
for item in pending {
|
||||
let _ = tokio::fs::remove_file(PathBuf::from(&state.config.media.root).join(&item.file_path)).await;
|
||||
attachment::Entity::delete_by_id(item.id).exec(&state.db).await?;
|
||||
}
|
||||
media::cleanup_temporary_files(
|
||||
PathBuf::from(&state.config.media.root).as_path(),
|
||||
std::time::Duration::from_secs(24 * 60 * 60),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| HTTPError::InternalServerError(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_by_id() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/attachments",
|
||||
request_body(content = String, content_type = "multipart/form-data"),
|
||||
responses((status = 201, body = AttachmentUploadResponse)),
|
||||
tag = "Attachments",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn create(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, axum::Json<AttachmentUploadResponse>), HTTPError> {
|
||||
cleanup_expired(&state).await?;
|
||||
let mut channel_id = None;
|
||||
let mut created = Vec::new();
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|error| HTTPError::BadRequest(error.to_string()))?
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "channel_id" {
|
||||
let value = field.text().await.map_err(|error| HTTPError::BadRequest(error.to_string()))?;
|
||||
channel_id = Some(value.parse::<Uuid>().map_err(HTTPError::UuidError)?);
|
||||
} else if name == "files" || name == "file" {
|
||||
let channel = channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id must precede files".into()))?;
|
||||
let filename = field.file_name().unwrap_or("unnamed").to_string();
|
||||
let mime_type = field.content_type().unwrap_or("application/octet-stream").to_string();
|
||||
if !can_access(&state, channel, user.id).await? { return Err(HTTPError::Forbidden); }
|
||||
let id = Uuid::new_v4();
|
||||
let mut output = PendingMediaFile::begin(
|
||||
PathBuf::from(&state.config.media.root).as_path(),
|
||||
"attachments",
|
||||
id,
|
||||
media::extension_from_filename(&filename).as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| HTTPError::InternalServerError(error.to_string()))?;
|
||||
let mut size = 0_i64;
|
||||
while let Some(chunk) = field.chunk().await.map_err(|error| HTTPError::BadRequest(error.to_string()))? {
|
||||
size += chunk.len() as i64;
|
||||
output.write(&chunk).await.map_err(|error| HTTPError::InternalServerError(error.to_string()))?;
|
||||
}
|
||||
let file_path = output.finish().await.map_err(|error| HTTPError::InternalServerError(error.to_string()))?;
|
||||
let model = attachment::ActiveModel {
|
||||
id: Set(id), message_id: Set(None), channel_id: Set(channel), user_id: Set(user.id),
|
||||
filename: Set(filename), file_size: Set(size), mime_type: Set(mime_type), file_path: Set(file_path.clone()), created_at: Set(Utc::now()),
|
||||
};
|
||||
match model.insert(&state.db).await {
|
||||
Ok(item) => created.push(item),
|
||||
Err(error) => { let _ = tokio::fs::remove_file(PathBuf::from(&state.config.media.root).join(file_path)).await; return Err(HTTPError::Database(error)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
let channel_id = channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?;
|
||||
if !can_access(&state, channel_id, user.id).await? { return Err(HTTPError::Forbidden); }
|
||||
if created.is_empty() { return Err(HTTPError::BadRequest("at least one file is required".into())); }
|
||||
Ok((StatusCode::CREATED, axum::Json(AttachmentUploadResponse { attachments: created.into_iter().map(mapper::to_response).collect() })))
|
||||
}
|
||||
|
||||
pub async fn create() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
|
||||
pub async fn update() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
|
||||
pub async fn delete() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
pub async fn file(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Response, HTTPError> {
|
||||
let item = attachment::Entity::find_by_id(id).one(&state.db).await?.ok_or(HTTPError::NotFound)?;
|
||||
let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(&item.file_path)).await.map_err(|_| HTTPError::NotFound)?;
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
if let Ok(value) = item.mime_type.parse() { response.headers_mut().insert(header::CONTENT_TYPE, value); }
|
||||
if !item.mime_type.starts_with("image/") {
|
||||
let safe_name = item.filename.replace(['\"', '\r', '\n'], "_");
|
||||
if let Ok(value) = format!("attachment; filename=\"{safe_name}\"").parse() { response.headers_mut().insert(header::CONTENT_DISPOSITION, value); }
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
use super::domain::Attachment;
|
||||
use crate::domain::dto::attachment::AttachmentResponse;
|
||||
use crate::models::attachment;
|
||||
|
||||
pub fn to_response(_item: Attachment) -> AttachmentResponse {
|
||||
todo!()
|
||||
pub fn to_response(item: attachment::Model) -> AttachmentResponse {
|
||||
AttachmentResponse {
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
file_size: item.file_size,
|
||||
mime_type: item.mime_type,
|
||||
created_at: item.created_at,
|
||||
url: format!("/api/attachments/{}/file", item.id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
pub mod domain;
|
||||
pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use axum::{Router, routing::{get, post}, extract::DefaultBodyLimit};
|
||||
use crate::core::state::AppState;
|
||||
use super::handlers;
|
||||
|
||||
pub fn router() -> Router {
|
||||
pub fn secure_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/attachments",
|
||||
get(handlers::get_all).post(handlers::create),
|
||||
)
|
||||
.route(
|
||||
"/attachments/{id}",
|
||||
get(handlers::get_by_id)
|
||||
.put(handlers::update)
|
||||
.delete(handlers::delete),
|
||||
)
|
||||
.route("/attachments", post(handlers::create))
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
}
|
||||
|
||||
pub fn public_router() -> Router<AppState> {
|
||||
Router::new().route("/attachments/{id}/file", get(handlers::file))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::{
|
||||
routes::emoji::mapper,
|
||||
services::emoji::EmojiService,
|
||||
};
|
||||
use crate::services::media;
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
@@ -126,9 +127,10 @@ pub async fn create(
|
||||
size = Some(bytes.len() as i64);
|
||||
let detected = detect_mime(&bytes)
|
||||
.ok_or_else(|| HTTPError::BadRequest("Unsupported or invalid image format".into()))?;
|
||||
let extension = media::extension_from_mime(&detected);
|
||||
mime = Some(detected);
|
||||
let (p, h) =
|
||||
EmojiService::save_asset(std::path::Path::new(&state.config.media.root), id, &bytes)
|
||||
EmojiService::save_asset(std::path::Path::new(&state.config.media.root), id, &bytes, extension)
|
||||
.await?;
|
||||
path = Some(p);
|
||||
sha = Some(h);
|
||||
|
||||
@@ -6,11 +6,13 @@ use crate::models::user::Model as User;
|
||||
use crate::routes::category::mapper::category_model_to_category_response;
|
||||
use crate::routes::channel::mapper::channel_model_to_channel_response;
|
||||
use crate::routes::message::mapper::{
|
||||
message_model_to_message_response_with_data,
|
||||
message_model_to_message_response_with_reactions,
|
||||
message_model_to_message_response_with_server_id, reaction_model_to_response,
|
||||
reaction_model_to_response,
|
||||
};
|
||||
use crate::routes::server::mapper::server_model_to_server_response;
|
||||
use crate::services::Services;
|
||||
use crate::repositories::Repositories;
|
||||
use axum::extract::ws::Message;
|
||||
use event_bus::EventBus;
|
||||
use events::GatewayEvent;
|
||||
@@ -29,6 +31,7 @@ pub mod routes;
|
||||
pub struct GatewayManager {
|
||||
pub clients: RwLock<HashMap<ConnectionKey, GatewayClient>>,
|
||||
services: Arc<Services>,
|
||||
repositories: Arc<Repositories>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -47,10 +50,11 @@ pub struct GatewayClient {
|
||||
}
|
||||
|
||||
impl GatewayManager {
|
||||
pub fn new(services: Arc<Services>) -> Self {
|
||||
pub fn new(services: Arc<Services>, repositories: Arc<Repositories>) -> Self {
|
||||
Self {
|
||||
clients: RwLock::new(HashMap::new()),
|
||||
services,
|
||||
repositories,
|
||||
}
|
||||
}
|
||||
/// Démarre les routeurs centraux des événements de messages.
|
||||
@@ -59,13 +63,19 @@ impl GatewayManager {
|
||||
event_bus.on_async::<MessageCreatedEvent, _, _>("message_created", move |event| {
|
||||
let manager = Arc::clone(&manager);
|
||||
async move {
|
||||
let message_id = event.message.id;
|
||||
let attachments = manager
|
||||
.repositories
|
||||
.message
|
||||
.attachments_for_messages(&[message_id])
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut items| items.remove(&message_id))
|
||||
.unwrap_or_default();
|
||||
manager.broadcast_message(
|
||||
event.channel_id,
|
||||
"add",
|
||||
message_model_to_message_response_with_server_id(
|
||||
event.message,
|
||||
event.server_id,
|
||||
),
|
||||
message_model_to_message_response_with_data(event.message, event.server_id, Vec::new(), attachments),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ pub async fn get_all(
|
||||
.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);
|
||||
|
||||
@@ -76,7 +77,8 @@ pub async fn get_all(
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
let groups = reactions.remove(&message.id).unwrap_or_default();
|
||||
mapper::message_model_to_message_response_with_reactions(message, None, groups)
|
||||
let files = attachments.remove(&message.id).unwrap_or_default();
|
||||
mapper::message_model_to_message_response_with_data(message, None, groups, files)
|
||||
})
|
||||
.collect(),
|
||||
oldest_id,
|
||||
@@ -122,9 +124,10 @@ pub async fn get_by_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_reactions(message, None, reactions),
|
||||
mapper::message_model_to_message_response_with_data(message, None, reactions, attachments),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -159,6 +162,10 @@ pub async fn create(
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
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 {
|
||||
state
|
||||
@@ -174,13 +181,17 @@ pub async fn create(
|
||||
let message = state
|
||||
.services
|
||||
.message
|
||||
.create_message(payload.channel_id, user.id, payload.content)
|
||||
.await?;
|
||||
.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()))?;
|
||||
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_server_id(
|
||||
Json(mapper::message_model_to_message_response_with_data(
|
||||
message,
|
||||
channel.server_id,
|
||||
Vec::new(),
|
||||
attachments,
|
||||
)),
|
||||
))
|
||||
}
|
||||
@@ -236,8 +247,9 @@ pub async fn update(
|
||||
.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_reactions(message, None, reactions),
|
||||
mapper::message_model_to_message_response_with_data(message, None, reactions, attachments),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -345,7 +357,22 @@ pub async fn delete(
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::domain::dto::message::{
|
||||
use crate::domain::dto::reaction::ReactionGroupResponse;
|
||||
use crate::domain::dto::reaction::ReactionResponse;
|
||||
use crate::models::message;
|
||||
use crate::models::attachment;
|
||||
use crate::models::message_reaction;
|
||||
use crate::repositories::types::MessageFilter;
|
||||
use chrono::Utc;
|
||||
@@ -18,13 +19,14 @@ pub fn message_model_to_message_response_with_server_id(
|
||||
model: message::Model,
|
||||
server_id: Option<Uuid>,
|
||||
) -> MessageResponse {
|
||||
message_model_to_message_response_with_reactions(model, server_id, Vec::new())
|
||||
message_model_to_message_response_with_data(model, server_id, Vec::new(), Vec::new())
|
||||
}
|
||||
|
||||
pub fn message_model_to_message_response_with_reactions(
|
||||
pub fn message_model_to_message_response_with_data(
|
||||
model: message::Model,
|
||||
server_id: Option<Uuid>,
|
||||
reactions: Vec<ReactionGroupResponse>,
|
||||
attachments: Vec<attachment::Model>,
|
||||
) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: model.id,
|
||||
@@ -36,9 +38,18 @@ pub fn message_model_to_message_response_with_reactions(
|
||||
updated_at: model.updated_at,
|
||||
reply_to_id: model.reply_to_id,
|
||||
reactions,
|
||||
attachments: attachments.into_iter().map(crate::routes::attachment::mapper::to_response).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_model_to_message_response_with_reactions(
|
||||
model: message::Model,
|
||||
server_id: Option<Uuid>,
|
||||
reactions: Vec<ReactionGroupResponse>,
|
||||
) -> MessageResponse {
|
||||
message_model_to_message_response_with_data(model, server_id, reactions, Vec::new())
|
||||
}
|
||||
|
||||
pub fn create_request_to_am(user_id: Uuid, payload: CreateMessageRequest) -> message::ActiveModel {
|
||||
message::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
|
||||
@@ -27,6 +27,7 @@ pub fn router() -> OxRouter {
|
||||
.merge(conversation::routes::router())
|
||||
.merge(role::routes::router())
|
||||
.merge(message::routes::router())
|
||||
.merge(attachment::routes::secure_router())
|
||||
.merge(user::routes::router())
|
||||
.merge(emoji::routes::router())
|
||||
.layer(axum_middleware::from_fn(middleware::require_auth));
|
||||
@@ -36,11 +37,13 @@ pub fn router() -> OxRouter {
|
||||
.merge(secure_routes)
|
||||
.merge(auth::routes::router())
|
||||
.merge(core::routes::router());
|
||||
let public_attachment_routes = attachment::routes::public_router();
|
||||
|
||||
let ws_routes = Router::new().merge(gateway::routes::router());
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api_routes)
|
||||
.nest("/api", public_attachment_routes)
|
||||
.nest("/ws", ws_routes)
|
||||
.merge(SwaggerUi::new("/swagger").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()))
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ use utoipa::{Modify, OpenApi};
|
||||
message::handlers::delete,
|
||||
message::handlers::add_reaction,
|
||||
message::handlers::remove_reaction,
|
||||
attachment::handlers::create,
|
||||
core::handlers::join,
|
||||
emoji::handlers::get_all,
|
||||
emoji::handlers::get_by_id,
|
||||
@@ -85,6 +86,8 @@ use utoipa::{Modify, OpenApi};
|
||||
crate::domain::dto::reaction::CreateReactionRequest,
|
||||
crate::domain::dto::reaction::ReactionResponse,
|
||||
crate::domain::dto::reaction::ReactionGroupResponse,
|
||||
crate::domain::dto::attachment::AttachmentResponse,
|
||||
crate::domain::dto::attachment::AttachmentUploadResponse,
|
||||
crate::domain::dto::core::JoinRequest,
|
||||
ChannelType,
|
||||
crate::domain::dto::emoji::EmojiResponse,
|
||||
@@ -102,6 +105,7 @@ use utoipa::{Modify, OpenApi};
|
||||
(name = "Channels", description = "Gestion des salons"),
|
||||
(name = "roles", description = "Gestion des rolees"),
|
||||
(name = "Messages", description = "Gestion des messages"),
|
||||
(name = "Attachments", description = "Upload et téléchargement des pièces jointes"),
|
||||
(name = "Core", description = "Endpoints de base (enregistrement, etc.)"),
|
||||
(name = "Emojis", description = "Gestion des emojis Unicode et personnalisés"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user