diff --git a/Cargo.lock b/Cargo.lock index b33f874..348fe8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2012,6 +2012,28 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2258,6 +2280,7 @@ dependencies = [ "tracing-subscriber", "tracing-tree", "uuid", + "validator", "zxcvbn", ] @@ -3471,6 +3494,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling 0.20.11", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 26613d5..35490d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ tower = "0.5.3" futures-util = "0.3.32" dashmap = "6.1.0" zxcvbn = "3.1.1" +validator = { version = "0.20.0", features = ["derive"] } [dev-dependencies] testcontainers = "0.23.1" diff --git a/src/config.rs b/src/config.rs index b58968b..045d843 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,7 +2,7 @@ use std::env; use dotenvy::dotenv; -use crate::errors::AppError; +use crate::errors::StartupError; #[derive(Debug, Clone, Copy, PartialEq)] pub enum AppEnv { @@ -11,15 +11,15 @@ pub enum AppEnv { } impl AppEnv { - pub fn from_env() -> Result { + pub fn from_env() -> Result { match env::var("APP_ENV").as_deref() { Ok("prod") => Ok(AppEnv::Production), Ok("dev") => Ok(AppEnv::Development), - Ok(other) => Err(AppError::InvalidConfig(format!( + Ok(other) => Err(StartupError::InvalidConfig(format!( "Invalid APP_ENV: {}", other ))), - Err(_) => Err(AppError::InvalidConfig("APP_ENV must be set".to_string())), + Err(_) => Err(StartupError::InvalidConfig("APP_ENV must be set".to_string())), } } } @@ -33,7 +33,7 @@ pub struct Config { } impl Config { - pub fn load() -> Result { + pub fn load() -> Result { dotenv().ok(); Ok(Self { db_url: env::var("DATABASE_URL")?, diff --git a/src/controller/extractor.rs b/src/controller/extractor.rs new file mode 100644 index 0000000..249fbab --- /dev/null +++ b/src/controller/extractor.rs @@ -0,0 +1,52 @@ +use axum::{ + Json, + extract::{FromRequest, FromRequestParts, Request}, + http::request::Parts, +}; +use serde::de::DeserializeOwned; +use uuid::Uuid; +use validator::Validate; + +use crate::errors::ApiError; + +#[derive(Debug, Clone)] +pub struct ValidJson(pub T); + +impl FromRequest for ValidJson +where + T: DeserializeOwned + Validate, + S: Send + Sync, + Json: FromRequest, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(value) = Json::::from_request(req, state) + .await + .map_err(|e| ApiError::Validation(e.to_string()))?; + + value + .validate() + .map_err(|e| ApiError::Validation(e.to_string()))?; + + Ok(ValidJson(value)) + } +} + +pub struct CurrentUser(pub Uuid); + +impl FromRequestParts for CurrentUser +where + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let user_id = parts + .extensions + .get::() + .ok_or(ApiError::Unauthorized)?; + + Ok(CurrentUser(*user_id)) + } +} diff --git a/src/controller/middleware/auth_middleware.rs b/src/controller/middleware/auth_middleware.rs index f71d520..178fad6 100644 --- a/src/controller/middleware/auth_middleware.rs +++ b/src/controller/middleware/auth_middleware.rs @@ -1,4 +1,4 @@ -use crate::{errors::AppError, state::AppState, utils::jwt::verify_access_token}; +use crate::{errors::ApiError, state::AppState, utils::jwt::verify_access_token}; use axum::{ extract::{Request, State}, middleware::Next, @@ -8,14 +8,14 @@ pub async fn auth_middleware( State(state): State, mut request: Request, next: Next, -) -> Result { +) -> Result { let auth_header = request .headers() .get(axum::http::header::AUTHORIZATION) .and_then(|h| h.to_str().ok()) - .ok_or(AppError::Unauthorized)?; + .ok_or(ApiError::Unauthorized)?; if !auth_header.starts_with("Bearer ") { - return Err(AppError::Unauthorized); + return Err(ApiError::Unauthorized); } let token = &auth_header[7..]; let claims = verify_access_token(token, &state.jwt_secret)?; diff --git a/src/controller/mod.rs b/src/controller/mod.rs index e76ac26..0256228 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -3,6 +3,7 @@ use tower_http::trace::TraceLayer; use crate::state::AppState; +pub mod extractor; mod middleware; pub mod model; mod v1; diff --git a/src/controller/model/auth_model.rs b/src/controller/model/auth_model.rs index ad92d69..93a99ac 100644 --- a/src/controller/model/auth_model.rs +++ b/src/controller/model/auth_model.rs @@ -1,13 +1,19 @@ use serde::{Deserialize, Serialize}; +use validator::Validate; -#[derive(Deserialize)] +#[derive(Deserialize, Validate)] pub struct LoginRequest { + #[validate(email)] pub email: String, + #[validate(length(min = 1))] pub password: String, } -#[derive(Deserialize)] + +#[derive(Deserialize, Validate)] pub struct RegisterRequest { + #[validate(email)] pub email: String, + #[validate(length(min = 8))] pub password: String, } diff --git a/src/controller/v1/auth_controller.rs b/src/controller/v1/auth_controller.rs index 6fe1bb1..3fb0142 100644 --- a/src/controller/v1/auth_controller.rs +++ b/src/controller/v1/auth_controller.rs @@ -4,10 +4,11 @@ use axum::{Json, Router, routing::post}; use tower_cookies::{CookieManagerLayer, Cookies}; use crate::{ + controller::extractor::ValidJson, controller::middleware::anti_enumeration_middleware::random_delay_middleware, controller::middleware::rate_limiting_middleware::rate_limiting_middleware, controller::model::auth_model::{AuthResponse, LoginRequest, RegisterRequest}, - errors::AppError, + errors::ApiError, service::auth_service::{login, refresh, register}, state::AppState, }; @@ -25,21 +26,22 @@ pub fn auth_router(state: AppState) -> Router { async fn login_handler( State(s): State, cookies: Cookies, - Json(payload): Json, -) -> Result, AppError> { + ValidJson(payload): ValidJson, +) -> Result, ApiError> { login(&s, cookies, payload).await } + async fn register_handler( State(s): State, cookies: Cookies, - Json(payload): Json, -) -> Result, AppError> { + ValidJson(payload): ValidJson, +) -> Result, ApiError> { register(&s, cookies, payload).await } async fn refresh_handler( State(s): State, cookies: Cookies, -) -> Result, AppError> { +) -> Result, ApiError> { refresh(&s, cookies).await } diff --git a/src/controller/v1/protected/auth_protected_controller.rs b/src/controller/v1/protected/auth_protected_controller.rs index ca34630..2286a46 100644 --- a/src/controller/v1/protected/auth_protected_controller.rs +++ b/src/controller/v1/protected/auth_protected_controller.rs @@ -1,7 +1,9 @@ -use axum::{Extension, Router, extract::State, routing::post}; +use axum::{Router, extract::State, routing::post}; use tower_cookies::{CookieManagerLayer, Cookies}; -use crate::{errors::AppError, service::auth_service, state::AppState}; +use crate::{ + controller::extractor::CurrentUser, errors::ApiError, service::auth_service, state::AppState, +}; pub fn router() -> Router { Router::new() @@ -10,18 +12,14 @@ pub fn router() -> Router { .layer(CookieManagerLayer::new()) } -async fn logout_handler( - State(s): State, - cookies: Cookies, - Extension(user_id): Extension, -) -> Result<(), AppError> { - auth_service::logout(&s, cookies, user_id).await +async fn logout_handler(State(s): State, cookies: Cookies) -> Result<(), ApiError> { + auth_service::logout(&s, cookies).await } async fn logout_all_handler( State(s): State, cookies: Cookies, - Extension(user_id): Extension, -) -> Result<(), AppError> { + CurrentUser(user_id): CurrentUser, +) -> Result<(), ApiError> { auth_service::logout_all(&s, cookies, user_id).await } diff --git a/src/database.rs b/src/database.rs index 5c7e16e..ae1dd8a 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,17 +1,15 @@ -use sqlx::{Pool, Postgres, migrate::MigrateError, postgres::PgPoolOptions}; +use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; -use crate::errors::AppError; +use crate::errors::StartupError; -pub async fn init(db_url: &str) -> Result, AppError> { +pub async fn init(db_url: &str) -> Result, StartupError> { let db = PgPoolOptions::new() .connect(db_url) - .await - .map_err(AppError::DbConnect)?; + .await?; sqlx::migrate!("./migrations") .run(&db) - .await - .map_err(|e: MigrateError| AppError::InvalidConfig(format!("Migration failed: {}", e)))?; + .await?; tracing::info!("Migration completed successfully"); diff --git a/src/db/repository/organization_repository.rs b/src/db/repository/organization_repository.rs index d178880..5b19394 100644 --- a/src/db/repository/organization_repository.rs +++ b/src/db/repository/organization_repository.rs @@ -1,13 +1,13 @@ use sqlx::{Executor, Postgres}; use uuid::Uuid; -use crate::{db::model::organization::Organization, errors::AppError}; +use crate::{db::model::organization::Organization, errors::ApiError}; pub async fn create_organization<'e, E>( executor: E, name: String, slug: String, -) -> Result +) -> Result where E: Executor<'e, Database = Postgres>, { @@ -19,7 +19,7 @@ where ) .fetch_one(executor) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; Ok(org) } @@ -27,7 +27,7 @@ where pub async fn get_organizations_by_id_list<'e, E>( executor: E, ids: &[Uuid], -) -> Result, AppError> +) -> Result, ApiError> where E: Executor<'e, Database = Postgres>, { @@ -38,7 +38,7 @@ where ) .fetch_all(executor) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; Ok(org) } diff --git a/src/db/repository/refresh_token_repository.rs b/src/db/repository/refresh_token_repository.rs index a1dcf83..b88d294 100644 --- a/src/db/repository/refresh_token_repository.rs +++ b/src/db/repository/refresh_token_repository.rs @@ -6,14 +6,14 @@ use sqlx::{ }, }; -use crate::{db::model::refresh_token::RefreshToken, errors::AppError}; +use crate::{db::model::refresh_token::RefreshToken, errors::ApiError}; pub async fn create_refresh_token<'e, E>( executor: E, user_id: Uuid, token_hash: String, expires_at: DateTime, -) -> Result +) -> Result where E: Executor<'e, Database = Postgres>, { @@ -25,13 +25,13 @@ where expires_at ).fetch_one(executor) .await - .map_err(AppError::from) + .map_err(ApiError::from) } pub async fn find_by_hash<'e, E>( executor: E, token_hash: &str, -) -> Result, AppError> +) -> Result, ApiError> where E: Executor<'e, Database = Postgres>, { @@ -42,9 +42,9 @@ where ) .fetch_optional(executor) .await - .map_err(AppError::from) + .map_err(ApiError::from) } -pub async fn revoke<'e, E>(executor: E, id: Uuid) -> Result<(), AppError> +pub async fn revoke<'e, E>(executor: E, id: Uuid) -> Result<(), ApiError> where E: Executor<'e, Database = Postgres>, { @@ -54,14 +54,14 @@ where ) .execute(executor) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; Ok(()) } pub async fn revoke_all_for_user<'e, E>( executor: E, user_id: Uuid, -) -> Result<(), AppError> +) -> Result<(), ApiError> where E: Executor<'e, Database = Postgres>, { @@ -71,6 +71,6 @@ where ) .execute(executor) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; Ok(()) } diff --git a/src/db/repository/user_repository.rs b/src/db/repository/user_repository.rs index 9a55ab5..aaf9829 100644 --- a/src/db/repository/user_repository.rs +++ b/src/db/repository/user_repository.rs @@ -1,12 +1,12 @@ use sqlx::{Executor, Postgres, types::Uuid}; -use crate::{db::model::user::User, errors::AppError}; +use crate::{db::model::user::User, errors::ApiError}; pub async fn create_user<'e, E>( executor: E, email: String, password: String, -) -> Result +) -> Result where E: Executor<'e, Database = Postgres>, { @@ -18,36 +18,31 @@ where ) .fetch_one(executor) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; Ok(user) } -/* - *And these two call patterns both work: -- Pool: user_repo::create_user(&state.db, ...) → E = &'static PgPool -- Transaction: user_repo::create_user(&mut *tx, ...) → E = &mut PgConnection - */ -pub async fn get_user_by_email<'e, E>(executor: E, email: &str) -> Result, AppError> +pub async fn get_user_by_email<'e, E>(executor: E, email: &str) -> Result, ApiError> where E: Executor<'e, Database = Postgres>, { let user = sqlx::query_as!(User, "select * from users where email=$1", email) .fetch_optional(executor) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; Ok(user) } -pub async fn get_user_by_id<'e, E>(executor: E, id: Uuid) -> Result, AppError> +pub async fn get_user_by_id<'e, E>(executor: E, id: Uuid) -> Result, ApiError> where E: Executor<'e, Database = Postgres>, { let user = sqlx::query_as!(User, "select * from users where id=$1", id) .fetch_optional(executor) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; Ok(user) } diff --git a/src/errors.rs b/src/errors.rs index b99589e..2ce9ce9 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,53 +6,65 @@ use axum::{ use thiserror::Error; #[derive(Debug, Error)] -pub enum AppError { +pub enum StartupError { #[error("Failed to load configuration: {0}")] Config(#[from] std::env::VarError), #[error("Invalid configuration value: {0}")] InvalidConfig(String), - #[error("Failed to connect to database")] + #[error("Failed to connect to database: {0}")] DbConnect(#[from] sqlx::Error), - #[error("Failed to bind to address")] + #[error("Failed to bind to address: {0}")] Bind(#[from] std::io::Error), - #[error("Invalid credentials")] - InvalidCredentials, - #[error("Validation error: {0}")] - Validation(String), - #[error("Internal server error")] - Internal, - #[error("Request not authorized")] - Unauthorized, + #[error("Migration error: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), } #[derive(Debug, Error)] -#[error("Application error: {0}")] -pub struct MainError(pub AppError); +pub enum ApiError { + #[error("Failed to connect to database: {0}")] + Database(#[from] sqlx::Error), -impl From for MainError { - fn from(err: AppError) -> Self { - Self(err) - } + #[error("Invalid credentials")] + InvalidCredentials, + + #[error("Validation error: {0}")] + Validation(String), + + #[error("Internal server error")] + Internal, + + #[error("Request not authorized")] + Unauthorized, + + #[error("Not Found")] + NotFound, } -impl IntoResponse for AppError { +impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, message) = match &self { - AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, self.to_string()), - AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()), - AppError::DbConnect(_) | AppError::Bind(_) | AppError::Internal => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error".to_string(), - ), - AppError::Config(_) | AppError::InvalidConfig(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error".to_string(), - ), - AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), + ApiError::InvalidCredentials => (StatusCode::UNAUTHORIZED, self.to_string()), + ApiError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()), + ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), + ApiError::NotFound => (StatusCode::NOT_FOUND, self.to_string()), + ApiError::Database(err) => { + tracing::error!("Database error: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + } + ApiError::Internal => { + tracing::error!("Internal server error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + } }; (status, Json(serde_json::json!({ "error": message }))).into_response() } diff --git a/src/main.rs b/src/main.rs index 4f66363..50c9139 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ -use rhythm_backend::{config, database, errors::MainError, logging, server}; +use rhythm_backend::{config, database, errors::StartupError, logging, server}; #[tokio::main] -async fn main() -> Result<(), MainError> { +async fn main() -> Result<(), StartupError> { let cfg = config::Config::load()?; let _logging_guard = logging::LoggerConfig::init(cfg.app_env); let db = database::init(&cfg.db_url).await?; diff --git a/src/server.rs b/src/server.rs index 085f243..1c99985 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,28 +1,27 @@ use axum::Router; use sqlx::PgPool; -use crate::{config, controller, errors::AppError, logging, state::AppState}; +use crate::{config, controller, errors::StartupError, logging, state::AppState}; -pub async fn init(cfg: &config::Config, db: PgPool) -> Result<(), AppError> { +pub async fn init(cfg: &config::Config, db: PgPool) -> Result<(), StartupError> { let state = AppState { db, jwt_secret: cfg.jwt_secret.clone(), rate_limit: std::sync::Arc::new(dashmap::DashMap::new()), }; - let app = Router::new().merge(controller::router(state.clone())).with_state(state); + let app = Router::new() + .merge(controller::router(state.clone())) + .with_state(state); - let listener = tokio::net::TcpListener::bind(&cfg.socket_address) - .await - .map_err(AppError::Bind)?; + let listener = tokio::net::TcpListener::bind(&cfg.socket_address).await?; tracing::info!("Server started on {}", cfg.socket_address); axum::serve( listener, app.into_make_service_with_connect_info::(), ) - .with_graceful_shutdown(logging::shutdown_signal()) - .await - .map_err(AppError::Bind)?; + .with_graceful_shutdown(logging::shutdown_signal()) + .await?; Ok(()) } diff --git a/src/service/auth_service.rs b/src/service/auth_service.rs index 03cf1c0..43d6d7a 100644 --- a/src/service/auth_service.rs +++ b/src/service/auth_service.rs @@ -6,7 +6,7 @@ use tower_cookies::{Cookie, Cookies}; use crate::controller::model::auth_model::*; use crate::db::repository::refresh_token_repository::{create_refresh_token, find_by_hash, revoke}; use crate::db::repository::user_repository; -use crate::errors::AppError; +use crate::errors::ApiError; use crate::state::AppState; use crate::utils::hash; use crate::utils::jwt::generate_access_token; @@ -16,19 +16,19 @@ pub async fn login( state: &AppState, cookies: Cookies, req: LoginRequest, -) -> Result, AppError> { +) -> Result, ApiError> { let mut tx = state.db.begin().await?; let user = user_repository::get_user_by_email(&mut *tx, &req.email) .await? .ok_or_else(|| { tracing::warn!(email = %req.email, "Login failed: user not found"); - AppError::InvalidCredentials + ApiError::InvalidCredentials })?; if !hash::verify(&req.password, &user.password)? { tracing::warn!(email = %req.email, "Login failed: invalid password"); - return Err(AppError::InvalidCredentials); + return Err(ApiError::InvalidCredentials); } let access_token = generate_access_token(user.id, &state.jwt_secret)?; @@ -47,12 +47,12 @@ pub async fn register( state: &AppState, cookies: Cookies, req: RegisterRequest, -) -> Result, AppError> { +) -> Result, ApiError> { let estimate = zxcvbn::zxcvbn(&req.password, &[]); if (estimate.score() as u8) < 3 { tracing::warn!(email = %req.email, score = ?estimate.score(), "Registration failed: password too weak"); - return Err(AppError::Validation( + return Err(ApiError::Validation( "Password is too weak. Please use a more complex password.".to_string(), )); } @@ -64,7 +64,7 @@ pub async fn register( .is_some() { tracing::warn!(email = %req.email, "Registration failed: email already exists"); - return Err(AppError::Validation("bad request".to_string())); + return Err(ApiError::Validation("bad request".to_string())); } let h = hash::hash(&req.password)?; @@ -82,18 +82,18 @@ pub async fn register( Ok(Json(AuthResponse { access_token })) } -pub async fn refresh(state: &AppState, cookies: Cookies) -> Result, AppError> { - let refresh_token = get_refresh_cookie(&cookies).ok_or(AppError::InvalidCredentials)?; +pub async fn refresh(state: &AppState, cookies: Cookies) -> Result, ApiError> { + let refresh_token = get_refresh_cookie(&cookies).ok_or(ApiError::InvalidCredentials)?; let mut tx = state.db.begin().await?; let hash = hash_refresh_token(&refresh_token); let token_data = find_by_hash(&mut *tx, &hash) .await? - .ok_or(AppError::InvalidCredentials)?; + .ok_or(ApiError::InvalidCredentials)?; if token_data.revoked_at.is_some() || token_data.expires_at < chrono::Utc::now() { - return Err(AppError::InvalidCredentials); + return Err(ApiError::InvalidCredentials); } revoke(&mut *tx, token_data.id).await?; @@ -113,7 +113,7 @@ pub async fn refresh(state: &AppState, cookies: Cookies) -> Result Result<(), AppError> { +pub async fn logout(state: &AppState, cookies: Cookies) -> Result<(), ApiError> { let refresh_token = match get_refresh_cookie(&cookies) { Some(t) => t, None => return Ok(()), // Already logged out @@ -134,7 +134,11 @@ pub async fn logout(state: &AppState, cookies: Cookies, _user_id: uuid::Uuid) -> Ok(()) } -pub async fn logout_all(state: &AppState, cookies: Cookies, user_id: uuid::Uuid) -> Result<(), AppError> { +pub async fn logout_all( + state: &AppState, + cookies: Cookies, + user_id: uuid::Uuid, +) -> Result<(), ApiError> { let mut tx = state.db.begin().await?; revoke_all_for_user(&mut *tx, user_id).await?; diff --git a/src/state.rs b/src/state.rs index 8948aa6..da7249d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,4 @@ +use axum::extract::FromRef; use dashmap::DashMap; use sqlx::PgPool; use std::sync::Arc; @@ -10,16 +11,19 @@ pub struct AppState { pub rate_limit: Arc>, } +impl FromRef for PgPool { + fn from_ref(state: &AppState) -> Self { + state.db.clone() + } +} + pub struct TokenBucket { pub tokens: f64, pub last_refill: Instant, } // --- Rate Limiting Configuration --- -// Strategy: 5 requests per minute. -// This means 1 new token is mathematically generated every 12 seconds. const REQUESTS_PER_MINUTE: f64 = 5.0; -// Maximum burst capacity. Users can make up to 5 rapid requests if their bucket is full. const BUCKET_CAPACITY: f64 = 5.0; impl TokenBucket { @@ -30,8 +34,6 @@ impl TokenBucket { } } - /// Refills the bucket based on time passed since the last request. - /// Mathematically simulates a background process adding 1 token every (60/RPM) seconds. fn refill(&mut self) { let now = Instant::now(); let elapsed = now.duration_since(self.last_refill).as_secs_f64(); @@ -40,7 +42,7 @@ impl TokenBucket { self.tokens = (self.tokens + elapsed * tokens_per_second).min(BUCKET_CAPACITY); self.last_refill = now; } - /// Attempts to consume 1 token from the bucket. + pub fn try_drain(&mut self) -> bool { self.refill(); if self.tokens >= 1.0 { diff --git a/src/utils/hash.rs b/src/utils/hash.rs index 1fcf691..88c9b9b 100644 --- a/src/utils/hash.rs +++ b/src/utils/hash.rs @@ -3,26 +3,29 @@ use argon2::{ password_hash::{SaltString, rand_core::OsRng}, }; -use crate::errors::AppError; +use crate::errors::ApiError; -pub fn hash(text: &str) -> Result { +pub fn hash(text: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); - let res = argon2 + let password_hash = argon2 .hash_password(text.as_bytes(), &salt) - .map_err(|e| AppError::InvalidConfig(format!("Invalid hash {}", e)))?; + .map_err(|e| { + tracing::error!("Hash error: {}", e); + ApiError::Internal + })?; - Ok(res.to_string()) + Ok(password_hash.to_string()) } -pub fn verify(text: &str, hash: &str) -> Result { + +pub fn verify(text: &str, hash: &str) -> Result { let parsed_hash = PasswordHash::new(hash) - .map_err(|e| AppError::InvalidConfig(format!("Invalid hash {}", e)))?; - + .map_err(|e| { + tracing::error!("Hash parsing error: {}", e); + ApiError::Internal + })?; + let argon2 = Argon2::default(); - let res = argon2 - .verify_password(text.as_bytes(), &parsed_hash) - .is_ok(); - - Ok(res) + Ok(argon2.verify_password(text.as_bytes(), &parsed_hash).is_ok()) } diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index 2432834..c5addd5 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -3,7 +3,7 @@ use jsonwebtoken::{EncodingKey, Header, encode}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::errors::AppError; +use crate::errors::ApiError; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { @@ -13,7 +13,7 @@ pub struct Claims { pub jti: Uuid, } -pub fn generate_access_token(user_id: Uuid, secret: &str) -> Result { +pub fn generate_access_token(user_id: Uuid, secret: &str) -> Result { let now = Utc::now(); let expires_at = now + Duration::minutes(15); @@ -29,10 +29,10 @@ pub fn generate_access_token(user_id: Uuid, secret: &str) -> Result Result { +pub fn verify_access_token(token: &str, secret: &str) -> Result { let mut validation = jsonwebtoken::Validation::default(); validation.validate_exp = true; // Ensure expired tokens are rejected jsonwebtoken::decode::( @@ -41,5 +41,5 @@ pub fn verify_access_token(token: &str, secret: &str) -> Result `slug-a7x9`). +- [ ] Build `POST /api/v1/orgs` and `GET /api/v1/orgs` endpoints using the new `CurrentUser` and `ValidJson` extractors. -## 2. Reduce Boilerplate in Controllers (`src/controller/`) -- [ ] Leverage Axum's `FromRequest` and `IntoResponse` traits more heavily on models. -- [ ] Implement a custom extractor (e.g., using `validator` crate) to ensure controller signatures guarantee valid data (e.g., `ValidJson(payload)`). +## 2. Projects Layer +- [ ] Create projects table (belongs to `org_id`). +- [ ] Create `project_memberships` table for specific project access (inherits downward from Org roles). +- [ ] CRUD endpoints for projects nested under `/api/v1/orgs/{org_slug}/projects`. -## 3. State Management (`src/state.rs`) -- [ ] Audit state struct to ensure `PgPool` is not wrapped in an unnecessary `Arc` (since it is already an `Arc` internally). -- [ ] Ensure application state leverages Axum's `FromRef` trait effectively for sub-components. +## 3. Core Issue Tracking +- [ ] Create issues table (belongs to `project_id`). +- [ ] Allow Projects to define their own custom workflow stages (e.g., Todo, In Progress, QA, Done). -## 4. Separation of Concerns in Database Repositories -- [ ] Update `user_repository.rs` and `refresh_token_repository.rs` methods to accept `&sqlx::PgPool` or `&mut sqlx::Transaction` as arguments to support multi-table atomic transactions cleanly. - -## 5. Clean up Middleware (`src/controller/middleware/`) -- [ ] Ensure `auth_middleware.rs` properly passes the authenticated user downstream using `Extension` or `State`. -- [ ] Create a `CurrentUser` Extractor so route handlers can easily extract the user via `async fn get_profile(user: CurrentUser)` instead of manually extracting extensions. +## 4. Power-User Features +- [ ] Build "Agglomeration Views": Allow creating Org-level Kanban boards that span multiple projects and map project-specific stages to unified columns.