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
@@ -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,