diff --git a/config.json b/config.json index 9baf180..2355b4a 100644 --- a/config.json +++ b/config.json @@ -1,20 +1,20 @@ { - "server": { - "host": "0.0.0.0", - "port": 3333, - "max_connections": 10000 - }, - "database": { - "url": "sqlite:chat.db" - }, - "files": { - "avatars_dir": "web/avatars", - "web_dir": "web", - "max_avatar_size": 5242880 - }, - "limits": { - "max_message_length": 4096, - "max_username_length": 50, - "min_password_length": 6 - } -} \ No newline at end of file + "server": { + "host": "0.0.0.0", + "port": 3333, + "max_connections": 10000 + }, + "database": { + "url": "chat.db" + }, + "files": { + "avatars_dir": "web/avatars", + "web_dir": "web", + "max_avatar_size": 5242880 + }, + "limits": { + "max_message_length": 4096, + "max_username_length": 50, + "min_password_length": 6 + } +} diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..3a0b5ab --- /dev/null +++ b/src/api.rs @@ -0,0 +1,9 @@ +pub mod chats; +pub mod debug; +pub mod login; +pub mod messages; +pub mod profile; +pub mod register; +pub mod search; +pub mod security; +pub mod websock; diff --git a/src/api/chats.rs b/src/api/chats.rs new file mode 100644 index 0000000..88ddfc9 --- /dev/null +++ b/src/api/chats.rs @@ -0,0 +1,214 @@ +use crate::model; +use crate::state; +use crate::utils; +use axum::extract::{Json, State}; +use sqlx::Row; +use std::sync::Arc; + +// УПРОЩЕННАЯ версия получения чатов +pub async fn get_chats_handler( + State(state): State>, + headers: axum::http::HeaderMap, +) -> 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(Vec::new()); + }; + + // Get current user from token + let username = match utils::verify_token(&state.db_pool, &token).await { + Ok(Some(user)) => user, + _ => return Json(Vec::new()), + }; + + println!("🔍 Fetching chats for user: {}", username); + + // ПРОСТОЙ И ПРЯМОЙ ЗАПРОС: находим всех пользователей, с которыми есть сообщения + let chat_partners = sqlx::query( + r#" + -- Находим всех уникальных собеседников + SELECT DISTINCT + CASE + WHEN from_user = ? THEN to_user + ELSE from_user + END as partner_username + FROM messages + WHERE (from_user = ? OR to_user = ?) + AND deleted_for_everyone = FALSE + AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE)) + "# + ) + .bind(&username) + .bind(&username) + .bind(&username) + .bind(&username) + .bind(&username) + .fetch_all(&state.db_pool) + .await; + + let partner_usernames = match chat_partners { + Ok(rows) => { + let partners: Vec = rows + .iter() + .map(|row| row.get::("partner_username")) + .collect(); + println!("🤝 Found partner usernames: {:?}", partners); + partners + } + Err(e) => { + eprintln!("❌ Error fetching partner usernames: {}", e); + Vec::new() + } + }; + + if partner_usernames.is_empty() { + println!("❌ No partners found for user {}", username); + + // Проверим, есть ли вообще сообщения у пользователя + let message_count = sqlx::query( + "SELECT COUNT(*) as count FROM messages WHERE (from_user = ? OR to_user = ?) AND deleted_for_everyone = FALSE" + ) + .bind(&username) + .bind(&username) + .fetch_one(&state.db_pool) + .await; + + match message_count { + Ok(row) => { + let count: i64 = row.get("count"); + println!( + "📊 User {} has {} total messages in database", + username, count + ); + } + Err(e) => { + eprintln!("❌ Error counting messages: {}", e); + } + } + + return Json(Vec::new()); + } + + // Теперь получаем детальную информацию о каждом собеседнике + let mut chats = Vec::new(); + + for partner_username in partner_usernames { + println!("📞 Getting details for partner: {}", partner_username); + + // Получаем информацию о пользователе + let user_result = + sqlx::query("SELECT username, phone, avatar_url FROM users WHERE username = ?") + .bind(&partner_username) + .fetch_optional(&state.db_pool) + .await; + + match user_result { + Ok(Some(user_row)) => { + let partner_name: String = user_row.get("username"); + let phone: String = user_row.get("phone"); + let avatar_url: Option = user_row.get("avatar_url"); + + // Получаем последнее сообщение + let last_message_result = sqlx::query( + r#" + SELECT content, timestamp + FROM messages + WHERE ((from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?)) + AND deleted_for_everyone = FALSE + AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE)) + ORDER BY timestamp DESC + LIMIT 1 + "# + ) + .bind(&username) // текущий пользователь + .bind(&partner_name) // собеседник + .bind(&partner_name) // собеседник + .bind(&username) // текущий пользователь + .bind(&username) // для проверки deleted_for_sender + .bind(&username) // для проверки deleted_for_receiver + .fetch_optional(&state.db_pool) + .await; + + let (last_message, last_message_time) = match last_message_result { + Ok(Some(row)) => { + let content: String = row.get("content"); + let timestamp: i64 = row.get("timestamp"); + println!( + "💬 Last message with {}: '{}' at {}", + partner_name, content, timestamp + ); + (Some(content), Some(timestamp)) + } + Ok(None) => { + println!("❌ No last message found for chat with {}", partner_name); + (None, None) + } + Err(e) => { + eprintln!("❌ Error fetching last message: {}", e); + (None, None) + } + }; + + // Получаем количество непрочитанных сообщений + let unread_count_result = sqlx::query( + "SELECT COUNT(*) as count FROM messages WHERE from_user = ? AND to_user = ? AND read = FALSE AND deleted_for_everyone = FALSE AND deleted_for_receiver = FALSE" + ) + .bind(&partner_name) + .bind(&username) + .fetch_one(&state.db_pool) + .await; + + let unread_count = match unread_count_result { + Ok(row) => row.get::("count"), + Err(e) => { + eprintln!("❌ Error counting unread messages: {}", e); + 0 + } + }; + + let chat = model::ChatPartner { + username: partner_name.clone(), + phone, + avatar_url, + last_message, + last_message_time, + unread_count, + }; + + println!( + "✅ Added chat with {} (phone: {}), last_message: {:?}, unread: {}", + chat.username, chat.phone, chat.last_message, chat.unread_count + ); + chats.push(chat); + } + Ok(None) => { + eprintln!("❌ User not found: {}", partner_username); + } + Err(e) => { + eprintln!( + "❌ Error fetching user details for {}: {}", + partner_username, e + ); + } + } + } + + // Сортируем по времени последнего сообщения (новые сверху) + chats.sort_by(|a, b| { + b.last_message_time + .unwrap_or(0) + .cmp(&a.last_message_time.unwrap_or(0)) + }); + + println!("🎉 Final chat list for {}: {} chats", username, chats.len()); + for chat in &chats { + println!( + " - {}: {:?} (unread: {})", + chat.username, chat.last_message, chat.unread_count + ); + } + + Json(chats) +} diff --git a/src/api/debug.rs b/src/api/debug.rs new file mode 100644 index 0000000..8446aee --- /dev/null +++ b/src/api/debug.rs @@ -0,0 +1,94 @@ +// use crate::state; +// use crate::utils; +// use axum::extract::{Json, State}; +// use sqlx::Row; +// use std::sync::Arc; + +// // Debug endpoint to see all messages +// pub async fn debug_messages_handler( +// State(state): State>, +// headers: axum::http::HeaderMap, +// ) -> 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!({"error": "No token"})); +// }; + +// // Get current user from token +// let username = match utils::verify_token(&state.db_pool, &token).await { +// Ok(Some(user)) => user, +// _ => return Json(serde_json::json!({"error": "Invalid token"})), +// }; + +// // Get all messages for this user +// let messages = sqlx::query( +// r#" +// SELECT id, from_user, to_user, content, timestamp, message_type, read, +// deleted_for_sender, deleted_for_receiver, deleted_for_everyone +// FROM messages +// WHERE from_user = ? OR to_user = ? +// ORDER BY timestamp DESC +// LIMIT 100 +// "#, +// ) +// .bind(&username) +// .bind(&username) +// .fetch_all(&state.db_pool) +// .await; + +// // Get chat partners count +// let partners_result = sqlx::query( +// r#" +// SELECT COUNT(DISTINCT +// CASE +// WHEN from_user = ? THEN to_user +// ELSE from_user +// END +// ) as partner_count +// FROM messages +// WHERE from_user = ? OR to_user = ? +// "#, +// ) +// .bind(&username) +// .bind(&username) +// .bind(&username) +// .fetch_one(&state.db_pool) +// .await; + +// match messages { +// Ok(rows) => { +// let mut result = Vec::new(); +// for row in rows { +// result.push(serde_json::json!({ +// "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"), +// "read": row.get::("read"), +// "deleted_for_sender": row.get::("deleted_for_sender"), +// "deleted_for_receiver": row.get::("deleted_for_receiver"), +// "deleted_for_everyone": row.get::("deleted_for_everyone"), +// "is_current_user_from": row.get::("from_user") == username, +// "is_current_user_to": row.get::("to_user") == username, +// })); +// } + +// let partner_count = match partners_result { +// Ok(row) => row.get::("partner_count"), +// Err(_) => 0, +// }; + +// Json(serde_json::json!({ +// "user": username, +// "total_messages": result.len(), +// "chat_partners_count": partner_count, +// "messages": result +// })) +// } +// Err(e) => Json(serde_json::json!({"error": format!("Database error: {}", e)})), +// } +// } diff --git a/src/api/login.rs b/src/api/login.rs new file mode 100644 index 0000000..7574cd5 --- /dev/null +++ b/src/api/login.rs @@ -0,0 +1,77 @@ +use crate::model; +use crate::state; +use crate::utils; +use axum::extract::{Json, State}; +use bcrypt::verify; +use chrono::{Duration, Utc}; +use sqlx::Row; +use std::sync::Arc; + +pub async fn login_handler( + State(state): State>, + Json(payload): Json, +) -> Json { + // Get user from database + let user_result = sqlx::query("SELECT username, password_hash FROM users WHERE phone = ?") + .bind(&payload.phone) + .fetch_optional(&state.db_pool) + .await; + + match user_result { + Ok(Some(row)) => { + let username: String = row.get("username"); + let password_hash: String = row.get("password_hash"); + + // Verify password + match verify(&payload.password, &password_hash) { + Ok(true) => { + // Generate session token + let token = utils::generate_token(); + let expires_at = Utc::now() + Duration::days(30); + + // Store session + let _ = sqlx::query( + "INSERT INTO sessions (token, username, phone, expires_at) VALUES (?, ?, ?, ?)" + ) + .bind(&token) + .bind(&username) + .bind(&payload.phone) + .bind(expires_at.naive_utc()) + .execute(&state.db_pool) + .await; + + Json(model::AuthResponse { + success: true, + message: "Login successful".to_string(), + username: Some(username), + token: Some(token), + }) + } + Ok(false) => Json(model::AuthResponse { + success: false, + message: "Invalid password".to_string(), + username: None, + token: None, + }), + Err(_) => Json(model::AuthResponse { + success: false, + message: "Error verifying password".to_string(), + username: None, + token: None, + }), + } + } + Ok(None) => Json(model::AuthResponse { + success: false, + message: "Phone number not found".to_string(), + username: None, + token: None, + }), + Err(_) => Json(model::AuthResponse { + success: false, + message: "Login failed".to_string(), + username: None, + token: None, + }), + } +} diff --git a/src/api/messages.rs b/src/api/messages.rs new file mode 100644 index 0000000..18da808 --- /dev/null +++ b/src/api/messages.rs @@ -0,0 +1,39 @@ +pub mod delete; +use crate::model; +use crate::state; +use crate::utils; +use axum::extract::{Json, State}; +use std::sync::Arc; + +// Mark messages as read endpoint +pub async fn mark_read_handler( + State(state): State>, + headers: axum::http::HeaderMap, + Json(payload): Json, +) -> 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 + let username = match utils::verify_token(&state.db_pool, &token).await { + Ok(Some(user)) => user, + _ => return Json(serde_json::json!({"success": false, "error": "Invalid token"})), + }; + + // Проверяем, что текущий пользователь - это получатель сообщений + if username != payload.to_user { + return Json(serde_json::json!({"success": false, "error": "Unauthorized"})); + } + + match utils::mark_messages_as_read(&state.db_pool, &payload.from_user, &payload.to_user).await { + Ok(_) => Json(serde_json::json!({"success": true})), + Err(e) => { + eprintln!("Error marking messages as read: {}", e); + Json(serde_json::json!({"success": false, "error": "Database error"})) + } + } +} diff --git a/src/api/messages/delete.rs b/src/api/messages/delete.rs new file mode 100644 index 0000000..6318ec7 --- /dev/null +++ b/src/api/messages/delete.rs @@ -0,0 +1,141 @@ +use crate::model; +use crate::state; +use crate::utils; +use axum::extract::{Json, State, ws::Message}; +use sqlx::Row; +use std::sync::Arc; + +// Delete message endpoint +pub async fn delete_message_handler( + State(state): State>, + headers: axum::http::HeaderMap, + Json(payload): Json, +) -> 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(model::DeleteMessageResponse { + success: false, + message: "No token".to_string(), + }); + }; + + // Get current user from token + let username = match utils::verify_token(&state.db_pool, &token).await { + Ok(Some(user)) => user, + _ => { + return Json(model::DeleteMessageResponse { + success: false, + message: "Invalid token".to_string(), + }); + } + }; + + // Получаем информацию о сообщении + let message_result = sqlx::query("SELECT from_user, to_user FROM messages WHERE id = ?") + .bind(&payload.message_id) + .fetch_optional(&state.db_pool) + .await; + + let message = match message_result { + Ok(Some(row)) => row, + Ok(None) => { + return Json(model::DeleteMessageResponse { + success: false, + message: "Message not found".to_string(), + }); + } + Err(e) => { + eprintln!("Error fetching message: {}", e); + return Json(model::DeleteMessageResponse { + success: false, + message: "Database error".to_string(), + }); + } + }; + + let from_user: String = message.get("from_user"); + let to_user: String = message.get("to_user"); + + // Проверяем права доступа + if from_user != username && to_user != username { + return Json(model::DeleteMessageResponse { + success: false, + message: "You don't have permission to delete this message".to_string(), + }); + } + + if payload.for_everyone { + // Удаление для всех + if from_user != username { + return Json(model::DeleteMessageResponse { + success: false, + message: "Only the sender can delete message for everyone".to_string(), + }); + } + + let result = sqlx::query("UPDATE messages SET deleted_for_everyone = TRUE WHERE id = ?") + .bind(&payload.message_id) + .execute(&state.db_pool) + .await; + + match result { + Ok(_) => { + // Отправляем уведомление через WebSocket + let users = state.users.read().await; + if let Some(recipient_tx) = users.get(&to_user) { + let delete_msg = serde_json::json!({ + "type": "message_deleted", + "message_id": payload.message_id, + "for_everyone": true + }); + let _ = recipient_tx.send(Message::Text(delete_msg.to_string())); + } + + Json(model::DeleteMessageResponse { + success: true, + message: "Message deleted for everyone".to_string(), + }) + } + Err(e) => { + eprintln!("Error deleting message for everyone: {}", e); + Json(model::DeleteMessageResponse { + success: false, + message: "Database error".to_string(), + }) + } + } + } else { + // Удаление только для себя + let field_to_update = if username == from_user { + "deleted_for_sender" + } else { + "deleted_for_receiver" + }; + + let query = format!( + "UPDATE messages SET {} = TRUE WHERE id = ?", + field_to_update + ); + + let result = sqlx::query(&query) + .bind(&payload.message_id) + .execute(&state.db_pool) + .await; + + match result { + Ok(_) => Json(model::DeleteMessageResponse { + success: true, + message: "Message deleted for you".to_string(), + }), + Err(e) => { + eprintln!("Error deleting message for user: {}", e); + Json(model::DeleteMessageResponse { + success: false, + message: "Database error".to_string(), + }) + } + } + } +} diff --git a/src/api/profile.rs b/src/api/profile.rs new file mode 100644 index 0000000..2c77115 --- /dev/null +++ b/src/api/profile.rs @@ -0,0 +1,2 @@ +pub mod avatar; +pub mod get; diff --git a/src/api/profile/avatar.rs b/src/api/profile/avatar.rs new file mode 100644 index 0000000..4cff39d --- /dev/null +++ b/src/api/profile/avatar.rs @@ -0,0 +1,116 @@ +use crate::model; +use crate::state; +use crate::utils; +use axum::extract::{Json, Multipart, State}; +use std::sync::Arc; +use uuid::Uuid; + +// Upload avatar endpoint +pub async fn upload_avatar_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(model::UploadAvatarResponse { + success: false, + avatar_url: None, + message: "No token".to_string(), + }); + }; + + // Get current user from token + let username = match utils::verify_token(&state.db_pool, &token).await { + Ok(Some(user)) => user, + _ => { + return Json(model::UploadAvatarResponse { + success: false, + avatar_url: None, + message: "Invalid token".to_string(), + }); + } + }; + + // Process multipart form + while let Some(field) = multipart.next_field().await.unwrap() { + let field_name = field.name().unwrap_or("").to_string(); + + if field_name == "avatar" { + let data = field.bytes().await.unwrap(); + + // Validate image size using config + if data.len() > state.config.files.max_avatar_size { + return Json(model::UploadAvatarResponse { + success: false, + avatar_url: None, + message: format!( + "Image too large (max {} bytes)", + state.config.files.max_avatar_size + ), + }); + } + + // Process image + match utils::process_avatar(&data).await { + Ok(processed_data) => { + // Generate unique filename + let filename = format!("{}.jpg", Uuid::new_v4()); + let avatar_path = format!("{}/{}", state.config.files.avatars_dir, filename); + + // Save file + if let Err(e) = tokio::fs::write(&avatar_path, &processed_data).await { + eprintln!("Error saving avatar: {}", e); + return Json(model::UploadAvatarResponse { + success: false, + avatar_url: None, + message: "Error saving image".to_string(), + }); + } + + let avatar_url = format!("/avatars/{}", filename); + + // Update user in database + let result = sqlx::query("UPDATE users SET avatar_url = ? WHERE username = ?") + .bind(&avatar_url) + .bind(&username) + .execute(&state.db_pool) + .await; + + match result { + Ok(_) => { + return Json(model::UploadAvatarResponse { + success: true, + avatar_url: Some(avatar_url), + message: "Avatar uploaded successfully".to_string(), + }); + } + Err(e) => { + eprintln!("Error updating user avatar: {}", e); + return Json(model::UploadAvatarResponse { + success: false, + avatar_url: None, + message: "Database error".to_string(), + }); + } + } + } + Err(e) => { + return Json(model::UploadAvatarResponse { + success: false, + avatar_url: None, + message: e, + }); + } + } + } + } + + Json(model::UploadAvatarResponse { + success: false, + avatar_url: None, + message: "No avatar file provided".to_string(), + }) +} diff --git a/src/api/profile/get.rs b/src/api/profile/get.rs new file mode 100644 index 0000000..ac22f29 --- /dev/null +++ b/src/api/profile/get.rs @@ -0,0 +1,54 @@ +use crate::model; +use crate::state; +use crate::utils; +use axum::extract::{Json, State}; +use sqlx::Row; +use std::sync::Arc; + +pub async fn profile_handler( + State(state): State>, + headers: axum::http::HeaderMap, +) -> 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(model::UserProfile { + username: "user".to_string(), + phone: "+1234567890".to_string(), + avatar_url: None, + }); + }; + + // Get current user from token + let username = match utils::verify_token(&state.db_pool, &token).await { + Ok(Some(user)) => user, + _ => { + return Json(model::UserProfile { + username: "user".to_string(), + phone: "+1234567890".to_string(), + avatar_url: None, + }); + } + }; + + // Get user details + let user_result = + sqlx::query("SELECT username, phone, avatar_url FROM users WHERE username = ?") + .bind(&username) + .fetch_optional(&state.db_pool) + .await; + + match user_result { + Ok(Some(row)) => Json(model::UserProfile { + username: row.get("username"), + phone: row.get("phone"), + avatar_url: row.get("avatar_url"), + }), + _ => Json(model::UserProfile { + username: "user".to_string(), + phone: "+1234567890".to_string(), + avatar_url: None, + }), + } +} diff --git a/src/api/register.rs b/src/api/register.rs new file mode 100644 index 0000000..4e5b035 --- /dev/null +++ b/src/api/register.rs @@ -0,0 +1,117 @@ +use crate::model; +use crate::state; +use crate::utils; +use axum::extract::{Json, State}; +use bcrypt::{DEFAULT_COST, hash}; +use chrono::{Duration, Utc}; +use std::sync::Arc; + +pub async fn register_handler( + State(state): State>, + Json(payload): Json, +) -> Json { + // Validate input + if payload.phone.len() < 5 { + return Json(model::AuthResponse { + success: false, + message: "Phone number is too short".to_string(), + username: None, + token: None, + }); + } + + if payload.username.len() < 3 + || payload.username.len() > state.config.limits.max_username_length + { + return Json(model::AuthResponse { + success: false, + message: format!( + "Username must be between 3 and {} characters", + state.config.limits.max_username_length + ), + username: None, + token: None, + }); + } + + if payload.password.len() < state.config.limits.min_password_length { + return Json(model::AuthResponse { + success: false, + message: format!( + "Password must be at least {} characters", + state.config.limits.min_password_length + ), + username: None, + token: None, + }); + } + + // Hash password + let password_hash = match hash(&payload.password, DEFAULT_COST) { + Ok(hash) => hash, + Err(_) => { + return Json(model::AuthResponse { + success: false, + message: "Error hashing password".to_string(), + username: None, + token: None, + }); + } + }; + + // Insert user into database + let result = sqlx::query("INSERT INTO users (phone, username, password_hash) VALUES (?, ?, ?)") + .bind(&payload.phone) + .bind(&payload.username) + .bind(&password_hash) + .execute(&state.db_pool) + .await; + + match result { + Ok(_) => { + // Generate session token + let token = utils::generate_token(); + let expires_at = Utc::now() + Duration::days(30); + + // Store session + let _ = sqlx::query( + "INSERT INTO sessions (token, username, phone, expires_at) VALUES (?, ?, ?, ?)", + ) + .bind(&token) + .bind(&payload.username) + .bind(&payload.phone) + .bind(expires_at.naive_utc()) + .execute(&state.db_pool) + .await; + + Json(model::AuthResponse { + success: true, + message: "Registration successful".to_string(), + username: Some(payload.username), + token: Some(token), + }) + } + Err(e) => { + let error_message = if e + .to_string() + .contains("UNIQUE constraint failed: users.phone") + { + "Phone number already registered".to_string() + } else if e + .to_string() + .contains("UNIQUE constraint failed: users.username") + { + "Username already taken".to_string() + } else { + "Registration failed".to_string() + }; + + Json(model::AuthResponse { + success: false, + message: error_message, + username: None, + token: None, + }) + } + } +} diff --git a/src/api/search.rs b/src/api/search.rs new file mode 100644 index 0000000..ae8e89a --- /dev/null +++ b/src/api/search.rs @@ -0,0 +1,48 @@ +use crate::model; +use crate::state; +use axum::extract::{Json, Query, State}; +use sqlx::Row; +use std::sync::Arc; + +// New endpoint to search users +pub async fn search_users_handler( + State(state): State>, + Query(params): Query, + // headers: axum::http::HeaderMap, +) -> Json> { + let search_pattern = format!("%{}%", params.q); + + let users = sqlx::query( + r#" + SELECT username, phone, avatar_url + FROM users + WHERE (username LIKE ? OR phone LIKE ?) + LIMIT 20 + "#, + ) + .bind(&search_pattern) + .bind(&search_pattern) + .fetch_all(&state.db_pool) + .await; + + match users { + Ok(rows) => { + let mut results = Vec::new(); + for row in rows { + results.push(model::ChatPartner { + username: row.get("username"), + phone: row.get("phone"), + avatar_url: row.get("avatar_url"), + last_message: None, + last_message_time: None, + unread_count: 0, + }); + } + Json(results) + } + Err(e) => { + eprintln!("Error searching users: {}", e); + Json(Vec::new()) + } + } +} diff --git a/src/api/security.rs b/src/api/security.rs new file mode 100644 index 0000000..374c64f --- /dev/null +++ b/src/api/security.rs @@ -0,0 +1,63 @@ +use crate::model; +use crate::state; +use crate::utils; +use axum::extract::{Json, State}; +use sqlx::Row; +use std::sync::Arc; + +pub async fn validate_token_handler( + State(state): State>, + Json(payload): Json, +) -> Json { + match utils::verify_token(&state.db_pool, &payload.token).await { + Ok(Some(username)) => { + // Get user details + let user_result = sqlx::query("SELECT phone, avatar_url FROM users WHERE username = ?") + .bind(&username) + .fetch_optional(&state.db_pool) + .await; + + match user_result { + Ok(Some(row)) => { + let phone: String = row.get("phone"); + let avatar_url: Option = row.get("avatar_url"); + Json(model::ValidateTokenResponse { + success: true, + username: Some(username), + phone: Some(phone), + avatar_url, + message: None, + }) + } + Ok(None) => Json(model::ValidateTokenResponse { + success: false, + username: None, + phone: None, + avatar_url: None, + message: Some("User not found".to_string()), + }), + Err(_) => Json(model::ValidateTokenResponse { + success: false, + username: None, + phone: None, + avatar_url: None, + message: Some("Database error".to_string()), + }), + } + } + Ok(None) => Json(model::ValidateTokenResponse { + success: false, + username: None, + phone: None, + avatar_url: None, + message: Some("Invalid or expired token".to_string()), + }), + Err(_) => Json(model::ValidateTokenResponse { + success: false, + username: None, + phone: None, + avatar_url: None, + message: Some("Token verification error".to_string()), + }), + } +} diff --git a/src/api/websock.rs b/src/api/websock.rs new file mode 100644 index 0000000..c2600d9 --- /dev/null +++ b/src/api/websock.rs @@ -0,0 +1,174 @@ +use crate::model; +use crate::state; +use crate::utils; +use axum::{ + extract::{ + State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::Response, +}; +use futures_util::{SinkExt, StreamExt}; +use std::sync::Arc; +use tokio::sync::mpsc; + +// WebSocket handlers +pub async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State>, +) -> Response { + ws.on_upgrade(|socket| handle_websocket(socket, state)) +} + +async fn handle_websocket(socket: WebSocket, state: Arc) { + let (mut sender, mut receiver) = socket.split(); + let (tx, mut rx) = mpsc::unbounded_channel(); + + // Handle authentication + let username = match receiver.next().await { + Some(Ok(Message::Text(auth_msg))) => { + match serde_json::from_str::(&auth_msg) { + Ok(auth) => { + if let (Some(token), Some(auth_type)) = + (auth["token"].as_str(), auth["type"].as_str()) + { + if auth_type == "auth" { + match utils::verify_token(&state.db_pool, token).await { + Ok(Some(user_username)) => { + if state.users.read().await.contains_key(&user_username) { + let _ = sender + .send(Message::Text( + serde_json::json!({ + "type": "error", + "content": "User already connected" + }) + .to_string(), + )) + .await; + return; + } + + state.users.write().await.insert(user_username.clone(), tx); + + let _ = sender + .send(Message::Text( + serde_json::json!({ + "type": "system", + "content": "Connected successfully" + }) + .to_string(), + )) + .await; + + // utils::broadcast_user_list(&state.users).await; + + if let Ok(messages) = utils::get_message_history( + &state.db_pool, + &user_username, + "", + ) + .await + { + for msg in messages { + let message_json = serde_json::to_string(&msg).unwrap(); + let _ = sender.send(Message::Text(message_json)).await; + } + } + + user_username + } + Ok(None) => { + let _ = sender + .send(Message::Text( + serde_json::json!({ + "type": "error", + "content": "Invalid or expired token" + }) + .to_string(), + )) + .await; + return; + } + Err(_) => { + let _ = sender + .send(Message::Text( + serde_json::json!({ + "type": "error", + "content": "Authentication error" + }) + .to_string(), + )) + .await; + return; + } + } + } else { + return; + } + } else { + return; + } + } + Err(_) => return, + } + } + _ => return, + }; + + println!("User {} connected", username); + + let state_clone = state.clone(); + let username_clone = username.clone(); + + let mut send_sender = sender; + let send_task = tokio::spawn(async move { + while let Some(message) = rx.recv().await { + if send_sender.send(message).await.is_err() { + break; + } + } + }); + + let recv_state = state.clone(); + let recv_task = tokio::spawn(async move { + while let Some(result) = receiver.next().await { + match result { + Ok(Message::Text(text)) => { + if let Ok(mut chat_msg) = serde_json::from_str::(&text) { + chat_msg.from_user = username.clone(); + + if let Err(e) = utils::save_message(&recv_state.db_pool, &chat_msg).await { + eprintln!("Failed to save message: {}", e); + continue; + } + + let users = recv_state.users.read().await; + if chat_msg.to_user != chat_msg.from_user { + if let Some(recipient_tx) = users.get(&chat_msg.to_user) { + let message_json = serde_json::to_string(&chat_msg).unwrap(); + let _ = recipient_tx.send(Message::Text(message_json)); + } + } + + if let Some(sender_tx) = users.get(&username) { + let message_json = serde_json::to_string(&chat_msg).unwrap(); + let _ = sender_tx.send(Message::Text(message_json)); + } + } + } + Ok(Message::Close(_)) => break, + Err(_) => break, + _ => {} + } + } + }); + + tokio::select! { + _ = send_task => {}, + _ = recv_task => {}, + } + + state_clone.users.write().await.remove(&username_clone); + // utils::broadcast_user_list(&state_clone.users).await; + println!("User {} disconnected", username_clone); +} diff --git a/src/conf.rs b/src/conf.rs new file mode 100644 index 0000000..8b57501 --- /dev/null +++ b/src/conf.rs @@ -0,0 +1,71 @@ +use config::{Config as ConfigLoader, File, FileFormat}; +use serde::Deserialize; + +// Структура для конфигурации +#[derive(Debug, Deserialize, Clone)] +pub struct AppConfig { + pub server: ServerConfig, + pub database: DatabaseConfig, + pub files: FilesConfig, + pub limits: LimitsConfig, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ServerConfig { + pub host: String, + pub port: u16, + pub max_connections: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct DatabaseConfig { + pub url: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct FilesConfig { + pub avatars_dir: String, + pub web_dir: String, + pub max_avatar_size: usize, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LimitsConfig { + // max_message_length: usize, + pub max_username_length: usize, + pub min_password_length: usize, +} + +impl AppConfig { + pub fn load() -> Result> { + let config_loader = ConfigLoader::builder() + // Добавляем конфиг по умолчанию + .add_source(File::from_str( + r#" + [server] + host = "0.0.0.0" + port = 3333 + max_connections = 10000 + + [database] + url = "sqlite:chat.db" + + [files] + avatars_dir = "web/avatars" + web_dir = "web" + max_avatar_size = 5242880 + + [limits] + max_message_length = 4096 + max_username_length = 50 + min_password_length = 6 + "#, + FileFormat::Toml, + )) + // Перезаписываем значения из config.json если он существует + .add_source(File::with_name("config").required(false)) + .build()?; + + Ok(config_loader.try_deserialize()?) + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..4d88731 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,61 @@ +use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; + +pub async fn init_db(database_url: &str) -> Result> { + let options = SqliteConnectOptions::new() + .filename(database_url) + .create_if_missing(true); + let pool = SqlitePool::connect_with(options).await?; + + // Create users table + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + avatar_url TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(&pool) + .await?; + + // Create messages table + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + from_user TEXT NOT NULL, + to_user TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + message_type TEXT NOT NULL, + read BOOLEAN DEFAULT FALSE, + deleted_for_sender BOOLEAN DEFAULT FALSE, + deleted_for_receiver BOOLEAN DEFAULT FALSE, + deleted_for_everyone BOOLEAN DEFAULT FALSE + ) + "#, + ) + .execute(&pool) + .await?; + + // Create sessions table + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + username TEXT NOT NULL, + phone TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(&pool) + .await?; + + Ok(pool) +} diff --git a/src/main.rs b/src/main.rs index 9c2a2ec..fc9d0fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,202 +1,17 @@ use axum::{ Router, - extract::{ - Json, Multipart, Query, State, - ws::{Message, WebSocket, WebSocketUpgrade}, - }, - response::Response, routing::{get, post}, }; -use bcrypt::{DEFAULT_COST, hash, verify}; -use chrono::{Duration, Utc}; -use config::{Config as ConfigLoader, File, FileFormat}; -use futures_util::{SinkExt, StreamExt}; -use rand::distributions::Alphanumeric; -use rand::{Rng, thread_rng}; -use serde::{Deserialize, Serialize}; -use sqlx::{Row, sqlite::SqlitePool}; -use std::io::Cursor; use std::{collections::HashMap, net::SocketAddr, sync::Arc}; -use tokio::sync::{RwLock, mpsc}; +use tokio::sync::RwLock; use tower_http::services::ServeDir; -use uuid::Uuid; -// Types for shared state -type UserMap = Arc>>>; - -// Структура для конфигурации -#[derive(Debug, Deserialize, Clone)] -struct AppConfig { - server: ServerConfig, - database: DatabaseConfig, - files: FilesConfig, - limits: LimitsConfig, -} - -#[derive(Debug, Deserialize, Clone)] -struct ServerConfig { - host: String, - port: u16, - max_connections: Option, -} - -#[derive(Debug, Deserialize, Clone)] -struct DatabaseConfig { - url: String, -} - -#[derive(Debug, Deserialize, Clone)] -struct FilesConfig { - avatars_dir: String, - web_dir: String, - max_avatar_size: usize, -} - -#[derive(Debug, Deserialize, Clone)] -struct LimitsConfig { - // max_message_length: usize, - max_username_length: usize, - min_password_length: usize, -} - -impl AppConfig { - fn load() -> Result> { - let config_loader = ConfigLoader::builder() - // Добавляем конфиг по умолчанию - .add_source(File::from_str( - r#" - [server] - host = "0.0.0.0" - port = 3333 - max_connections = 10000 - - [database] - url = "sqlite:chat.db" - - [files] - avatars_dir = "web/avatars" - web_dir = "web" - max_avatar_size = 5242880 - - [limits] - max_message_length = 4096 - max_username_length = 50 - min_password_length = 6 - "#, - FileFormat::Toml, - )) - // Перезаписываем значения из config.json если он существует - .add_source(File::with_name("config").required(false)) - .build()?; - - Ok(config_loader.try_deserialize()?) - } -} - -#[derive(Clone)] -struct AppState { - users: UserMap, - db_pool: SqlitePool, - config: AppConfig, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct ChatMessage { - id: String, - from_user: String, - to_user: String, - content: String, - timestamp: i64, - message_type: String, -} - -// #[derive(Debug, Serialize, Deserialize)] -// struct UserList { -// users: Vec, -// } - -// Структуры для аутентификации -#[derive(Debug, Deserialize)] -struct RegisterRequest { - phone: String, - username: String, - password: String, -} - -#[derive(Debug, Deserialize)] -struct LoginRequest { - phone: String, - password: String, -} - -#[derive(Debug, Serialize)] -struct AuthResponse { - success: bool, - message: String, - username: Option, - token: Option, -} - -#[derive(Debug, Serialize)] -struct UserProfile { - username: String, - phone: String, - avatar_url: Option, -} - -#[derive(Debug, Serialize)] -struct ValidateTokenResponse { - success: bool, - username: Option, - phone: Option, - avatar_url: Option, - message: Option, -} - -#[derive(Debug, Serialize)] -struct ChatPartner { - username: String, - phone: String, - avatar_url: Option, - last_message: Option, - last_message_time: Option, - unread_count: i64, -} - -#[derive(Debug, Deserialize)] -struct SearchQuery { - q: String, -} - -#[derive(Debug, Deserialize)] -struct MarkReadRequest { - from_user: String, - to_user: String, -} - -#[derive(Debug, Deserialize)] -struct DeleteMessageRequest { - message_id: String, - for_everyone: bool, -} - -#[derive(Debug, Serialize)] -struct DeleteMessageResponse { - success: bool, - message: String, -} - -#[derive(Debug, Serialize)] -struct UploadAvatarResponse { - success: bool, - avatar_url: Option, - message: String, -} - -#[derive(Debug, Deserialize)] -struct ValidateTokenRequest { - token: String, -} +mod api; +mod conf; +mod db; +mod model; +mod state; +mod utils; #[tokio::main] async fn main() -> Result<(), Box> { @@ -204,7 +19,7 @@ async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); // Load configuration - let config = AppConfig::load()?; + let config = conf::AppConfig::load()?; println!("📋 Loaded configuration: {:?}", config); // Create directories if they don't exist @@ -212,10 +27,10 @@ async fn main() -> Result<(), Box> { tokio::fs::create_dir_all(&config.files.web_dir).await?; // Initialize database with configured URL - let db_pool = init_db(&config.database.url).await?; + let db_pool = db::init_db(&config.database.url).await?; // Create shared state - let state = Arc::new(AppState { + let state = Arc::new(state::AppState { users: Arc::new(RwLock::new(HashMap::new())), db_pool, config, @@ -223,17 +38,29 @@ async fn main() -> Result<(), Box> { // Build the application let app = Router::new() - .route("/ws", get(websocket_handler)) - .route("/api/register", post(register_handler)) - .route("/api/login", post(login_handler)) - .route("/api/validate-token", post(validate_token_handler)) - .route("/api/chats", get(get_chats_handler)) - .route("/api/search-users", get(search_users_handler)) - .route("/api/debug-messages", get(debug_messages_handler)) - .route("/api/mark-read", post(mark_read_handler)) - .route("/api/delete-message", post(delete_message_handler)) - .route("/api/upload-avatar", post(upload_avatar_handler)) - .route("/api/profile", get(profile_handler)) + .route("/ws", get(api::websock::websocket_handler)) + .route("/api/register", post(api::register::register_handler)) + .route("/api/login", post(api::login::login_handler)) + .route( + "/api/validate-token", + post(api::security::validate_token_handler), + ) + .route("/api/chats", get(api::chats::get_chats_handler)) + .route("/api/search-users", get(api::search::search_users_handler)) + // .route( + // "/api/debug-messages", + // get(api::debug::debug_messages_handler), + // ) + .route("/api/mark-read", post(api::messages::mark_read_handler)) + .route( + "/api/delete-message", + post(api::messages::delete::delete_message_handler), + ) + .route( + "/api/upload-avatar", + post(api::profile::avatar::upload_avatar_handler), + ) + .route("/api/profile", get(api::profile::get::profile_handler)) .nest_service("/", ServeDir::new(&state.config.files.web_dir)) .with_state(state.clone()); @@ -254,1303 +81,3 @@ async fn main() -> Result<(), Box> { Ok(()) } - -async fn init_db(database_url: &str) -> Result> { - let pool = SqlitePool::connect(database_url).await?; - - // Create users table - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - phone TEXT UNIQUE NOT NULL, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - avatar_url TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - "#, - ) - .execute(&pool) - .await?; - - // Create messages table - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - from_user TEXT NOT NULL, - to_user TEXT NOT NULL, - content TEXT NOT NULL, - timestamp INTEGER NOT NULL, - message_type TEXT NOT NULL, - read BOOLEAN DEFAULT FALSE, - deleted_for_sender BOOLEAN DEFAULT FALSE, - deleted_for_receiver BOOLEAN DEFAULT FALSE, - deleted_for_everyone BOOLEAN DEFAULT FALSE - ) - "#, - ) - .execute(&pool) - .await?; - - // Create sessions table - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS sessions ( - token TEXT PRIMARY KEY, - username TEXT NOT NULL, - phone TEXT NOT NULL, - expires_at DATETIME NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - "#, - ) - .execute(&pool) - .await?; - - Ok(pool) -} - -// Обработчики HTTP API -async fn register_handler( - State(state): State>, - Json(payload): Json, -) -> Json { - // Validate input - if payload.phone.len() < 5 { - return Json(AuthResponse { - success: false, - message: "Phone number is too short".to_string(), - username: None, - token: None, - }); - } - - if payload.username.len() < 3 - || payload.username.len() > state.config.limits.max_username_length - { - return Json(AuthResponse { - success: false, - message: format!( - "Username must be between 3 and {} characters", - state.config.limits.max_username_length - ), - username: None, - token: None, - }); - } - - if payload.password.len() < state.config.limits.min_password_length { - return Json(AuthResponse { - success: false, - message: format!( - "Password must be at least {} characters", - state.config.limits.min_password_length - ), - username: None, - token: None, - }); - } - - // Hash password - let password_hash = match hash(&payload.password, DEFAULT_COST) { - Ok(hash) => hash, - Err(_) => { - return Json(AuthResponse { - success: false, - message: "Error hashing password".to_string(), - username: None, - token: None, - }); - } - }; - - // Insert user into database - let result = sqlx::query("INSERT INTO users (phone, username, password_hash) VALUES (?, ?, ?)") - .bind(&payload.phone) - .bind(&payload.username) - .bind(&password_hash) - .execute(&state.db_pool) - .await; - - match result { - Ok(_) => { - // Generate session token - let token = generate_token(); - let expires_at = Utc::now() + Duration::days(30); - - // Store session - let _ = sqlx::query( - "INSERT INTO sessions (token, username, phone, expires_at) VALUES (?, ?, ?, ?)", - ) - .bind(&token) - .bind(&payload.username) - .bind(&payload.phone) - .bind(expires_at.naive_utc()) - .execute(&state.db_pool) - .await; - - Json(AuthResponse { - success: true, - message: "Registration successful".to_string(), - username: Some(payload.username), - token: Some(token), - }) - } - Err(e) => { - let error_message = if e - .to_string() - .contains("UNIQUE constraint failed: users.phone") - { - "Phone number already registered".to_string() - } else if e - .to_string() - .contains("UNIQUE constraint failed: users.username") - { - "Username already taken".to_string() - } else { - "Registration failed".to_string() - }; - - Json(AuthResponse { - success: false, - message: error_message, - username: None, - token: None, - }) - } - } -} - -async fn login_handler( - State(state): State>, - Json(payload): Json, -) -> Json { - // Get user from database - let user_result = sqlx::query("SELECT username, password_hash FROM users WHERE phone = ?") - .bind(&payload.phone) - .fetch_optional(&state.db_pool) - .await; - - match user_result { - Ok(Some(row)) => { - let username: String = row.get("username"); - let password_hash: String = row.get("password_hash"); - - // Verify password - match verify(&payload.password, &password_hash) { - Ok(true) => { - // Generate session token - let token = generate_token(); - let expires_at = Utc::now() + Duration::days(30); - - // Store session - let _ = sqlx::query( - "INSERT INTO sessions (token, username, phone, expires_at) VALUES (?, ?, ?, ?)" - ) - .bind(&token) - .bind(&username) - .bind(&payload.phone) - .bind(expires_at.naive_utc()) - .execute(&state.db_pool) - .await; - - Json(AuthResponse { - success: true, - message: "Login successful".to_string(), - username: Some(username), - token: Some(token), - }) - } - Ok(false) => Json(AuthResponse { - success: false, - message: "Invalid password".to_string(), - username: None, - token: None, - }), - Err(_) => Json(AuthResponse { - success: false, - message: "Error verifying password".to_string(), - username: None, - token: None, - }), - } - } - Ok(None) => Json(AuthResponse { - success: false, - message: "Phone number not found".to_string(), - username: None, - token: None, - }), - Err(_) => Json(AuthResponse { - success: false, - message: "Login failed".to_string(), - username: None, - token: None, - }), - } -} - -async fn validate_token_handler( - State(state): State>, - Json(payload): Json, -) -> Json { - match verify_token(&state.db_pool, &payload.token).await { - Ok(Some(username)) => { - // Get user details - let user_result = sqlx::query("SELECT phone, avatar_url FROM users WHERE username = ?") - .bind(&username) - .fetch_optional(&state.db_pool) - .await; - - match user_result { - Ok(Some(row)) => { - let phone: String = row.get("phone"); - let avatar_url: Option = row.get("avatar_url"); - Json(ValidateTokenResponse { - success: true, - username: Some(username), - phone: Some(phone), - avatar_url, - message: None, - }) - } - Ok(None) => Json(ValidateTokenResponse { - success: false, - username: None, - phone: None, - avatar_url: None, - message: Some("User not found".to_string()), - }), - Err(_) => Json(ValidateTokenResponse { - success: false, - username: None, - phone: None, - avatar_url: None, - message: Some("Database error".to_string()), - }), - } - } - Ok(None) => Json(ValidateTokenResponse { - success: false, - username: None, - phone: None, - avatar_url: None, - message: Some("Invalid or expired token".to_string()), - }), - Err(_) => Json(ValidateTokenResponse { - success: false, - username: None, - phone: None, - avatar_url: None, - message: Some("Token verification error".to_string()), - }), - } -} - -// Debug endpoint to see all messages -async fn debug_messages_handler( - State(state): State>, - headers: axum::http::HeaderMap, -) -> 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!({"error": "No token"})); - }; - - // Get current user from token - let username = match verify_token(&state.db_pool, &token).await { - Ok(Some(user)) => user, - _ => return Json(serde_json::json!({"error": "Invalid token"})), - }; - - // Get all messages for this user - let messages = sqlx::query( - r#" - SELECT id, from_user, to_user, content, timestamp, message_type, read, - deleted_for_sender, deleted_for_receiver, deleted_for_everyone - FROM messages - WHERE from_user = ? OR to_user = ? - ORDER BY timestamp DESC - LIMIT 100 - "#, - ) - .bind(&username) - .bind(&username) - .fetch_all(&state.db_pool) - .await; - - // Get chat partners count - let partners_result = sqlx::query( - r#" - SELECT COUNT(DISTINCT - CASE - WHEN from_user = ? THEN to_user - ELSE from_user - END - ) as partner_count - FROM messages - WHERE from_user = ? OR to_user = ? - "#, - ) - .bind(&username) - .bind(&username) - .bind(&username) - .fetch_one(&state.db_pool) - .await; - - match messages { - Ok(rows) => { - let mut result = Vec::new(); - for row in rows { - result.push(serde_json::json!({ - "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"), - "read": row.get::("read"), - "deleted_for_sender": row.get::("deleted_for_sender"), - "deleted_for_receiver": row.get::("deleted_for_receiver"), - "deleted_for_everyone": row.get::("deleted_for_everyone"), - "is_current_user_from": row.get::("from_user") == username, - "is_current_user_to": row.get::("to_user") == username, - })); - } - - let partner_count = match partners_result { - Ok(row) => row.get::("partner_count"), - Err(_) => 0, - }; - - Json(serde_json::json!({ - "user": username, - "total_messages": result.len(), - "chat_partners_count": partner_count, - "messages": result - })) - } - Err(e) => Json(serde_json::json!({"error": format!("Database error: {}", e)})), - } -} - -// УПРОЩЕННАЯ версия получения чатов -async fn get_chats_handler( - State(state): State>, - headers: axum::http::HeaderMap, -) -> 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(Vec::new()); - }; - - // Get current user from token - let username = match verify_token(&state.db_pool, &token).await { - Ok(Some(user)) => user, - _ => return Json(Vec::new()), - }; - - println!("🔍 Fetching chats for user: {}", username); - - // ПРОСТОЙ И ПРЯМОЙ ЗАПРОС: находим всех пользователей, с которыми есть сообщения - let chat_partners = sqlx::query( - r#" - -- Находим всех уникальных собеседников - SELECT DISTINCT - CASE - WHEN from_user = ? THEN to_user - ELSE from_user - END as partner_username - FROM messages - WHERE (from_user = ? OR to_user = ?) - AND deleted_for_everyone = FALSE - AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE)) - "# - ) - .bind(&username) - .bind(&username) - .bind(&username) - .bind(&username) - .bind(&username) - .fetch_all(&state.db_pool) - .await; - - let partner_usernames = match chat_partners { - Ok(rows) => { - let partners: Vec = rows - .iter() - .map(|row| row.get::("partner_username")) - .collect(); - println!("🤝 Found partner usernames: {:?}", partners); - partners - } - Err(e) => { - eprintln!("❌ Error fetching partner usernames: {}", e); - Vec::new() - } - }; - - if partner_usernames.is_empty() { - println!("❌ No partners found for user {}", username); - - // Проверим, есть ли вообще сообщения у пользователя - let message_count = sqlx::query( - "SELECT COUNT(*) as count FROM messages WHERE (from_user = ? OR to_user = ?) AND deleted_for_everyone = FALSE" - ) - .bind(&username) - .bind(&username) - .fetch_one(&state.db_pool) - .await; - - match message_count { - Ok(row) => { - let count: i64 = row.get("count"); - println!( - "📊 User {} has {} total messages in database", - username, count - ); - } - Err(e) => { - eprintln!("❌ Error counting messages: {}", e); - } - } - - return Json(Vec::new()); - } - - // Теперь получаем детальную информацию о каждом собеседнике - let mut chats = Vec::new(); - - for partner_username in partner_usernames { - println!("📞 Getting details for partner: {}", partner_username); - - // Получаем информацию о пользователе - let user_result = - sqlx::query("SELECT username, phone, avatar_url FROM users WHERE username = ?") - .bind(&partner_username) - .fetch_optional(&state.db_pool) - .await; - - match user_result { - Ok(Some(user_row)) => { - let partner_name: String = user_row.get("username"); - let phone: String = user_row.get("phone"); - let avatar_url: Option = user_row.get("avatar_url"); - - // Получаем последнее сообщение - let last_message_result = sqlx::query( - r#" - SELECT content, timestamp - FROM messages - WHERE ((from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?)) - AND deleted_for_everyone = FALSE - AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE)) - ORDER BY timestamp DESC - LIMIT 1 - "# - ) - .bind(&username) // текущий пользователь - .bind(&partner_name) // собеседник - .bind(&partner_name) // собеседник - .bind(&username) // текущий пользователь - .bind(&username) // для проверки deleted_for_sender - .bind(&username) // для проверки deleted_for_receiver - .fetch_optional(&state.db_pool) - .await; - - let (last_message, last_message_time) = match last_message_result { - Ok(Some(row)) => { - let content: String = row.get("content"); - let timestamp: i64 = row.get("timestamp"); - println!( - "💬 Last message with {}: '{}' at {}", - partner_name, content, timestamp - ); - (Some(content), Some(timestamp)) - } - Ok(None) => { - println!("❌ No last message found for chat with {}", partner_name); - (None, None) - } - Err(e) => { - eprintln!("❌ Error fetching last message: {}", e); - (None, None) - } - }; - - // Получаем количество непрочитанных сообщений - let unread_count_result = sqlx::query( - "SELECT COUNT(*) as count FROM messages WHERE from_user = ? AND to_user = ? AND read = FALSE AND deleted_for_everyone = FALSE AND deleted_for_receiver = FALSE" - ) - .bind(&partner_name) - .bind(&username) - .fetch_one(&state.db_pool) - .await; - - let unread_count = match unread_count_result { - Ok(row) => row.get::("count"), - Err(e) => { - eprintln!("❌ Error counting unread messages: {}", e); - 0 - } - }; - - let chat = ChatPartner { - username: partner_name.clone(), - phone, - avatar_url, - last_message, - last_message_time, - unread_count, - }; - - println!( - "✅ Added chat with {} (phone: {}), last_message: {:?}, unread: {}", - chat.username, chat.phone, chat.last_message, chat.unread_count - ); - chats.push(chat); - } - Ok(None) => { - eprintln!("❌ User not found: {}", partner_username); - } - Err(e) => { - eprintln!( - "❌ Error fetching user details for {}: {}", - partner_username, e - ); - } - } - } - - // Сортируем по времени последнего сообщения (новые сверху) - chats.sort_by(|a, b| { - b.last_message_time - .unwrap_or(0) - .cmp(&a.last_message_time.unwrap_or(0)) - }); - - println!("🎉 Final chat list for {}: {} chats", username, chats.len()); - for chat in &chats { - println!( - " - {}: {:?} (unread: {})", - chat.username, chat.last_message, chat.unread_count - ); - } - - Json(chats) -} - -// New endpoint to search users -async fn search_users_handler( - State(state): State>, - Query(params): Query, - // headers: axum::http::HeaderMap, -) -> 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(Vec::new()); - // }; - - // Get current user from token - // let current_user = match verify_token(&state.db_pool, &token).await { - // Ok(Some(user)) => user, - // _ => return Json(Vec::new()), - // }; - - let search_pattern = format!("%{}%", params.q); - - let users = sqlx::query( - r#" - SELECT username, phone, avatar_url - FROM users - WHERE (username LIKE ? OR phone LIKE ?) - LIMIT 20 - "#, - ) - // AND username != ? - .bind(&search_pattern) - .bind(&search_pattern) - // .bind(¤t_user) - .fetch_all(&state.db_pool) - .await; - - match users { - Ok(rows) => { - let mut results = Vec::new(); - for row in rows { - results.push(ChatPartner { - username: row.get("username"), - phone: row.get("phone"), - avatar_url: row.get("avatar_url"), - last_message: None, - last_message_time: None, - unread_count: 0, - }); - } - Json(results) - } - Err(e) => { - eprintln!("Error searching users: {}", e); - Json(Vec::new()) - } - } -} - -// Mark messages as read endpoint -async fn mark_read_handler( - State(state): State>, - headers: axum::http::HeaderMap, - Json(payload): Json, -) -> 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 - let username = match verify_token(&state.db_pool, &token).await { - Ok(Some(user)) => user, - _ => return Json(serde_json::json!({"success": false, "error": "Invalid token"})), - }; - - // Проверяем, что текущий пользователь - это получатель сообщений - if username != payload.to_user { - return Json(serde_json::json!({"success": false, "error": "Unauthorized"})); - } - - match mark_messages_as_read(&state.db_pool, &payload.from_user, &payload.to_user).await { - Ok(_) => Json(serde_json::json!({"success": true})), - Err(e) => { - eprintln!("Error marking messages as read: {}", e); - Json(serde_json::json!({"success": false, "error": "Database error"})) - } - } -} - -// Delete message endpoint -async fn delete_message_handler( - State(state): State>, - headers: axum::http::HeaderMap, - Json(payload): Json, -) -> 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(DeleteMessageResponse { - success: false, - message: "No token".to_string(), - }); - }; - - // Get current user from token - let username = match verify_token(&state.db_pool, &token).await { - Ok(Some(user)) => user, - _ => { - return Json(DeleteMessageResponse { - success: false, - message: "Invalid token".to_string(), - }); - } - }; - - // Получаем информацию о сообщении - let message_result = sqlx::query("SELECT from_user, to_user FROM messages WHERE id = ?") - .bind(&payload.message_id) - .fetch_optional(&state.db_pool) - .await; - - let message = match message_result { - Ok(Some(row)) => row, - Ok(None) => { - return Json(DeleteMessageResponse { - success: false, - message: "Message not found".to_string(), - }); - } - Err(e) => { - eprintln!("Error fetching message: {}", e); - return Json(DeleteMessageResponse { - success: false, - message: "Database error".to_string(), - }); - } - }; - - let from_user: String = message.get("from_user"); - let to_user: String = message.get("to_user"); - - // Проверяем права доступа - if from_user != username && to_user != username { - return Json(DeleteMessageResponse { - success: false, - message: "You don't have permission to delete this message".to_string(), - }); - } - - if payload.for_everyone { - // Удаление для всех - if from_user != username { - return Json(DeleteMessageResponse { - success: false, - message: "Only the sender can delete message for everyone".to_string(), - }); - } - - let result = sqlx::query("UPDATE messages SET deleted_for_everyone = TRUE WHERE id = ?") - .bind(&payload.message_id) - .execute(&state.db_pool) - .await; - - match result { - Ok(_) => { - // Отправляем уведомление через WebSocket - let users = state.users.read().await; - if let Some(recipient_tx) = users.get(&to_user) { - let delete_msg = serde_json::json!({ - "type": "message_deleted", - "message_id": payload.message_id, - "for_everyone": true - }); - let _ = recipient_tx.send(Message::Text(delete_msg.to_string())); - } - - Json(DeleteMessageResponse { - success: true, - message: "Message deleted for everyone".to_string(), - }) - } - Err(e) => { - eprintln!("Error deleting message for everyone: {}", e); - Json(DeleteMessageResponse { - success: false, - message: "Database error".to_string(), - }) - } - } - } else { - // Удаление только для себя - let field_to_update = if username == from_user { - "deleted_for_sender" - } else { - "deleted_for_receiver" - }; - - let query = format!( - "UPDATE messages SET {} = TRUE WHERE id = ?", - field_to_update - ); - - let result = sqlx::query(&query) - .bind(&payload.message_id) - .execute(&state.db_pool) - .await; - - match result { - Ok(_) => Json(DeleteMessageResponse { - success: true, - message: "Message deleted for you".to_string(), - }), - Err(e) => { - eprintln!("Error deleting message for user: {}", e); - Json(DeleteMessageResponse { - success: false, - message: "Database error".to_string(), - }) - } - } - } -} - -// Upload avatar endpoint -async fn upload_avatar_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(UploadAvatarResponse { - success: false, - avatar_url: None, - message: "No token".to_string(), - }); - }; - - // Get current user from token - let username = match verify_token(&state.db_pool, &token).await { - Ok(Some(user)) => user, - _ => { - return Json(UploadAvatarResponse { - success: false, - avatar_url: None, - message: "Invalid token".to_string(), - }); - } - }; - - // Process multipart form - while let Some(field) = multipart.next_field().await.unwrap() { - let field_name = field.name().unwrap_or("").to_string(); - - if field_name == "avatar" { - let data = field.bytes().await.unwrap(); - - // Validate image size using config - if data.len() > state.config.files.max_avatar_size { - return Json(UploadAvatarResponse { - success: false, - avatar_url: None, - message: format!( - "Image too large (max {} bytes)", - state.config.files.max_avatar_size - ), - }); - } - - // Process image - match process_avatar(&data).await { - Ok(processed_data) => { - // Generate unique filename - let filename = format!("{}.jpg", Uuid::new_v4()); - let avatar_path = format!("{}/{}", state.config.files.avatars_dir, filename); - - // Save file - if let Err(e) = tokio::fs::write(&avatar_path, &processed_data).await { - eprintln!("Error saving avatar: {}", e); - return Json(UploadAvatarResponse { - success: false, - avatar_url: None, - message: "Error saving image".to_string(), - }); - } - - let avatar_url = format!("/avatars/{}", filename); - - // Update user in database - let result = sqlx::query("UPDATE users SET avatar_url = ? WHERE username = ?") - .bind(&avatar_url) - .bind(&username) - .execute(&state.db_pool) - .await; - - match result { - Ok(_) => { - return Json(UploadAvatarResponse { - success: true, - avatar_url: Some(avatar_url), - message: "Avatar uploaded successfully".to_string(), - }); - } - Err(e) => { - eprintln!("Error updating user avatar: {}", e); - return Json(UploadAvatarResponse { - success: false, - avatar_url: None, - message: "Database error".to_string(), - }); - } - } - } - Err(e) => { - return Json(UploadAvatarResponse { - success: false, - avatar_url: None, - message: e, - }); - } - } - } - } - - Json(UploadAvatarResponse { - success: false, - avatar_url: None, - message: "No avatar file provided".to_string(), - }) -} - -// Process and resize avatar -async fn process_avatar(data: &[u8]) -> Result, String> { - // Load image - let img = match image::load_from_memory(data) { - Ok(img) => img, - Err(_) => return Err("Invalid image format".to_string()), - }; - - // Resize image to 150x150 pixels - let resized = img.resize_to_fill(150, 150, image::imageops::FilterType::Lanczos3); - - // Convert to JPEG - let mut jpeg_data = Vec::new(); - match resized.write_to(&mut Cursor::new(&mut jpeg_data), image::ImageFormat::Jpeg) { - Ok(_) => Ok(jpeg_data), - Err(_) => Err("Error processing image".to_string()), - } -} - -async fn profile_handler( - State(state): State>, - headers: axum::http::HeaderMap, -) -> 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(UserProfile { - username: "user".to_string(), - phone: "+1234567890".to_string(), - avatar_url: None, - }); - }; - - // Get current user from token - let username = match verify_token(&state.db_pool, &token).await { - Ok(Some(user)) => user, - _ => { - return Json(UserProfile { - username: "user".to_string(), - phone: "+1234567890".to_string(), - avatar_url: None, - }); - } - }; - - // Get user details - let user_result = - sqlx::query("SELECT username, phone, avatar_url FROM users WHERE username = ?") - .bind(&username) - .fetch_optional(&state.db_pool) - .await; - - match user_result { - Ok(Some(row)) => Json(UserProfile { - username: row.get("username"), - phone: row.get("phone"), - avatar_url: row.get("avatar_url"), - }), - _ => Json(UserProfile { - username: "user".to_string(), - phone: "+1234567890".to_string(), - avatar_url: None, - }), - } -} - -// WebSocket handlers -async fn websocket_handler(ws: WebSocketUpgrade, State(state): State>) -> Response { - ws.on_upgrade(|socket| handle_websocket(socket, state)) -} - -async fn handle_websocket(socket: WebSocket, state: Arc) { - let (mut sender, mut receiver) = socket.split(); - let (tx, mut rx) = mpsc::unbounded_channel(); - - // Handle authentication - let username = match receiver.next().await { - Some(Ok(Message::Text(auth_msg))) => { - match serde_json::from_str::(&auth_msg) { - Ok(auth) => { - if let (Some(token), Some(auth_type)) = - (auth["token"].as_str(), auth["type"].as_str()) - { - if auth_type == "auth" { - match verify_token(&state.db_pool, token).await { - Ok(Some(user_username)) => { - if state.users.read().await.contains_key(&user_username) { - let _ = sender - .send(Message::Text( - serde_json::json!({ - "type": "error", - "content": "User already connected" - }) - .to_string(), - )) - .await; - return; - } - - state.users.write().await.insert(user_username.clone(), tx); - - let _ = sender - .send(Message::Text( - serde_json::json!({ - "type": "system", - "content": "Connected successfully" - }) - .to_string(), - )) - .await; - - broadcast_user_list(&state.users).await; - - if let Ok(messages) = - get_message_history(&state.db_pool, &user_username, "") - .await - { - for msg in messages { - let message_json = serde_json::to_string(&msg).unwrap(); - let _ = sender.send(Message::Text(message_json)).await; - } - } - - user_username - } - Ok(None) => { - let _ = sender - .send(Message::Text( - serde_json::json!({ - "type": "error", - "content": "Invalid or expired token" - }) - .to_string(), - )) - .await; - return; - } - Err(_) => { - let _ = sender - .send(Message::Text( - serde_json::json!({ - "type": "error", - "content": "Authentication error" - }) - .to_string(), - )) - .await; - return; - } - } - } else { - return; - } - } else { - return; - } - } - Err(_) => return, - } - } - _ => return, - }; - - println!("User {} connected", username); - - let state_clone = state.clone(); - let username_clone = username.clone(); - - let mut send_sender = sender; - let send_task = tokio::spawn(async move { - while let Some(message) = rx.recv().await { - if send_sender.send(message).await.is_err() { - break; - } - } - }); - - let recv_state = state.clone(); - let recv_task = tokio::spawn(async move { - while let Some(result) = receiver.next().await { - match result { - Ok(Message::Text(text)) => { - if let Ok(mut chat_msg) = serde_json::from_str::(&text) { - chat_msg.from_user = username.clone(); - - if let Err(e) = save_message(&recv_state.db_pool, &chat_msg).await { - eprintln!("Failed to save message: {}", e); - continue; - } - - let users = recv_state.users.read().await; - if let Some(recipient_tx) = users.get(&chat_msg.to_user) { - let message_json = serde_json::to_string(&chat_msg).unwrap(); - let _ = recipient_tx.send(Message::Text(message_json)); - } - - if let Some(sender_tx) = users.get(&username) { - let message_json = serde_json::to_string(&chat_msg).unwrap(); - let _ = sender_tx.send(Message::Text(message_json)); - } - } // else if let Ok(delete_msg) = serde_json::from_str::(&text) - // { - // Обработка удаления сообщений через WebSocket - // if let (Some(msg_type), Some(_message_id)) = ( - // delete_msg["type"].as_str(), - // delete_msg["message_id"].as_str(), - // ) { - // if msg_type == "delete_message" { - // let for_everyone = - // delete_msg["for_everyone"].as_bool().unwrap_or(false); - - // // Здесь можно добавить логику удаления через WebSocket - // // Пока что используем HTTP API - // } - // } - // } - } - Ok(Message::Close(_)) => break, - Err(_) => break, - _ => {} - } - } - }); - - tokio::select! { - _ = send_task => {}, - _ = recv_task => {}, - } - - state_clone.users.write().await.remove(&username_clone); - broadcast_user_list(&state_clone.users).await; - println!("User {} disconnected", username_clone); -} - -// Utility functions -fn generate_token() -> String { - thread_rng() - .sample_iter(&Alphanumeric) - .take(32) - .map(char::from) - .collect() -} - -async fn verify_token(pool: &SqlitePool, token: &str) -> Result, sqlx::Error> { - let result = sqlx::query( - "SELECT username FROM sessions WHERE token = ? AND expires_at > datetime('now')", - ) - .bind(token) - .fetch_optional(pool) - .await?; - - Ok(result.map(|row| row.get("username"))) -} - -async fn broadcast_user_list(users: &UserMap) { - let user_list: Vec = users.read().await.keys().cloned().collect(); - let user_list_msg = serde_json::json!({ - "type": "user_list", - "users": user_list - }); - - for tx in users.read().await.values() { - let _ = tx.send(Message::Text(user_list_msg.to_string())); - } -} - -async fn save_message(pool: &SqlitePool, msg: &ChatMessage) -> Result<(), sqlx::Error> { - sqlx::query( - "INSERT OR REPLACE INTO messages (id, from_user, to_user, content, timestamp, message_type) VALUES (?, ?, ?, ?, ?, ?)", - ) - .bind(&msg.id) - .bind(&msg.from_user) - .bind(&msg.to_user) - .bind(&msg.content) - .bind(msg.timestamp) - .bind(&msg.message_type) - .execute(pool) - .await?; - - Ok(()) -} - -async fn get_message_history( - pool: &SqlitePool, - username: &str, - other_user: &str, -) -> Result, sqlx::Error> { - // Если указан конкретный собеседник, помечаем сообщения как прочитанные - if !other_user.is_empty() { - mark_messages_as_read(pool, other_user, username).await?; - } - - let query = if other_user.is_empty() { - // Получаем все сообщения пользователя (исключая удаленные) - r#" - SELECT id, from_user, to_user, content, timestamp, message_type - FROM messages - WHERE (from_user = ? OR to_user = ?) - AND deleted_for_everyone = FALSE - AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE)) - ORDER BY timestamp - "# - } else { - // Получаем сообщения только с конкретным пользователем (исключая удаленные) - r#" - SELECT id, from_user, to_user, content, timestamp, message_type - FROM messages - WHERE ((from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?)) - AND deleted_for_everyone = FALSE - AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE)) - ORDER BY timestamp - "# - }; - - let rows = if other_user.is_empty() { - sqlx::query(query) - .bind(username) - .bind(username) - .bind(username) - .bind(username) - .fetch_all(pool) - .await? - } else { - sqlx::query(query) - .bind(username) - .bind(other_user) - .bind(other_user) - .bind(username) - .bind(username) - .bind(username) - .fetch_all(pool) - .await? - }; - - let mut messages = Vec::new(); - for row in rows { - let message = 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"), - }; - messages.push(message); - } - - Ok(messages) -} - -async fn mark_messages_as_read( - pool: &SqlitePool, - from_user: &str, - to_user: &str, -) -> Result<(), sqlx::Error> { - let result = sqlx::query( - "UPDATE messages SET read = TRUE WHERE from_user = ? AND to_user = ? AND read = FALSE AND deleted_for_everyone = FALSE" - ) - .bind(from_user) - .bind(to_user) - .execute(pool) - .await?; - - println!( - "✅ Marked {} messages as read from {} to {}", - result.rows_affected(), - from_user, - to_user - ); - Ok(()) -} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..90522a3 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,93 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub success: bool, + pub message: String, + pub username: Option, + pub token: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub phone: String, + pub username: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub phone: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct ValidateTokenResponse { + pub success: bool, + pub username: Option, + pub phone: Option, + pub avatar_url: Option, + pub message: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ValidateTokenRequest { + pub token: String, +} + +#[derive(Debug, Serialize)] +pub struct ChatPartner { + pub username: String, + pub phone: String, + pub avatar_url: Option, + pub last_message: Option, + pub last_message_time: Option, + pub unread_count: i64, +} + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub q: String, +} + +#[derive(Debug, Deserialize)] +pub struct MarkReadRequest { + pub from_user: String, + pub to_user: String, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteMessageRequest { + pub message_id: String, + pub for_everyone: bool, +} + +#[derive(Debug, Serialize)] +pub struct DeleteMessageResponse { + pub success: bool, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub struct UploadAvatarResponse { + pub success: bool, + pub avatar_url: Option, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub struct UserProfile { + pub username: String, + pub phone: String, + pub avatar_url: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ChatMessage { + pub id: String, + pub from_user: String, + pub to_user: String, + pub content: String, + pub timestamp: i64, + pub message_type: String, +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..18fa99b --- /dev/null +++ b/src/state.rs @@ -0,0 +1,14 @@ +use crate::conf; +use axum::extract::ws::Message; +use sqlx::sqlite::SqlitePool; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::{RwLock, mpsc}; + +pub type UserMap = Arc>>>; + +#[derive(Clone)] +pub struct AppState { + pub users: UserMap, + pub db_pool: SqlitePool, + pub config: conf::AppConfig, +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f7a56cd --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,164 @@ +use crate::model; +// use crate::state; +// use axum::extract::ws::Message; +use rand::distributions::Alphanumeric; +use rand::{Rng, thread_rng}; +use sqlx::{Row, SqlitePool}; +use std::io::Cursor; + +// Utility functions +pub fn generate_token() -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect() +} + +pub async fn verify_token(pool: &SqlitePool, token: &str) -> Result, sqlx::Error> { + let result = sqlx::query( + "SELECT username FROM sessions WHERE token = ? AND expires_at > datetime('now')", + ) + .bind(token) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row| row.get("username"))) +} + +pub async fn mark_messages_as_read( + pool: &SqlitePool, + from_user: &str, + to_user: &str, +) -> Result<(), sqlx::Error> { + let result = sqlx::query( + "UPDATE messages SET read = TRUE WHERE from_user = ? AND to_user = ? AND read = FALSE AND deleted_for_everyone = FALSE" + ) + .bind(from_user) + .bind(to_user) + .execute(pool) + .await?; + + println!( + "✅ Marked {} messages as read from {} to {}", + result.rows_affected(), + from_user, + to_user + ); + Ok(()) +} + +// Process and resize avatar +pub async fn process_avatar(data: &[u8]) -> Result, String> { + // Load image + let img = match image::load_from_memory(data) { + Ok(img) => img, + Err(_) => return Err("Invalid image format".to_string()), + }; + + // Resize image to 150x150 pixels + let resized = img.resize_to_fill(150, 150, image::imageops::FilterType::Lanczos3); + + // Convert to JPEG + let mut jpeg_data = Vec::new(); + match resized.write_to(&mut Cursor::new(&mut jpeg_data), image::ImageFormat::Jpeg) { + Ok(_) => Ok(jpeg_data), + Err(_) => Err("Error processing image".to_string()), + } +} + +pub async fn get_message_history( + pool: &SqlitePool, + username: &str, + other_user: &str, +) -> Result, sqlx::Error> { + // Если указан конкретный собеседник, помечаем сообщения как прочитанные + if !other_user.is_empty() { + mark_messages_as_read(pool, other_user, username).await?; + } + + let query = if other_user.is_empty() { + // Получаем все сообщения пользователя (исключая удаленные) + r#" + SELECT id, from_user, to_user, content, timestamp, message_type + FROM messages + WHERE (from_user = ? OR to_user = ?) + AND deleted_for_everyone = FALSE + AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE)) + ORDER BY timestamp + "# + } else { + // Получаем сообщения только с конкретным пользователем (исключая удаленные) + r#" + SELECT id, from_user, to_user, content, timestamp, message_type + FROM messages + WHERE ((from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?)) + AND deleted_for_everyone = FALSE + AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE)) + ORDER BY timestamp + "# + }; + + let rows = if other_user.is_empty() { + sqlx::query(query) + .bind(username) + .bind(username) + .bind(username) + .bind(username) + .fetch_all(pool) + .await? + } else { + sqlx::query(query) + .bind(username) + .bind(other_user) + .bind(other_user) + .bind(username) + .bind(username) + .bind(username) + .fetch_all(pool) + .await? + }; + + let mut messages = Vec::new(); + for row in rows { + 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"), + }; + messages.push(message); + } + + Ok(messages) +} + +// pub async fn broadcast_user_list(users: &state::UserMap) { +// let user_list: Vec = users.read().await.keys().cloned().collect(); +// let user_list_msg = serde_json::json!({ +// "type": "user_list", +// "users": user_list +// }); + +// for tx in users.read().await.values() { +// let _ = tx.send(Message::Text(user_list_msg.to_string())); +// } +// } + +pub async fn save_message(pool: &SqlitePool, msg: &model::ChatMessage) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT OR REPLACE INTO messages (id, from_user, to_user, content, timestamp, message_type) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&msg.id) + .bind(&msg.from_user) + .bind(&msg.to_user) + .bind(&msg.content) + .bind(msg.timestamp) + .bind(&msg.message_type) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/web/css/index.css b/web/css/index.css new file mode 100644 index 0000000..ca33679 --- /dev/null +++ b/web/css/index.css @@ -0,0 +1,1287 @@ +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #e9ecef; + --text-primary: #2c3e50; + --text-secondary: #7f8c8d; + --border-color: #e9ecef; + --primary-color: #3498db; + --primary-hover: #2980b9; + --success-color: #27ae60; + --error-color: #e74c3c; + --warning-color: #f39c12; + --shadow: rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --bg-tertiary: #3d3d3d; + --text-primary: #ffffff; + --text-secondary: #b0b0b0; + --border-color: #444444; + --primary-color: #2980b9; + --primary-hover: #3498db; + --success-color: #2ecc71; + --error-color: #e74c3c; + --warning-color: #f39c12; + --shadow: rgba(0, 0, 0, 0.3); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + height: 100vh; + overflow: hidden; + transition: background 0.3s ease; +} + +[data-theme="dark"] body { + background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); +} + +/* Auth Container */ +.auth-container { + width: 100%; + max-width: 400px; + margin: 0 auto; + background: var(--bg-primary); + padding: 30px 20px; + border-radius: 0; + box-shadow: 0 0 0 rgba(0, 0, 0, 0); + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + transition: all 0.3s ease; +} + +.auth-tabs { + display: flex; + margin-bottom: 25px; + border-bottom: 1px solid var(--border-color); +} + +.auth-tab { + flex: 1; + padding: 15px 10px; + text-align: center; + cursor: pointer; + border-bottom: 3px solid transparent; + transition: all 0.3s ease; + font-size: 14px; + color: var(--text-primary); +} + +.auth-tab.active { + border-bottom-color: var(--primary-color); + color: var(--primary-color); + font-weight: 600; +} + +.auth-form { + display: none; +} + +.auth-form.active { + display: block; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--text-primary); + font-size: 14px; +} + +.form-control { + width: 100%; + padding: 12px 15px; + border: 2px solid var(--border-color); + border-radius: 8px; + font-size: 16px; + outline: none; + transition: border-color 0.3s ease; + background: var(--bg-primary); + color: var(--text-primary); +} + +.form-control:focus { + border-color: var(--primary-color); +} + +.btn { + width: 100%; + padding: 12px; + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + font-weight: 600; + transition: all 0.3s ease; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4); +} + +.alert { + padding: 10px 12px; + border-radius: 6px; + margin-bottom: 15px; + display: none; + font-size: 14px; +} + +.alert-success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.alert-error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +/* Chat Container */ +.chat-container { + width: 100%; + height: 100vh; + background: var(--bg-primary); + display: none; + flex-direction: column; + transition: background 0.3s ease; +} + +/* Mobile Header */ +.mobile-header { + display: none; + padding: 15px; + background: var(--bg-secondary); + color: var(--text-primary); + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 100; +} + +.menu-btn { + background: none; + border: none; + color: var(--text-primary); + font-size: 20px; + cursor: pointer; + padding: 5px; +} + +.mobile-title { + font-weight: 600; + font-size: 18px; +} + +.profile-header-btn { + background: none; + border: none; + cursor: pointer; + padding: 5px; + border-radius: 50%; + transition: background 0.3s ease; +} + +.profile-header-btn:hover { + background: var(--bg-tertiary); +} + +.profile-header-avatar { + width: 35px; + height: 35px; + border-radius: 50%; + object-fit: cover; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; +} + +.profile-header-avatar img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.chats-panel { + width: 350px; + background: var(--bg-secondary); + color: var(--text-primary); + display: flex; + flex-direction: column; + height: 100vh; + transition: transform 0.3s ease; + border-right: 1px solid var(--border-color); +} + +.chats-header { + padding: 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.chats-tabs { + display: flex; + border-bottom: 1px solid var(--border-color); +} + +.chat-tab { + flex: 1; + padding: 15px 10px; + text-align: center; + cursor: pointer; + border-bottom: 3px solid transparent; + transition: all 0.3s ease; + font-size: 14px; + color: var(--text-primary); +} + +.chat-tab.active { + border-bottom-color: var(--primary-color); + color: var(--primary-color); + font-weight: 600; +} + +.search-container { + padding: 15px; + border-bottom: 1px solid var(--border-color); + display: none; +} + +.search-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 20px; + background: var(--bg-tertiary); + color: var(--text-primary); + outline: none; + font-size: 14px; +} + +.search-input::placeholder { + color: var(--text-secondary); +} + +.search-results { + max-height: 300px; + overflow-y: auto; +} + +.chat-list { + list-style: none; + flex: 1; + overflow-y: auto; +} + +.chat-item { + padding: 12px 15px; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; +} + +.chat-item:hover { + background: var(--bg-tertiary); +} + +.chat-item.active { + background: var(--primary-color); + color: white; +} + +.chat-avatar { + width: 45px; + height: 45px; + border-radius: 50%; + margin-right: 12px; + object-fit: cover; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 18px; + flex-shrink: 0; +} + +.chat-avatar img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.chat-info { + flex: 1; + min-width: 0; +} + +.chat-username { + font-weight: 600; + margin-bottom: 3px; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-phone { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 3px; +} + +.chat-last-message { + font-size: 12px; + color: var(--text-secondary); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + flex-shrink: 0; + margin-left: 10px; +} + +.chat-time { + font-size: 10px; + color: var(--text-secondary); + margin-bottom: 5px; +} + +.unread-badge { + background: var(--error-color); + color: white; + border-radius: 50%; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; +} + +.chat-panel { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-primary); + height: 100vh; + position: relative; +} + +.chat-header { + padding: 12px 15px; + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 10; + min-height: 64px; +} + +.chat-header-info { + display: flex; + align-items: center; + flex: 1; +} + +.back-btn { + display: none; + background: none; + border: none; + font-size: 18px; + margin-right: 10px; + cursor: pointer; + color: var(--primary-color); +} + +.chat-header-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + margin-right: 10px; + object-fit: cover; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 16px; + flex-shrink: 0; +} + +.chat-header-avatar img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.chat-header-text { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.chat-header-username { + font-weight: 600; + font-size: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); +} + +.chat-header-phone { + font-size: 12px; + color: var(--text-secondary); +} + +.chat-messages { + flex: 1; + padding: 15px; + overflow-y: auto; + background: var(--bg-primary); + display: flex; + flex-direction: column; + min-height: 0; +} + +.message { + max-width: 85%; + margin: 8px 0; + padding: 12px 15px; + border-radius: 18px; + position: relative; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.incoming { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + align-self: flex-start; + border-bottom-left-radius: 5px; + box-shadow: 0 2px 5px var(--shadow); + color: var(--text-primary); +} + +.outgoing { + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); + color: white; + align-self: flex-end; + margin-left: auto; + border-bottom-right-radius: 5px; + box-shadow: 0 2px 5px rgba(52, 152, 219, 0.3); +} + +.message-sender { + font-size: 11px; + font-weight: 600; + margin-bottom: 4px; + opacity: 0.8; +} + +.message-content { + margin: 6px 0; + line-height: 1.4; + font-size: 14px; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.message-time { + font-size: 10px; + text-align: right; + opacity: 0.7; + margin-top: 4px; +} + +.chat-input-container { + position: sticky; + bottom: 0; + background: var(--bg-primary); + border-top: 1px solid var(--border-color); + padding: 15px; + z-index: 10; +} + +.chat-input-area { + display: flex; + gap: 10px; + align-items: flex-end; + max-height: 120px; +} + +.message-input { + flex: 1; + padding: 12px 15px; + border: 2px solid var(--border-color); + border-radius: 25px; + outline: none; + font-size: 14px; + transition: border-color 0.3s ease; + background: var(--bg-primary); + color: var(--text-primary); + resize: none; + min-height: 44px; + max-height: 100px; + font-family: inherit; + line-height: 1.4; +} + +.message-input:focus { + border-color: var(--primary-color); +} + +.send-button { + padding: 12px 20px; + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); + color: white; + border: none; + border-radius: 25px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.3s ease; + flex-shrink: 0; + min-height: 44px; +} + +.send-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4); +} + +.send-button:disabled { + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.add-button { + padding: 12px 20px; + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); + color: white; + border: none; + border-radius: 25px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.3s ease; + flex-shrink: 0; + min-height: 44px; +} + +.add-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4); +} + +.add-button:disabled { + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.system-message { + text-align: center; + color: var(--text-secondary); + font-style: italic; + margin: 10px 0; + font-size: 12px; + padding: 8px; + background: var(--bg-secondary); + border-radius: 10px; + align-self: center; + max-width: 80%; +} + +.empty-state { + text-align: center; + color: var(--text-secondary); + padding: 30px 15px; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.empty-state h3 { + margin-bottom: 8px; + font-weight: 300; + font-size: 16px; +} + +.empty-state p { + font-size: 14px; +} + +/* Стили для кнопки удаления сообщения */ +.message-actions { + position: absolute; + top: 5px; + right: 8px; + opacity: 0; + transition: opacity 0.3s ease; +} + +.message:hover .message-actions { + opacity: 1; +} + +.delete-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 4px; + border-radius: 3px; +} + +.delete-btn:hover { + background: rgba(0, 0, 0, 0.1); + color: var(--error-color); +} + +/* Стили для модального окна профиля */ +.profile-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.profile-modal { + background: var(--bg-primary); + border-radius: 15px; + padding: 0; + max-width: 400px; + width: 90%; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + margin: 20px; + overflow: hidden; +} + +.profile-header { + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); + padding: 30px 20px; + text-align: center; + color: white; + position: relative; +} + +.profile-avatar-large { + width: 100px; + height: 100px; + border-radius: 50%; + margin: 0 auto 15px; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 36px; + font-weight: 600; + border: 4px solid white; + position: relative; + cursor: pointer; + transition: transform 0.3s ease; +} + +.profile-avatar-large:hover { + transform: scale(1.05); +} + +.profile-avatar-large img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.avatar-upload-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + color: white; + font-size: 12px; + text-align: center; + padding: 10px; +} + +.profile-avatar-large:hover .avatar-upload-overlay { + opacity: 1; +} + +.profile-name { + font-size: 20px; + font-weight: 600; + margin-bottom: 5px; +} + +.profile-phone { + font-size: 14px; + opacity: 0.9; +} + +.profile-content { + padding: 25px; +} + +.profile-section { + margin-bottom: 25px; +} + +.profile-section-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 15px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.profile-section-title i { + font-size: 18px; +} + +.profile-info { + background: var(--bg-secondary); + border-radius: 10px; + padding: 15px; +} + +.info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border-color); +} + +.info-item:last-child { + border-bottom: none; +} + +.info-label { + font-size: 14px; + color: var(--text-secondary); +} + +.info-value { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +.profile-actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile-btn { + padding: 12px 16px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 10px; +} + +.profile-btn-primary { + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); + color: white; +} + +.profile-btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4); +} + +.profile-btn-danger { + background: var(--error-color); + color: white; +} + +.profile-btn-danger:hover { + background: #c0392b; + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(231, 76, 60, 0.4); +} + +.profile-btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.profile-btn-secondary:hover { + background: var(--border-color); +} + +/* Стили для скрытого input file */ +.avatar-input { + display: none; +} + +/* Scrollbars */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Desktop Layout */ +.desktop-layout { + display: flex; + height: 100vh; + width: 100%; +} + +/* Connection status */ +.connection-status { + position: fixed; + top: 10px; + right: 10px; + padding: 8px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + z-index: 1000; + transition: all 0.3s ease; +} + +.connection-status.connected { + background: var(--success-color); + color: white; +} + +.connection-status.disconnected { + background: var(--error-color); + color: white; +} + +.connection-status.connecting { + background: var(--warning-color); + color: white; +} + +/* Mobile Styles */ +@media (max-width: 768px) { + .auth-container { + max-width: 100%; + padding: 20px; + border-radius: 0; + } + + .desktop-layout { + position: relative; + height: 100%; + } + + .chats-panel { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + z-index: 100; + transform: translateX(-100%); + } + + .chats-panel.active { + transform: translateX(0); + } + + .chat-panel { + width: 100%; + height: 100vh; + } + + .mobile-header { + display: flex; + } + + .back-btn { + display: block; + } + + .message { + max-width: 90%; + margin: 6px 0; + } + + .modal { + margin: 15px; + } + + .modal-buttons { + flex-direction: column; + } + + .modal-btn { + width: 100%; + } + + .chat-input-container { + padding: 10px; + padding-bottom: max(10px, env(safe-area-inset-bottom)); + } + + .chat-input-area { + gap: 8px; + } + + .message-input { + padding: 10px 12px; + min-height: 44px; + font-size: 16px; + } + + .send-button { + padding: 10px 16px; + min-height: 44px; + font-size: 14px; + } + + .connection-status { + top: 60px; + right: 10px; + } +} + +@media (max-width: 480px) { + .auth-container { + padding: 15px; + } + + .auth-tab { + padding: 12px 8px; + font-size: 13px; + } + + .form-control { + padding: 10px 12px; + font-size: 16px; + } + + .btn { + padding: 12px; + } + + .chat-header { + padding: 10px 12px; + min-height: 60px; + } + + .chat-messages { + padding: 10px; + padding-bottom: 70px; + } + + .message { + padding: 10px 12px; + max-width: 95%; + margin: 4px 0; + } + + .message-content { + font-size: 14px; + } + + .chat-input-container { + padding: 8px; + padding-bottom: max(8px, env(safe-area-inset-bottom)); + } + + .message-input { + padding: 8px 12px; + font-size: 16px; + } + + .send-button { + padding: 8px 14px; + font-size: 14px; + } + + .mobile-title { + font-size: 16px; + } + + .chat-header-username { + font-size: 15px; + } +} + +/* Very small screens */ +@media (max-width: 320px) { + .chat-avatar { + width: 40px; + height: 40px; + font-size: 16px; + margin-right: 8px; + } + + .chat-header-avatar { + width: 35px; + height: 35px; + font-size: 14px; + } + + .chat-item { + padding: 10px 12px; + } + + .chat-input-area { + gap: 6px; + } + + .message-input { + padding: 8px 10px; + } + + .send-button { + padding: 8px 12px; + min-width: 60px; + } +} + +/* Safe area support for notches */ +@supports (padding: max(0px)) { + .chat-input-container { + padding-bottom: max(15px, env(safe-area-inset-bottom)); + } + + @media (max-width: 768px) { + .chat-input-container { + padding-bottom: max(10px, env(safe-area-inset-bottom)); + } + } + + @media (max-width: 480px) { + .chat-input-container { + padding-bottom: max(8px, env(safe-area-inset-bottom)); + } + } +} + +/* Prevent body scroll when modal is open */ +body.modal-open { + overflow: hidden; +} + +/* Theme transition */ +* { + transition: + background-color 0.3s ease, + color 0.3s ease, + border-color 0.3s ease; +} + +/* Ensure messages are always fully visible */ +.chat-messages-container { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + position: relative; +} + +/* Стили для модального окна удаления */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background: var(--bg-primary); + border-radius: 10px; + padding: 20px; + max-width: 350px; + width: 90%; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + margin: 20px; +} + +.modal h3 { + margin-bottom: 12px; + color: var(--text-primary); + font-size: 18px; +} + +.modal p { + margin-bottom: 18px; + color: var(--text-secondary); + font-size: 14px; +} + +.modal-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + flex-wrap: wrap; +} + +.modal-btn { + padding: 8px 16px; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; +} + +.modal-btn-cancel { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.modal-btn-cancel:hover { + background: var(--border-color); +} + +.modal-btn-delete { + background: var(--error-color); + color: white; +} + +.modal-btn-delete:hover { + background: #c0392b; +} + +.modal-btn-delete-for-me { + background: var(--primary-color); + color: white; +} + +.modal-btn-delete-for-me:hover { + background: var(--primary-hover); +} diff --git a/web/index.html b/web/index.html index 7dcd861..5fbc485 100644 --- a/web/index.html +++ b/web/index.html @@ -1,2391 +1,348 @@ - + - - - - Secure Chat App - - - - - - - -
-
-
Login
-
Register
-
- - - - -
-
- - -
-
- - -
- -
- - -
-
- - -
-
- - -
-
- - -
- -
-
- - -
- -
- -
Secure Chat
- +
+ Register +
+
+ + + + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
- -
- -
-
-

