This commit is contained in:
2026-08-23 16:02:17 +02:00
parent 8d57310ec6
commit 5e97e9c223
26 changed files with 818 additions and 77 deletions
+12 -1
View File
@@ -72,7 +72,7 @@ impl App {
services
.realtime_registry
.start_listening(repositories.clone(), event_bus.clone());
let gateway = Arc::new(GatewayManager::new(services.clone()));
let gateway = Arc::new(GatewayManager::new(services.clone(), repositories.clone()));
gateway.start(event_bus.clone());
let state = AppState {
@@ -95,6 +95,17 @@ impl App {
let config = self.state.config.clone();
let cleanup_state = self.state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600));
loop {
interval.tick().await;
if let Err(error) = crate::routes::attachment::handlers::cleanup_expired(&cleanup_state).await {
tracing::warn!(%error, "Attachment cleanup failed");
}
}
});
// Initialize HTTP Server
let (http_server, http_shutdown_tx) = HttpServer::new(&config.network, self.state.clone());
+1 -1
View File
@@ -45,7 +45,7 @@ impl Database {
}
connection
.execute_unprepared("PRAGMA wal_checkpoint;")
.execute_unprepared("PRAGMA wal_checkpoint(TRUNCATE);")
.await?;
Ok(())
+16 -7
View File
@@ -1,10 +1,19 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateAttachmentRequest {}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AttachmentResponse {
pub id: Uuid,
pub filename: String,
pub file_size: i64,
pub mime_type: String,
pub created_at: DateTime<Utc>,
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateAttachmentRequest {}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentResponse {}
#[derive(Debug, Serialize, ToSchema)]
pub struct AttachmentUploadResponse {
pub attachments: Vec<AttachmentResponse>,
}
+4
View File
@@ -1,4 +1,5 @@
use crate::domain::dto::reaction::ReactionGroupResponse;
use crate::domain::dto::attachment::AttachmentResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -15,6 +16,7 @@ pub struct MessageResponse {
pub updated_at: Option<DateTime<Utc>>,
pub reply_to_id: Option<Uuid>,
pub reactions: Vec<ReactionGroupResponse>,
pub attachments: Vec<AttachmentResponse>,
}
#[derive(Debug, Serialize, ToSchema)]
@@ -31,6 +33,8 @@ pub struct CreateMessageRequest {
pub channel_id: Uuid,
pub content: String,
pub reply_to_id: Option<Uuid>,
#[serde(default)]
pub file_ids: Vec<Uuid>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
+5 -2
View File
@@ -10,10 +10,13 @@ use sea_orm::prelude::async_trait::async_trait;
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub message_id: Uuid,
pub message_id: Option<Uuid>,
pub channel_id: Uuid,
pub user_id: Uuid,
pub filename: String,
pub file_size: i32,
pub file_size: i64,
pub mime_type: String,
pub file_path: String,
pub created_at: DateTimeUtc,
#[sea_orm(
belongs_to,
+22
View File
@@ -1,7 +1,9 @@
use super::types::MessageFilter;
use crate::models::message;
use crate::models::attachment;
use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
@@ -30,6 +32,26 @@ impl MessageRepository {
.await?)
}
pub async fn attachments_for_messages(
&self,
message_ids: &[Uuid],
) -> AnyResult<HashMap<Uuid, Vec<attachment::Model>>> {
if message_ids.is_empty() {
return Ok(HashMap::new());
}
let items = attachment::Entity::find()
.filter(attachment::Column::MessageId.is_in(message_ids.to_vec()))
.all(&self.context.db)
.await?;
let mut grouped = HashMap::new();
for item in items {
if let Some(message_id) = item.message_id {
grouped.entry(message_id).or_insert_with(Vec::new).push(item);
}
}
Ok(grouped)
}
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<MessagePage> {
let limit = filter
.limit
+103 -16
View File
@@ -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)
}
+10 -3
View File
@@ -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),
}
}
-2
View File
@@ -1,5 +1,3 @@
pub mod domain;
pub mod handlers;
pub mod mapper;
pub mod routes;
pub mod service;
+9 -13
View File
@@ -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))
}
+3 -1
View 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);
+16 -6
View File
@@ -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),
);
}
});
+33 -6
View File
@@ -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)
+13 -2
View File
@@ -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()),
+3
View File
@@ -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()))
}
+4
View File
@@ -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"),
)
+9 -7
View File
@@ -1,10 +1,11 @@
use crate::http::error::HTTPError;
use crate::models::emoji;
use crate::services::ServicesContext;
use crate::services::media::PendingMediaFile;
use sea_orm::{ActiveModelTrait, Set, TransactionTrait};
use sha2::{Digest, Sha256};
use std::{
path::{Path, PathBuf},
path::Path,
sync::Arc,
};
use tokio::fs;
@@ -85,17 +86,18 @@ impl EmojiService {
root: &Path,
id: Uuid,
data: &[u8],
extension: Option<&str>,
) -> Result<(String, String), HTTPError> {
let dir = root.join("emoji");
fs::create_dir_all(&dir)
let mut pending = PendingMediaFile::begin(root, "emoji", id, extension)
.await
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
let relative = PathBuf::from("emoji").join(id.to_string());
let path = root.join(&relative);
fs::write(&path, data)
pending.write(data)
.await
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
Ok((relative.to_string_lossy().into_owned(), Self::hash(data)))
let relative = pending.finish()
.await
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
Ok((relative, Self::hash(data)))
}
pub async fn remove_asset(root: &Path, path: Option<&str>) {
if let Some(path) = path {
+132
View File
@@ -0,0 +1,132 @@
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
pub struct PendingMediaFile {
file: File,
temporary_path: PathBuf,
final_path: PathBuf,
relative_final_path: String,
}
impl PendingMediaFile {
pub async fn begin(root: &Path, directory: &str, id: Uuid, extension: Option<&str>) -> std::io::Result<Self> {
let relative_directory = PathBuf::from(directory);
let directory_path = root.join(&relative_directory);
fs::create_dir_all(&directory_path).await?;
let uuid_name = id.to_string();
let temporary_name = format!("~{uuid_name}.part");
let final_name = match extension.filter(|value| !value.is_empty()) {
Some(extension) => format!("{uuid_name}.{extension}"),
None => uuid_name,
};
let temporary_path = directory_path.join(temporary_name);
let final_path = directory_path.join(&final_name);
let file = File::create(&temporary_path).await?;
Ok(Self {
file,
temporary_path,
final_path,
relative_final_path: relative_directory.join(final_name).to_string_lossy().into_owned(),
})
}
pub async fn write(&mut self, chunk: &[u8]) -> std::io::Result<()> {
self.file.write_all(chunk).await
}
pub async fn finish(mut self) -> std::io::Result<String> {
self.file.flush().await?;
self.file.sync_all().await?;
drop(self.file);
fs::rename(&self.temporary_path, &self.final_path).await?;
Ok(self.relative_final_path)
}
pub async fn remove_final(path: &Path) {
let _ = fs::remove_file(path).await;
}
}
pub fn extension_from_filename(filename: &str) -> Option<String> {
let name = Path::new(filename).file_name()?.to_str()?;
let lower = name.to_ascii_lowercase();
let extension = ["tar.gz", "tar.bz2", "tar.xz", "tar.zst"]
.iter()
.find(|candidate| lower.ends_with(&format!(".{candidate}")))
.map(|candidate| (*candidate).to_string())
.or_else(|| Path::new(name).extension().and_then(|value| value.to_str()).map(str::to_ascii_lowercase))?;
if extension.chars().all(|character| character.is_ascii_alphanumeric() || character == '.') {
Some(extension)
} else {
None
}
}
pub fn extension_from_mime(mime_type: &str) -> Option<&'static str> {
match mime_type {
"image/png" => Some("png"),
"image/gif" => Some("gif"),
"image/webp" => Some("webp"),
"image/jpeg" => Some("jpg"),
_ => None,
}
}
pub async fn cleanup_temporary_files(root: &Path, max_age: Duration) -> std::io::Result<()> {
let mut directories = vec![root.to_path_buf()];
let mut root_entries = match fs::read_dir(root).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
while let Some(entry) = root_entries.next_entry().await? {
if entry.file_type().await?.is_dir() {
directories.push(entry.path());
}
}
let now = SystemTime::now();
for directory in directories {
let mut entries = match fs::read_dir(&directory).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with('~') || !name.ends_with(".part") {
continue;
}
let modified = entry.metadata().await?.modified().unwrap_or(now);
if now.duration_since(modified).unwrap_or_default() > max_age {
let _ = fs::remove_file(entry.path()).await;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::extension_from_filename;
#[test]
fn preserves_compound_extensions() {
assert_eq!(extension_from_filename("archive.tar.gz").as_deref(), Some("tar.gz"));
}
#[test]
fn extracts_simple_extensions() {
assert_eq!(extension_from_filename("photo.PNG").as_deref(), Some("png"));
}
#[test]
fn returns_none_without_an_extension() {
assert_eq!(extension_from_filename("README").as_deref(), None);
}
}
+47 -2
View File
@@ -1,10 +1,10 @@
use crate::domain::events::message::{
MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
};
use crate::models::{channel, message};
use crate::models::{attachment, channel, message};
use crate::services::ServicesContext;
use event_bus::Scope;
use sea_orm::{ActiveModelTrait, EntityTrait, QuerySelect, Set, TransactionTrait};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait};
use std::sync::Arc;
use uuid::Uuid;
@@ -67,6 +67,51 @@ impl MessageService {
Ok(msg)
}
pub async fn create_message_with_attachments(
&self,
channel_id: Uuid,
author_id: Uuid,
content: String,
file_ids: Vec<Uuid>,
reply_to_id: Option<Uuid>,
) -> Result<message::Model, anyhow::Error> {
let db = &self.service_context.repositories.server.context.db;
let event_bus = &self.service_context.event_bus;
let txn = db.begin().await?;
let files = if file_ids.is_empty() {
Vec::new()
} else {
attachment::Entity::find()
.filter(attachment::Column::Id.is_in(file_ids.clone()))
.all(&txn)
.await?
};
if files.len() != file_ids.len()
|| files.iter().any(|file| file.channel_id != channel_id || file.user_id != author_id || file.message_id.is_some())
{
return Err(anyhow::anyhow!("Invalid or already used attachment"));
}
let msg = message::ActiveModel {
channel_id: Set(channel_id), user_id: Set(author_id), content: Set(content),
reply_to_id: Set(reply_to_id), ..Default::default()
}.insert(&txn).await?;
for file in files {
let mut active: attachment::ActiveModel = file.into();
active.message_id = Set(Some(msg.id));
active.update(&txn).await?;
}
txn.commit().await?;
let server_id = channel::Entity::find_by_id(msg.channel_id)
.select_only().column(channel::Column::ServerId)
.into_tuple::<Option<Uuid>>().one(db).await?.flatten();
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)];
if let Some(server_id) = server_id { scopes.push(Scope::uuid("server", server_id)); }
event_bus.emit_scoped("message_created", scopes, MessageCreatedEvent {
server_id, channel_id: msg.channel_id, message: msg.clone(),
});
Ok(msg)
}
pub async fn update_message(
&self,
id: Uuid,
+1
View File
@@ -18,6 +18,7 @@ pub mod channel;
pub mod emoji;
pub mod message;
pub mod message_reaction;
pub mod media;
mod permission;
pub mod permission_sync;
pub mod realtime_registry;