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:
Dionisio
2026-02-14 10:34:07 +01:00
parent 1c5cf97cc5
commit 3f60765d9a
9 changed files with 239 additions and 8 deletions
+18
View File
@@ -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)
}
}