use std::sync::Arc;
use uuid::Uuid;
use chrono::Utc;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use crate::domain::entity::{
Bucket, StoredFile, UserQuota, FileStatus, ThreatLevel,
};
use super::storage_service::{StorageService, StorageError};
use super::virus_scanner::{VirusScannerService, VirusScanResult};
use super::image_compressor::{ImageCompressorService, CompressionResult, ImageCompressionError};
#[derive(Debug, Clone)]
pub struct UploadRequest {
pub bucket_id: Uuid,
pub owner_id: Uuid,
pub path: String,
pub filename: String,
pub content: Vec<u8>,
pub mime_type: String,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct UploadResult {
pub file: StoredFile,
pub was_compressed: bool,
pub compression_ratio: Option<f64>,
pub scan_result: VirusScanResult,
}
#[derive(Debug)]
pub enum UploadError {
BucketValidation(String),
QuotaExceeded(String),
VirusDetected {
filename: String,
threats: Vec<String>,
},
Storage(StorageError),
Compression(ImageCompressionError),
Quarantined {
file: StoredFile,
reason: String,
},
}
impl std::fmt::Display for UploadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BucketValidation(msg) => write!(f, "Bucket validation failed: {}", msg),
Self::QuotaExceeded(msg) => write!(f, "Quota exceeded: {}", msg),
Self::VirusDetected { filename, threats } => {
write!(f, "Virus detected in '{}': {:?}", filename, threats)
}
Self::Storage(e) => write!(f, "Storage error: {}", e),
Self::Compression(e) => write!(f, "Compression error: {}", e),
Self::Quarantined { reason, .. } => write!(f, "File quarantined: {}", reason),
}
}
}
impl std::error::Error for UploadError {}
impl From<StorageError> for UploadError {
fn from(err: StorageError) -> Self {
UploadError::Storage(err)
}
}
impl From<ImageCompressionError> for UploadError {
fn from(err: ImageCompressionError) -> Self {
UploadError::Compression(err)
}
}
#[derive(Clone)]
pub struct FileUploadService {
storage: Arc<StorageService>,
scanner: Arc<VirusScannerService>,
compressor: Arc<ImageCompressorService>,
auto_compress_images: bool,
thumbnail_size: u32,
}
impl FileUploadService {
pub fn new(
storage: Arc<StorageService>,
scanner: Arc<VirusScannerService>,
compressor: Arc<ImageCompressorService>,
) -> Self {
Self {
storage,
scanner,
compressor,
auto_compress_images: true,
thumbnail_size: 256,
}
}
pub fn with_settings(
storage: Arc<StorageService>,
scanner: Arc<VirusScannerService>,
compressor: Arc<ImageCompressorService>,
auto_compress: bool,
thumbnail_size: u32,
) -> Self {
Self {
storage,
scanner,
compressor,
auto_compress_images: auto_compress,
thumbnail_size,
}
}
pub async fn upload(
&self,
request: UploadRequest,
bucket: &Bucket,
quota: &mut UserQuota,
) -> Result<UploadResult, UploadError> {
let content_size = request.content.len() as i64;
bucket
.can_upload(content_size, &request.mime_type)
.map_err(UploadError::BucketValidation)?;
quota
.can_upload_file(content_size)
.map_err(|e| UploadError::QuotaExceeded(e.to_string()))?;
let scan_result = self.scanner.scan(&request.content, &request.filename);
if self.scanner.should_block(&scan_result) {
return Err(UploadError::VirusDetected {
filename: request.filename,
threats: scan_result.threats,
});
}
let (final_content, compression) = if self.auto_compress_images
&& self.compressor.is_image(&request.content)
{
let result = self.compressor.compress(&request.content)?;
let was_compressed = result.was_compressed;
(result.content.clone(), Some(result))
} else {
(request.content.clone(), None)
};
let storage_key = self
.storage
.store_file(&bucket.slug, &request.path, &final_content)
.await?;
let (has_thumbnail, thumbnail_path) = if self.compressor.is_image(&final_content) {
match self.generate_thumbnail(&final_content).await {
Ok(path) => (true, Some(path)),
Err(_) => (false, None),
}
} else {
(false, None)
};
let file_id = Uuid::new_v4();
let final_size = final_content.len() as i64;
let checksum = compute_checksum(&final_content);
let status = if self.scanner.should_quarantine(&scan_result) {
FileStatus::Quarantined
} else {
FileStatus::Active
};
let mut file = StoredFile {
id: file_id,
bucket_id: request.bucket_id,
owner_id: request.owner_id,
path: request.path,
original_name: request.filename.clone(),
size_bytes: final_size,
mime_type: request.mime_type,
checksum: Some(checksum),
is_compressed: compression.as_ref().map(|c| c.was_compressed).unwrap_or(false),
original_size: compression.as_ref().filter(|c| c.was_compressed).map(|c| c.original_size as i64),
compression_algorithm: compression.as_ref().and_then(|c| c.algorithm.clone()),
is_scanned: true,
scan_result: Some(scan_result.scan_details.clone()),
threat_level: Some(scan_result.threat_level),
has_thumbnail,
thumbnail_path,
status,
storage_key,
version: 1,
previous_version_id: None,
download_count: 0,
last_accessed_at: None,
metadata: request.metadata.unwrap_or(serde_json::json!({})),
};
if let Err(e) = quota.add_usage(final_size) {
let _ = self.storage.delete_file(&file.storage_key).await;
return Err(UploadError::QuotaExceeded(e.to_string()));
}
if file.status == FileStatus::Quarantined {
return Err(UploadError::Quarantined {
file,
reason: "File flagged for review".to_string(),
});
}
Ok(UploadResult {
file,
was_compressed: compression.as_ref().map(|c| c.was_compressed).unwrap_or(false),
compression_ratio: compression.as_ref().filter(|c| c.was_compressed).map(|c| c.compression_ratio()),
scan_result,
})
}
async fn generate_thumbnail(&self, content: &[u8]) -> Result<String, UploadError> {
let thumbnail_content = self
.compressor
.generate_thumbnail(content, self.thumbnail_size)?;
let file_id = Uuid::new_v4();
let path = self
.storage
.store_thumbnail(file_id, &thumbnail_content)
.await?;
Ok(path)
}
pub async fn upload_version(
&self,
request: UploadRequest,
bucket: &Bucket,
quota: &mut UserQuota,
previous_file: &StoredFile,
) -> Result<UploadResult, UploadError> {
let mut result = self.upload(request, bucket, quota).await?;
result.file.previous_version_id = Some(previous_file.id);
result.file.version = previous_file.version + 1;
Ok(result)
}
}
fn compute_checksum(content: &[u8]) -> String {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entity::{BucketType, BucketStatus, StorageBackend};
fn create_test_bucket() -> Bucket {
Bucket {
id: Uuid::new_v4(),
name: "Test Bucket".to_string(),
slug: "test-bucket".to_string(),
description: None,
owner_id: Uuid::new_v4(),
bucket_type: BucketType::User,
status: BucketStatus::Active,
storage_backend: StorageBackend::Local,
root_path: "/tmp/test".to_string(),
file_count: 0,
total_size_bytes: 0,
max_file_size: Some(10 * 1024 * 1024), allowed_mime_types: vec![],
auto_delete_after_days: None,
metadata: serde_json::json!({}),
}
}
fn create_test_quota() -> UserQuota {
UserQuota {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
limit_bytes: 1024 * 1024 * 1024, used_bytes: 0,
file_count: 0,
max_file_size: None,
max_file_count: None,
tier: "free".to_string(),
warning_threshold_percent: 80,
last_warning_sent_at: None,
peak_usage_bytes: 0,
peak_usage_at: None,
metadata: serde_json::json!({}),
}
}
#[test]
fn test_compute_checksum() {
let checksum1 = compute_checksum(b"Hello, World!");
let checksum2 = compute_checksum(b"Hello, World!");
let checksum3 = compute_checksum(b"Different content");
assert_eq!(checksum1, checksum2);
assert_ne!(checksum1, checksum3);
}
#[test]
fn test_bucket_validation() {
let bucket = create_test_bucket();
assert!(bucket.can_upload(1000, "text/plain").is_ok());
assert!(bucket.can_upload(20 * 1024 * 1024, "text/plain").is_err());
}
#[test]
fn test_quota_validation() {
let mut quota = create_test_quota();
assert!(quota.can_upload_file(1000).is_ok());
quota.used_bytes = quota.limit_bytes;
assert!(quota.can_upload_file(1000).is_err());
}
}