From c07aeabd8541dea89117741b6e0c1ec5ffc720c6 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Mon, 13 Jul 2026 00:34:17 +0200 Subject: [PATCH] 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.