feat: update dependencies and add MinIO integration

- Updated `async-channel` to version 2.5.0 and added new dependencies in `Cargo.lock`.
- Introduced `minio` crate for file upload functionality.
- Added `career_status` field to user-related DTOs and schemas.
- Implemented file upload endpoint in `users_controller.rs` with multipart support.
- Created `MinioService` for handling file uploads to MinIO.
- Updated user seeding and test cases to accommodate new `career_status` field.
- Refactored permissions guard to return user details.
This commit is contained in:
MythEclipse
2025-08-14 13:16:39 +07:00
parent f6232832af
commit 5f51dd281d
16 changed files with 770 additions and 17 deletions
+5
View File
@@ -16,3 +16,8 @@ chrono.workspace = true
surrealdb.workspace = true
jsonwebtoken.workspace = true
dotenvy.workspace = true
anyhow.workspace = true
uuid.workspace = true
base64.workspace = true
minio.workspace = true
http.workspace = true
+2
View File
@@ -5,6 +5,7 @@ pub mod axum;
pub mod enviroment;
pub mod jsonwebtoken;
pub mod lettre;
pub mod minio;
pub mod surrealdb;
pub use argon::*;
@@ -13,4 +14,5 @@ pub use enviroment::*;
pub use imphnen_entities::*;
pub use jsonwebtoken::*;
pub use lettre::*;
pub use minio::*;
pub use surrealdb::*;
+299
View File
@@ -0,0 +1,299 @@
use std::io::Cursor;
use anyhow::{Result, bail};
use base64::{Engine as _, engine::general_purpose};
use minio::s3::args::{BucketExistsArgs, GetPresignedObjectUrlArgs, MakeBucketArgs, PutObjectArgs};
use minio::s3::client::Client;
use minio::s3::creds::StaticProvider;
use minio::s3::http::BaseUrl;
use uuid::Uuid;
pub struct MinioService {
client: Client,
bucket_name: String,
}
impl MinioService {
pub async fn new(endpoint: &str, access_key: &str, secret_key: &str, bucket_name: &str) -> Result<Self> {
let base_url = endpoint.parse::<BaseUrl>()?;
let static_provider = StaticProvider::new(access_key, secret_key, None);
let client = Client::new(
base_url.clone(),
Some(Box::new(static_provider)),
None,
None,
)?;
let service = Self {
client,
bucket_name: bucket_name.to_string(),
};
// Ensure bucket exists
service.ensure_bucket_exists().await?;
Ok(service)
}
async fn ensure_bucket_exists(&self) -> Result<()> {
let exists_args = BucketExistsArgs::new(&self.bucket_name)?;
let exists = self.client.bucket_exists(&exists_args).await?;
if !exists {
let make_bucket_args = MakeBucketArgs::new(&self.bucket_name)?;
self.client.make_bucket(&make_bucket_args).await?;
}
Ok(())
}
pub async fn upload_file(
&self,
file_data: &[u8],
content_type: &str,
folder: &str,
original_filename: &str,
) -> Result<String> {
self.ensure_bucket_exists().await?;
// Validate file type based on content type
Self::validate_file_type(content_type, file_data)?;
// Generate unique filename
let file_extension = Self::get_file_extension(original_filename);
let unique_filename = format!("{}/{}.{}", folder, Uuid::new_v4(), file_extension);
// Upload file
let mut cursor = Cursor::new(file_data);
let mut put_object_args = PutObjectArgs::new(
&self.bucket_name,
&unique_filename,
&mut cursor,
Some(file_data.len()),
None, // No additional metadata size
)?;
self.client.put_object(&mut put_object_args).await?;
Ok(unique_filename)
}
pub async fn upload_base64_file(
&self,
base64_data: &str,
content_type: &str,
folder: &str,
original_filename: &str,
) -> Result<String> {
// Remove data URL prefix if present
let base64_clean = if base64_data.contains(',') {
base64_data.split(',').nth(1).unwrap_or(base64_data)
} else {
base64_data
};
// Decode base64
let file_data = general_purpose::STANDARD.decode(base64_clean)
.map_err(|e| anyhow::anyhow!("Invalid base64 data: {}", e))?;
self.upload_file(&file_data, content_type, folder, original_filename).await
}
pub async fn get_presigned_url(&self, object_name: &str, _expiry_seconds: u32) -> Result<String> {
use http::Method;
let get_presigned_args = GetPresignedObjectUrlArgs::new(
&self.bucket_name,
object_name,
Method::GET,
)?;
let url = self.client.get_presigned_object_url(&get_presigned_args).await?;
Ok(url.url)
}
pub async fn delete_file(&self, object_name: &str) -> Result<()> {
use minio::s3::args::RemoveObjectArgs;
let remove_args = RemoveObjectArgs::new(&self.bucket_name, object_name)?;
self.client.remove_object(&remove_args).await?;
Ok(())
}
fn validate_file_type(content_type: &str, file_data: &[u8]) -> Result<()> {
// Validate file size (10MB max)
const MAX_SIZE: usize = 10 * 1024 * 1024; // 10MB
if file_data.len() > MAX_SIZE {
bail!("File size exceeds 10MB limit");
}
// Validate content type and magic numbers
match content_type {
"image/jpeg" | "image/jpg" => {
if !file_data.starts_with(&[0xFF, 0xD8, 0xFF]) {
bail!("Invalid JPEG file");
}
},
"image/png" => {
if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
bail!("Invalid PNG file");
}
},
"application/pdf" => {
if !file_data.starts_with(b"%PDF") {
bail!("Invalid PDF file");
}
},
_ => {
bail!("Unsupported file type: {}", content_type);
}
}
Ok(())
}
fn get_file_extension(filename: &str) -> String {
std::path::Path::new(filename)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("bin")
.to_lowercase()
}
}
#[derive(Debug, Clone)]
pub struct UploadResult {
pub object_name: String,
pub url: String,
pub size: usize,
pub content_type: String,
}
// Configuration struct for easier management
#[derive(Debug, Clone)]
pub struct MinioConfig {
pub endpoint: String,
pub access_key: String,
pub secret_key: String,
pub bucket_name: String,
pub secure: bool,
}
impl MinioConfig {
pub fn from_env() -> Result<Self> {
let endpoint = std::env::var("MINIO_ENDPOINT")
.unwrap_or_else(|_| "localhost:9000".to_string());
let access_key = std::env::var("MINIO_ACCESS_KEY")
.map_err(|_| anyhow::anyhow!("MINIO_ACCESS_KEY environment variable not set"))?;
let secret_key = std::env::var("MINIO_SECRET_KEY")
.map_err(|_| anyhow::anyhow!("MINIO_SECRET_KEY environment variable not set"))?;
let bucket_name = std::env::var("MINIO_BUCKET")
.unwrap_or_else(|_| "imphnen-uploads".to_string());
let secure = std::env::var("MINIO_SECURE")
.unwrap_or_else(|_| "false".to_string())
.parse()
.unwrap_or(false);
Ok(Self {
endpoint,
access_key,
secret_key,
bucket_name,
secure,
})
}
pub fn endpoint_url(&self) -> String {
if self.secure {
format!("https://{}", self.endpoint)
} else {
format!("http://{}", self.endpoint)
}
}
}
// File type enumeration for better organization
#[derive(Debug, Clone)]
pub enum FileType {
ProfileImage,
CvResume,
Document,
}
impl FileType {
pub fn as_folder(&self) -> &str {
match self {
FileType::ProfileImage => "profiles",
FileType::CvResume => "resumes",
FileType::Document => "documents",
}
}
pub fn max_size(&self) -> usize {
match self {
FileType::ProfileImage => 5 * 1024 * 1024, // 5MB
FileType::CvResume => 10 * 1024 * 1024, // 10MB
FileType::Document => 10 * 1024 * 1024, // 10MB
}
}
pub fn allowed_types(&self) -> Vec<&str> {
match self {
FileType::ProfileImage => vec!["image/jpeg", "image/png", "image/webp"],
FileType::CvResume => vec!["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
FileType::Document => vec!["application/pdf", "image/jpeg", "image/png"],
}
}
}
// Upload request structure
#[derive(Debug, Clone)]
pub struct UploadRequest {
pub user_id: String,
pub file_type: FileType,
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}
// File metadata response
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub filename: String,
pub content_type: String,
pub size: usize,
pub path: String,
pub url: String,
}
// Helper function to decode base64 file data
pub fn decode_base64_file(base64_data: &str) -> Result<Vec<u8>> {
// Remove data URL prefix if present (e.g., "data:image/jpeg;base64,")
let clean_data = if base64_data.contains(',') {
base64_data.split(',').nth(1).unwrap_or(base64_data)
} else {
base64_data
};
general_purpose::STANDARD
.decode(clean_data)
.map_err(|e| anyhow::anyhow!("Failed to decode base64 data: {}", e))
}
// Helper function to extract content type from data URL
pub fn extract_content_type_from_data_url(data_url: &str) -> Option<String> {
if data_url.starts_with("data:") {
if let Some(type_part) = data_url.split(';').next() {
return Some(type_part.replace("data:", ""));
}
}
None
}
// Helper function to create MinIO service from config
pub async fn create_minio_service_from_config(config: MinioConfig) -> Result<MinioService> {
MinioService::new(
&config.endpoint_url(),
&config.access_key,
&config.secret_key,
&config.bucket_name,
).await
}