From 9016889a7842a61860625d7b03c2089ace2e5f56 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sun, 15 Feb 2026 16:55:51 +0700 Subject: [PATCH] added file audio video and files --- config.json | 3 +- src/api.rs | 1 + src/api/messages/delete.rs | 49 ++++++ src/api/upload.rs | 296 +++++++++++++++++++++++++++++++++++++ src/conf.rs | 4 + src/main.rs | 4 + src/model.rs | 162 +++++++++++++++++++- src/utils.rs | 22 ++- web/css/index.css | 164 ++++++++++++++++++++ web/index.html | 13 +- web/js/messages.js | 277 +++++++++++++++++++++++++++++++++- 11 files changed, 988 insertions(+), 7 deletions(-) create mode 100644 src/api/upload.rs diff --git a/config.json b/config.json index 2355b4a..d3c141a 100644 --- a/config.json +++ b/config.json @@ -10,7 +10,8 @@ "files": { "avatars_dir": "web/avatars", "web_dir": "web", - "max_avatar_size": 5242880 + "max_avatar_size": 5242880, + "upload_dir": "web/uploads" }, "limits": { "max_message_length": 4096, diff --git a/src/api.rs b/src/api.rs index 3a0b5ab..a6ff3b4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -6,4 +6,5 @@ pub mod profile; pub mod register; pub mod search; pub mod security; +pub mod upload; pub mod websock; diff --git a/src/api/messages/delete.rs b/src/api/messages/delete.rs index 6318ec7..33e0559 100644 --- a/src/api/messages/delete.rs +++ b/src/api/messages/delete.rs @@ -4,6 +4,7 @@ use crate::utils; use axum::extract::{Json, State, ws::Message}; use sqlx::Row; use std::sync::Arc; +use tokio::fs; // Delete message endpoint pub async fn delete_message_handler( @@ -66,6 +67,32 @@ pub async fn delete_message_handler( }); } + // Получаем тип сообщения и его содержимое + let message_details = sqlx::query("SELECT message_type, content FROM messages WHERE id = ?") + .bind(&payload.message_id) + .fetch_optional(&state.db_pool) + .await; + + let (message_type, content) = match message_details { + Ok(Some(row)) => ( + row.get::("message_type"), + row.get::("content"), + ), + Ok(None) => { + return Json(model::DeleteMessageResponse { + success: false, + message: "Message not found".to_string(), + }); + } + Err(e) => { + eprintln!("Error fetching message details: {}", e); + return Json(model::DeleteMessageResponse { + success: false, + message: "Database error".to_string(), + }); + } + }; + if payload.for_everyone { // Удаление для всех if from_user != username { @@ -75,6 +102,28 @@ pub async fn delete_message_handler( }); } + // Если это сообщение с файлом, удаляем физический файл + if message_type != "text" { + // Создаем временную структуру для извлечения пути к файлу + #[derive(serde::Deserialize)] + struct FilePathExtractor { + filepath: String, + } + + if let Ok(file_extractor) = serde_json::from_str::(&content) { + let file_path = + format!("{}{}", state.config.files.web_dir, file_extractor.filepath); + + // Пытаемся удалить файл из файловой системы + if let Err(e) = fs::remove_file(&file_path).await { + eprintln!("Error deleting file {}: {}", file_path, e); + // Продолжаем выполнение, даже если удаление файла не удалось + } else { + println!("Deleted file: {}", file_path); + } + } + } + let result = sqlx::query("UPDATE messages SET deleted_for_everyone = TRUE WHERE id = ?") .bind(&payload.message_id) .execute(&state.db_pool) diff --git a/src/api/upload.rs b/src/api/upload.rs new file mode 100644 index 0000000..a81eaee --- /dev/null +++ b/src/api/upload.rs @@ -0,0 +1,296 @@ +use crate::model::{AudioInfo, FileInfo, PhotoInfo, VideoInfo}; +use crate::state; +use crate::utils; +use axum::extract::{Multipart, State}; +use axum::response::Json; +use image::GenericImageView; +use std::path::Path; +use std::sync::Arc; +use tokio::fs; +use tokio::io::AsyncWriteExt; + +pub async fn upload_file_handler( + State(state): State>, + headers: axum::http::HeaderMap, + mut multipart: Multipart, +) -> Json { + // Extract token from header + let token = if let Some(auth_header) = headers.get("authorization") { + auth_header.to_str().unwrap_or("").replace("Bearer ", "") + } else { + return Json(serde_json::json!({"success": false, "error": "No token"})); + }; + + // Get current user from token + match utils::verify_token(&state.db_pool, &token).await { + Ok(Some(_user)) => {} // We verify the token is valid, but don't need the username for file upload + _ => return Json(serde_json::json!({"success": false, "error": "Invalid token"})), + }; + + // Process uploaded file + while let Some(field) = match multipart.next_field().await { + Ok(field_opt) => field_opt, + Err(e) => { + eprintln!("Error reading multipart field: {}", e); + return Json( + serde_json::json!({"success": false, "error": "Error reading multipart data"}), + ); + } + } { + let name = field.name().unwrap_or("unknown").to_string(); + if name == "file" { + // Get field properties before consuming the field + let filename = field.file_name().unwrap_or("unknown").to_string(); + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + + let file_data = match field.bytes().await { + Ok(data) => { + if data.is_empty() { + eprintln!( + "Received empty file data for file: {}, content-type: {}", + filename, content_type + ); + return Json( + serde_json::json!({"success": false, "error": "Empty file received"}), + ); + } + + // Check file size against the configured limit + if data.len() > state.config.files.max_upload_size { + eprintln!( + "File {} exceeds size limit. Size: {} bytes, Limit: {} bytes", + filename, + data.len(), + state.config.files.max_upload_size + ); + return Json( + serde_json::json!({"success": false, "error": "File exceeds size limit"}), + ); + } + + eprintln!( + "Successfully read {} bytes for file: {}, content-type: {}", + data.len(), + filename, + content_type + ); + data + } + Err(e) => { + eprintln!( + "Error reading file data for {}: {} - Request may not contain proper file data", + filename, e + ); + eprintln!("Content-Type: {}, Filename: {}", content_type, filename); + return Json( + serde_json::json!({"success": false, "error": format!("No valid file data found in request for {}", filename)}), + ); + } + }; + + // Determine file type and validate + let file_type = determine_file_type(&content_type, &filename); + + if file_type.is_none() { + return Json( + serde_json::json!({"success": false, "error": "Unsupported file type"}), + ); + } + + // Generate unique filename to prevent conflicts + let ext = Path::new(&filename) + .extension() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or("tmp"); + let unique_filename = format!( + "{}_{}.{}", + chrono::Utc::now().timestamp(), + uuid::Uuid::new_v4().simple(), + ext + ); + + // Create upload directory if it doesn't exist + let upload_dir = &state.config.files.upload_dir; + if let Err(e) = fs::create_dir_all(upload_dir).await { + eprintln!("Error creating upload directory: {}", e); + return Json( + serde_json::json!({"success": false, "error": "Error creating upload directory"}), + ); + } + + // Save file + let filepath = format!("{}/{}", upload_dir, unique_filename); + eprintln!("Saving file to: {}", filepath); + let filepathweb = format!("/uploads/{}", unique_filename); // Web-accessible path - files are in web/uploads + let mut file = match fs::File::create(&filepath).await { + Ok(f) => f, + Err(e) => { + eprintln!("Error creating file: {}", e); + return Json( + serde_json::json!({"success": false, "error": "Error creating file"}), + ); + } + }; + if let Err(e) = file.write_all(&file_data).await { + eprintln!("Error writing file: {}", e); + return Json(serde_json::json!({"success": false, "error": "Error writing file"})); + } + drop(file); // Close the file + + // Get file size + let metadata = match fs::metadata(&filepath).await { + Ok(m) => m, + Err(e) => { + eprintln!("Error getting file metadata: {}", e); + return Json( + serde_json::json!({"success": false, "error": "Error getting file metadata"}), + ); + } + }; + let size = metadata.len(); + + // Prepare response based on file type + let response = match file_type.unwrap() { + FileType::Image => { + // Try to get image dimensions + let img_result = image::open(&filepath); + let (width, height) = if let Ok(img) = img_result { + let (w, h) = img.dimensions(); + (w, h) + } else { + (0, 0) // fallback if we can't read dimensions + }; + + let photo_info = PhotoInfo { + filename: filename.clone(), + filepath: filepathweb.clone(), + size, + width, + height, + mime_type: content_type.clone(), + }; + + Json(serde_json::json!({ + "success": true, + "file_type": "photo", + "info": photo_info + })) + } + FileType::Video => { + // For now, we'll just return basic info for videos + // In a real implementation, you might want to extract duration using ffmpeg + let video_info = VideoInfo { + filename: filename.clone(), + filepath: filepathweb.clone(), + size, + duration: None, // Duration extraction would require additional libraries + mime_type: content_type.clone(), + }; + + Json(serde_json::json!({ + "success": true, + "file_type": "video", + "info": video_info + })) + } + FileType::Audio => { + // For now, we'll just return basic info for audio + // In a real implementation, you might want to extract duration using ffmpeg + let audio_info = AudioInfo { + filename: filename.clone(), + filepath: filepathweb.clone(), + size, + duration: None, // Duration extraction would require additional libraries + mime_type: content_type.clone(), + }; + + Json(serde_json::json!({ + "success": true, + "file_type": "audio", + "info": audio_info + })) + } + FileType::Generic => { + let file_info = FileInfo { + filename: filename.clone(), + filepath: filepathweb.clone(), + size, + mime_type: content_type.clone(), + }; + + Json(serde_json::json!({ + "success": true, + "file_type": "file", + "info": file_info + })) + } + }; + return response; // Return the response from the function + } + } + + Json(serde_json::json!({"success": false, "error": "No file found"})) +} + +enum FileType { + Image, + Video, + Audio, + Generic, +} + +fn determine_file_type(content_type: &str, filename: &str) -> Option { + let lower_ct = content_type.to_lowercase(); + let lower_fn = filename.to_lowercase(); + + // Check content type first + if lower_ct.starts_with("image/") { + return Some(FileType::Image); + } else if lower_ct.starts_with("video/") { + return Some(FileType::Video); + } else if lower_ct.starts_with("audio/") { + return Some(FileType::Audio); + } + + // Fallback to file extension - expanded list of supported formats + if lower_fn.ends_with(".jpg") + || lower_fn.ends_with(".jpeg") + || lower_fn.ends_with(".png") + || lower_fn.ends_with(".gif") + || lower_fn.ends_with(".webp") + || lower_fn.ends_with(".bmp") + || lower_fn.ends_with(".svg") + || lower_fn.ends_with(".tiff") + || lower_fn.ends_with(".tif") + { + return Some(FileType::Image); + } else if lower_fn.ends_with(".mp4") + || lower_fn.ends_with(".avi") + || lower_fn.ends_with(".mov") + || lower_fn.ends_with(".mkv") + || lower_fn.ends_with(".webm") + || lower_fn.ends_with(".wmv") + || lower_fn.ends_with(".flv") + || lower_fn.ends_with(".m4v") + || lower_fn.ends_with(".3gp") + || lower_fn.ends_with(".mpeg") + || lower_fn.ends_with(".mpg") + { + return Some(FileType::Video); + } else if lower_fn.ends_with(".mp3") + || lower_fn.ends_with(".wav") + || lower_fn.ends_with(".ogg") + || lower_fn.ends_with(".flac") + || lower_fn.ends_with(".m4a") + || lower_fn.ends_with(".aac") + || lower_fn.ends_with(".wma") + || lower_fn.ends_with(".opus") + { + return Some(FileType::Audio); + } + + Some(FileType::Generic) +} diff --git a/src/conf.rs b/src/conf.rs index 8b57501..626e541 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -26,7 +26,9 @@ pub struct DatabaseConfig { pub struct FilesConfig { pub avatars_dir: String, pub web_dir: String, + pub upload_dir: String, pub max_avatar_size: usize, + pub max_upload_size: usize, } #[derive(Debug, Deserialize, Clone)] @@ -53,7 +55,9 @@ impl AppConfig { [files] avatars_dir = "web/avatars" web_dir = "web" + upload_dir = "uploads" max_avatar_size = 5242880 + max_upload_size = 52428800 # 50MB [limits] max_message_length = 4096 diff --git a/src/main.rs b/src/main.rs index fc9d0fd..0c7e6fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use axum::{ Router, + extract::DefaultBodyLimit, routing::{get, post}, }; use std::{collections::HashMap, net::SocketAddr, sync::Arc}; @@ -25,6 +26,7 @@ async fn main() -> Result<(), Box> { // Create directories if they don't exist tokio::fs::create_dir_all(&config.files.avatars_dir).await?; tokio::fs::create_dir_all(&config.files.web_dir).await?; + tokio::fs::create_dir_all(&config.files.upload_dir).await?; // Initialize database with configured URL let db_pool = db::init_db(&config.database.url).await?; @@ -60,8 +62,10 @@ async fn main() -> Result<(), Box> { "/api/upload-avatar", post(api::profile::avatar::upload_avatar_handler), ) + .route("/api/upload", post(api::upload::upload_file_handler)) .route("/api/profile", get(api::profile::get::profile_handler)) .nest_service("/", ServeDir::new(&state.config.files.web_dir)) + .layer(DefaultBodyLimit::max(state.config.files.max_upload_size)) .with_state(state.clone()); let addr = format!("{}:{}", state.config.server.host, state.config.server.port) diff --git a/src/model.rs b/src/model.rs index 90522a3..97b276a 100644 --- a/src/model.rs +++ b/src/model.rs @@ -82,12 +82,170 @@ pub struct UserProfile { pub avatar_url: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum MessageType { + #[serde(rename = "text")] + Text, + #[serde(rename = "file")] + File, + #[serde(rename = "photo")] + Photo, + #[serde(rename = "video")] + Video, + #[serde(rename = "audio")] + Audio, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ChatMessage { pub id: String, pub from_user: String, pub to_user: String, - pub content: String, + pub content: String, // Store serialized content as JSON string pub timestamp: i64, - pub message_type: String, + pub message_type: MessageType, +} + +// impl ChatMessage { +// pub fn new_text(from_user: String, to_user: String, text: String) -> Self { +// ChatMessage { +// id: uuid::Uuid::new_v4().to_string(), +// from_user, +// to_user, +// content: text, +// timestamp: chrono::Utc::now().timestamp(), +// message_type: MessageType::Text, +// } +// } + +// pub fn new_file(from_user: String, to_user: String, file_info: FileInfo) -> Self { +// ChatMessage { +// id: uuid::Uuid::new_v4().to_string(), +// from_user, +// to_user, +// content: serde_json::to_string(&file_info).unwrap_or_default(), +// timestamp: chrono::Utc::now().timestamp(), +// message_type: MessageType::File, +// } +// } + +// pub fn new_photo(from_user: String, to_user: String, photo_info: PhotoInfo) -> Self { +// ChatMessage { +// id: uuid::Uuid::new_v4().to_string(), +// from_user, +// to_user, +// content: serde_json::to_string(&photo_info).unwrap_or_default(), +// timestamp: chrono::Utc::now().timestamp(), +// message_type: MessageType::Photo, +// } +// } + +// pub fn new_video(from_user: String, to_user: String, video_info: VideoInfo) -> Self { +// ChatMessage { +// id: uuid::Uuid::new_v4().to_string(), +// from_user, +// to_user, +// content: serde_json::to_string(&video_info).unwrap_or_default(), +// timestamp: chrono::Utc::now().timestamp(), +// message_type: MessageType::Video, +// } +// } + +// pub fn new_audio(from_user: String, to_user: String, audio_info: AudioInfo) -> Self { +// ChatMessage { +// id: uuid::Uuid::new_v4().to_string(), +// from_user, +// to_user, +// content: serde_json::to_string(&audio_info).unwrap_or_default(), +// timestamp: chrono::Utc::now().timestamp(), +// message_type: MessageType::Audio, +// } +// } + +// pub fn get_content_as_text(&self) -> Option { +// match self.message_type { +// MessageType::Text => Some(self.content.clone()), +// _ => None, +// } +// } + +// pub fn get_content_as_file(&self) -> Option { +// if matches!(self.message_type, MessageType::File) { +// serde_json::from_str(&self.content).ok() +// } else { +// None +// } +// } + +// pub fn get_content_as_photo(&self) -> Option { +// if matches!(self.message_type, MessageType::Photo) { +// serde_json::from_str(&self.content).ok() +// } else { +// None +// } +// } + +// pub fn get_content_as_video(&self) -> Option { +// if matches!(self.message_type, MessageType::Video) { +// serde_json::from_str(&self.content).ok() +// } else { +// None +// } +// } + +// pub fn get_content_as_audio(&self) -> Option { +// if matches!(self.message_type, MessageType::Audio) { +// serde_json::from_str(&self.content).ok() +// } else { +// None +// } +// } +// } + +// #[derive(Debug, Serialize, Deserialize, Clone)] +// pub enum MessageContent { +// #[serde(rename = "text")] +// Text(String), +// #[serde(rename = "file")] +// File(FileInfo), +// #[serde(rename = "photo")] +// Photo(PhotoInfo), +// #[serde(rename = "video")] +// Video(VideoInfo), +// } + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FileInfo { + pub filename: String, + pub filepath: String, + pub size: u64, + pub mime_type: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PhotoInfo { + pub filename: String, + pub filepath: String, + pub size: u64, + pub width: u32, + pub height: u32, + pub mime_type: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct VideoInfo { + pub filename: String, + pub filepath: String, + pub size: u64, + pub duration: Option, // in seconds + pub mime_type: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AudioInfo { + pub filename: String, + pub filepath: String, + pub size: u64, + pub duration: Option, // in seconds + pub mime_type: String, } diff --git a/src/utils.rs b/src/utils.rs index f7a56cd..8a9d99b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -121,13 +121,23 @@ pub async fn get_message_history( let mut messages = Vec::new(); for row in rows { + let message_type_str: String = row.get("message_type"); + let message_type: model::MessageType = match message_type_str.as_str() { + "text" => model::MessageType::Text, + "file" => model::MessageType::File, + "photo" => model::MessageType::Photo, + "video" => model::MessageType::Video, + "audio" => model::MessageType::Audio, + _ => model::MessageType::Text, // Default fallback + }; + let message = model::ChatMessage { id: row.get("id"), from_user: row.get("from_user"), to_user: row.get("to_user"), content: row.get("content"), timestamp: row.get("timestamp"), - message_type: row.get("message_type"), + message_type, }; messages.push(message); } @@ -148,6 +158,14 @@ pub async fn get_message_history( // } pub async fn save_message(pool: &SqlitePool, msg: &model::ChatMessage) -> Result<(), sqlx::Error> { + let message_type_str = match msg.message_type { + model::MessageType::Text => "text", + model::MessageType::File => "file", + model::MessageType::Photo => "photo", + model::MessageType::Video => "video", + model::MessageType::Audio => "audio", + }; + sqlx::query( "INSERT OR REPLACE INTO messages (id, from_user, to_user, content, timestamp, message_type) VALUES (?, ?, ?, ?, ?, ?)", ) @@ -156,7 +174,7 @@ pub async fn save_message(pool: &SqlitePool, msg: &model::ChatMessage) -> Result .bind(&msg.to_user) .bind(&msg.content) .bind(msg.timestamp) - .bind(&msg.message_type) + .bind(message_type_str) .execute(pool) .await?; diff --git a/web/css/index.css b/web/css/index.css index 20471a5..03dd500 100644 --- a/web/css/index.css +++ b/web/css/index.css @@ -1285,3 +1285,167 @@ body.modal-open { .modal-btn-delete-for-me:hover { background: var(--primary-hover); } + +/* File message styles */ +.file-message { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + background: var(--bg-tertiary); + border-radius: 8px; + max-width: 300px; +} + +.file-icon { + font-size: 24px; +} + +.file-info { + flex: 1; + min-width: 0; +} + +.file-name { + font-weight: 500; + font-size: 13px; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-size, +.file-type { + font-size: 11px; + color: var(--text-secondary); +} + +.download-btn { + background: var(--primary-color); + color: white; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + cursor: pointer; + text-decoration: none; + display: inline-block; + margin-top: 5px; +} + +.download-btn:hover { + background: var(--primary-hover); +} + +/* Media message styles */ +.media-message { + display: flex; + flex-direction: column; + gap: 5px; + max-width: 300px; +} + +.message-image { + max-width: 100%; + max-height: 200px; + border-radius: 8px; + cursor: pointer; + object-fit: cover; +} + +.message-video { + max-width: 100%; + max-height: 200px; + border-radius: 8px; +} + +.message-audio { + max-width: 100%; + border-radius: 8px; +} + +/* Image viewer modal */ +.image-viewer-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; +} + +.image-viewer-content { + position: relative; + max-width: 90%; + max-height: 90%; +} + +.image-viewer-large { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.close-image-viewer { + position: absolute; + top: -40px; + right: 0; + color: white; + font-size: 35px; + font-weight: bold; + cursor: pointer; + z-index: 2001; +} + +.close-image-viewer:hover { + color: #ccc; +} + +/* Upload progress styles */ +.upload-progress { + padding: 10px; + margin: 5px 0; + background: var(--bg-tertiary); + border-radius: 10px; + align-self: flex-start; + max-width: 85%; +} + +.progress-text { + font-size: 12px; + margin-bottom: 5px; + color: var(--text-secondary); +} + +.progress-bar { + width: 100%; + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--primary-color); + width: 0%; + transition: width 0.3s ease; + animation: progressAnimation 2s infinite; +} + +@keyframes progressAnimation { + 0% { + width: 0%; + } + 50% { + width: 100%; + } + 100% { + width: 0%; + } +} diff --git a/web/index.html b/web/index.html index 5fbc485..1e1df19 100644 --- a/web/index.html +++ b/web/index.html @@ -194,7 +194,18 @@ disabled rows="1" > - + +