Merge pull request #570 from swissiety/rfc-4331-quota-properties
feat(webdav): RFC 4331 quota-available-bytes/quota-used-bytes
This commit is contained in:
@@ -430,18 +430,27 @@ impl WebDavAdapter {
|
|||||||
Ok(PropFindRequest { prop_find_type })
|
Ok(PropFindRequest { prop_find_type })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn folder_prop_is_known(prop: &QualifiedName) -> bool {
|
/// `quota` reflects whether the caller could resolve the account's
|
||||||
prop.namespace == "DAV:"
|
/// storage quota for this request (the quota service is optional —
|
||||||
&& matches!(
|
/// `OXICLOUD_ENABLE_*` feature flags can disable it) and, independently,
|
||||||
prop.name.as_str(),
|
/// whether the account has a finite available-bytes figure to report.
|
||||||
"resourcetype"
|
/// RFC 4331's `quota-used-bytes` / `quota-available-bytes` are each only
|
||||||
| "displayname"
|
/// reported as known properties when a value actually exists —
|
||||||
| "creationdate"
|
/// otherwise they fall through to the standard 404 propstat like any
|
||||||
| "getlastmodified"
|
/// other property this server doesn't support. Unlimited accounts have
|
||||||
| "getetag"
|
/// `quota-used-bytes` known but `quota-available-bytes` unknown (see
|
||||||
| "getcontentlength"
|
/// `resolve_quota` in `webdav_handler.rs`).
|
||||||
| "getcontenttype"
|
fn folder_prop_is_known(prop: &QualifiedName, quota: Option<(i64, Option<i64>)>) -> bool {
|
||||||
)
|
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 {
|
fn file_prop_is_known(prop: &QualifiedName) -> bool {
|
||||||
@@ -550,16 +559,25 @@ impl WebDavAdapter {
|
|||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
request: &PropFindRequest,
|
request: &PropFindRequest,
|
||||||
href: &str,
|
href: &str,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
Self::write_folder_response_with_dead_props(xml_writer, folder, request, href, &[])
|
Self::write_folder_response_with_dead_props(xml_writer, folder, request, href, &[], quota)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `quota` is `Some((used_bytes, available_bytes))` for the caller's
|
||||||
|
/// account when the quota subsystem is enabled and reachable —
|
||||||
|
/// `available_bytes` is itself `None` for unlimited accounts, which
|
||||||
|
/// omits `quota-available-bytes` from the response entirely (see
|
||||||
|
/// [`Self::folder_prop_is_known`]). It's the same value regardless of
|
||||||
|
/// which folder is being described (quota is account-wide, not
|
||||||
|
/// per-folder), so callers resolve it once per PROPFIND request.
|
||||||
fn write_folder_response_with_dead_props<W: Write>(
|
fn write_folder_response_with_dead_props<W: Write>(
|
||||||
xml_writer: &mut Writer<W>,
|
xml_writer: &mut Writer<W>,
|
||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
request: &PropFindRequest,
|
request: &PropFindRequest,
|
||||||
href: &str,
|
href: &str,
|
||||||
dead_props: &[(QualifiedName, Option<String>)],
|
dead_props: &[(QualifiedName, Option<String>)],
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||||
|
|
||||||
@@ -585,8 +603,9 @@ impl WebDavAdapter {
|
|||||||
// RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat.
|
// RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat.
|
||||||
// Props found in the dead store are returned in the dead 200 propstat,
|
// Props found in the dead store are returned in the dead 200 propstat,
|
||||||
// so exclude them from the 404 propstat to avoid duplicate reporting.
|
// so exclude them from the 404 propstat to avoid duplicate reporting.
|
||||||
let (known, unknown): (Vec<_>, Vec<_>) =
|
let (known, unknown): (Vec<_>, Vec<_>) = props
|
||||||
props.iter().partition(|p| Self::folder_prop_is_known(p));
|
.iter()
|
||||||
|
.partition(|p| Self::folder_prop_is_known(p, quota));
|
||||||
let truly_unknown: Vec<_> = unknown
|
let truly_unknown: Vec<_> = unknown
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|p| !dead_name_set.contains(*p))
|
.filter(|p| !dead_name_set.contains(*p))
|
||||||
@@ -594,7 +613,7 @@ impl WebDavAdapter {
|
|||||||
|
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||||
Self::write_folder_requested_props(xml_writer, folder, &known)?;
|
Self::write_folder_requested_props(xml_writer, folder, &known, quota)?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||||
@@ -608,10 +627,10 @@ impl WebDavAdapter {
|
|||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||||
match other {
|
match other {
|
||||||
PropFindType::AllProp => {
|
PropFindType::AllProp => {
|
||||||
Self::write_folder_standard_props(xml_writer, folder)?;
|
Self::write_folder_standard_props(xml_writer, folder, quota)?;
|
||||||
}
|
}
|
||||||
PropFindType::PropName => {
|
PropFindType::PropName => {
|
||||||
Self::write_folder_prop_names(xml_writer)?;
|
Self::write_folder_prop_names(xml_writer, quota)?;
|
||||||
}
|
}
|
||||||
PropFindType::Prop(_) => unreachable!(),
|
PropFindType::Prop(_) => unreachable!(),
|
||||||
}
|
}
|
||||||
@@ -720,6 +739,7 @@ impl WebDavAdapter {
|
|||||||
fn write_folder_standard_props<W: Write>(
|
fn write_folder_standard_props<W: Write>(
|
||||||
xml_writer: &mut Writer<W>,
|
xml_writer: &mut Writer<W>,
|
||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Resource type (collection)
|
// Resource type (collection)
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
||||||
@@ -768,6 +788,33 @@ impl WebDavAdapter {
|
|||||||
xml_writer.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
|
xml_writer.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||||
|
|
||||||
|
if let Some((used, available)) = quota {
|
||||||
|
Self::write_quota_props(xml_writer, used, available)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write RFC 4331 `quota-used-bytes` / `quota-available-bytes`. Shared
|
||||||
|
/// by the allprop and named-prop paths so the element shape only
|
||||||
|
/// lives in one place. `available_bytes` is `None` for unlimited
|
||||||
|
/// accounts — RFC 4331 §3 lets a server omit `quota-available-bytes`
|
||||||
|
/// rather than disclose a made-up value, so the element is skipped.
|
||||||
|
fn write_quota_props<W: Write>(
|
||||||
|
xml_writer: &mut Writer<W>,
|
||||||
|
used_bytes: i64,
|
||||||
|
available_bytes: Option<i64>,
|
||||||
|
) -> Result<()> {
|
||||||
|
xml_writer.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?;
|
||||||
|
xml_writer.write_event(Event::Text(BytesText::new(&used_bytes.to_string())))?;
|
||||||
|
xml_writer.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?;
|
||||||
|
|
||||||
|
if let Some(available_bytes) = available_bytes {
|
||||||
|
xml_writer.write_event(Event::Start(BytesStart::new("D:quota-available-bytes")))?;
|
||||||
|
xml_writer.write_event(Event::Text(BytesText::new(&available_bytes.to_string())))?;
|
||||||
|
xml_writer.write_event(Event::End(BytesEnd::new("D:quota-available-bytes")))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,7 +872,10 @@ impl WebDavAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write folder property names
|
/// Write folder property names
|
||||||
fn write_folder_prop_names<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
|
fn write_folder_prop_names<W: Write>(
|
||||||
|
xml_writer: &mut Writer<W>,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
|
) -> Result<()> {
|
||||||
// Write empty property elements for folders
|
// Write empty property elements for folders
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:displayname")))?;
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:displayname")))?;
|
||||||
@@ -834,6 +884,12 @@ impl WebDavAdapter {
|
|||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:getetag")))?;
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:getetag")))?;
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontentlength")))?;
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontentlength")))?;
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontenttype")))?;
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontenttype")))?;
|
||||||
|
if let Some((_, available)) = quota {
|
||||||
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:quota-used-bytes")))?;
|
||||||
|
if available.is_some() {
|
||||||
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:quota-available-bytes")))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -857,6 +913,7 @@ impl WebDavAdapter {
|
|||||||
xml_writer: &mut Writer<W>,
|
xml_writer: &mut Writer<W>,
|
||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
props: &[&QualifiedName],
|
props: &[&QualifiedName],
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
for prop in props {
|
for prop in props {
|
||||||
if prop.namespace == "DAV:" {
|
if prop.namespace == "DAV:" {
|
||||||
@@ -917,6 +974,28 @@ impl WebDavAdapter {
|
|||||||
.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
|
.write_event(Event::Text(BytesText::new("httpd/unix-directory")))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||||
}
|
}
|
||||||
|
"quota-used-bytes" => {
|
||||||
|
if let Some((used, _)) = quota {
|
||||||
|
xml_writer
|
||||||
|
.write_event(Event::Start(BytesStart::new("D:quota-used-bytes")))?;
|
||||||
|
xml_writer
|
||||||
|
.write_event(Event::Text(BytesText::new(&used.to_string())))?;
|
||||||
|
xml_writer
|
||||||
|
.write_event(Event::End(BytesEnd::new("D:quota-used-bytes")))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quota-available-bytes" => {
|
||||||
|
if let Some((_, Some(available))) = quota {
|
||||||
|
xml_writer.write_event(Event::Start(BytesStart::new(
|
||||||
|
"D:quota-available-bytes",
|
||||||
|
)))?;
|
||||||
|
xml_writer
|
||||||
|
.write_event(Event::Text(BytesText::new(&available.to_string())))?;
|
||||||
|
xml_writer.write_event(Event::End(BytesEnd::new(
|
||||||
|
"D:quota-available-bytes",
|
||||||
|
)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Unknown prop — skipped here; caller writes 404 propstat.
|
// Unknown prop — skipped here; caller writes 404 propstat.
|
||||||
}
|
}
|
||||||
@@ -1445,7 +1524,7 @@ impl WebDavAdapter {
|
|||||||
request: &PropFindRequest,
|
request: &PropFindRequest,
|
||||||
href: &str,
|
href: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
Self::write_folder_response(writer, folder, request, href)
|
Self::write_folder_response(writer, folder, request, href, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a single `<D:response>` element for a file, including dead properties.
|
/// Writes a single `<D:response>` element for a file, including dead properties.
|
||||||
@@ -1465,8 +1544,11 @@ impl WebDavAdapter {
|
|||||||
request: &PropFindRequest,
|
request: &PropFindRequest,
|
||||||
href: &str,
|
href: &str,
|
||||||
dead_props: &[(QualifiedName, Option<String>)],
|
dead_props: &[(QualifiedName, Option<String>)],
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
Self::write_folder_response_with_dead_props(writer, folder, request, href, dead_props)
|
Self::write_folder_response_with_dead_props(
|
||||||
|
writer, folder, request, href, dead_props, quota,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a file entry including dead (custom) properties.
|
/// Writes a file entry including dead (custom) properties.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
|
use crate::domain::entities::drive::DriveKind;
|
||||||
use crate::domain::repositories::drive_repository::{DriveRepository, DriveRepositoryError};
|
use crate::domain::repositories::drive_repository::{DriveRepository, DriveRepositoryError};
|
||||||
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
||||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
|
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
|
||||||
@@ -556,7 +557,7 @@ impl DriveManagementService {
|
|||||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||||
})?;
|
})?;
|
||||||
if drive.drive.is_personal() {
|
if matches!(drive.drive.kind, DriveKind::Personal) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "audit",
|
target: "audit",
|
||||||
event = "drive_membership.rejected",
|
event = "drive_membership.rejected",
|
||||||
|
|||||||
@@ -353,16 +353,24 @@ impl FileUploadService {
|
|||||||
/// the target drive is `kind='personal'`, so a shared-drive upload
|
/// the target drive is `kind='personal'`, so a shared-drive upload
|
||||||
/// still doesn't touch any user envelope.
|
/// still doesn't touch any user envelope.
|
||||||
fn maybe_update_storage_usage(&self, file: &FileDto, caller_id: Uuid) {
|
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<String>, caller_id: Uuid) {
|
||||||
let Some(storage_service) = &self.storage_usage_service else {
|
let Some(storage_service) = &self.storage_usage_service else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let delta = file.size as i64;
|
if delta == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let owner = Some(caller_id);
|
let owner = Some(caller_id);
|
||||||
let folder = file
|
let folder = folder_id.as_deref().and_then(|s| Uuid::parse_str(s).ok());
|
||||||
.folder_id
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|s| Uuid::parse_str(s).ok());
|
|
||||||
|
|
||||||
// Per-user delta — only when the target drive is `kind='personal'`.
|
// Per-user delta — only when the target drive is `kind='personal'`.
|
||||||
// The user envelope (`auth.users.storage_quota_bytes`) caps the SUM
|
// The user envelope (`auth.users.storage_quota_bytes`) caps the SUM
|
||||||
@@ -496,6 +504,7 @@ impl FileUploadUseCase for FileUploadService {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let old_size = file.size();
|
||||||
let file_id = file.id().to_string();
|
let file_id = file.id().to_string();
|
||||||
let (new_hash, updated_at) = self
|
let (new_hash, updated_at) = self
|
||||||
.file_write
|
.file_write
|
||||||
@@ -531,6 +540,11 @@ impl FileUploadUseCase for FileUploadService {
|
|||||||
DomainError::internal_error("FileUpload", format!("rebuild entity: {e}"))
|
DomainError::internal_error("FileUpload", format!("rebuild entity: {e}"))
|
||||||
})?;
|
})?;
|
||||||
let dto = FileDto::from(updated);
|
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 {
|
if let Some(hook) = &self.file_lifecycle_hook {
|
||||||
hook.on_file_updated(&file_id, &dto.content_hash, content_type);
|
hook.on_file_updated(&file_id, &dto.content_hash, content_type);
|
||||||
}
|
}
|
||||||
@@ -603,6 +617,7 @@ impl FileUploadUseCase for FileUploadService {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let dto = FileDto::from(created);
|
let dto = FileDto::from(created);
|
||||||
|
self.maybe_update_storage_usage(&dto, caller_id);
|
||||||
if let Some(hook) = &self.file_lifecycle_hook {
|
if let Some(hook) = &self.file_lifecycle_hook {
|
||||||
hook.on_file_created(&dto.id, &dto.content_hash, content_type, is_new_blob);
|
hook.on_file_created(&dto.id, &dto.content_hash, content_type, is_new_blob);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||||
|
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||||
use crate::common::config::StorageBackendType;
|
use crate::common::config::StorageBackendType;
|
||||||
|
use crate::domain::entities::drive::DriveKind;
|
||||||
|
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||||
use crate::infrastructure::db::DbPools;
|
use crate::infrastructure::db::DbPools;
|
||||||
|
|
||||||
use crate::application::services::admin_settings_service::AdminSettingsService;
|
use crate::application::services::admin_settings_service::AdminSettingsService;
|
||||||
@@ -2165,6 +2169,51 @@ pub struct AppState {
|
|||||||
|
|
||||||
// All AppState construction is done via struct literal in build_app_state().
|
// 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;
|
||||||
|
match drive.kind {
|
||||||
|
DriveKind::Personal => {
|
||||||
|
let (used, quota) = storage_svc.get_user_storage_info(user_id).await.ok()?;
|
||||||
|
Some((used, (quota > 0).then(|| (quota - used).max(0))))
|
||||||
|
}
|
||||||
|
DriveKind::Shared => {
|
||||||
|
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`;
|
/// Builds the authorization engine. Today this only constructs `PgAclEngine`;
|
||||||
/// the `OXICLOUD_AUTHZ_ENGINE` env var is reserved for future alternate
|
/// the `OXICLOUD_AUTHZ_ENGINE` env var is reserved for future alternate
|
||||||
/// implementations (e.g. `openfga`).
|
/// implementations (e.g. `openfga`).
|
||||||
|
|||||||
@@ -131,12 +131,6 @@ impl Drive {
|
|||||||
self.default_for_user == Some(user_id)
|
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:
|
/// Typed view of `policies` for enforcement code. Lenient deserialise:
|
||||||
/// unknown keys are preserved on disk (the column stays the canonical
|
/// unknown keys are preserved on disk (the column stays the canonical
|
||||||
/// JSONB bag) but ignored here, missing keys default to `false`.
|
/// JSONB bag) but ignored here, missing keys default to `false`.
|
||||||
@@ -144,6 +138,12 @@ impl Drive {
|
|||||||
pub fn typed_policies(&self) -> DrivePolicies {
|
pub fn typed_policies(&self) -> DrivePolicies {
|
||||||
DrivePolicies::from_value(&self.policies)
|
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
|
/// Typed mirror of the `policies` JSONB. Five known keys; the JSONB column
|
||||||
|
|||||||
@@ -557,6 +557,7 @@ async fn handle_propfind(
|
|||||||
created_by: None,
|
created_by: None,
|
||||||
updated_by: None,
|
updated_by: None,
|
||||||
};
|
};
|
||||||
|
let quota = state.resolve_webdav_quota(user.id, Uuid::nil()).await;
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
root_folder,
|
root_folder,
|
||||||
None, // folder_id = None → root children (drive-root folders)
|
None, // folder_id = None → root children (drive-root folders)
|
||||||
@@ -567,6 +568,7 @@ async fn handle_propfind(
|
|||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
user.id,
|
user.id,
|
||||||
state.webdav_dead_props.clone(),
|
state.webdav_dead_props.clone(),
|
||||||
|
quota,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -595,6 +597,7 @@ async fn handle_propfind(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let folder_id = folder.id.clone();
|
let folder_id = folder.id.clone();
|
||||||
|
let quota = state.resolve_webdav_quota(user.id, drive_id).await;
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
folder,
|
folder,
|
||||||
Some(folder_id),
|
Some(folder_id),
|
||||||
@@ -605,6 +608,7 @@ async fn handle_propfind(
|
|||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
user.id,
|
user.id,
|
||||||
state.webdav_dead_props.clone(),
|
state.webdav_dead_props.clone(),
|
||||||
|
quota,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -659,6 +663,7 @@ async fn handle_propfind(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let folder_id = folder.id.clone();
|
let folder_id = folder.id.clone();
|
||||||
|
let quota = state.resolve_webdav_quota(user.id, drive_id).await;
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
folder,
|
folder,
|
||||||
Some(folder_id),
|
Some(folder_id),
|
||||||
@@ -669,6 +674,7 @@ async fn handle_propfind(
|
|||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
user.id,
|
user.id,
|
||||||
state.webdav_dead_props.clone(),
|
state.webdav_dead_props.clone(),
|
||||||
|
quota,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -732,6 +738,7 @@ async fn build_streaming_propfind_response(
|
|||||||
file_retrieval_service: std::sync::Arc<FileRetrievalService>,
|
file_retrieval_service: std::sync::Arc<FileRetrievalService>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
dead_props_store: Arc<DeadPropertyStore>,
|
dead_props_store: Arc<DeadPropertyStore>,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
let depth = depth.to_string();
|
let depth = depth.to_string();
|
||||||
let base_href = base_href.to_string();
|
let base_href = base_href.to_string();
|
||||||
@@ -752,7 +759,7 @@ async fn build_streaming_propfind_response(
|
|||||||
let mut w = Writer::new(&mut buf);
|
let mut w = Writer::new(&mut buf);
|
||||||
WebDavAdapter::write_multistatus_start(&mut w)
|
WebDavAdapter::write_multistatus_start(&mut w)
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, &folder, &propfind_request, &base_href, &folder_dead)
|
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, &folder, &propfind_request, &base_href, &folder_dead, quota)
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
}
|
}
|
||||||
yield Bytes::from(buf);
|
yield Bytes::from(buf);
|
||||||
@@ -795,7 +802,7 @@ async fn build_streaming_propfind_response(
|
|||||||
let mut w = Writer::new(&mut chunk);
|
let mut w = Writer::new(&mut chunk);
|
||||||
for (subfolder, child_dead) in result.items.iter().zip(subfolder_deads.iter()) {
|
for (subfolder, child_dead) in result.items.iter().zip(subfolder_deads.iter()) {
|
||||||
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
|
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
|
||||||
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead)
|
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead, quota)
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,10 @@ async fn handle_filter_files(
|
|||||||
(fid, oc_id.as_deref()),
|
(fid, oc_id.as_deref()),
|
||||||
&user.username,
|
&user.username,
|
||||||
&favorite_ids,
|
&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,
|
||||||
&dead,
|
&dead,
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
@@ -346,6 +350,10 @@ async fn handle_search(
|
|||||||
(fid, oc_id.as_deref()),
|
(fid, oc_id.as_deref()),
|
||||||
&user.username,
|
&user.username,
|
||||||
&favorite_ids,
|
&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,
|
||||||
&dead,
|
&dead,
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ async fn handle_propfind(
|
|||||||
// function's username arg. Refining the owner-id usages
|
// function's username arg. Refining the owner-id usages
|
||||||
// back to the canonical username is deferred to the
|
// back to the canonical username is deferred to the
|
||||||
// NcSession commit.
|
// NcSession commit.
|
||||||
|
let quota = state.resolve_webdav_quota(user.id, chroot.drive_id).await;
|
||||||
Ok(build_nc_streaming_propfind(
|
Ok(build_nc_streaming_propfind(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
folder,
|
folder,
|
||||||
@@ -348,6 +349,7 @@ async fn handle_propfind(
|
|||||||
user.id,
|
user.id,
|
||||||
url_user.to_string(),
|
url_user.to_string(),
|
||||||
subpath.to_string(),
|
subpath.to_string(),
|
||||||
|
quota,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
ResolvedResource::File(file) => {
|
ResolvedResource::File(file) => {
|
||||||
@@ -1481,6 +1483,7 @@ fn build_nc_streaming_propfind(
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
username: String,
|
username: String,
|
||||||
subpath: String,
|
subpath: String,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
) -> Response<Body> {
|
) -> Response<Body> {
|
||||||
let stream = async_stream::try_stream! {
|
let stream = async_stream::try_stream! {
|
||||||
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
|
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
|
||||||
@@ -1509,7 +1512,7 @@ fn build_nc_streaming_propfind(
|
|||||||
let href = nc_collection_href(&username, &subpath);
|
let href = nc_collection_href(&username, &subpath);
|
||||||
let fid = folder_id_map.get(&folder.id).copied();
|
let fid = folder_id_map.get(&folder.id).copied();
|
||||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
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, &folder_dead)
|
write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, quota, &folder_dead)
|
||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
}
|
}
|
||||||
yield Bytes::from(buf);
|
yield Bytes::from(buf);
|
||||||
@@ -1608,7 +1611,7 @@ fn build_nc_streaming_propfind(
|
|||||||
let href = nc_collection_href(&username, &child_sub);
|
let href = nc_collection_href(&username, &child_sub);
|
||||||
let fid = sub_id_map.get(&sf.id).copied();
|
let fid = sub_id_map.get(&sf.id).copied();
|
||||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
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, dead)
|
write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, quota, dead)
|
||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1647,6 +1650,7 @@ fn build_nc_streaming_propfind(
|
|||||||
/// together (`oc_id` is derived from `file_id`) — to stay under
|
/// together (`oc_id` is derived from `file_id`) — to stay under
|
||||||
/// clippy's argument-count lint now that `dead_props` is also threaded
|
/// clippy's argument-count lint now that `dead_props` is also threaded
|
||||||
/// through.
|
/// through.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn write_folder_response<W: std::io::Write>(
|
pub fn write_folder_response<W: std::io::Write>(
|
||||||
xml: &mut Writer<W>,
|
xml: &mut Writer<W>,
|
||||||
folder: &FolderDto,
|
folder: &FolderDto,
|
||||||
@@ -1654,6 +1658,7 @@ pub fn write_folder_response<W: std::io::Write>(
|
|||||||
oc_ids: (Option<i64>, Option<&str>),
|
oc_ids: (Option<i64>, Option<&str>),
|
||||||
owner: &str,
|
owner: &str,
|
||||||
favorite_ids: &HashSet<String>,
|
favorite_ids: &HashSet<String>,
|
||||||
|
quota: Option<(i64, Option<i64>)>,
|
||||||
dead_props: &[(QualifiedName, Option<String>)],
|
dead_props: &[(QualifiedName, Option<String>)],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (file_id, oc_id) = oc_ids;
|
let (file_id, oc_id) = oc_ids;
|
||||||
@@ -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
|
// 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, "ocs:share-permissions", "31")?;
|
||||||
write_text_element(xml, "oc:size", "0")?;
|
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-id", owner)?;
|
||||||
write_text_element(xml, "oc:owner-display-name", owner)?;
|
write_text_element(xml, "oc:owner-display-name", owner)?;
|
||||||
write_text_element(xml, "nc:has-preview", "false")?;
|
write_text_element(xml, "nc:has-preview", "false")?;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# =============================================================
|
||||||
|
# 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
|
||||||
|
[Options]
|
||||||
|
variable: ncq_composite_user={{ncq_nc_username}}~{{ncq_root_folder_id}}
|
||||||
|
[BasicAuth]
|
||||||
|
{{ncq_composite_user}}: {{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
|
||||||
|
[Options]
|
||||||
|
variable: ncq_composite_user={{ncq_nc_username}}~{{ncq_root_folder_id}}
|
||||||
|
[BasicAuth]
|
||||||
|
{{ncq_composite_user}}: {{ncq_nc_password}}
|
||||||
|
```
|
||||||
|
32-byte-ish payload for nc
|
||||||
|
```
|
||||||
|
|
||||||
|
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_composite_user}}: {{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.
|
||||||
@@ -195,6 +195,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$API_DIR/webdav_dead_properties.hurl" \
|
"$API_DIR/webdav_dead_properties.hurl" \
|
||||||
"$API_DIR/nc_webdav_dead_properties.hurl" \
|
"$API_DIR/nc_webdav_dead_properties.hurl" \
|
||||||
"$API_DIR/webdav_protected_properties.hurl" \
|
"$API_DIR/webdav_protected_properties.hurl" \
|
||||||
|
"$API_DIR/webdav_quota_properties.hurl" \
|
||||||
|
"$API_DIR/nc_webdav_quota_properties.hurl" \
|
||||||
"$API_DIR/webdav_drive_root.hurl" \
|
"$API_DIR/webdav_drive_root.hurl" \
|
||||||
"$API_DIR/webdav_permissions.hurl" \
|
"$API_DIR/webdav_permissions.hurl" \
|
||||||
"$API_DIR/webdav_nested_move_cascade.hurl" \
|
"$API_DIR/webdav_nested_move_cascade.hurl" \
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — WebDAV quota properties (RFC 4331)
|
||||||
|
# =============================================================
|
||||||
|
# `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,
|
||||||
|
# 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.
|
||||||
|
# 4. A SHARED drive with its own finite quota reports THAT quota on
|
||||||
|
# `/webdav/@drive/<id>/`, distinct from the caller's personal
|
||||||
|
# envelope — the drive-awareness fix (previously `resolve_quota`
|
||||||
|
# ignored `drive_id` entirely and always reported the envelope).
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — Login, capture JWT
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — Named-prop PROPFIND for the two quota properties.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PROPFIND {{base_url}}/webdav/
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Depth: 0
|
||||||
|
Content-Type: application/xml; charset=utf-8
|
||||||
|
```
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:quota-available-bytes/>
|
||||||
|
<D:quota-used-bytes/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 207
|
||||||
|
[Asserts]
|
||||||
|
xpath "string(//*[local-name()='propstat'][1]/*[local-name()='status'])" contains "200 OK"
|
||||||
|
xpath "number(//*[local-name()='quota-used-bytes'])" >= 0
|
||||||
|
xpath "number(//*[local-name()='quota-available-bytes'])" > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — allprop PROPFIND also surfaces both properties.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PROPFIND {{base_url}}/webdav/
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Depth: 0
|
||||||
|
Content-Type: application/xml; charset=utf-8
|
||||||
|
```
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:allprop/>
|
||||||
|
</D:propfind>
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 207
|
||||||
|
[Asserts]
|
||||||
|
xpath "number(//*[local-name()='quota-used-bytes'])" >= 0
|
||||||
|
xpath "number(//*[local-name()='quota-available-bytes'])" > 0
|
||||||
|
[Captures]
|
||||||
|
used_before: xpath "number(//*[local-name()='quota-used-bytes'])"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Upload a file, then confirm quota-used-bytes reflects it.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/webdav/quota-probe.txt
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: text/plain
|
||||||
|
```
|
||||||
|
quota accounting probe payload
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
PROPFIND {{base_url}}/webdav/
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Depth: 0
|
||||||
|
Content-Type: application/xml; charset=utf-8
|
||||||
|
```
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:quota-used-bytes/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 207
|
||||||
|
[Asserts]
|
||||||
|
xpath "number(//*[local-name()='quota-used-bytes'])" >= {{used_before}}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Cleanup
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/webdav/quota-probe.txt
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 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/<id>/` (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
|
||||||
|
```
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:quota-available-bytes/>
|
||||||
|
<D:quota-used-bytes/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 207
|
||||||
|
[Asserts]
|
||||||
|
xpath "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
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:quota-available-bytes/>
|
||||||
|
<D:quota-used-bytes/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 207
|
||||||
|
[Asserts]
|
||||||
|
xpath "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.
|
||||||
Reference in New Issue
Block a user