Compare commits

..

No commits in common. "client" and "main" have entirely different histories.
client ... main

16 changed files with 2222 additions and 1711 deletions

22
.vscode/settings.json vendored
View file

@ -1,22 +0,0 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#65c89b",
"activityBar.background": "#65c89b",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#945bc4",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#15202b99",
"sash.hoverBorder": "#65c89b",
"statusBar.background": "#42b883",
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#359268",
"statusBarItem.remoteBackground": "#42b883",
"statusBarItem.remoteForeground": "#15202b",
"titleBar.activeBackground": "#42b883",
"titleBar.activeForeground": "#15202b",
"titleBar.inactiveBackground": "#42b88399",
"titleBar.inactiveForeground": "#15202b99"
},
"peacock.color": "#42b883"
}

1903
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
[package] [package]
name = "chatclient" name = "chatserver"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@ -7,16 +7,18 @@ edition = "2021"
colog = "1.3.0" colog = "1.3.0"
log = "0.4.22" log = "0.4.22"
tokio = { version = "1.41.1", features = ["full"] } tokio = { version = "1.41.1", features = ["full"] }
tokio-util = { version = "0.7.12", features = ["codec"] }
serde = { version = "1.0", features = ["derive"] }
toml = "0.8.19"
x25519-dalek = "2.0.0-rc.3" x25519-dalek = "2.0.0-rc.3"
aes-gcm = "0.10.3" aes-gcm = "0.10.3"
rand = "0.8.5" rand = "0.8.5"
rand_core = "0.6.4" rand_core = "0.6.4"
crypto = "0.5.1" crypto = "0.5.1"
base64 = "0.21" base64 = "0.21"
ratatui = { version = "0.29.0", features = ["all-widgets"] } serde = { version = "1.0", features = ["derive"] }
crossterm = "0.27" toml = "0.8.19"
reqwest = "0.12.15" ratatui = "0.29.0"
notify-rust = "4" crossterm = "0.28.1"
chrono = "0.4.39"
sqlx = { version = "0.8", features = [ "sqlite", "runtime-tokio", "tls-native-tls" ] }
bcrypt = "0.17.0"
argon2 = "0.5.3"
thiserror = "2.0.12"

78
README.md Normal file
View file

