From 3f60765d9a018250bd049749fa7553e984cc195b Mon Sep 17 00:00:00 2001 From: Dionisio Date: Sat, 14 Feb 2026 10:34:07 +0100 Subject: [PATCH] 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 --- src/application/ports/storage_ports.rs | 18 ++++ .../services/file_upload_service.rs | 17 +++- .../services/storage_usage_service.rs | 95 +++++++++++++++++++ src/domain/errors.rs | 14 +++ .../api/handlers/chunked_upload_handler.rs | 26 +++++ src/interfaces/api/handlers/file_handler.rs | 40 +++++++- src/interfaces/errors.rs | 1 + static/js/fileOperations.js | 34 ++++++- static/sw.js | 2 +- 9 files changed, 239 insertions(+), 8 deletions(-) diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index c5ac56fe..19dad826 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -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; + /// Updates storage usage statistics for a user, looked up by username + async fn update_user_storage_usage_by_username( + &self, + username: &str, + ) -> Result; + /// 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 diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 059ea262..14d0f808 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -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 { if !path.contains("My Folder - ") { return None; @@ -26,7 +27,14 @@ fn extract_username_from_path(path: &str) -> Option { 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 diff --git a/src/application/services/storage_usage_service.rs b/src/application/services/storage_usage_service.rs index 0aeee068..5c549aed 100644 --- a/src/application/services/storage_usage_service.rs +++ b/src/application/services/storage_usage_service.rs @@ -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 { + 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 { + 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) + } +} diff --git a/src/domain/errors.rs b/src/domain/errors.rs index 2eabddfd..605d58a5 100644 --- a/src/domain/errors.rs +++ b/src/domain/errors.rs @@ -31,6 +31,8 @@ pub enum ErrorKind { UnsupportedOperation, /// Database error DatabaseError, + /// Storage quota exceeded + QuotaExceeded, } impl Display for ErrorKind { @@ -45,6 +47,7 @@ impl Display for ErrorKind { ErrorKind::NotImplemented => write!(f, "Not Implemented"), ErrorKind::UnsupportedOperation => write!(f, "Unsupported Operation"), ErrorKind::DatabaseError => write!(f, "Database Error"), + ErrorKind::QuotaExceeded => write!(f, "Quota Exceeded"), } } } @@ -162,6 +165,17 @@ impl DomainError { } } + /// Creates a storage quota exceeded error + pub fn quota_exceeded>(message: S) -> Self { + Self { + kind: ErrorKind::QuotaExceeded, + entity_type: "Storage", + entity_id: None, + message: message.into(), + source: None, + } + } + /// Creates a validation error pub fn validation_error>(message: S) -> Self { Self { diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 5b372608..d4a85789 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE; use crate::common::di::AppState; use crate::domain::errors::ErrorKind; +use crate::interfaces::middleware::auth::AuthUser; /// Request body for creating an upload session #[derive(Debug, Deserialize)] @@ -75,6 +76,7 @@ impl ChunkedUploadHandler { /// ``` pub async fn create_upload( State(state): State>, + auth_user: AuthUser, Json(request): Json, ) -> impl IntoResponse { let chunked_service = &state.core.chunked_upload_service; @@ -100,6 +102,30 @@ impl ChunkedUploadHandler { .into_response(); } + // ── Quota enforcement ──────────────────────────────────── + if let Some(storage_svc) = state.storage_usage_service.as_ref() { + if let Err(err) = storage_svc + .check_storage_quota(&auth_user.id, request.total_size) + .await + { + tracing::warn!( + "⛔ CHUNKED UPLOAD REJECTED (quota): user={}, file={}, size={} — {}", + auth_user.username, + request.filename, + request.total_size, + err.message + ); + return ( + StatusCode::INSUFFICIENT_STORAGE, + Json(serde_json::json!({ + "error": err.message, + "error_type": "QuotaExceeded" + })), + ) + .into_response(); + } + } + // Validate chunk size if provided let chunk_size = request.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE); if chunk_size < 1024 * 1024 { diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 3e9d3305..dfcff289 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort}; use crate::application::ports::file_ports::OptimizedFileContent; use crate::common::di::AppState; -use crate::interfaces::middleware::auth::OptionalUserId; +use crate::interfaces::middleware::auth::{AuthUser, OptionalUserId}; /** * Type aliases for dependency injection state. @@ -121,6 +121,7 @@ impl FileHandler { /// optimal tier and handles deduplication internally. pub async fn upload_file_with_cache( State(state): State, + auth_user: AuthUser, mut multipart: Multipart, ) -> impl IntoResponse { let upload_service = &state.applications.file_upload_service; @@ -167,6 +168,23 @@ impl FileHandler { }; } + // ── Quota enforcement ──────────────────────────────────── + if let Some(storage_svc) = state.storage_usage_service.as_ref() { + if let Err(err) = storage_svc + .check_storage_quota(&auth_user.id, total_size as u64) + .await + { + tracing::warn!( + "⛔ UPLOAD REJECTED (quota): user={}, file={}, size={} — {}", + auth_user.username, + filename, + total_size, + err + ); + return Self::quota_error_response(err).into_response(); + } + } + // Delegate to smart_upload (handles write-behind, dedup, streaming) match upload_service .smart_upload( @@ -531,10 +549,12 @@ impl FileHandler { /// a background task to generate all thumbnail sizes. pub async fn upload_file_with_thumbnails( State(state): State, + auth_user: AuthUser, multipart: Multipart, ) -> impl IntoResponse { // Use the smart upload handler - let response = Self::upload_file_with_cache(State(state.clone()), multipart).await; + let response = + Self::upload_file_with_cache(State(state.clone()), auth_user, multipart).await; // Try to extract file info for thumbnail generation if let Ok(body_bytes) = @@ -827,6 +847,7 @@ impl FileHandler { fn domain_error_response(err: crate::common::errors::DomainError) -> Response { let status = match err.kind { crate::common::errors::ErrorKind::NotFound => StatusCode::NOT_FOUND, + crate::common::errors::ErrorKind::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE, _ => StatusCode::INTERNAL_SERVER_ERROR, }; Response::builder() @@ -838,6 +859,21 @@ impl FileHandler { .unwrap() } + /// Build a quota-specific error response with 507 status and structured body. + fn quota_error_response(err: crate::common::errors::DomainError) -> Response { + Response::builder() + .status(StatusCode::INSUFFICIENT_STORAGE) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ + "error": err.message, + "error_type": "QuotaExceeded" + }) + .to_string(), + )) + .unwrap() + } + /// Build response for cached/small files with optional compression. async fn build_cached_response( content: Bytes, diff --git a/src/interfaces/errors.rs b/src/interfaces/errors.rs index 666a353e..6dd05261 100644 --- a/src/interfaces/errors.rs +++ b/src/interfaces/errors.rs @@ -109,6 +109,7 @@ impl From for AppError { ErrorKind::NotImplemented => StatusCode::NOT_IMPLEMENTED, ErrorKind::UnsupportedOperation => StatusCode::METHOD_NOT_ALLOWED, ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, + ErrorKind::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE, }; Self { diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index 420c6d3f..160979f5 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -128,7 +128,15 @@ const fileOps = { fileRowElements.icon.innerHTML = ''; fileRowElements.icon.classList.add('error'); } - resolve({ ok: false }); + // Parse error body for quota-exceeded or other messages + let errorMsg = null; + let isQuotaError = false; + try { + const errBody = JSON.parse(xhr.responseText); + errorMsg = errBody.error || null; + isQuotaError = errBody.error_type === 'QuotaExceeded' || xhr.status === 507; + } catch (_) {} + resolve({ ok: false, errorMsg, isQuotaError }); } }); @@ -208,7 +216,14 @@ const fileOps = { console.log(`Successfully uploaded ${file.name}`, result.data); } else { console.error(`Upload error for ${file.name}`); - window.ui.showNotification('Error', `Error uploading file: ${file.name}`); + if (result.isQuotaError) { + const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded'; + window.ui.showNotification('Error', `${file.name}: ${msg}`); + // Stop uploading remaining files — quota is full + break; + } else { + window.ui.showNotification('Error', `Error uploading file: ${file.name}`); + } } } @@ -218,6 +233,11 @@ const fileOps = { // Wait for backend to persist, then reload await new Promise(resolve => setTimeout(resolve, 800)); + // Refresh storage usage display + if (typeof window.refreshUserData === 'function') { + try { await window.refreshUserData(); } catch (_) {} + } + try { await window.loadFiles({ forceRefresh: true }); } catch (reloadError) { @@ -330,6 +350,11 @@ const fileOps = { console.log(`Uploaded: ${file.webkitRelativePath}`); } else { console.error(`Error uploading ${file.webkitRelativePath}`); + if (result.isQuotaError) { + const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded'; + window.ui.showNotification('Error', `${file.name}: ${msg}`); + break; + } } } @@ -337,6 +362,11 @@ const fileOps = { this._finishUploadToast(successCount, totalFiles); await new Promise(resolve => setTimeout(resolve, 800)); + + // Refresh storage usage display + if (typeof window.refreshUserData === 'function') { + try { await window.refreshUserData(); } catch (_) {} + } try { await window.loadFiles({ forceRefresh: true }); diff --git a/static/sw.js b/static/sw.js index 537a782a..5524761a 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,5 +1,5 @@ // OxiCloud Service Worker -const CACHE_NAME = 'oxicloud-cache-v8'; +const CACHE_NAME = 'oxicloud-cache-v9'; const ASSETS_TO_CACHE = [ '/', '/index.html',