From 7fdf18ff1b514d8a4d17f68e7e681179e4db5b43 Mon Sep 17 00:00:00 2001 From: pavel Date: Sat, 7 Feb 2026 17:11:56 +0100 Subject: [PATCH] individual messages --- Cargo.lock | 160 +++++++------------ Cargo.toml | 9 +- index.html | 446 +++++++++++++++++++++++++++++++++++++++++++++------- src/main.rs | 328 +++++++++++++++++++++----------------- 4 files changed, 624 insertions(+), 319 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 624f5ca..a642299 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,17 +11,6 @@ dependencies = [ "libc", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -199,13 +188,13 @@ dependencies = [ "axum", "dotenvy", "futures", + "jsonwebtoken", "oauth2", "reqwest 0.13.2", "serde", "serde_json", "time", "tokio", - "tower-sessions", ] [[package]] @@ -241,17 +230,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -310,7 +288,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -937,6 +914,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "libc" version = "0.2.180" @@ -999,12 +991,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1069,6 +1080,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1639,6 +1660,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.14", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1947,22 +1980,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-cookies" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" -dependencies = [ - "axum-core", - "cookie", - "futures-util", - "http 1.4.0", - "parking_lot", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.6.8" @@ -1993,57 +2010,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "tower-sessions" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" -dependencies = [ - "async-trait", - "http 1.4.0", - "time", - "tokio", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions-core", - "tower-sessions-memory-store", - "tracing", -] - -[[package]] -name = "tower-sessions-core" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" -dependencies = [ - "async-trait", - "axum-core", - "base64 0.22.1", - "futures", - "http 1.4.0", - "parking_lot", - "rand 0.9.2", - "serde", - "serde_json", - "thiserror 2.0.14", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "tower-sessions-memory-store" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460" -dependencies = [ - "async-trait", - "time", - "tokio", - "tower-sessions-core", -] - [[package]] name = "tracing" version = "0.1.44" @@ -2052,21 +2018,9 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tracing-core" version = "0.1.36" diff --git a/Cargo.toml b/Cargo.toml index 233fcda..23cfb31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,16 +8,9 @@ axum = { version = "0.8.8", features = ["ws"] } dotenvy = "0.15.7" futures = "0.3.31" oauth2 = "4.4.2" +jsonwebtoken = "9.3.0" reqwest = { version = "0.13.2", features = ["json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" time = "0.3.47" tokio = { version = "1.49.0", features = ["full"] } -tower-sessions = "0.15.0" - -[profile.release] -strip = true # Automatically strip symbols from the binary. -opt-level = "z" # Optimize for size. -lto = true # Enable Link Time Optimization -codegen-units = 1 # Maximize LTO -panic = "abort" # Abort on panic diff --git a/index.html b/index.html index ffe5d7b..8b9bf5e 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,11 @@ --parchment: #f5e6d3; --steam: #e0e0e0; --gear-color: rgba(0, 0, 0, 0.2); + --online-green: #4caf50; + } + + * { + box-sizing: border-box; } body { @@ -34,11 +39,97 @@ padding-bottom: 10px; letter-spacing: 2px; text-transform: uppercase; + margin-bottom: 20px; } - #chat-container { + #main-container { width: 100%; - max-width: 800px; + max-width: 1000px; + display: flex; + gap: 15px; + height: 600px; + } + + /* Contacts Sidebar */ + #contacts-panel { + width: 250px; + background: rgba(43, 29, 14, 0.9); + border: 4px solid var(--copper); + border-radius: 10px; + box-shadow: + 0 0 15px var(--brass), + inset 0 0 20px #000; + display: flex; + flex-direction: column; + position: relative; + } + + #contacts-header { + padding: 15px; + border-bottom: 2px solid var(--copper); + font-weight: bold; + color: var(--brass); + text-transform: uppercase; + letter-spacing: 1px; + } + + #contacts-list { + flex: 1; + overflow-y: auto; + padding: 10px; + scrollbar-width: thin; + scrollbar-color: var(--brass) var(--leather); + } + + .contact { + padding: 12px 15px; + margin-bottom: 8px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--copper); + border-radius: 5px; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + transition: all 0.2s; + } + + .contact:hover { + background: rgba(212, 175, 55, 0.2); + border-color: var(--brass); + } + + .contact.selected { + background: rgba(212, 175, 55, 0.3); + border-color: var(--brass); + box-shadow: 0 0 10px rgba(212, 175, 55, 0.3); + } + + .contact .status-dot { + width: 10px; + height: 10px; + background: var(--online-green); + border-radius: 50%; + box-shadow: 0 0 5px var(--online-green); + } + + .contact .username { + flex: 1; + font-weight: bold; + } + + .contact .unread { + background: var(--copper); + color: var(--parchment); + padding: 2px 8px; + border-radius: 10px; + font-size: 0.8em; + font-weight: bold; + } + + /* Chat Panel */ + #chat-container { + flex: 1; background: rgba(43, 29, 14, 0.9); border: 4px solid var(--copper); border-radius: 10px; @@ -74,14 +165,37 @@ right: 10px; } + #chat-header { + padding: 10px; + border-bottom: 2px solid var(--copper); + font-weight: bold; + color: var(--brass); + text-transform: uppercase; + letter-spacing: 1px; + } + + #no-chat-selected { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--copper); + font-style: italic; + } + #chat { - height: 500px; - overflow-y: scroll; + flex: 1; + overflow-y: auto; border: 2px inset var(--copper); background: rgba(0, 0, 0, 0.3); padding: 15px; scrollbar-width: thin; scrollbar-color: var(--brass) var(--leather); + display: none; + } + + #chat.active { + display: block; } #chat::-webkit-scrollbar { @@ -107,16 +221,28 @@ box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.3); font-weight: bold; position: relative; + max-width: 80%; } - .message::after { - content: ''; - position: absolute; - bottom: -5px; - left: 10px; - border-width: 5px 5px 0; - border-style: solid; - border-color: var(--copper) transparent; + .message.sent { + margin-left: auto; + background: linear-gradient(to bottom, var(--brass), var(--copper)); + color: #2b1d0e; + } + + .message.received { + margin-right: auto; + } + + .message .sender { + font-size: 0.8em; + color: var(--copper); + margin-bottom: 4px; + } + + .message.sent .sender { + color: #2b1d0e; + opacity: 0.7; } .system-msg { @@ -128,14 +254,11 @@ background: none; border: none; box-shadow: none; - } - - .system-msg::after { - display: none; + max-width: 100%; } #controls { - display: flex; + display: none; gap: 10px; padding: 10px; background: #1a1a1a; @@ -143,6 +266,10 @@ border-radius: 5px; } + #controls.active { + display: flex; + } + input[type="text"] { flex-grow: 1; padding: 10px; @@ -179,62 +306,261 @@