Chats

- +
Secure Chat
+ +
+ +
+ +
+
+

Chats

+ +
+ +
+
+ My Chats
+
+ New Chat +
+
+ + +
+ +
+
+ + +
    + +
+
+ + +
+
+
+ +
+ +
+
+
+ Select a chat +
+
+ Choose someone to message +
+
+
+ +
+ +
+
+
+

Welcome to Secure Chat

+

Select a chat to start messaging

+
+
+
+ +
+
+ + + +
+
+
+
+
+ + + + + + -
- - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/web/js/avatar.js b/web/js/avatar.js new file mode 100644 index 0000000..9df78c5 --- /dev/null +++ b/web/js/avatar.js @@ -0,0 +1,72 @@ +function updateAllAvatars() { + const avatarContent = currentUserAvatar + ? `${currentUser}` + : currentUser.charAt(0).toUpperCase(); + + // Update all avatar locations (убран profileHeaderAvatarMain) + const avatarLocations = [ + "profileHeaderAvatar", + "profileHeaderAvatarDesktop", + "currentUserAvatar", + ]; + + avatarLocations.forEach((id) => { + const element = document.getElementById(id); + if (element) { + element.innerHTML = avatarContent; + } + }); + + // Update profile modal avatar + updateProfileModal(); +} + +// Function to upload avatar +async function uploadAvatar(file) { + const formData = new FormData(); + formData.append("avatar", file); + + try { + const response = await fetch("/api/upload-avatar", { + method: "POST", + headers: { + Authorization: `Bearer ${currentToken}`, + }, + body: formData, + }); + + const data = await response.json(); + if (data.success) { + currentUserAvatar = data.avatar_url; + updateAllAvatars(); + showAlert("Avatar uploaded successfully!", "success"); + loadChats(); // Refresh chats to show updated avatar + } else { + showAlert(data.message, "error"); + } + } catch (error) { + console.error("Error uploading avatar:", error); + showAlert("Error uploading avatar", "error"); + } +} + +// Avatar upload handler +document.getElementById("avatarInput").addEventListener("change", function (e) { + const file = e.target.files[0]; + if (file) { + // Validate file type + if (!file.type.startsWith("image/")) { + showAlert("Please select an image file", "error"); + return; + } + + // Validate file size (5MB) + if (file.size > 5 * 1024 * 1024) { + showAlert("Image must be less than 5MB", "error"); + return; + } + + uploadAvatar(file); + e.target.value = ""; // Reset input + } +}); diff --git a/web/js/chats.js b/web/js/chats.js new file mode 100644 index 0000000..67f8624 --- /dev/null +++ b/web/js/chats.js @@ -0,0 +1,184 @@ +async function selectChat(username, phone, avatarUrl) { + if (!username) return; + + currentRecipient = username; + + // Update mobile header + document.getElementById("mobileChatTitle").textContent = username; + + // Update chat header with recipient info + const chatHeaderInfo = document.querySelector(".chat-header-info"); + const avatarContent = avatarUrl + ? `${username}` + : username.charAt(0).toUpperCase(); + + chatHeaderInfo.innerHTML = ` + +
${avatarContent}
+
+
${username}
+
${phone}
+
+ `; + + // Помечаем сообщения как прочитанные при открытии чата + await markMessagesAsRead(username); + + // Update active chat in list + document.querySelectorAll(".chat-item").forEach((item) => { + item.classList.remove("active"); + const usernameElement = item.querySelector(".chat-username"); + if (usernameElement && usernameElement.textContent === username) { + item.classList.add("active"); + } + }); + + // Enable message input + document.getElementById("sendButton").disabled = false; + document.getElementById("messageInput").disabled = false; + + // Focus on input after a short delay to ensure UI is updated + setTimeout(() => { + document.getElementById("messageInput").focus(); + }, 100); + + displayChatHistory(username); +} + +function displayChatHistory(username) { + const chatMessages = document.getElementById("chatMessages"); + chatMessages.innerHTML = ""; + + if (messageHistory[username] && messageHistory[username].length > 0) { + messageHistory[username].forEach((msg) => displayMessage(msg, false)); + // Scroll to bottom after rendering + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 50); + } else { + chatMessages.innerHTML = ` +
+

Start a conversation

+

Send a message to start chatting with ${username}

+
+ `; + } +} + +function renderChatsList() { + const chatsList = document.getElementById("chatsList"); + chatsList.innerHTML = ""; + + console.log("Rendering chats:", chats); + + if (chats.length === 0) { + const emptyState = document.createElement("div"); + emptyState.className = "empty-state"; + emptyState.innerHTML = ` +

No chats yet

+

Start a new conversation by clicking "New Chat"

+ `; + chatsList.appendChild(emptyState); + return; + } + + chats.forEach((chat) => { + const li = document.createElement("li"); + li.className = "chat-item"; + if (chat.username === currentRecipient) { + li.classList.add("active"); + } + + const lastMessageTime = chat.last_message_time + ? formatTime(chat.last_message_time) + : ""; + const lastMessage = chat.last_message || "No messages yet"; + + const avatarContent = chat.avatar_url + ? `${chat.username}` + : chat.username.charAt(0).toUpperCase(); + + li.innerHTML = ` +
${avatarContent}
+
+
${chat.username}
+
${chat.phone}
+
${lastMessage}
+
+
+
${lastMessageTime}
+ ${chat.unread_count > 0 ? `
${chat.unread_count}
` : ""} +
+ `; + + li.onclick = () => { + selectChat(chat.username, chat.phone, chat.avatar_url); + if (isMobile) { + hideChatsPanel(); + } + }; + chatsList.appendChild(li); + }); +} + +function showChatTab(tab) { + currentChatTab = tab; + document + .querySelectorAll(".chat-tab") + .forEach((tab) => tab.classList.remove("active")); + + if (tab === "existing") { + document.querySelector(".chat-tab:nth-child(1)").classList.add("active"); + document.getElementById("searchContainer").style.display = "none"; + renderChatsList(); + } else { + document.querySelector(".chat-tab:nth-child(2)").classList.add("active"); + document.getElementById("searchContainer").style.display = "block"; + document.getElementById("searchResults").innerHTML = + '

Search for users by phone or username

'; + document.getElementById("searchInput").value = ""; + } +} + +async function loadChats() { + try { + console.log("Loading chats for user:", currentUser); + const response = await fetch("/api/chats", { + headers: { Authorization: `Bearer ${currentToken}` }, + }); + + console.log("Chats response status:", response.status); + if (response.ok) { + chats = await response.json(); + console.log("Loaded chats:", chats); + renderChatsList(); + } else { + console.error("Failed to load chats, status:", response.status); + } + } catch (error) { + console.error("Error loading chats:", error); + } +} + +// Mobile navigation functions +function toggleChatsPanel() { + const chatsPanel = document.getElementById("chatsPanel"); + chatsPanel.classList.toggle("active"); +} + +function showChatsPanel() { + const chatsPanel = document.getElementById("chatsPanel"); + chatsPanel.classList.add("active"); +} + +function hideChatsPanel() { + const chatsPanel = document.getElementById("chatsPanel"); + chatsPanel.classList.remove("active"); +} + +// Close chats panel when clicking on overlay (mobile) +document.getElementById("chatsPanel").addEventListener("click", function (e) { + if (e.target === this && isMobile) { + hideChatsPanel(); + } +}); diff --git a/web/js/connection.js b/web/js/connection.js new file mode 100644 index 0000000..676a564 --- /dev/null +++ b/web/js/connection.js @@ -0,0 +1,128 @@ +function reconnectWebSocket() { + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + updateConnectionStatus( + "connecting", + `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`, + ); + + reconnectTimeout = setTimeout(() => { + console.log(`Attempting to reconnect... (${reconnectAttempts})`); + connectWebSocket(); + }, RECONNECT_DELAY); + } else { + updateConnectionStatus( + "disconnected", + "Failed to connect. Please refresh the page.", + ); + showAlert("Connection lost. Please refresh the page.", "error"); + } +} + +function clearReconnection() { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + reconnectAttempts = 0; +} + +function updateConnectionStatus(status, message = "") { + const statusElement = document.getElementById("connectionStatus"); + statusElement.textContent = message || status; + statusElement.className = `connection-status ${status}`; + statusElement.style.display = "block"; + + if (status === "connected") { + setTimeout(() => { + statusElement.style.display = "none"; + }, 3000); + } +} + +function connectWebSocket() { + // Clear any existing reconnection attempts + clearReconnection(); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + updateConnectionStatus("connecting", "Connecting..."); + + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + console.log("WebSocket connected"); + updateConnectionStatus("connected", "Connected"); + clearReconnection(); + + ws.send( + JSON.stringify({ + type: "auth", + token: currentToken, + }), + ); + addSystemMessage("Connected to chat server"); + }; + + ws.onmessage = (event) => { + console.log("Received message:", event.data); + try { + const data = JSON.parse(event.data); + + if (data.type === "error") { + showAlert(data.content, "error"); + if ( + data.content.includes("Invalid") || + data.content.includes("expired") || + data.content.includes("auth") + ) { + logout(); + } + } else if (data.type === "system") { + addSystemMessage(data.content); + } else if (data.type === "message_deleted") { + // Handle message deletion from server + const messageElement = document.querySelector( + `[data-message-id="${data.message_id}"]`, + ); + if (messageElement) { + messageElement.remove(); + } + + // Update chat history + if (messageHistory[currentRecipient]) { + messageHistory[currentRecipient] = messageHistory[ + currentRecipient + ].filter((msg) => msg.id !== data.message_id); + } + + // Reload chats to update last message + loadChats(); + } else { + handleChatMessage(data); + } + } catch (e) { + console.error("Error parsing message:", e, "Raw data:", event.data); + } + }; + + ws.onclose = (event) => { + console.log("WebSocket disconnected:", event.code, event.reason); + updateConnectionStatus("disconnected", "Disconnected"); + addSystemMessage("Disconnected from server"); + document.getElementById("sendButton").disabled = true; + document.getElementById("messageInput").disabled = true; + + // Attempt to reconnect if this wasn't a normal closure + if (event.code !== 1000 && event.code !== 1001) { + reconnectWebSocket(); + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + updateConnectionStatus("disconnected", "Connection error"); + addSystemMessage("Connection error"); + }; +} diff --git a/web/js/deletemodal.js b/web/js/deletemodal.js new file mode 100644 index 0000000..a02e707 --- /dev/null +++ b/web/js/deletemodal.js @@ -0,0 +1,19 @@ +document.getElementById("deleteModal").addEventListener("click", function (e) { + if (e.target === this) { + closeDeleteModal(); + } +}); + +// Modal functions +function showDeleteModal(messageId) { + messageToDelete = messageId; + document.getElementById("deleteModal").style.display = "flex"; + document.getElementById("deleteModal").style.zIndex = 10; + document.body.classList.add("modal-open"); +} + +function closeDeleteModal() { + messageToDelete = null; + document.getElementById("deleteModal").style.display = "none"; + document.body.classList.remove("modal-open"); +} diff --git a/web/js/index.js b/web/js/index.js new file mode 100644 index 0000000..c0ace07 --- /dev/null +++ b/web/js/index.js @@ -0,0 +1,126 @@ +let ws = null; +let currentUser = ""; +let currentToken = ""; +let currentRecipient = ""; +let currentUserAvatar = ""; +let currentUserPhone = ""; +let chats = []; +let searchResults = []; +let messageHistory = {}; +let currentChatTab = "existing"; +let messageToDelete = null; +let isMobile = window.innerWidth <= 768; +let reconnectTimeout = null; +let reconnectAttempts = 0; +const MAX_RECONNECT_ATTEMPTS = 5; +const RECONNECT_DELAY = 3000; + +function changePhoneNumber() { + const newPhone = prompt("Enter your new phone number:", currentUserPhone); + if (newPhone && newPhone.trim() !== "") { + // In a real app, you would send this to the server + currentUserPhone = newPhone.trim(); + localStorage.setItem("chatUserPhone", currentUserPhone); + updateProfileModal(); + showAlert("Phone number updated successfully!", "success"); + } +} + +function showTab(tabName) { + document + .querySelectorAll(".auth-tab") + .forEach((tab) => tab.classList.remove("active")); + document + .querySelectorAll(".auth-form") + .forEach((form) => form.classList.remove("active")); + + if (tabName === "login") { + document.querySelector(".auth-tab:nth-child(1)").classList.add("active"); + document.getElementById("loginForm").classList.add("active"); + } else { + document.querySelector(".auth-tab:nth-child(2)").classList.add("active"); + document.getElementById("registerForm").classList.add("active"); + } + + hideAlert(); +} + +function showAlert(message, type) { + const alert = document.getElementById("alert"); + alert.textContent = message; + alert.className = `alert alert-${type}`; + alert.style.display = "block"; + + // Auto hide after 5 seconds + setTimeout(hideAlert, 5000); +} + +function hideAlert() { + document.getElementById("alert").style.display = "none"; +} + +async function validateToken(token, username) { + try { + const response = await fetch("/api/validate-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + + const data = await response.json(); + + if (data.success) { + currentUser = data.username; + currentToken = token; + currentUserAvatar = data.avatar_url; + + localStorage.setItem("chatToken", token); + localStorage.setItem("chatUsername", data.username); + + document.getElementById("authContainer").style.display = "none"; + document.getElementById("chatContainer").style.display = "flex"; + + updateUserProfile(); + loadChats(); + connectWebSocket(); + } else { + localStorage.removeItem("chatToken"); + localStorage.removeItem("chatUsername"); + showAlert("Session expired. Please login again.", "error"); + } + } catch (error) { + console.error("Token validation failed:", error); + localStorage.removeItem("chatToken"); + localStorage.removeItem("chatUsername"); + showAlert("Session validation failed. Please login again.", "error"); + } +} + +function updateUserProfile() { + document.getElementById("currentUsername").textContent = currentUser; + updateAllAvatars(); +} + +function generateId() { + return Date.now().toString(36) + Math.random().toString(36).substr(2); +} + +function formatTime(timestamp) { + if (!timestamp) return ""; + + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + if (diff < 24 * 60 * 60 * 1000) { + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + } else { + return date.toLocaleDateString([], { + month: "short", + day: "numeric", + }); + } +} diff --git a/web/js/login.js b/web/js/login.js new file mode 100644 index 0000000..59fef67 --- /dev/null +++ b/web/js/login.js @@ -0,0 +1,94 @@ +async function login() { + const phone = document.getElementById("loginPhone").value.trim(); + const password = document.getElementById("loginPassword").value; + + if (!phone || !password) { + showAlert("Please fill all fields", "error"); + return; + } + + try { + const response = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone, password }), + }); + + const data = await response.json(); + + if (data.success) { + localStorage.setItem("chatToken", data.token); + localStorage.setItem("chatUsername", data.username); + localStorage.setItem("chatUserPhone", phone); + + currentUser = data.username; + currentToken = data.token; + currentUserPhone = phone; + + // Get user profile to load avatar + const profileResponse = await fetch("/api/profile", { + headers: { + Authorization: `Bearer ${currentToken}`, + }, + }); + + if (profileResponse.ok) { + const profile = await profileResponse.json(); + currentUserAvatar = profile.avatar_url; + } + + document.getElementById("authContainer").style.display = "none"; + document.getElementById("chatContainer").style.display = "flex"; + + updateUserProfile(); + loadChats(); + connectWebSocket(); + } else { + showAlert(data.message, "error"); + } + } catch (error) { + showAlert("Login failed. Please try again.", "error"); + } +} + +function logout() { + localStorage.removeItem("chatToken"); + localStorage.removeItem("chatUsername"); + localStorage.removeItem("chatUserPhone"); + closeProfileModal(); + + if (ws) { + ws.close(); + ws = null; + } + + clearReconnection(); + + currentUser = ""; + currentToken = ""; + currentRecipient = ""; + currentUserAvatar = ""; + currentUserPhone = ""; + chats = []; + searchResults = []; + messageHistory = {}; + + document.getElementById("authContainer").style.display = "block"; + document.getElementById("chatContainer").style.display = "none"; + + document.getElementById("loginPhone").value = ""; + document.getElementById("loginPassword").value = ""; + document.getElementById("registerPhone").value = ""; + document.getElementById("registerUsername").value = ""; + document.getElementById("registerPassword").value = ""; + + showTab("login"); +} + +document + .getElementById("loginPassword") + .addEventListener("keypress", function (e) { + if (e.key === "Enter") login(); + }); + +document.getElementById("loginPhone").focus(); diff --git a/web/js/messages.js b/web/js/messages.js new file mode 100644 index 0000000..146a8c7 --- /dev/null +++ b/web/js/messages.js @@ -0,0 +1,245 @@ +// Function to delete message +async function deleteMessage(messageId, forEveryone) { + try { + const response = await fetch("/api/delete-message", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${currentToken}`, + }, + body: JSON.stringify({ + message_id: messageId, + for_everyone: forEveryone, + }), + }); + + const data = await response.json(); + if (data.success) { + console.log(`✅ Message deleted: ${data.message}`); + showAlert(data.message, "success"); + + // Remove message from UI + const messageElement = document.querySelector( + `[data-message-id="${messageId}"]`, + ); + if (messageElement) { + messageElement.remove(); + } + + // Update chat history + if (messageHistory[currentRecipient]) { + messageHistory[currentRecipient] = messageHistory[ + currentRecipient + ].filter((msg) => msg.id !== messageId); + } + + // Reload chats to update last message + loadChats(); + } else { + console.error("❌ Failed to delete message:", data.message); + showAlert(data.message, "error"); + } + } catch (error) { + console.error("❌ Error deleting message:", error); + showAlert("Error deleting message", "error"); + } +} + +// Function to mark messages as read +async function markMessagesAsRead(fromUser) { + try { + const response = await fetch("/api/mark-read", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${currentToken}`, + }, + body: JSON.stringify({ + from_user: fromUser, + to_user: currentUser, + }), + }); + + const data = await response.json(); + if (data.success) { + console.log(`✅ Marked messages as read from ${fromUser}`); + // Обновляем список чатов чтобы убрать красный badge + loadChats(); + } else { + console.error("❌ Failed to mark messages as read:", data.error); + } + } catch (error) { + console.error("❌ Error marking messages as read:", error); + } +} + +function deleteMessageForMe() { + if (messageToDelete) { + deleteMessage(messageToDelete, false); + closeDeleteModal(); + } +} + +function deleteMessageForEveryone() { + if (messageToDelete) { + deleteMessage(messageToDelete, true); + closeDeleteModal(); + } +} + +function handleChatMessage(msg) { + console.log("Handling chat message:", msg); + const otherUser = msg.from_user === currentUser ? msg.to_user : msg.from_user; + + if (!messageHistory[otherUser]) { + messageHistory[otherUser] = []; + } + + const messageExists = messageHistory[otherUser].some( + (existingMsg) => existingMsg.id === msg.id, + ); + + if (!messageExists) { + messageHistory[otherUser].push(msg); + } + + loadChats(); + + if (otherUser === currentRecipient) { + displayMessage(msg, true); + // Если сообщение пришло в открытый чат, сразу помечаем как прочитанное + if (msg.from_user === currentRecipient) { + markMessagesAsRead(currentRecipient); + } + } +} + +function displayMessage(msg, scrollToBottom) { + const chatMessages = document.getElementById("chatMessages"); + + // Remove empty state if it exists + const emptyState = chatMessages.querySelector(".empty-state"); + if (emptyState) { + emptyState.remove(); + } + + const messageDiv = document.createElement("div"); + messageDiv.className = `message ${msg.from_user === currentUser ? "outgoing" : "incoming"}`; + messageDiv.setAttribute("data-message-id", msg.id); + + if (msg.message_type === "system") { + messageDiv.className = "system-message"; + messageDiv.textContent = msg.content; + } else { + const isOutgoing = msg.from_user === currentUser; + + if (!isOutgoing) { + const senderDiv = document.createElement("div"); + senderDiv.className = "message-sender"; + senderDiv.textContent = msg.from_user; + messageDiv.appendChild(senderDiv); + } + + const contentDiv = document.createElement("div"); + contentDiv.className = "message-content"; + contentDiv.textContent = msg.content; + messageDiv.appendChild(contentDiv); + + const timeDiv = document.createElement("div"); + timeDiv.className = "message-time"; + const date = new Date(msg.timestamp); + timeDiv.textContent = date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + messageDiv.appendChild(timeDiv); + + // Add delete button for user's own messages or all messages + if (msg.from_user === currentUser) { + const actionsDiv = document.createElement("div"); + actionsDiv.className = "message-actions"; + const deleteBtn = document.createElement("button"); + deleteBtn.className = "delete-btn"; + deleteBtn.innerHTML = "×"; + deleteBtn.title = "Delete message"; + deleteBtn.onclick = (e) => { + console.log("delete message button clicked"); + e.stopPropagation(); + showDeleteModal(msg.id); + }; + actionsDiv.appendChild(deleteBtn); + messageDiv.appendChild(actionsDiv); + } + } + + chatMessages.appendChild(messageDiv); + console.log("message added"); + + if (scrollToBottom) { + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 100); + } +} + +function addSystemMessage(content) { + const chatMessages = document.getElementById("chatMessages"); + + // Remove empty state if it exists + const emptyState = chatMessages.querySelector(".empty-state"); + if (emptyState) { + emptyState.remove(); + } + + const systemDiv = document.createElement("div"); + systemDiv.className = "system-message"; + systemDiv.textContent = content; + chatMessages.appendChild(systemDiv); + + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 100); +} + +function sendMessage() { + 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; + } + + const input = document.getElementById("messageInput"); + const content = input.value.trim(); + + if (!content) return; + + const message = { + id: generateId(), + from_user: currentUser, + to_user: currentRecipient, + content: content, + timestamp: Date.now(), + message_type: "text", + }; + + console.log("Sending message:", message); + ws.send(JSON.stringify(message)); + input.value = ""; + input.style.height = "auto"; + + setTimeout(() => loadChats(), 100); +} + +// Event listeners +document + .getElementById("messageInput") + .addEventListener("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); diff --git a/web/js/profilemodal.js b/web/js/profilemodal.js new file mode 100644 index 0000000..64e8bb0 --- /dev/null +++ b/web/js/profilemodal.js @@ -0,0 +1,37 @@ +function openProfileModal() { + updateProfileModal(); + document.getElementById("profileModal").style.display = "flex"; + document.body.classList.add("modal-open"); +} + +function closeProfileModal() { + document.getElementById("profileModal").style.display = "none"; + document.body.classList.remove("modal-open"); +} + +function updateProfileModal() { + // Update profile modal content + document.getElementById("profileModalName").textContent = currentUser; + document.getElementById("profileModalUsername").textContent = currentUser; + document.getElementById("profileModalPhone").textContent = currentUserPhone; + document.getElementById("profileModalPhoneNumber").textContent = + currentUserPhone; + + // Update large avatar in modal + const modalAvatar = document.getElementById("profileModalAvatar"); + if (currentUserAvatar) { + modalAvatar.innerHTML = `${currentUser}`; + } else { + modalAvatar.innerHTML = currentUser.charAt(0).toUpperCase(); + } + + // Add the upload overlay + modalAvatar.innerHTML += + '
Change Photo
'; +} + +document.getElementById("profileModal").addEventListener("click", function (e) { + if (e.target === this) { + closeProfileModal(); + } +}); diff --git a/web/js/register.js b/web/js/register.js new file mode 100644 index 0000000..d77d05f --- /dev/null +++ b/web/js/register.js @@ -0,0 +1,54 @@ +async function register() { + const phone = document.getElementById("registerPhone").value.trim(); + const username = document.getElementById("registerUsername").value.trim(); + const password = document.getElementById("registerPassword").value; + + if (!phone || !username || !password) { + showAlert("Please fill all fields", "error"); + return; + } + + if (password.length < 6) { + showAlert("Password must be at least 6 characters", "error"); + return; + } + + try { + const response = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone, username, password }), + }); + + const data = await response.json(); + + if (data.success) { + localStorage.setItem("chatToken", data.token); + localStorage.setItem("chatUsername", data.username); + localStorage.setItem("chatUserPhone", phone); + + currentUser = data.username; + currentToken = data.token; + currentUserPhone = phone; + + document.getElementById("authContainer").style.display = "none"; + document.getElementById("chatContainer").style.display = "flex"; + + updateUserProfile(); + loadChats(); + connectWebSocket(); + + showAlert("Registration successful!", "success"); + } else { + showAlert(data.message, "error"); + } + } catch (error) { + showAlert("Registration failed. Please try again.", "error"); + } +} + +document + .getElementById("registerPassword") + .addEventListener("keypress", function (e) { + if (e.key === "Enter") register(); + }); diff --git a/web/js/search.js b/web/js/search.js new file mode 100644 index 0000000..6cbc1a5 --- /dev/null +++ b/web/js/search.js @@ -0,0 +1,75 @@ +let searchTimeout; +document.getElementById("searchInput").addEventListener("input", function (e) { + clearTimeout(searchTimeout); + const query = e.target.value.trim(); + + if (query.length < 2) { + document.getElementById("searchResults").innerHTML = + '

Enter at least 2 characters to search

'; + return; + } + + searchTimeout = setTimeout(() => searchUsers(query), 300); +}); + +async function searchUsers(query) { + try { + const response = await fetch( + `/api/search-users?q=${encodeURIComponent(query)}`, + { + headers: { + Authorization: `Bearer ${currentToken}`, + }, + }, + ); + + if (response.ok) { + searchResults = await response.json(); + renderSearchResults(); + } else { + console.error("Search failed"); + } + } catch (error) { + console.error("Error searching users:", error); + } +} + +function renderSearchResults() { + const searchResultsContainer = document.getElementById("searchResults"); + searchResultsContainer.innerHTML = ""; + + if (searchResults.length === 0) { + searchResultsContainer.innerHTML = + '

No users found

'; + return; + } + + searchResults.forEach((user) => { + const div = document.createElement("div"); + div.className = "chat-item"; + + const avatarContent = user.avatar_url + ? `${user.username}` + : user.username.charAt(0).toUpperCase(); + + div.innerHTML = ` +
${avatarContent}
+
+
${user.username}
+
${user.phone}
+
Click to start chatting
+
+ `; + + div.onclick = () => { + selectChat(user.username, user.phone, user.avatar_url); + showChatTab("existing"); + document.getElementById("searchInput").value = ""; + if (isMobile) { + hideChatsPanel(); + } + }; + + searchResultsContainer.appendChild(div); + }); +} diff --git a/web/js/theme.js b/web/js/theme.js new file mode 100644 index 0000000..928033f --- /dev/null +++ b/web/js/theme.js @@ -0,0 +1,10 @@ +let currentTheme = localStorage.getItem("theme") || "light"; + +// Initialize theme +document.body.setAttribute("data-theme", currentTheme); + +function toggleTheme() { + currentTheme = currentTheme === "light" ? "dark" : "light"; + document.body.setAttribute("data-theme", currentTheme); + localStorage.setItem("theme", currentTheme); +} diff --git a/web/js/window.js b/web/js/window.js new file mode 100644 index 0000000..57ece0b --- /dev/null +++ b/web/js/window.js @@ -0,0 +1,27 @@ +// Check for saved session on page load +window.addEventListener("DOMContentLoaded", function () { + const savedToken = localStorage.getItem("chatToken"); + const savedUsername = localStorage.getItem("chatUsername"); + const savedPhone = localStorage.getItem("chatUserPhone"); + + if (savedToken && savedUsername) { + currentUserPhone = savedPhone; + validateToken(savedToken, savedUsername); + } + + // Auto-resize textarea + const messageInput = document.getElementById("messageInput"); + messageInput.addEventListener("input", function () { + this.style.height = "auto"; + this.style.height = Math.min(this.scrollHeight, 100) + "px"; + }); + + // Update mobile detection on resize + window.addEventListener("resize", function () { + isMobile = window.innerWidth <= 768; + if (!isMobile) { + // On desktop, ensure chats panel is visible + document.getElementById("chatsPanel").classList.remove("active"); + } + }); +});