feat: Implement file upload functionality with MinIO integration
- Added new dependencies for HMAC, hex, and urlencoding in Cargo.toml. - Introduced FileUploadSchema for handling multipart file uploads. - Enhanced UsersService with upload_file method to handle file uploads to MinIO. - Implemented MinioService for managing MinIO interactions, including file uploads and presigned URL generation. - Updated environment configuration to include MinIO region and secure settings. - Added validation for file types and sizes during upload. - Improved error handling and logging for file upload processes. - Created utility functions for base64 decoding and content type extraction.
This commit is contained in:
@@ -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"
|
||||
Generated
+9
-318
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<serde_json::Value>),
|
||||
(status = 400, description = "Bad request"),
|
||||
|
||||
@@ -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<Vec<u8>> = None;
|
||||
let mut filename: Option<String> = None;
|
||||
let mut content_type: Option<String> = 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(_) => {
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"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",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 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)"
|
||||
});
|
||||
let filename = filename.unwrap_or_else(|| "unnamed_file".to_string());
|
||||
let content_type = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
return success_response(ResponseSuccessDto {
|
||||
data: response_data,
|
||||
});
|
||||
// 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",
|
||||
);
|
||||
}
|
||||
|
||||
common_response(
|
||||
// Validate file type matches content type
|
||||
if !file_type.allowed_types().contains(&content_type.as_str()) {
|
||||
return common_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"No file provided",
|
||||
&format!("File type '{}' does not match content type '{:?}'", content_type, file_type),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if file_data.len() > file_type.max_size() {
|
||||
return common_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("File too large. Maximum size for {:?} is {} bytes",
|
||||
file_type, file_type.max_size()),
|
||||
);
|
||||
}
|
||||
|
||||
// Create secure upload path with user ID (sanitized for filesystem)
|
||||
let sanitized_user_id = user_email
|
||||
.replace("%", "")
|
||||
.replace(":", "_")
|
||||
.replace("@", "_at_")
|
||||
.replace(".", "_");
|
||||
|
||||
let folder = format!("{}/{}", file_type.as_folder(), sanitized_user_id);
|
||||
|
||||
// Upload file to MinIO
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Env> = 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"),
|
||||
|
||||
+405
-155
@@ -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<Self> {
|
||||
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<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,
|
||||
)?;
|
||||
|
||||
/// Membuat instance layanan MinIO baru.
|
||||
pub async fn new(
|
||||
endpoint: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
bucket_name: &str,
|
||||
region: &str,
|
||||
) -> Result<Self> {
|
||||
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<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);
|
||||
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://");
|
||||
|
||||
self.client.put_object(&mut put_object_args).await?;
|
||||
let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name);
|
||||
|
||||
// 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::<Sha256>::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<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
|
||||
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<String> {
|
||||
use http::Method;
|
||||
/// Menghasilkan URL yang telah ditandatangani sebelumnya untuk mengunduh objek.
|
||||
pub async fn get_presigned_url(&self, object_name: &str, expiry_seconds: u32) -> Result<String> {
|
||||
// Extract host from endpoint (remove protocol)
|
||||
let host = self.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
|
||||
let get_presigned_args = GetPresignedObjectUrlArgs::new(
|
||||
&self.bucket_name,
|
||||
object_name,
|
||||
Method::GET,
|
||||
)?;
|
||||
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::<Vec<_>>()
|
||||
.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::<Sha256>::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::<Sha256>::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<Vec<u8>> {
|
||||
let secret = format!("AWS4{}", self.secret_key);
|
||||
let mut mac1 = Hmac::<Sha256>::new_from_slice(secret.as_bytes())?;
|
||||
mac1.update(date_stamp.as_bytes());
|
||||
let date_key = mac1.finalize().into_bytes();
|
||||
|
||||
let mut mac2 = Hmac::<Sha256>::new_from_slice(&date_key)?;
|
||||
mac2.update(self.region.as_bytes());
|
||||
let date_region_key = mac2.finalize().into_bytes();
|
||||
|
||||
let mut mac3 = Hmac::<Sha256>::new_from_slice(&date_region_key)?;
|
||||
mac3.update(b"s3");
|
||||
let date_region_service_key = mac3.finalize().into_bytes();
|
||||
|
||||
let mut mac4 = Hmac::<Sha256>::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<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,
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
// 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> {
|
||||
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<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 {
|
||||
@@ -275,10 +535,10 @@ pub fn decode_base64_file(base64_data: &str) -> Result<Vec<u8>> {
|
||||
|
||||
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<String> {
|
||||
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<String> {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user