Compare commits
No commits in common. "client" and "main" have entirely different histories.
16 changed files with 2222 additions and 1711 deletions
22
.vscode/settings.json
vendored
22
.vscode/settings.json
vendored
|
@ -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
1903
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
@ -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
78
README.md
Normal 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
|
||||||
|
```
|
|
@ -1,2 +1,2 @@
|
||||||
ip = '127.0.0.1'
|
address = "127.0.0.1"
|
||||||
port = 25565
|
port = "25565"
|
BIN
db.sqlite
Normal file
BIN
db.sqlite
Normal file
Binary file not shown.
9
migrations/001_create_users_table.sql
Normal file
9
migrations/001_create_users_table.sql
Normal 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);
|
2
migrations/002_create_admin_flag.sql
Normal file
2
migrations/002_create_admin_flag.sql
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN is_admin BOOLEAN DEFAULT FALSE;
|
5
migrations/003_create_ban_flag.sql
Normal file
5
migrations/003_create_ban_flag.sql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN is_banned BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN ban_reason VARCHAR(255);
|
7
migrations/004_create_kick_table.sql
Normal file
7
migrations/004_create_kick_table.sql
Normal 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);
|
8
migrations/005_create_files_table.sql
Normal file
8
migrations/005_create_files_table.sql
Normal 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);
|
2
migrations/006_add_admin_verified_to_files.sql
Normal file
2
migrations/006_add_admin_verified_to_files.sql
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE files
|
||||||
|
ADD COLUMN admin_verified BOOLEAN DEFAULT FALSE;
|
781
src/client/mod.rs
Normal file
781
src/client/mod.rs
Normal 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
392
src/db/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
558
src/main.rs
558
src/main.rs
|
@ -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
138
src/tui/mod.rs
Normal 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(())
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue