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:
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user