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.
This commit is contained in:
MythEclipse
2025-08-14 13:16:39 +07:00
parent f6232832af
commit 5f51dd281d
16 changed files with 770 additions and 17 deletions
+23
View File
@@ -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_ENV=development
RUST_LOG=debug RUST_LOG=debug
PORT=4099 PORT=4099
Generated
+321 -4
View File
@@ -42,7 +42,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dde2a385b82232b559baeec740c37809051c596f9b56e7da0d0da2c8e8f54f6" checksum = "2dde2a385b82232b559baeec740c37809051c596f9b56e7da0d0da2c8e8f54f6"
dependencies = [ dependencies = [
"async-channel", "async-channel 2.5.0",
"num_cpus", "num_cpus",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
@@ -247,6 +247,27 @@ dependencies = [
"serde_json", "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]] [[package]]
name = "async-channel" name = "async-channel"
version = "2.5.0" version = "2.5.0"
@@ -273,6 +294,22 @@ dependencies = [
"slab", "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]] [[package]]
name = "async-graphql" name = "async-graphql"
version = "7.0.17" version = "7.0.17"
@@ -344,6 +381,73 @@ dependencies = [
"serde_json", "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]] [[package]]
name = "async-stream" name = "async-stream"
version = "0.3.6" version = "0.3.6"
@@ -394,6 +498,12 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "auto-future" name = "auto-future"
version = "1.0.0" version = "1.0.0"
@@ -622,6 +732,19 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "blowfish" name = "blowfish"
version = "0.9.1" version = "0.9.1"
@@ -915,6 +1038,21 @@ dependencies = [
"libc", "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]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -1052,6 +1190,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "derive_arbitrary" name = "derive_arbitrary"
version = "1.4.1" version = "1.4.1"
@@ -1262,6 +1411,12 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.0" version = "5.4.0"
@@ -1279,7 +1434,7 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [ dependencies = [
"event-listener", "event-listener 5.4.0",
"pin-project-lite", "pin-project-lite",
] ]
@@ -1588,6 +1743,18 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 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]] [[package]]
name = "h2" name = "h2"
version = "0.3.27" version = "0.3.27"
@@ -2238,18 +2405,23 @@ dependencies = [
name = "imphnen-libs" name = "imphnen-libs"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"argon2", "argon2",
"axum", "axum",
"base64 0.22.1",
"chrono", "chrono",
"dotenvy", "dotenvy",
"http 0.2.12",
"imphnen-entities", "imphnen-entities",
"jsonwebtoken", "jsonwebtoken",
"lettre", "lettre",
"log", "log",
"minio",
"once_cell", "once_cell",
"serde", "serde",
"surrealdb", "surrealdb",
"tokio", "tokio",
"uuid",
] ]
[[package]] [[package]]
@@ -2451,6 +2623,15 @@ dependencies = [
"simple_asn1", "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]] [[package]]
name = "lalrpop" name = "lalrpop"
version = "0.20.2" version = "0.20.2"
@@ -2595,6 +2776,9 @@ name = "log"
version = "0.4.27" version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
dependencies = [
"value-bag",
]
[[package]] [[package]]
name = "lru" name = "lru"
@@ -2680,6 +2864,12 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.5" version = "2.7.5"
@@ -2725,6 +2915,42 @@ dependencies = [
"unicase", "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]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2789,6 +3015,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "multimap"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1a5d38b9b352dbd913288736af36af41c48d61b1a8cd34bcecd727561b7d511"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "nanoid" name = "nanoid"
version = "0.4.0" version = "0.4.0"
@@ -3059,6 +3294,18 @@ dependencies = [
"vcpkg", "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]] [[package]]
name = "overload" name = "overload"
version = "0.1.1" version = "0.1.1"
@@ -3248,12 +3495,50 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.11.1" version = "1.11.1"
@@ -3410,6 +3695,15 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "quick_cache" name = "quick_cache"
version = "0.5.2" version = "0.5.2"
@@ -3756,10 +4050,12 @@ dependencies = [
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls 0.24.1", "tokio-rustls 0.24.1",
"tokio-util",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
"webpki-roots 0.25.4", "webpki-roots 0.25.4",
"winreg", "winreg",
@@ -4637,7 +4933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5545940eb21920f4eb3fbdd4a805c68c9917e9ee95b805d7702c0a6cf61ed4d0" checksum = "5545940eb21920f4eb3fbdd4a805c68c9917e9ee95b805d7702c0a6cf61ed4d0"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"async-channel", "async-channel 2.5.0",
"bincode", "bincode",
"chrono", "chrono",
"dmp", "dmp",
@@ -4684,7 +4980,7 @@ dependencies = [
"ammonia", "ammonia",
"any_ascii", "any_ascii",
"argon2", "argon2",
"async-channel", "async-channel 2.5.0",
"async-executor", "async-executor",
"async-graphql", "async-graphql",
"base64 0.21.7", "base64 0.21.7",
@@ -5538,6 +5834,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "value-bag"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
[[package]] [[package]]
name = "vart" name = "vart"
version = "0.8.1" version = "0.8.1"
@@ -6175,6 +6477,21 @@ dependencies = [
"tap", "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]] [[package]]
name = "yansi" name = "yansi"
version = "1.0.1" version = "1.0.1"
+2 -1
View File
@@ -53,6 +53,7 @@ base64 = "0.22.1"
sha2 = "0.10.8" sha2 = "0.10.8"
hyper = "1.6.0" hyper = "1.6.0"
hyper-util = "0.1.0" hyper-util = "0.1.0"
minio = "0.1.0"
async-trait = "0.1.77" async-trait = "0.1.77"
tokio-test = "0.4.3" tokio-test = "0.4.3"
@@ -63,7 +64,7 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
tokio-tungstenite = "0.23" tokio-tungstenite = "0.23"
url = "2.5" url = "2.5"
futures-util = "0.3" futures-util = "0.3"
http = "0.2"
tests = { path = "./tests" } tests = { path = "./tests" }
imphnen-iam = { path = "./imphnen-iam" } imphnen-iam = { path = "./imphnen-iam" }
imphnen-cms = { path = "./imphnen-cms" } imphnen-cms = { path = "./imphnen-cms" }
+1
View File
@@ -71,6 +71,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
skills: Some(vec!["JavaScript".into(), "React".into(), "Node.js".into()]), skills: Some(vec!["JavaScript".into(), "React".into(), "Node.js".into()]),
experience: None, experience: None,
education: None, education: None,
career_status: Some("Senior Developer".into()),
role: Thing::from(("app_roles", role_id)), role: Thing::from(("app_roles", role_id)),
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
@@ -116,6 +116,7 @@ impl<'a> AuthRepository<'a> {
skills: None, skills: None,
experience: None, experience: None,
education: None, education: None,
career_status: None,
password: String::new(), password: String::new(),
role: role_detail_query_dto, role: role_detail_query_dto,
created_at: String::new(), created_at: String::new(),
@@ -1,5 +1,5 @@
use super::PermissionsEnum; 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::{ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Response, response::Response,
@@ -9,7 +9,7 @@ pub async fn permissions_guard(
headers: &HeaderMap, headers: &HeaderMap,
state: AppState, state: AppState,
required_permissions: Vec<PermissionsEnum>, required_permissions: Vec<PermissionsEnum>,
) -> Result<(), Response> { ) -> Result<UsersDetailQueryDto, Response> {
let auth_repo = AuthRepository::new(&state); let auth_repo = AuthRepository::new(&state);
// Try synchronous email extraction first (for internal JWT tokens) // Try synchronous email extraction first (for internal JWT tokens)
@@ -38,9 +38,8 @@ pub async fn permissions_guard(
"User session expired or not found", "User session expired or not found",
) )
})?; })?;
let role = raw_user.role;
let role_permissions: Vec<String> = let role_permissions: Vec<String> =
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 { for required in &required_permissions {
let required_str = required.to_string(); let required_str = required.to_string();
@@ -52,5 +51,5 @@ pub async fn permissions_guard(
)); ));
} }
} }
Ok(()) Ok(raw_user)
} }
+1
View File
@@ -25,4 +25,5 @@ pub fn users_router() -> Router {
.route("/detail/{id}", get(get_user_by_id)) .route("/detail/{id}", get(get_user_by_id))
.route("/update/{id}", put(put_update_user)) .route("/update/{id}", put(put_update_user))
.route("/update/me", put(put_update_user_me)) .route("/update/me", put(put_update_user_me))
.route("/upload", post(upload_file))
} }
+51 -6
View File
@@ -3,7 +3,7 @@ use crate::{
MessageResponseDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto, MessageResponseDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto,
UsersCreateRequestDto, UsersDetailItemDto, permissions_guard, UsersCreateRequestDto, UsersDetailItemDto, permissions_guard,
}; };
use axum::extract::{Path}; // Removed Query use axum::extract::{Path, Multipart};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::{Extension, Json}; use axum::{Extension, Json};
@@ -72,7 +72,7 @@ pub async fn get_user_by_id(
match permissions_guard( match permissions_guard(
&headers, &headers,
state.clone(), state.clone(),
vec![PermissionsEnum::ReadDetailUsers], vec![PermissionsEnum::ReadListUsers],
) )
.await .await
{ {
@@ -110,7 +110,7 @@ pub async fn get_user_me(
path = "/v1/users/create", path = "/v1/users/create",
request_body = UsersCreateRequestDto, request_body = UsersCreateRequestDto,
responses( responses(
(status = 201, description = "Create new user", body = MessageResponseDto) (status = 200, description = "Create new user", body = ResponseSuccessDto<UsersDetailItemDto>)
), ),
tag = "Users" tag = "Users"
)] )]
@@ -137,9 +137,12 @@ pub async fn post_create_user(
("Bearer" = []) ("Bearer" = [])
), ),
path = "/v1/users/update/{id}", path = "/v1/users/update/{id}",
params(
("id" = String, Path, description = "User ID")
),
request_body = UsersUpdateRequestDto, request_body = UsersUpdateRequestDto,
responses( responses(
(status = 200, description = "Update user", body = MessageResponseDto) (status = 200, description = "Update user", body = ResponseSuccessDto<UsersDetailItemDto>)
), ),
tag = "Users" tag = "Users"
)] )]
@@ -169,7 +172,7 @@ pub async fn put_update_user(
path = "/v1/users/update/me", path = "/v1/users/update/me",
request_body = UsersUpdateRequestDto, request_body = UsersUpdateRequestDto,
responses( responses(
(status = 200, description = "Update user me", body = MessageResponseDto) (status = 200, description = "Update current user", body = ResponseSuccessDto<UsersDetailItemDto>)
), ),
tag = "Users" tag = "Users"
)] )]
@@ -190,9 +193,12 @@ pub async fn put_update_user_me(
("Bearer" = []) ("Bearer" = [])
), ),
path = "/v1/users/activate/{id}", path = "/v1/users/activate/{id}",
params(
("id" = String, Path, description = "User ID")
),
request_body = UsersActiveInactiveRequestDto, request_body = UsersActiveInactiveRequestDto,
responses( responses(
(status = 200, description = "Set user active/inactive", body = MessageResponseDto) (status = 200, description = "Set user active status", body = MessageResponseDto)
), ),
tag = "Users" tag = "Users"
)] )]
@@ -241,3 +247,42 @@ pub async fn delete_user(
Err(response) => response, 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<serde_json::Value>),
(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<AppState>,
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,
}
}
+8
View File
@@ -145,6 +145,8 @@ pub struct UsersUpdateRequestDto {
pub experience: Option<Vec<ExperienceDto>>, pub experience: Option<Vec<ExperienceDto>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub education: Option<Vec<EducationDto>>, pub education: Option<Vec<EducationDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub career_status: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
@@ -173,6 +175,7 @@ pub struct UsersDetailItemDto {
pub skills: Option<Vec<String>>, pub skills: Option<Vec<String>>,
pub experience: Option<Vec<ExperienceDto>>, pub experience: Option<Vec<ExperienceDto>>,
pub education: Option<Vec<EducationDto>>, pub education: Option<Vec<EducationDto>>,
pub career_status: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -204,6 +207,7 @@ impl UsersDetailItemDto {
skills: dto.skills.clone(), skills: dto.skills.clone(),
experience: dto.experience.clone(), experience: dto.experience.clone(),
education: dto.education.clone(), education: dto.education.clone(),
career_status: dto.career_status.clone(),
created_at: dto.created_at.clone(), created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(), updated_at: dto.updated_at.clone(),
} }
@@ -235,6 +239,7 @@ impl UsersDetailItemDto {
skills: schema.skills.clone(), skills: schema.skills.clone(),
experience: schema.experience.clone(), experience: schema.experience.clone(),
education: schema.education.clone(), education: schema.education.clone(),
career_status: schema.career_status.clone(),
created_at: schema.created_at.clone(), created_at: schema.created_at.clone(),
updated_at: schema.updated_at.clone(), updated_at: schema.updated_at.clone(),
} }
@@ -309,6 +314,7 @@ pub struct UsersDetailQueryDto {
pub skills: Option<Vec<String>>, pub skills: Option<Vec<String>>,
pub experience: Option<Vec<ExperienceDto>>, pub experience: Option<Vec<ExperienceDto>>,
pub education: Option<Vec<EducationDto>>, pub education: Option<Vec<EducationDto>>,
pub career_status: Option<String>,
pub password: String, pub password: String,
pub role: RolesDetailQueryDto, pub role: RolesDetailQueryDto,
pub created_at: String, pub created_at: String,
@@ -343,6 +349,7 @@ impl UsersDetailQueryDto {
skills: self.skills.clone(), skills: self.skills.clone(),
experience: self.experience.clone(), experience: self.experience.clone(),
education: self.education.clone(), education: self.education.clone(),
career_status: self.career_status.clone(),
is_deleted: self.is_deleted, is_deleted: self.is_deleted,
password: self.password.clone(), password: self.password.clone(),
birthdate: self.birthdate.clone(), birthdate: self.birthdate.clone(),
@@ -379,6 +386,7 @@ impl From<&UsersDetailItemDto> for UsersDetailQueryDto {
skills: dto.skills.clone(), skills: dto.skills.clone(),
experience: dto.experience.clone(), experience: dto.experience.clone(),
education: dto.education.clone(), education: dto.education.clone(),
career_status: dto.career_status.clone(),
password: String::new(), password: String::new(),
role: RolesDetailQueryDto::default(), role: RolesDetailQueryDto::default(),
created_at: dto.created_at.clone(), created_at: dto.created_at.clone(),
+8
View File
@@ -52,6 +52,8 @@ pub struct UsersSchema {
pub experience: Option<Vec<ExperienceDto>>, pub experience: Option<Vec<ExperienceDto>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub education: Option<Vec<EducationDto>>, pub education: Option<Vec<EducationDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub career_status: Option<String>,
pub role: Thing, pub role: Thing,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
@@ -89,6 +91,7 @@ impl Default for UsersSchema {
skills: None, skills: None,
experience: None, experience: None,
education: None, education: None,
career_status: None,
role: make_thing( role: make_thing(
&ResourceEnum::Roles.to_string(), &ResourceEnum::Roles.to_string(),
"5713cb37-dc02-4e87-8048-d7a41d352059", "5713cb37-dc02-4e87-8048-d7a41d352059",
@@ -127,6 +130,7 @@ impl UsersSchema {
skills: dto.skills, skills: dto.skills,
experience: dto.experience, experience: dto.experience,
education: dto.education, education: dto.education,
career_status: dto.career_status,
password: dto.password, password: dto.password,
created_at: dto.created_at, created_at: dto.created_at,
updated_at: dto.updated_at, updated_at: dto.updated_at,
@@ -219,6 +223,9 @@ impl UsersSchema {
if let Some(education) = user.education { if let Some(education) = user.education {
schema.education = Some(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 { if let Some(avatar) = user.avatar {
schema.avatar = Some(avatar); schema.avatar = Some(avatar);
} }
@@ -256,6 +263,7 @@ impl UsersSchema {
skills: None, skills: None,
experience: None, experience: None,
education: None, education: None,
career_status: None,
avatar: user.avatar, avatar: user.avatar,
is_deleted: false, is_deleted: false,
role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id), role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id),
+42 -1
View File
@@ -10,7 +10,7 @@ use crate::{
success_response, validate_request, success_response, validate_request,
}; };
use axum::http::HeaderMap; 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_libs::{ResourceEnum, hash_password, verify_password, surrealdb_init_ws, surrealdb_init_mem};
use imphnen_utils::make_thing; use imphnen_utils::make_thing;
use uuid::Uuid; use uuid::Uuid;
@@ -18,6 +18,7 @@ use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use tracing::info; use tracing::info;
use crate::v1::users::users_dto::{UsersDetailItemDto as UserDto, UsersCreateRequestDto as CreateUserDto}; use crate::v1::users::users_dto::{UsersDetailItemDto as UserDto, UsersCreateRequestDto as CreateUserDto};
use serde_json::json;
#[async_trait] #[async_trait]
pub trait UsersServiceTrait: Send + Sync + 'static { 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<Option<UserDto>>; async fn get_user_by_email(&self, email: &str) -> Result<Option<UserDto>>;
async fn create_user_by_dto(&self, new_user: CreateUserDto) -> Result<UserDto>; async fn create_user_by_dto(&self, new_user: CreateUserDto) -> Result<UserDto>;
async fn update_user_avatar(&self, email: &str, avatar_url: Option<String>) -> Result<()>; async fn update_user_avatar(&self, email: &str, avatar_url: Option<String>) -> Result<()>;
async fn upload_file(state: &AppState, user_id: String, multipart: Multipart) -> Response;
} }
#[derive(Clone)] #[derive(Clone)]
@@ -362,4 +364,43 @@ impl UsersServiceTrait for UsersService {
Err(e) => Err(anyhow::anyhow!("Failed to update user avatar: {}", e)), 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",
)
}
} }
+5
View File
@@ -16,3 +16,8 @@ chrono.workspace = true
surrealdb.workspace = true surrealdb.workspace = true
jsonwebtoken.workspace = true jsonwebtoken.workspace = true
dotenvy.workspace = true dotenvy.workspace = true
anyhow.workspace = true
uuid.workspace = true
base64.workspace = true
minio.workspace = true
http.workspace = true
+2
View File
@@ -5,6 +5,7 @@ pub mod axum;
pub mod enviroment; pub mod enviroment;
pub mod jsonwebtoken; pub mod jsonwebtoken;
pub mod lettre; pub mod lettre;
pub mod minio;
pub mod surrealdb; pub mod surrealdb;
pub use argon::*; pub use argon::*;
@@ -13,4 +14,5 @@ pub use enviroment::*;
pub use imphnen_entities::*; pub use imphnen_entities::*;
pub use jsonwebtoken::*; pub use jsonwebtoken::*;
pub use lettre::*; pub use lettre::*;
pub use minio::*;
pub use surrealdb::*; pub use surrealdb::*;
+299
View File
@@ -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<Self> {
let base_url = endpoint.parse::<BaseUrl>()?;
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<String> {
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<String> {
// 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<String> {
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<Self> {
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<u8>,
}
// 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<Vec<u8>> {
// 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<String> {
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> {
MinioService::new(
&config.endpoint_url(),
&config.access_key,
&config.secret_key,
&config.bucket_name,
).await
}
+1
View File
@@ -43,6 +43,7 @@ pub fn create_test_user(
skills: None, skills: None,
experience: None, experience: None,
education: None, education: None,
career_status: None,
role: make_thing("app_roles", role_id), role: make_thing("app_roles", role_id),
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
+1
View File
@@ -173,6 +173,7 @@ pub async fn seed_users_for_test(
skills: None, skills: None,
experience: None, experience: None,
education: None, education: None,
career_status: None,
role: Thing::from(("app_roles", role_id)), role: Thing::from(("app_roles", role_id)),
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),