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:
MythEclipse
2025-08-14 16:53:14 +07:00
parent bbff555a06
commit 874986f1d6
9 changed files with 641 additions and 510 deletions
+15 -1
View File
@@ -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"),
+192 -31
View File
@@ -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(_) => {
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),
)
}
}
}
}