work version

This commit is contained in:
wt
2025-11-23 17:03:48 +07:00
commit e03af7466b
5 changed files with 4135 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+2597
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "msgs"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.7", features = ["ws", "json"] }
tokio = { version = "1.0", features = ["full"] }
tower-http = { version = "0.5", features = ["fs"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
sqlx = { version = "0.7", features = ["runtime-tokio-native-tls", "sqlite", "macros", "chrono"] }
futures-util = "0.3"
bcrypt = "0.15"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
+651
View File
@@ -0,0 +1,651 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State, Json,
},
response::Response,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use sqlx::{sqlite::SqlitePool, Row};
use std::{
collections::HashMap,
net::SocketAddr,
sync::Arc,
};
use tokio::sync::{mpsc, RwLock};
use tower_http::services::ServeDir;
use futures_util::{StreamExt, SinkExt};
use bcrypt::{hash, verify, DEFAULT_COST};
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use chrono::{Utc, Duration};
// Types for shared state
type UserMap = Arc<RwLock<HashMap<String, mpsc::UnboundedSender<Message>>>>;
#[derive(Clone)]
struct AppState {
users: UserMap,
db_pool: SqlitePool,
}
#[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<String>,
}
// Структуры для аутентификации
#[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<String>,
token: Option<String>,
}
#[derive(Debug, Serialize)]
struct UserProfile {
username: String,
phone: String,
}
#[derive(Debug, Serialize)]
struct ValidateTokenResponse {
success: bool,
username: Option<String>,
phone: Option<String>,
message: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
tracing_subscriber::fmt::init();
// Initialize database
let db_pool = init_db().await?;
// Create shared state
let state = Arc::new(AppState {
users: Arc::new(RwLock::new(HashMap::new())),
db_pool,
});
// 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/profile", get(profile_handler))
.nest_service("/", ServeDir::new("web"))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3333));
println!("Server running on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn init_db() -> Result<SqlitePool, Box<dyn std::error::Error>> {
let pool = SqlitePool::connect("sqlite:chat.db").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,
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
)
"#,
)
.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<Arc<AppState>>,
Json(payload): Json<RegisterRequest>,
) -> Json<AuthResponse> {
// 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 {
return Json(AuthResponse {
success: false,
message: "Username must be at least 3 characters".to_string(),
username: None,
token: None,
});
}
if payload.password.len() < 6 {
return Json(AuthResponse {
success: false,
message: "Password must be at least 6 characters".to_string(),
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<Arc<AppState>>,
Json(payload): Json<LoginRequest>,
) -> Json<AuthResponse> {
// 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,
}),
}
}
#[derive(Debug, Deserialize)]
struct ValidateTokenRequest {
token: String,
}
async fn validate_token_handler(
State(state): State<Arc<AppState>>,
Json(payload): Json<ValidateTokenRequest>,
) -> Json<ValidateTokenResponse> {
match verify_token(&state.db_pool, &payload.token).await {
Ok(Some(username)) => {
// Get user details
let user_result = sqlx::query(
"SELECT phone FROM users WHERE username = ?"
)
.bind(&username)
.fetch_optional(&state.db_pool)
.await;
match user_result {
Ok(Some(row)) => {
let phone: String = row.get("phone");
Json(ValidateTokenResponse {
success: true,
username: Some(username),
phone: Some(phone),
message: None,
})
}
Ok(None) => Json(ValidateTokenResponse {
success: false,
username: None,
phone: None,
message: Some("User not found".to_string()),
}),
Err(_) => Json(ValidateTokenResponse {
success: false,
username: None,
phone: None,
message: Some("Database error".to_string()),
}),
}
}
Ok(None) => Json(ValidateTokenResponse {
success: false,
username: None,
phone: None,
message: Some("Invalid or expired token".to_string()),
}),
Err(_) => Json(ValidateTokenResponse {
success: false,
username: None,
phone: None,
message: Some("Token verification error".to_string()),
}),
}
}
async fn profile_handler() -> Json<UserProfile> {
// This would normally verify the token and return user profile
// For now, return a placeholder
Json(UserProfile {
username: "user".to_string(),
phone: "+1234567890".to_string(),
})
}
// WebSocket handlers
async fn websocket_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> Response {
ws.on_upgrade(|socket| handle_websocket(socket, state))
}
async fn handle_websocket(socket: WebSocket, state: Arc<AppState>) {
// Split the socket into sender and receiver
let (mut sender, mut receiver) = socket.split();
// Create channel for sending messages to this client
let (tx, mut rx) = mpsc::unbounded_channel();
// Handle authentication - receive first message with token
let username = match receiver.next().await {
Some(Ok(Message::Text(auth_msg))) => {
match serde_json::from_str::<serde_json::Value>(&auth_msg) {
Ok(auth) => {
if let (Some(token), Some(auth_type)) = (auth["token"].as_str(), auth["type"].as_str()) {
if auth_type == "auth" {
// Verify token and get username
match verify_token(&state.db_pool, token).await {
Ok(Some(user_username)) => {
// Check if username is already connected
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;
}
// Register user
state.users.write().await.insert(user_username.clone(), tx);
// Send success message
let _ = sender.send(Message::Text(
serde_json::json!({
"type": "system",
"content": "Connected successfully"
}).to_string(),
)).await;
// Send user list
broadcast_user_list(&state.users).await;
// Send message history
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);
// Clone state for cleanup
let state_clone = state.clone();
let username_clone = username.clone();
// Spawn a task to handle sending messages to the client
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;
}
}
});
// Handle receiving messages from the client in the main task
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)) => {
println!("Received message from {}: {}", username, text);
if let Ok(mut chat_msg) = serde_json::from_str::<ChatMessage>(&text) {
// Set the from_user to the authenticated username
chat_msg.from_user = username.clone();
println!("Processing message from {} to {}", chat_msg.from_user, chat_msg.to_user);
// Save message to database
if let Err(e) = save_message(&recv_state.db_pool, &chat_msg).await {
eprintln!("Failed to save message: {}", e);
continue;
}
// Send to recipient if online
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();
println!("Sending message to {}: {}", chat_msg.to_user, message_json);
let _ = recipient_tx.send(Message::Text(message_json));
} else {
println!("Recipient {} not found online", chat_msg.to_user);
}
// Also send the message back to the sender for confirmation
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 {
println!("Failed to parse message as ChatMessage: {}", text);
}
}
Ok(Message::Close(_)) => {
println!("WebSocket closed by client");
break;
}
Err(e) => {
eprintln!("WebSocket error: {}", e);
break;
}
_ => {}
}
}
});
// Wait for both tasks to finish
tokio::select! {
_ = send_task => {},
_ = recv_task => {},
}
// Cleanup
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<Option<String>, 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<String> = 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) -> Result<Vec<ChatMessage>, sqlx::Error> {
let rows = sqlx::query(
"SELECT id, from_user, to_user, content, timestamp, message_type FROM messages WHERE from_user = ? OR to_user = ? ORDER BY timestamp"
)
.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)
}
+868
View File
@@ -0,0 +1,868 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Secure Chat App</title>
<style>
* {
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;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 90%;
max-width: 1200px;
height: 90vh;
background: white;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
display: flex;
overflow: hidden;
}
.auth-container {
width: 400px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.auth-tabs {
display: flex;
margin-bottom: 30px;
border-bottom: 1px solid #e9ecef;
}
.auth-tab {
flex: 1;
padding: 15px;
text-align: center;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
}
.auth-tab.active {
border-bottom-color: #3498db;
color: #3498db;
font-weight: 600;
}
.auth-form {
display: none;
}
.auth-form.active {
display: block;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #2c3e50;
}
.form-control {
width: 100%;
padding: 15px 20px;
border: 2px solid #e9ecef;
border-radius: 10px;
font-size: 16px;
outline: none;
transition: border-color 0.3s ease;
}
.form-control:focus {
border-color: #3498db;
}
.btn {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
border: none;
border-radius: 10px;
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: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* Chat styles remain the same as before */
.chat-container {
width: 100%;
height: 100%;
display: none;
}
.users-panel {
width: 300px;
background: #2c3e50;
color: white;
padding: 20px;
display: flex;
flex-direction: column;
}
.users-panel h2 {
margin-bottom: 20px;
text-align: center;
font-weight: 300;
border-bottom: 1px solid #34495e;
padding-bottom: 10px;
}
.user-list {
list-style: none;
flex: 1;
overflow-y: auto;
}
.user-item {
padding: 15px;
margin: 8px 0;
background: #34495e;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.user-item:hover {
background: #3d566e;
transform: translateX(5px);
}
.user-item.active {
background: #3498db;
border-left-color: #2980b9;
}
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #f8f9fa;
}
.chat-header {
padding: 20px;
background: white;
border-bottom: 1px solid #e9ecef;
text-align: center;
font-weight: 600;
color: #2c3e50;
display: flex;
justify-content: space-between;
align-items: center;
}
.logout-btn {
padding: 8px 16px;
background: #e74c3c;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.logout-btn:hover {
background: #c0392b;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #f8f9fa;
}
.message {
max-width: 70%;
margin: 15px 0;
padding: 15px 20px;
border-radius: 20px;
position: relative;
word-wrap: break-word;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.incoming {
background: white;
border: 1px solid #e9ecef;
align-self: flex-start;
border-bottom-left-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}
.outgoing {
background: linear-gradient(135deg, #3498db, #2980b9);
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: 12px;
font-weight: 600;
margin-bottom: 5px;
opacity: 0.8;
}
.message-content {
margin: 8px 0;
line-height: 1.4;
}
.message-time {
font-size: 11px;
text-align: right;
opacity: 0.7;
margin-top: 5px;
}
.chat-input-area {
padding: 20px;
background: white;
border-top: 1px solid #e9ecef;
display: flex;
gap: 12px;
align-items: center;
}
.message-input {
flex: 1;
padding: 15px 20px;
border: 2px solid #e9ecef;
border-radius: 25px;
outline: none;
font-size: 14px;
transition: border-color 0.3s ease;
}
.message-input:focus {
border-color: #3498db;
}
.send-button {
padding: 15px 25px;
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
border: none;
border-radius: 25px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
}
.send-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
}
.send-button:disabled {
background: #bdc3c7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.system-message {
text-align: center;
color: #7f8c8d;
font-style: italic;
margin: 15px 0;
font-size: 13px;
padding: 10px;
background: #f8f9fa;
border-radius: 10px;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
</head>
<body>
<!-- Authentication Container -->
<div id="authContainer" class="auth-container">
<div class="auth-tabs">
<div class="auth-tab active" onclick="showTab('login')">Login</div>
<div class="auth-tab" onclick="showTab('register')">Register</div>
</div>
<div id="alert" class="alert" style="display: none;"></div>
<!-- Login Form -->
<div id="loginForm" class="auth-form active">
<div class="form-group">
<label for="loginPhone">Phone Number</label>
<input type="text" id="loginPhone" class="form-control" placeholder="+1234567890">
</div>
<div class="form-group">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" class="form-control" placeholder="Enter your password">
</div>
<button class="btn" onclick="login()">Login</button>
</div>
<!-- Register Form -->
<div id="registerForm" class="auth-form">
<div class="form-group">
<label for="registerPhone">Phone Number</label>
<input type="text" id="registerPhone" class="form-control" placeholder="+1234567890">
</div>
<div class="form-group">
<label for="registerUsername">Username</label>
<input type="text" id="registerUsername" class="form-control" placeholder="Choose a username">
</div>
<div class="form-group">
<label for="registerPassword">Password</label>
<input type="password" id="registerPassword" class="form-control" placeholder="Choose a password (min 6 characters)">
</div>
<button class="btn" onclick="register()">Register</button>
</div>
</div>
<!-- Chat Container -->
<div class="container" style="display: none;" id="chatContainer">
<div class="users-panel">
<h2>Online Users</h2>
<ul class="user-list" id="userList"></ul>
</div>
<div class="chat-panel">
<div class="chat-header">
<span id="chatHeader">Select a user to start chatting</span>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-input-area">
<input type="text" id="messageInput" class="message-input" placeholder="Type a message..." disabled>
<button onclick="sendMessage()" class="send-button" id="sendButton" disabled>Send</button>
</div>
</div>
</div>
<script>
let ws = null;
let currentUser = '';
let currentToken = '';
let currentRecipient = '';
let userList = [];
let messageHistory = {};
// Check for saved session on page load
window.addEventListener('DOMContentLoaded', function() {
const savedToken = localStorage.getItem('chatToken');
const savedUsername = localStorage.getItem('chatUsername');
if (savedToken && savedUsername) {
validateToken(savedToken, savedUsername);
}
});
function showTab(tabName) {
// Update tabs
document.querySelectorAll('.auth-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.auth-form').forEach(form => {
form.classList.remove('active');
});
// Show selected tab
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');
}
// Clear alerts
hideAlert();
}
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = `alert alert-${type}`;
alert.style.display = 'block';
}
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;
// Save to localStorage
localStorage.setItem('chatToken', token);
localStorage.setItem('chatUsername', data.username);
// Hide auth, show chat
document.getElementById('authContainer').style.display = 'none';
document.getElementById('chatContainer').style.display = 'flex';
document.getElementById('chatHeader').textContent = `Welcome, ${currentUser}`;
// Connect to WebSocket
connectWebSocket();
} else {
// Clear invalid token
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');
}
}
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) {
// Save token and username to localStorage
localStorage.setItem('chatToken', data.token);
localStorage.setItem('chatUsername', data.username);
currentUser = data.username;
currentToken = data.token;
// Hide auth, show chat
document.getElementById('authContainer').style.display = 'none';
document.getElementById('chatContainer').style.display = 'flex';
document.getElementById('chatHeader').textContent = `Welcome, ${currentUser}`;
// Connect to WebSocket
connectWebSocket();
showAlert('Registration successful!', 'success');
} else {
showAlert(data.message, 'error');
}
} catch (error) {
showAlert('Registration failed. Please try again.', 'error');
}
}
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) {
// Save token and username to localStorage
localStorage.setItem('chatToken', data.token);
localStorage.setItem('chatUsername', data.username);
currentUser = data.username;
currentToken = data.token;
// Hide auth, show chat
document.getElementById('authContainer').style.display = 'none';
document.getElementById('chatContainer').style.display = 'flex';
document.getElementById('chatHeader').textContent = `Welcome, ${currentUser}`;
// Connect to WebSocket
connectWebSocket();
} else {
showAlert(data.message, 'error');
}
} catch (error) {
showAlert('Login failed. Please try again.', 'error');
}
}
function logout() {
// Clear localStorage
localStorage.removeItem('chatToken');
localStorage.removeItem('chatUsername');
// Close WebSocket connection
if (ws) {
ws.close();
}
// Reset state
currentUser = '';
currentToken = '';
currentRecipient = '';
userList = [];
messageHistory = {};
// Show auth, hide chat
document.getElementById('authContainer').style.display = 'block';
document.getElementById('chatContainer').style.display = 'none';
// Clear forms
document.getElementById('loginPhone').value = '';
document.getElementById('loginPassword').value = '';
document.getElementById('registerPhone').value = '';
document.getElementById('registerUsername').value = '';
document.getElementById('registerPassword').value = '';
showTab('login');
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
// Send authentication
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 === 'user_list') {
updateUserList(data.users);
} else if (data.type === 'error') {
showAlert(data.content, 'error');
// If authentication error, logout
if (data.content.includes('Invalid') || data.content.includes('expired') || data.content.includes('auth')) {
logout();
}
} else if (data.type === 'system') {
addSystemMessage(data.content);
} else {
// Regular chat message
handleChatMessage(data);
}
} catch (e) {
console.error('Error parsing message:', e, 'Raw data:', event.data);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
addSystemMessage('Disconnected from server');
document.getElementById('sendButton').disabled = true;
document.getElementById('messageInput').disabled = true;
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
addSystemMessage('Connection error');
};
}
function updateUserList(users) {
userList = users.filter(user => user !== currentUser);
const userListElement = document.getElementById('userList');
userListElement.innerHTML = '';
userList.forEach(user => {
const li = document.createElement('li');
li.className = 'user-item';
li.textContent = user;
li.onclick = () => selectUser(user);
userListElement.appendChild(li);
});
// Auto-select first user if none selected
if (!currentRecipient && userList.length > 0) {
selectUser(userList[0]);
}
}
function selectUser(user) {
currentRecipient = user;
// Update active state in user list
document.querySelectorAll('.user-item').forEach(item => {
item.classList.remove('active');
if (item.textContent === user) {
item.classList.add('active');
}
});
// Update chat header
document.getElementById('chatHeader').textContent = `Chat with ${user}`;
// Enable input
document.getElementById('sendButton').disabled = false;
document.getElementById('messageInput').disabled = false;
document.getElementById('messageInput').focus();
// Display chat history with this user
displayChatHistory(user);
}
function displayChatHistory(user) {
const chatMessages = document.getElementById('chatMessages');
chatMessages.innerHTML = '';
if (messageHistory[user]) {
messageHistory[user].forEach(msg => {
displayMessage(msg, false);
});
}
chatMessages.scrollTop = chatMessages.scrollHeight;
}
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] = [];
}
// Check if message already exists to avoid duplicates
const messageExists = messageHistory[otherUser].some(
existingMsg => existingMsg.id === msg.id
);
if (!messageExists) {
messageHistory[otherUser].push(msg);
}
if (otherUser === currentRecipient) {
displayMessage(msg, true);
}
}
function displayMessage(msg, scrollToBottom) {
const chatMessages = document.getElementById('chatMessages');
const messageDiv = document.createElement('div');
if (msg.message_type === 'system') {
messageDiv.className = 'system-message';
messageDiv.textContent = msg.content;
} else {
const isOutgoing = msg.from_user === currentUser;
messageDiv.className = `message ${isOutgoing ? 'outgoing' : 'incoming'}`;
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);
}
chatMessages.appendChild(messageDiv);
if (scrollToBottom) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
function addSystemMessage(content) {
const chatMessages = document.getElementById('chatMessages');
const systemDiv = document.createElement('div');
systemDiv.className = 'system-message';
systemDiv.textContent = content;
chatMessages.appendChild(systemDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
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, // This will be overwritten by server, but needed for structure
to_user: currentRecipient,
content: content,
timestamp: Date.now(),
message_type: 'text'
};
console.log('Sending message:', message);
ws.send(JSON.stringify(message));
input.value = '';
}
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
// Event listeners for Enter key
document.getElementById('messageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
document.getElementById('loginPassword').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
login();
}
});
document.getElementById('registerPassword').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
register();
}
});
// Focus phone input on load
document.getElementById('loginPhone').focus();
</script>
</body>
</html>