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 })
|
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:"
|
prop.namespace == "DAV:"
|
||||||
&& matches!(
|
&& (matches!(
|
||||||
prop.name.as_str(),
|
prop.name.as_str(),
|
||||||
"resourcetype"
|
"resourcetype"
|
||||||
| "displayname"
|
| "displayname"
|
||||||
@@ -401,7 +411,9 @@ impl WebDavAdapter {
|
|||||||
| "getetag"
|
| "getetag"
|
||||||
| "getcontentlength"
|
| "getcontentlength"
|
||||||
| "getcontenttype"
|
| "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 {
|
fn file_prop_is_known(prop: &QualifiedName) -> bool {
|
||||||
@@ -505,16 +517,25 @@ impl WebDavAdapter {
|
|||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
request: &PropFindRequest,
|
request: &PropFindRequest,
|
||||||
href: &str,
|
href: &str,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> 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>(
|
fn write_folder_response_with_dead_props<W: Write>(
|
||||||
xml_writer: &mut Writer<W>,
|
xml_writer: &mut Writer<W>,
|
||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
request: &PropFindRequest,
|
request: &PropFindRequest,
|
||||||
href: &str,
|
href: &str,
|
||||||
dead_props: &[(QualifiedName, Option<String>)],
|
dead_props: &[(QualifiedName, Option<String>)],
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
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.
|
// RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat.
|
||||||
// Props found in the dead store are returned in the dead 200 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.
|
// so exclude them from the 404 propstat to avoid duplicate reporting.
|
||||||
let (known, unknown): (Vec<_>, Vec<_>) =
|
let (known, unknown): (Vec<_>, Vec<_>) = props
|
||||||
props.iter().partition(|p| Self::folder_prop_is_known(p));
|
.iter()
|
||||||
|
.partition(|p| Self::folder_prop_is_known(p, quota));
|
||||||
let truly_unknown: Vec<_> = unknown
|
let truly_unknown: Vec<_> = unknown
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|p| !dead_name_set.contains(*p))
|
.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:propstat")))?;
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
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::End(BytesEnd::new("D:prop")))?;
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
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")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||||
match other {
|
match other {
|
||||||
PropFindType::AllProp => {
|
PropFindType::AllProp => {
|
||||||
Self::write_folder_standard_props(xml_writer, folder)?;
|
Self::write_folder_standard_props(xml_writer, folder, quota)?;
|
||||||
}
|
}
|
||||||
PropFindType::PropName => {
|
PropFindType::PropName => {
|
||||||
Self::write_folder_prop_names(xml_writer)?;
|
Self::write_folder_prop_names(xml_writer, quota)?;
|
||||||
}
|
}
|
||||||
PropFindType::Prop(_) => unreachable!(),
|
PropFindType::Prop(_) => unreachable!(),
|
||||||
}
|
}
|
||||||
@@ -675,6 +697,7 @@ impl WebDavAdapter {
|
|||||||
fn write_folder_standard_props<W: Write>(
|
fn write_folder_standard_props<W: Write>(
|
||||||
xml_writer: &mut Writer<W>,
|
xml_writer: &mut Writer<W>,
|
||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Resource type (collection)
|
// Resource type (collection)
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
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::Text(BytesText::new("httpd/unix-directory")))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,7 +830,10 @@ impl WebDavAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write folder property names
|
/// 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
|
// 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:resourcetype")))?;
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:displayname")))?;
|
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:getetag")))?;
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontentlength")))?;
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontentlength")))?;
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontenttype")))?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -812,6 +871,7 @@ impl WebDavAdapter {
|
|||||||
xml_writer: &mut Writer<W>,
|
xml_writer: &mut Writer<W>,
|
||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
props: &[&QualifiedName],
|
props: &[&QualifiedName],
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
for prop in props {
|
for prop in props {
|
||||||
if prop.namespace == "DAV:" {
|
if prop.namespace == "DAV:" {
|
||||||
@@ -872,6 +932,28 @@ impl WebDavAdapter {
|
|||||||
.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
|
.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
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.
|
// Unknown prop — skipped here; caller writes 404 propstat.
|
||||||
}
|
}
|
||||||
@@ -1400,7 +1482,7 @@ impl WebDavAdapter {
|
|||||||
request: &PropFindRequest,
|
request: &PropFindRequest,
|
||||||
href: &str,
|
href: &str,
|
||||||
) -> Result<()> {
|
) -> 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.
|
/// Writes a single `<D:response>` element for a file, including dead properties.
|
||||||
@@ -1420,8 +1502,11 @@ impl WebDavAdapter {
|
|||||||
request: &PropFindRequest,
|
request: &PropFindRequest,
|
||||||
href: &str,
|
href: &str,
|
||||||
dead_props: &[(QualifiedName, Option<String>)],
|
dead_props: &[(QualifiedName, Option<String>)],
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> 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.
|
/// 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 {
|
fn join_drive_path(root_name: &str, subpath: &str) -> String {
|
||||||
let subpath = subpath.trim_start_matches('/').trim_end_matches('/');
|
let subpath = subpath.trim_start_matches('/').trim_end_matches('/');
|
||||||
if subpath.is_empty() {
|
if subpath.is_empty() {
|
||||||
@@ -557,6 +584,7 @@ async fn handle_propfind(
|
|||||||
created_by: None,
|
created_by: None,
|
||||||
updated_by: None,
|
updated_by: None,
|
||||||
};
|
};
|
||||||
|
let quota = resolve_quota(&state, user.id).await;
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
root_folder,
|
root_folder,
|
||||||
None, // folder_id = None → root children (drive-root folders)
|
None, // folder_id = None → root children (drive-root folders)
|
||||||
@@ -567,6 +595,7 @@ async fn handle_propfind(
|
|||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
user.id,
|
user.id,
|
||||||
state.webdav_dead_props.clone(),
|
state.webdav_dead_props.clone(),
|
||||||
|
quota,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -595,6 +624,7 @@ async fn handle_propfind(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let folder_id = folder.id.clone();
|
let folder_id = folder.id.clone();
|
||||||
|
let quota = resolve_quota(&state, user.id).await;
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
folder,
|
folder,
|
||||||
Some(folder_id),
|
Some(folder_id),
|
||||||
@@ -605,6 +635,7 @@ async fn handle_propfind(
|
|||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
user.id,
|
user.id,
|
||||||
state.webdav_dead_props.clone(),
|
state.webdav_dead_props.clone(),
|
||||||
|
quota,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -659,6 +690,7 @@ async fn handle_propfind(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let folder_id = folder.id.clone();
|
let folder_id = folder.id.clone();
|
||||||
|
let quota = resolve_quota(&state, user.id).await;
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
folder,
|
folder,
|
||||||
Some(folder_id),
|
Some(folder_id),
|
||||||
@@ -669,6 +701,7 @@ async fn handle_propfind(
|
|||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
user.id,
|
user.id,
|
||||||
state.webdav_dead_props.clone(),
|
state.webdav_dead_props.clone(),
|
||||||
|
quota,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -732,6 +765,7 @@ async fn build_streaming_propfind_response(
|
|||||||
file_retrieval_service: std::sync::Arc<FileRetrievalService>,
|
file_retrieval_service: std::sync::Arc<FileRetrievalService>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
dead_props_store: Arc<DeadPropertyStore>,
|
dead_props_store: Arc<DeadPropertyStore>,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
let depth = depth.to_string();
|
let depth = depth.to_string();
|
||||||
let base_href = base_href.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);
|
let mut w = Writer::new(&mut buf);
|
||||||
WebDavAdapter::write_multistatus_start(&mut w)
|
WebDavAdapter::write_multistatus_start(&mut w)
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
.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()))?;
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
}
|
}
|
||||||
yield Bytes::from(buf);
|
yield Bytes::from(buf);
|
||||||
@@ -795,7 +829,7 @@ async fn build_streaming_propfind_response(
|
|||||||
let mut w = Writer::new(&mut chunk);
|
let mut w = Writer::new(&mut chunk);
|
||||||
for (subfolder, child_dead) in result.items.iter().zip(subfolder_deads.iter()) {
|
for (subfolder, child_dead) in result.items.iter().zip(subfolder_deads.iter()) {
|
||||||
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
|
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()))?;
|
.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