fixed old todos
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 13m48s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 13m48s
This commit is contained in:
parent
0173e01f49
commit
8b69b59485
53
Cargo.lock
generated
53
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<Self, AppError> {
|
||||
pub fn from_env() -> Result<Self, StartupError> {
|
||||
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<Self, AppError> {
|
||||
pub fn load() -> Result<Self, StartupError> {
|
||||
dotenv().ok();
|
||||
Ok(Self {
|
||||
db_url: env::var("DATABASE_URL")?,
|
||||
|
||||
52
src/controller/extractor.rs
Normal file
52
src/controller/extractor.rs
Normal file
@ -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<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for ValidJson<T>
|
||||
where
|
||||
T: DeserializeOwned + Validate,
|
||||
S: Send + Sync,
|
||||
Json<T>: FromRequest<S, Rejection = axum::extract::rejection::JsonRejection>,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let Json(value) = Json::<T>::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<S> FromRequestParts<S> for CurrentUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let user_id = parts
|
||||
.extensions
|
||||
.get::<Uuid>()
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
|
||||
Ok(CurrentUser(*user_id))
|
||||
}
|
||||
}
|
||||
@ -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<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
) -> Result<Response, ApiError> {
|
||||
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)?;
|
||||
|
||||
@ -3,6 +3,7 @@ use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub mod extractor;
|
||||
mod middleware;
|
||||
pub mod model;
|
||||
mod v1;
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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<AppState> {
|
||||
async fn login_handler(
|
||||
State(s): State<AppState>,
|
||||
cookies: Cookies,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
ValidJson(payload): ValidJson<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
login(&s, cookies, payload).await
|
||||
}
|
||||
|
||||
async fn register_handler(
|
||||
State(s): State<AppState>,
|
||||
cookies: Cookies,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
ValidJson(payload): ValidJson<RegisterRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
register(&s, cookies, payload).await
|
||||
}
|
||||
|
||||
async fn refresh_handler(
|
||||
State(s): State<AppState>,
|
||||
cookies: Cookies,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
refresh(&s, cookies).await
|
||||
}
|
||||
|
||||
@ -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<AppState> {
|
||||
Router::new()
|
||||
@ -10,18 +12,14 @@ pub fn router() -> Router<AppState> {
|
||||
.layer(CookieManagerLayer::new())
|
||||
}
|
||||
|
||||
async fn logout_handler(
|
||||
State(s): State<AppState>,
|
||||
cookies: Cookies,
|
||||
Extension(user_id): Extension<uuid::Uuid>,
|
||||
) -> Result<(), AppError> {
|
||||
auth_service::logout(&s, cookies, user_id).await
|
||||
async fn logout_handler(State(s): State<AppState>, cookies: Cookies) -> Result<(), ApiError> {
|
||||
auth_service::logout(&s, cookies).await
|
||||
}
|
||||
|
||||
async fn logout_all_handler(
|
||||
State(s): State<AppState>,
|
||||
cookies: Cookies,
|
||||
Extension(user_id): Extension<uuid::Uuid>,
|
||||
) -> Result<(), AppError> {
|
||||
CurrentUser(user_id): CurrentUser,
|
||||
) -> Result<(), ApiError> {
|
||||
auth_service::logout_all(&s, cookies, user_id).await
|
||||
}
|
||||
|
||||
@ -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<Pool<Postgres>, AppError> {
|
||||
pub async fn init(db_url: &str) -> Result<Pool<Postgres>, 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");
|
||||
|
||||
|
||||
@ -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<Organization, AppError>
|
||||
) -> Result<Organization, ApiError>
|
||||
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<Vec<Organization>, AppError>
|
||||
) -> Result<Vec<Organization>, 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)
|
||||
}
|
||||
|
||||
@ -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<Utc>,
|
||||
) -> Result<RefreshToken, AppError>
|
||||
) -> Result<RefreshToken, ApiError>
|
||||
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<Option<RefreshToken>, AppError>
|
||||
) -> Result<Option<RefreshToken>, 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(())
|
||||
}
|
||||
|
||||
@ -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<User, AppError>
|
||||
) -> Result<User, ApiError>
|
||||
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<Option<User>, AppError>
|
||||
pub async fn get_user_by_email<'e, E>(executor: E, email: &str) -> Result<Option<User>, 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<Option<User>, AppError>
|
||||
pub async fn get_user_by_id<'e, E>(executor: E, id: Uuid) -> Result<Option<User>, 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)
|
||||
}
|
||||
|
||||
@ -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<AppError> 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()
|
||||
}
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -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::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(logging::shutdown_signal())
|
||||
.await
|
||||
.map_err(AppError::Bind)?;
|
||||
.with_graceful_shutdown(logging::shutdown_signal())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -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<Json<AuthResponse>, AppError> {
|
||||
) -> Result<Json<AuthResponse>, 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<Json<AuthResponse>, AppError> {
|
||||
) -> Result<Json<AuthResponse>, 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<Json<AuthResponse>, AppError> {
|
||||
let refresh_token = get_refresh_cookie(&cookies).ok_or(AppError::InvalidCredentials)?;
|
||||
pub async fn refresh(state: &AppState, cookies: Cookies) -> Result<Json<AuthResponse>, 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<Json<AuthResp
|
||||
|
||||
use crate::db::repository::refresh_token_repository::revoke_all_for_user;
|
||||
|
||||
pub async fn logout(state: &AppState, cookies: Cookies, _user_id: uuid::Uuid) -> 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?;
|
||||
|
||||
14
src/state.rs
14
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<DashMap<String, TokenBucket>>,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> 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 {
|
||||
|
||||
@ -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<String, AppError> {
|
||||
pub fn hash(text: &str) -> Result<String, ApiError> {
|
||||
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<bool, AppError> {
|
||||
|
||||
pub fn verify(text: &str, hash: &str) -> Result<bool, ApiError> {
|
||||
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())
|
||||
}
|
||||
|
||||
@ -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<String, AppError> {
|
||||
pub fn generate_access_token(user_id: Uuid, secret: &str) -> Result<String, ApiError> {
|
||||
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<String, AppE
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|_| AppError::Internal)
|
||||
.map_err(|_| ApiError::Internal)
|
||||
}
|
||||
|
||||
pub fn verify_access_token(token: &str, secret: &str) -> Result<Claims, AppError> {
|
||||
pub fn verify_access_token(token: &str, secret: &str) -> Result<Claims, ApiError> {
|
||||
let mut validation = jsonwebtoken::Validation::default();
|
||||
validation.validate_exp = true; // Ensure expired tokens are rejected
|
||||
jsonwebtoken::decode::<Claims>(
|
||||
@ -41,5 +41,5 @@ pub fn verify_access_token(token: &str, secret: &str) -> Result<Claims, AppError
|
||||
&validation,
|
||||
)
|
||||
.map(|data| data.claims)
|
||||
.map_err(|_| AppError::Unauthorized) // Map any JWT error to 401
|
||||
.map_err(|_| ApiError::Unauthorized) // Map any JWT error to 401
|
||||
}
|
||||
|
||||
33
todo.md
33
todo.md
@ -1,22 +1,19 @@
|
||||
# Project Optimizations TODO
|
||||
# Project Roadmap TODO
|
||||
|
||||
## 1. Consolidate and Refine Error Handling (`src/errors.rs`)
|
||||
- [ ] Split errors into two domains:
|
||||
- `StartupError`: For config parsing, database connection pooling, and server binding failures (can return `eyre` or `anyhow::Error` in `main.rs`).
|
||||
- `ApiError`: Dedicated exclusively to HTTP responses.
|
||||
- [ ] Add `tracing::error!` logging in the `IntoResponse` implementation for internal server errors (like `DbConnect`) before returning generic error JSON to clients.
|
||||
## 1. Organizations & Roles (Next Up)
|
||||
- [ ] Create `0004_create_org_memberships_table.sql` with `org_role` ENUM (owner, admin, member, viewer).
|
||||
- [ ] Implement `src/db/repository/organization_repository.rs` with `create_org_with_owner` (using `&mut sqlx::Transaction`).
|
||||
- [ ] Add slug generation utility (name -> `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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user