feat(webdav): drive-aware RFC 4331 quota properties
resolve_quota only ever reported the caller's personal envelope, ignoring the drive_id already resolved at every PROPFIND call site — shared drives with their own quota showed the wrong numbers. Adds AppState::resolve_webdav_quota, shared by both WebDAV surfaces: nil drive_id or personal drive -> account envelope, shared drive -> its own storage.drives quota/used_bytes. Also adds quota-used-bytes/quota-available-bytes to the NextCloud- compatible surface, which previously had no RFC 4331 support at all. Registers webdav_quota_properties.hurl and the new nc_webdav_quota_properties.hurl in tests/api/run.sh — neither was wired into the suite before this change.
This commit is contained in:
@@ -346,33 +346,6 @@ 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() {
|
||||
@@ -584,7 +557,7 @@ async fn handle_propfind(
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
let quota = resolve_quota(&state, user.id).await;
|
||||
let quota = state.resolve_webdav_quota(user.id, Uuid::nil()).await;
|
||||
return build_streaming_propfind_response(
|
||||
root_folder,
|
||||
None, // folder_id = None → root children (drive-root folders)
|
||||
@@ -624,7 +597,7 @@ async fn handle_propfind(
|
||||
)
|
||||
.await?;
|
||||
let folder_id = folder.id.clone();
|
||||
let quota = resolve_quota(&state, user.id).await;
|
||||
let quota = state.resolve_webdav_quota(user.id, drive_id).await;
|
||||
return build_streaming_propfind_response(
|
||||
folder,
|
||||
Some(folder_id),
|
||||
@@ -690,7 +663,7 @@ async fn handle_propfind(
|
||||
)
|
||||
.await?;
|
||||
let folder_id = folder.id.clone();
|
||||
let quota = resolve_quota(&state, user.id).await;
|
||||
let quota = state.resolve_webdav_quota(user.id, drive_id).await;
|
||||
return build_streaming_propfind_response(
|
||||
folder,
|
||||
Some(folder_id),
|
||||
|
||||
@@ -211,6 +211,10 @@ async fn handle_filter_files(
|
||||
oc_id.as_deref(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
// REPORT results are a flat filter/search listing, not a
|
||||
// PROPFIND on a specific collection — quota isn't
|
||||
// meaningful here (see `AppState::resolve_webdav_quota`).
|
||||
None,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -342,6 +346,10 @@ async fn handle_search(
|
||||
oc_id.as_deref(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
// REPORT results are a flat filter/search listing, not a
|
||||
// PROPFIND on a specific collection — quota isn't
|
||||
// meaningful here (see `AppState::resolve_webdav_quota`).
|
||||
None,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@ async fn handle_propfind(
|
||||
// function's username arg. Refining the owner-id usages
|
||||
// back to the canonical username is deferred to the
|
||||
// NcSession commit.
|
||||
let quota = state.resolve_webdav_quota(user.id, chroot.drive_id).await;
|
||||
Ok(build_nc_streaming_propfind(
|
||||
state.clone(),
|
||||
folder,
|
||||
@@ -343,6 +344,7 @@ async fn handle_propfind(
|
||||
user.id,
|
||||
url_user.to_string(),
|
||||
subpath.to_string(),
|
||||
quota,
|
||||
))
|
||||
}
|
||||
ResolvedResource::File(file) => {
|
||||
@@ -1495,6 +1497,7 @@ fn build_nc_streaming_propfind(
|
||||
user_id: Uuid,
|
||||
username: String,
|
||||
subpath: String,
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
) -> Response<Body> {
|
||||
let stream = async_stream::try_stream! {
|
||||
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
|
||||
@@ -1522,7 +1525,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_collection_href(&username, &subpath);
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(&mut xml, &folder, &href, fid, oc_id.as_deref(), &username, &folder_favs)
|
||||
write_folder_response(&mut xml, &folder, &href, fid, oc_id.as_deref(), &username, &folder_favs, quota)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
yield Bytes::from(buf);
|
||||
@@ -1613,7 +1616,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_collection_href(&username, &child_sub);
|
||||
let fid = sub_id_map.get(&sf.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(&mut xml, sf, &href, fid, oc_id.as_deref(), &username, &favs)
|
||||
write_folder_response(&mut xml, sf, &href, fid, oc_id.as_deref(), &username, &favs, quota)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
@@ -1648,6 +1651,7 @@ fn build_nc_streaming_propfind(
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn write_folder_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
@@ -1656,6 +1660,7 @@ pub fn write_folder_response<W: std::io::Write>(
|
||||
oc_id: Option<&str>,
|
||||
owner: &str,
|
||||
favorite_ids: &HashSet<String>,
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
) -> Result<(), String> {
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.xml_err()?;
|
||||
@@ -1705,6 +1710,16 @@ pub fn write_folder_response<W: std::io::Write>(
|
||||
// Numeric share-permissions bitmask: Read=1 + Update=2 + Create=4 + Delete=8 + Share=16 = 31
|
||||
write_text_element(xml, "ocs:share-permissions", "31")?;
|
||||
write_text_element(xml, "oc:size", "0")?;
|
||||
// RFC 4331 — same account/drive-wide value regardless of which
|
||||
// folder entry is being described, mirroring the native WebDAV
|
||||
// surface's `write_folder_standard_props` (see
|
||||
// `AppState::resolve_webdav_quota`).
|
||||
if let Some((used, available)) = quota {
|
||||
write_text_element(xml, "d:quota-used-bytes", &used.to_string())?;
|
||||
if let Some(avail) = available {
|
||||
write_text_element(xml, "d:quota-available-bytes", &avail.to_string())?;
|
||||
}
|
||||
}
|
||||
write_text_element(xml, "oc:owner-id", owner)?;
|
||||
write_text_element(xml, "oc:owner-display-name", owner)?;
|
||||
write_text_element(xml, "nc:has-preview", "false")?;
|
||||
|
||||
Reference in New Issue
Block a user