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
+7 -1
View File
@@ -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"),
+407 -157
View File
@@ -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://");
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::<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;
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<String> {
// 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::<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
}