feat(drive): add quota calculus per drive

- quota per drive
    - add 2 internal API endpoints to test purpose
        disabled by default, enable it via `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`

        this enable:

            /api/admin/internal/trigger-sweep
                to sweep the trash and recalculated quota

            /api/admin/internal/trigger-gc
                to garbage orphan blobs

        use full for end to end tests and validate lifecycles
This commit is contained in:
Edouard Vanbelle
2026-06-24 22:28:15 +02:00
parent b73f176024
commit a9a8604322
8 changed files with 391 additions and 17 deletions
@@ -95,6 +95,13 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
// when `OXICLOUD_SMTP_MOCK` is off, so production deployments
// can route the path freely without leaking inboxes.
.route("/smtp/test/captured", get(get_captured_email))
// Test-only sweep triggers. Routes are always registered; the
// handlers themselves short-circuit to 404 when
// `features.enable_admin_internal_endpoints` is off — matches
// the `/smtp/test/captured` convention so production
// deployments don't need a different route table.
.route("/internal/trigger-sweep", post(internal_trigger_sweep))
.route("/internal/trigger-gc", post(internal_trigger_gc))
// Drives — admin-wide view (distinct from `/api/drives` which
// is filtered to the caller's role grants).
.route("/drives", get(list_all_drives))
@@ -214,7 +214,7 @@ impl ChunkedUploadHandler {
.await
{
tracing::warn!(
"⛔ CHUNKED UPLOAD REJECTED (quota): user={}, file={}, size={} — {}",
"⛔ CHUNKED UPLOAD REJECTED (user quota): user={}, file={}, size={} — {}",
auth_user.username,
request.filename,
request.total_size,
@@ -230,6 +230,37 @@ impl ChunkedUploadHandler {
.into_response();
}
// ── Per-drive quota (D4) ─────────────────────────────────
// Native-chunked declares `total_size` at session creation,
// so we can refuse here before any chunk is accepted — same
// wasted-bandwidth optimisation the multipart path has via
// the post-ingest check. No folder_id means root-level which
// the folder-permission check above already rejects.
if let Some(storage_svc) = state.storage_usage_service.as_ref()
&& let Some(fid_str) = request.folder_id.as_deref()
&& let Ok(fid) = uuid::Uuid::parse_str(fid_str)
&& let Err(err) = storage_svc
.check_drive_quota_by_folder(fid, request.total_size)
.await
{
tracing::warn!(
"⛔ CHUNKED UPLOAD REJECTED (drive quota): user={}, folder={}, file={}, size={} — {}",
auth_user.username,
fid,
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 {
+26 -1
View File
@@ -267,7 +267,7 @@ impl FileHandler {
{
upload_ingest::discard_ingested(dedup, &ingested).await;
tracing::warn!(
"⛔ UPLOAD REJECTED (quota): user={}, file={}, size={}",
"⛔ UPLOAD REJECTED (user quota): user={}, file={}, size={}",
auth_user.username,
filename,
ingested.size
@@ -275,6 +275,31 @@ impl FileHandler {
return Err(Self::quota_error_response(err));
}
// ── Per-drive quota enforcement (D4) ─────────────────
// Sibling to the per-user check above: same read-only
// SELECT shape, same discard-then-507 outcome. Skipped
// when there's no folder_id (root-level upload — no
// drive to charge; folder service refuses these
// independently). Unlimited-quota drives (`NULL`)
// short-circuit inside the service.
if let Some(storage_svc) = state.storage_usage_service.as_ref()
&& let Some(fid_str) = folder_id.as_deref()
&& let Ok(fid) = uuid::Uuid::parse_str(fid_str)
&& let Err(err) = storage_svc
.check_drive_quota_by_folder(fid, ingested.size)
.await
{
upload_ingest::discard_ingested(dedup, &ingested).await;
tracing::warn!(
"⛔ UPLOAD REJECTED (drive quota): user={}, folder={}, file={}, size={}",
auth_user.username,
fid,
filename,
ingested.size
);
return Err(Self::quota_error_response(err));
}
// ── Register the file row against the ingested blob ──
let hash = ingested.hash.clone();
let size = ingested.size;