diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 6731e185..61d5f8ff 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -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)>) -> 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)>, ) -> 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( xml_writer: &mut Writer, folder: &FolderDto, request: &PropFindRequest, href: &str, dead_props: &[(QualifiedName, Option)], + quota: Option<(i64, Option)>, ) -> 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( xml_writer: &mut Writer, folder: &FolderDto, + quota: Option<(i64, Option)>, ) -> 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( + xml_writer: &mut Writer, + used_bytes: i64, + available_bytes: Option, + ) -> 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(xml_writer: &mut Writer) -> Result<()> { + fn write_folder_prop_names( + xml_writer: &mut Writer, + quota: Option<(i64, Option)>, + ) -> 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, folder: &FolderDto, props: &[&QualifiedName], + quota: Option<(i64, Option)>, ) -> 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 `` element for a file, including dead properties. @@ -1420,8 +1502,11 @@ impl WebDavAdapter { request: &PropFindRequest, href: &str, dead_props: &[(QualifiedName, Option)], + quota: Option<(i64, Option)>, ) -> 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. diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 6761efce..073256ea 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -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, user_id: Uuid) -> Option<(i64, Option)> { + 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, user_id: Uuid, dead_props_store: Arc, + quota: Option<(i64, Option)>, ) -> Result, 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()))?; } } diff --git a/tests/api/webdav_quota_properties.hurl b/tests/api/webdav_quota_properties.hurl new file mode 100644 index 00000000..4f5fbe9c --- /dev/null +++ b/tests/api/webdav_quota_properties.hurl @@ -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 +``` + + + + + + + +``` + +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 +``` + + + + +``` + +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 +``` + + + + + + +``` + +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