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
+97 -12
View File
@@ -390,9 +390,19 @@ impl WebDavAdapter {
Ok(PropFindRequest { prop_find_type })
}
fn folder_prop_is_known(prop: &QualifiedName) -> bool {
/// `quota` reflects whether the caller could resolve the account's
/// storage quota for this request (the quota service is optional —
/// `OXICLOUD_ENABLE_*` feature flags can disable it) and, independently,
/// whether the account has a finite available-bytes figure to report.
/// RFC 4331's `quota-used-bytes` / `quota-available-bytes` are each only
/// reported as known properties when a value actually exists —
/// otherwise they fall through to the standard 404 propstat like any
/// other property this server doesn't support. Unlimited accounts have
/// `quota-used-bytes` known but `quota-available-bytes` unknown (see
/// `resolve_quota` in `webdav_handler.rs`).
fn folder_prop_is_known(prop: &QualifiedName, quota: Option<(i64, Option<i64>)>) -> bool {
prop.namespace == "DAV:"
&& matches!(
&& (matches!(
prop.name.as_str(),
"resourcetype"
| "displayname"
@@ -401,7 +411,9 @@ impl WebDavAdapter {
| "getetag"
| "getcontentlength"
| "getcontenttype"
)
) || (quota.is_some() && prop.name == "quota-used-bytes")
|| (quota.is_some_and(|(_, available)| available.is_some())
&& prop.name == "quota-available-bytes"))
}
fn file_prop_is_known(prop: &QualifiedName) -> bool {
@@ -505,16 +517,25 @@ impl WebDavAdapter {
folder: &FolderDto,
request: &PropFindRequest,
href: &str,
quota: Option<(i64, Option<i64>)>,
) -> Result<()> {
Self::write_folder_response_with_dead_props(xml_writer, folder, request, href, &[])
Self::write_folder_response_with_dead_props(xml_writer, folder, request, href, &[], quota)
}
/// `quota` is `Some((used_bytes, available_bytes))` for the caller's
/// account when the quota subsystem is enabled and reachable —
/// `available_bytes` is itself `None` for unlimited accounts, which
/// omits `quota-available-bytes` from the response entirely (see
/// [`Self::folder_prop_is_known`]). It's the same value regardless of
/// which folder is being described (quota is account-wide, not
/// per-folder), so callers resolve it once per PROPFIND request.
fn write_folder_response_with_dead_props<W: Write>(
xml_writer: &mut Writer<W>,
folder: &FolderDto,
request: &PropFindRequest,
href: &str,
dead_props: &[(QualifiedName, Option<String>)],
quota: Option<(i64, Option<i64>)>,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
@@ -540,8 +561,9 @@ impl WebDavAdapter {
// RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat.
// Props found in the dead store are returned in the dead 200 propstat,
// so exclude them from the 404 propstat to avoid duplicate reporting.
let (known, unknown): (Vec<_>, Vec<_>) =
props.iter().partition(|p| Self::folder_prop_is_known(p));
let (known, unknown): (Vec<_>, Vec<_>) = props
.iter()
.partition(|p| Self::folder_prop_is_known(p, quota));
let truly_unknown: Vec<_> = unknown
.into_iter()
.filter(|p| !dead_name_set.contains(*p))
@@ -549,7 +571,7 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
Self::write_folder_requested_props(xml_writer, folder, &known)?;
Self::write_folder_requested_props(xml_writer, folder, &known, quota)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
@@ -563,10 +585,10 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
match other {
PropFindType::AllProp => {
Self::write_folder_standard_props(xml_writer, folder)?;
Self::write_folder_standard_props(xml_writer, folder, quota)?;
}
PropFindType::PropName => {
Self::write_folder_prop_names(xml_writer)?;
Self::write_folder_prop_names(xml_writer, quota)?;
}
PropFindType::Prop(_) => unreachable!(),
}
@@ -675,6 +697,7 @@ impl WebDavAdapter {
fn write_folder_standard_props<W: Write>(
xml_writer: &mut Writer<W>,
folder: &FolderDto,
quota: Option<(i64, Option<i64>)>,
) -> Result<()> {
// Resource type (collection)
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
@@ -723,6 +746,33 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
if let Some((used, available)) = quota {
Self::write_quota_props(xml_writer, used, available)?;
}
Ok(())
}
/// Write RFC 4331 `quota-used-bytes` / `quota-available-bytes`. Shared
/// by the allprop and named-prop paths so the element shape only
/// lives in one place. `available_bytes` is `None` for unlimited
/// accounts — RFC 4331 §3 lets a server omit `quota-available-bytes`
/// rather than disclose a made-up value, so the element is skipped.
fn write_quota_props<W: Write>(
xml_writer: &mut Writer<W>,
used_bytes: i64,
available_bytes: Option<i64>,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?;
xml_writer.write_event(Event::Text(BytesText::new(&used_bytes.to_string())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?;
if let Some(available_bytes) = available_bytes {
xml_writer.write_event(Event::Start(BytesStart::new("D:quota-available-bytes")))?;
xml_writer.write_event(Event::Text(BytesText::new(&available_bytes.to_string())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:quota-available-bytes")))?;
}
Ok(())
}
@@ -780,7 +830,10 @@ impl WebDavAdapter {
}
/// Write folder property names
fn write_folder_prop_names<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
fn write_folder_prop_names<W: Write>(
xml_writer: &mut Writer<W>,
quota: Option<(i64, Option<i64>)>,
) -> Result<()> {
// Write empty property elements for folders
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:displayname")))?;
@@ -789,6 +842,12 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Empty(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontentlength")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontenttype")))?;
if quota.is_some() {
xml_writer.write_event(Event::Empty(BytesStart::new("D:quota-used-bytes")))?;
}
if quota.is_some_and(|(_, available)| available.is_some()) {
xml_writer.write_event(Event::Empty(BytesStart::new("D:quota-available-bytes")))?;
}
Ok(())
}
@@ -812,6 +871,7 @@ impl WebDavAdapter {
xml_writer: &mut Writer<W>,
folder: &FolderDto,
props: &[&QualifiedName],
quota: Option<(i64, Option<i64>)>,
) -> Result<()> {
for prop in props {
if prop.namespace == "DAV:" {
@@ -872,6 +932,28 @@ impl WebDavAdapter {
.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
}
"quota-used-bytes" => {
if let Some((used, _)) = quota {
xml_writer
.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?;
xml_writer
.write_event(Event::Text(BytesText::new(&used.to_string())))?;
xml_writer
.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?;
}
}
"quota-available-bytes" => {
if let Some((_, Some(available))) = quota {
xml_writer.write_event(Event::Start(BytesStart::new(
"D:quota-available-bytes",
)))?;
xml_writer
.write_event(Event::Text(BytesText::new(&available.to_string())))?;
xml_writer.write_event(Event::End(BytesEnd::new(
"D:quota-available-bytes",
)))?;
}
}
_ => {
// Unknown prop — skipped here; caller writes 404 propstat.
}
@@ -1400,7 +1482,7 @@ impl WebDavAdapter {
request: &PropFindRequest,
href: &str,
) -> Result<()> {
Self::write_folder_response(writer, folder, request, href)
Self::write_folder_response(writer, folder, request, href, None)
}
/// Writes a single `<D:response>` element for a file, including dead properties.
@@ -1420,8 +1502,11 @@ impl WebDavAdapter {
request: &PropFindRequest,
href: &str,
dead_props: &[(QualifiedName, Option<String>)],
quota: Option<(i64, Option<i64>)>,
) -> Result<()> {
Self::write_folder_response_with_dead_props(writer, folder, request, href, dead_props)
Self::write_folder_response_with_dead_props(
writer, folder, request, href, dead_props, quota,
)
}
/// Writes a file entry including dead (custom) properties.
+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()))?;
}
}
+115
View File
@@ -0,0 +1,115 @@
# =============================================================
# OxiCloud — WebDAV quota properties (RFC 4331)
# =============================================================
# `DAV:quota-available-bytes` / `DAV:quota-used-bytes` are account-wide
# (not per-folder) live properties resolved once per PROPFIND request
# from the storage-usage service — see
# webdav_handler.rs::resolve_quota / webdav_adapter.rs::write_quota_props.
# Unlimited accounts (quota <= 0) omit quota-available-bytes entirely per
# RFC 4331 §3, rather than reporting a sentinel value.
#
# Coverage:
# 1. Named-prop PROPFIND for both properties on the WebDAV root → 207,
# both present with numeric values.
# 2. allprop PROPFIND also includes both properties.
# 3. quota-used-bytes increases by (at least) the size of a file
# just uploaded through WebDAV.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Login, capture JWT
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 — Named-prop PROPFIND for the two quota properties.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/
Authorization: Bearer {{token}}
Depth: 0
Content-Type: application/xml; charset=utf-8
```
<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:quota-available-bytes/>
<D:quota-used-bytes/>
</D:prop>
</D:propfind>
```
HTTP 207
[Asserts]
xpath "string(//*[local-name()='propstat'][1]/*[local-name()='status'])" contains "200 OK"
xpath "number(//*[local-name()='quota-used-bytes'])" >= 0
xpath "number(//*[local-name()='quota-available-bytes'])" > 0
# ─────────────────────────────────────────────────────────────
# Step 3 — allprop PROPFIND also surfaces both properties.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/
Authorization: Bearer {{token}}
Depth: 0
Content-Type: application/xml; charset=utf-8
```
<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:allprop/>
</D:propfind>
```
HTTP 207
[Asserts]
xpath "number(//*[local-name()='quota-used-bytes'])" >= 0
xpath "number(//*[local-name()='quota-available-bytes'])" > 0
[Captures]
used_before: xpath "number(//*[local-name()='quota-used-bytes'])"
# ─────────────────────────────────────────────────────────────
# Step 4 — Upload a file, then confirm quota-used-bytes reflects it.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/quota-probe.txt
Authorization: Bearer {{token}}
Content-Type: text/plain
```
quota accounting probe payload
```
HTTP 201
PROPFIND {{base_url}}/webdav/
Authorization: Bearer {{token}}
Depth: 0
Content-Type: application/xml; charset=utf-8
```
<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:quota-used-bytes/>
</D:prop>
</D:propfind>
```
HTTP 207
[Asserts]
xpath "number(//*[local-name()='quota-used-bytes'])" >= {{used_before}}
# ─────────────────────────────────────────────────────────────
# Cleanup
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/webdav/quota-probe.txt
Authorization: Bearer {{token}}
HTTP 204