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:
@@ -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.
|
||||
|
||||
@@ -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()))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user