feat(webdav): drive-aware RFC 4331 quota properties

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.
This commit is contained in:
M.Schmidt
2026-07-13 00:34:17 +02:00
parent fdef73380f
commit c07aeabd85
7 changed files with 352 additions and 38 deletions
+45
View File
@@ -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<i64>)> {
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`).
+3 -30
View File
@@ -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<AppState>, user_id: Uuid) -> Option<(i64, Option<i64>)> {
let storage_svc = state.storage_usage_service.as_ref()?;
let (used, quota_bytes) = storage_svc.get_user_storage_info(user_id).await.ok()?;
// Quota <= 0 means unlimited (see `StorageUsageService::check_storage_quota`).
let available = if quota_bytes <= 0 {
None
} else {
Some((quota_bytes - used).max(0))
};
Some((used, available))
}
fn join_drive_path(root_name: &str, subpath: &str) -> String {
let subpath = subpath.trim_start_matches('/').trim_end_matches('/');
if subpath.is_empty() {
@@ -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),
@@ -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)))?;
}
+17 -2
View File
@@ -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<i64>)>,
) -> Response<Body> {
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<W: std::io::Write>(
xml: &mut Writer<W>,
folder: &FolderDto,
@@ -1656,6 +1660,7 @@ pub fn write_folder_response<W: std::io::Write>(
oc_id: Option<&str>,
owner: &str,
favorite_ids: &HashSet<String>,
quota: Option<(i64, Option<i64>)>,
) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new("d:response")))
.xml_err()?;
@@ -1705,6 +1710,16 @@ pub fn write_folder_response<W: std::io::Write>(
// 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")?;