added file audio video and files
This commit is contained in:
1 parent
8cb6143bac
commit
9016889a78
11 files changed
+988
-7
No files matched your search
+2
-1
@@ -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,
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod profile;
|
||||
pub mod register;
|
||||
pub mod search;
|
||||
pub mod security;
|
||||
pub mod upload;
|
||||
pub mod websock;
|
||||
@@ -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::<String, _>("message_type"),
|
||||
row.get::<String, _>("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::<FilePathExtractor>(&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)
|
||||
|
||||
@@ -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<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Json<serde_json::Value> {
|
||||
// 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<FileType> {
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
// 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<dyn std::error::Error>> {
|
||||
"/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)
|
||||
|
||||
+160
-2
@@ -82,12 +82,170 @@ pub struct UserProfile {
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
// match self.message_type {
|
||||
// MessageType::Text => Some(self.content.clone()),
|
||||
// _ => None,
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub fn get_content_as_file(&self) -> Option<FileInfo> {
|
||||
// if matches!(self.message_type, MessageType::File) {
|
||||
// serde_json::from_str(&self.content).ok()
|
||||
// } else {
|
||||
// None
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub fn get_content_as_photo(&self) -> Option<PhotoInfo> {
|
||||
// if matches!(self.message_type, MessageType::Photo) {
|
||||
// serde_json::from_str(&self.content).ok()
|
||||
// } else {
|
||||
// None
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub fn get_content_as_video(&self) -> Option<VideoInfo> {
|
||||
// if matches!(self.message_type, MessageType::Video) {
|
||||
// serde_json::from_str(&self.content).ok()
|
||||
// } else {
|
||||
// None
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub fn get_content_as_audio(&self) -> Option<AudioInfo> {
|
||||
// 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<f64>, // 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<f64>, // in seconds
|
||||
pub mime_type: String,
|
||||
}
|
||||
+20
-2
@@ -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?;
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -194,7 +194,18 @@
|
||||
disabled
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button class="add-button">+</button>
|
||||
<input
|
||||
type="file"
|
||||
id="fileInput"
|
||||
class="file-input"
|
||||
style="display: none"
|
||||
/>
|
||||
<button
|
||||
class="add-button"
|
||||
onclick="document.getElementById('fileInput').click()"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onclick="sendMessage()"
|
||||
class="send-button"
|
||||
|
||||
+276
-1
@@ -142,7 +142,67 @@ function displayMessage(msg, scrollToBottom) {
|
||||
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "message-content";
|
||||
contentDiv.textContent = msg.content;
|
||||
|
||||
// Handle different message types
|
||||
if (msg.message_type === "text") {
|
||||
contentDiv.textContent = msg.content;
|
||||
} else if (msg.message_type === "file") {
|
||||
const fileInfo = JSON.parse(msg.content);
|
||||
contentDiv.innerHTML = `
|
||||
<div class="file-message">
|
||||
<div class="file-icon">📄</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${fileInfo.filename}</div>
|
||||
<div class="file-size">${formatFileSize(fileInfo.size)}</div>
|
||||
<div class="file-type">${fileInfo.mime_type}</div>
|
||||
</div>
|
||||
<a href="${fileInfo.filepath}" download="${fileInfo.filename}" class="download-btn">⬇️ Download</a>
|
||||
</div>
|
||||
`;
|
||||
} else if (msg.message_type === "photo") {
|
||||
const photoInfo = JSON.parse(msg.content);
|
||||
contentDiv.innerHTML = `
|
||||
<div class="media-message">
|
||||
<img src="${photoInfo.filepath}" alt="Photo" class="message-image" onclick="viewImage('${photoInfo.filepath}')"/>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${photoInfo.filename}</div>
|
||||
<div class="file-size">${formatFileSize(photoInfo.size)}</div>
|
||||
</div>
|
||||
<a href="${photoInfo.filepath}" download="${photoInfo.filename}" class="download-btn">⬇️ Download</a>
|
||||
</div>
|
||||
`;
|
||||
} else if (msg.message_type === "video") {
|
||||
const videoInfo = JSON.parse(msg.content);
|
||||
contentDiv.innerHTML = `
|
||||
<div class="media-message">
|
||||
<video controls class="message-video">
|
||||
<source src="${videoInfo.filepath}" type="${videoInfo.mime_type}">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${videoInfo.filename}</div>
|
||||
<div class="file-size">${formatFileSize(videoInfo.size)}</div>
|
||||
</div>
|
||||
<a href="${videoInfo.filepath}" download="${videoInfo.filename}" class="download-btn">⬇️ Download</a>
|
||||
</div>
|
||||
`;
|
||||
} else if (msg.message_type === "audio") {
|
||||
const audioInfo = JSON.parse(msg.content);
|
||||
contentDiv.innerHTML = `
|
||||
<div class="media-message">
|
||||
<audio controls class="message-audio">
|
||||
<source src="${audioInfo.filepath}" type="${audioInfo.mime_type}">
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${audioInfo.filename}</div>
|
||||
<div class="file-size">${formatFileSize(audioInfo.size)}</div>
|
||||
</div>
|
||||
<a href="${audioInfo.filepath}" download="${audioInfo.filename}" class="download-btn">⬇️ Download</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
messageDiv.appendChild(contentDiv);
|
||||
|
||||
const timeDiv = document.createElement("div");
|
||||
@@ -182,6 +242,40 @@ function displayMessage(msg, scrollToBottom) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function viewImage(imageUrl) {
|
||||
// Create a modal to view the image
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "image-viewer-modal";
|
||||
modal.innerHTML = `
|
||||
<div class="image-viewer-content">
|
||||
<span class="close-image-viewer">×</span>
|
||||
<img src="${imageUrl}" alt="Full size image" class="image-viewer-large">
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Close modal when clicking on close button
|
||||
modal.querySelector(".close-image-viewer").onclick = function () {
|
||||
document.body.removeChild(modal);
|
||||
};
|
||||
|
||||
// Close modal when clicking outside the image
|
||||
modal.onclick = function (event) {
|
||||
if (event.target === modal) {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function addSystemMessage(content) {
|
||||
const chatMessages = document.getElementById("chatMessages");
|
||||
|
||||
@@ -234,6 +328,163 @@ function sendMessage() {
|
||||
setTimeout(() => loadChats(), 100);
|
||||
}
|
||||
|
||||
// Queue to handle file uploads sequentially
|
||||
let uploadQueue = [];
|
||||
let isUploading = false;
|
||||
|
||||
// Function to process the upload queue
|
||||
async function processUploadQueue() {
|
||||
if (isUploading || uploadQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
const file = uploadQueue.shift(); // Get the first file in the queue
|
||||
|
||||
try {
|
||||
await sendFileMessageInternal(file);
|
||||
} catch (error) {
|
||||
console.error("Upload failed for file:", error);
|
||||
} finally {
|
||||
isUploading = false;
|
||||
// Process next file in queue if available
|
||||
setTimeout(processUploadQueue, 100); // Small delay to allow server to settle
|
||||
}
|
||||
}
|
||||
|
||||
// Internal function to handle the actual file upload
|
||||
async function sendFileMessageInternal(file) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
showAlert("Please connect to the server first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentRecipient) {
|
||||
showAlert("Please select a user to chat with", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show upload progress
|
||||
const chatMessages = document.getElementById("chatMessages");
|
||||
const progressDiv = document.createElement("div");
|
||||
progressDiv.className = "upload-progress";
|
||||
progressDiv.innerHTML = `<div class="progress-text">Uploading ${file.name}</div><div class="progress-bar"><div class="progress-fill"></div></div>`;
|
||||
chatMessages.appendChild(progressDiv);
|
||||
|
||||
try {
|
||||
// Create FormData for file upload
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
// Upload file to server with better error handling
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
body: formData,
|
||||
}).catch((error) => {
|
||||
console.error("Network error during upload:", error);
|
||||
throw new Error("Network error during upload. Please try again.");
|
||||
});
|
||||
|
||||
// Remove progress indicator
|
||||
progressDiv.remove();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
// Create message object based on file type
|
||||
let message;
|
||||
let messageType;
|
||||
|
||||
if (result.file_type === "photo") {
|
||||
messageType = "photo";
|
||||
message = {
|
||||
id: generateId(),
|
||||
from_user: currentUser,
|
||||
to_user: currentRecipient,
|
||||
content: JSON.stringify(result.info),
|
||||
timestamp: Date.now(),
|
||||
message_type: messageType,
|
||||
};
|
||||
} else if (result.file_type === "video") {
|
||||
messageType = "video";
|
||||
message = {
|
||||
id: generateId(),
|
||||
from_user: currentUser,
|
||||
to_user: currentRecipient,
|
||||
content: JSON.stringify(result.info),
|
||||
timestamp: Date.now(),
|
||||
message_type: messageType,
|
||||
};
|
||||
} else if (result.file_type === "audio") {
|
||||
messageType = "audio";
|
||||
message = {
|
||||
id: generateId(),
|
||||
from_user: currentUser,
|
||||
to_user: currentRecipient,
|
||||
content: JSON.stringify(result.info),
|
||||
timestamp: Date.now(),
|
||||
message_type: messageType,
|
||||
};
|
||||
} else {
|
||||
messageType = "file";
|
||||
message = {
|
||||
id: generateId(),
|
||||
from_user: currentUser,
|
||||
to_user: currentRecipient,
|
||||
content: JSON.stringify(result.info),
|
||||
timestamp: Date.now(),
|
||||
message_type: messageType,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("Sending file message:", message);
|
||||
ws.send(JSON.stringify(message));
|
||||
|
||||
setTimeout(() => loadChats(), 100);
|
||||
} catch (error) {
|
||||
// Remove progress indicator
|
||||
progressDiv.remove();
|
||||
console.error("Upload error:", error);
|
||||
showAlert(`Failed to upload file: ${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Function to send a file message (adds to queue)
|
||||
async function sendFileMessage(file) {
|
||||
// Check if file is already in queue to prevent duplicates
|
||||
const fileName = file.name;
|
||||
const fileSize = file.size;
|
||||
const isInQueue = uploadQueue.some(
|
||||
(item) => item.name === fileName && item.size === fileSize,
|
||||
);
|
||||
|
||||
if (isInQueue) {
|
||||
console.log(
|
||||
`File ${fileName} is already in upload queue, skipping duplicate.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add file to upload queue
|
||||
uploadQueue.push(file);
|
||||
console.log(
|
||||
`File ${file.name} added to upload queue. Queue length: ${uploadQueue.length}`,
|
||||
);
|
||||
|
||||
// Process the queue
|
||||
setTimeout(processUploadQueue, 0);
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document
|
||||
.getElementById("messageInput")
|
||||
@@ -243,3 +494,27 @@ document
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// Track if event listener is already attached to prevent duplicates
|
||||
if (!window.fileInputListenerAttached) {
|
||||
// File input event listener
|
||||
document.getElementById("fileInput").addEventListener("change", function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Add file to upload queue regardless of current upload status
|
||||
// The queue system will handle the sequencing
|
||||
uploadQueue.push(file);
|
||||
console.log(
|
||||
`File ${file.name} added to upload queue. Queue length: ${uploadQueue.length}`,
|
||||
);
|
||||
|
||||
// Process the queue
|
||||
setTimeout(processUploadQueue, 0);
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
window.fileInputListenerAttached = true;
|
||||
}
|
||||
Reference in new issue
Block a user