Merge upstream/main into feat/external-file-mounts

Resolve conflicts between the external-file-mounts feature and upstream's
D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move
gates, resource-access hook, folder-cascade lifecycle hook).

Key resolutions:
- FolderService::new now takes (repo, authz, file_lifecycle, mount_router);
  all callers + DI updated.
- FileRetrievalService / FileManagementService keep both the mount_router
  and the new resource_access_hook / drive_repo / storage_usage wiring.
- list_files_batch_with_perms: adapt the mount branch from offset- to
  keyset (after_name) pagination, mirroring paginate_mount_entries.
- download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>`
  signature, retain the mount-download branch.
- Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by
  (the mount owner) — the fields the frontend now uses for owner display.
- admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab.
- Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and
  regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
Bradley Nelson
2026-07-21 17:09:36 -06:00
600 changed files with 105575 additions and 14440 deletions
+313 -44
View File
@@ -1,9 +1,13 @@
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::entities::drive::DriveKind;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::infrastructure::db::DbPools;
use crate::application::services::admin_settings_service::AdminSettingsService;
@@ -50,13 +54,13 @@ use crate::application::ports::video_frame_ports::VideoFramePort;
use crate::application::services::app_password_service::AppPasswordService;
use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
use crate::application::services::calendar_service::CalendarService;
use crate::application::services::contact_service::ContactService;
use crate::application::services::device_auth_service::DeviceAuthService;
use crate::application::services::file_lifecycle_service::FileLifecycleService;
use crate::application::services::music_service::MusicService;
use crate::application::services::storage_usage_service::StorageUsageService;
use crate::application::services::wopi_lock_service::WopiLockService;
use crate::application::services::wopi_token_service::WopiTokenService;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
use crate::infrastructure::repositories::AppPasswordPgRepository;
use crate::infrastructure::repositories::DeviceCodePgRepository;
use crate::infrastructure::repositories::pg::{
@@ -519,46 +523,83 @@ impl AppServiceFactory {
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
>,
mount_router: Arc<crate::application::services::external_mount_router::MountRouter>,
resource_access_hook: Option<
Arc<dyn crate::application::ports::resource_access_hook::ResourceAccessHook>,
>,
) -> ApplicationServices {
// Main services
let folder_service = Arc::new(FolderService::new(
repos.folder_repository.clone(),
authz.clone(),
mount_router.clone(),
));
let folder_service = Arc::new(
FolderService::new(
repos.folder_repository.clone(),
authz.clone(),
// Same dispatcher TrashService uses, so the cascade hook in
// `delete_folder_with_perms` fans out to the same handlers
// (thumbnails, metadata, …) as a single-file delete.
core.file_lifecycle.clone(),
mount_router.clone(),
)
// D5 cross-drive move gate reads policies via the same
// drive repo every other policy uses. Wired here so
// `move_folder_with_perms` can enforce
// `forbid_cross_drive_move` without a separate construction path.
.with_drive_repo(drive_repo.clone())
// Destination-drive quota pre-check on cross-drive folder
// MOVE. Reuses the `check_drive_quota` the upload path
// already runs. Without this, a Move that would push the
// destination past its cap succeeds silently.
.with_storage_usage(storage_usage.clone()),
);
// Built before the upload/management services so the plugin lifecycle
// bridge (which looks file metadata up by id) can be wired into the
// dispatcher they receive. It depends only on repos + core, never on
// the upload service, so the reorder is safe.
let file_retrieval_service = Arc::new(
FileRetrievalService::new_with_cache(
let file_retrieval_service = {
let mut svc = FileRetrievalService::new_with_cache(
repos.file_read_repository.clone(),
core.file_content_cache.clone(),
core.image_transcode_service.clone(),
authz.clone(),
)
.with_mount_router(mount_router.clone()),
);
.with_mount_router(mount_router.clone());
if let Some(hook) = resource_access_hook.clone() {
svc = svc.with_resource_access_hook(hook);
}
Arc::new(svc)
};
// Effective lifecycle dispatcher: the core hooks (thumbnails, metadata)
// plus, when the plugins feature is enabled, the WASM plugin bridge.
let file_lifecycle =
self.effective_file_lifecycle(core, &file_retrieval_service, plugin_dispatch);
let file_upload_service = Arc::new(
FileUploadService::new_with_read(
let file_upload_service = Arc::new({
let mut svc = FileUploadService::new_with_read(
repos.file_write_repository.clone(),
repos.file_read_repository.clone(),
)
.with_content_cache(core.file_content_cache.clone())
.with_file_lifecycle_hook(file_lifecycle.clone())
// `with_storage_usage_service` wires the post-write delta
// hook (`maybe_update_storage_usage`). Without this the
// hook is dead code — both per-user and per-drive
// `used_bytes` deltas would silently no-op and the
// counters drift until the next reconciliation sweep
// (default 10 min). `with_instant_upload` below stashes
// the same service under a different field used only by
// the dedup-instant-upload check, so they're not
// interchangeable.
.with_storage_usage_service(storage_usage.clone())
.with_instant_upload(
authz.clone(),
core.dedup_service.clone(),
storage_usage.clone(),
),
);
);
if let Some(hook) = resource_access_hook.clone() {
svc = svc.with_resource_access_hook(hook);
}
svc
});
// Delta-upload protocol — chunk negotiation over the same dedup
// store. Bounded by the same whole-file ceiling as byte uploads.
@@ -575,8 +616,8 @@ impl AppServiceFactory {
);
// FileManagementService — ref_count handled by PG trigger, no dedup port needed
let file_management_service = Arc::new(
FileManagementService::with_trash(
let file_management_service = Arc::new({
let mut svc = FileManagementService::with_trash(
repos.file_write_repository.clone(),
trash_service.clone(),
Some(repos.file_read_repository.clone()),
@@ -585,8 +626,20 @@ impl AppServiceFactory {
authz.clone(),
)
.with_file_lifecycle_hook(file_lifecycle.clone())
.with_mount_router(mount_router.clone()),
);
.with_mount_router(mount_router.clone())
// D5 cross-drive move gate reads policies via the same
// drive repo every other policy uses. Wired here so
// `move_file_with_perms` can enforce `forbid_cross_drive_move`
// without a separate construction path.
.with_drive_repo(drive_repo.clone())
// Destination-drive quota pre-check on cross-drive file
// MOVE. Same rationale as the folder side above.
.with_storage_usage(storage_usage.clone());
if let Some(hook) = resource_access_hook.clone() {
svc = svc.with_resource_access_hook(hook);
}
svc
});
// Streams uploads to external mount providers (bypasses the CAS).
let external_upload_service = Arc::new(
@@ -614,8 +667,12 @@ impl AppServiceFactory {
content_index_port,
Some(authz.clone()),
Some(drive_repo.clone()),
300, // Cache TTL in seconds (5 minutes)
1000, // Maximum cache entries
300, // Cache TTL in seconds (5 minutes)
// Byte budget for cached result pages (weigher-bounded, 32 MiB
// default; env OXICLOUD_SEARCH_CACHE_MAX_BYTES). Replaces the old
// entry-count capacity, which let 500-row pages keyed by
// user×query×offset×limit pin hundreds of MB for the TTL.
self.config.search_cache.max_bytes,
)));
tracing::info!("Application services initialized");
@@ -795,10 +852,8 @@ impl AppServiceFactory {
let service = Arc::new(
TrashService::new(
trash_repo.clone(),
repos.file_read_repository.clone(),
repos.file_write_repository.clone(),
repos.folder_repository.clone(),
self.config.storage.trash_retention_days,
core.dedup_service.clone(),
Some(core.file_content_cache.clone()),
authz.clone(),
@@ -828,6 +883,7 @@ impl AppServiceFactory {
repos: &RepositoryServices,
db_pool: &Arc<PgPool>,
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
) -> Option<Arc<ShareService>> {
if !self.config.features.enable_file_sharing {
tracing::info!("File sharing service is disabled in configuration");
@@ -850,6 +906,7 @@ impl AppServiceFactory {
share_repository,
repos.file_read_repository.clone(),
repos.folder_repository.clone(),
drive_repo.clone(),
password_hasher,
authorization.clone(),
));
@@ -858,30 +915,49 @@ impl AppServiceFactory {
Some(service)
}
/// Creates the favorites service (requires database)
pub fn create_favorites_service(&self, db_pool: &Arc<PgPool>) -> Arc<FavoritesService> {
/// Creates the favorites service (requires database + authz engine
/// for the Read gate on `add_to_favorites` — see the post-Drive
/// AuthZ audit).
pub fn create_favorites_service(
&self,
db_pool: &Arc<PgPool>,
authorization: &Arc<PgAclEngine>,
) -> Arc<FavoritesService> {
let repo = Arc::new(
crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()),
);
let service = Arc::new(FavoritesService::new(repo));
let service = Arc::new(FavoritesService::new(repo, authorization.clone()));
tracing::info!("Favorites service initialized");
service
}
/// Creates the recent items service (requires database)
pub fn create_recent_service(&self, db_pool: &Arc<PgPool>) -> Arc<RecentService> {
/// Creates the recent items service (requires database + authz
/// engine for the Read gate on `record_item_access` — see the
/// post-Drive AuthZ audit).
pub fn create_recent_service(
&self,
db_pool: &Arc<PgPool>,
authorization: &Arc<PgAclEngine>,
) -> Arc<RecentService> {
let repo = Arc::new(
crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()),
);
let service = Arc::new(RecentService::new(
repo, 50, // Maximum recent items per user
repo,
authorization.clone(),
50, // Maximum recent items per user
));
tracing::info!("Recent items service initialized");
service
}
/// Creates the Places (photo map) service. Reuses the existing file-read
/// repository — the data is the caller's own geotagged photos.
/// repository — the data is the caller's Photos-scope geotagged photos
/// (§15: default personal drive + drives with
/// `include_in_photo_index = true` AND caller has Read).
/// Group-membership expansion is inline in the SQL via
/// `storage.caller_group_ids`, so the service needs no AuthZ engine
/// handle.
pub fn create_places_service(
&self,
file_read: &Arc<FileBlobReadRepository>,
@@ -992,14 +1068,26 @@ impl AppServiceFactory {
_repos: &RepositoryServices,
db_pool: &Arc<PgPool>,
maintenance_pool: &Arc<PgPool>,
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
) -> Arc<StorageUsageService> {
let user_repository = Arc::new(
crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()),
);
// The `drive_repo` passed in is the SAME instance held on
// `AppState`, so its `readable_cache` / `default_drive_cache`
// are the caches the request path reads from. A separately
// constructed `DrivePgRepository` would have its OWN caches
// and invalidation would be a no-op observed by nobody —
// this is the trap that regressed the used_bytes freshness
// after perf commit `12dc648c`.
let service = Arc::new(
crate::application::services::storage_usage_service::StorageUsageService::new(
maintenance_pool.clone(),
user_repository,
)
.with_drive_repo(
drive_repo
as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
),
);
// Keep cached storage usage fresh off the request path: GET /api/auth/me
@@ -1135,6 +1223,10 @@ impl AppServiceFactory {
// because services hold an Arc<PgAclEngine> for ReBAC checks.
// SubjectGroupPgRepository is constructed here too so the engine can
// expand a user's transitive group set on cache misses.
//
// Moved above the eager recent-service build so `create_recent_service`
// can receive an `Arc<PgAclEngine>` — the Read gate on
// `record_item_access` (post-Drive AuthZ audit fix) needs it.
let subject_group_repo = Arc::new(
crate::infrastructure::repositories::pg::SubjectGroupPgRepository::new(pool.clone()),
);
@@ -1145,6 +1237,29 @@ impl AppServiceFactory {
subject_group_repo.clone(),
);
// Recent service + recording hook are built up-front so the
// hook can be threaded into `create_application_services` below.
// The file services hold the hook directly so every authorised
// `_with_perms` read/write fires into `auth.user_recent_files`
// without per-handler wiring.
//
// The back-edge `recent_service_eager.set_resource_access_hook`
// closes the loop so the clear/remove handlers can drop the
// hook's in-memory throttle entries — without it a freshly
// cleared Recent list refuses to re-record the same file for a
// full TTL window, surfacing as "I cleared, opened the file,
// and Recent is still empty" (caught by tests/api/recent.hurl
// step 8).
let recent_service_eager = self.create_recent_service(&pool, &authorization);
let resource_access_hook: Arc<
dyn crate::application::ports::resource_access_hook::ResourceAccessHook,
> = Arc::new(
crate::infrastructure::services::recent_recording_hook::RecentRecordingHook::new(
recent_service_eager.clone(),
),
);
recent_service_eager.set_resource_access_hook(resource_access_hook.clone());
// Drive repository — needed both by the lifecycle hook (when auth
// is enabled) and by `GET /api/drives` on the final `AppState`,
// so declared at the outer scope.
@@ -1159,7 +1274,8 @@ impl AppServiceFactory {
// 3c. Storage usage / quota service (needed by the instant-upload
// path inside the application services, and re-exposed on AppState
// for the handler-side quota checks of the byte-upload paths).
let storage_usage = self.create_storage_usage_service(&repos, &pool, &maintenance_pool);
let storage_usage =
self.create_storage_usage_service(&repos, &pool, &maintenance_pool, drive_repo.clone());
// 3d. Content index (embedded Tantivy) — opened before application
// services so SearchService can hold the query port; the feeding
@@ -1205,10 +1321,11 @@ impl AppServiceFactory {
content_index.as_ref().map(|(idx, _)| idx.clone()),
plugin_dispatch.clone(),
mount_router.clone(),
Some(resource_access_hook.clone()),
);
// 5. Share service
let share_service = self.create_share_service(&repos, &pool, &authorization);
let share_service = self.create_share_service(&repos, &pool, &authorization, &drive_repo);
apps.share_service = share_service.clone();
let share_browse_service = share_service.as_ref().map(|s| {
@@ -1226,6 +1343,9 @@ impl AppServiceFactory {
let places_service: Option<Arc<PlacesService>>;
let people_service: Option<Arc<PeopleService>>;
let storage_usage_service: Option<Arc<StorageUsageService>>;
let grant_cleanup_service: Option<
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
>;
let mut auth_services: Option<crate::common::di::AuthServices> = None;
let mut nextcloud_services: Option<NextcloudServices> = None;
// Lifted out of the database-services block so PR 9's invite
@@ -1239,13 +1359,15 @@ impl AppServiceFactory {
> = None;
{
let favs = self.create_favorites_service(&pool);
let favs = self.create_favorites_service(&pool, &authorization);
favorites_service = Some(favs.clone());
apps.favorites_service = Some(favs);
let recent = self.create_recent_service(&pool);
recent_service = Some(recent.clone());
apps.recent_service = Some(recent);
// Already built up-front so the file services could hold the
// RecentRecordingHook — reuse the same Arc here so AppState and
// the recording hook share one service instance.
recent_service = Some(recent_service_eager.clone());
apps.recent_service = Some(recent_service_eager.clone());
places_service = if core.config.features.enable_places {
Some(self.create_places_service(&repos.file_read_repository))
@@ -1267,6 +1389,25 @@ impl AppServiceFactory {
self.start_content_index_job(&maintenance_pool, &core, content_index);
grant_cleanup_service = if core.config.features.grant_cleanup.enabled {
let svc = Arc::new(
crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new(
authorization.clone(),
core.config.features.grant_cleanup.grace_days,
core.config.features.grant_cleanup.interval_hours,
),
);
// First tick fires immediately inside start_cleanup_job —
// matches the trash/storage-usage daemon shape.
svc.clone().start_cleanup_job().await;
Some(svc)
} else {
tracing::info!(
"Grant-cleanup daemon disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false"
);
None
};
// User-lifecycle dispatcher. Hook order is registration order;
// document dependencies inline if/when any arise. Today:
// 1. AuditLifecycleHook — fires first so the
@@ -1310,6 +1451,50 @@ impl AppServiceFactory {
pool.clone(),
),
);
// CalDAV / CardDAV storage — constructed here (rather than in
// block #10 below) so the two default-provisioning lifecycle
// hooks can be wired into `user_lifecycle_builder` with the
// rest of the chain. The Arcs are cloned into both the hooks
// and, later, into their respective services — cheap and
// matches the pattern used for `drive_repo` above.
let calendar_repo_for_hook: Arc<
crate::infrastructure::repositories::pg::CalendarPgRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()),
);
let event_repo_for_hook: Arc<
crate::infrastructure::repositories::pg::CalendarEventPgRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::CalendarEventPgRepository::new(
pool.clone(),
),
);
let calendar_storage_for_hook = Arc::new(
crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new(
calendar_repo_for_hook.clone(),
event_repo_for_hook.clone(),
)
);
let address_book_repo_for_hook: Arc<AddressBookPgRepository> = Arc::new(
crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()),
);
let contact_repo_for_hook: Arc<ContactPgRepository> = Arc::new(
crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()),
);
let group_repo_for_hook: Arc<ContactGroupPgRepository> = Arc::new(
crate::infrastructure::repositories::pg::ContactGroupPgRepository::new(
pool.clone(),
),
);
let contact_storage_for_hook = Arc::new(
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
address_book_repo_for_hook.clone(),
contact_repo_for_hook.clone(),
group_repo_for_hook.clone(),
),
);
let mut user_lifecycle_builder =
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
.with_hook(Arc::new(
@@ -1321,6 +1506,19 @@ impl AppServiceFactory {
authorization.clone(),
),
))
.with_hook(Arc::new(
crate::application::services::calendar_service::DefaultCalendarLifecycleHook::new(
calendar_storage_for_hook.clone(),
authorization.clone(),
),
))
.with_hook(Arc::new(
crate::application::services::contact_service::DefaultAddressBookLifecycleHook::new(
address_book_repo_for_hook.clone(),
contact_storage_for_hook.clone(),
authorization.clone(),
),
))
.with_hook(Arc::new(
crate::infrastructure::services::pg_acl_engine::AuthzCacheLifecycleHook::new(
authorization.clone(),
@@ -1492,8 +1690,8 @@ impl AppServiceFactory {
places_service,
people_service,
storage_usage_service,
grant_cleanup_service,
calendar_service: None,
contact_service: None,
calendar_use_case: None,
addressbook_use_case: None,
contact_use_case: None,
@@ -1506,6 +1704,8 @@ impl AppServiceFactory {
path_resolver: None,
webdav_lock_store:
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
webdav_dead_props:
crate::infrastructure::services::webdav_dead_property_store::create_dead_property_store(pool.clone()),
authorization: authorization.clone(),
drive_repo: drive_repo.clone(),
drive_management_service: Arc::new(
@@ -1513,6 +1713,11 @@ impl AppServiceFactory {
drive_repo.clone(),
authorization.clone(),
subject_group_repo.clone(),
Arc::new(
crate::infrastructure::repositories::pg::UserPgRepository::new(
pool.clone(),
),
),
),
),
subject_group_service: Some(Arc::new(
@@ -1525,6 +1730,7 @@ impl AppServiceFactory {
),
),
authorization.clone(),
drive_repo.clone(),
),
)),
email_sender: None, // populated below
@@ -1737,7 +1943,13 @@ impl AppServiceFactory {
tracing::info!("PathResolver service initialized");
}
// 10. Wire CalDAV/CardDAV services
// 10. Wire CalDAV/CardDAV services. Note: the `*_for_hook`
// adapters constructed inside the enable-auth block above
// are out of scope here (that block ends before AppState
// assembly). Re-constructing local adapters over the same
// `pool` is cheap — the pool itself is shared via Arc, and
// adapters are stateless delegators. Both instances end up
// talking to the same rows.
{
// CalDAV
let calendar_repo: Arc<CalendarPgRepository> = Arc::new(
@@ -1757,6 +1969,7 @@ impl AppServiceFactory {
let calendar_service = Arc::new(
crate::application::services::calendar_service::CalendarService::new(
calendar_storage,
authorization.clone(),
),
);
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
@@ -1778,10 +1991,12 @@ impl AppServiceFactory {
address_book_repo,
contact_repo,
group_repo,
)
),
);
app_state.addressbook_use_case = Some(contact_storage.clone());
app_state.contact_use_case = Some(contact_storage);
let contact_service =
Arc::new(ContactService::new(contact_storage, authorization.clone()));
app_state.addressbook_use_case = Some(contact_service.clone());
app_state.contact_use_case = Some(contact_service);
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
}
@@ -1801,7 +2016,7 @@ impl AppServiceFactory {
audio_metadata_repo,
),
);
let music_svc = Arc::new(MusicService::new(music_storage));
let music_svc = Arc::new(MusicService::new(music_storage, authorization.clone()));
app_state.music_service = Some(music_svc);
tracing::info!("Music service initialized");
}
@@ -1959,11 +2174,18 @@ pub struct AppState {
pub places_service: Option<Arc<PlacesService>>,
pub people_service: Option<Arc<PeopleService>>,
pub storage_usage_service: Option<Arc<StorageUsageService>>,
/// Handle to the background daemon that purges expired
/// `storage.role_grants` rows. `None` when the daemon is disabled
/// via `OXICLOUD_GRANT_CLEANUP_ENABLED=false`. The admin
/// `POST /api/admin/internal/trigger-grant-cleanup` handler uses
/// this to invoke the purge on demand (test-only).
pub grant_cleanup_service: Option<
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
>,
pub calendar_service: Option<Arc<CalendarService>>,
pub contact_service: Option<Arc<ContactStorageAdapter>>,
pub calendar_use_case: Option<Arc<CalendarService>>,
pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
pub addressbook_use_case: Option<Arc<ContactService>>,
pub contact_use_case: Option<Arc<ContactService>>,
pub music_service: Option<Arc<MusicService>>,
pub wopi_token_service:
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
@@ -1979,6 +2201,8 @@ pub struct AppState {
Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>,
pub webdav_lock_store:
Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>,
pub webdav_dead_props:
Arc<crate::infrastructure::services::webdav_dead_property_store::DeadPropertyStore>,
/// ReBAC authorization engine — all service-layer permission checks go
/// through this. Concrete type today is `PgAclEngine`; the
/// `AuthorizationEngine` trait describes the contract. When alternate
@@ -2063,6 +2287,51 @@ 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;
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`;
/// the `OXICLOUD_AUTHZ_ENGINE` env var is reserved for future alternate
/// implementations (e.g. `openfga`).