fix(#104): enforce storage quota on uploads & fix usage tracking
Backend: - Add QuotaExceeded error kind mapped to HTTP 507 Insufficient Storage - Add check_storage_quota() and get_user_storage_info() to StorageUsagePort - Enforce quota in upload_file_with_cache, upload_file_with_thumbnails (AuthUser extractor) - Enforce quota in chunked upload create_upload handler - Add update_user_storage_usage_by_username() for username-based lookup - Fix extract_username_from_path() to handle subfolders (take first segment only) - Fix maybe_update_storage_usage() to use username-based lookup instead of passing username to get_user_by_id (which always failed silently) Frontend: - Parse and display quota error messages on upload failure (507 / QuotaExceeded) - Stop remaining uploads when quota is exceeded - Call refreshUserData() after uploads to update storage usage display - Bump service worker cache to v9
This commit is contained in:
@@ -168,8 +168,26 @@ pub trait StorageUsagePort: Send + Sync + 'static {
|
||||
/// Updates storage usage statistics for a user
|
||||
async fn update_user_storage_usage(&self, user_id: &str) -> Result<i64, DomainError>;
|
||||
|
||||
/// Updates storage usage statistics for a user, looked up by username
|
||||
async fn update_user_storage_usage_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<i64, DomainError>;
|
||||
|
||||
/// Updates storage usage statistics for all users
|
||||
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError>;
|
||||
|
||||
/// Checks if a user has enough quota for an additional upload.
|
||||
/// Returns Ok(()) if the upload is allowed, or Err(QuotaExceeded) with a
|
||||
/// descriptive message otherwise.
|
||||
async fn check_storage_quota(
|
||||
&self,
|
||||
user_id: &str,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Returns (used_bytes, quota_bytes) for a user.
|
||||
async fn get_user_storage_info(&self, user_id: &str) -> Result<(i64, i64), DomainError>;
|
||||
}
|
||||
|
||||
/// Generic storage service interface for calendar and contact services
|
||||
|
||||
@@ -17,7 +17,8 @@ const STREAMING_UPLOAD_THRESHOLD: usize = 1024 * 1024;
|
||||
/// Threshold for write-behind cache (files < 256KB get instant response)
|
||||
const WRITE_BEHIND_THRESHOLD: usize = 256 * 1024;
|
||||
|
||||
/// Helper function to extract username from folder path string
|
||||
/// Helper function to extract username from folder path string.
|
||||
/// e.g. "My Folder - user1/subfolder/file.txt" → "user1"
|
||||
fn extract_username_from_path(path: &str) -> Option<String> {
|
||||
if !path.contains("My Folder - ") {
|
||||
return None;
|
||||
@@ -26,7 +27,14 @@ fn extract_username_from_path(path: &str) -> Option<String> {
|
||||
if parts.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(parts[1].trim().to_string())
|
||||
// Take only the first segment (username), not any subfolders
|
||||
let remainder = parts[1].trim();
|
||||
let username = remainder.split('/').next().unwrap_or(remainder);
|
||||
let username = username.trim();
|
||||
if username.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(username.to_string())
|
||||
}
|
||||
|
||||
/// Service for file upload operations
|
||||
@@ -125,7 +133,10 @@ impl FileUploadService {
|
||||
if let Some(username) = extract_username_from_path(&file_path) {
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(async move {
|
||||
match service_clone.update_user_storage_usage(&username).await {
|
||||
match service_clone
|
||||
.update_user_storage_usage_by_username(&username)
|
||||
.await
|
||||
{
|
||||
Ok(usage) => debug!(
|
||||
"Updated storage usage for user {} to {} bytes",
|
||||
username, usage
|
||||
|
||||
@@ -149,6 +149,32 @@ impl StorageUsageService {
|
||||
let repo_clone = Arc::clone(&self.file_repository);
|
||||
inner_calculate_size(repo_clone, folder_id).await
|
||||
}
|
||||
|
||||
/// Calculates and updates storage usage for a user identified by username.
|
||||
pub async fn update_user_storage_usage_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<i64, DomainError> {
|
||||
info!("Updating storage usage for username: {}", username);
|
||||
|
||||
let user = self.user_repository.get_user_by_username(username).await?;
|
||||
let user_id = user.id().to_string();
|
||||
|
||||
// Reuse the existing calculation logic
|
||||
let total_usage = self.calculate_user_storage_usage(username).await?;
|
||||
|
||||
// Update the user's storage usage in the database
|
||||
self.user_repository
|
||||
.update_storage_usage(&user_id, total_usage)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Updated storage usage for username {} (id={}) to {} bytes",
|
||||
username, user_id, total_usage
|
||||
);
|
||||
|
||||
Ok(total_usage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +187,13 @@ impl StorageUsagePort for StorageUsageService {
|
||||
StorageUsageService::update_user_storage_usage(self, user_id).await
|
||||
}
|
||||
|
||||
async fn update_user_storage_usage_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<i64, DomainError> {
|
||||
StorageUsageService::update_user_storage_usage_by_username(self, username).await
|
||||
}
|
||||
|
||||
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError> {
|
||||
info!("Starting batch update of all users' storage usage");
|
||||
|
||||
@@ -203,6 +236,51 @@ impl StorageUsagePort for StorageUsageService {
|
||||
info!("Completed batch update of all users' storage usage");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_storage_quota(
|
||||
&self,
|
||||
user_id: &str,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let user = self.user_repository.get_user_by_id(user_id).await?;
|
||||
let quota = user.storage_quota_bytes();
|
||||
let used = user.storage_used_bytes();
|
||||
|
||||
// Quota of 0 means unlimited
|
||||
if quota <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let additional = additional_bytes as i64;
|
||||
|
||||
// Case 1: the single file alone exceeds the entire quota
|
||||
if additional > quota {
|
||||
let quota_fmt = format_bytes(quota);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"File size ({}) exceeds your total storage quota ({})",
|
||||
file_fmt, quota_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
// Case 2: the upload would push usage over the quota
|
||||
if used + additional > quota {
|
||||
let available = (quota - used).max(0);
|
||||
let avail_fmt = format_bytes(available);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"Not enough storage space. File size: {}, available: {}",
|
||||
file_fmt, avail_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_storage_info(&self, user_id: &str) -> Result<(i64, i64), DomainError> {
|
||||
let user = self.user_repository.get_user_by_id(user_id).await?;
|
||||
Ok((user.storage_used_bytes(), user.storage_quota_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
// Make StorageUsageService cloneable to support spawning concurrent tasks
|
||||
@@ -214,3 +292,20 @@ impl Clone for StorageUsageService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes into human-readable units for error messages.
|
||||
fn format_bytes(bytes: i64) -> String {
|
||||
const KB: i64 = 1024;
|
||||
const MB: i64 = KB * 1024;
|
||||
const GB: i64 = MB * 1024;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.2} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.2} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.2} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user