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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user