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)
}
}
+14
View File
@@ -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<S: Into<String>>(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<S: Into<String>>(message: S) -> Self {
Self {
@@ -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<Arc<AppState>>,
auth_user: AuthUser,
Json(request): Json<CreateUploadRequest>,
) -> 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 {
+38 -2
View File
@@ -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<GlobalState>,
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<GlobalState>,
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<Body> {
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<Body> {
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,
+1
View File
@@ -109,6 +109,7 @@ impl From<DomainError> 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 {
+32 -2
View File
@@ -128,7 +128,15 @@ const fileOps = {
fileRowElements.icon.innerHTML = '<i class="fas fa-exclamation-circle"></i>';
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;
}
}
}
@@ -338,6 +363,11 @@ const fileOps = {
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) {
+1 -1
View File
@@ -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',