From 5f51dd281dd1b1196a242cef6fe62b2e00ef3c73 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 14 Aug 2025 13:16:39 +0700 Subject: [PATCH] feat: update dependencies and add MinIO integration - Updated `async-channel` to version 2.5.0 and added new dependencies in `Cargo.lock`. - Introduced `minio` crate for file upload functionality. - Added `career_status` field to user-related DTOs and schemas. - Implemented file upload endpoint in `users_controller.rs` with multipart support. - Created `MinioService` for handling file uploads to MinIO. - Updated user seeding and test cases to accommodate new `career_status` field. - Refactored permissions guard to return user details. --- .env.example | 23 ++ Cargo.lock | 325 +++++++++++++++++- Cargo.toml | 3 +- imphnen-backend/src/bin/seed_users.rs | 1 + imphnen-iam/src/v1/auth/auth_repository.rs | 1 + .../src/v1/permissions/permissions_guard.rs | 9 +- imphnen-iam/src/v1/users/mod.rs | 1 + imphnen-iam/src/v1/users/users_controller.rs | 57 ++- imphnen-iam/src/v1/users/users_dto.rs | 8 + imphnen-iam/src/v1/users/users_schema.rs | 8 + imphnen-iam/src/v1/users/users_service.rs | 43 ++- imphnen-libs/Cargo.toml | 5 + imphnen-libs/src/lib.rs | 2 + imphnen-libs/src/minio.rs | 299 ++++++++++++++++ tests/src/lib.rs | 1 + tests/src/mock_test.rs | 1 + 16 files changed, 770 insertions(+), 17 deletions(-) create mode 100644 imphnen-libs/src/minio.rs diff --git a/.env.example b/.env.example index 03bf304..5fbc377 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,26 @@ +# RUST_ENV : Mode environment aplikasi (misal: development, production) +# RUST_LOG : Level logging aplikasi Rust (misal: debug, info, warn, error) +# PORT : Port yang digunakan backend service +# SURREALDB_URL : URL koneksi ke SurrealDB (gunakan ws:// untuk WebSocket) +# SURREALDB_USERNAME : Username untuk SurrealDB +# SURREALDB_PASSWORD : Password untuk SurrealDB +# SURREALDB_NAMESPACE : Namespace SurrealDB yang digunakan +# SURREALDB_DBNAME : Nama database SurrealDB +# ACCESS_TOKEN_SECRET : Secret key untuk JWT access token (ganti dengan nilai rahasia) +# REFRESH_TOKEN_SECRET : Secret key untuk JWT refresh token (ganti dengan nilai rahasia) +# SMTP_EMAIL : Email yang digunakan untuk mengirim email (SMTP) +# SMTP_PASSWORD : Password email SMTP +# SMTP_NAME : Nama pengirim email (misal: nama aplikasi) +# SMTP_HOST : Host SMTP server (misal: smtp.gmail.com) +# REDISDB_URL : URL/host Redis database +# FE_URL : URL frontend aplikasi (misal: http://localhost) +# MINIO_ENDPOINT : Endpoint MinIO object storage +# 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 +# GOOGLE_CLIENT_ID : Client ID OAuth Google (untuk login Google) +# GOOGLE_CLIENT_SECRET : Client Secret OAuth Google + RUST_ENV=development RUST_LOG=debug PORT=4099 diff --git a/Cargo.lock b/Cargo.lock index 48608ec..62d2697 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,7 +42,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dde2a385b82232b559baeec740c37809051c596f9b56e7da0d0da2c8e8f54f6" dependencies = [ - "async-channel", + "async-channel 2.5.0", "num_cpus", "thiserror 1.0.69", "tokio", @@ -247,6 +247,27 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -273,6 +294,22 @@ dependencies = [ "slab", ] +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", + "tokio", +] + [[package]] name = "async-graphql" version = "7.0.17" @@ -344,6 +381,73 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-io" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.60.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.0", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "async-std" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -394,6 +498,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "auto-future" version = "1.0.0" @@ -622,6 +732,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -915,6 +1038,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1052,6 +1190,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.4.1" @@ -1262,6 +1411,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.0" @@ -1279,7 +1434,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.0", "pin-project-lite", ] @@ -1588,6 +1743,18 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.3.27" @@ -2238,18 +2405,23 @@ dependencies = [ name = "imphnen-libs" version = "0.1.0" dependencies = [ + "anyhow", "argon2", "axum", + "base64 0.22.1", "chrono", "dotenvy", + "http 0.2.12", "imphnen-entities", "jsonwebtoken", "lettre", "log", + "minio", "once_cell", "serde", "surrealdb", "tokio", + "uuid", ] [[package]] @@ -2451,6 +2623,15 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lalrpop" version = "0.20.2" @@ -2595,6 +2776,9 @@ name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +dependencies = [ + "value-bag", +] [[package]] name = "lru" @@ -2680,6 +2864,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.7.5" @@ -2725,6 +2915,42 @@ dependencies = [ "unicase", ] +[[package]] +name = "minio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a322ca30e1a0b771b1158a950a71e2edcc31cd99ed593fa707493cdff8f2dd" +dependencies = [ + "async-recursion", + "async-std", + "base64 0.21.7", + "byteorder", + "bytes", + "chrono", + "crc", + "dashmap", + "derivative", + "futures-core", + "futures-util", + "hex", + "hmac", + "http 0.2.12", + "hyper 0.14.32", + "lazy_static", + "md5", + "multimap", + "os_info", + "rand 0.8.5", + "regex", + "reqwest 0.11.27", + "serde", + "serde_json", + "sha2", + "tokio", + "urlencoding", + "xmltree", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2789,6 +3015,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "multimap" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1a5d38b9b352dbd913288736af36af41c48d61b1a8cd34bcecd727561b7d511" +dependencies = [ + "serde", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -3059,6 +3294,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "os_info" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" +dependencies = [ + "log", + "plist", + "serde", + "windows-sys 0.52.0", +] + [[package]] name = "overload" version = "0.1.1" @@ -3248,12 +3495,50 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plist" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.10.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polling" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -3410,6 +3695,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9845d9dccf565065824e69f9f235fafba1587031eda353c1f1561cd6a6be78f4" +dependencies = [ + "memchr", +] + [[package]] name = "quick_cache" version = "0.5.2" @@ -3756,10 +4050,12 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls 0.24.1", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 0.25.4", "winreg", @@ -4637,7 +4933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5545940eb21920f4eb3fbdd4a805c68c9917e9ee95b805d7702c0a6cf61ed4d0" dependencies = [ "arrayvec", - "async-channel", + "async-channel 2.5.0", "bincode", "chrono", "dmp", @@ -4684,7 +4980,7 @@ dependencies = [ "ammonia", "any_ascii", "argon2", - "async-channel", + "async-channel 2.5.0", "async-executor", "async-graphql", "base64 0.21.7", @@ -5538,6 +5834,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" + [[package]] name = "vart" version = "0.8.1" @@ -6175,6 +6477,21 @@ dependencies = [ "tap", ] +[[package]] +name = "xml-rs" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 8a4082c..fa743f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ base64 = "0.22.1" sha2 = "0.10.8" hyper = "1.6.0" hyper-util = "0.1.0" +minio = "0.1.0" async-trait = "0.1.77" tokio-test = "0.4.3" @@ -63,7 +64,7 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tokio-tungstenite = "0.23" url = "2.5" futures-util = "0.3" - +http = "0.2" tests = { path = "./tests" } imphnen-iam = { path = "./imphnen-iam" } imphnen-cms = { path = "./imphnen-cms" } diff --git a/imphnen-backend/src/bin/seed_users.rs b/imphnen-backend/src/bin/seed_users.rs index 3052e59..50db3f4 100644 --- a/imphnen-backend/src/bin/seed_users.rs +++ b/imphnen-backend/src/bin/seed_users.rs @@ -71,6 +71,7 @@ async fn main() -> Result<(), Box> { skills: Some(vec!["JavaScript".into(), "React".into(), "Node.js".into()]), experience: None, education: None, + career_status: Some("Senior Developer".into()), role: Thing::from(("app_roles", role_id)), created_at: get_iso_date(), updated_at: get_iso_date(), diff --git a/imphnen-iam/src/v1/auth/auth_repository.rs b/imphnen-iam/src/v1/auth/auth_repository.rs index 0ac14d8..671e740 100644 --- a/imphnen-iam/src/v1/auth/auth_repository.rs +++ b/imphnen-iam/src/v1/auth/auth_repository.rs @@ -116,6 +116,7 @@ impl<'a> AuthRepository<'a> { skills: None, experience: None, education: None, + career_status: None, password: String::new(), role: role_detail_query_dto, created_at: String::new(), diff --git a/imphnen-iam/src/v1/permissions/permissions_guard.rs b/imphnen-iam/src/v1/permissions/permissions_guard.rs index d478aa6..4c5572f 100644 --- a/imphnen-iam/src/v1/permissions/permissions_guard.rs +++ b/imphnen-iam/src/v1/permissions/permissions_guard.rs @@ -1,5 +1,5 @@ use super::PermissionsEnum; -use crate::{AppState, AuthRepository, common_response, extract_email, extract_email_async}; +use crate::{AppState, AuthRepository, common_response, extract_email, extract_email_async, UsersDetailQueryDto}; use axum::{ http::{HeaderMap, StatusCode}, response::Response, @@ -9,7 +9,7 @@ pub async fn permissions_guard( headers: &HeaderMap, state: AppState, required_permissions: Vec, -) -> Result<(), Response> { +) -> Result { let auth_repo = AuthRepository::new(&state); // Try synchronous email extraction first (for internal JWT tokens) @@ -38,9 +38,8 @@ pub async fn permissions_guard( "User session expired or not found", ) })?; - let role = raw_user.role; let role_permissions: Vec = - role.permissions.into_iter().map(|perm| perm.name).collect(); + raw_user.role.permissions.iter().map(|perm| perm.name.clone()).collect(); for required in &required_permissions { let required_str = required.to_string(); @@ -52,5 +51,5 @@ pub async fn permissions_guard( )); } } - Ok(()) + Ok(raw_user) } diff --git a/imphnen-iam/src/v1/users/mod.rs b/imphnen-iam/src/v1/users/mod.rs index d2001d6..fd945b4 100644 --- a/imphnen-iam/src/v1/users/mod.rs +++ b/imphnen-iam/src/v1/users/mod.rs @@ -25,4 +25,5 @@ pub fn users_router() -> Router { .route("/detail/{id}", get(get_user_by_id)) .route("/update/{id}", put(put_update_user)) .route("/update/me", put(put_update_user_me)) + .route("/upload", post(upload_file)) } diff --git a/imphnen-iam/src/v1/users/users_controller.rs b/imphnen-iam/src/v1/users/users_controller.rs index 358409e..f322174 100644 --- a/imphnen-iam/src/v1/users/users_controller.rs +++ b/imphnen-iam/src/v1/users/users_controller.rs @@ -3,7 +3,7 @@ use crate::{ MessageResponseDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto, UsersCreateRequestDto, UsersDetailItemDto, permissions_guard, }; -use axum::extract::{Path}; // Removed Query +use axum::extract::{Path, Multipart}; use axum::http::HeaderMap; use axum::response::IntoResponse; use axum::{Extension, Json}; @@ -72,7 +72,7 @@ pub async fn get_user_by_id( match permissions_guard( &headers, state.clone(), - vec![PermissionsEnum::ReadDetailUsers], + vec![PermissionsEnum::ReadListUsers], ) .await { @@ -110,7 +110,7 @@ pub async fn get_user_me( path = "/v1/users/create", request_body = UsersCreateRequestDto, responses( - (status = 201, description = "Create new user", body = MessageResponseDto) + (status = 200, description = "Create new user", body = ResponseSuccessDto) ), tag = "Users" )] @@ -137,9 +137,12 @@ pub async fn post_create_user( ("Bearer" = []) ), path = "/v1/users/update/{id}", + params( + ("id" = String, Path, description = "User ID") + ), request_body = UsersUpdateRequestDto, responses( - (status = 200, description = "Update user", body = MessageResponseDto) + (status = 200, description = "Update user", body = ResponseSuccessDto) ), tag = "Users" )] @@ -169,7 +172,7 @@ pub async fn put_update_user( path = "/v1/users/update/me", request_body = UsersUpdateRequestDto, responses( - (status = 200, description = "Update user me", body = MessageResponseDto) + (status = 200, description = "Update current user", body = ResponseSuccessDto) ), tag = "Users" )] @@ -190,9 +193,12 @@ pub async fn put_update_user_me( ("Bearer" = []) ), path = "/v1/users/activate/{id}", + params( + ("id" = String, Path, description = "User ID") + ), request_body = UsersActiveInactiveRequestDto, responses( - (status = 200, description = "Set user active/inactive", body = MessageResponseDto) + (status = 200, description = "Set user active status", body = MessageResponseDto) ), tag = "Users" )] @@ -241,3 +247,42 @@ pub async fn delete_user( Err(response) => response, } } + +#[utoipa::path( + post, + security( + ("Bearer" = []) + ), + path = "/v1/users/upload", + request_body(content = String, description = "Upload file", content_type = "multipart/form-data"), + responses( + (status = 200, description = "Upload file successfully", body = ResponseSuccessDto), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + tag = "Users" +)] +pub async fn upload_file( + headers: HeaderMap, + Extension(state): Extension, + multipart: Multipart, +) -> impl IntoResponse { + // Check authentication first + match permissions_guard( + &headers, + state.clone(), + vec![], // No specific permission needed, just authentication + ) + .await + { + Ok(user) => { + // Extract user ID from user data + let user_id = user.id.to_string(); + + // Process upload - don't use match here since it returns Response directly + UsersService::upload_file(&state, user_id, multipart).await + }, + Err(response) => response, + } +} diff --git a/imphnen-iam/src/v1/users/users_dto.rs b/imphnen-iam/src/v1/users/users_dto.rs index 33b9df5..b60dc6b 100644 --- a/imphnen-iam/src/v1/users/users_dto.rs +++ b/imphnen-iam/src/v1/users/users_dto.rs @@ -145,6 +145,8 @@ pub struct UsersUpdateRequestDto { pub experience: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub education: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub career_status: Option, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] @@ -173,6 +175,7 @@ pub struct UsersDetailItemDto { pub skills: Option>, pub experience: Option>, pub education: Option>, + pub career_status: Option, pub created_at: String, pub updated_at: String, } @@ -204,6 +207,7 @@ impl UsersDetailItemDto { skills: dto.skills.clone(), experience: dto.experience.clone(), education: dto.education.clone(), + career_status: dto.career_status.clone(), created_at: dto.created_at.clone(), updated_at: dto.updated_at.clone(), } @@ -235,6 +239,7 @@ impl UsersDetailItemDto { skills: schema.skills.clone(), experience: schema.experience.clone(), education: schema.education.clone(), + career_status: schema.career_status.clone(), created_at: schema.created_at.clone(), updated_at: schema.updated_at.clone(), } @@ -309,6 +314,7 @@ pub struct UsersDetailQueryDto { pub skills: Option>, pub experience: Option>, pub education: Option>, + pub career_status: Option, pub password: String, pub role: RolesDetailQueryDto, pub created_at: String, @@ -343,6 +349,7 @@ impl UsersDetailQueryDto { skills: self.skills.clone(), experience: self.experience.clone(), education: self.education.clone(), + career_status: self.career_status.clone(), is_deleted: self.is_deleted, password: self.password.clone(), birthdate: self.birthdate.clone(), @@ -379,6 +386,7 @@ impl From<&UsersDetailItemDto> for UsersDetailQueryDto { skills: dto.skills.clone(), experience: dto.experience.clone(), education: dto.education.clone(), + career_status: dto.career_status.clone(), password: String::new(), role: RolesDetailQueryDto::default(), created_at: dto.created_at.clone(), diff --git a/imphnen-iam/src/v1/users/users_schema.rs b/imphnen-iam/src/v1/users/users_schema.rs index cbada14..521b2f6 100644 --- a/imphnen-iam/src/v1/users/users_schema.rs +++ b/imphnen-iam/src/v1/users/users_schema.rs @@ -52,6 +52,8 @@ pub struct UsersSchema { pub experience: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub education: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub career_status: Option, pub role: Thing, pub created_at: String, pub updated_at: String, @@ -89,6 +91,7 @@ impl Default for UsersSchema { skills: None, experience: None, education: None, + career_status: None, role: make_thing( &ResourceEnum::Roles.to_string(), "5713cb37-dc02-4e87-8048-d7a41d352059", @@ -127,6 +130,7 @@ impl UsersSchema { skills: dto.skills, experience: dto.experience, education: dto.education, + career_status: dto.career_status, password: dto.password, created_at: dto.created_at, updated_at: dto.updated_at, @@ -219,6 +223,9 @@ impl UsersSchema { if let Some(education) = user.education { schema.education = Some(education); } + if let Some(career_status) = user.career_status { + schema.career_status = Some(career_status); + } if let Some(avatar) = user.avatar { schema.avatar = Some(avatar); } @@ -256,6 +263,7 @@ impl UsersSchema { skills: None, experience: None, education: None, + career_status: None, avatar: user.avatar, is_deleted: false, role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id), diff --git a/imphnen-iam/src/v1/users/users_service.rs b/imphnen-iam/src/v1/users/users_service.rs index 148a0d9..a0c3e32 100644 --- a/imphnen-iam/src/v1/users/users_service.rs +++ b/imphnen-iam/src/v1/users/users_service.rs @@ -10,7 +10,7 @@ use crate::{ success_response, validate_request, }; use axum::http::HeaderMap; -use axum::{http::StatusCode, response::Response}; +use axum::{http::StatusCode, response::Response, extract::Multipart}; use imphnen_libs::{ResourceEnum, hash_password, verify_password, surrealdb_init_ws, surrealdb_init_mem}; use imphnen_utils::make_thing; use uuid::Uuid; @@ -18,6 +18,7 @@ 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 { @@ -35,6 +36,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { async fn get_user_by_email(&self, email: &str) -> Result>; async fn create_user_by_dto(&self, new_user: CreateUserDto) -> Result; async fn update_user_avatar(&self, email: &str, avatar_url: Option) -> Result<()>; + async fn upload_file(state: &AppState, user_id: String, multipart: Multipart) -> Response; } #[derive(Clone)] @@ -362,4 +364,43 @@ impl UsersServiceTrait for UsersService { Err(e) => Err(anyhow::anyhow!("Failed to update user avatar: {}", e)), } } + + async fn upload_file(_state: &AppState, _user_id: String, mut multipart: Multipart) -> Response { + // Temporary implementation without MinIO to fix compilation + // Process multipart form + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let name = field.name().unwrap_or("").to_string(); + let filename = field.file_name().map(|s| s.to_string()).unwrap_or_else(|| "unnamed".to_string()); + let content_type = field.content_type().map(|s| s.to_string()).unwrap_or_else(|| "application/octet-stream".to_string()); + + // Get file data + let data = match field.bytes().await { + Ok(bytes) => bytes, + Err(_) => { + return common_response( + StatusCode::BAD_REQUEST, + "Failed to read file data", + ); + } + }; + + // For now, just return basic file info + let response_data = json!({ + "field_name": name, + "filename": filename, + "content_type": content_type, + "size": data.len(), + "message": "File received successfully (MinIO upload will be implemented later)" + }); + + return success_response(ResponseSuccessDto { + data: response_data, + }); + } + + common_response( + StatusCode::BAD_REQUEST, + "No file provided", + ) + } } diff --git a/imphnen-libs/Cargo.toml b/imphnen-libs/Cargo.toml index d22397d..87788ef 100644 --- a/imphnen-libs/Cargo.toml +++ b/imphnen-libs/Cargo.toml @@ -16,3 +16,8 @@ chrono.workspace = true surrealdb.workspace = true jsonwebtoken.workspace = true dotenvy.workspace = true +anyhow.workspace = true +uuid.workspace = true +base64.workspace = true +minio.workspace = true +http.workspace = true diff --git a/imphnen-libs/src/lib.rs b/imphnen-libs/src/lib.rs index 0678288..50d0efe 100644 --- a/imphnen-libs/src/lib.rs +++ b/imphnen-libs/src/lib.rs @@ -5,6 +5,7 @@ pub mod axum; pub mod enviroment; pub mod jsonwebtoken; pub mod lettre; +pub mod minio; pub mod surrealdb; pub use argon::*; @@ -13,4 +14,5 @@ pub use enviroment::*; pub use imphnen_entities::*; pub use jsonwebtoken::*; pub use lettre::*; +pub use minio::*; pub use surrealdb::*; diff --git a/imphnen-libs/src/minio.rs b/imphnen-libs/src/minio.rs new file mode 100644 index 0000000..c57f527 --- /dev/null +++ b/imphnen-libs/src/minio.rs @@ -0,0 +1,299 @@ +use std::io::Cursor; +use anyhow::{Result, bail}; +use base64::{Engine as _, engine::general_purpose}; +use minio::s3::args::{BucketExistsArgs, GetPresignedObjectUrlArgs, MakeBucketArgs, PutObjectArgs}; +use minio::s3::client::Client; +use minio::s3::creds::StaticProvider; +use minio::s3::http::BaseUrl; +use uuid::Uuid; + +pub struct MinioService { + client: Client, + bucket_name: String, +} + +impl MinioService { + pub async fn new(endpoint: &str, access_key: &str, secret_key: &str, bucket_name: &str) -> Result { + let base_url = endpoint.parse::()?; + let static_provider = StaticProvider::new(access_key, secret_key, None); + let client = Client::new( + base_url.clone(), + Some(Box::new(static_provider)), + None, + None, + )?; + + let service = Self { + client, + bucket_name: bucket_name.to_string(), + }; + + // Ensure bucket exists + service.ensure_bucket_exists().await?; + + Ok(service) + } + + async fn ensure_bucket_exists(&self) -> Result<()> { + let exists_args = BucketExistsArgs::new(&self.bucket_name)?; + let exists = self.client.bucket_exists(&exists_args).await?; + + if !exists { + let make_bucket_args = MakeBucketArgs::new(&self.bucket_name)?; + self.client.make_bucket(&make_bucket_args).await?; + } + + Ok(()) + } + + pub async fn upload_file( + &self, + file_data: &[u8], + content_type: &str, + folder: &str, + original_filename: &str, + ) -> Result { + self.ensure_bucket_exists().await?; + + // Validate file type based on content type + Self::validate_file_type(content_type, file_data)?; + + // Generate unique filename + let file_extension = Self::get_file_extension(original_filename); + let unique_filename = format!("{}/{}.{}", folder, Uuid::new_v4(), file_extension); + + // Upload file + let mut cursor = Cursor::new(file_data); + let mut put_object_args = PutObjectArgs::new( + &self.bucket_name, + &unique_filename, + &mut cursor, + Some(file_data.len()), + None, // No additional metadata size + )?; + + self.client.put_object(&mut put_object_args).await?; + + Ok(unique_filename) + } + + pub async fn upload_base64_file( + &self, + base64_data: &str, + content_type: &str, + folder: &str, + original_filename: &str, + ) -> Result { + // Remove data URL prefix if present + let base64_clean = if base64_data.contains(',') { + base64_data.split(',').nth(1).unwrap_or(base64_data) + } else { + base64_data + }; + + // Decode base64 + let file_data = general_purpose::STANDARD.decode(base64_clean) + .map_err(|e| anyhow::anyhow!("Invalid base64 data: {}", e))?; + + self.upload_file(&file_data, content_type, folder, original_filename).await + } + + pub async fn get_presigned_url(&self, object_name: &str, _expiry_seconds: u32) -> Result { + use http::Method; + + let get_presigned_args = GetPresignedObjectUrlArgs::new( + &self.bucket_name, + object_name, + Method::GET, + )?; + + let url = self.client.get_presigned_object_url(&get_presigned_args).await?; + Ok(url.url) + } + + pub async fn delete_file(&self, object_name: &str) -> Result<()> { + use minio::s3::args::RemoveObjectArgs; + let remove_args = RemoveObjectArgs::new(&self.bucket_name, object_name)?; + self.client.remove_object(&remove_args).await?; + Ok(()) + } + + fn validate_file_type(content_type: &str, file_data: &[u8]) -> Result<()> { + // Validate file size (10MB max) + const MAX_SIZE: usize = 10 * 1024 * 1024; // 10MB + if file_data.len() > MAX_SIZE { + bail!("File size exceeds 10MB limit"); + } + + // Validate content type and magic numbers + match content_type { + "image/jpeg" | "image/jpg" => { + if !file_data.starts_with(&[0xFF, 0xD8, 0xFF]) { + bail!("Invalid JPEG file"); + } + }, + "image/png" => { + if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { + bail!("Invalid PNG file"); + } + }, + "application/pdf" => { + if !file_data.starts_with(b"%PDF") { + bail!("Invalid PDF file"); + } + }, + _ => { + bail!("Unsupported file type: {}", content_type); + } + } + + Ok(()) + } + + fn get_file_extension(filename: &str) -> String { + std::path::Path::new(filename) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("bin") + .to_lowercase() + } +} + +#[derive(Debug, Clone)] +pub struct UploadResult { + pub object_name: String, + pub url: String, + pub size: usize, + pub content_type: String, +} + +// Configuration struct for easier management +#[derive(Debug, Clone)] +pub struct MinioConfig { + pub endpoint: String, + pub access_key: String, + pub secret_key: String, + pub bucket_name: String, + pub secure: bool, +} + +impl MinioConfig { + pub fn from_env() -> Result { + let endpoint = std::env::var("MINIO_ENDPOINT") + .unwrap_or_else(|_| "localhost:9000".to_string()); + let access_key = std::env::var("MINIO_ACCESS_KEY") + .map_err(|_| anyhow::anyhow!("MINIO_ACCESS_KEY environment variable not set"))?; + let secret_key = std::env::var("MINIO_SECRET_KEY") + .map_err(|_| anyhow::anyhow!("MINIO_SECRET_KEY environment variable not set"))?; + let bucket_name = std::env::var("MINIO_BUCKET") + .unwrap_or_else(|_| "imphnen-uploads".to_string()); + let secure = std::env::var("MINIO_SECURE") + .unwrap_or_else(|_| "false".to_string()) + .parse() + .unwrap_or(false); + + Ok(Self { + endpoint, + access_key, + secret_key, + bucket_name, + secure, + }) + } + + pub fn endpoint_url(&self) -> String { + if self.secure { + format!("https://{}", self.endpoint) + } else { + format!("http://{}", self.endpoint) + } + } +} + +// File type enumeration for better organization +#[derive(Debug, Clone)] +pub enum FileType { + ProfileImage, + CvResume, + Document, +} + +impl FileType { + pub fn as_folder(&self) -> &str { + match self { + FileType::ProfileImage => "profiles", + FileType::CvResume => "resumes", + FileType::Document => "documents", + } + } + + pub fn max_size(&self) -> usize { + match self { + FileType::ProfileImage => 5 * 1024 * 1024, // 5MB + FileType::CvResume => 10 * 1024 * 1024, // 10MB + FileType::Document => 10 * 1024 * 1024, // 10MB + } + } + + pub fn allowed_types(&self) -> Vec<&str> { + match self { + FileType::ProfileImage => vec!["image/jpeg", "image/png", "image/webp"], + FileType::CvResume => vec!["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + FileType::Document => vec!["application/pdf", "image/jpeg", "image/png"], + } + } +} + +// Upload request structure +#[derive(Debug, Clone)] +pub struct UploadRequest { + pub user_id: String, + pub file_type: FileType, + pub filename: String, + pub content_type: String, + pub data: Vec, +} + +// File metadata response +#[derive(Debug, Clone)] +pub struct FileMetadata { + pub filename: String, + pub content_type: String, + pub size: usize, + pub path: String, + pub url: String, +} + +// Helper function to decode base64 file data +pub fn decode_base64_file(base64_data: &str) -> Result> { + // Remove data URL prefix if present (e.g., "data:image/jpeg;base64,") + let clean_data = if base64_data.contains(',') { + base64_data.split(',').nth(1).unwrap_or(base64_data) + } else { + base64_data + }; + + general_purpose::STANDARD + .decode(clean_data) + .map_err(|e| anyhow::anyhow!("Failed to decode base64 data: {}", e)) +} + +// Helper function to extract content type from data URL +pub fn extract_content_type_from_data_url(data_url: &str) -> Option { + if data_url.starts_with("data:") { + if let Some(type_part) = data_url.split(';').next() { + return Some(type_part.replace("data:", "")); + } + } + None +} + +// Helper function to create MinIO service from config +pub async fn create_minio_service_from_config(config: MinioConfig) -> Result { + MinioService::new( + &config.endpoint_url(), + &config.access_key, + &config.secret_key, + &config.bucket_name, + ).await +} diff --git a/tests/src/lib.rs b/tests/src/lib.rs index a80d85a..772fcde 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -43,6 +43,7 @@ pub fn create_test_user( skills: None, experience: None, education: None, + career_status: None, role: make_thing("app_roles", role_id), created_at: get_iso_date(), updated_at: get_iso_date(), diff --git a/tests/src/mock_test.rs b/tests/src/mock_test.rs index d383740..b95db27 100644 --- a/tests/src/mock_test.rs +++ b/tests/src/mock_test.rs @@ -173,6 +173,7 @@ pub async fn seed_users_for_test( skills: None, experience: None, education: None, + career_status: None, role: Thing::from(("app_roles", role_id)), created_at: get_iso_date(), updated_at: get_iso_date(),