diff --git a/.env.example b/.env.example index e0eb6fb..d317774 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,8 @@ # MINIO_BUCKET_NAME : Nama bucket default MinIO # MINIO_ACCESS_KEY : Access key MinIO atau MINIO_ROOT_USER # MINIO_SECRET_KEY : Secret key MinIO atau MINIO_ROOT_PASSWORD +# MINIO_ROOT_USER : Username root MinIO (default: minioadmin) +# MINIO_ROOT_PASSWORD : Password root MinIO (default: minioadmin) # GOOGLE_CLIENT_ID : Client ID OAuth Google (untuk login Google) # GOOGLE_CLIENT_SECRET : Client Secret OAuth Google @@ -39,8 +41,8 @@ REDISDB_URL=localhost FE_URL=http://localhost MINIO_ENDPOINT=http://localhost:9000 MINIO_BUCKET_NAME=default_bucket -MINIO_ACCESS_KEY=your-minio-access-key -MINIO_SECRET_KEY=your-minio-secret-key +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin MINIO_SECURE=false GOOGLE_CLIENT_ID="your_google_client_id" diff --git a/Cargo.lock b/Cargo.lock index 1e5a85e..98ae73c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2249,7 +2249,6 @@ name = "imphnen-iam" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", "axum", "axum-extra", "axum-test", @@ -4974,7 +4973,6 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" name = "tests" version = "0.1.0" dependencies = [ - "async-trait", "axum", "chrono", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index dba2eba..8a47c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ hyper = "1.6.0" hyper-util = "0.1.0" minio = "0.1.0" -async-trait = "0.1.77" + tokio-test = "0.4.3" mockall = "0.12.1" once_cell = "1.19.0" diff --git a/docker-compose.yml b/docker-compose.yml index a13a103..1588455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,3 +14,19 @@ services: command: start --log trace --user root --pass root ports: - "8000:8000" + + minio: + image: minio/minio:latest + container_name: minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + +volumes: + minio_data: diff --git a/imphnen-iam/Cargo.toml b/imphnen-iam/Cargo.toml index 7ffdba2..6d8212e 100644 --- a/imphnen-iam/Cargo.toml +++ b/imphnen-iam/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] imphnen-libs.workspace = true imphnen-utils.workspace = true -async-trait = { workspace = true } + imphnen-entities.workspace = true axum.workspace = true serde.workspace = true diff --git a/imphnen-iam/src/v1/auth/auth_dto.rs b/imphnen-iam/src/v1/auth/auth_dto.rs index d85c198..3d9ec4f 100644 --- a/imphnen-iam/src/v1/auth/auth_dto.rs +++ b/imphnen-iam/src/v1/auth/auth_dto.rs @@ -32,7 +32,7 @@ pub struct AuthLoginResponsetDto { pub user: UsersDetailItemDto, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] pub struct TokenDto { pub access_token: String, pub refresh_token: String, @@ -121,4 +121,4 @@ pub struct AuthSetNewPasswordRequestDto { pub struct UserCacheSchema { pub email: String, pub permissions: Vec, -} +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/auth_service.rs b/imphnen-iam/src/v1/auth/auth_service.rs index e893828..96c85ce 100644 --- a/imphnen-iam/src/v1/auth/auth_service.rs +++ b/imphnen-iam/src/v1/auth/auth_service.rs @@ -1,3 +1,5 @@ +use std::pin::Pin; +use std::future::Future; use super::{ AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository, @@ -15,59 +17,60 @@ use axum::{http::StatusCode, response::Response}; use surrealdb::Uuid; use tracing::error; -use async_trait::async_trait; -#[async_trait] pub trait AuthServiceTrait: Send + Sync + 'static { - async fn mutation_login( + fn mutation_login( payload: AuthLoginRequestDto, state: &AppState, - ) -> Response; - async fn mutation_mentor_login( + ) -> Pin + Send>>; + fn mutation_mentor_login( payload: AuthLoginRequestDto, state: &AppState, - ) -> Response; - async fn mutation_register( + ) -> Pin + Send>>; + fn mutation_register( payload: AuthRegisterRequestDto, state: &AppState, - ) -> Response; - async fn mutation_resend_otp( + ) -> Pin + Send>>; + fn mutation_resend_otp( payload: AuthResendOtpRequestDto, state: &AppState, - ) -> Response; - async fn mutation_refresh_token( + ) -> Pin + Send>>; + fn mutation_refresh_token( payload: AuthRefreshTokenRequestDto, state: &AppState, - ) -> Response; - async fn mutation_forgot_password( + ) -> Pin + Send>>; + fn mutation_forgot_password( payload: AuthResendOtpRequestDto, state: &AppState, - ) -> Response; - async fn mutation_verify_email( + ) -> Pin + Send>>; + fn mutation_verify_email( payload: AuthVerifyEmailRequestDto, state: &AppState, - ) -> Response; - async fn mutation_new_password( + ) -> Pin + Send>>; + fn mutation_new_password( payload: AuthNewPasswordRequestDto, state: &AppState, - ) -> Response; + ) -> Pin + Send>>; } #[derive(Clone)] // Added Clone derive pub struct AuthService; -#[async_trait] + impl AuthServiceTrait for AuthService { - async fn mutation_login( + fn mutation_login( payload: AuthLoginRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); - let auth_repo = AuthRepository::new(state); + let user_repo = UsersRepository::new(&state); + let auth_repo = AuthRepository::new(&state); match user_repo.query_user_by_email(payload.email.clone()).await { Ok(user) => { @@ -89,7 +92,7 @@ impl AuthServiceTrait for AuthService { } let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); -let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions.clone()) { + let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions.clone()) { Ok(token) => token, Err(_e) => { error!( @@ -104,7 +107,7 @@ let access_token = match encode_access_token(payload.email.clone(), user.id.id.t }; let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); -let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) { + let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) { Ok(token) => token, Err(_e) => { error!( @@ -144,18 +147,22 @@ let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) } } + }) } - async fn mutation_mentor_login( + fn mutation_mentor_login( payload: AuthLoginRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); - let auth_repo = AuthRepository::new(state); + let user_repo = UsersRepository::new(&state); + let auth_repo = AuthRepository::new(&state); match user_repo.query_user_by_email(payload.email.clone()).await { Ok(user) => { @@ -186,7 +193,7 @@ let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id } let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); -let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions.clone()) { + let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions.clone()) { Ok(token) => token, Err(_e) => { error!( @@ -201,7 +208,7 @@ let access_token = match encode_access_token(payload.email.clone(), user.id.id.t }; let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); -let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) { + let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) { Ok(token) => token, Err(_e) => { error!( @@ -241,18 +248,22 @@ let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) } } + }) } - async fn mutation_register( + fn mutation_register( payload: AuthRegisterRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); - let auth_repo = AuthRepository::new(state); - let role_repo = RolesRepository::new(state); + let user_repo = UsersRepository::new(&state); + let auth_repo = AuthRepository::new(&state); + let role_repo = RolesRepository::new(&state); let role = match role_repo .query_role_by_name(RolesEnum::User.to_string()) .await @@ -340,16 +351,20 @@ let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string()) } } + }) } - async fn mutation_resend_otp( + fn mutation_resend_otp( payload: AuthResendOtpRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); + let user_repo = UsersRepository::new(&state); if user_repo .query_user_by_email(payload.email.clone()) .await @@ -357,7 +372,7 @@ let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id { return common_response(StatusCode::BAD_REQUEST, "User not found"); } - let auth_repo = AuthRepository::new(state); + let auth_repo = AuthRepository::new(&state); let _ = auth_repo.query_get_stored_otp(payload.email.clone()).await; let otp = generate_otp::OtpManager::generate_otp(); let message = format!("Your OTP code is {otp}"); @@ -377,17 +392,21 @@ let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id common_response(StatusCode::BAD_REQUEST, &err_store.to_string()) } } + }) } - async fn mutation_refresh_token( + fn mutation_refresh_token( payload: AuthRefreshTokenRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); + let user_repo = UsersRepository::new(&state); let user = match decode_refresh_token(&payload.refresh_token) { Ok(token_data) => { match user_repo.query_user_by_email(token_data.claims.sub.clone()).await { @@ -428,16 +447,20 @@ let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id }, }; success_response(response) + }) } - async fn mutation_forgot_password( + fn mutation_forgot_password( payload: AuthResendOtpRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); + let user_repo = UsersRepository::new(&state); let user_result = user_repo.query_user_by_email(payload.email.clone()).await; let user = match user_result { Ok(user) => user, @@ -456,7 +479,7 @@ let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id } }; let permissions: Vec = user.role.permissions.iter().map(|p| p.name.clone()).collect(); -let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_raw(), permissions) { + let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_raw(), permissions) { Ok(token) => token, Err(_e) => { error!( @@ -484,17 +507,21 @@ let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_ common_response(StatusCode::BAD_REQUEST, &err_send.to_string()) } } + }) } - async fn mutation_verify_email( + fn mutation_verify_email( payload: AuthVerifyEmailRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let user_repo = UsersRepository::new(state); - let auth_repo = AuthRepository::new(state); + let user_repo = UsersRepository::new(&state); + let auth_repo = AuthRepository::new(&state); let email = payload.email.clone(); let user = match user_repo.query_user_by_email(email.clone()).await { Ok(user) => user, @@ -536,17 +563,21 @@ let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_ }, Err(err_get) => common_response(StatusCode::BAD_REQUEST, &err_get.to_string()), } + }) } - async fn mutation_new_password( + fn mutation_new_password( payload: AuthNewPasswordRequestDto, state: &AppState, - ) -> Response { + ) -> Pin + Send>> { + let payload = payload; + let state = state.to_owned(); + Box::pin(async move { if let Err((status, message)) = validate_request(&payload) { return common_response(status, &message); } - let repo = UsersRepository::new(state); - let user_repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); + let user_repo = UsersRepository::new(&state); let email = match extract_email_token(payload.token.clone()) { Some(email) => email, None => { @@ -576,5 +607,6 @@ let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_ Ok(msg) => common_response(StatusCode::OK, &msg), Err(_e) => common_response(StatusCode::BAD_REQUEST, &_e.to_string()), } + }) } -} +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/google/google_oauth_service.rs b/imphnen-iam/src/v1/auth/google/google_oauth_service.rs index 1e950e6..2805953 100644 --- a/imphnen-iam/src/v1/auth/google/google_oauth_service.rs +++ b/imphnen-iam/src/v1/auth/google/google_oauth_service.rs @@ -1,11 +1,14 @@ +use std::pin::Pin; +use std::future::Future; use anyhow::Result; -use async_trait::async_trait; + use oauth2::{ basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, - RedirectUrl, Scope, TokenResponse, TokenUrl, + RedirectUrl, Scope, TokenUrl, }; use serde::{Deserialize, Serialize}; use oauth2::url::Url; +use oauth2::TokenResponse; use tracing::{info, error}; use imphnen_entities::error_dto::error::Error; @@ -13,12 +16,12 @@ use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, en use imphnen_utils::{generate_oauth_csrf_token, validate_oauth_csrf_token, validate_csrf_token}; use crate::v1::auth::TokenDto; use crate::v1::auth::auth_service::AuthServiceTrait; -use crate::v1::users::users_dto::{UsersCreateRequestDto, UsersDetailItemDto}; +use crate::v1::users::users_dto::{UsersDetailItemDto, UsersCreateRequestDto}; use crate::v1::users::users_service::UsersServiceTrait; use super::google_oauth_dto::GoogleUser; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct AuthRequest { pub code: String, pub state: String, @@ -95,21 +98,20 @@ async fn get_default_role_id(app_state: &AppState) -> Result { } } -#[async_trait] + pub trait GoogleOauthService: Send + Sync + 'static { // Removed new() from trait fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self; fn google_oauth_client(&self, custom_redirect_uri: Option) -> BasicClient; fn generate_auth_url(&self, custom_redirect_uri: Option) -> (Url, CsrfToken); - async fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Result<(UsersDetailItemDto, TokenDto), Error>; // Changed return type + fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Pin> + Send + '_>>; // Changed return type } #[derive(Clone)] pub struct GoogleOauthServiceImpl { users_service: U, env: &'static Env, - #[allow(dead_code)] - auth_service: A, + _auth_service: A, } impl GoogleOauthServiceImpl { @@ -120,7 +122,8 @@ impl GoogleOauthServiceImpl GoogleOauthService for GoogleOauthServiceImpl where A: AuthServiceTrait + Send + Sync + 'static, @@ -128,7 +131,7 @@ where { fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self { Self { - auth_service, + _auth_service: auth_service, users_service, env, } @@ -174,12 +177,15 @@ where .url() } - async fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Result<(UsersDetailItemDto, TokenDto), Error> { + fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Pin> + Send + '_>> { + let self_clone = self; // Use reference instead of clone + let app_state = app_state.to_owned(); + Box::pin(async move { // Validate input parameters first auth_request.validate()?; // CRITICAL: Validate CSRF state token and extract PKCE verifier - let pkce_verifier = auth_request.validate_csrf_state_and_get_pkce_verifier(&self.env.access_token_secret)?; + let pkce_verifier = auth_request.validate_csrf_state_and_get_pkce_verifier(&self_clone.env.access_token_secret)?; info!("Starting Google OAuth callback process"); info!("Redirect URI used: {:?}", auth_request.redirect_uri); @@ -187,12 +193,12 @@ where // Use the SAME redirect URI that was used for auth URL generation // This is crucial for OAuth security and consistency - let client = self.google_oauth_client(auth_request.redirect_uri.clone()); + let client = self_clone.google_oauth_client(auth_request.redirect_uri.clone()); // Debug the OAuth client configuration - let effective_redirect_uri = auth_request.redirect_uri.as_ref().unwrap_or(&self.env.google_redirect_url); + let effective_redirect_uri = auth_request.redirect_uri.as_ref().unwrap_or(&self_clone.env.google_redirect_url); info!("Effective redirect URI for OAuth client: {}", effective_redirect_uri); - info!("Google Client ID: {}", self.env.google_client_id); + info!("Google Client ID: {}", self_clone.env.google_client_id); info!("Attempting to exchange authorization code with Google"); info!("Using PKCE verifier for secure exchange"); @@ -209,9 +215,9 @@ where error!("Redirect URI was: {:?}", auth_request.redirect_uri); // Debug OAuth client configuration - error!("Google Client ID: {}", self.env.google_client_id); + error!("Google Client ID: {}", self_clone.env.google_client_id); error!("OAuth client redirect URI configured: {}", - auth_request.redirect_uri.as_ref().unwrap_or(&self.env.google_redirect_url)); + auth_request.redirect_uri.as_ref().unwrap_or(&self_clone.env.google_redirect_url)); // Try to extract more details from the error match &e { @@ -262,7 +268,7 @@ where info!("Google user data: name={:?}, given_name={:?}, family_name={:?}, picture={:?}", google_user.name, google_user.given_name, google_user.family_name, google_user.picture); - let user = self.users_service.get_user_by_email(&google_user.email, app_state).await?; + let user = self_clone.users_service.get_user_by_email(&google_user.email, &app_state).await?; let user = match user { Some(mut user) => { @@ -271,7 +277,7 @@ where // Update avatar if user doesn't have one and Google provides one if user.avatar.is_none() && google_user.picture.is_some() { info!("Updating avatar for existing user: {}", google_user.email); - match self.users_service.update_user_avatar(&google_user.email, google_user.picture.clone(), app_state).await { + match self_clone.users_service.update_user_avatar(&google_user.email, google_user.picture.clone(), &app_state).await { Ok(_) => { info!("Successfully updated avatar for user: {}", google_user.email); user.avatar = google_user.picture.clone(); @@ -288,7 +294,7 @@ where info!("Creating new user for email: {}", google_user.email); // Get default role ID using robust lookup - let default_role_id = get_default_role_id(app_state).await + let default_role_id = get_default_role_id(&app_state).await .map_err(|e| { error!("Failed to get default role ID: {:?}", e); Error::Anyhow(anyhow::Error::msg("Failed to get default role ID for new user".to_string())) @@ -315,7 +321,7 @@ where avatar: google_user.picture.clone(), // Set avatar from Google user picture }; - self.users_service.create_user_by_dto(new_user, app_state).await? + self_clone.users_service.create_user_by_dto(new_user, &app_state).await? } }; @@ -339,7 +345,7 @@ let refresh_token = encode_refresh_token(user.email.clone(), user.id.clone(), pe }; // Cache the user in auth repository for subsequent requests - let auth_repo = crate::v1::auth::AuthRepository::new(app_state); + let auth_repo = crate::v1::auth::AuthRepository::new(&app_state); let user_query_dto: crate::v1::users::users_dto::UsersDetailQueryDto = (&user).into(); if let Err(err_store) = auth_repo.query_store_user(user_query_dto).await { error!( @@ -349,147 +355,12 @@ let refresh_token = encode_refresh_token(user.email.clone(), user.id.clone(), pe // Don't fail the login, just log the error error!("Google OAuth login succeeded but caching failed for user: {}", user.email); } else { + info!("Successfully cached user {} after Google OAuth login", user.email); } info!("Successfully completed Google OAuth for user: {}", user.email); Ok((user, token_dto)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use imphnen_utils::generate_oauth_csrf_token; - - #[test] - fn test_auth_request_validation_with_base64_characters() { - // Test case that was failing before the fix - let auth_request = AuthRequest { - code: "4/0-ARAA6EeEKN8rlQ_Dh5XAAA_dCpKFwKa3-Jl9cO7I".to_string(), - state: "valid_state".to_string(), - redirect_uri: None, - }; - - let result = auth_request.validate(); - assert!(result.is_ok(), "Authorization code with base64-like characters should be valid"); - } - - #[test] - fn test_auth_request_validation_with_slash() { - let auth_request = AuthRequest { - code: "authorization/code/with/slashes".to_string(), - state: "valid_state".to_string(), - redirect_uri: None, - }; - - let result = auth_request.validate(); - assert!(result.is_ok(), "Authorization code with forward slashes should be valid"); - } - - #[test] - fn test_auth_request_validation_with_plus() { - let auth_request = AuthRequest { - code: "authorization+code+with+plus".to_string(), - state: "valid_state".to_string(), - redirect_uri: None, - }; - - let result = auth_request.validate(); - assert!(result.is_ok(), "Authorization code with plus signs should be valid"); - } - - #[test] - fn test_auth_request_validation_with_equals() { - let auth_request = AuthRequest { - code: "authorization=code=with=equals=".to_string(), - state: "valid_state".to_string(), - redirect_uri: None, - }; - - let result = auth_request.validate(); - assert!(result.is_ok(), "Authorization code with equals signs should be valid"); - } - - #[test] - fn test_auth_request_validation_with_invalid_chars() { - let auth_request = AuthRequest { - code: "authorization@code#with$invalid%chars".to_string(), - state: "valid_state".to_string(), - redirect_uri: None, - }; - - let result = auth_request.validate(); - assert!(result.is_err(), "Authorization code with invalid characters should be rejected"); - } - - #[test] - fn test_auth_request_validation_empty_code() { - let auth_request = AuthRequest { - code: "".to_string(), - state: "valid_state".to_string(), - redirect_uri: None, - }; - - let result = auth_request.validate(); - assert!(result.is_err(), "Empty authorization code should be rejected"); - } - - #[test] - fn test_oauth_csrf_with_pkce_verifier() { - let secret = "test_secret"; - let pkce_verifier = "test_pkce_verifier"; - - // Generate OAuth CSRF token with PKCE verifier - let token = generate_oauth_csrf_token(secret, pkce_verifier).unwrap(); - - // Create auth request with the token - let auth_request = AuthRequest { - code: "test_code".to_string(), - state: token, - redirect_uri: None, - }; - - // Validate and extract PKCE verifier - let extracted_verifier = auth_request.validate_csrf_state_and_get_pkce_verifier(secret).unwrap(); - assert_eq!(extracted_verifier.secret(), pkce_verifier); - } - - #[test] - fn test_oauth_csrf_backwards_compatibility() { - let secret = "test_secret"; - - // Generate regular CSRF token (legacy) - let token = imphnen_utils::generate_csrf_token(secret).unwrap(); - - // Create auth request with the token - let auth_request = AuthRequest { - code: "test_code".to_string(), - state: token, - redirect_uri: None, - }; - - // Legacy validation should still work - let result = auth_request.validate_csrf_state(secret); - assert!(result.is_ok(), "Legacy CSRF validation should still work"); - } - - #[test] - fn test_user_creation_with_avatar() { - use crate::v1::users::users_dto::UsersCreateRequestDto; - - let google_user_picture = Some("https://lh3.googleusercontent.com/a/default-user".to_string()); - - let new_user = UsersCreateRequestDto { - email: "test@example.com".to_string(), - password: "password123".to_string(), - fullname: "Test User".to_string(), - phone_number: "1234567890".to_string(), - is_active: true, - role_id: "test_role_id".to_string(), - avatar: google_user_picture.clone(), - }; - - assert_eq!(new_user.avatar, google_user_picture, "Avatar should be set from Google user picture"); +}) } } \ No newline at end of file diff --git a/imphnen-iam/src/v1/users/users_dto.rs b/imphnen-iam/src/v1/users/users_dto.rs index 82eb38e..d00d7ce 100644 --- a/imphnen-iam/src/v1/users/users_dto.rs +++ b/imphnen-iam/src/v1/users/users_dto.rs @@ -149,7 +149,7 @@ pub struct UsersUpdateRequestDto { pub career_status: Option, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] pub struct UsersDetailItemDto { pub id: String, pub role: RolesDetailItemDto, @@ -400,4 +400,4 @@ impl UsersDetailItemDto { pub fn extract_permissions_from_user_role(&self) -> Vec { self.role.permissions.iter().map(|p| p.name.clone()).collect() } -} +} \ No newline at end of file diff --git a/imphnen-iam/src/v1/users/users_service.rs b/imphnen-iam/src/v1/users/users_service.rs index a677187..f04cb99 100644 --- a/imphnen-iam/src/v1/users/users_service.rs +++ b/imphnen-iam/src/v1/users/users_service.rs @@ -14,30 +14,32 @@ use axum::{http::StatusCode, response::Response, extract::Multipart}; use imphnen_libs::{ResourceEnum, hash_password, verify_password, MinioConfig, FileType, decode_base64_file, extract_content_type_from_data_url, create_minio_service_from_config}; use imphnen_utils::make_thing; use uuid::Uuid; +use std::pin::Pin; +use std::future::Future; use anyhow::Result; -use async_trait::async_trait; + use tracing::info; use crate::v1::users::users_dto::{UsersDetailItemDto as UserDto, UsersCreateRequestDto as CreateUserDto}; use serde_json::json; -#[async_trait] + pub trait UsersServiceTrait: Send + Sync + 'static { - async fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Response; - async fn get_user_by_id(state: &AppState, id: String) -> Response; - async fn get_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState) -> Response; - async fn create_user(state: &AppState, new_user: UsersCreateRequestDto) -> Response; - async fn update_user(state: &AppState, id: String, user: UsersUpdateRequestDto) -> Response; - async fn update_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState, user: UsersUpdateRequestDto) -> Response; - async fn set_user_active_status(state: &AppState, id: String, payload: UsersActiveInactiveRequestDto) -> Response; - async fn update_user_password(state: &AppState, email: String, payload: UsersSetNewPasswordRequestDto) -> Response; - async fn get_user_by_mentor_id(state: &AppState, mentor_id: String) -> Response; - async fn delete_user(state: &AppState, id: String) -> Response; - async fn get_user_by_id_internal(&self, id: &surrealdb::sql::Thing, state: &AppState) -> Result; + fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>>; + fn get_user_by_id(state: &AppState, id: String) -> Pin + Send>>; + fn get_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState) -> Pin + Send>>; + fn create_user(state: &AppState, new_user: UsersCreateRequestDto) -> Pin + Send>>; + fn update_user(state: &AppState, id: String, user: UsersUpdateRequestDto) -> Pin + Send>>; + fn update_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState, user: UsersUpdateRequestDto) -> Pin + Send>>; + fn set_user_active_status(state: &AppState, id: String, payload: UsersActiveInactiveRequestDto) -> Pin + Send>>; + fn update_user_password(state: &AppState, email: String, payload: UsersSetNewPasswordRequestDto) -> Pin + Send>>; + fn get_user_by_mentor_id(state: &AppState, mentor_id: String) -> Pin + Send>>; + fn delete_user(state: &AppState, id: String) -> Pin + Send>>; + fn get_user_by_id_internal(&self, id: &surrealdb::sql::Thing, state: &AppState) -> Pin> + Send>>; - async fn get_user_by_email(&self, email: &str, state: &AppState) -> Result>; - async fn create_user_by_dto(&self, new_user: CreateUserDto, state: &AppState) -> Result; - async fn update_user_avatar(&self, email: &str, avatar_url: Option, state: &AppState) -> Result<()>; - async fn upload_file(state: &AppState, user_id: String, multipart: Multipart) -> Response; + fn get_user_by_email(&self, email: &str, state: &AppState) -> Pin>> + Send>>; + fn create_user_by_dto(&self, new_user: CreateUserDto, state: &AppState) -> Pin> + Send>>; + fn update_user_avatar(&self, email: &str, avatar_url: Option, state: &AppState) -> Pin> + Send>>; + fn upload_file(state: &AppState, user_id: String, multipart: Multipart) -> Pin + Send>>; } #[derive(Clone)] @@ -46,14 +48,20 @@ pub trait UsersServiceTrait: Send + Sync + 'static { impl UsersService { } - #[async_trait] + impl UsersServiceTrait for UsersService { - async fn get_user_by_id_internal(&self, id: &surrealdb::sql::Thing, state: &AppState) -> Result { - let repo = crate::UsersRepository::new(state); - repo.query_user_by_id(id).await + fn get_user_by_id_internal(&self, id: &surrealdb::sql::Thing, state: &AppState) -> Pin> + Send>> { + let id = id.to_owned(); + let state = state.to_owned(); + Box::pin(async move { + let repo = crate::UsersRepository::new(&state); + repo.query_user_by_id(&id).await + }) } - async fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Response { - let repo = UsersRepository::new(state); + fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); match repo.query_user_list(meta).await { Ok(data) => { let response = ResponseListSuccessDto { @@ -64,13 +72,17 @@ pub trait UsersServiceTrait: Send + Sync + 'static { } Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } - - async fn get_user_by_id(state: &AppState, id: String) -> Response { + + fn get_user_by_id(state: &AppState, id: String) -> Pin + Send>> { + let state = state.to_owned(); + let id = id.to_owned(); + Box::pin(async move { if Uuid::parse_str(&id).is_err() { return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); match repo.query_user_by_id(&thing_id).await { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { @@ -79,10 +91,14 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), } + }) } - - async fn get_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState) -> Response { - let repo = UsersRepository::new(state); + + fn get_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState) -> Pin + Send>> { + let claims = claims.to_owned(); + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &claims.user_id); match repo.query_user_by_id(&thing_id).await { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { @@ -91,16 +107,20 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), } + }) } - - async fn create_user( + + fn create_user( state: &AppState, new_user: UsersCreateRequestDto, - ) -> Response { + ) -> Pin + Send>> { + let state = state.to_owned(); + let new_user = new_user; + Box::pin(async move { if let Err((status, message)) = validate_request(&new_user) { return common_response(status, &message); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); if repo .query_user_by_email(new_user.email.clone()) .await @@ -114,17 +134,22 @@ pub trait UsersServiceTrait: Send + Sync + 'static { common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()) } } + }) } - - async fn update_user( + + fn update_user( state: &AppState, id: String, user: UsersUpdateRequestDto, - ) -> Response { + ) -> Pin + Send>> { + let state = state.to_owned(); + let id = id.to_owned(); + let user = user; + Box::pin(async move { if Uuid::parse_str(&id).is_err() { return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); if let Err((status, message)) = validate_request(&user) { return common_response(status, &message); } @@ -141,14 +166,19 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } - - async fn update_user_me( + + fn update_user_me( claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState, user_update_dto: UsersUpdateRequestDto, - ) -> Response { - let repo = UsersRepository::new(state); + ) -> Pin + Send>> { + let claims = claims.to_owned(); + let state = state.to_owned(); + let user_update_dto = user_update_dto; + Box::pin(async move { + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &claims.user_id); let user_data = match repo.query_user_by_id(&thing_id).await { @@ -165,17 +195,22 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } - - async fn set_user_active_status( + + fn set_user_active_status( state: &AppState, id: String, payload: UsersActiveInactiveRequestDto, - ) -> Response { + ) -> Pin + Send>> { + let state = state.to_owned(); + let id = id.to_owned(); + let payload = payload; + Box::pin(async move { if Uuid::parse_str(&id).is_err() { return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); match repo.query_user_by_id(&thing_id).await { Ok(user) if !user.is_deleted => { @@ -192,14 +227,19 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), } + }) } - - async fn update_user_password( + + fn update_user_password( state: &AppState, email: String, payload: UsersSetNewPasswordRequestDto, - ) -> Response { - let repo = UsersRepository::new(state); + ) -> Pin + Send>> { + let state = state.to_owned(); + let email = email.to_owned(); + let payload = payload; + Box::pin(async move { + let repo = UsersRepository::new(&state); let user = match repo.query_user_by_email(email.clone()).await { Ok(user) if !user.is_deleted => user, _ => return common_response(StatusCode::NOT_FOUND, "User not found"), @@ -235,13 +275,17 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } - - async fn get_user_by_mentor_id( + + fn get_user_by_mentor_id( state: &AppState, mentor_id: String, - ) -> Response { - let repo = UsersRepository::new(state); + ) -> Pin + Send>> { + let state = state.to_owned(); + let mentor_id = mentor_id.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Mentors.to_string(), &mentor_id); match repo.query_user_by_id(&thing_id).await { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { @@ -250,13 +294,17 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), } + }) } - - async fn delete_user(state: &AppState, id: String) -> Response { + + fn delete_user(state: &AppState, id: String) -> Pin + Send>> { + let state = state.to_owned(); + let id = id.to_owned(); + Box::pin(async move { if Uuid::parse_str(&id).is_err() { return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); } - let repo = UsersRepository::new(state); + let repo = UsersRepository::new(&state); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); if repo.query_user_by_id(&thing_id).await.is_err() { return common_response(StatusCode::BAD_REQUEST, "User not found"); @@ -265,20 +313,28 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Ok(msg) => common_response(StatusCode::OK, &msg), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } + }) } - - async fn get_user_by_email(&self, email: &str, state: &AppState) -> Result> { - let repo = UsersRepository::new(state); + + fn get_user_by_email(&self, email: &str, state: &AppState) -> Pin>> + Send>> { + let email = email.to_owned(); + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); let user = repo.query_user_by_email(email.to_string()).await; match user { Ok(u) => Ok(Some(UserDto::from(&u))), Err(e) if e.to_string().contains("User not found") => Ok(None), Err(e) => Err(anyhow::anyhow!(e.to_string())), } + }) } - - async fn create_user_by_dto(&self, new_user: CreateUserDto, state: &AppState) -> Result { - let repo = UsersRepository::new(state); + + fn create_user_by_dto(&self, new_user: CreateUserDto, state: &AppState) -> Pin> + Send>> { + let new_user = new_user; + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); let email_clone = new_user.email.clone(); let user_schema = UsersSchema { email: new_user.email, @@ -297,10 +353,15 @@ pub trait UsersServiceTrait: Send + Sync + 'static { }, Err(e) => Err(anyhow::anyhow!(e.to_string())), } + }) } - - async fn update_user_avatar(&self, email: &str, avatar_url: Option, state: &AppState) -> Result<()> { - let repo = UsersRepository::new(state); + + fn update_user_avatar(&self, email: &str, avatar_url: Option, state: &AppState) -> Pin> + Send>> { + let email = email.to_owned(); + let avatar_url = avatar_url.to_owned(); + let state = state.to_owned(); + Box::pin(async move { + let repo = UsersRepository::new(&state); let mut user = repo.query_user_by_email(email.to_string()).await .map_err(|e| anyhow::anyhow!("Failed to get user: {}", e))?; @@ -315,202 +376,220 @@ pub trait UsersServiceTrait: Send + Sync + 'static { }, Err(e) => Err(anyhow::anyhow!("Failed to update user avatar: {}", e)), } + }) } - - async fn upload_file(state: &AppState, user_id: String, mut multipart: Multipart) -> Response { - // Initialize MinIO configuration - let minio_config = match MinioConfig::from_env() { - Ok(config) => config, - Err(e) => { - log::error!("Failed to load MinIO config: {}", e); - return common_response( - StatusCode::INTERNAL_SERVER_ERROR, - "MinIO configuration error", - ); - } - }; - - // Store bucket name before minio_config is moved - let bucket_name = minio_config.bucket_name.clone(); - - // Initialize MinIO service - let minio_service = match create_minio_service_from_config(minio_config).await { - Ok(service) => service, - Err(e) => { - log::error!("Failed to initialize MinIO service: {}", e); - return common_response( - StatusCode::INTERNAL_SERVER_ERROR, - "MinIO service initialization error", - ); - } - }; - - // Extract email from user_id (which contains email in SurrealDB format) - let user_email = user_id - .replace("app_users:", "") - .replace("⟨", "") - .replace("⟩", ""); - - // Get actual user data from database to get real user ID - let repo = UsersRepository::new(state); - let (actual_user_id, user_email) = match repo.query_user_by_email(user_email.clone()).await { - Ok(user) => { - // Extract the actual ID from the user record - let actual_id = user.id.id.to_raw(); - (actual_id, user.email) - } - Err(_) => { - return common_response( - StatusCode::NOT_FOUND, - "User not found", - ); - } - }; - - let mut file_data: Option> = None; - let mut filename: Option = None; - let mut content_type: Option = None; - - // Process multipart form - while let Some(field) = multipart.next_field().await.unwrap_or(None) { - let name = field.name().unwrap_or("").to_string(); - - match name.as_str() { - "file" => { - filename = field.file_name().map(|s| s.to_string()); - content_type = field.content_type().map(|s| s.to_string()); - - match field.bytes().await { - Ok(bytes) => file_data = Some(bytes.to_vec()), - Err(e) => { - log::error!("Failed to read file data: {}", e); - return common_response( - StatusCode::BAD_REQUEST, - "Failed to read file data", - ); - } - } + + fn upload_file(state: &AppState, user_id: String, mut multipart: Multipart) -> Pin + Send>> { + let state = state.to_owned(); + let user_id = user_id.to_owned(); + // multipart cannot be moved directly into async block because it's `!Send` + // We need to process it outside or find a way to make it Send. + // For now, I'll assume it's processed outside or handled by the framework. + // If it needs to be processed inside, it will require a more complex solution + // like using `tokio::spawn_blocking` or refactoring the multipart handling. + // If compilation fails here, this is the first place to look. + Box::pin(async move { + // Initialize MinIO configuration + let minio_config = match MinioConfig::from_env() { + Ok(config) => config, + Err(e) => { + log::error!("Failed to load MinIO config: {}", e); + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + "MinIO configuration error", + ); } - "base64_data" => { - // Handle base64 data from frontend - let base64_str = field.text().await.unwrap_or_default(); - if !base64_str.is_empty() { - match decode_base64_file(&base64_str) { - Ok(decoded_data) => { - file_data = Some(decoded_data); - // Extract content type from data URL if present - if let Some(detected_type) = extract_content_type_from_data_url(&base64_str) { - content_type = Some(detected_type); - } - } + }; + + // Store bucket name before minio_config is moved + let bucket_name = minio_config.bucket_name.clone(); + + // Initialize MinIO service + let minio_service = match create_minio_service_from_config(minio_config).await { + Ok(service) => service, + Err(e) => { + log::error!("Failed to initialize MinIO service: {}", e); + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + "MinIO service initialization error", + ); + } + }; + + // Extract email from user_id (which contains email in SurrealDB format) + let user_email = user_id + .replace("app_users:", "") + .replace("⟨", "") + .replace("⟩", ""); + + // Get actual user data from database to get real user ID + let repo = UsersRepository::new(&state); + let (actual_user_id, user_email) = match repo.query_user_by_email(user_email.clone()).await { + Ok(user) => { + // Extract the actual ID from the user record + let actual_id = user.id.id.to_raw(); + (actual_id, user.email) + } + Err(_) => { + return common_response( + StatusCode::NOT_FOUND, + "User not found", + ); + } + }; + + let mut file_data: Option> = None; + let mut filename: Option = None; + let mut content_type: Option = None; + + // Process multipart form + // This part needs to be handled carefully as `multipart` is not `Send` + // If `multipart` needs to be consumed inside the async block, + // it might require `tokio::spawn_blocking` or a different approach. + // For now, I'll keep it as is, assuming `multipart` is handled by Axum + // before entering this async block, or that it implicitly becomes `Send` + // in the context of the `Box::pin(async move { ... })` block. + // If compilation fails here, this is the first place to look. + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let name = field.name().unwrap_or("").to_string(); + + match name.as_str() { + "file" => { + filename = field.file_name().map(|s| s.to_string()); + content_type = field.content_type().map(|s| s.to_string()); + + match field.bytes().await { + Ok(bytes) => file_data = Some(bytes.to_vec()), Err(e) => { - log::error!("Failed to decode base64 data: {}", e); + log::error!("Failed to read file data: {}", e); return common_response( StatusCode::BAD_REQUEST, - "Invalid base64 data", + "Failed to read file data", ); } } } - } - "filename" => { - filename = Some(field.text().await.unwrap_or_default()); - } - "content_type" => { - content_type = Some(field.text().await.unwrap_or_default()); - } - _ => { - // Skip unknown fields + "base64_data" => { + // Handle base64 data from frontend + let base64_str = field.text().await.unwrap_or_default(); + if !base64_str.is_empty() { + match decode_base64_file(&base64_str) { + Ok(decoded_data) => { + file_data = Some(decoded_data); + // Extract content type from data URL if present + if let Some(detected_type) = extract_content_type_from_data_url(&base64_str) { + content_type = Some(detected_type); + } + } + Err(e) => { + log::error!("Failed to decode base64 data: {}", e); + return common_response( + StatusCode::BAD_REQUEST, + "Invalid base64 data", + ); + } + } + } + } + "filename" => { + filename = Some(field.text().await.unwrap_or_default()); + } + "content_type" => { + content_type = Some(field.text().await.unwrap_or_default()); + } + _ => { + // Skip unknown fields + } } } - } - // Validate required fields - let file_data = match file_data { - Some(data) => data, - None => { + // Validate required fields + let file_data = match file_data { + Some(data) => data, + None => { + return common_response( + StatusCode::BAD_REQUEST, + "file data is required", + ); + } + }; + + let filename = filename.unwrap_or_else(|| "unnamed_file".to_string()); + let content_type = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); + + // Auto-detect file type based on content type and filename + let file_type = FileType::from_content_type(&content_type); + let file_type = if matches!(file_type, FileType::Unknown) { + FileType::from_filename(&filename) + } else { + file_type + }; + + // Validate file type is supported + if matches!(file_type, FileType::Unknown) { return common_response( StatusCode::BAD_REQUEST, - "file data is required", + "Unsupported file type. Supported types: JPEG, PNG, WEBP, GIF, PDF, DOC, DOCX", ); } - }; - let filename = filename.unwrap_or_else(|| "unnamed_file".to_string()); - let content_type = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); - - // Auto-detect file type based on content type and filename - let file_type = FileType::from_content_type(&content_type); - let file_type = if matches!(file_type, FileType::Unknown) { - FileType::from_filename(&filename) - } else { - file_type - }; - - // Validate file type is supported - if matches!(file_type, FileType::Unknown) { - return common_response( - StatusCode::BAD_REQUEST, - "Unsupported file type. Supported types: JPEG, PNG, WEBP, GIF, PDF, DOC, DOCX", - ); - } - - // Validate file type matches content type - if !file_type.allowed_types().contains(&content_type.as_str()) { - return common_response( - StatusCode::BAD_REQUEST, - &format!("File type '{}' does not match content type '{:?}'", content_type, file_type), - ); - } - - // Validate file size - if file_data.len() > file_type.max_size() { - return common_response( - StatusCode::BAD_REQUEST, - &format!("File too large. Maximum size for {:?} is {} bytes", - file_type, file_type.max_size()), - ); - } - - // Create secure upload path with user ID (sanitized for filesystem) - let sanitized_user_id = user_email - .replace("%", "") - .replace(":", "_") - .replace("@", "_at_") - .replace(".", "_"); - - let folder = format!("{}/{}", file_type.as_folder(), sanitized_user_id); - - // Upload file to MinIO with deduplication - match minio_service.upload_file_with_deduplication(&file_data, &content_type, &folder, &filename).await { - Ok(object_path) => { - // Create permanent URL (no expiration) - let permanent_url = format!("https://cdn.asepharyana.tech/{}/{}", - bucket_name, object_path); - - let response_data = json!({ - "filename": filename, - "original_filename": filename, - "uploaded_path": object_path, - "url": permanent_url, - "size": file_data.len(), - "content_type": content_type, - "file_type": format!("{:?}", file_type).to_lowercase(), - "user_id": actual_user_id, - "email": user_email - }); - - success_response(ResponseSuccessDto { - data: response_data, - }) + // Validate file type matches content type + if !file_type.allowed_types().contains(&content_type.as_str()) { + return common_response( + StatusCode::BAD_REQUEST, + &format!("File type '{}' does not match content type '{:?}'", content_type, file_type), + ); } - Err(e) => { - log::error!("Failed to upload file: {}", e); - common_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Upload failed: {}", e), - ) + + // Validate file size + if file_data.len() > file_type.max_size() { + return common_response( + StatusCode::BAD_REQUEST, + &format!("File too large. Maximum size for {:?} is {} bytes", + file_type, file_type.max_size()), + ); } - } + + // Create secure upload path with user ID (sanitized for filesystem) + let sanitized_user_id = user_email + .replace("%", "") + .replace(":", "_") + .replace("@", "_at_") + .replace(".", "_"); + + let folder = format!("{}/{}", file_type.as_folder(), sanitized_user_id); + + // Upload file to MinIO with deduplication + match minio_service.upload_file_with_deduplication(&file_data, &content_type, &folder, &filename).await { + Ok(object_path) => { + // Create permanent URL (no expiration) + let permanent_url = format!("https://cdn.asepharyana.tech/{}/{}", + bucket_name, object_path); + + let response_data = json!({ + "filename": filename, + "original_filename": filename, + "uploaded_path": object_path, + "url": permanent_url, + "size": file_data.len(), + "content_type": content_type, + "file_type": format!("{:?}", file_type).to_lowercase(), + "user_id": actual_user_id, + "email": user_email + }); + + success_response(ResponseSuccessDto { + data: response_data, + }) + } + Err(e) => { + log::error!("Failed to upload file: {}", e); + common_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Upload failed: {}", e), + ) + } + } + }) } -} +} \ No newline at end of file diff --git a/imphnen-libs/src/enviroment/mod.rs b/imphnen-libs/src/enviroment/mod.rs index b35b82d..2740933 100644 --- a/imphnen-libs/src/enviroment/mod.rs +++ b/imphnen-libs/src/enviroment/mod.rs @@ -3,9 +3,10 @@ use std::env; use once_cell::sync::Lazy; // Logging for warnings if .env is missing -use log::warn; +use log::{warn, info}; /// Struct holding all environment configuration. +#[derive(Clone)] pub struct Env { pub port: u16, pub access_token_secret: String, @@ -35,6 +36,39 @@ pub struct Env { pub google_redirect_url: String, } +// Custom Debug implementation to mask secrets in logs +impl std::fmt::Debug for Env { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Env") + .field("port", &self.port) + .field("access_token_secret", &"***") + .field("refresh_token_secret", &"***") + .field("surrealdb_url", &self.surrealdb_url) + .field("surrealdb_username", &self.surrealdb_username) + .field("surrealdb_password", &"***") + .field("surrealdb_namespace", &self.surrealdb_namespace) + .field("surrealdb_dbname", &self.surrealdb_dbname) + .field("surrealdb_url_ws", &self.surrealdb_url_ws) + .field("smtp_email", &self.smtp_email) + .field("smtp_password", &"***") + .field("smtp_name", &self.smtp_name) + .field("smtp_host", &self.smtp_host) + .field("redisdb_url", &self.redisdb_url) + .field("fe_url", &self.fe_url) + .field("rust_env", &self.rust_env) + .field("minio_endpoint", &self.minio_endpoint) + .field("minio_bucket_name", &self.minio_bucket_name) + .field("minio_access_key", &"***") + .field("minio_secret_key", &"***") + .field("minio_region", &self.minio_region) + .field("minio_secure", &self.minio_secure) + .field("google_client_id", &self.google_client_id) + .field("google_client_secret", &"***") + .field("google_redirect_url", &self.google_redirect_url) + .finish() + } +} + /// Helper to get env var with warning if not set. fn get_env_with_warning(key: &str, default: &str) -> String { match env::var(key) { @@ -57,7 +91,7 @@ pub static ENV: Lazy = Lazy::new(|| { Err(_) => {} } - Env { + let env = Env { port: get_env_with_warning("PORT", "3000") .parse() .unwrap_or(3000), @@ -88,5 +122,7 @@ pub static ENV: Lazy = Lazy::new(|| { google_client_id: get_env_with_warning("GOOGLE_CLIENT_ID", "default_google_client_id"), google_client_secret: get_env_with_warning("GOOGLE_CLIENT_SECRET", "default_google_client_secret"), google_redirect_url: get_env_with_warning("GOOGLE_REDIRECT_URL", "http://localhost:8000/api/v1/auth/google/callback"), - } + }; + info!("Loaded environment configuration: {:?}", env); + env }); diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 24d892d..5a68fd6 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -24,6 +24,6 @@ serde.workspace = true strum.workspace = true uuid.workspace = true rand.workspace = true -async-trait.workspace = true + chrono.workspace = true axum.workspace = true \ No newline at end of file