⚙ AetherChat ⚙

-
-
-
- - +
+ +
+
⚙ Contacts
+
+
+ + +
+
Select a contact to start chatting
+
Click on a contact to begin transmission...
+
+
+ + +
diff --git a/src/main.rs b/src/main.rs index 69fb1d7..fb9106f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,211 +1,243 @@ use axum::{ + Router, extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, Query, State, + ws::{Message, WebSocket, WebSocketUpgrade}, }, http::StatusCode, - response::{Html, IntoResponse, Redirect}, + response::{Html, IntoResponse}, routing::get, - Router, }; use dotenvy::dotenv; use futures::{sink::SinkExt, stream::StreamExt}; -use oauth2::{ - basic::BasicClient,AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenResponse, - TokenUrl, -}; -use std::sync::Arc; -use tokio::sync::broadcast; -use tower_sessions::{cookie::SameSite, Expiry, MemoryStore, Session, SessionManagerLayer}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::{RwLock, mpsc}; mod user; use user::User; -// Define application state -struct AppState { - tx: broadcast::Sender, - oauth_client: BasicClient, +// WebSocket message types for client-server communication +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +enum WsMessage { + #[serde(rename = "user_list")] + UserList { users: Vec }, + #[serde(rename = "private_message")] + PrivateMessage { + from: String, + to: String, + content: String, + }, + #[serde(rename = "user_joined")] + UserJoined { username: String }, + #[serde(rename = "system")] + System { content: String }, } -const AFTER_LOGIN_URL: &str = "/"; +// Per-user connection channel +type UserTx = mpsc::UnboundedSender; + +// Shared state for tracking online users and their connections +struct ChatState { + // Map of username -> sender channel + connections: RwLock>, +} + +impl ChatState { + fn new() -> Self { + Self { + connections: RwLock::new(HashMap::new()), + } + } + + async fn add_user(&self, username: String, tx: UserTx) { + let mut conns = self.connections.write().await; + conns.insert(username, tx); + } + + async fn remove_user(&self, username: &str) { + let mut conns = self.connections.write().await; + conns.remove(username); + } + + async fn get_online_users(&self) -> Vec { + let conns = self.connections.read().await; + conns.keys().cloned().collect() + } + + async fn send_to_user(&self, username: &str, message: &str) -> bool { + let conns = self.connections.read().await; + if let Some(tx) = conns.get(username) { + tx.send(message.to_string()).is_ok() + } else { + false + } + } + + async fn broadcast_except(&self, message: &str, exclude: &str) { + let conns = self.connections.read().await; + for (username, tx) in conns.iter() { + if username != exclude { + let _ = tx.send(message.to_string()); + } + } + } +} + +// Application state +struct AppState { + chat: ChatState, +} #[tokio::main] async fn main() { dotenv().ok(); - // Create a broadcast channel - let (tx, _rx) = broadcast::channel(100); - - // OAuth configuration - let client_id = dotenvy::var("CLIENT_ID").unwrap(); - let client_secret = dotenvy::var("CLIENT_SECRET").unwrap(); - let port = dotenvy::var("PORT").unwrap_or_else(|_| "3001".to_string()); - let host = dotenvy::var("HOST").unwrap_or_else(|_| "http://localhost:".to_string() + &port); - // NOTE: In production, do not hardcode localhost - let auth_url = AuthUrl::new("https://idm.flegr.me/application/o/authorize/".to_string()) - .expect("Invalid authorization endpoint URL"); - let token_url = TokenUrl::new("https://idm.flegr.me/application/o/token/".to_string()) - .expect("Invalid token endpoint URL"); - - let client = BasicClient::new( - ClientId::new(client_id), - Some(ClientSecret::new(client_secret)), - auth_url, - Some(token_url), - ) - .set_redirect_uri( - RedirectUrl::new(host + "/auth/callback") - .expect("Invalid redirect URL"), - ); - let app_state = Arc::new(AppState { - tx, - oauth_client: client, + chat: ChatState::new(), }); - // Session configuration - let session_store = MemoryStore::default(); - let session_layer = SessionManagerLayer::new(session_store) - .with_secure(false) // For localhost; set to true in production with HTTPS - .with_same_site(SameSite::Lax) // Ensure we can receive cookies from OAuth redirect - .with_expiry(Expiry::OnInactivity(time::Duration::minutes(30))); - // Build application with routes let app = Router::new() .route("/", get(index)) - .route("/login", get(login)) - .route("/logout", get(logout)) - .route("/auth/callback", get(auth_callback)) .route("/ws", get(websocket_handler)) - .layer(session_layer) .with_state(app_state); // Run the app let port = dotenvy::var("PORT").unwrap_or_else(|_| "3001".to_string()); - let listener = tokio::net::TcpListener::bind("0.0.0.0:".to_string() + &port).await.unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:".to_string() + &port) + .await + .unwrap(); println!("Chat server listening on http://0.0.0.0:{}", port); axum::serve(listener, app).await.unwrap(); } // Handlers -async fn index(session: Session) -> impl IntoResponse { - if let Some(_user) = session.get::("user").await.unwrap() { - return Html(include_str!("../index.html")).into_response(); - } - Redirect::to("/login").into_response() +async fn index() -> impl IntoResponse { + Html(include_str!("../index.html")) } -async fn login(State(state): State>) -> impl IntoResponse { - let (auth_url, _csrf_token) = state - .oauth_client - .authorize_url(oauth2::CsrfToken::new_random) - .url(); - - Redirect::to(auth_url.as_str()) +#[derive(Deserialize)] +struct WsParams { + token: String, } -async fn logout(session: Session) -> impl IntoResponse { - session.delete().await.unwrap(); - Redirect::to("/login") -} - -#[derive(serde::Deserialize)] -struct AuthRequest { - code: String, - state: String, -} - -async fn auth_callback( - Query(query): Query, - State(state): State>, - session: Session, -) -> impl IntoResponse { - let token = match state - .oauth_client - .exchange_code(oauth2::AuthorizationCode::new(query.code)) - .request_async(oauth2::reqwest::async_http_client) - .await - { - Ok(t) => t, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - format!("Failed to exchange authorization code: {}", e), - ) - .into_response(); - } - }; - - let client = reqwest::Client::new(); - let user_info_resp = client - .get("https://idm.flegr.me/application/o/userinfo/") - .header("User-Agent", "axum-chat-app") - .bearer_auth(token.access_token().secret()) - .send() - .await; - - let user_data: User = match user_info_resp { - Ok(resp) => match resp.json().await { - Ok(u) => u, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to parse user info: {}", e) - ).into_response() - }, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to fetch user info: {}", e) - ).into_response() - }; - - session.insert("user", user_data).await.unwrap(); - - Redirect::to(AFTER_LOGIN_URL).into_response() +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + preferred_username: String, + // Add other fields as needed } async fn websocket_handler( ws: WebSocketUpgrade, + Query(params): Query, State(state): State>, - session: Session, ) -> impl IntoResponse { - if let Some(user) = session.get::("user").await.unwrap() { - return ws.on_upgrade(move |socket| websocket(socket, state, user)); + // For now, we'll verify the token by calling the userinfo endpoint. + // In a production app, you should verify the JWT signature locally using JWKS. + let client = reqwest::Client::new(); + let user_info_resp = client + .get("https://idm.flegr.me/application/o/userinfo/") + .header("User-Agent", "axum-chat-app") + .bearer_auth(¶ms.token) + .send() + .await; + + match user_info_resp { + Ok(resp) if resp.status().is_success() => { + let user: User = match resp.json().await { + Ok(u) => u, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + ws.on_upgrade(move |socket| handle_websocket(socket, state, user)) + } + _ => StatusCode::UNAUTHORIZED.into_response(), } - StatusCode::UNAUTHORIZED.into_response() } -// WebSocket handler -async fn websocket(stream: WebSocket, state: Arc, user: User) { - let (mut sender, mut receiver) = stream.split(); - let mut rx = state.tx.subscribe(); +// WebSocket connection handler +async fn handle_websocket(stream: WebSocket, state: Arc, user: User) { + let (mut ws_sender, mut ws_receiver) = stream.split(); + let username = user.login.clone(); + + // Create channel for this user + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Add user to connections + state.chat.add_user(username.clone(), tx).await; + println!("User connected: {}", username); + + // Send current user list to the newly connected user + let users = state.chat.get_online_users().await; + println!("Sending user list to {}: {:?}", username, users); + let user_list_msg = serde_json::to_string(&WsMessage::UserList { users }).unwrap(); + let _ = ws_sender.send(Message::Text(user_list_msg.into())).await; // Send welcome message - let msg = format!("System: Welcome, {}!", user.login); - let _ = sender.send(Message::Text(msg.into())).await; + let welcome = serde_json::to_string(&WsMessage::System { + content: format!("Welcome, {}!", username), + }) + .unwrap(); + let _ = ws_sender.send(Message::Text(welcome.into())).await; - // Send task - let mut send_task = tokio::spawn(async move { - while let Ok(msg) = rx.recv().await { - if sender.send(Message::Text(msg.into())).await.is_err() { + // Task to forward messages from channel to WebSocket + let send_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if ws_sender.send(Message::Text(msg.into())).await.is_err() { break; } } }); - // Receive task - let tx = state.tx.clone(); - let username = user.login.clone(); - let mut recv_task = tokio::spawn(async move { - while let Some(Ok(Message::Text(text))) = receiver.next().await { - // Prepend username - let msg = format!("{}: {}", username, text); - let _ = tx.send(msg); + // Broadcast user joined to everyone else (after send_task is spawned so others receive it) + let join_msg = serde_json::to_string(&WsMessage::UserJoined { + username: username.clone(), + }) + .unwrap(); + state.chat.broadcast_except(&join_msg, &username).await; + + // Task to receive messages from WebSocket and route them + let state_clone = state.clone(); + let username_clone = username.clone(); + let recv_task = tokio::spawn(async move { + while let Some(Ok(Message::Text(text))) = ws_receiver.next().await { + // Parse incoming message + if let Ok(msg) = serde_json::from_str::(&text) { + // Create private message + let private_msg = serde_json::to_string(&WsMessage::PrivateMessage { + from: username_clone.clone(), + to: msg.to.clone(), + content: msg.content.clone(), + }) + .unwrap(); + + // Send to recipient + state_clone.chat.send_to_user(&msg.to, &private_msg).await; + + // Also send back to sender (for their own chat view) + state_clone + .chat + .send_to_user(&username_clone, &private_msg) + .await; + } } }); + // Wait for either task to complete tokio::select! { - _ = (&mut send_task) => recv_task.abort(), - _ = (&mut recv_task) => send_task.abort(), - }; + _ = send_task => {}, + _ = recv_task => {}, + } + + // User disconnected - clean up + state.chat.remove_user(&username).await; +} + +// Message structure from client +#[derive(Debug, Deserialize)] +struct ClientMessage { + to: String, + content: String, }