p2p_chat/web/
mod.rs

1//! This module contains the web server implementation for the application.
2//!
3//! It sets up an Axum server to serve both static web UI assets and a REST API,
4//! including WebSocket communication.
5mod api;
6mod websocket;
7
8use crate::cli::commands::{Node, UiNotification};
9use anyhow::Result;
10use axum::{
11    http::{header, StatusCode, Uri},
12    response::Response,
13    routing::get,
14    Router,
15};
16use rust_embed::RustEmbed;
17use std::sync::Arc;
18use tokio::sync::{broadcast, mpsc};
19use tower_http::cors::CorsLayer;
20use tracing::info;
21use websocket::{WebSocketMessage, WebSocketState};
22
23/// Embeds the web UI static assets into the binary.
24#[derive(RustEmbed)]
25#[folder = "web-ui/dist"]
26struct Assets;
27
28/// Starts the web server.
29///
30/// This function initializes an Axum server, sets up API and WebSocket routes,
31/// serves static web UI assets, and forwards UI notifications to connected
32/// WebSocket clients.
33///
34/// # Arguments
35///
36/// * `node` - A shared reference to the application's core `Node`.
37/// * `port` - The port to bind the web server to.
38/// * `ui_notify_rx` - Receiver for UI notifications from the core application.
39///
40/// # Errors
41///
42/// Returns an error if the server fails to bind or run.
43pub async fn start_server(
44    node: Arc<Node>,
45    port: u16,
46    mut ui_notify_rx: mpsc::UnboundedReceiver<UiNotification>,
47) -> Result<()> {
48    let (broadcast_tx, _) = broadcast::channel::<WebSocketMessage>(100);
49
50    let ws_state = Arc::new(WebSocketState {
51        broadcast_tx: broadcast_tx.clone(),
52    });
53
54    // Spawn task to forward UI notifications to broadcast channel.
55    let node_clone = node.clone();
56    tokio::spawn(async move {
57        while let Some(notification) = ui_notify_rx.recv().await {
58            match notification {
59                UiNotification::NewMessage(msg) => {
60                    // Decrypt message content before broadcasting.
61                    let other_peer = if msg.sender == node_clone.identity.peer_id {
62                        &msg.recipient
63                    } else {
64                        &msg.sender
65                    };
66
67                    let content = match node_clone.friends.get_friend(other_peer).await {
68                        Ok(Some(friend)) => {
69                            match node_clone
70                                .identity
71                                .decrypt_from(&friend.e2e_public_key, &msg.content)
72                            {
73                                Ok(plaintext) => match String::from_utf8(plaintext) {
74                                    Ok(s) => s,
75                                    Err(_) => continue, // Skip non-UTF8 messages
76                                },
77                                Err(_) => continue, // Skip decryption failures
78                            }
79                        }
80                        _ => continue, // Skip if friend not found
81                    };
82
83                    let ws_msg = WebSocketMessage::NewMessage {
84                        id: msg.id.to_string(),
85                        sender: msg.sender.to_string(),
86                        recipient: msg.recipient.to_string(),
87                        content,
88                        timestamp: msg.timestamp,
89                        nonce: msg.nonce,
90                        delivery_status: format!("{:?}", msg.delivery_status),
91                    };
92                    let _ = broadcast_tx.send(ws_msg);
93                }
94                UiNotification::PeerConnected(peer_id) => {
95                    let ws_msg = WebSocketMessage::PeerConnected {
96                        peer_id: peer_id.to_string(),
97                    };
98                    let _ = broadcast_tx.send(ws_msg);
99                }
100                UiNotification::PeerDisconnected(peer_id) => {
101                    let ws_msg = WebSocketMessage::PeerDisconnected {
102                        peer_id: peer_id.to_string(),
103                    };
104                    let _ = broadcast_tx.send(ws_msg);
105                }
106                UiNotification::DeliveryStatusUpdate { message_id, new_status } => {
107                    let ws_msg = WebSocketMessage::DeliveryStatusUpdate {
108                        message_id: message_id.to_string(),
109                        new_status: format!("{:?}", new_status),
110                    };
111                    let _ = broadcast_tx.send(ws_msg);
112                }
113            }
114        }
115    });
116
117    let api_router = Router::new()
118        .route("/api/me", get(api::get_me))
119        .route("/api/friends", get(api::list_friends).post(api::add_friend))
120        .route("/api/conversations", get(api::list_conversations))
121        .route("/api/conversations/:peer_id/messages", get(api::get_messages))
122        .route("/api/conversations/:peer_id/messages", axum::routing::post(api::send_message))
123        .route("/api/messages/:msg_id/read", axum::routing::post(api::mark_message_read))
124        .route("/api/peers/online", get(api::get_online_peers))
125        .route("/api/system/status", get(api::get_system_status))
126        .with_state(node);
127
128    let ws_router = Router::new()
129        .route("/ws", get(websocket::ws_handler))
130        .with_state(ws_state);
131
132    let app = Router::new()
133        .merge(api_router)
134        .merge(ws_router)
135        .fallback(static_handler)
136        .layer(CorsLayer::permissive());
137
138    let addr = format!("127.0.0.1:{}", port);
139    let listener = tokio::net::TcpListener::bind(&addr).await?;
140
141    info!("Web server listening on http://{}", addr);
142
143    axum::serve(listener, app).await?;
144
145    Ok(())
146}
147
148/// Handles requests for static web UI assets.
149///
150/// This function attempts to serve the requested file from the embedded assets.
151/// If the path is empty or "index.html", it serves "index.html".
152/// If a file is not found, it falls back to serving "index.html" for client-side routing.
153///
154/// # Arguments
155///
156/// * `uri` - The `Uri` of the incoming request.
157///
158/// # Returns
159///
160/// An Axum `Response` containing the static file or a 404 error.
161async fn static_handler(uri: Uri) -> Response {
162    let path = uri.path().trim_start_matches('/');
163
164    if path.is_empty() || path == "index.html" {
165        return serve_file("index.html");
166    }
167
168    // Try to serve the file, if not found serve index.html for client-side routing.
169    if Assets::get(path).is_some() {
170        serve_file(path)
171    } else {
172        serve_file("index.html")
173    }
174}
175
176/// Serves a static file from the embedded assets.
177///
178/// # Arguments
179///
180/// * `path` - The path to the file within the embedded assets.
181///
182/// # Returns
183///
184/// An Axum `Response` containing the file content or a 404 error.
185fn serve_file(path: &str) -> Response {
186    match Assets::get(path) {
187        Some(content) => {
188            let mime = mime_guess::from_path(path).first_or_octet_stream();
189            let body = content.data.into_owned();
190
191            Response::builder()
192                .status(StatusCode::OK)
193                .header(header::CONTENT_TYPE, mime.as_ref())
194                .body(body.into())
195                .unwrap()
196        }
197        None => {
198            Response::builder()
199                .status(StatusCode::NOT_FOUND)
200                .header(header::CONTENT_TYPE, "text/plain")
201                .body("404 Not Found".into())
202                .unwrap()
203        }
204    }
205}