diff --git a/.env.example b/.env.example index 5fbc377..e0eb6fb 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,7 @@ 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_SECURE=false GOOGLE_CLIENT_ID="your_google_client_id" GOOGLE_CLIENT_SECRET="your_google_client_secret" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 62d2697..ce5065e 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 2.5.0", + "async-channel", "num_cpus", "thiserror 1.0.69", "tokio", @@ -247,27 +247,6 @@ 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" @@ -294,22 +273,6 @@ 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" @@ -381,73 +344,6 @@ 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" @@ -498,12 +394,6 @@ 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" @@ -732,19 +622,6 @@ 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" @@ -1038,21 +915,6 @@ 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" @@ -1190,17 +1052,6 @@ 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" @@ -1411,12 +1262,6 @@ 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" @@ -1434,7 +1279,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.0", + "event-listener", "pin-project-lite", ] @@ -1743,18 +1588,6 @@ 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" @@ -2411,16 +2244,19 @@ dependencies = [ "base64 0.22.1", "chrono", "dotenvy", - "http 0.2.12", + "hex", + "hmac", "imphnen-entities", "jsonwebtoken", "lettre", "log", - "minio", "once_cell", + "reqwest 0.11.27", "serde", + "sha2", "surrealdb", "tokio", + "urlencoding", "uuid", ] @@ -2623,15 +2459,6 @@ 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" @@ -2776,9 +2603,6 @@ name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" -dependencies = [ - "value-bag", -] [[package]] name = "lru" @@ -2864,12 +2688,6 @@ 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" @@ -2915,42 +2733,6 @@ 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" @@ -3015,15 +2797,6 @@ 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" @@ -3294,18 +3067,6 @@ 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" @@ -3495,50 +3256,12 @@ 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" @@ -3695,15 +3418,6 @@ 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" @@ -4050,12 +3764,10 @@ 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", @@ -4933,7 +4645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5545940eb21920f4eb3fbdd4a805c68c9917e9ee95b805d7702c0a6cf61ed4d0" dependencies = [ "arrayvec", - "async-channel 2.5.0", + "async-channel", "bincode", "chrono", "dmp", @@ -4980,7 +4692,7 @@ dependencies = [ "ammonia", "any_ascii", "argon2", - "async-channel 2.5.0", + "async-channel", "async-executor", "async-graphql", "base64 0.21.7", @@ -5834,12 +5546,6 @@ 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" @@ -6477,21 +6183,6 @@ 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 fa743f5..bdf3f92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,9 @@ strum = { version = "0.27.1", features = ["derive"] } strum_macros = "0.27.1" base64 = "0.22.1" sha2 = "0.10.8" +hmac = "0.12" +hex = "0.4" +urlencoding = "2.1" hyper = "1.6.0" hyper-util = "0.1.0" minio = "0.1.0" diff --git a/imphnen-gateway/src/docs.rs b/imphnen-gateway/src/docs.rs index dfad73e..059b8ca 100644 --- a/imphnen-gateway/src/docs.rs +++ b/imphnen-gateway/src/docs.rs @@ -29,6 +29,7 @@ use imphnen_iam::{ RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto, UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users, }; +use imphnen_iam::users::users_controller::FileUploadSchema; use utoipa::{ Modify, OpenApi, openapi::security::{Http, HttpAuthScheme, SecurityScheme}, @@ -115,6 +116,7 @@ use utoipa::{ UsersListItemDto, UsersUpdateRequestDto, UsersCreateRequestDto, + FileUploadSchema, GachaClaimItemDto, GachaClaimRequestDto, GachaItemDto, diff --git a/imphnen-iam/src/v1/users/users_controller.rs b/imphnen-iam/src/v1/users/users_controller.rs index f322174..414baa0 100644 --- a/imphnen-iam/src/v1/users/users_controller.rs +++ b/imphnen-iam/src/v1/users/users_controller.rs @@ -7,12 +7,22 @@ use axum::extract::{Path, Multipart}; use axum::http::HeaderMap; use axum::response::IntoResponse; use axum::{Extension, Json}; +use utoipa::ToSchema; +use serde::{Deserialize, Serialize}; use super::{ UsersActiveInactiveRequestDto, UsersListItemDto, UsersUpdateRequestDto, }; use crate::v1::users::users_service::{UsersServiceTrait, UsersService}; +#[derive(Serialize, Deserialize, ToSchema)] +#[schema(description = "File upload form data for multipart/form-data")] +pub struct FileUploadSchema { + /// Binary file data to upload + #[schema(format = "binary")] + pub file: String, +} + #[utoipa::path( get, security( @@ -254,7 +264,11 @@ pub async fn delete_user( ("Bearer" = []) ), path = "/v1/users/upload", - request_body(content = String, description = "Upload file", content_type = "multipart/form-data"), + request_body( + content = FileUploadSchema, + description = "Upload file with multipart form data. Only 'file' field is required - file type will be detected automatically from the uploaded file.", + content_type = "multipart/form-data" + ), responses( (status = 200, description = "Upload file successfully", body = ResponseSuccessDto), (status = 400, description = "Bad request"), diff --git a/imphnen-iam/src/v1/users/users_service.rs b/imphnen-iam/src/v1/users/users_service.rs index a0c3e32..ae20524 100644 --- a/imphnen-iam/src/v1/users/users_service.rs +++ b/imphnen-iam/src/v1/users/users_service.rs @@ -11,7 +11,7 @@ use crate::{ }; use axum::http::HeaderMap; 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, 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 anyhow::Result; @@ -42,6 +42,9 @@ pub trait UsersServiceTrait: Send + Sync + 'static { #[derive(Clone)] pub struct UsersService; +impl UsersService { +} + #[async_trait] impl UsersServiceTrait for UsersService { async fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Response { @@ -365,42 +368,200 @@ impl UsersServiceTrait for UsersService { } } - async fn upload_file(_state: &AppState, _user_id: String, mut multipart: Multipart) -> Response { - // Temporary implementation without MinIO to fix compilation + 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(); - 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", - ); + 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", + ); + } + } } - }; - - // 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, - }); + "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 => { + 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, + "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(".", "_"); - common_response( - StatusCode::BAD_REQUEST, - "No file provided", - ) + let folder = format!("{}/{}", file_type.as_folder(), sanitized_user_id); + + // Upload file to MinIO + match minio_service.upload_file(&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), + ) + } + } } } diff --git a/imphnen-libs/Cargo.toml b/imphnen-libs/Cargo.toml index 87788ef..7d1dbad 100644 --- a/imphnen-libs/Cargo.toml +++ b/imphnen-libs/Cargo.toml @@ -19,5 +19,8 @@ dotenvy.workspace = true anyhow.workspace = true uuid.workspace = true base64.workspace = true -minio.workspace = true -http.workspace = true +reqwest = { version = "0.11", features = ["json"] } +sha2 = "0.10" +hmac = "0.12" +hex = "0.4" +urlencoding = "2.1" diff --git a/imphnen-libs/src/enviroment/mod.rs b/imphnen-libs/src/enviroment/mod.rs index 3ffb2d2..b35b82d 100644 --- a/imphnen-libs/src/enviroment/mod.rs +++ b/imphnen-libs/src/enviroment/mod.rs @@ -27,6 +27,8 @@ pub struct Env { pub minio_bucket_name: String, pub minio_access_key: String, pub minio_secret_key: String, + pub minio_region: String, + pub minio_secure: bool, // Google OAuth 2.1 pub google_client_id: String, pub google_client_secret: String, @@ -74,9 +76,13 @@ pub static ENV: Lazy = Lazy::new(|| { fe_url: get_env_with_warning("FE_URL", "http://localhost"), rust_env: get_env_with_warning("RUST_ENV", "development"), minio_endpoint: get_env_with_warning("MINIO_ENDPOINT", "http://localhost:9000"), - minio_bucket_name: get_env_with_warning("MINIO_BUCKET_NAME", "default_bucket"), + minio_bucket_name: get_env_with_warning("MINIO_BUCKET_NAME", "imphnen-uploads"), minio_access_key: get_env_with_warning("MINIO_ACCESS_KEY", "minio_access"), minio_secret_key: get_env_with_warning("MINIO_SECRET_KEY", "minio_secret"), + minio_region: get_env_with_warning("MINIO_REGION", "us-east-1"), + minio_secure: get_env_with_warning("MINIO_SECURE", "false") + .parse() + .unwrap_or(false), surrealdb_url_ws: String::new(), // Google OAuth 2.1 google_client_id: get_env_with_warning("GOOGLE_CLIENT_ID", "default_google_client_id"), diff --git a/imphnen-libs/src/minio.rs b/imphnen-libs/src/minio.rs index c57f527..c979e09 100644 --- a/imphnen-libs/src/minio.rs +++ b/imphnen-libs/src/minio.rs @@ -1,51 +1,90 @@ -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 anyhow::{anyhow, bail, Result}; +use base64::{engine::general_purpose, Engine as _}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; use uuid::Uuid; +use crate::enviroment::ENV; +// CATATAN: Pastikan untuk menambahkan dependensi ini ke `Cargo.toml` Anda: +// reqwest = { version = "0.11", features = ["json"] } +// anyhow = "1.0" +// uuid = { version = "1.3", features = ["v4"] } +// base64 = "0.21" +// log = "0.4" +// chrono = "0.4" +// sha2 = "0.10" +// hmac = "0.12" +// hex = "0.4" + +// --- Struct Konfigurasi MinIO --- +#[derive(Debug, Clone)] +pub struct MinioConfig { + pub endpoint: String, + pub access_key: String, + pub secret_key: String, + pub bucket_name: String, + pub region: String, + pub secure: bool, +} + +impl MinioConfig { + /// Memuat konfigurasi MinIO dari variabel lingkungan. + pub fn from_env() -> Result { + Ok(Self { + endpoint: ENV.minio_endpoint.clone(), + access_key: ENV.minio_access_key.clone(), + secret_key: ENV.minio_secret_key.clone(), + bucket_name: ENV.minio_bucket_name.clone(), + region: ENV.minio_region.clone(), + secure: ENV.minio_secure, + }) + } + + /// Mendapatkan URL endpoint lengkap (http atau https). + pub fn endpoint_url(&self) -> String { + // If endpoint already has protocol, use it as-is + if self.endpoint.starts_with("http://") || self.endpoint.starts_with("https://") { + self.endpoint.clone() + } else { + // Only add protocol if not present + let protocol = if self.secure { "https" } else { "http" }; + format!("{}://{}", protocol, self.endpoint) + } + } +} + +// --- Layanan MinIO --- pub struct MinioService { - client: Client, + endpoint: String, + access_key: String, + secret_key: String, bucket_name: String, + region: String, + client: reqwest::Client, } 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, - )?; - + /// Membuat instance layanan MinIO baru. + pub async fn new( + endpoint: &str, + access_key: &str, + secret_key: &str, + bucket_name: &str, + region: &str, + ) -> Result { let service = Self { - client, + endpoint: endpoint.to_string(), + access_key: access_key.to_string(), + secret_key: secret_key.to_string(), bucket_name: bucket_name.to_string(), + region: region.to_string(), + client: reqwest::Client::new(), }; - - // 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(()) - } - + /// Mengunggah file biner ke MinIO. pub async fn upload_file( &self, file_data: &[u8], @@ -53,30 +92,113 @@ impl MinioService { 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); + let object_name = &unique_filename; - // 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 - )?; + // Extract host from endpoint (remove protocol) + let host = self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name); - self.client.put_object(&mut put_object_args).await?; + // Debug logging + log::debug!("MinIO Endpoint config: {}", self.endpoint); + log::debug!("MinIO Region config: {}", self.region); + log::debug!("MinIO Access Key: {}", self.access_key); + log::debug!("MinIO Bucket: {}", self.bucket_name); + log::debug!("Extracted host: {}", host); + log::debug!("Final URL: {}", url); + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + + // 1) Use UNSIGNED-PAYLOAD for HTTPS uploads (safer for proxies) + let payload_hash = "UNSIGNED-PAYLOAD".to_string(); + + // 2) Path-style canonical URI: /{bucket}/{object} + let canonical_uri = format!("/{}/{}", self.bucket_name, object_name); + + // 3) ONLY sign essential headers (no content-type to avoid proxy issues) + let canonical_headers = format!( + "host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", + host, payload_hash, amz_date + ); + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + + // 4) Canonical request + let canonical_request = format!( + "PUT\n{}\n\n{}\n{}\n{}", + canonical_uri, canonical_headers, signed_headers, payload_hash + ); + + // Debug logging + log::debug!("URL: {}", url); + log::debug!("Host: {}", host); + log::debug!("Bucket: {}", self.bucket_name); + log::debug!("Object: {}", object_name); + log::debug!("Canonical URI: {}", canonical_uri); + log::debug!("Payload hash: {}", payload_hash); + log::debug!("Canonical Request:\n{}", canonical_request); + + let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + log::debug!("Scope: {}", scope); + log::debug!("String to sign:\n{}", string_to_sign); + + let signing_key = self.get_signature_key(&date_stamp)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + log::debug!("Generated signature: {}", signature); + + let auth_header = format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key, scope, signed_headers, signature + ); + + // 5) Send request: Content-Type included but NOT signed + let response = self + .client + .put(&url) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .header("Content-Type", content_type) + // Don't set Host header manually - let reqwest handle it + // Add headers for reverse proxy support (not signed) + .header("X-Forwarded-Proto", "https") + .header("X-Forwarded-Host", host) + .body(file_data.to_vec()) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await?; + bail!( + "Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}", + status, + error_body + ); + } + + log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename); Ok(unique_filename) } + /// Mengunggah file yang dikodekan base64 ke MinIO. pub async fn upload_base64_file( &self, base64_data: &str, @@ -84,72 +206,197 @@ impl MinioService { 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 + let file_data = decode_base64_file(base64_data)?; + 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, - )?; + /// Menghasilkan URL yang telah ditandatangani sebelumnya untuk mengunduh objek. + pub async fn get_presigned_url(&self, object_name: &str, expiry_seconds: u32) -> Result { + // Extract host from endpoint (remove protocol) + let host = self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); + let credential = format!("{}/{}", self.access_key, scope); - let url = self.client.get_presigned_object_url(&get_presigned_args).await?; - Ok(url.url) + let expires_str = expiry_seconds.to_string(); + let mut query_params = std::collections::BTreeMap::new(); + query_params.insert("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + query_params.insert("X-Amz-Credential", &credential); + query_params.insert("X-Amz-Date", &amz_date); + query_params.insert("X-Amz-Expires", &expires_str); + query_params.insert("X-Amz-SignedHeaders", "host"); + + let canonical_query_string = query_params + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect::>() + .join("&"); + + let canonical_request = format!( + "GET\n/{}/{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD", + self.bucket_name, object_name, canonical_query_string, host + ); + + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + let signing_key = self.get_signature_key(&date_stamp)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + let url = format!( + "https://{}/{}/{}?{}&X-Amz-Signature={}", + host, self.bucket_name, object_name, canonical_query_string, signature + ); + + Ok(url) } - + + /// Menghapus file dari MinIO. 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?; + // Extract host from endpoint (remove protocol) + let host = self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name); + + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let payload_hash = hex::encode(Sha256::digest(b"")); + + let canonical_headers = format!("host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", host, payload_hash, amz_date); + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + let canonical_request = format!( + "DELETE\n/{}/{}\n\n{}\n{}\n{}", + self.bucket_name, object_name, canonical_headers, signed_headers, payload_hash + ); + + let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + // Debug logging for signature calculation + log::debug!("Region: {}", self.region); + log::debug!("Scope: {}", scope); + log::debug!("String to sign:\n{}", string_to_sign); + + let signing_key = self.get_signature_key(&date_stamp)?; + let mut mac = Hmac::::new_from_slice(&signing_key)?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + log::debug!("Final signature: {}", signature); + + let auth_header = format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key, scope, signed_headers, signature + ); + + let response = self + .client + .delete(&url) + .header("Host", host) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Authorization", &auth_header) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await?; + bail!( + "Gagal menghapus file dari MinIO. Status: {}. Pesan: {}", + status, + error_body + ); + } + + log::info!("File berhasil dihapus: {}", object_name); Ok(()) } + /// Fungsi pembantu untuk menghasilkan kunci tanda tangan AWS v4. + fn get_signature_key(&self, date_stamp: &str) -> Result> { + let secret = format!("AWS4{}", self.secret_key); + let mut mac1 = Hmac::::new_from_slice(secret.as_bytes())?; + mac1.update(date_stamp.as_bytes()); + let date_key = mac1.finalize().into_bytes(); + + let mut mac2 = Hmac::::new_from_slice(&date_key)?; + mac2.update(self.region.as_bytes()); + let date_region_key = mac2.finalize().into_bytes(); + + let mut mac3 = Hmac::::new_from_slice(&date_region_key)?; + mac3.update(b"s3"); + let date_region_service_key = mac3.finalize().into_bytes(); + + let mut mac4 = Hmac::::new_from_slice(&date_region_service_key)?; + mac4.update(b"aws4_request"); + Ok(mac4.finalize().into_bytes().to_vec()) + } + + /// Memvalidasi jenis file dan ukuran. 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"); + bail!("Ukuran file melebihi batas 10MB"); } - // 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"); + bail!("File JPEG tidak valid"); } - }, + } "image/png" => { if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { - bail!("Invalid PNG file"); + bail!("File PNG tidak valid"); } - }, + } "application/pdf" => { if !file_data.starts_with(b"%PDF") { - bail!("Invalid PDF file"); + bail!("File PDF tidak valid"); } - }, + } + "image/webp" => { + if !file_data.starts_with(b"RIFF") + || !file_data.get(8..12).map_or(false, |s| s == b"WEBP") + { + bail!("File WEBP tidak valid"); + } + } + "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { + if file_data.len() < 512 { + bail!("File dokumen tidak valid"); + } + } _ => { - bail!("Unsupported file type: {}", content_type); + bail!("Jenis file tidak didukung: {}", content_type); } } Ok(()) } + /// Mendapatkan ekstensi file dari nama file. fn get_file_extension(filename: &str) -> String { std::path::Path::new(filename) .extension() @@ -159,6 +406,8 @@ impl MinioService { } } +// --- Struct dan Enum Pembantu --- + #[derive(Debug, Clone)] pub struct UploadResult { pub object_name: String, @@ -167,84 +416,83 @@ pub struct UploadResult { 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, + Jpeg, + Png, + Webp, + Gif, + Pdf, + Doc, + Docx, + Unknown, } impl FileType { pub fn as_folder(&self) -> &str { match self { - FileType::ProfileImage => "profiles", - FileType::CvResume => "resumes", - FileType::Document => "documents", + FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => "profiles", + FileType::Pdf | FileType::Doc | FileType::Docx => "documents", + FileType::Unknown => "misc", } } 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 + FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => 5 * 1024 * 1024, // 5MB for images + FileType::Pdf | FileType::Doc | FileType::Docx => 10 * 1024 * 1024, // 10MB for documents + FileType::Unknown => 5 * 1024 * 1024, // 5MB default } } 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"], + FileType::Jpeg => vec!["image/jpeg", "image/jpg"], + FileType::Png => vec!["image/png"], + FileType::Webp => vec!["image/webp"], + FileType::Gif => vec!["image/gif"], + FileType::Pdf => vec!["application/pdf"], + FileType::Doc => vec!["application/msword"], + FileType::Docx => vec!["application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + FileType::Unknown => vec![], // No allowed types for unknown + } + } + + pub fn from_content_type(content_type: &str) -> Self { + match content_type { + "image/jpeg" | "image/jpg" => FileType::Jpeg, + "image/png" => FileType::Png, + "image/webp" => FileType::Webp, + "image/gif" => FileType::Gif, + "application/pdf" => FileType::Pdf, + "application/msword" => FileType::Doc, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => FileType::Docx, + _ => FileType::Unknown, + } + } + + pub fn from_filename(filename: &str) -> Self { + let filename_lower = filename.to_lowercase(); + if filename_lower.ends_with(".jpg") || filename_lower.ends_with(".jpeg") { + FileType::Jpeg + } else if filename_lower.ends_with(".png") { + FileType::Png + } else if filename_lower.ends_with(".webp") { + FileType::Webp + } else if filename_lower.ends_with(".gif") { + FileType::Gif + } else if filename_lower.ends_with(".pdf") { + FileType::Pdf + } else if filename_lower.ends_with(".doc") { + FileType::Doc + } else if filename_lower.ends_with(".docx") { + FileType::Docx + } else { + FileType::Unknown } } } -// Upload request structure #[derive(Debug, Clone)] pub struct UploadRequest { pub user_id: String, @@ -254,7 +502,6 @@ pub struct UploadRequest { pub data: Vec, } -// File metadata response #[derive(Debug, Clone)] pub struct FileMetadata { pub filename: String, @@ -264,9 +511,22 @@ pub struct FileMetadata { pub url: String, } -// Helper function to decode base64 file data +// --- Fungsi Pembantu --- + +/// Membuat instance MinioService dari struct MinioConfig. +pub async fn create_minio_service_from_config(config: MinioConfig) -> Result { + MinioService::new( + &config.endpoint, // Use raw endpoint, not endpoint_url() + &config.access_key, + &config.secret_key, + &config.bucket_name, + &config.region, + ) + .await +} + +/// Mendekode data file base64. 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 { @@ -275,10 +535,10 @@ pub fn decode_base64_file(base64_data: &str) -> Result> { general_purpose::STANDARD .decode(clean_data) - .map_err(|e| anyhow::anyhow!("Failed to decode base64 data: {}", e)) + .map_err(|e| anyhow!("Gagal mendekode data base64: {}", e)) } -// Helper function to extract content type from data URL +/// Mengekstrak tipe konten dari URL data. 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() { @@ -287,13 +547,3 @@ pub fn extract_content_type_from_data_url(data_url: &str) -> Option { } 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 -}