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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user