From 347c722941c81ed6f18d0fc7037cec355430d93d Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Thu, 21 May 2026 13:16:35 +0100 Subject: [PATCH 1/8] organized gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2a86dd0..c850387 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -/target .DS_Store .env + +/target /test-dbs \ No newline at end of file From a2161b1bbb911a203a3d6b7f4c808d1d3c91def0 Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Thu, 21 May 2026 13:17:23 +0100 Subject: [PATCH 2/8] removed old schemas and added migrations using sqlx --- .../20260521102510_initial_schema.sql | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) rename schemas/chat.sql => migrations/20260521102510_initial_schema.sql (55%) diff --git a/schemas/chat.sql b/migrations/20260521102510_initial_schema.sql similarity index 55% rename from schemas/chat.sql rename to migrations/20260521102510_initial_schema.sql index bcec6ae..1601f2f 100644 --- a/schemas/chat.sql +++ b/migrations/20260521102510_initial_schema.sql @@ -1,21 +1,22 @@ +-- Add migration script here -- Users table CREATE TABLE users ( - id TEXT PRIMARY KEY, -- UUID stored as string, generated in Rust + id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, + public_key TEXT, -- for future E2EE date_joined TEXT NOT NULL DEFAULT (datetime('now')), - public_key TEXT NOT NULL, - deleted_at TEXT + deleted_at TEXT -- soft delete ); --- Chats table +-- Chats (conversations) CREATE TABLE chats ( id TEXT PRIMARY KEY, created_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT ); --- Chat participants (many-to-many) +-- Participants (many-to-many) CREATE TABLE chat_participants ( chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -23,20 +24,20 @@ CREATE TABLE chat_participants ( PRIMARY KEY (chat_id, user_id) ); --- Messages table (with encryption support) +-- Messages with encryption storage CREATE TABLE messages ( - id TEXT PRIMARY KEY, -- UUID generated in Rust + id TEXT PRIMARY KEY, chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, - sender_id TEXT REFERENCES users(id) ON DELETE SET NULL, - encrypted_content BLOB NOT NULL, -- AES-GCM ciphertext (without tag) - nonce BLOB NOT NULL, -- 12 bytes unique per message - tag BLOB NOT NULL, -- 16 bytes authentication tag - created_at TEXT NOT NULL DEFAULT (datetime('now')) + sender_id TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL, + encrypted_content BLOB NOT NULL, + nonce BLOB NOT NULL, + tag BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT ); --- Indexes +-- Indexes for performance CREATE INDEX idx_messages_chat_id ON messages(chat_id); CREATE INDEX idx_messages_sender_id ON messages(sender_id); CREATE INDEX idx_messages_created_at ON messages(created_at); -CREATE INDEX idx_chat_participants_user_id ON chat_participants(user_id); -CREATE INDEX idx_chat_participants_chat_id ON chat_participants(chat_id); \ No newline at end of file +CREATE INDEX idx_chat_participants_user_id ON chat_participants(user_id); \ No newline at end of file From 0272798b5bf573a73f40456dc5728783dc70ec22 Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Thu, 21 May 2026 13:24:18 +0100 Subject: [PATCH 3/8] updated config and formated code properly --- src/config.rs | 13 ++++++++----- src/db/mod.rs | 2 +- src/main.rs | 14 +++++++++----- src/routes/helper.rs | 2 +- src/routes/signup.rs | 2 +- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 0c689d6..15e0916 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,22 +3,25 @@ use std::env; pub struct Config { pub database_url: String, + pub ip_address: String, pub jwt_secret: String, pub port: u16, - pub ip_address: String, } impl Config { pub fn from_env() -> Self { - dotenv().ok(); + dotenv().expect("Failed to load .env variables"); Self { database_url: env::var("DATABASE_URL").expect("DATABASE_URL must be set"), jwt_secret: env::var("JWT_SECRET").expect("JWT_SECRET must be set"), + ip_address: env::var("IP_ADDRESS") + .unwrap_or("127.0.0.1".to_string()) + .parse() + .expect("Ip address not set"), port: env::var("PORT") - .unwrap_or_else(|_| "8080".to_string()) + .unwrap_or("8080".to_string()) .parse() - .expect("PORT must be a number"), - ip_address: env::var("IP_ADDRESS").expect("Ip address not set") + .expect("PORT not set"), } } } diff --git a/src/db/mod.rs b/src/db/mod.rs index aa4d396..f9bd309 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -9,5 +9,5 @@ pub async fn create_pool(database_url: &str) -> Result } pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::MigrateError> { - sqlx::migrate!("./schemas/").run(pool).await + sqlx::migrate!("./migrations").run(pool).await } diff --git a/src/main.rs b/src/main.rs index 1d713b7..9736fa0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,14 +8,18 @@ use std::net::SocketAddr; #[tokio::main] async fn main() -> Result<()> { let config = Config::from_env(); - // Create connection pool - let pool = db::create_pool(&config.database_url).await?; - // Run migrations (creates tables if not present) - db::run_migrations(&pool).await?; + let pool = db::create_pool(&config.database_url) + .await + .context("Failed to create pool")?; + + // Run migrations + db::run_migrations(&pool) + .await + .context("Failed to run migrations")?; let socket = format!("{}:{}", config.ip_address, config.port) .parse() - .expect("Failed to open socket"); + .context("Failed to open socket")?; println!("server running on {:?}", &socket); Server::bind(&socket) diff --git a/src/routes/helper.rs b/src/routes/helper.rs index 4faf522..179de0e 100644 --- a/src/routes/helper.rs +++ b/src/routes/helper.rs @@ -1,8 +1,8 @@ use axum::http::StatusCode; +use chrono::{Duration, Utc}; use jsonwebtoken::{encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; use std::env; -use chrono::{Utc, Duration}; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { diff --git a/src/routes/signup.rs b/src/routes/signup.rs index 940f34c..bf9cfdf 100644 --- a/src/routes/signup.rs +++ b/src/routes/signup.rs @@ -47,7 +47,7 @@ pub async fn signup( // 4. Insert into database let result = sqlx::query( - "INSERT INTO users (id, username, password_hash, date_joined) VALUES (?1, ?2, ?3, ?4)" + "INSERT INTO users (id, username, password_hash, date_joined) VALUES (?1, ?2, ?3, ?4)", ) .bind(&user_id) .bind(&payload.username) From 455cffd01cb44f464f021cd0e1e505513432b452 Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Thu, 21 May 2026 16:12:08 +0100 Subject: [PATCH 4/8] finished signup login and fixed sql table migrations --- Cargo.lock | 77 +++++++++++++++++-- Cargo.toml | 1 + migrations/20260521102510_initial_schema.sql | 1 + ...1141725_changing_password_hash_to_blob.sql | 34 ++++++++ .../20260521151036_drop_nonce_from_users.sql | 16 ++++ src/routes/mod.rs | 18 ++++- src/routes/signup.rs | 22 ++---- 7 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 migrations/20260521141725_changing_password_hash_to_blob.sql create mode 100644 migrations/20260521151036_drop_nonce_from_users.sql diff --git a/Cargo.lock b/Cargo.lock index 4c5f796..09e4874 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -19,7 +19,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures", ] @@ -31,7 +31,7 @@ checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", "aes", - "cipher", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -168,6 +168,19 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.4.2", + "subtle", + "zeroize", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -192,6 +205,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -248,8 +271,18 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -342,13 +375,22 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -379,7 +421,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -422,6 +464,7 @@ dependencies = [ "aes-gcm", "anyhow", "axum", + "bcrypt", "chrono", "dotenvy", "jsonwebtoken", @@ -761,6 +804,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -938,6 +990,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2227,7 +2288,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] diff --git a/Cargo.toml b/Cargo.toml index f0ea326..b284946 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ serde_json = "1.0" uuid = { version = "1.23.1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } +bcrypt = "0.19.1" [lib] name = "ems" diff --git a/migrations/20260521102510_initial_schema.sql b/migrations/20260521102510_initial_schema.sql index 1601f2f..0b2a4a0 100644 --- a/migrations/20260521102510_initial_schema.sql +++ b/migrations/20260521102510_initial_schema.sql @@ -4,6 +4,7 @@ CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, + nonce BLOB NOT NULL, public_key TEXT, -- for future E2EE date_joined TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT -- soft delete diff --git a/migrations/20260521141725_changing_password_hash_to_blob.sql b/migrations/20260521141725_changing_password_hash_to_blob.sql new file mode 100644 index 0000000..c28a2cf --- /dev/null +++ b/migrations/20260521141725_changing_password_hash_to_blob.sql @@ -0,0 +1,34 @@ +-- Add migration script here +-- 1. Create a new table with the desired schema +CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash BLOB NOT NULL, -- changed from TEXT to BLOB + nonce BLOB NOT NULL, + public_key TEXT, + date_joined TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT +); + +-- 2. Copy data from the old table +-- Convert password_hash from TEXT to BLOB (SQLite does this automatically, +-- but we explicitly cast to ensure correct type) +INSERT INTO users_new (id, username, password_hash, nonce, public_key, date_joined, deleted_at) +SELECT + id, + username, + CAST(password_hash AS BLOB), -- convert to BLOB + CAST(nonce AS BLOB), + public_key, + date_joined, + deleted_at +FROM users; + +-- 3. Drop the old table +DROP TABLE users; + +-- 4. Rename the new table +ALTER TABLE users_new RENAME TO users; + +-- 5. Recreate indexes (if any – usually on username) +CREATE UNIQUE INDEX idx_users_username ON users(username); \ No newline at end of file diff --git a/migrations/20260521151036_drop_nonce_from_users.sql b/migrations/20260521151036_drop_nonce_from_users.sql new file mode 100644 index 0000000..fa6523f --- /dev/null +++ b/migrations/20260521151036_drop_nonce_from_users.sql @@ -0,0 +1,16 @@ +-- Add migration script here +-- SQLite doesn't support DROP COLUMN directly, so we recreate the table. +CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + public_key TEXT, + date_joined TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT +); + +INSERT INTO users_new (id, username, password_hash, public_key, date_joined, deleted_at) +SELECT id, username, password_hash, public_key, date_joined, deleted_at FROM users; + +DROP TABLE users; +ALTER TABLE users_new RENAME TO users; \ No newline at end of file diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 71061b6..7e1c4c7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,17 +2,29 @@ mod helper; mod signup; use super::routes::signup::signup; -use axum::{http::StatusCode, routing::get, Router}; +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::post; +use axum::{routing::get, Router}; use sqlx::SqlitePool; // our router pub fn app(pool: SqlitePool) -> Router { Router::new() .route("/healthz", get(health_check)) - .route("/signup", get(signup)) + .route("/is_ready", get(readiness_check)) + .route("/signup", post(signup)) .with_state(pool) } async fn health_check() -> (StatusCode, &'static str) { - (StatusCode::OK, "ok") + (StatusCode::OK, "Ok") +} + +async fn readiness_check(State(pool): State) -> (StatusCode, &'static str) { + // Try to execute a simple query (e.g., SELECT 1) + match sqlx::query("SELECT 1").execute(&pool).await { + Ok(_) => (StatusCode::OK, "ready"), + Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "db unavailable"), + } } diff --git a/src/routes/signup.rs b/src/routes/signup.rs index bf9cfdf..072db58 100644 --- a/src/routes/signup.rs +++ b/src/routes/signup.rs @@ -1,11 +1,9 @@ use super::helper::*; -use aes_gcm::{ - aead::{Aead, AeadCore, KeyInit, OsRng}, - Aes256Gcm, -}; +use anyhow::Result; use axum::extract::State; use axum::http::StatusCode; use axum::Json; +use bcrypt; use chrono::Utc; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; @@ -15,12 +13,11 @@ use uuid::Uuid; pub struct SignupRequest { username: String, password: String, - // optional public_key for future E2EE } #[derive(Serialize)] pub struct AuthResponse { - token: String, // JWT + token: String, user_id: String, } @@ -28,20 +25,13 @@ pub async fn signup( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // 1. Validate input (username not empty, password strength, etc.) + // Validate input if payload.username.is_empty() || payload.password.is_empty() { return Err((StatusCode::BAD_REQUEST, "Missing fields".to_string())); } - let key = Aes256Gcm::generate_key(OsRng); - let cipher = Aes256Gcm::new(&key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - // 2. Hash password - let hashed = cipher - .encrypt(&nonce, payload.password.as_ref()) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hash error".to_string()))?; - - // 3. Generate user ID + let hashed = bcrypt::hash(payload.password, bcrypt::DEFAULT_COST) + .expect("Failed to hash password"); let user_id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); From 7c83341af7c070af9fce230da689e501d61b1116 Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Thu, 21 May 2026 21:14:28 +0100 Subject: [PATCH 5/8] finalized signup and added tracing for logging and finalized login with tracing --- Cargo.lock | 142 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 + src/config.rs | 8 +-- src/lib.rs | 1 + src/main.rs | 4 ++ src/routes/helper.rs | 10 +-- src/routes/login.rs | 65 ++++++++++++++++++++ src/routes/mod.rs | 5 +- src/routes/signup.rs | 21 ++++--- src/tracing.rs | 9 +++ 10 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 src/routes/login.rs create mode 100644 src/tracing.rs diff --git a/Cargo.lock b/Cargo.lock index 09e4874..e750014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -349,6 +358,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -472,6 +490,9 @@ dependencies = [ "serde_json", "sqlx", "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", "uuid", ] @@ -1122,6 +1143,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.7.3" @@ -1178,6 +1208,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1532,6 +1571,23 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rsa" version = "0.9.10" @@ -1709,6 +1765,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2021,6 +2086,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.117" @@ -2082,6 +2153,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -2217,6 +2297,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -2235,6 +2328,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -2327,6 +2463,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index b284946..fa668ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ uuid = { version = "1.23.1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } bcrypt = "0.19.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" # optional, for file output [lib] name = "ems" diff --git a/src/config.rs b/src/config.rs index 15e0916..dd82e6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,12 +14,8 @@ impl Config { Self { database_url: env::var("DATABASE_URL").expect("DATABASE_URL must be set"), jwt_secret: env::var("JWT_SECRET").expect("JWT_SECRET must be set"), - ip_address: env::var("IP_ADDRESS") - .unwrap_or("127.0.0.1".to_string()) - .parse() - .expect("Ip address not set"), - port: env::var("PORT") - .unwrap_or("8080".to_string()) + ip_address: env::var("IP_ADDRESS").expect("Ip address not set"), + port: env::var("PORT").unwrap_or("8080".to_string()) .parse() .expect("PORT not set"), } diff --git a/src/lib.rs b/src/lib.rs index 3809eb3..d2a86e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod config; pub mod db; pub mod routes; +pub mod tracing; diff --git a/src/main.rs b/src/main.rs index 9736fa0..9b9c616 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,14 @@ use axum::Server; use ems::config::Config; use ems::db; use ems::routes::app; +use ems::tracing; use std::net::SocketAddr; #[tokio::main] async fn main() -> Result<()> { + // Tracing initiallization + tracing::init_tracing(); + let config = Config::from_env(); let pool = db::create_pool(&config.database_url) .await diff --git a/src/routes/helper.rs b/src/routes/helper.rs index 179de0e..5d9b7d9 100644 --- a/src/routes/helper.rs +++ b/src/routes/helper.rs @@ -1,8 +1,8 @@ +use crate::config::Config; use axum::http::StatusCode; use chrono::{Duration, Utc}; use jsonwebtoken::{encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; -use std::env; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { @@ -19,12 +19,8 @@ pub fn create_jwt(user_id: &str) -> Result { sub: user_id.to_string(), exp: expiration, }; - let secret = env::var("JWT_SECRET").map_err(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - "JWT secret missing".to_string(), - ) - })?; + let config = Config::from_env(); + let secret = config.jwt_secret; encode( &Header::default(), &claims, diff --git a/src/routes/login.rs b/src/routes/login.rs new file mode 100644 index 0000000..22ee468 --- /dev/null +++ b/src/routes/login.rs @@ -0,0 +1,65 @@ +use super::signup::{AuthResponse}; +use axum::extract::State; +use axum::Json; +use axum::http::StatusCode; +use sqlx::SqlitePool; +use serde::Deserialize; +use tracing::{info, warn, error}; +use bcrypt::verify; +use super::helper::create_jwt; + +#[derive(Deserialize)] +pub struct LoginRequest { + username: String, + password: String, +} + +pub async fn login( + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + info!("Login attempt detected"); + + if payload.username.is_empty() || payload.password.is_empty() { + warn!("Empty payload received for login"); + return Err((StatusCode::BAD_REQUEST, "Missing fields".to_string())); + } + let row = sqlx::query!( + "SELECT id, password_hash FROM users WHERE username = ? AND deleted_at IS NULL", + payload.username + ) + .fetch_optional(&pool) + .await + .map_err(|e| { + error!("DB error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "DB error".to_string()) + })?; + + let (id, stored_hash) = match row { + Some(r) => (r.id, r.password_hash), + None => { + warn!("Failed to find credentials for login attempt of user"); + return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".to_string())) + }, + }; + + let user_id = id.unwrap_or_default(); + info!("Record found for login of User: {}", &user_id); + + // 3. Verify password + let is_valid = verify(&payload.password, &stored_hash) + .map_err(|_| { + warn!("Failed to verify password for login attempt of user: {}", &user_id); + (StatusCode::INTERNAL_SERVER_ERROR, "Hash error".to_string()) + })?; + + if !is_valid { + warn!("Invalid credentials provided for login attempt of user: {}", &user_id); + return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".to_string())); + } + + // 4. Generate and return JWT + let token = create_jwt(&user_id)?; + info!("Authentication response returned to user: {}", &user_id); + Ok(Json(AuthResponse { token, user_id })) +} \ No newline at end of file diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 7e1c4c7..150ae8a 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,8 @@ mod helper; mod signup; +mod login; +use super::routes::login::login; use super::routes::signup::signup; use axum::extract::State; use axum::http::StatusCode; @@ -13,7 +15,8 @@ pub fn app(pool: SqlitePool) -> Router { Router::new() .route("/healthz", get(health_check)) .route("/is_ready", get(readiness_check)) - .route("/signup", post(signup)) + .route("/auth/signup", post(signup)) + .route("/auth/login", post(login)) .with_state(pool) } diff --git a/src/routes/signup.rs b/src/routes/signup.rs index 072db58..abc9998 100644 --- a/src/routes/signup.rs +++ b/src/routes/signup.rs @@ -3,10 +3,11 @@ use anyhow::Result; use axum::extract::State; use axum::http::StatusCode; use axum::Json; -use bcrypt; +use bcrypt::{hash, DEFAULT_COST}; use chrono::Utc; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; +use tracing::{error, info, warn}; use uuid::Uuid; #[derive(Deserialize)] @@ -17,25 +18,26 @@ pub struct SignupRequest { #[derive(Serialize)] pub struct AuthResponse { - token: String, - user_id: String, + pub token: String, + pub user_id: String, } pub async fn signup( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // Validate input + info!("Signup attempt detected"); + if payload.username.is_empty() || payload.password.is_empty() { + warn!("Empty payload received for signup"); return Err((StatusCode::BAD_REQUEST, "Missing fields".to_string())); } - let hashed = bcrypt::hash(payload.password, bcrypt::DEFAULT_COST) + let hashed = hash(payload.password, DEFAULT_COST) .expect("Failed to hash password"); let user_id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); - // 4. Insert into database let result = sqlx::query( "INSERT INTO users (id, username, password_hash, date_joined) VALUES (?1, ?2, ?3, ?4)", ) @@ -48,13 +50,18 @@ pub async fn signup( match result { Ok(_) => { + info!("Created user successfully. ID: {}", user_id); // 5. Generate JWT let token = create_jwt(&user_id)?; // implement this helper Ok(Json(AuthResponse { token, user_id })) } Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => { + error!("Creating user failed. Username already exist."); Err((StatusCode::CONFLICT, "Username already taken".to_string())) } - Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "DB error".to_string())), + Err(_) => { + error!("Creating user failed. Failed to load database."); + Err((StatusCode::INTERNAL_SERVER_ERROR, "DB error".to_string())) + } } } diff --git a/src/tracing.rs b/src/tracing.rs new file mode 100644 index 0000000..f4fcf41 --- /dev/null +++ b/src/tracing.rs @@ -0,0 +1,9 @@ +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +pub fn init_tracing() { + // Set up console subscriber with environment filter (RUST_LOG) + tracing_subscriber::registry() + .with(fmt::layer()) // human‑readable output + .with(EnvFilter::from_default_env()) // read from RUST_LOG env var + .init(); +} From 74738106a03d6ba1840403febcdb9f2df0661b53 Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Thu, 21 May 2026 22:27:39 +0100 Subject: [PATCH 6/8] using best practices for tracing and polishing login and signup --- .github/workflows/rust.yml | 4 +- .gitignore | 1 + src/config.rs | 3 +- src/main.rs | 7 ++-- src/routes/login.rs | 78 ++++++++++++++++++++++---------------- src/routes/mod.rs | 2 +- src/routes/signup.rs | 16 ++++---- src/tracing.rs | 19 ++++++++-- 8 files changed, 80 insertions(+), 50 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9ad738e..90e5a3b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - name: Formatting run: cargo fmt + - name: sqlx serve + run: cargo sqlx prepare - name: Checking run: cargo check - - name: Run tests - run: cargo test --verbose diff --git a/.gitignore b/.gitignore index c850387..b98b3af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .env +/logs /target /test-dbs \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index dd82e6c..0512806 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,7 +15,8 @@ impl Config { database_url: env::var("DATABASE_URL").expect("DATABASE_URL must be set"), jwt_secret: env::var("JWT_SECRET").expect("JWT_SECRET must be set"), ip_address: env::var("IP_ADDRESS").expect("Ip address not set"), - port: env::var("PORT").unwrap_or("8080".to_string()) + port: env::var("PORT") + .unwrap_or("8080".to_string()) .parse() .expect("PORT not set"), } diff --git a/src/main.rs b/src/main.rs index 9b9c616..d4ddaff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,13 +3,14 @@ use axum::Server; use ems::config::Config; use ems::db; use ems::routes::app; -use ems::tracing; +use ems::tracing::init_tracing; use std::net::SocketAddr; +use tracing::info; #[tokio::main] async fn main() -> Result<()> { // Tracing initiallization - tracing::init_tracing(); + let _guard = init_tracing(); let config = Config::from_env(); let pool = db::create_pool(&config.database_url) @@ -25,7 +26,7 @@ async fn main() -> Result<()> { .parse() .context("Failed to open socket")?; - println!("server running on {:?}", &socket); + info!("server running on {:?}", &socket); Server::bind(&socket) .serve(app(pool).into_make_service_with_connect_info::()) .await diff --git a/src/routes/login.rs b/src/routes/login.rs index 22ee468..de8af86 100644 --- a/src/routes/login.rs +++ b/src/routes/login.rs @@ -1,12 +1,12 @@ -use super::signup::{AuthResponse}; +use super::helper::create_jwt; +use super::signup::AuthResponse; use axum::extract::State; -use axum::Json; use axum::http::StatusCode; -use sqlx::SqlitePool; -use serde::Deserialize; -use tracing::{info, warn, error}; +use axum::Json; use bcrypt::verify; -use super::helper::create_jwt; +use serde::Deserialize; +use sqlx::SqlitePool; +use tracing::{debug, error, warn}; #[derive(Deserialize)] pub struct LoginRequest { @@ -18,48 +18,62 @@ pub async fn login( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - info!("Login attempt detected"); + debug!("Login attempt detected"); if payload.username.is_empty() || payload.password.is_empty() { - warn!("Empty payload received for login"); + debug!("Empty payload received for login"); return Err((StatusCode::BAD_REQUEST, "Missing fields".to_string())); } let row = sqlx::query!( - "SELECT id, password_hash FROM users WHERE username = ? AND deleted_at IS NULL", - payload.username - ) - .fetch_optional(&pool) - .await - .map_err(|e| { - error!("DB error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, "DB error".to_string()) - })?; - + "SELECT id, password_hash FROM users WHERE username = ? AND deleted_at IS NULL", + payload.username + ) + .fetch_optional(&pool) + .await + .map_err(|e| { + error!( + error = ?e, + "DB failed to initialize" + ); + (StatusCode::INTERNAL_SERVER_ERROR, "DB error".to_string()) + })?; + let (id, stored_hash) = match row { Some(r) => (r.id, r.password_hash), None => { - warn!("Failed to find credentials for login attempt of user"); - return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".to_string())) - }, + warn!( + username = payload.username, + "Failed to find record for login attempt of user" + ); + return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".to_string())); + } }; - + let user_id = id.unwrap_or_default(); - info!("Record found for login of User: {}", &user_id); - + debug!(user_id = user_id, "Record found for login of User"); + // 3. Verify password - let is_valid = verify(&payload.password, &stored_hash) - .map_err(|_| { - warn!("Failed to verify password for login attempt of user: {}", &user_id); - (StatusCode::INTERNAL_SERVER_ERROR, "Hash error".to_string()) - })?; + let is_valid = verify(&payload.password, &stored_hash).map_err(|_| { + warn!( + user_id = user_id, + "Failed to verify password for login attempt of user" + ); + (StatusCode::INTERNAL_SERVER_ERROR, "Hash error".to_string()) + })?; if !is_valid { - warn!("Invalid credentials provided for login attempt of user: {}", &user_id); + warn!( + user_id = user_id, + "Invalid credentials provided for login attempt of user" + ); return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".to_string())); } // 4. Generate and return JWT let token = create_jwt(&user_id)?; - info!("Authentication response returned to user: {}", &user_id); + debug!( + user_id = user_id, + "Authentication response returned to user" + ); Ok(Json(AuthResponse { token, user_id })) -} \ No newline at end of file +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 150ae8a..4c3f856 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,6 @@ mod helper; -mod signup; mod login; +mod signup; use super::routes::login::login; use super::routes::signup::signup; diff --git a/src/routes/signup.rs b/src/routes/signup.rs index abc9998..14e6e75 100644 --- a/src/routes/signup.rs +++ b/src/routes/signup.rs @@ -7,7 +7,7 @@ use bcrypt::{hash, DEFAULT_COST}; use chrono::Utc; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; -use tracing::{error, info, warn}; +use tracing::{debug, error}; use uuid::Uuid; #[derive(Deserialize)] @@ -26,15 +26,14 @@ pub async fn signup( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - info!("Signup attempt detected"); + debug!("Signup attempt detected"); if payload.username.is_empty() || payload.password.is_empty() { - warn!("Empty payload received for signup"); + debug!("Empty payload received for signup"); return Err((StatusCode::BAD_REQUEST, "Missing fields".to_string())); } - let hashed = hash(payload.password, DEFAULT_COST) - .expect("Failed to hash password"); + let hashed = hash(payload.password, DEFAULT_COST).expect("Failed to hash password"); let user_id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); @@ -50,13 +49,16 @@ pub async fn signup( match result { Ok(_) => { - info!("Created user successfully. ID: {}", user_id); + debug!(user_id = user_id, "Created user successfully"); // 5. Generate JWT let token = create_jwt(&user_id)?; // implement this helper Ok(Json(AuthResponse { token, user_id })) } Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => { - error!("Creating user failed. Username already exist."); + debug!( + username = payload.username, + "Creating user failed. Username already exist" + ); Err((StatusCode::CONFLICT, "Username already taken".to_string())) } Err(_) => { diff --git a/src/tracing.rs b/src/tracing.rs index f4fcf41..a05bc0a 100644 --- a/src/tracing.rs +++ b/src/tracing.rs @@ -1,9 +1,20 @@ +use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; -pub fn init_tracing() { - // Set up console subscriber with environment filter (RUST_LOG) +pub fn init_tracing() -> WorkerGuard { + // Create a rolling file appender – rotates daily + let file_appender = tracing_appender::rolling::daily("logs", "ems.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + // Set up subscriber with both console and file writers + let console_layer = fmt::layer().with_writer(std::io::stdout); + let file_layer = fmt::layer().with_writer(non_blocking).with_ansi(false); // no ANSI colours in file + tracing_subscriber::registry() - .with(fmt::layer()) // human‑readable output - .with(EnvFilter::from_default_env()) // read from RUST_LOG env var + .with(EnvFilter::from_default_env()) + .with(console_layer) + .with(file_layer) .init(); + + guard // must be stored to keep the writer alive } From 97f2e9cfc82b8b85a7e71780bd65e6dac8600088 Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Thu, 21 May 2026 22:30:36 +0100 Subject: [PATCH 7/8] added downloading sqlx-cli --- .github/workflows/rust.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 90e5a3b..905d8c5 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -18,6 +18,8 @@ jobs: - uses: actions/checkout@v4 - name: Formatting run: cargo fmt + - name: adding sqlx + run: cargo add sqlx-cli - name: sqlx serve run: cargo sqlx prepare - name: Checking From 4c1d15fb6af63348982b7e1fe3a1a98341d699e3 Mon Sep 17 00:00:00 2001 From: Emmanuel Date: Thu, 21 May 2026 22:33:00 +0100 Subject: [PATCH 8/8] simplified workflow by removing sqlx-serve --- .github/workflows/rust.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 905d8c5..37def77 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -18,9 +18,5 @@ jobs: - uses: actions/checkout@v4 - name: Formatting run: cargo fmt - - name: adding sqlx - run: cargo add sqlx-cli - - name: sqlx serve - run: cargo sqlx prepare - name: Checking run: cargo check