From 3362e277abe7890cd88634dac09875e6efc7de34 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 11:07:04 +0200 Subject: [PATCH] feat(authz): check permission on read handlers + check create permission on folder --- src/application/ports/authorization_ports.rs | 13 ++ src/application/ports/file_ports.rs | 20 ++- src/application/ports/folder_ports.rs | 12 +- src/application/services/batch_operations.rs | 10 +- .../services/file_management_service.rs | 12 ++ .../services/file_retrieval_service.rs | 65 +++++-- src/application/services/folder_service.rs | 170 ++++++++++++------ .../services/idor_protection_test.rs | 4 +- .../services/share_browse_service.rs | 4 +- src/application/services/trash_service.rs | 44 ++++- src/common/di.rs | 15 +- src/common/stubs.rs | 37 +++- src/domain/services/authorization.rs | 38 +++- .../api/handlers/chunked_upload_handler.rs | 3 + src/interfaces/api/handlers/file_handler.rs | 92 ++++++---- src/interfaces/api/handlers/folder_handler.rs | 49 ++--- src/interfaces/api/handlers/share_handler.rs | 1 + src/interfaces/api/handlers/webdav_handler.rs | 6 +- src/interfaces/api/handlers/wopi_handler.rs | 2 +- tests/common/server.env | 1 + tests/webdav/common.sh | 10 +- 21 files changed, 428 insertions(+), 180 deletions(-) diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 2ded9878..941d4bd5 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -36,12 +36,25 @@ pub trait AuthorizationEngine: Send + Sync + 'static { resource: Resource, ) -> Result<(), DomainError> { if self.check(subject, permission, resource).await? { + tracing::debug!( + "👮🏻‍♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'", + subject, + permission, + resource + ); Ok(()) } else { let (kind, id) = match resource { Resource::Folder(id) => ("Folder", id), Resource::File(id) => ("File", id), }; + // log it for audit + tracing::info!( + "👮🏻‍♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'", + subject, + permission, + resource + ); Err(DomainError::not_found(kind, id.to_string())) } } diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index bc2be325..50ef37cc 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -11,6 +11,7 @@ use crate::application::services::file_management_service::FileManagementService use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::file_upload_service::FileUploadService; use crate::common::errors::DomainError; +use crate::domain::services::authorization::Permission; // ───────────────────────────────────────────────────── // Upload port @@ -119,7 +120,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// /// Returns `NotFound` if the file does not exist **or** belongs to /// another user. All user-facing handlers should use this method. - async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result; + async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result; /// Gets a file by its path (for WebDAV) async fn get_file_by_path(&self, path: &str) -> Result; @@ -131,7 +132,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// /// Uses SQL-level `AND user_id` filtering — no in-memory post-filter. /// All user-facing list handlers should use this method. - async fn list_files_owned( + async fn list_files_with_perms( &self, folder_id: Option<&str>, owner_id: Uuid, @@ -144,7 +145,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { ) -> Result> + Send>, DomainError>; /// Gets file content as a stream, enforcing that `caller_id` is the owner. - async fn get_file_stream_owned( + async fn get_file_stream_with_perms( &self, id: &str, caller_id: Uuid, @@ -166,7 +167,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// /// Verifies `caller_id` owns the file before returning content. /// All user-facing download handlers should use this. - async fn get_file_optimized_owned( + async fn get_file_optimized_with_perms( &self, id: &str, caller_id: Uuid, @@ -198,7 +199,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { ) -> Result> + Send>, DomainError>; /// Ownership-scoped range stream — verifies caller owns the file first. - async fn get_file_range_stream_owned( + async fn get_file_range_stream_with_perms( &self, id: &str, caller_id: Uuid, @@ -238,7 +239,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// /// Used by streaming WebDAV PROPFIND so that each user only sees their /// own files, even in shared folder_id namespaces. - async fn list_files_batch_for_owner( + async fn list_files_batch_with_perms( &self, folder_id: Option<&str>, owner_id: Uuid, @@ -256,6 +257,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// Primary port for file management operations pub trait FileManagementUseCase: Send + Sync + 'static { + async fn has_permission( + &self, + caller_id: Uuid, + permission: Permission, + file_id: &str, + ) -> Result<(), DomainError>; + /// Moves a file, enforcing that `caller_id` is the owner. async fn move_file_with_perms( &self, diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs index 1e736adc..f34e61ad 100644 --- a/src/application/ports/folder_ports.rs +++ b/src/application/ports/folder_ports.rs @@ -6,8 +6,16 @@ use crate::application::dtos::folder_dto::{ }; use crate::common::errors::DomainError; +use crate::domain::services::authorization::Permission; pub trait FolderUseCase: Send + Sync + 'static { + async fn has_permission( + &self, + caller_id: Uuid, + permission: Permission, + folder_id: &str, + ) -> Result<(), DomainError>; + /// Creates a new folder async fn create_folder_with_perms( &self, @@ -36,7 +44,7 @@ pub trait FolderUseCase: Send + Sync + 'static { /// Lists folders scoped to a specific owner (for user-facing endpoints). /// At root level, only returns folders belonging to this user. - async fn list_folders_for_owner( + async fn list_folders_with_perms( &self, parent_id: Option<&str>, owner_id: Uuid, @@ -50,7 +58,7 @@ pub trait FolderUseCase: Send + Sync + 'static { ) -> Result, DomainError>; /// Lists folders with pagination, scoped to a specific owner. - async fn list_folders_for_owner_paginated( + async fn list_folders_paginated_with_perms( &self, parent_id: Option<&str>, owner_id: Uuid, diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 7bbaa9ec..921d88b4 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -330,7 +330,7 @@ impl BatchOperationService { let retrieval = self.file_retrieval.clone(); async move { - let get_result = retrieval.get_file_owned(&file_id, user_id).await; + let get_result = retrieval.get_file_with_perms(&file_id, user_id).await; (file_id, get_result) } })) @@ -717,7 +717,11 @@ impl BatchOperationService { // ── Add individual files at the root of the ZIP ────────────────── for file_id in &file_ids { - match self.file_retrieval.get_file_owned(file_id, user_id).await { + match self + .file_retrieval + .get_file_with_perms(file_id, user_id) + .await + { Ok(file_dto) => { if let Err(e) = self .add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id) @@ -790,7 +794,7 @@ impl BatchOperationService { let stream = self .file_retrieval - .get_file_stream_owned(file_id, caller_id) + .get_file_stream_with_perms(file_id, caller_id) .await .map_err(BatchOperationError::Domain)?; let mut stream = std::pin::Pin::from(stream); diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index f234e0f7..8f875a7b 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -225,6 +225,18 @@ impl FileManagementService { } impl FileManagementUseCase for FileManagementService { + async fn has_permission( + &self, + caller_id: Uuid, + permission: Permission, + file_id: &str, + ) -> Result<(), DomainError> { + let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?; + self.authz + .require(Subject::User(caller_id), permission, Resource::File(uuid)) + .await + } + async fn move_file_with_perms( &self, file_id: &str, diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 037049a6..d9a4fe9a 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -64,6 +64,8 @@ impl FileRetrievalService { } } + // ── private helpers ────────────────────────────────────────── + /// Helper: require the caller has `perm` on the given file id. /// Fail-closed if no engine was injected (stub/test path). async fn require_file( @@ -81,7 +83,25 @@ impl FileRetrievalService { .await } - // ── private helpers ────────────────────────────────────────── + /// Engine check for a target folder. `None` is allowed (root namespace, + /// implicitly owned by the caller). + async fn require_target_folder_perm( + &self, + folder_id: Option<&str>, + perm: Permission, + caller_id: Uuid, + ) -> Result<(), DomainError> { + let Some(target) = folder_id else { + return Ok(()); + }; + let authz = self.authz.as_ref().ok_or_else(|| { + DomainError::internal_error("FileRetrieval", "Authorization engine unavailable") + })?; + let uuid = Uuid::parse_str(target).map_err(|_| DomainError::not_found("Folder", target))?; + authz + .require(Subject::User(caller_id), perm, Resource::Folder(uuid)) + .await + } /// Try to transcode image content to WebP and return transcoded variant. async fn try_transcode( @@ -229,12 +249,13 @@ impl FileRetrievalUseCase for FileRetrievalService { Ok(FileDto::from(file)) } - async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result { + async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result { self.require_file(id, Permission::Read, caller_id).await?; let file = self.file_read.get_file(id).await?; Ok(FileDto::from(file)) } + // FIXME no authorisation at all async fn get_file_by_path(&self, path: &str) -> Result { // Direct SQL lookup — O(folder_depth) queries instead of O(total_files) // NOTE: This method does NOT perform any authorization check. Callers @@ -256,16 +277,24 @@ impl FileRetrievalUseCase for FileRetrievalService { Ok(files.into_iter().map(FileDto::from).collect()) } - async fn list_files_owned( + async fn list_files_with_perms( &self, folder_id: Option<&str>, owner_id: Uuid, ) -> Result, DomainError> { - let files = self - .file_read - .list_files_for_owner(folder_id, owner_id) - .await?; - Ok(files.into_iter().map(FileDto::from).collect()) + if folder_id.is_some() { + // folder id is defined, check permissions + self.require_target_folder_perm(folder_id, Permission::Read, owner_id) + .await?; + self.list_files(folder_id).await + } else { + // no folder id, get owners's files' root + let files = self + .file_read + .list_files_for_owner(folder_id, owner_id) + .await?; + Ok(files.into_iter().map(FileDto::from).collect()) + } } async fn get_file_stream( @@ -275,7 +304,7 @@ impl FileRetrievalUseCase for FileRetrievalService { self.file_read.get_file_stream(id).await } - async fn get_file_stream_owned( + async fn get_file_stream_with_perms( &self, id: &str, caller_id: Uuid, @@ -297,7 +326,7 @@ impl FileRetrievalUseCase for FileRetrievalService { .await } - async fn get_file_optimized_owned( + async fn get_file_optimized_with_perms( &self, id: &str, caller_id: Uuid, @@ -333,7 +362,7 @@ impl FileRetrievalUseCase for FileRetrievalService { self.file_read.get_file_range_stream(id, start, end).await } - async fn get_file_range_stream_owned( + async fn get_file_range_stream_with_perms( &self, id: &str, caller_id: Uuid, @@ -344,6 +373,7 @@ impl FileRetrievalUseCase for FileRetrievalService { self.file_read.get_file_range_stream(id, start, end).await } + // TODO: check: no permission check async fn stream_files_in_subtree( &self, folder_id: &str, @@ -366,13 +396,24 @@ impl FileRetrievalUseCase for FileRetrievalService { Ok(files.into_iter().map(FileDto::from).collect()) } - async fn list_files_batch_for_owner( + async fn list_files_batch_with_perms( &self, folder_id: Option<&str>, owner_id: Uuid, offset: i64, limit: i64, ) -> Result, DomainError> { + if folder_id.is_some() { + // folder id is defined, check permissions + self.require_target_folder_perm(folder_id, Permission::Read, owner_id) + .await?; + let files = self + .file_read + .list_files_batch(folder_id, offset, limit) + .await?; + return Ok(files.into_iter().map(FileDto::from).collect()); + } + let files = self .file_read .list_files_batch_for_owner(folder_id, owner_id, offset, limit) diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 0a3fc2df..03ba62ac 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -41,6 +41,14 @@ impl FolderService { struct FolderServiceStub; impl FolderUseCase for FolderServiceStub { + async fn has_permission( + &self, + _caller_id: Uuid, + _permission: Permission, + _folder_id: &str, + ) -> Result<(), DomainError> { + Ok(()) + } async fn create_folder_with_perms( &self, _dto: CreateFolderDto, @@ -72,7 +80,7 @@ impl FolderService { Ok(vec![]) } - async fn list_folders_for_owner( + async fn list_folders_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, @@ -98,7 +106,7 @@ impl FolderService { ) } - async fn list_folders_for_owner_paginated( + async fn list_folders_paginated_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, @@ -157,6 +165,28 @@ impl FolderService { } impl FolderUseCase for FolderService { + /// Verifies the caller has the given permition on a resource + /// `folder_id`. `None` is the caller's root namespace and always allowed. + /// + /// Returns `Ok(())` when permitted, `DomainError::not_found(...)` when not + /// (anti-enumeration — same error as "folder doesn't exist"). + /// + /// Used by handlers that need a fail-fast pre-check BEFORE spooling + /// large request bodies (file upload, chunked upload). The authoritative + /// check happens again inside the upload/management services before any + /// DB write — this is a UX/resource optimization, not a security boundary. + async fn has_permission( + &self, + caller_id: Uuid, + permission: Permission, + folder_id: &str, + ) -> Result<(), DomainError> { + let resource = Self::folder_resource(folder_id)?; + self.authz + .require(Subject::User(caller_id), permission, resource) + .await + } + /// Creates a new folder async fn create_folder_with_perms( &self, @@ -271,6 +301,7 @@ impl FolderUseCase for FolderService { .list_folders(parent_id) .await .map_err(|e| { + tracing::warn!("errror while fetching folders {}", e); DomainError::internal_error( "FolderStorage", format!("Failed to list folders in parent: {:?}: {}", parent_id, e), @@ -283,59 +314,77 @@ impl FolderUseCase for FolderService { /// Lists folders scoped to a specific owner. /// Self-healing: if listing root folders and none exist, creates a home folder. - async fn list_folders_for_owner( + async fn list_folders_with_perms( &self, parent_id: Option<&str>, - owner_id: Uuid, + caller_id: Uuid, ) -> Result, DomainError> { - let folders = self - .folder_storage - .list_folders_by_owner(parent_id, owner_id) - .await - .map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!( - "Failed to list folders for owner '{}' in parent {:?}: {}", - owner_id, parent_id, e - ), + if let Some(parent_id_unwrapped) = parent_id { + // check authorisation + self.authz + .require( + Subject::User(caller_id), + Permission::Read, + Self::folder_resource(parent_id_unwrapped)?, ) - })?; - - // Self-healing: if listing root folders and none exist, create a home folder - // This ensures the frontend always gets a valid userHomeFolderId - if parent_id.is_none() && folders.is_empty() { - tracing::info!( - "No root folders found for user {}, creating home folder automatically", - owner_id - ); - let owner_id_short = { - let s = owner_id.to_string(); - s[..8.min(s.len())].to_string() - }; - let folder_name = format!("My Folder - {}", owner_id_short); - match self + .await?; + return self.list_folders(parent_id).await; + } else { + // No parent defined grab user's homes + let folders = self .folder_storage - .create_home_folder(owner_id, folder_name.clone()) + .list_folders_by_owner(parent_id, caller_id) .await - { - Ok(home_folder) => { - tracing::info!( - "Created home folder '{}' for user {}", - folder_name, - owner_id - ); - return Ok(vec![FolderDto::from(home_folder)]); - } - Err(e) => { - tracing::warn!("Failed to create home folder for user {}: {}", owner_id, e); - // Return empty list rather than failing - user might not have storage quota, etc. + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!( + "Failed to list folders for owner '{}' in parent {:?}: {}", + caller_id, parent_id, e + ), + ) + })?; + + if folders.is_empty() { + // Self-healing: if listing root folders and none exist, create a home folder + // This ensures the frontend always gets a valid userHomeFolderId + tracing::info!( + "No root folders found for user {}, creating home folder automatically", + caller_id + ); + let owner_id_short = { + let s = caller_id.to_string(); + s[..8.min(s.len())].to_string() + }; + // TODO: what about i18n ? + let folder_name = format!("My Folder - {}", owner_id_short); + match self + .folder_storage + .create_home_folder(caller_id, folder_name.clone()) + .await + { + Ok(home_folder) => { + tracing::info!( + "Created home folder '{}' for user {}", + folder_name, + caller_id + ); + return Ok(vec![FolderDto::from(home_folder)]); + } + Err(e) => { + tracing::warn!( + "Failed to create home folder for user {}: {}", + caller_id, + e + ); + // Return empty list rather than failing - user might not have storage quota, etc. + } } } + Ok(folders.into_iter().map(FolderDto::from).collect()) } - - Ok(folders.into_iter().map(FolderDto::from).collect()) } + // TODO: move self healing in other part (on account creation on or login ?) /// Lists folders with pagination async fn list_folders_paginated( @@ -373,7 +422,7 @@ impl FolderUseCase for FolderService { } /// Lists folders with pagination, scoped to a specific owner. - async fn list_folders_for_owner_paginated( + async fn list_folders_paginated_with_perms( &self, parent_id: Option<&str>, owner_id: Uuid, @@ -382,7 +431,17 @@ impl FolderUseCase for FolderService { { let pagination = pagination.validate_and_adjust(); - let (folders, total_items) = self + if let Some(parent_id_unwrapped) = parent_id { + self.authz + .require( + Subject::User(owner_id), + Permission::Read, + Self::folder_resource(parent_id_unwrapped)?, + ) + .await?; + return self.list_folders_paginated(parent_id, &pagination).await; + } else { + let (folders, total_items) = self .folder_storage .list_folders_by_owner_paginated( parent_id, @@ -402,16 +461,17 @@ impl FolderUseCase for FolderService { ) })?; - let total = total_items.unwrap_or(folders.len()); + let total = total_items.unwrap_or(folders.len()); - let response = crate::application::dtos::pagination::PaginatedResponseDto::new( - folders.into_iter().map(FolderDto::from).collect(), - pagination.page, - pagination.page_size, - total, - ); + let response = crate::application::dtos::pagination::PaginatedResponseDto::new( + folders.into_iter().map(FolderDto::from).collect(), + pagination.page, + pagination.page_size, + total, + ); - Ok(response) + Ok(response) + } } /// Renames a folder after verifying the caller has `Update` permission. diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index 0a36815f..e143bc5c 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -387,7 +387,7 @@ use crate::common::stubs::StubFileRetrievalUseCase; async fn stub_get_file_owned_returns_ok() { let user_id = Uuid::new_v4(); let stub = StubFileRetrievalUseCase; - let result = stub.get_file_owned("file-1", user_id).await; + let result = stub.get_file_with_perms("file-1", user_id).await; assert!(result.is_ok(), "stub should return Ok for get_file_owned"); } @@ -396,7 +396,7 @@ async fn stub_get_file_optimized_owned_returns_ok() { let user_id = Uuid::new_v4(); let stub = StubFileRetrievalUseCase; let result = stub - .get_file_optimized_owned("file-1", user_id, true, false) + .get_file_optimized_with_perms("file-1", user_id, true, false) .await; assert!( result.is_ok(), diff --git a/src/application/services/share_browse_service.rs b/src/application/services/share_browse_service.rs index fdd2ecf5..64f0bd92 100644 --- a/src/application/services/share_browse_service.rs +++ b/src/application/services/share_browse_service.rs @@ -179,9 +179,9 @@ impl ShareBrowseService { ) -> Result { let (folders_res, files_res) = tokio::join!( self.folder_service - .list_folders_for_owner(Some(parent_folder_id), owner_id), + .list_folders_with_perms(Some(parent_folder_id), owner_id), self.file_retrieval - .list_files_owned(Some(parent_folder_id), owner_id), + .list_files_with_perms(Some(parent_folder_id), owner_id), ); Ok(FolderListingDto { folders: folders_res?, diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 95249994..0980da6e 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -6,18 +6,21 @@ use crate::application::dtos::display_helpers::{ category_for, icon_class_for, icon_special_class_for, }; use crate::application::dtos::trash_dto::TrashedItemDto; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::trash_repository::TrashRepository; +use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository; use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::file_content_cache::FileContentCache; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::infrastructure::services::thumbnail_service::ThumbnailService; /** @@ -56,6 +59,9 @@ pub struct TrashService { /// Content cache — invalidated when files are permanently deleted from trash. content_cache: Option>, + /// Authz engine + authz: Arc, + /// Number of days items should be kept in trash before automatic cleanup retention_days: u32, } @@ -71,6 +77,7 @@ impl TrashService { dedup_service: Arc, thumbnail_service: Option>, content_cache: Option>, + authz: Arc, ) -> Self { Self { trash_repository, @@ -80,6 +87,7 @@ impl TrashService { dedup_service, thumbnail_service, content_cache, + authz, retention_days, } } @@ -176,6 +184,7 @@ impl TrashUseCase for TrashService { Ok(dtos) } + // TODO: change item_type into Resource enum #[instrument(skip(self))] async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: Uuid) -> Result<()> { info!( @@ -209,10 +218,23 @@ impl TrashUseCase for TrashService { "file" => { info!("Processing file to move to trash: {}", item_id); + // XXX: right now only owner can move to trash, need to improve + // Get the file — ownership-verified at SQL level. // Returns NotFound if the file does not exist OR belongs to // another user, preventing cross-user trash operations. debug!("Getting file data (owner-scoped): {}", item_id); + + let file_id = Uuid::parse_str(item_id) + .map_err(|_| DomainError::not_found("File", item_id))?; + self.authz + .require( + Subject::User(user_id), + Permission::Delete, + Resource::File(file_id), + ) + .await?; + let file = match self .file_read_port .get_file_for_owner(item_id, user_id) @@ -236,6 +258,7 @@ impl TrashUseCase for TrashService { debug!("Original file path: {}", original_path); // Create the trash item + // FIXME: item will be created with user_id that mat not be the owner_id debug!("Creating TrashedItem object for the file"); let trashed_item = TrashedItem::new( item_uuid, @@ -286,6 +309,17 @@ impl TrashUseCase for TrashService { Ok(()) } "folder" => { + // check deletion permition + let folder_id = Uuid::parse_str(item_id) + .map_err(|_| DomainError::not_found("Folder", item_id))?; + self.authz + .require( + Subject::User(user_id), + Permission::Delete, + Resource::Folder(folder_id), + ) + .await?; + // Get the folder and verify ownership. // Returns NotFound if the folder does not exist or belongs // to another user — prevents cross-user trash operations. @@ -301,18 +335,10 @@ impl TrashUseCase for TrashService { ) })?; - // Ownership check — return NotFound (not Forbidden) to - // prevent leaking whether the folder exists. - if folder.owner_id() != Some(user_id) { - return Err(DomainError::not_found( - "Folder", - format!("Folder not found: {}", item_id), - )); - } - let original_path = folder.storage_path().to_string(); // Create the trash item + // FIXME: item will be created with user_id that mat not be the owner_id let trashed_item = TrashedItem::new( item_uuid, user_uuid, diff --git a/src/common/di.rs b/src/common/di.rs index ffabe91c..d44e7baa 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -38,6 +38,7 @@ use crate::infrastructure::services::file_content_cache::{ use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService; use crate::infrastructure::services::path_service::PathService; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::application::services::app_password_service::AppPasswordService; @@ -350,7 +351,7 @@ impl AppServiceFactory { repos: &RepositoryServices, trash_service: Option>, db_pool: &Arc, - authz: &Arc, + authz: &Arc, ) -> ApplicationServices { // Main services let folder_service = Arc::new(FolderService::new( @@ -452,6 +453,7 @@ impl AppServiceFactory { &self, repos: &RepositoryServices, core: &CoreServices, + authz: &Arc, ) -> Option> { if !self.config.features.enable_trash { tracing::info!("Trash service is disabled in configuration"); @@ -470,6 +472,7 @@ impl AppServiceFactory { core.dedup_service.clone(), Some(core.thumbnail_service.clone()), Some(core.file_content_cache.clone()), + authz.clone(), )); // Initialize cleanup service (bulk-deletes expired items in 2 SQL queries) @@ -609,10 +612,7 @@ impl AppServiceFactory { // 2. Repository services (requires PgPool for all metadata) let repos = self.create_repository_services(&core, &pool); - // 3. Trash service (needed before application services) - let trash_service = self.create_trash_service(&repos, &core).await; - - // 3b. Authorization engine — must exist before application services + // 3a. Authorization engine — must exist before application services // because services hold an Arc for ReBAC checks. let authorization = build_authorization_engine( pool.clone(), @@ -620,6 +620,11 @@ impl AppServiceFactory { repos.file_read_repository.clone(), ); + // 3b. Trash service (needed before application services) + let trash_service = self + .create_trash_service(&repos, &core, &authorization) + .await; + // 4. Application services (with trash + authz already wired) let mut apps = self.create_application_services( &core, diff --git a/src/common/stubs.rs b/src/common/stubs.rs index dd944da2..ca4972a3 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -34,6 +34,7 @@ use crate::common::errors::DomainError; use crate::domain::entities::file::File; use crate::domain::entities::folder::Folder; use crate::domain::repositories::folder_repository::FolderRepository; +use crate::domain::services::authorization::Permission; use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale}; use crate::domain::services::path_service::StoragePath; @@ -355,6 +356,15 @@ impl I18nService for StubI18nService { pub struct StubFolderUseCase; impl FolderUseCase for StubFolderUseCase { + async fn has_permission( + &self, + _caller_id: Uuid, + _permission: Permission, + _file_id: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn create_folder_with_perms( &self, _dto: CreateFolderDto, @@ -383,7 +393,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(Vec::new()) } - async fn list_folders_for_owner( + async fn list_folders_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, @@ -399,7 +409,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) } - async fn list_folders_for_owner_paginated( + async fn list_folders_paginated_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, @@ -521,7 +531,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Vec::new()) } - async fn list_files_owned( + async fn list_files_with_perms( &self, _folder_id: Option<&str>, _owner_id: Uuid, @@ -537,7 +547,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Box::new(empty_stream)) } - async fn get_file_stream_owned( + async fn get_file_stream_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -583,11 +593,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Box::pin(futures::stream::empty())) } - async fn get_file_owned(&self, _id: &str, _caller_id: Uuid) -> Result { + async fn get_file_with_perms( + &self, + _id: &str, + _caller_id: Uuid, + ) -> Result { Ok(FileDto::default()) } - async fn get_file_optimized_owned( + async fn get_file_optimized_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -604,7 +618,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { )) } - async fn get_file_range_stream_owned( + async fn get_file_range_stream_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -623,6 +637,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { pub struct StubFileManagementUseCase; impl FileManagementUseCase for StubFileManagementUseCase { + async fn has_permission( + &self, + _caller_id: Uuid, + _permission: Permission, + _file_id: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn copy_file_with_perms( &self, _file_id: &str, diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 98036d81..42a03f02 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -5,6 +5,7 @@ //! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation //! maps them to / from `storage.access_grants` rows. +use std::fmt; use uuid::Uuid; // ════════════════════════════════════════════════════════════════════════════ @@ -60,6 +61,12 @@ impl Subject { } } +impl fmt::Display for Subject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({})", self.type_str(), self.id()) + } +} + // ════════════════════════════════════════════════════════════════════════════ // Resource — what the permission is on // ════════════════════════════════════════════════════════════════════════════ @@ -68,6 +75,12 @@ impl Subject { pub enum Resource { Folder(Uuid), File(Uuid), + // Reserved for future use: + // Calendar(Uuid), + // Reserved for future use: + // AddressBook(Uuid), + // Reserved for future use: + // Playlist(Uuid), } impl Resource { @@ -75,12 +88,20 @@ impl Resource { match self { Resource::Folder(_) => "folder", Resource::File(_) => "file", + //Resource::Calendar(_) => "calendar", + //Resource::AddressBook(_) => "adressbook", + //Resource::Playlist(_) => "playlist", } } pub fn id(&self) -> Uuid { match self { - Resource::Folder(id) | Resource::File(id) => *id, + Resource::Folder(id) + | Resource::File(id) + //| Resource::Calendar(id) + //| Resource::AddressBook(id) + //| Resource::Playlist(id) + => *id, } } @@ -88,11 +109,20 @@ impl Resource { match resource_type { "folder" => Some(Resource::Folder(id)), "file" => Some(Resource::File(id)), + //"calendar" => Some(Resource::Calendar(id)), + //"adressbook" => Some(Resource::AddressBook(id)), + //"playlist" => Some(Resource::Playlist(id)), _ => None, } } } +impl fmt::Display for Resource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({})", self.type_str(), self.id()) + } +} + // ════════════════════════════════════════════════════════════════════════════ // Permission — what action is allowed // ════════════════════════════════════════════════════════════════════════════ @@ -151,6 +181,12 @@ impl Permission { } } +impl fmt::Display for Permission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + // ════════════════════════════════════════════════════════════════════════════ // Grant — a row in storage.access_grants // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 61c14209..8a5b8b44 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -90,6 +90,8 @@ impl ChunkedUploadHandler { /// "expires_at": 86400 /// } /// ``` + /// TODO: how is implemented security (owneship, permission ?) + /// current caveat: upload can start without know is path permits upload pub(super) async fn create_upload_impl( State(state): State>, auth_user: AuthUser, @@ -264,6 +266,7 @@ impl ChunkedUploadHandler { /// POST /api/uploads/:upload_id/complete - Finalize upload /// /// Assembles all chunks into the final file and creates the file record + // TODO: how is implemented security (owneship, permission ?) pub(super) async fn complete_upload_impl( State(state): State>, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 0286a5b4..4ffbac29 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -11,17 +11,17 @@ use serde::Deserialize; use std::collections::HashMap; use utoipa::ToSchema; -use crate::application::dtos::file_dto::FileDto; -use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::thumbnail_ports::ThumbnailPort; +use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase}; use crate::common::di::AppState; use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; +use crate::{application::dtos::file_dto::FileDto, domain::services::authorization::Permission}; use std::sync::Arc; /** @@ -85,6 +85,7 @@ impl FileHandler { tracing::debug!("📤 Processing streaming file upload (hash-on-write)"); + // caveat: if folder_id field is given after check can fails while let Some(field) = multipart.next_field().await.unwrap_or(None) { let name = field.name().unwrap_or("").to_string(); @@ -115,24 +116,24 @@ impl FileHandler { .unwrap_or("application/octet-stream") .to_string(); - // ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ── - if let Some(ref fid) = folder_id { - use crate::application::ports::folder_ports::FolderUseCase; - let folder_service = &state.applications.folder_service; - if folder_service - .get_folder_with_perms(fid, auth_user.id) + // ── Fail-fast pre-check: verify the caller can Create inside + // the target folder BEFORE spooling the multipart body to disk. + // The upload service re-checks at write time — this is a + // UX/resource optimization, not the security boundary. + if let Some(ref fid) = folder_id + && let Err(err) = state + .applications + .folder_service_concrete + .has_permission(auth_user.id, Permission::Create, fid) .await - .is_err() - { - tracing::warn!( - "⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user", - auth_user.username, - fid, - ); - return Err(Self::domain_error_response( - crate::common::errors::DomainError::not_found("Folder", fid), - )); - } + { + tracing::warn!( + "⛔ UPLOAD REJECTED: user='{}' folder='{}' err='{}'", + auth_user.username, + fid, + err + ); + return Err(Self::domain_error_response(err)); } // ── Early quota check (before spooling to disk) ────── @@ -328,6 +329,16 @@ impl FileHandler { ) -> impl IntoResponse { use crate::application::ports::thumbnail_ports::ThumbnailSize; + // check first that user can access this resource + if let Err(err) = state + .applications + .file_management_service + .has_permission(auth_user.id, Permission::Read, &id) + .await + { + return AppError::from(err).into_response(); + } + let thumbnail_service = &state.core.thumbnail_service; let thumb_size = match size.as_str() { @@ -385,7 +396,7 @@ impl FileHandler { let file_retrieval_service = &state.applications.file_retrieval_service; let file = match file_retrieval_service - .get_file_owned(&id, auth_user.id) + .get_file_with_perms(&id, auth_user.id) .await { Ok(f) => f, @@ -478,6 +489,16 @@ impl FileHandler { ) -> impl IntoResponse { use crate::application::ports::thumbnail_ports::ThumbnailSize; + // check first that user can access this resource + if let Err(err) = state + .applications + .file_management_service + .has_permission(auth_user.id, Permission::Update, &id) + .await + { + return AppError::from(err).into_response(); + } + let thumbnail_service = &state.core.thumbnail_service; // Validate size @@ -508,7 +529,7 @@ impl FileHandler { // Validate file ownership let file_retrieval_service = &state.applications.file_retrieval_service; if let Err(err) = file_retrieval_service - .get_file_owned(&id, auth_user.id) + .get_file_with_perms(&id, auth_user.id) .await { return AppError::from(err).into_response(); @@ -545,7 +566,7 @@ impl FileHandler { let retrieval = &state.applications.file_retrieval_service; // ── Get file metadata (ownership-scoped) ──────────────────────── - let file_dto = match retrieval.get_file_owned(&id, auth_user.id).await { + let file_dto = match retrieval.get_file_with_perms(&id, auth_user.id).await { Ok(f) => f, Err(err) => { return AppError::from(err).into_response(); @@ -603,7 +624,7 @@ impl FileHandler { Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); match retrieval - .get_file_range_stream_owned(&id, auth_user.id, start, Some(end + 1)) + .get_file_range_stream_with_perms(&id, auth_user.id, start, Some(end + 1)) .await { Ok(stream) => { @@ -713,7 +734,10 @@ impl FileHandler { tracing::info!("API: Listing files with folder_id: {:?}", folder_id); let retrieval = &state.applications.file_retrieval_service; - match retrieval.list_files_owned(folder_id, auth_user.id).await { + match retrieval + .list_files_with_perms(folder_id, auth_user.id) + .await + { Ok(files) => { // Compute lightweight ETag from max modified_at + count let max_mod = files.iter().map(|f| f.modified_at).max().unwrap_or(0); @@ -751,6 +775,7 @@ impl FileHandler { /// Delegates to [`Self::upload_file_inner`] and, on success, spawns /// a background task to generate all thumbnail sizes before serialising /// the `FileDto` once. + /// TODO: should move thumbnail generation to a generic hook ? (onfileUploaded, other services will beneficiate it) pub(super) async fn upload_file_with_thumbnails_impl( State(state): State, auth_user: AuthUser, @@ -797,6 +822,7 @@ impl FileHandler { }); } + // TODO: same remark: a hook to handle easily audio service // Extract audio metadata for supported audio files in background. if let Some(ref audio_service) = state.applications.audio_metadata_service && AudioMetadataService::is_audio_file(&file.mime_type) @@ -825,15 +851,14 @@ impl FileHandler { auth_user: AuthUser, Path(file_id): Path, ) -> impl IntoResponse { - // Verify ownership - let file_read = &state.repositories.file_read_repository; - if let Err(e) = file_read.verify_file_owner(&file_id, auth_user.id).await { - let msg = e.to_string(); - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": msg })), - ) - .into_response(); + // check first that user can access this resource + if let Err(err) = state + .applications + .file_management_service + .has_permission(auth_user.id, Permission::Read, &file_id) + .await + { + return AppError::from(err).into_response(); } let metadata_repo = &state.repositories.file_metadata_repository; @@ -927,6 +952,7 @@ impl FileHandler { } /// Moves a file to a different folder (ownership-verified) + /// TODO: dead function ? pub async fn move_file( State(state): State, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index bf757ce3..370722be 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -51,7 +51,7 @@ impl FolderHandler { "create_folder: parent_id is None for user '{}', resolving home folder", auth_user.username ); - match service.list_folders_for_owner(None, auth_user.id).await { + match service.list_folders_with_perms(None, auth_user.id).await { Ok(folders) => { if let Some(home) = folders.first() { tracing::info!( @@ -89,22 +89,8 @@ impl FolderHandler { auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - match service.get_folder(&id).await { - Ok(folder) => { - // Access check: folder must belong to the requesting user - if let Some(ref owner) = folder.owner_id - && owner != &auth_user.id.to_string() - { - tracing::warn!( - "get_folder: user '{}' attempted to access folder '{}' owned by '{}'", - auth_user.id, - id, - owner - ); - return AppError::not_found("Folder not found").into_response(); - } - (StatusCode::OK, Json(folder)).into_response() - } + match service.get_folder_with_perms(&id, auth_user.id).await { + Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } } @@ -146,7 +132,7 @@ impl FolderHandler { pagination: Query, ) -> axum::response::Response { match service - .list_folders_for_owner_paginated(Some(&id), auth_user.id, &pagination) + .list_folders_paginated_with_perms(Some(&id), auth_user.id, &pagination) .await { Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), @@ -163,7 +149,7 @@ impl FolderHandler { auth_user: &AuthUser, ) -> axum::response::Response { match service - .list_folders_for_owner(parent_id, auth_user.id) + .list_folders_with_perms(parent_id, auth_user.id) .await { Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), @@ -206,8 +192,8 @@ impl FolderHandler { // Run both queries concurrently — no sequential wait. let (folders_result, files_result) = tokio::join!( - folder_service.list_folders_for_owner(Some(&id), auth_user.id), - file_service.list_files_owned(Some(&id), auth_user.id) + folder_service.list_folders_with_perms(Some(&id), auth_user.id), + file_service.list_files_with_perms(Some(&id), auth_user.id) ); match (folders_result, files_result) { @@ -226,7 +212,6 @@ impl FolderHandler { .unwrap() .into_response(); } - let listing = FolderListingDto { folders, files }; let mut resp = (StatusCode::OK, Json(listing)).into_response(); resp.headers_mut() @@ -286,6 +271,7 @@ impl FolderHandler { ) -> impl IntoResponse { let user_id = auth_user.id; // Check if trash service is available + // FIXME: permissions !! if let Some(trash_service) = &state.trash_service { tracing::info!("Moving folder to trash: {}", id); @@ -328,22 +314,11 @@ impl FolderHandler { // Get folder information and verify ownership let folder_service = &state.applications.folder_service; - match folder_service.get_folder(&id).await { + match folder_service + .get_folder_with_perms(&id, auth_user.id) + .await + { Ok(folder) => { - // Access check: folder must belong to the requesting user - if folder.owner_id.as_deref() != Some(&auth_user.id.to_string()) { - tracing::warn!( - "download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'", - auth_user.id, - id, - folder.owner_id - ); - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Folder not found" })), - ) - .into_response(); - } tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); // Use ZIP service from DI container diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index be709244..2cc97852 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -561,6 +561,7 @@ fn share_browse_error_response(err: crate::common::errors::DomainError) -> Respo AppError::from(err).into_response() } +// TODO: remove this and use the classic /api/files & /api/folders get, but with the token as session ? #[utoipa::path( get, path = "/api/s/{token}/contents", diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 5609675d..9d91f329 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -189,7 +189,7 @@ async fn handle_webdav_methods( async fn resolve_webdav_path(state: &Arc, user_id: Uuid, path: &str) -> Option { let folder_service = &state.applications.folder_service; let home_folders = folder_service - .list_folders_for_owner(None, user_id) + .list_folders_with_perms(None, user_id) .await .ok()?; let home = home_folders.first()?; @@ -514,7 +514,7 @@ async fn build_streaming_propfind_response( page_size: pagination.page_size, }; let result = folder_service - .list_folders_for_owner_paginated(fid_ref, user_id, &pag) + .list_folders_paginated_with_perms(fid_ref, user_id, &pag) .await .map_err(|e| std::io::Error::other(e.to_string()))?; @@ -544,7 +544,7 @@ async fn build_streaming_propfind_response( let mut offset: i64 = 0; loop { let batch: Vec = file_retrieval_service - .list_files_batch_for_owner(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE) + .list_files_batch_with_perms(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE) .await .map_err(|e| std::io::Error::other(e.to_string()))?; diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index fc5cd595..80ed7b81 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -401,7 +401,7 @@ async fn authorize_wopi_access( requested_action: &str, ) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> { let file = file_retrieval - .get_file_owned(file_id, caller_id) + .get_file_with_perms(file_id, caller_id) .await .map_err(|_| StatusCode::NOT_FOUND)?; // Owner verified — grant write unless explicitly requesting view-only. diff --git a/tests/common/server.env b/tests/common/server.env index 717ab4dc..b8f142f5 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -17,6 +17,7 @@ OXICLOUD_WOPI_ENABLED=false OXICLOUD_OIDC_ENABLED=false RUST_LOG=warn #RUST_LOG=debug +#RUST_LOG=info # grow up limits for tests OXICLOUD_RATE_LIMIT_REFRESH_MAX=120 diff --git a/tests/webdav/common.sh b/tests/webdav/common.sh index 7dada61c..a5149a0b 100644 --- a/tests/webdav/common.sh +++ b/tests/webdav/common.sh @@ -1,6 +1,9 @@ #!/bin/bash -source test.env +if [ -z "$base_url" ] +then + source test.env +fi err() { echo "$*" >&2 @@ -32,7 +35,10 @@ oxicloud_setup() { # returns TOKEN variable oxicloud_login() { - oxicloud_setup + if [[ ( $# -eq 0 ) || ( "$1" != "no-create" ) ]] + then + oxicloud_setup + fi LOGIN_DATA='{"username":"'$username'","password":"'$password'"}'