@ -0,0 +1,78 @@
# SRC (Simple Rust Chat)
Simple Rust Chat è una chat Client/Server TCP
La chat è basata molto sull'idea di una chat IRC (inizialmente il progetto aveva come scopo la creazione di un server IRC da utilizzare con dei clienti IRC come Halloy o mIRC)
## Linguaggio
Ho utilizzato Rust come linguaggio per questo programma per la sua velocità e leggerezza che permette di farlo runnare anche su sistemi con componenti poco potenti. Anche il client è scritto in Rust per compatibilità tra librerie utilizzate nel client e nel server. Permette anche di essere dockerizzato come immagine il server che permette di scalare il server utilizzando Kubernets o altri sistemi di scalability.
## Librerie
Le librerie utilizzate in particolare sono Tokio, un framework per applicazioni async e che offre anche connessioni Socket. Per la gestione del database viene usato SQLx, una libreria che offre una connessione standard per vari tipi di DBMS.
serde: Per serializzazione/deserializzazione strutturata dei pacchetti
log + env_logger: Per logging strutturato
## Funzioni
### Funzioni Fondamentali:
- Chattare con altri utenti in canali per topic
- Chattare con una persona sola (DMs)
- Inviare i file tra utenti
- Possibilità di amministrare la chat con comandi di /kick o /ban
- Usa un Db SQLite per tenere le informazioni degli utenti registrati
- È possibile registarsi usando /register password che viene salvata usando SHA-256 e usare dal prossimo login il comando /login
### Funzionalità opzionali che si potrebbero aggiungere:
- Lista utenti online per canale
- Cronologia messaggi
- Sistema di ruoli più granulare
- Notifiche di menzione (@user)
## Protocolli e Sicurezza
Il server utilizza TCP/IP come protocollo per la trasmissione dei dati in rete. I pacchetti sono composti da un pacchetto prestabilito
```
/*
Specifications of the packet
32 bytes - Command name
512 bytes - Command argument
if command is empty then it is a message
*/
```
La chat è sicura usando x25519-dalek e AES-128 per criptare i messaggi e i dati dei file che vengono inviati. Lo scambio di chiavi viene effettuato con Diffie Hellman
Il Db è SQLite che permette di usarlo da un singolo file senza nessun problema. Per lo sviluppo sono utilizzate le mitigations cosi da aggiornare il db anche con versione vecchie del server
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
participant DB as SQLite DB
C->>S: Connessione TCP
S->>C: Challenge DH
C->>S: Risposta DH
Note over C,S: Generazione chiavi AES-128
alt Registrazione
C->>S: /register [password]
Note over S: Hash SHA-256
S->>DB: Salva utente + hash
S->>C: Conferma registrazione
else Login
C->>S: /login [password]
S->>DB: Verifica hash
S->>C: Conferma login
end
rect rgb(200, 220, 255)
Note over C,S: Comunicazione crittografata
C->>S: Messaggi/Comandi (AES-128)
S->>C: Risposte/Broadcast (AES-128)
end
```

View file

@ -1,2 +1,2 @@
ip = '127.0.0.1' address = "127.0.0.1"
port = 25565 port = "25565"

BIN
db.sqlite Normal file

Binary file not shown.

View file

@ -0,0 +1,9 @@
-- Create the users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- Unique ID for each user
username TEXT NOT NULL UNIQUE, -- Username, must be unique
password_hash TEXT NOT NULL -- Hashed password for security
);
-- Create an index on the username and email columns for faster lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);

View file

@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN is_admin BOOLEAN DEFAULT FALSE;

View file

@ -0,0 +1,5 @@
ALTER TABLE users
ADD COLUMN is_banned BOOLEAN DEFAULT FALSE;
ALTER TABLE users
ADD COLUMN ban_reason VARCHAR(255);

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS kick (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- Unique ID for each kick
user_name VARCHAR(255) NOT NULL -- ID of the user who made the kick
);
-- -- Create an index on the user_name column for faster lookups
CREATE INDEX IF NOT EXISTS idx_kick_user_name ON kick (user_name);

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
path VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_files_name ON files (name);

View file

@ -0,0 +1,2 @@
ALTER TABLE files
ADD COLUMN admin_verified BOOLEAN DEFAULT FALSE;

781
src/client/mod.rs Normal file
View file

@ -0,0 +1,781 @@
pub(crate) mod handlers {
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use crate::db::users::{
add_kick, add_new_file, add_verified_flag_to_file, ban_user, change_password, check_ban,
check_file_verified, check_for_account, check_kick, create_user, get_ban_reason,
hash_password, remove_kick, request_file, unban_user, verify_admin, verify_password,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use log::{debug, error, info};
use serde::Deserialize;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::sync::broadcast;
use x25519_dalek::{EphemeralSecret, PublicKey};
/*
Specifications of the packet
32 bytes - Command name
512 bytes - Command argument
if command is empty then it is a message
*/
#[derive(Deserialize, Debug)]
struct Message {
command: Vec<String>,
argument: Vec<String>, // Changed from Vec<str> to Vec<String>
}
fn parse_message(message: &str) -> Message {
let mut iter = message.split_whitespace();
let command: Vec<String> = if let Some(cmd) = iter.next() {
if cmd.starts_with("/") {
vec![cmd.to_string()]
} else {
Vec::new() // Empty command means it's a regular message
}
} else {
Vec::new()
};
let argument: Vec<String> = iter.map(String::from).collect();
Message { command, argument }
}
pub async fn handle_client(
socket: TcpStream,
tx: broadcast::Sender<String>,
mut rx: broadcast::Receiver<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
let server_secret = EphemeralSecret::random_from_rng(OsRng);
let server_public = PublicKey::from(&server_secret);
// Send the server's public key to the client
writer.write_all(server_public.as_bytes()).await?;
// Receive the client's public key
let mut client_public_bytes = [0u8; 32];
reader.read_exact(&mut client_public_bytes).await?;
let client_public = PublicKey::from(client_public_bytes);
// Compute the shared secret
let shared_secret = server_secret.diffie_hellman(&client_public);
let key = Key::<Aes256Gcm>::from_slice(shared_secret.as_bytes());
let cipher_reader = Aes256Gcm::new(&key);
let cipher_writer = Aes256Gcm::new(&key);
let nonce_reader = Nonce::from_slice(b"unique nonce"); // 96-bits; fixed nonce
let nonce_writer = nonce_reader.clone();
debug!("Reciving Username");
// Read the username from the client
line.clear();
reader.read_line(&mut line).await?;
let decoded = BASE64.decode(line.trim().as_bytes())?;
let decrypted = cipher_reader
.decrypt(&nonce_reader, decoded.as_ref())
.unwrap();
let username = Arc::new(String::from_utf8(decrypted)?);
let username_read = Arc::clone(&username); // Clone the Arc for read task
let username_write = Arc::clone(&username); // Clone the Arc for write task
info!("Username received: {}", username);
// Check if the user already exists in the database
if check_for_account(&username).await? {
// Check if the user is banned
if check_ban(&username).await? == true {
let ban_reason_result = get_ban_reason(&username).await;
let message: String = match ban_reason_result {
Ok(Some(reason)) => {
info!("User {} is banned, Reason: {}", username, reason);
format!("User {} is banned, Reason: {}", username, reason).to_string()
}
Ok(None) => {
info!("User {} is banned, but no reason provided", username);
format!("User {} is banned, but no reason provided", username).to_string()
}
Err(e) => {
error!("Error fetching ban reason: {}", e);
format!("You are banned").to_string();
return Ok(());
}
};
let encrypted = match cipher_writer.encrypt(&nonce_writer, message.as_bytes()) {
Ok(encrypted) => encrypted,
Err(e) => {
error!("Encryption error: {}", e);
return Ok(());
}
};
let message = format!("{}\n", BASE64.encode(&encrypted));
writer.write_all(message.as_bytes()).await?;
return Ok(());
}
info!("User {} already exists", username);
// Send a message to the client
let message = format!("User {} is registered, input your password", username);
let encrypted = match cipher_writer.encrypt(&nonce_writer, message.as_bytes()) {
Ok(encrypted) => encrypted,
Err(e) => {
error!("Encryption error: {}", e);
return Ok(());
}
};
let message = format!("{}\n", BASE64.encode(&encrypted));
writer.write_all(message.as_bytes()).await?;
// Read the password from the client
line.clear();
reader.read_line(&mut line).await?;
let decoded = BASE64.decode(line.trim().as_bytes())?;
let decrypted = cipher_reader
.decrypt(&nonce_reader, decoded.as_ref())
.unwrap();
// verifiy password
let password = String::from_utf8(decrypted)?;
if verify_password(&username, &password).await? == true {
info!("Password verified successfully");
} else {
info!("Password verification failed");
// Send an error message to the client
let message = format!("Invalid password for user {}", username);
let encrypted = match cipher_writer.encrypt(&nonce_writer, message.as_bytes()) {
Ok(encrypted) => encrypted,
Err(e) => {
error!("Encryption error: {}", e);
return Ok(());
}
};
let message = format!("{}\n", BASE64.encode(&encrypted));
writer.write_all(message.as_bytes()).await?;
return Ok(());
}
} else {
// User does not exist, create a new account
// Send a message to the client
let message = format!("User {} is not registered, input your password", username);
let encrypted = match cipher_writer.encrypt(&nonce_writer, message.as_bytes()) {
Ok(encrypted) => encrypted,
Err(e) => {
error!("Encryption error: {}", e);
return Ok(());
}
};
let message = format!("{}\n", BASE64.encode(&encrypted));
writer.write_all(message.as_bytes()).await?;
// Read the password from the client
line.clear();
reader.read_line(&mut line).await?;
let decoded = BASE64.decode(line.trim().as_bytes())?;
let decrypted = cipher_reader
.decrypt(&nonce_reader, decoded.as_ref())
.unwrap();
let password = String::from_utf8(decrypted)?;
info!("Password received");
// Hash the password
let password_hash = hash_password(&password).await;
let password_hash = password_hash.as_str();
info!("Password hashed successfully");
debug!("Hash: {}", password_hash);
// Create the user in the database
create_user(&username, password_hash).await?;
}
// Send a success message to the client
let message = format!("Welcome, {}!", username);
let encrypted = match cipher_writer.encrypt(&nonce_writer, message.as_bytes()) {
Ok(encrypted) => encrypted,
Err(e) => {
error!("Encryption error: {}", e);
return Ok(());
}
};
let message = format!("{}\n", BASE64.encode(&encrypted));
writer.write_all(message.as_bytes()).await?;
// Read task for receiving messages from the client
let read_task = tokio::spawn(async move {
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(bytes_read) => {
if bytes_read == 0 {
info!("Client disconnected");
break;
}
let decoded = match BASE64.decode(line.trim().as_bytes()) {
Ok(decoded) => decoded,
Err(e) => {
error!("Base64 decode error: {:?}", e);
continue;
}
};
let decrypted = match cipher_reader.decrypt(&nonce_reader, decoded.as_ref())
{
Ok(decrypted) => decrypted,
Err(e) => {
error!("Decryption error: {:?}", e);
continue;
}
};
let message = match String::from_utf8(decrypted) {
Ok(msg) => msg,
Err(e) => {
error!("UTF-8 conversion error: {:?}", e);
continue;
}
};
info!("Parsing message");
let parsed_message = parse_message(message.as_str());
if check_kick(&username).await.unwrap() == true {
info!("User {} is kicked", username);
let message = format!("User {} is kicked", username);
let _ = tx.send(message);
remove_kick(&username).await.unwrap();
break;
}
if check_ban(&username).await.unwrap() == true {
info!("User {} is banned", username);
let message = format!("User {} is banned", username);
let _ = tx.send(message);
break;
}
// Handle commands
if !parsed_message.command.is_empty() {
match parsed_message.command[0].as_str() {
"/msg" => {
if parsed_message.argument.len() < 2 {
match tx.send("Error! Invalid /msg format".to_string()) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
continue;
}
let target_user = &parsed_message.argument[0];
let msg_content = parsed_message.argument[1..].join(" ");
info!("Private message to {}: {}", target_user, msg_content);
// dm format sender|target_user message
let formatted_message = format!(
"{}|{} {}",
username_read, target_user, msg_content
);
match tx.send(formatted_message) {
Ok(_) => info!("Private message sent successfully"),
Err(e) => {
error!("Failed to send private message: {:?}", e);
break;
}
}
}
"/quit" => {
info!("Client requested to quit");
break;
}
"/kick" => {
if parsed_message.argument.is_empty() {
error!("Invalid /kick format. Usage: /kick username");
match tx.send(
format!("Error! Invalid /kick format").to_string(),
) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
continue;
}
match verify_admin(&username_read).await {
Ok(true) => {
info!("User {} is admin", username);
let target_user = &parsed_message.argument[0];
info!("Kicking user: {}", target_user);
add_kick(&target_user).await.unwrap();
match tx.send(format!(
"User {} has been kicked",
target_user
)) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
Ok(false) => {
error!("User {} is not admin", username);
continue;
}
Err(e) => {
error!("Error verifying admin: {:?}", e);
continue;
}
}
}
"/addfile" => {
if parsed_message.argument.is_empty() {
error!("Invalid /addfile format. Usage: /addfile filename link");
match tx.send(
format!("Invalid /addfile format. Usage: /addfile filename link").to_string(),
) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
continue;
}
let file_name = &parsed_message.argument[0];
let file_link = &parsed_message.argument[1];
info!("Adding file: {}", file_name);
info!("File link: {}", file_link);
add_new_file(&file_name, &file_link).await.unwrap();
match tx.send(format!("File {} has been added", file_name)) {
Ok(_) => {
info!("Error message sent to client {}", username_write)
}
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
"/verifylink" => {
if parsed_message.argument.is_empty() {
error!("Invalid /verifylink format. Usage: /verifylink filename");
match tx.send(
format!("Invalid /verifylink format. Usage: /verifylink filename").to_string(),
) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
continue;
}
let file_name = &parsed_message.argument[0];
info!("Verifying link for file: {}", file_name);
match verify_admin(&username).await {
Ok(true) => {
info!("User {} is admin", username);
add_verified_flag_to_file(file_name).await.unwrap();
match tx.send(format!(
"File {} has been verified",
file_name
)) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
Ok(false) => {
error!("User {} is not admin", username);
continue;
}
Err(e) => {
error!("Error verifying admin: {:?}", e);
continue;
}
}
}
"/requestfile" => {
if parsed_message.argument.is_empty() {
error!("Invalid /requestfile format. Usage: /requestfile filename");
match tx.send(
format!("Invalid /requestfile format. Usage: /requestfile filename").to_string(),
) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
continue;
}
let file_name = &parsed_message.argument[0];
info!("Requesting file: {}", file_name);
let file_link = request_file(file_name).await.unwrap();
match tx.send(format!("Link for {}: {}", file_name, file_link))
{
Ok(_) => {
info!("message sent to client {}", username_write)
}
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
if check_file_verified(file_name).await.unwrap() == true {
match tx.send(format!("dl! {}", file_link)) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
}
"/ban" => {
if parsed_message.argument.is_empty() {
error!("Invalid /ban format. Usage: /ban username");
match tx
.send(format!("Error! Invalid /ban format").to_string())
{
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
continue;
}
match verify_admin(&username_read).await {
Ok(true) => {
info!("User {} is admin", username);
let target_user = &parsed_message.argument[0];
info!("Banning user: {}", target_user);
match check_ban(target_user).await {
Ok(true) => {
info!("User {} is already banned", target_user);
match tx.send(format!(
"User {} is already banned",
target_user
)) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
Ok(false) => {
ban_user(
target_user,
"You're banned from this server.",
)
.await
.unwrap();
info!("User {} has been banned", target_user);
match tx.send(
format!(
"User {} has been banned",
target_user,
)
.to_string(),
) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
continue;
}
Err(e) => {
error!("Error checking ban status: {:?}", e);
}
}
}
Ok(false) => {
error!("User {} is not admin", username);
continue;
}
Err(e) => {
error!("Error verifying admin: {:?}", e);
continue;
}
}
}
"/unban" => {
if parsed_message.argument.is_empty() {
error!("Invalid /unban format. Usage: /unban username");
}
match verify_admin(&username_read).await {
Ok(true) => {
info!("User {} is admin", username);
let target_user = &parsed_message.argument[0];
info!("Unbanning user: {}", target_user);
match check_ban(target_user).await {
Ok(true) => {
info!("User {} is banned", target_user);
unban_user(target_user).await.unwrap();
info!("User {} has been unbanned", target_user);
match tx.send(
format!(
"User {} has been unbanned",
target_user,
)
.to_string(),
) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
Ok(false) => {
info!("User {} is not banned", target_user);
match tx.send(format!(
"User {} is not banned",
target_user
)) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
Err(e) => {
error!("Error checking ban status: {:?}", e);
}
}
}
Ok(false) => {
error!("User {} is not admin", username);
continue;
}
Err(e) => {
error!("Error verifying admin: {:?}", e);
continue;
}
}
}
"/changepassword" => {
if parsed_message.argument.len() < 2 {
error!("Invalid /changepassword format. Usage: /changepassword old_password new_password");
match tx
.send(format!("Invalid /changepassword format. Usage: /changepassword old_password new_password"))
{
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
continue;
}
let old_password = &parsed_message.argument[0];
let new_password = &parsed_message.argument[1];
info!("Changing password for user {}", username);
info!("new password: {}", new_password);
info!("old password: {}", old_password);
if verify_password(old_password, &username).await.is_ok() {
match change_password(&username, new_password).await {
Ok(_) => {
info!("Password changed successfully");
let _ = tx.send(
"Password changed successfully".to_string(),
);
}
Err(e) => {
error!("Error changing password: {:?}", e);
match tx.send(format!(
"Error changing password: {:?}",
e
)) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!(
"Failed to send error message: {:?}",
e
);
break;
}
}
continue;
}
}
} else {
info!("Old password verification failed");
match tx.send(format!(
"Invalid old password for user {}",
username
)) {
Ok(_) => info!(
"Error message sent to client {}",
username_write
),
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
}
_ => {
error!("Unknown command: {}", parsed_message.command[0]);
match tx.send("Error! Unknown command".to_string()) {
Ok(_) => {
info!("Error message sent to client {}", username_write)
}
Err(e) => {
error!("Failed to send error message: {:?}", e);
break;
}
}
}
}
} else {
// Regular message handling
info!(
"Received message from {}: {}",
username_read,
parsed_message.argument.join(" ")
);
let formatted_message =
format!("{}: {}", username_read, message.trim());
// Broadcast the message to all clients
match tx.send(formatted_message) {
Ok(_) => info!("Message broadcast successfully"),
Err(e) => {
error!("Failed to broadcast message: {:?}", e);
break;
}
}
}
}
Err(e) => {
error!("Error reading from client: {:?}", e);
break;
}
}
}
});
// Write task for sending messages to the client
let write_task = tokio::spawn(async move {
while let Ok(msg) = rx.recv().await {
if !msg.is_empty() {
// Encrypt the message with error handling
let encrypted = match cipher_writer.encrypt(&nonce_writer, msg.as_bytes()) {
Ok(encrypted) => encrypted,
Err(e) => {
error!("Encryption error: {:?}", e);
continue;
}
};
// Base64 encode and format with newline
let message = format!("{}\n", BASE64.encode(&encrypted));
// Write with proper error handling
if let Err(e) = writer.write_all(message.as_bytes()).await {
error!("Failed to send message: {:?}", e);
break;
}
}
}
});
// Wait for both tasks to complete
tokio::select! {
_ = read_task => (),
_ = write_task => (),
}
info!("Client handling completed");
Ok(())
}
}

392
src/db/mod.rs Normal file
View file

@ -0,0 +1,392 @@
pub(crate) mod users {
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use log::info;
use sqlx::{sqlite::SqlitePool, Row};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DbError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Password hashing error: {0}")]
Hashing(argon2::password_hash::Error),
#[error("User not found")]
UserNotFound,
}
pub async fn connect_to_db() -> Result<SqlitePool, sqlx::Error> {
let pool = SqlitePool::connect("sqlite:./db.sqlite").await?;
Ok(pool)
}
pub async fn create_db_pool() -> Result<SqlitePool, sqlx::Error> {
let pool = connect_to_db().await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
pub async fn check_for_account(username: &str) -> Result<bool, sqlx::Error> {
// Fixed error type
let pool = create_db_pool().await?;
let exists = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1
FROM users
WHERE username = ?
)
"#,
)
.bind(username)
.fetch_one(&pool)
.await?
.get::<i64, _>(0);
Ok(exists == 1)
}
pub async fn create_user(username: &str, password_hash: &str) -> Result<(), sqlx::Error> {
let pool = create_db_pool().await?;
sqlx::query(
r#"
INSERT INTO users (username, password_hash)
VALUES (?, ?)
"#,
)
.bind(username)
.bind(password_hash)
.execute(&pool)
.await?;
Ok(())
}
pub async fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut rand::thread_rng());
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)
.expect("Failed to hash password");
password_hash.to_string()
}
pub async fn check_ban(username: &str) -> Result<bool, sqlx::Error> {
let pool = create_db_pool().await?;
let is_banned = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1
FROM users
WHERE username = ? AND is_banned = 1
)
"#,
)
.bind(username)
.fetch_one(&pool)
.await?
.get::<i64, _>(0);
// Check if the user is banned
if is_banned == 1 {
info!("User {} is banned", username);
} else {
info!("User {} is not banned", username);
}
Ok(is_banned == 1)
}
pub async fn get_ban_reason(username: &str) -> Result<Option<String>, sqlx::Error> {
let pool = create_db_pool().await?;
info!("Attempting to fetch ban reason for user: {}", username);
let row_option = sqlx::query(
r#"
SELECT ban_reason
FROM users
WHERE username = ?
"#,
)
.bind(username)
.fetch_optional(&pool)
.await?;
// Process the result
match row_option {
Some(row) => {
// Row found, now get the ban_reason (which might be NULL)
let reason: Option<String> = row.get(0); // Type annotation clarifies intent
if let Some(ref r) = reason {
info!("User {} found. Ban reason: {}", username, r);
} else {
// User exists, but ban_reason is NULL in the database
info!(
"User {} found, but ban_reason is NULL (not banned)",
username
);
}
Ok(reason)
}
None => {
// No row found for the username
info!("User {} not found in the database", username);
// Return Ok(None) as per the function signature, indicating no ban reason found
// because the user doesn't exist.
Ok(None)
}
}
}
pub async fn ban_user(username: &str, ban_reason: &str) -> Result<(), sqlx::Error> {
let pool = create_db_pool().await?;
// Use a single query to update the user
sqlx::query(
r#"
UPDATE users
SET is_banned = 1, ban_reason = ?
WHERE username = ?
"#,
)
.bind(ban_reason)
.bind(username)
.execute(&pool)
.await?;
Ok(())
}
pub async fn unban_user(username: &str) -> Result<(), sqlx::Error> {
let pool = create_db_pool().await?;
// Use a single query to update the user
sqlx::query(
r#"
UPDATE users
SET is_banned = 0, ban_reason = NULL
WHERE username = ?
"#,
)
.bind(username)
.execute(&pool)
.await?;
Ok(())
}
pub async fn change_password(username: &str, new_password: &str) -> Result<(), sqlx::Error> {
let pool = create_db_pool().await?;
// Hash the new password
let new_password_hash = hash_password(new_password).await;
// Update the password in the database
sqlx::query(
r#"
UPDATE users
SET password_hash = ?
WHERE username = ?
"#,
)
.bind(new_password_hash)
.bind(username)
.execute(&pool)
.await?;
Ok(())
}
pub async fn verify_password(username: &str, provided_password: &str) -> Result<bool, DbError> {
let pool = create_db_pool().await?; // Propagates sqlx::Error
// Fetch the stored hash for the user
let user_row = sqlx::query(
r#"
SELECT password_hash
FROM users
WHERE username = ?
"#,
)
.bind(username)
.fetch_optional(&pool) // Use fetch_optional to handle not found case
.await?;
// Get the stored hash string or return error if user not found
let stored_hash_str = match user_row {
Some(row) => row.get::<String, _>(0),
None => return Err(DbError::UserNotFound),
};
// Parse the stored hash
let parsed_hash = PasswordHash::new(&stored_hash_str).map_err(DbError::Hashing)?;
let argon2 = Argon2::default();
let verification_result =
argon2.verify_password(provided_password.as_bytes(), &parsed_hash);
// Check the result and return true/false accordingly
match verification_result {
Ok(()) => {
info!("Password check successful for user: {}", username);
Ok(true)
}
Err(argon2::password_hash::Error::Password) => {
info!("Password check failed (mismatch) for user: {}", username);
Ok(false)
}
Err(e) => {
// Handle other potential argon2 errors (e.g., invalid hash format)
info!(
"Password check failed for user {} with error: {}",
username, e
);
Err(DbError::Hashing(e))
}
}
}
pub async fn verify_admin(username: &str) -> Result<bool, sqlx::Error> {
let pool = create_db_pool().await?;
let is_admin = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1
FROM users
WHERE username = ? AND is_admin = 1
)
"#,
)
.bind(username)
.fetch_one(&pool)
.await?
.get::<i64, _>(0);
Ok(is_admin == 1)
}
pub async fn add_kick(username: &str) -> Result<(), sqlx::Error> {
let pool = create_db_pool().await?;
sqlx::query(
r#"
INSERT INTO kick (user_name)
VALUES (?)
"#,
)
.bind(username)
.execute(&pool)
.await?;
Ok(())
}
pub async fn remove_kick(username: &str) -> Result<(), sqlx::Error> {
let pool = create_db_pool().await?;
sqlx::query(
r#"
DELETE FROM kick
WHERE user_name = ?
"#,
)
.bind(username)
.execute(&pool)
.await?;
Ok(())
}
pub async fn check_kick(username: &str) -> Result<bool, sqlx::Error> {
let pool = create_db_pool().await?;
let exists = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1
FROM kick
WHERE user_name = ?
)
"#,
)
.bind(username)
.fetch_one(&pool)
.await?
.get::<i64, _>(0);
Ok(exists == 1)
}
pub async fn add_new_file(name: &str, link: &str) -> Result<(), sqlx::Error> {
let pool = create_db_pool().await?;
sqlx::query(
r#"
INSERT INTO files (name, path)
VALUES (?, ?)
"#,
)
.bind(name)
.bind(link)
.execute(&pool)
.await?;
Ok(())
}
pub async fn request_file(name: &str) -> Result<String, sqlx::Error> {
let pool = create_db_pool().await?;
let file_path = sqlx::query(
r#"
SELECT path
FROM files
WHERE name = ?
"#,
)
.bind(name)
.fetch_one(&pool)
.await?
.get::<String, _>(0);
Ok(file_path)
}
pub async fn add_verified_flag_to_file(name: &str) -> Result<(), sqlx::Error> {
let pool = create_db_pool().await?;
sqlx::query(
r#"
UPDATE files
SET admin_verified = 1
WHERE name = ?
"#,
)
.bind(name)
.execute(&pool)
.await?;
Ok(())
}
pub async fn check_file_verified(name: &str) -> Result<bool, sqlx::Error> {
let pool = create_db_pool().await?;
let is_verified = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1
FROM files
WHERE name = ? AND admin_verified = 1
)
"#,
)
.bind(name)
.fetch_one(&pool)
.await?
.get::<i64, _>(0);
Ok(is_verified == 1)
}
}

View file

@ -1,510 +1,108 @@
use aes_gcm::{ mod client;
aead::{Aead, KeyInit, OsRng}, mod db;
Aes256Gcm, Key, Nonce, mod tui;
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use client::handlers::handle_client;
use colog; use db::users::create_db_pool;
use log::{debug, error, info, warn}; use log::{error, info, Level, Log, Metadata, Record};
use notify_rust::Notification;
use serde::Deserialize; use serde::Deserialize;
use std::{ use std::thread;
fs, use std::{process::exit, sync::mpsc};
fs::File, use tokio::net::TcpListener;
io, use tokio::sync::broadcast;
sync::{Arc, Mutex}, use tui::{run_app, LogEntry};
};
use tokio::{
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
net::TcpStream,
sync::mpsc,
time::{sleep, Duration},
};
use x25519_dalek::{EphemeralSecret, PublicKey};
// Ratatui imports
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Position},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Terminal,
};
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
struct Config { struct Config {
ip: String, address: String,
port: Option<u16>, port: String,
} }
// UI structs and enums struct CustomLogger {
enum InputMode { tx: mpsc::Sender<LogEntry>,
Normal,
Editing,
} }
struct ChatState { impl Log for CustomLogger {
input: String, fn enabled(&self, metadata: &Metadata) -> bool {
messages: Vec<(String, String)>, // (username, message) metadata.level() <= Level::Info
input_mode: InputMode,
username: String,
should_quit: bool,
} }
impl ChatState { fn log(&self, record: &Record) {
fn new(username: String) -> Self { if self.enabled(record.metadata()) {
ChatState { let now = chrono::Local::now();
input: String::new(), let log_entry = LogEntry {
messages: Vec::new(), timestamp: now.format("%H:%M:%S").to_string(),
input_mode: InputMode::Editing, level: record.level().to_string(),
username, message: record.args().to_string(),
should_quit: false, };
let _ = self.tx.send(log_entry);
} }
} }
fn flush(&self) {}
} }
#[tokio::main] #[tokio::main]
async fn main() -> io::Result<()> { async fn main() {
let original_hook = std::panic::take_hook(); create_db_pool().await.unwrap();
std::panic::set_hook(Box::new(move |panic_info| {
// Restore terminal
let _ = disable_raw_mode();
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture);
// Call the original hook // Create a channel for logging
original_hook(panic_info); let (tx, rx) = mpsc::channel();
}));
colog::init(); // Create and set the custom logger
let logger = Box::new(CustomLogger { tx });
log::set_boxed_logger(logger).unwrap();
log::set_max_level(log::LevelFilter::Info);
let contents = // Start the TUI in a separate thread
fs::read_to_string("config.toml").expect("Should have been able to read the file"); let _tui_handle = thread::spawn(move || {
let config: Config = if let Err(e) = run_app(rx) {
toml::from_str(&contents).expect("Should have been able to parse the file"); eprintln!("Error running TUI: {:?}", e);
}
info!("Enter your username (or press Enter to use a random one): "); // Exit the process when the TUI closes
let mut input = String::new(); exit(0);
std::io::stdin().read_line(&mut input).unwrap(); });
let username = if input.trim().is_empty() {
format!("User{}", rand::random::<u32>()) // Load the configuration from config file
} else { let config = match std::fs::read_to_string("config.toml") {
input.trim().to_string() Ok(config) => match toml::from_str::<Config>(&config) {
Ok(config) => config,
Err(e) => {
error!("Failed to parse config file: {:?}", e);
std::process::exit(1);
}
},
Err(e) => {
error!("Failed to read config file: {:?}", e);
std::process::exit(1);
}
}; };
info!("Username: {}", username); info!("Configuration loaded: {:?}", config);
let port = config.port.unwrap_or(8080); // Bind a TCP listener to accept incoming connections
info!("Connecting to server at {}:{}", config.ip, port); let listener = TcpListener::bind(config.address + ":" + config.port.as_str())
// Connect to the server
let stream = TcpStream::connect(format!("{}:{}", config.ip, port))
.await .await
.unwrap(); .unwrap();
info!("Server running on port {}", config.port);
let (reader, mut writer) = stream.into_split(); // Create a broadcast channel for sharing messages
let mut reader = BufReader::new(reader); let (tx, _) = broadcast::channel(100);
info!("Generating the Keys");
let client_secret = EphemeralSecret::random_from_rng(OsRng);
let client_public = PublicKey::from(&client_secret);
writer.write_all(client_public.as_bytes()).await.unwrap();
let mut server_public_bytes = [0u8; 32];
reader.read_exact(&mut server_public_bytes).await.unwrap();
let server_public = PublicKey::from(server_public_bytes);
let shared_secret = client_secret.diffie_hellman(&server_public);
info!("Shared Secret: {:?}", shared_secret.as_bytes());
info!("Server public key: {:?}", server_public.as_bytes());
let key = Key::<Aes256Gcm>::from_slice(shared_secret.as_bytes());
let cipher_reader = Aes256Gcm::new(&key);
let cipher_writer = Aes256Gcm::new(&key);
let nonce_reader = Nonce::from_slice(b"unique nonce"); // 96-bits; fixed nonce
let nonce_writer = nonce_reader.clone();
warn!("Nonce: {:?}", nonce_reader);
debug!("Sending Username");
let encrypted = match cipher_writer.encrypt(&nonce_writer, username.as_bytes()) {
Ok(encrypted) => encrypted,
Err(e) => {
error!("Encryption error: {}", e);
return Ok(());
}
};
let encoded = BASE64.encode(&encrypted);
if let Err(e) = writer.write_all((encoded + "\n").as_bytes()).await {
error!("Failed to send username: {}", e);
return Ok(());
}
info!("Starting the chat");
// Setup UI
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Setup channels for communication
let (tx_ui, mut rx_ui) = mpsc::channel::<(String, String)>(100);
let (tx_net, mut rx_net) = mpsc::channel::<String>(100);
// Create shared state
let chat_state = Arc::new(Mutex::new(ChatState::new(username.clone())));
let chat_state_ui = Arc::clone(&chat_state);
// Task for UI handling
let ui_task = tokio::spawn(async move {
let mut chat_state = chat_state_ui;
loop { loop {
let should_quit = { // Accept a new client
let state = chat_state.lock().unwrap(); let (socket, addr) = listener.accept().await.unwrap();
state.should_quit info!("Client connected: {}", addr);
};
if should_quit { let tx = tx.clone();
break; let rx = tx.subscribe();
}
// Check for new messages from network // Handle the client in a new task
if let Ok(msg) = rx_ui.try_recv() { tokio::spawn(async move {
let mut state = chat_state.lock().unwrap(); if let Err(e) = handle_client(socket, tx, rx).await {
state.messages.push(msg); error!("Error handling client: {}", e);
}
// Handle input events
if let Ok(should_break) = ui_loop(&mut terminal, &mut chat_state, &tx_net) {
if should_break {
break;
}
}
sleep(Duration::from_millis(16)).await; // ~60 fps refresh rate
}
if let Err(e) = disable_raw_mode() {
error!("Failed to disable raw mode: {}", e);
}
if let Err(e) = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
) {
error!("Failed to leave alternate screen: {}", e);
}
if let Err(e) = terminal.show_cursor() {
error!("Failed to show cursor: {}", e);
} }
}); });
// Task for sending messages to the server
let send_task = tokio::spawn(async move {
while let Some(input) = rx_net.recv().await {
// Encrypt the input
let encrypted = match cipher_writer.encrypt(&nonce_writer, input.as_bytes()) {
Ok(encrypted) => encrypted,
Err(e) => {
error!("Encryption error: {}", e);
continue;
}
};
let encoded = BASE64.encode(&encrypted);
if let Err(e) = writer.write_all((encoded + "\n").as_bytes()).await {
error!("Failed to send message: {}", e);
break;
} }
} }
});
// Task for receiving messages from the server
let receive_task = tokio::spawn(async move {
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => {
// Server closed connection
info!("\nServer disconnected");
tx_ui
.send(("System".to_string(), "Server disconnected".to_string()))
.await
.ok();
break;
}
Ok(_) => {
let decoded = match BASE64.decode(line.trim()) {
Ok(decoded) => decoded,
Err(e) => {
error!("Base64 decode error: {}", e);
continue;
}
};
let decrypted = match cipher_reader.decrypt(&nonce_reader, &*decoded) {
Ok(decrypted) => decrypted,
Err(e) => {
error!("Decryption error: {}", e);
continue;
}
};
let message = match String::from_utf8(decrypted) {
Ok(msg) => msg,
Err(e) => {
error!("UTF-8 conversion error: {}", e);
continue;
}
};
if message.contains('|') {
// Handle DM format
let parts: Vec<&str> = message.splitn(2, '|').collect();
if parts.len() == 2 {
let sender = parts[0].trim();
// The second part contains both receiver and message
let receiver_and_message = parts[1].trim();
// Split at the first space to separate receiver from message
if let Some(space_pos) = receiver_and_message.find(' ') {
let (receiver, content) = receiver_and_message.split_at(space_pos);
if receiver != username {
// If the receiver is the same as the client, ignore
continue;
}
let content = content.trim_start();
// Style as DM
let dm_label = if sender == &username {
format!("DM to {}: ", receiver)
} else {
format!("DM from {}: ", sender)
};
tx_ui
.send(("DM".to_string(), format!("{}{}", dm_label, content)))
.await
.ok();
}
}
} else if message.contains("dl!") {
// Handle file download request
let parts: Vec<&str> = message.splitn(2, ' ').collect();
if parts.len() == 2 {
let filename = parts[1].trim();
tx_ui
.send((
"System".to_string(),
format!("Download request for file: {}", filename),
))
.await
.ok();
let resp = reqwest::get(filename).await.expect("request failed");
let body = resp.bytes().await.expect("body invalid");
// get the file name from the end of the link
let filename = filename.split('/').last().unwrap_or("file");
// Create the file
let mut out = File::create(filename).expect("failed to create file");
let body_bytes = body.to_vec();
io::copy(&mut &body_bytes[..], &mut out)
.expect("failed to copy content");
tx_ui
.send((
"System".to_string(),
format!("Download completed, {}", filename),
))
.await
.ok();
}
} else if let Some(pos) = message.find(':') {
let (sender, content) = message.split_at(pos);
if sender == username {
// If the sender is the same as the client, ignore
continue;
}
// if the message contains a @username, highlight it
if content.contains(&username) {
// send the message in chat
Notification::new()
.summary("You got tagged in a message")
.body(&format!("{}{}", sender, content))
.show()
.unwrap();
}
// Skip the colon and any space
let content = content.trim_start_matches(|c| c == ':' || c == ' ');
tx_ui
.send((sender.to_string(), content.to_string()))
.await
.ok();
} else {
// If message format is different, treat as system message
tx_ui.send(("System".to_string(), message)).await.ok();
}
}
Err(e) => {
error!("Error reading from server: {}", e);
tx_ui
.send(("System".to_string(), format!("Error: {}", e)))
.await
.ok();
break;
}
}
}
});
// Wait for tasks to complete
tokio::select! {
_ = ui_task => (),
_ = send_task => (),
_ = receive_task => (),
}
info!("Client exiting");
Ok(())
}
// UI rendering function
fn ui_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
chat_state: &mut Arc<Mutex<ChatState>>,
tx_net: &mpsc::Sender<String>,
) -> io::Result<bool> {
terminal.draw(|f| {
let size = f.area();
// Create layout with chat messages on top and input at bottom
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(size);
let state = chat_state.lock().unwrap();
// Create messages list
let messages: Vec<ListItem> = state
.messages
.iter()
.map(|(username, message)| {
let username_style = if username == &state.username {
Style::default().fg(Color::Green)
} else if username == "System" {
Style::default().fg(Color::Yellow)
} else if username == "DM" {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Blue)
};
ListItem::new(Line::from(vec![
Span::styled(format!("{}: ", username), username_style),
Span::raw(message),
]))
})
.collect();
let messages =
List::new(messages).block(Block::default().borders(Borders::ALL).title("Messages"));
// Input box
let input = Paragraph::new(state.input.as_str())
.style(match state.input_mode {
InputMode::Normal => Style::default(),
InputMode::Editing => Style::default().fg(Color::Yellow),
})
.block(Block::default().borders(Borders::ALL).title("Input"));
f.render_widget(messages, chunks[0]);
f.render_widget(input, chunks[1]);
// Set cursor position
match state.input_mode {
InputMode::Normal => {}
InputMode::Editing => {
f.set_cursor_position(Position::new(
chunks[1].x + 1 + state.input.len() as u16,
chunks[1].y + 1,
));
}
}
})?;
// Handle events
if event::poll(Duration::from_millis(10))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
let mut state = chat_state.lock().unwrap();
match state.input_mode {
InputMode::Normal => match key.code {
KeyCode::Char('e') => {
state.input_mode = InputMode::Editing;
}
KeyCode::Char('q') => {
state.should_quit = true;
tx_net.try_send("/quit".to_string()).ok();
return Ok(true);
}
_ => {}
},
InputMode::Editing => match key.code {
KeyCode::Enter => {
let message = state.input.drain(..).collect::<String>();
if !message.is_empty() {
drop(state); // Release mutex before async operation
// Add message to UI
let username_clone = {
let state = chat_state.lock().unwrap();
state.username.clone()
};
let mut state = chat_state.lock().unwrap();
state
.messages
.push((username_clone.clone(), message.clone()));
// Send to network
tx_net.try_send(message).ok();
}
}
KeyCode::Char(c) => {
state.input.push(c);
}
KeyCode::Backspace => {
state.input.pop();
}
KeyCode::Esc => {
state.input_mode = InputMode::Normal;
}
_ => {}
},
}
}
}
}
Ok(false)
}

138
src/tui/mod.rs Normal file
View file

@ -0,0 +1,138 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
Terminal,
};
use std::{
collections::VecDeque,
sync::mpsc,
time::{Duration, Instant},
};
pub struct LogEntry {
pub timestamp: String,
pub level: String,
pub message: String,
}
pub struct App {
logs: VecDeque<LogEntry>,
should_quit: bool,
}
impl App {
pub fn new() -> App {
App {
logs: VecDeque::with_capacity(100),
should_quit: false,
}
}
pub fn add_log(&mut self, entry: LogEntry) {
if self.logs.len() >= 100 {
self.logs.pop_front();
}
self.logs.push_back(entry);
}
}
pub fn run_app(rx: mpsc::Receiver<LogEntry>) -> Result<(), Box<dyn std::error::Error>> {
// Terminal initialization
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
let mut last_tick = Instant::now();
let tick_rate = Duration::from_millis(250);
loop {
terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[
Constraint::Min(0), // Logs
]
.as_ref(),
)
.split(f.area());
// Logs
let logs: Vec<ListItem> = app
.logs
.iter()
.map(|log| {
let color = match log.level.as_str() {
"ERROR" => Color::Red,
"WARN" => Color::Yellow,
"INFO" => Color::Blue,
"DEBUG" => Color::Green,
_ => Color::White,
};
ListItem::new(Line::from(vec![
Span::styled(&log.timestamp, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(
format!("[{}]", log.level),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw(&log.message),
]))
})
.collect();
let logs =
List::new(logs).block(Block::default().borders(Borders::ALL).title("Server Logs"));
f.render_widget(logs, chunks[0]);
})?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if let KeyCode::Char('q') = key.code {
app.should_quit = true;
}
}
}
if last_tick.elapsed() >= tick_rate {
// Check for new log entries
if let Ok(log_entry) = rx.try_recv() {
app.add_log(log_entry);
}
last_tick = Instant::now();
}
if app.should_quit {
break;
}
}
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}