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]
|
||||
name = "chatclient"
|
||||
name = "chatserver"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
@ -7,16 +7,18 @@ edition = "2021"
|
|||
colog = "1.3.0"
|
||||
log = "0.4.22"
|
||||
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"
|
||||
aes-gcm = "0.10.3"
|
||||
rand = "0.8.5"
|
||||
rand_core = "0.6.4"
|
||||
crypto = "0.5.1"
|
||||
base64 = "0.21"
|
||||
ratatui = { version = "0.29.0", features = ["all-widgets"] }
|
||||
crossterm = "0.27"
|
||||
reqwest = "0.12.15"
|
||||
notify-rust = "4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
toml = "0.8.19"
|
||||
ratatui = "0.29.0"
|
||||
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'
|
||||
port = 25565
|
||||
address = "127.0.0.1"
|
||||
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)
|
||||
}
|
||||
}
|
564
src/main.rs
564
src/main.rs
|
@ -1,510 +1,108 @@
|
|||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use colog;
|
||||
use log::{debug, error, info, warn};
|
||||
use notify_rust::Notification;
|
||||
mod client;
|
||||
mod db;
|
||||
mod tui;
|
||||
|
||||
use client::handlers::handle_client;
|
||||
use db::users::create_db_pool;
|
||||
use log::{error, info, Level, Log, Metadata, Record};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
fs,
|
||||
fs::File,
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
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,
|
||||
};
|
||||
use std::thread;
|
||||
use std::{process::exit, sync::mpsc};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::broadcast;
|
||||
use tui::{run_app, LogEntry};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct Config {
|
||||
ip: String,
|
||||
port: Option<u16>,
|
||||
address: String,
|
||||
port: String,
|
||||
}
|
||||
|
||||
// UI structs and enums
|
||||
enum InputMode {
|
||||
Normal,
|
||||
Editing,
|
||||
struct CustomLogger {
|
||||
tx: mpsc::Sender<LogEntry>,
|
||||
}
|
||||
|
||||
struct ChatState {
|
||||
input: String,
|
||||
messages: Vec<(String, String)>, // (username, message)
|
||||
input_mode: InputMode,
|
||||
username: String,
|
||||
should_quit: bool,
|
||||
}
|
||||
impl Log for CustomLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= Level::Info
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
fn new(username: String) -> Self {
|
||||
ChatState {
|
||||
input: String::new(),
|
||||
messages: Vec::new(),
|
||||
input_mode: InputMode::Editing,
|
||||
username,
|
||||
should_quit: false,
|
||||
fn log(&self, record: &Record) {
|
||||
if self.enabled(record.metadata()) {
|
||||
let now = chrono::Local::now();
|
||||
let log_entry = LogEntry {
|
||||
timestamp: now.format("%H:%M:%S").to_string(),
|
||||
level: record.level().to_string(),
|
||||
message: record.args().to_string(),
|
||||
};
|
||||
|
||||
let _ = self.tx.send(log_entry);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
let original_hook = std::panic::take_hook();
|
||||
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);
|
||||
async fn main() {
|
||||
create_db_pool().await.unwrap();
|
||||
|
||||
// Call the original hook
|
||||
original_hook(panic_info);
|
||||
}));
|
||||
// Create a channel for logging
|
||||
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 =
|
||||
fs::read_to_string("config.toml").expect("Should have been able to read the file");
|
||||
let config: Config =
|
||||
toml::from_str(&contents).expect("Should have been able to parse the file");
|
||||
// Start the TUI in a separate thread
|
||||
let _tui_handle = thread::spawn(move || {
|
||||
if let Err(e) = run_app(rx) {
|
||||
eprintln!("Error running TUI: {:?}", e);
|
||||
}
|
||||
|
||||
info!("Enter your username (or press Enter to use a random one): ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let username = if input.trim().is_empty() {
|
||||
format!("User{}", rand::random::<u32>())
|
||||
} else {
|
||||
input.trim().to_string()
|
||||
// Exit the process when the TUI closes
|
||||
exit(0);
|
||||
});
|
||||
|
||||
// Load the configuration from config file
|
||||
let config = match std::fs::read_to_string("config.toml") {
|
||||
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);
|
||||
info!("Connecting to server at {}:{}", config.ip, port);
|
||||
|
||||
// Connect to the server
|
||||
let stream = TcpStream::connect(format!("{}:{}", config.ip, port))
|
||||
// Bind a TCP listener to accept incoming connections
|
||||
let listener = TcpListener::bind(config.address + ":" + config.port.as_str())
|
||||
.await
|
||||
.unwrap();
|
||||
info!("Server running on port {}", config.port);
|
||||
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
// Create a broadcast channel for sharing messages
|
||||
let (tx, _) = broadcast::channel(100);
|
||||
loop {
|
||||
// Accept a new client
|
||||
let (socket, addr) = listener.accept().await.unwrap();
|
||||
info!("Client connected: {}", addr);
|
||||
|
||||
info!("Generating the Keys");
|
||||
let tx = tx.clone();
|
||||
let rx = tx.subscribe();
|
||||
|
||||
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(());
|
||||
// Handle the client in a new task
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(socket, tx, rx).await {
|
||||
error!("Error handling client: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
let should_quit = {
|
||||
let state = chat_state.lock().unwrap();
|
||||
state.should_quit
|
||||
};
|
||||
|
||||
if should_quit {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for new messages from network
|
||||
if let Ok(msg) = rx_ui.try_recv() {
|
||||
let mut state = chat_state.lock().unwrap();
|
||||
state.messages.push(msg);
|
||||
}
|
||||
|
||||
// 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