feat(webdav): add RFC 4331 quota-available-bytes/quota-used-bytes properties

Threads the caller's account-wide (used, available) storage figures through
PROPFIND for the plain-file WebDAV surface, resolved once per request via
StorageUsagePort::get_user_storage_info and reused for every folder entry
in the response. Unlimited accounts (quota <= 0) omit quota-available-bytes
entirely per RFC 4331 §3, rather than disclosing a sentinel value.
Properties are only advertised as known when the quota subsystem is enabled
and the lookup succeeds; otherwise they fall through to the standard 404
propstat.
This commit is contained in:
M.Schmidt
2026-07-11 08:34:02 +02:00
parent 9ed360443a
commit f017c700f1
3 changed files with 248 additions and 14 deletions
+36 -2
View File
@@ -346,6 +346,33 @@ async fn resolve_webdav_scope_or_405(
}
}
/// Resolve `(used_bytes, available_bytes)` for RFC 4331 quota properties.
///
/// `None` when the quota subsystem is disabled (`storage_usage_service` is
/// only wired up behind its feature flag) or the lookup fails — callers
/// treat that as "quota properties aren't known", not an error, since
/// PROPFIND must still succeed for the rest of the response. Quota is
/// account-wide, not per-folder, so this is resolved once per PROPFIND
/// request and reused for every folder entry in the response.
///
/// `available_bytes` is itself `None` for unlimited accounts (quota <= 0):
/// RFC 4331 §3 says a server MAY omit `quota-available-bytes` when there's
/// no enforced/finite quota rather than disclose a made-up value, so
/// callers drop the property (404 propstat) instead of reporting a
/// sentinel like `i64::MAX`. `quota-used-bytes` is unaffected — it's a real
/// measured value regardless of whether a limit exists.
async fn resolve_quota(state: &Arc<AppState>, user_id: Uuid) -> Option<(i64, Option<i64>)> {
let storage_svc = state.storage_usage_service.as_ref()?;
let (used, quota_bytes) = storage_svc.get_user_storage_info(user_id).await.ok()?;
// Quota <= 0 means unlimited (see `StorageUsageService::check_storage_quota`).
let available = if quota_bytes <= 0 {
None
} else {
Some((quota_bytes - used).max(0))
};
Some((used, available))
}
fn join_drive_path(root_name: &str, subpath: &str) -> String {
let subpath = subpath.trim_start_matches('/').trim_end_matches('/');
if subpath.is_empty() {
@@ -557,6 +584,7 @@ async fn handle_propfind(
created_by: None,
updated_by: None,
};
let quota = resolve_quota(&state, user.id).await;
return build_streaming_propfind_response(
root_folder,
None, // folder_id = None → root children (drive-root folders)
@@ -567,6 +595,7 @@ async fn handle_propfind(
file_retrieval_service,
user.id,
state.webdav_dead_props.clone(),
quota,
)
.await;
}
@@ -595,6 +624,7 @@ async fn handle_propfind(
)
.await?;
let folder_id = folder.id.clone();
let quota = resolve_quota(&state, user.id).await;
return build_streaming_propfind_response(
folder,
Some(folder_id),
@@ -605,6 +635,7 @@ async fn handle_propfind(
file_retrieval_service,
user.id,
state.webdav_dead_props.clone(),
quota,
)
.await;
}
@@ -659,6 +690,7 @@ async fn handle_propfind(
)
.await?;
let folder_id = folder.id.clone();
let quota = resolve_quota(&state, user.id).await;
return build_streaming_propfind_response(
folder,
Some(folder_id),
@@ -669,6 +701,7 @@ async fn handle_propfind(
file_retrieval_service,
user.id,
state.webdav_dead_props.clone(),
quota,
)
.await;
}
@@ -732,6 +765,7 @@ async fn build_streaming_propfind_response(
file_retrieval_service: std::sync::Arc<FileRetrievalService>,
user_id: Uuid,
dead_props_store: Arc<DeadPropertyStore>,
quota: Option<(i64, Option<i64>)>,
) -> Result<Response<Body>, AppError> {
let depth = depth.to_string();
let base_href = base_href.to_string();
@@ -752,7 +786,7 @@ async fn build_streaming_propfind_response(
let mut w = Writer::new(&mut buf);
WebDavAdapter::write_multistatus_start(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, &folder, &propfind_request, &base_href, &folder_dead)
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, &folder, &propfind_request, &base_href, &folder_dead, quota)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
@@ -795,7 +829,7 @@ async fn build_streaming_propfind_response(
let mut w = Writer::new(&mut chunk);
for (subfolder, child_dead) in result.items.iter().zip(subfolder_deads.iter()) {
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead)
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead, quota)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
}