From f017c700f1e149561227406e4282b17e43be1aeb Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Sat, 11 Jul 2026 08:34:02 +0200 Subject: [PATCH 1/7] feat(webdav): add RFC 4331 quota-available-bytes/quota-used-bytes properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/application/adapters/webdav_adapter.rs | 109 +++++++++++++++-- src/interfaces/api/handlers/webdav_handler.rs | 38 +++++- tests/api/webdav_quota_properties.hurl | 115 ++++++++++++++++++ 3 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 tests/api/webdav_quota_properties.hurl 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 From 3cb12c6fc1fe5aca0a869c644e21c034d2a4cce9 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Sun, 12 Jul 2026 21:04:37 +0200 Subject: [PATCH 2/7] refactor(readability): and reducing condition evaluation twice on same condition --- src/application/adapters/webdav_adapter.rs | 31 ++++++++++------------ 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 61d5f8ff..326d653d 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -401,19 +401,16 @@ impl WebDavAdapter { /// `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!( - prop.name.as_str(), - "resourcetype" - | "displayname" - | "creationdate" - | "getlastmodified" - | "getetag" - | "getcontentlength" - | "getcontenttype" - ) || (quota.is_some() && prop.name == "quota-used-bytes") - || (quota.is_some_and(|(_, available)| available.is_some()) - && prop.name == "quota-available-bytes")) + if prop.namespace != "DAV:" { + return false; + } + match prop.name.as_str() { + "resourcetype" | "displayname" | "creationdate" | "getlastmodified" | "getetag" + | "getcontentlength" | "getcontenttype" => true, + "quota-used-bytes" => quota.is_some(), + "quota-available-bytes" => quota.is_some_and(|(_, available)| available.is_some()), + _ => false, + } } fn file_prop_is_known(prop: &QualifiedName) -> bool { @@ -842,11 +839,11 @@ 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() { + if let Some((_, available)) = quota { 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")))?; + if available.is_some() { + xml_writer.write_event(Event::Empty(BytesStart::new("D:quota-available-bytes")))?; + } } Ok(()) From fdef73380f3757f71badd7a044c641e1553f60c5 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Sun, 12 Jul 2026 22:30:45 +0200 Subject: [PATCH 3/7] fix(thumbnail): remove redundant reference in format! arg clippy::useless_borrows_in_formatting --- src/infrastructure/services/thumbnail_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index cbcd2b2a..5f8913cd 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -1479,7 +1479,7 @@ impl crate::application::ports::blob_lifecycle::BlobLifecycleHook for ThumbnailS for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] { let path = root.join(size.dir_name()) - .join(format!("{}.{}", &blob_hash, format.ext())); + .join(format!("{}.{}", blob_hash, format.ext())); if tokio::fs::metadata(&path).await.is_ok() { let _ = tokio::fs::remove_file(&path).await; } From c07aeabd8541dea89117741b6e0c1ec5ffc720c6 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Mon, 13 Jul 2026 00:34:17 +0200 Subject: [PATCH 4/7] feat(webdav): drive-aware RFC 4331 quota properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/common/di.rs | 45 +++++ src/interfaces/api/handlers/webdav_handler.rs | 33 +--- src/interfaces/nextcloud/report_handler.rs | 8 + src/interfaces/nextcloud/webdav_handler.rs | 19 ++- tests/api/nc_webdav_quota_properties.hurl | 159 ++++++++++++++++++ tests/api/run.sh | 2 + tests/api/webdav_quota_properties.hurl | 124 +++++++++++++- 7 files changed, 352 insertions(+), 38 deletions(-) create mode 100644 tests/api/nc_webdav_quota_properties.hurl diff --git a/src/common/di.rs b/src/common/di.rs index b6dfa469..8700b55d 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1,9 +1,12 @@ use sqlx::PgPool; use std::path::{Path, PathBuf}; use std::sync::Arc; +use uuid::Uuid; use crate::application::ports::blob_storage_ports::BlobStorageBackend; +use crate::application::ports::storage_ports::StorageUsagePort; use crate::common::config::StorageBackendType; +use crate::domain::repositories::drive_repository::DriveRepository; use crate::infrastructure::db::DbPools; use crate::application::services::admin_settings_service::AdminSettingsService; @@ -2134,6 +2137,48 @@ pub struct AppState { // All AppState construction is done via struct literal in build_app_state(). +impl AppState { + /// Drive-aware RFC 4331 quota resolution — shared by the native and + /// NextCloud-compatible WebDAV PROPFIND handlers so both surfaces + /// report the same numbers for the same drive. + /// + /// - `drive_id == Uuid::nil()`: synthetic drive-listing pseudo-root — + /// no single drive, so the account envelope is the only defensible + /// answer. + /// - Personal drives carry no quota of their own (`Drive::quota_bytes` + /// is NULL post-migration) — the account envelope in `auth.users` + /// caps them. + /// - Shared drives carry their own finite quota on `storage.drives` — + /// report that, not the owner's unrelated personal envelope. + /// + /// `available` is `None` for unlimited accounts/drives (quota <= 0 or + /// unset) — RFC 4331 §3 lets a server omit `quota-available-bytes` + /// rather than disclose a made-up value. Any lookup failure (quota + /// subsystem disabled, drive gone) is treated the same way: quota is + /// silently omitted rather than failing the whole PROPFIND. + pub async fn resolve_webdav_quota( + &self, + user_id: Uuid, + drive_id: Uuid, + ) -> Option<(i64, Option)> { + let storage_svc = self.storage_usage_service.as_ref()?; + + if drive_id.is_nil() { + let (used, quota) = storage_svc.get_user_storage_info(user_id).await.ok()?; + return Some((used, (quota > 0).then(|| (quota - used).max(0)))); + } + + let drive = self.drive_repo.get_by_id(drive_id).await.ok()?.drive; + if drive.is_personal() { + let (used, quota) = storage_svc.get_user_storage_info(user_id).await.ok()?; + Some((used, (quota > 0).then(|| (quota - used).max(0)))) + } else { + let used = drive.used_bytes; + Some((used, drive.quota_bytes.map(|q| (q - used).max(0)))) + } + } +} + /// Builds the authorization engine. Today this only constructs `PgAclEngine`; /// the `OXICLOUD_AUTHZ_ENGINE` env var is reserved for future alternate /// implementations (e.g. `openfga`). diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 073256ea..22c109a2 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -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, 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() { @@ -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), diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 39f913c0..98f3356e 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -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)))?; } diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 74eba460..fc4703a7 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -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)>, ) -> Response { 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( xml: &mut Writer, folder: &FolderDto, @@ -1656,6 +1660,7 @@ pub fn write_folder_response( oc_id: Option<&str>, owner: &str, favorite_ids: &HashSet, + quota: Option<(i64, Option)>, ) -> Result<(), String> { xml.write_event(Event::Start(BytesStart::new("d:response"))) .xml_err()?; @@ -1705,6 +1710,16 @@ pub fn write_folder_response( // 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")?; diff --git a/tests/api/nc_webdav_quota_properties.hurl b/tests/api/nc_webdav_quota_properties.hurl new file mode 100644 index 00000000..928f14c4 --- /dev/null +++ b/tests/api/nc_webdav_quota_properties.hurl @@ -0,0 +1,159 @@ +# ============================================================= +# OxiCloud — NC WebDAV quota properties (RFC 4331) +# ============================================================= +# The NextCloud-compatible WebDAV surface +# (`interfaces/nextcloud/webdav_handler.rs`) previously had NO +# `d:quota-used-bytes`/`d:quota-available-bytes` support at all. This +# pins the new coverage added alongside the native surface's +# drive-awareness fix (`AppState::resolve_webdav_quota`): +# +# 1. Personal-drive PROPFIND (no drive marker in the Basic Auth +# username) reports the caller's account envelope. +# 2. A SHARED drive with its own finite quota reports THAT quota — +# not the owner's personal envelope — when addressed via the +# multi-drive POC's `{user}~{root_folder_id}` Basic Auth marker +# (see `basic_auth_middleware.rs` — the marker is the drive's +# ROOT FOLDER id, not the drive's own id). +# 3. quota-used-bytes on the shared drive increases after a PUT. +# +# This file always emits the full property set regardless of the +# PROPFIND request body (see `handle_propfind`'s doc comment — "the +# NC response always emits the full property set"), so no XML body +# is needed to request the props; a bare PROPFIND suffices. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Admin login + mint admin's own NC app password (for +# the personal-envelope case). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "label": "nc_webdav_quota_properties personal" } + +HTTP 200 +[Captures] +admin_nc_username: jsonpath "$.username" +admin_nc_password: jsonpath "$.password" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Personal-drive PROPFIND (no `~` marker) surfaces the +# account envelope. `>= 0` / `> 0` rather than exact +# numbers since admin's envelope already has content +# from earlier tests in the suite. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +Depth: 0 +[BasicAuth] +{{admin_nc_username}}: {{admin_nc_password}} + +HTTP 207 +[Asserts] +xpath "number(//*[local-name()='quota-used-bytes'])" >= 0 +xpath "number(//*[local-name()='quota-available-bytes'])" > 0 + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Fresh user + a 500-byte shared drive owned by them. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "ncq_owner", + "password": "NcqOwnerPwd1!", + "email": "ncq_owner@example.com", + "role": "user" +} + +HTTP 201 + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "ncq_owner", "password": "NcqOwnerPwd1!" } + +HTTP 200 +[Captures] +ncq_owner_jwt: jsonpath "$.access_token" +ncq_owner_id: jsonpath "$.user.id" + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{ncq_owner_jwt}} +Content-Type: application/json +{ "label": "nc_webdav_quota_properties shared" } + +HTTP 200 +[Captures] +ncq_nc_username: jsonpath "$.username" +ncq_nc_password: jsonpath "$.password" + + +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "ncq-shared", + "owner": { "type": "user", "id": "{{ncq_owner_id}}" }, + "quota_bytes": 500 +} + +HTTP 201 +[Captures] +ncq_root_folder_id: jsonpath "$.root_folder_id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — PROPFIND the shared drive via the multi-drive POC's +# `{user}~{root_folder_id}` Basic Auth marker. Brand-new +# drive → used == 0, available == quota exactly. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/remote.php/dav/files/{{ncq_nc_username}}~{{ncq_root_folder_id}}/ +Depth: 0 +[BasicAuth] +{{ncq_nc_username}}~{{ncq_root_folder_id}}: {{ncq_nc_password}} + +HTTP 207 +[Asserts] +xpath "number(//*[local-name()='quota-used-bytes'])" == 0 +xpath "number(//*[local-name()='quota-available-bytes'])" == 500 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PUT a file into the shared drive; quota-used-bytes +# must reflect it, quota-available-bytes must shrink. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/remote.php/dav/files/{{ncq_nc_username}}~{{ncq_root_folder_id}}/quota-probe.txt +Content-Type: text/plain +[BasicAuth] +{{ncq_nc_username}}~{{ncq_root_folder_id}}: {{ncq_nc_password}} +``` +32-byte-ish payload for nc +``` + +HTTP 201 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{ncq_nc_username}}~{{ncq_root_folder_id}}/ +Depth: 0 +[BasicAuth] +{{ncq_nc_username}}~{{ncq_root_folder_id}}: {{ncq_nc_password}} + +HTTP 207 +[Asserts] +xpath "number(//*[local-name()='quota-used-bytes'])" > 0 +xpath "number(//*[local-name()='quota-available-bytes'])" < 500 + + +# No further cleanup needed — `tests/api/storage_cleanup_check.sh` +# drains/deletes every non-admin-default drive at suite end. diff --git a/tests/api/run.sh b/tests/api/run.sh index 5f734bb1..e8ba8050 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -187,6 +187,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/cross_drive_move.hurl" \ "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/webdav_dead_properties.hurl" \ + "$API_DIR/webdav_quota_properties.hurl" \ + "$API_DIR/nc_webdav_quota_properties.hurl" \ "$API_DIR/webdav_drive_root.hurl" \ "$API_DIR/webdav_permissions.hurl" \ "$API_DIR/webdav_nested_move_cascade.hurl" \ diff --git a/tests/api/webdav_quota_properties.hurl b/tests/api/webdav_quota_properties.hurl index 4f5fbe9c..e7d48f93 100644 --- a/tests/api/webdav_quota_properties.hurl +++ b/tests/api/webdav_quota_properties.hurl @@ -1,12 +1,12 @@ # ============================================================= # 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. +# `DAV:quota-available-bytes` / `DAV:quota-used-bytes` are resolved once +# per PROPFIND request via `AppState::resolve_webdav_quota` — see +# webdav_handler.rs / webdav_adapter.rs::write_quota_props. +# Unlimited accounts/drives (quota <= 0 or unset) 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, @@ -14,6 +14,10 @@ # 2. allprop PROPFIND also includes both properties. # 3. quota-used-bytes increases by (at least) the size of a file # just uploaded through WebDAV. +# 4. A SHARED drive with its own finite quota reports THAT quota on +# `/webdav/@drive//`, distinct from the caller's personal +# envelope — the drive-awareness fix (previously `resolve_quota` +# ignored `drive_id` entirely and always reported the envelope). # ============================================================= @@ -113,3 +117,111 @@ DELETE {{base_url}}/webdav/quota-probe.txt Authorization: Bearer {{token}} HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Shared drive with its own finite quota reports THAT +# quota, not the owner's personal envelope. +# +# Provision a fresh user + a 500-byte shared drive owned +# by them, then PROPFIND `/webdav/@drive//` (see +# `webdav_drive_root.hurl` for the URL-scheme contract). +# A brand-new drive has `used_bytes == 0`, so +# quota-available-bytes must equal the quota exactly — +# a value that cannot coincide with the personal envelope +# asserted above (that account already had files on it +# from earlier steps). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "username": "wq_owner", + "password": "WqOwnerPwd1!", + "email": "wq_owner@example.com", + "role": "user" +} + +HTTP 201 + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "wq_owner", "password": "WqOwnerPwd1!" } + +HTTP 200 +[Captures] +wq_owner_token: jsonpath "$.access_token" +wq_owner_id: jsonpath "$.user.id" + + +POST {{base_url}}/api/drives +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "wq-shared", + "owner": { "type": "user", "id": "{{wq_owner_id}}" }, + "quota_bytes": 500 +} + +HTTP 201 +[Captures] +wq_drive_id: jsonpath "$.id" + + +PROPFIND {{base_url}}/webdav/@drive/{{wq_drive_id}}/ +Authorization: Bearer {{wq_owner_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'])" == 500 + + +# Upload a 32-byte file into the shared drive; quota-used-bytes must +# reflect it and quota-available-bytes must shrink accordingly — +# confirms the shared-drive branch reads `storage.drives` live, not +# a cached/stale value. +PUT {{base_url}}/webdav/@drive/{{wq_drive_id}}/quota-probe.txt +Authorization: Bearer {{wq_owner_token}} +Content-Type: text/plain +``` +32-byte-ish payload for drv +``` + +HTTP 201 + + +PROPFIND {{base_url}}/webdav/@drive/{{wq_drive_id}}/ +Authorization: Bearer {{wq_owner_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'])" < 500 + + +# No further cleanup needed — `tests/api/storage_cleanup_check.sh` +# drains/deletes every non-admin-default drive at suite end. From 8a405af5e1c2310cc53bcd5718471bf280967322 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Mon, 13 Jul 2026 19:51:40 +0200 Subject: [PATCH 5/7] refactor(drive): match DriveKind directly instead of Drive::is_personal() Drops the boolean is_personal() wrapper in favor of matching DriveKind::Personal/Shared at the two call sites, matching the exhaustive-match convention already used for DriveKind elsewhere (as_str, parse, DriveKindDto::from). --- .../services/drive_management_service.rs | 5 ++++- src/common/di.rs | 15 +++++++++------ src/domain/entities/drive.rs | 6 ------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index b2b34537..eba53de1 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -556,7 +556,10 @@ impl DriveManagementService { let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| { DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}")) })?; - if drive.drive.is_personal() { + if matches!( + drive.drive.kind, + crate::domain::entities::drive::DriveKind::Personal + ) { tracing::info!( target: "audit", event = "drive_membership.rejected", diff --git a/src/common/di.rs b/src/common/di.rs index 8700b55d..d64f79de 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2169,12 +2169,15 @@ impl AppState { } let drive = self.drive_repo.get_by_id(drive_id).await.ok()?.drive; - if drive.is_personal() { - let (used, quota) = storage_svc.get_user_storage_info(user_id).await.ok()?; - Some((used, (quota > 0).then(|| (quota - used).max(0)))) - } else { - let used = drive.used_bytes; - Some((used, drive.quota_bytes.map(|q| (q - used).max(0)))) + match drive.kind { + crate::domain::entities::drive::DriveKind::Personal => { + let (used, quota) = storage_svc.get_user_storage_info(user_id).await.ok()?; + Some((used, (quota > 0).then(|| (quota - used).max(0)))) + } + crate::domain::entities::drive::DriveKind::Shared => { + let used = drive.used_bytes; + Some((used, drive.quota_bytes.map(|q| (q - used).max(0)))) + } } } } diff --git a/src/domain/entities/drive.rs b/src/domain/entities/drive.rs index 7d1f562b..274b7b70 100644 --- a/src/domain/entities/drive.rs +++ b/src/domain/entities/drive.rs @@ -131,12 +131,6 @@ impl Drive { self.default_for_user == Some(user_id) } - /// `true` if this drive is a personal drive of any kind (default or - /// secondary). Encapsulates the kind check at the call site. - pub fn is_personal(&self) -> bool { - matches!(self.kind, DriveKind::Personal) - } - /// Typed view of `policies` for enforcement code. Lenient deserialise: /// unknown keys are preserved on disk (the column stays the canonical /// JSONB bag) but ignored here, missing keys default to `false`. From e5c8d89da9a37b81e762a217b287c7bd3518b8a9 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Mon, 13 Jul 2026 20:00:01 +0200 Subject: [PATCH 6/7] fix(webdav): bump storage usage on PUT, not just REST multipart upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_file_streaming_with_perms (the method behind every WebDAV/ NextCloud/WOPI PUT) never called the storage-usage-delta hook, so drives.used_bytes and the RFC 4331 quota-used-bytes property never reflected content written via WebDAV — only the REST multipart upload path bumped usage. Extract apply_storage_usage_delta() from maybe_update_storage_usage() and wire it into both branches: the overwrite path applies new_size - old_size, the create path applies the full size. Also fixes the two RFC 4331 hurl tests that caught this: nc_webdav_quota_properties.hurl had a Hurl parse error ([BasicAuth] section keys can't mix literal+template, so the {user}~{folder} composite marker is now pre-resolved via [Options] variable: before being referenced as a single template), and both quota-properties tests now retry the post-upload PROPFIND (matching the existing drive_quota.hurl/user_envelope_quota.hurl pattern) since the delta is applied fire-and-forget on a background task. --- .../services/file_upload_service.rs | 25 +++++++++++++++---- tests/api/nc_webdav_quota_properties.hurl | 18 ++++++++++--- tests/api/webdav_quota_properties.hurl | 8 ++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index a6ac8209..f3eb48f7 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -353,16 +353,24 @@ impl FileUploadService { /// the target drive is `kind='personal'`, so a shared-drive upload /// still doesn't touch any user envelope. fn maybe_update_storage_usage(&self, file: &FileDto, caller_id: Uuid) { + self.apply_storage_usage_delta(file.size as i64, &file.folder_id, caller_id); + } + + /// Same as [`Self::maybe_update_storage_usage`] but takes an explicit + /// `delta` instead of assuming "whole file size" — the overwrite path + /// (`update_file_streaming_with_perms`) needs `new_size - old_size`, + /// not the new size added a second time on top of what the old + /// content already contributed. + fn apply_storage_usage_delta(&self, delta: i64, folder_id: &Option, caller_id: Uuid) { let Some(storage_service) = &self.storage_usage_service else { return; }; - let delta = file.size as i64; + if delta == 0 { + return; + } let owner = Some(caller_id); - let folder = file - .folder_id - .as_deref() - .and_then(|s| Uuid::parse_str(s).ok()); + let folder = folder_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()); // Per-user delta — only when the target drive is `kind='personal'`. // The user envelope (`auth.users.storage_quota_bytes`) caps the SUM @@ -496,6 +504,7 @@ impl FileUploadUseCase for FileUploadService { ) .await?; + let old_size = file.size(); let file_id = file.id().to_string(); let (new_hash, updated_at) = self .file_write @@ -531,6 +540,11 @@ impl FileUploadUseCase for FileUploadService { DomainError::internal_error("FileUpload", format!("rebuild entity: {e}")) })?; let dto = FileDto::from(updated); + self.apply_storage_usage_delta( + blob.size as i64 - old_size as i64, + &dto.folder_id, + caller_id, + ); if let Some(hook) = &self.file_lifecycle_hook { hook.on_file_updated(&file_id, &dto.content_hash, content_type); } @@ -603,6 +617,7 @@ impl FileUploadUseCase for FileUploadService { ) .await?; let dto = FileDto::from(created); + self.maybe_update_storage_usage(&dto, caller_id); if let Some(hook) = &self.file_lifecycle_hook { hook.on_file_created(&dto.id, &dto.content_hash, content_type, is_new_blob); } diff --git a/tests/api/nc_webdav_quota_properties.hurl b/tests/api/nc_webdav_quota_properties.hurl index 928f14c4..99775a07 100644 --- a/tests/api/nc_webdav_quota_properties.hurl +++ b/tests/api/nc_webdav_quota_properties.hurl @@ -120,8 +120,10 @@ ncq_root_folder_id: jsonpath "$.root_folder_id" # ───────────────────────────────────────────────────────────── PROPFIND {{base_url}}/remote.php/dav/files/{{ncq_nc_username}}~{{ncq_root_folder_id}}/ Depth: 0 +[Options] +variable: ncq_composite_user={{ncq_nc_username}}~{{ncq_root_folder_id}} [BasicAuth] -{{ncq_nc_username}}~{{ncq_root_folder_id}}: {{ncq_nc_password}} +{{ncq_composite_user}}: {{ncq_nc_password}} HTTP 207 [Asserts] @@ -135,8 +137,10 @@ xpath "number(//*[local-name()='quota-available-bytes'])" == 500 # ───────────────────────────────────────────────────────────── PUT {{base_url}}/remote.php/dav/files/{{ncq_nc_username}}~{{ncq_root_folder_id}}/quota-probe.txt Content-Type: text/plain +[Options] +variable: ncq_composite_user={{ncq_nc_username}}~{{ncq_root_folder_id}} [BasicAuth] -{{ncq_nc_username}}~{{ncq_root_folder_id}}: {{ncq_nc_password}} +{{ncq_composite_user}}: {{ncq_nc_password}} ``` 32-byte-ish payload for nc ``` @@ -144,10 +148,18 @@ Content-Type: text/plain HTTP 201 +# The drive-usage bump is fire-and-forget on a tokio task (see +# `file_upload_service.rs::maybe_update_storage_usage`), so retry +# until `used_bytes` catches up — same shape as `drive_quota.hurl` +# Step 5. PROPFIND {{base_url}}/remote.php/dav/files/{{ncq_nc_username}}~{{ncq_root_folder_id}}/ Depth: 0 +[Options] +variable: ncq_composite_user={{ncq_nc_username}}~{{ncq_root_folder_id}} +retry: 10 +retry-interval: 200ms [BasicAuth] -{{ncq_nc_username}}~{{ncq_root_folder_id}}: {{ncq_nc_password}} +{{ncq_composite_user}}: {{ncq_nc_password}} HTTP 207 [Asserts] diff --git a/tests/api/webdav_quota_properties.hurl b/tests/api/webdav_quota_properties.hurl index e7d48f93..d092578f 100644 --- a/tests/api/webdav_quota_properties.hurl +++ b/tests/api/webdav_quota_properties.hurl @@ -203,10 +203,18 @@ Content-Type: text/plain HTTP 201 +# The drive-usage bump is fire-and-forget on a tokio task (see +# `file_upload_service.rs::maybe_update_storage_usage`), so the SQL +# UPDATE may not have landed yet when the PUT above returned. Retry +# the PROPFIND until `used_bytes` catches up — same shape as +# `drive_quota.hurl` Step 5. PROPFIND {{base_url}}/webdav/@drive/{{wq_drive_id}}/ Authorization: Bearer {{wq_owner_token}} Depth: 0 Content-Type: application/xml; charset=utf-8 +[Options] +retry: 10 +retry-interval: 200ms ``` From c62f97d8f20db4141b3cff1fbbec0e4f23fe48d7 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Mon, 13 Jul 2026 22:22:12 +0200 Subject: [PATCH 7/7] readd removed utility, cleanup/shorten enum usage --- src/application/services/drive_management_service.rs | 6 ++---- src/common/di.rs | 5 +++-- src/domain/entities/drive.rs | 6 ++++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index eba53de1..3276ce87 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -24,6 +24,7 @@ use uuid::Uuid; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; +use crate::domain::entities::drive::DriveKind; use crate::domain::repositories::drive_repository::{DriveRepository, DriveRepositoryError}; use crate::domain::repositories::subject_group_repository::SubjectGroupRepository; use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject}; @@ -556,10 +557,7 @@ impl DriveManagementService { let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| { DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}")) })?; - if matches!( - drive.drive.kind, - crate::domain::entities::drive::DriveKind::Personal - ) { + if matches!(drive.drive.kind, DriveKind::Personal) { tracing::info!( target: "audit", event = "drive_membership.rejected", diff --git a/src/common/di.rs b/src/common/di.rs index a03c506b..be2f9c7a 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use crate::application::ports::blob_storage_ports::BlobStorageBackend; use crate::application::ports::storage_ports::StorageUsagePort; use crate::common::config::StorageBackendType; +use crate::domain::entities::drive::DriveKind; use crate::domain::repositories::drive_repository::DriveRepository; use crate::infrastructure::db::DbPools; @@ -2201,11 +2202,11 @@ impl AppState { let drive = self.drive_repo.get_by_id(drive_id).await.ok()?.drive; match drive.kind { - crate::domain::entities::drive::DriveKind::Personal => { + DriveKind::Personal => { let (used, quota) = storage_svc.get_user_storage_info(user_id).await.ok()?; Some((used, (quota > 0).then(|| (quota - used).max(0)))) } - crate::domain::entities::drive::DriveKind::Shared => { + DriveKind::Shared => { let used = drive.used_bytes; Some((used, drive.quota_bytes.map(|q| (q - used).max(0)))) } diff --git a/src/domain/entities/drive.rs b/src/domain/entities/drive.rs index 274b7b70..d2f2cb9b 100644 --- a/src/domain/entities/drive.rs +++ b/src/domain/entities/drive.rs @@ -138,6 +138,12 @@ impl Drive { pub fn typed_policies(&self) -> DrivePolicies { DrivePolicies::from_value(&self.policies) } + + /// `true` if this drive is a personal drive of any kind (default or + /// secondary). Encapsulates the kind check at the call site. + pub fn is_personal(&self) -> bool { + matches!(self.kind, DriveKind::Personal) + } } /// Typed mirror of the `policies` JSONB. Five known keys; the JSONB column