diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 20c5d79b..1e486997 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -213,81 +213,6 @@ impl WebDavAdapter { Ok(PropFindRequest { prop_find_type }) } - /// Generate a PROPFIND response for files and folders - pub fn generate_propfind_response( - writer: W, - folder: Option<&FolderDto>, - files: &[FileDto], - subfolders: &[FolderDto], - request: &PropFindRequest, - _depth: &str, - base_href: &str, - ) -> Result<()> { - let mut xml_writer = Writer::new(writer); - - // Start multistatus response - xml_writer.write_event(Event::Start( - BytesStart::new("D:multistatus").with_attributes([("xmlns:D", "DAV:")]), - ))?; - - // Add response for current folder if provided - if let Some(folder) = folder { - Self::write_folder_response(&mut xml_writer, folder, request, base_href)?; - } - - // If depth allows, add responses for files and subfolders - if _depth != "0" { - // Add responses for files - for file in files { - Self::write_file_response( - &mut xml_writer, - file, - request, - &format!("{}{}", base_href, file.name), - )?; - } - - // Add responses for subfolders - for subfolder in subfolders { - Self::write_folder_response( - &mut xml_writer, - subfolder, - request, - &format!("{}{}/", base_href, subfolder.name), - )?; - } - } - - // End multistatus - xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; - - Ok(()) - } - - /// Generate a PROPFIND response for a single file - pub fn generate_propfind_response_for_file( - writer: W, - file: &FileDto, - request: &PropFindRequest, - _depth: &str, - href: &str, - ) -> Result<()> { - let mut xml_writer = Writer::new(writer); - - // Start multistatus response - xml_writer.write_event(Event::Start( - BytesStart::new("D:multistatus").with_attributes([("xmlns:D", "DAV:")]), - ))?; - - // Add response for file - Self::write_file_response(&mut xml_writer, file, request, href)?; - - // End multistatus - xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; - - Ok(()) - } - /// Write folder properties as a response fn write_folder_response( xml_writer: &mut Writer, @@ -1102,4 +1027,46 @@ impl WebDavAdapter { } name.to_string() } + + // ───────────────────────────────────────────────────────────── + // Streaming PROPFIND helpers + // + // These methods write incremental XML fragments so the caller + // can flush chunks to the HTTP body without buffering the whole + // response in memory. + // ───────────────────────────────────────────────────────────── + + /// Writes the opening `` tag. + pub fn write_multistatus_start(writer: &mut Writer) -> Result<()> { + writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([("xmlns:D", "DAV:")]), + ))?; + Ok(()) + } + + /// Writes the closing `` tag. + pub fn write_multistatus_end(writer: &mut Writer) -> Result<()> { + writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(()) + } + + /// Writes a single `` element for a folder. + pub fn write_folder_entry( + writer: &mut Writer, + folder: &FolderDto, + request: &PropFindRequest, + href: &str, + ) -> Result<()> { + Self::write_folder_response(writer, folder, request, href) + } + + /// Writes a single `` element for a file. + pub fn write_file_entry( + writer: &mut Writer, + file: &FileDto, + request: &PropFindRequest, + href: &str, + ) -> Result<()> { + Self::write_file_response(writer, file, request, href) + } } diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index c685e9b4..ab1f356d 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -170,6 +170,20 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { ) -> Result, DomainError> { self.list_files(Some(folder_id)).await } + + /// Lists files in a folder with LIMIT/OFFSET pagination. + /// + /// Used by streaming WebDAV PROPFIND to avoid loading all files at once. + /// Default: falls back to `list_files` (loads all, then slices in memory). + async fn list_files_batch( + &self, + folder_id: Option<&str>, + offset: i64, + limit: i64, + ) -> Result, DomainError> { + let all = self.list_files(folder_id).await?; + Ok(all.into_iter().skip(offset as usize).take(limit as usize).collect()) + } } // ───────────────────────────────────────────────────── diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 74a5c6c2..f758f44a 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -78,6 +78,22 @@ pub trait FileReadPort: Send + Sync + 'static { Ok(None) } + /// Lists files in a folder with LIMIT/OFFSET pagination. + /// + /// Used by streaming WebDAV PROPFIND to avoid loading all files at once. + /// Default: falls back to `list_files` (loads all, then slices in memory). + async fn list_files_batch( + &self, + folder_id: Option<&str>, + offset: i64, + limit: i64, + ) -> Result, DomainError> { + let all = self.list_files(folder_id).await?; + let start = (offset as usize).min(all.len()); + let end = (start + limit as usize).min(all.len()); + Ok(all.into_iter().skip(start).take(end - start).collect()) + } + /// Lists every file in the subtree rooted at `folder_id`. /// /// Uses an ltree `<@` join against `storage.folders` so the entire diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index a9c326d7..f877dd21 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -317,4 +317,14 @@ impl FileRetrievalUseCase for FileRetrievalService { let files = self.file_read.list_files_in_subtree(folder_id).await?; Ok(files.into_iter().map(FileDto::from).collect()) } + + async fn list_files_batch( + &self, + folder_id: Option<&str>, + offset: i64, + limit: i64, + ) -> Result, DomainError> { + let files = self.file_read.list_files_batch(folder_id, offset, limit).await?; + Ok(files.into_iter().map(FileDto::from).collect()) + } } diff --git a/src/common/di.rs b/src/common/di.rs index f227b384..33e7c8a2 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -45,11 +45,7 @@ use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nSer use crate::infrastructure::services::path_service::PathService; use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; -use crate::common::stubs::{ - StubDedupPort, StubFileManagementUseCase, StubFileReadPort, - StubFileRetrievalUseCase, StubFileUploadUseCase, StubFileUseCaseFactory, StubFileWritePort, - StubFolderStoragePort, StubFolderUseCase, StubI18nService, StubSearchUseCase, StubZipPort, -}; +use crate::common::stubs::StubZipPort; /// Factory for the different application components /// @@ -765,317 +761,4 @@ pub struct AppState { Option>, } -impl Default for AppState { - fn default() -> Self { - // Minimal stub version for auth middleware and route construction. - // Real services are wired in main.rs via AppServiceFactory. - - let config = crate::common::config::AppConfig::default(); - let path_service = Arc::new( - crate::infrastructure::services::path_service::PathService::new( - std::path::PathBuf::from("./storage"), - ), - ); - - let i18n_repository = Arc::new(StubI18nService) - as Arc; - let folder_service = Arc::new(StubFolderUseCase) - as Arc; - let file_upload_service = Arc::new(StubFileUploadUseCase) - as Arc; - let file_retrieval_service = Arc::new(StubFileRetrievalUseCase) - as Arc; - let file_management_service = Arc::new(StubFileManagementUseCase) - as Arc; - let file_use_case_factory = Arc::new(StubFileUseCaseFactory) - as Arc; - - // Create file content cache for stub - let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig::default())); - - // Create dummy thumbnail service - let dummy_thumbnail_service: Arc = Arc::new( - crate::infrastructure::services::thumbnail_service::ThumbnailService::new( - &std::path::PathBuf::from("./storage"), - 100, - 10 * 1024 * 1024, - ), - ); - - // Create dummy chunked upload service - let dummy_chunked_upload_service: Arc = Arc::new( - crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new_stub( - std::path::PathBuf::from("./storage/.uploads"), - ), - ); - - // Create dummy image transcode service - let dummy_image_transcode_service: Arc = Arc::new( - crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new( - &std::path::PathBuf::from("./storage"), - 100, - 10 * 1024 * 1024, - ), - ); - - // Stub dedup service (Default is only used for routing stubs, never for real I/O) - let dummy_dedup_service: Arc = Arc::new(StubDedupPort); - - // Core services using stubs - let core_services = CoreServices { - path_service: path_service.clone(), - file_content_cache, - thumbnail_service: dummy_thumbnail_service, - chunked_upload_service: dummy_chunked_upload_service, - image_transcode_service: dummy_image_transcode_service, - dedup_service: dummy_dedup_service, - zip_service: Arc::new(StubZipPort) as Arc, - config: config.clone(), - }; - - // Stub folder repo concrete — uses a dummy PgPool-less constructor - // In practice, Default is only used for routing stubs; real app always goes through build_app_state - let dummy_folder_repo_concrete = Arc::new(FolderDbRepository::new_stub()); - - // Repository services using stubs - let repository_services = RepositoryServices { - folder_repository: Arc::new(StubFolderStoragePort) - as Arc, - folder_repo_concrete: dummy_folder_repo_concrete, - file_read_repository: Arc::new(StubFileReadPort) - as Arc, - file_write_repository: Arc::new(StubFileWritePort) - as Arc, - i18n_repository, - trash_repository: None, - }; - - // Dummy concrete services for compatibility - let dummy_folder_storage = Arc::new(StubFolderStoragePort) - as Arc; - let folder_service_concrete = Arc::new(FolderService::new(dummy_folder_storage)); - - // Dummy I18nApplicationService - let dummy_i18n_app_service = - crate::application::services::i18n_application_service::I18nApplicationService::new( - Arc::new(StubI18nService) - as Arc, - ); - - // Application services using stubs - let application_services = ApplicationServices { - folder_service_concrete: folder_service_concrete.clone(), - folder_service, - file_upload_service, - file_retrieval_service, - file_management_service, - file_use_case_factory, - i18n_service: Arc::new(dummy_i18n_app_service), - trash_service: None, - search_service: Some(Arc::new(StubSearchUseCase) - as Arc), - share_service: None, - favorites_service: None, - recent_service: None, - }; - - Self { - core: core_services, - repositories: repository_services, - applications: application_services, - db_pool: None, - auth_service: None, - admin_settings_service: None, - trash_service: None, - share_service: None, - favorites_service: None, - recent_service: None, - storage_usage_service: None, - calendar_service: None, - contact_service: None, - calendar_use_case: None, - addressbook_use_case: None, - contact_use_case: None, - wopi_token_service: None, - wopi_lock_service: None, - wopi_discovery_service: None, - } - } -} - -impl AppState { - pub fn new( - core: CoreServices, - repositories: RepositoryServices, - applications: ApplicationServices, - ) -> Self { - Self { - core, - repositories, - applications, - db_pool: None, - auth_service: None, - admin_settings_service: None, - trash_service: None, - share_service: None, - favorites_service: None, - recent_service: None, - storage_usage_service: None, - calendar_service: None, - contact_service: None, - calendar_use_case: None, - addressbook_use_case: None, - contact_use_case: None, - wopi_token_service: None, - wopi_lock_service: None, - wopi_discovery_service: None, - } - } - - pub fn with_database(mut self, db_pool: Arc) -> Self { - self.db_pool = Some(db_pool); - self - } - - /// Creates a minimal AppState for route construction. - /// - /// Uses `Default` stubs for infrastructure services, then overlays the real - /// application-level services that arrive as parameters from `main.rs`. - /// This keeps `routes.rs` free of any `crate::infrastructure` references. - #[allow(clippy::too_many_arguments)] - pub fn for_routing( - folder_service: Arc, - file_retrieval_service: Arc< - dyn crate::application::ports::file_ports::FileRetrievalUseCase, - >, - file_upload_service: Arc, - file_management_service: Arc, - folder_use_case: Arc, - i18n_service: Option< - Arc, - >, - trash_service: Option>, - search_service: Option>, - share_service: Option>, - favorites_service: Option>, - recent_service: Option>, - ) -> Self { - let mut state = Self::default(); - - // Override application services with real ones - state.applications.folder_service_concrete = folder_service.clone(); - state.applications.folder_service = folder_use_case; - state.applications.file_upload_service = file_upload_service; - state.applications.file_retrieval_service = file_retrieval_service.clone(); - state.applications.file_management_service = file_management_service; - - if let Some(i18n) = i18n_service { - state.applications.i18n_service = i18n; - } - - state.applications.trash_service = trash_service.clone(); - state.applications.search_service = search_service.clone(); - state.applications.share_service = share_service.clone(); - state.applications.favorites_service = favorites_service.clone(); - state.applications.recent_service = recent_service.clone(); - - // Also set top-level optional services - state.trash_service = trash_service; - state.share_service = share_service; - state.favorites_service = favorites_service; - state.recent_service = recent_service; - - // Create real ZipService with the actual file/folder services - state.core.zip_service = Arc::new( - crate::infrastructure::services::zip_service::ZipService::new( - file_retrieval_service - as Arc, - folder_service.clone() - as Arc, - ), - ); - - state - } - - pub fn with_auth_services(mut self, auth_services: AuthServices) -> Self { - self.auth_service = Some(auth_services); - self - } - - pub fn with_trash_service(mut self, trash_service: Arc) -> Self { - self.trash_service = Some(trash_service); - self - } - - pub fn with_share_service( - mut self, - share_service: Arc, - ) -> Self { - self.share_service = Some(share_service); - self - } - - pub fn with_favorites_service(mut self, favorites_service: Arc) -> Self { - self.favorites_service = Some(favorites_service); - self - } - - pub fn with_recent_service(mut self, recent_service: Arc) -> Self { - self.recent_service = Some(recent_service); - self - } - - pub fn with_storage_usage_service( - mut self, - storage_usage_service: Arc, - ) -> Self { - self.storage_usage_service = Some(storage_usage_service); - self - } - - pub fn with_calendar_service( - mut self, - calendar_service: Arc, - ) -> Self { - self.calendar_service = Some(calendar_service); - self - } - - pub fn with_contact_service( - mut self, - contact_service: Arc, - ) -> Self { - self.contact_service = Some(contact_service); - self - } - - pub fn with_calendar_use_case( - mut self, - calendar_use_case: Arc, - ) -> Self { - self.calendar_use_case = Some(calendar_use_case); - self - } - - pub fn with_addressbook_use_case( - mut self, - addressbook_use_case: Arc, - ) -> Self { - self.addressbook_use_case = Some(addressbook_use_case); - self - } - - pub fn with_contact_use_case( - mut self, - contact_use_case: Arc, - ) -> Self { - self.contact_use_case = Some(contact_use_case); - self - } - - pub fn with_zip_service(mut self, zip_service: Arc) -> Self { - self.core.zip_service = zip_service; - self - } -} +// All AppState construction is done via struct literal in build_app_state(). diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 3f8b5f69..a14bbd60 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -207,6 +207,75 @@ impl FileReadPort for FileBlobReadRepository { self.resolve_blob_hash(file_id).await } + /// Paginated file listing — fetches only `limit` rows starting at `offset`. + /// + /// Uses a single SQL query with `LIMIT/OFFSET` to avoid loading the full + /// folder contents into memory. Ideal for streaming WebDAV PROPFIND. + async fn list_files_batch( + &self, + folder_id: Option<&str>, + offset: i64, + limit: i64, + ) -> Result, DomainError> { + let rows: Vec<( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + Option, + )> = if let Some(fid) = folder_id { + sqlx::query_as( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed + ORDER BY fi.name + LIMIT $2 OFFSET $3 + "#, + ) + .bind(fid) + .bind(limit) + .bind(offset) + .fetch_all(self.pool.as_ref()) + .await + } else { + sqlx::query_as( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.folder_id IS NULL AND NOT fi.is_trashed + ORDER BY fi.name + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(self.pool.as_ref()) + .await + } + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_batch: {e}")))?; + + rows.into_iter() + .map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid) + }) + .collect() + } + async fn get_file_stream( &self, id: &str, diff --git a/src/infrastructure/services/chunked_upload_service.rs b/src/infrastructure/services/chunked_upload_service.rs index 01d9eea7..18ec96fb 100644 --- a/src/infrastructure/services/chunked_upload_service.rs +++ b/src/infrastructure/services/chunked_upload_service.rs @@ -23,8 +23,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use tokio::fs::{self, File, OpenOptions}; -use tokio::io::{AsyncWriteExt, BufWriter}; +use tokio::fs::{self, File}; +use tokio::io::AsyncWriteExt; use tokio::sync::RwLock; use uuid::Uuid; @@ -633,59 +633,73 @@ impl ChunkedUploadService { session.clone() }; - // Assemble file with hash-on-write + // Assemble file with hash-on-write. + // + // The entire loop is offloaded to spawn_blocking because SHA-256 + // hashing is CPU-bound (~130 ms for 500 MB) and would otherwise + // block a Tokio worker, starving all other connections. + // Synchronous I/O is used inside the blocking thread — it avoids + // the async reactor overhead and is actually faster for this + // sequential workload. let assembled_path = session.temp_dir.join("assembled"); - let raw_output = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&assembled_path) - .await - .map_err(|e| format!("Failed to create assembled file: {e}"))?; + let chunks_meta: Vec<(usize, PathBuf)> = session + .chunks + .iter() + .map(|c| (c.index, session.temp_dir.join(format!("chunk_{:06}", c.index)))) + .collect(); + let total_size = session.total_size; - // Pre-allocate assembled file to reduce fragmentation - let _ = raw_output.set_len(session.total_size).await; + let hash = tokio::task::spawn_blocking(move || -> Result { + use std::io::{Read, Write, BufWriter as StdBufWriter}; - // 512 KB I/O buffers — 8× fewer syscalls than 64 KB - let mut output = BufWriter::with_capacity(524_288, raw_output); - let mut hasher = Sha256::new(); + let raw_output = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&assembled_path) + .map_err(|e| format!("Failed to create assembled file: {e}"))?; - // Stream each chunk into the assembled file + hash - // Single 512 KB read buffer reused across all chunks (avoids N allocations) - let mut buf = vec![0u8; 524_288]; - for chunk in &session.chunks { - let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index)); - let mut chunk_file = File::open(&chunk_path) - .await - .map_err(|e| format!("Failed to open chunk {}: {}", chunk.index, e))?; - loop { - let n = tokio::io::AsyncReadExt::read(&mut chunk_file, &mut buf) - .await - .map_err(|e| format!("Failed to read chunk {}: {}", chunk.index, e))?; - if n == 0 { - break; + // Pre-allocate assembled file to reduce fragmentation + let _ = raw_output.set_len(total_size); + + // 512 KB I/O buffers — 8× fewer syscalls than 64 KB + let mut output = StdBufWriter::with_capacity(524_288, raw_output); + let mut hasher = Sha256::new(); + + // Single 512 KB read buffer reused across all chunks (avoids N allocations) + let mut buf = vec![0u8; 524_288]; + for (index, chunk_path) in &chunks_meta { + let mut chunk_file = std::fs::File::open(chunk_path) + .map_err(|e| format!("Failed to open chunk {index}: {e}"))?; + loop { + let n = chunk_file + .read(&mut buf) + .map_err(|e| format!("Failed to read chunk {index}: {e}"))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + output.write_all(&buf[..n]).map_err(|e| { + format!("Failed to write chunk {index} to assembled file: {e}") + })?; } - hasher.update(&buf[..n]); - output.write_all(&buf[..n]).await.map_err(|e| { - format!( - "Failed to write chunk {} to assembled file: {}", - chunk.index, e - ) - })?; } - } - tokio::io::AsyncWriteExt::flush(&mut output) - .await - .map_err(|e| format!("Failed to flush assembled file: {e}"))?; + output + .flush() + .map_err(|e| format!("Failed to flush assembled file: {e}"))?; - let hash = hex::encode(hasher.finalize()); + // Clean up chunk files (keep assembled) — already on a blocking thread + for (_index, chunk_path) in &chunks_meta { + let _ = std::fs::remove_file(chunk_path); + } - // Clean up chunk files (keep assembled) - for chunk in &session.chunks { - let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index)); - let _ = fs::remove_file(&chunk_path).await; - } + Ok(hex::encode(hasher.finalize())) + }) + .await + .map_err(|e| format!("Assembly task panicked: {e}"))??; + + let assembled_path = session.temp_dir.join("assembled"); tracing::info!( "✅ Assembled chunked upload: {} ({} bytes from {} chunks)", diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 375d1206..c90b11f9 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -13,9 +13,10 @@ use crate::application::dtos::settings_dto::{ }; use crate::common::di::AppState; use crate::interfaces::errors::AppError; +use std::sync::Arc; /// Admin API routes — all require admin role. -pub fn admin_routes() -> Router { +pub fn admin_routes() -> Router> { Router::new() // OIDC settings .route("/settings/oidc", get(get_oidc_settings)) @@ -69,7 +70,7 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(String, S /// GET /api/admin/settings/oidc — get OIDC settings for the admin panel async fn get_oidc_settings( - State(state): State, + State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; @@ -89,7 +90,7 @@ async fn get_oidc_settings( /// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload async fn save_oidc_settings( - State(state): State, + State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { @@ -114,7 +115,7 @@ async fn save_oidc_settings( /// POST /api/admin/settings/oidc/test — test OIDC discovery async fn test_oidc_connection( - State(state): State, + State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { @@ -135,7 +136,7 @@ async fn test_oidc_connection( /// GET /api/admin/settings/general — system overview (backward compat) async fn get_general_settings( - State(state): State, + State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; @@ -166,7 +167,7 @@ async fn get_general_settings( /// GET /api/admin/dashboard — full dashboard statistics async fn get_dashboard_stats( - State(state): State, + State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; @@ -242,7 +243,7 @@ async fn get_dashboard_stats( /// GET /api/admin/users?limit=50&offset=0 — list all users async fn list_users( - State(state): State, + State(state): State>, headers: HeaderMap, Query(query): Query, ) -> Result { @@ -278,7 +279,7 @@ async fn list_users( /// GET /api/admin/users/:id — get single user async fn get_user( - State(state): State, + State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { @@ -300,7 +301,7 @@ async fn get_user( /// DELETE /api/admin/users/:id — delete a user async fn delete_user( - State(state): State, + State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { @@ -335,7 +336,7 @@ async fn delete_user( /// PUT /api/admin/users/:id/role — change user role async fn update_user_role( - State(state): State, + State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, @@ -371,7 +372,7 @@ async fn update_user_role( /// PUT /api/admin/users/:id/active — activate/deactivate user async fn update_user_active( - State(state): State, + State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, @@ -412,7 +413,7 @@ async fn update_user_active( /// PUT /api/admin/users/:id/quota — update user storage quota async fn update_user_quota( - State(state): State, + State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, @@ -444,7 +445,7 @@ async fn update_user_quota( /// POST /api/admin/users — create a new user (admin only) async fn create_user( - State(state): State, + State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { @@ -472,7 +473,7 @@ async fn create_user( /// PUT /api/admin/users/:id/password — reset a user's password (admin only) async fn reset_user_password( - State(state): State, + State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, @@ -509,7 +510,7 @@ async fn reset_user_password( /// GET /api/admin/settings/registration — check if public registration is enabled async fn get_registration_setting( - State(state): State, + State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; @@ -528,7 +529,7 @@ async fn get_registration_setting( /// PUT /api/admin/settings/registration — enable/disable public registration async fn set_registration_setting( - State(state): State, + State(state): State>, headers: HeaderMap, Json(body): Json, ) -> Result { diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index e83929e8..f472bc59 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -40,7 +40,7 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); /// /// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap. /// Registers `/caldav`, `/caldav/`, and `/caldav/{*path}` explicitly. -pub fn caldav_routes() -> Router { +pub fn caldav_routes() -> Router> { Router::new() .route("/caldav/{*path}", axum::routing::any(handle_caldav_methods)) .route("/caldav/", axum::routing::any(handle_caldav_methods_root)) @@ -48,14 +48,14 @@ pub fn caldav_routes() -> Router { } async fn handle_caldav_methods_root( - axum::extract::State(state): axum::extract::State, + axum::extract::State(state): axum::extract::State>, req: Request, ) -> Result, AppError> { handle_caldav_methods_inner(state, req, String::new()).await } async fn handle_caldav_methods( - axum::extract::State(state): axum::extract::State, + axum::extract::State(state): axum::extract::State>, req: Request, ) -> Result, AppError> { let uri = req.uri().clone(); @@ -64,12 +64,11 @@ async fn handle_caldav_methods( } async fn handle_caldav_methods_inner( - state: AppState, + state: Arc, req: Request, path: String, ) -> Result, AppError> { let method = req.method().clone(); - let state = Arc::new(state); match method.as_str() { "OPTIONS" => handle_options().await, diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 6e989d46..7e60faf4 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -42,7 +42,7 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); /// /// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap. /// Registers `/carddav`, `/carddav/`, and `/carddav/{*path}` explicitly. -pub fn carddav_routes() -> Router { +pub fn carddav_routes() -> Router> { Router::new() .route( "/carddav/{*path}", @@ -53,14 +53,14 @@ pub fn carddav_routes() -> Router { } async fn handle_carddav_methods_root( - axum::extract::State(state): axum::extract::State, + axum::extract::State(state): axum::extract::State>, req: Request, ) -> Result, AppError> { handle_carddav_methods_inner(state, req, String::new()).await } async fn handle_carddav_methods( - axum::extract::State(state): axum::extract::State, + axum::extract::State(state): axum::extract::State>, req: Request, ) -> Result, AppError> { let uri = req.uri().clone(); @@ -69,11 +69,10 @@ async fn handle_carddav_methods( } async fn handle_carddav_methods_inner( - state: AppState, + state: Arc, req: Request, path: String, ) -> Result, AppError> { - let state = Arc::new(state); let method = req.method().clone(); match method.as_str() { diff --git a/src/interfaces/api/handlers/dedup_handler.rs b/src/interfaces/api/handlers/dedup_handler.rs index e4aa3d1d..58c5fa84 100644 --- a/src/interfaces/api/handlers/dedup_handler.rs +++ b/src/interfaces/api/handlers/dedup_handler.rs @@ -9,9 +9,10 @@ use serde::Serialize; use crate::application::ports::dedup_ports::DedupResultDto; use crate::common::di::AppState; +use std::sync::Arc; /// Global application state for dependency injection -type GlobalState = AppState; +type GlobalState = Arc; /// Response for hash check endpoint #[derive(Debug, Serialize)] diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index d0569b8e..62f33d55 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -13,12 +13,13 @@ use std::collections::HashMap; use crate::application::ports::file_ports::OptimizedFileContent; use crate::common::di::AppState; use crate::interfaces::middleware::auth::{AuthUser, OptionalUserId}; +use std::sync::Arc; /** * Type aliases for dependency injection state. */ /// Global application state for dependency injection -type GlobalState = AppState; +type GlobalState = Arc; /** * API handler for file-related operations. diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 4d8830fa..e224ab23 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -216,7 +216,7 @@ impl FolderHandler { /// Both queries run concurrently via `tokio::join!`. /// Supports `If-None-Match` / ETag for conditional responses (304). pub async fn list_folder_listing( - State(state): State, + State(state): State>, auth_user: AuthUser, headers: HeaderMap, Path(id): Path, @@ -339,7 +339,7 @@ impl FolderHandler { /// Deletes a folder with trash functionality (ownership enforced by service layer) pub async fn delete_folder_with_trash( - State(state): State, + State(state): State>, auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { @@ -392,7 +392,7 @@ impl FolderHandler { /// Downloads a folder as a ZIP file (ownership enforced) pub async fn download_folder_zip( - State(state): State, + State(state): State>, auth_user: AuthUser, Path(id): Path, Query(_params): Query>, diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index 46a56fe8..876a011a 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -8,6 +8,7 @@ use tracing::{error, info}; use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::common::di::AppState; +use std::sync::Arc; /** * Handler for search operations through the API. @@ -21,7 +22,7 @@ pub struct SearchHandler; impl SearchHandler { /// GET /search — simple query-parameter-based search. pub async fn search_files_get( - State(state): State, + State(state): State>, Query(params): Query, ) -> impl IntoResponse { info!("API: File search with parameters: {:?}", params); @@ -79,7 +80,7 @@ impl SearchHandler { /// POST /search/advanced — full criteria in the request body. pub async fn search_files_post( - State(state): State, + State(state): State>, Json(criteria): Json, ) -> impl IntoResponse { info!("API: Advanced file search"); @@ -119,7 +120,7 @@ impl SearchHandler { /// GET /search/suggest — lightweight autocomplete suggestions. pub async fn suggest_files( - State(state): State, + State(state): State>, Query(params): Query, ) -> impl IntoResponse { info!("API: Search suggestions for {:?}", params.query); @@ -162,7 +163,7 @@ impl SearchHandler { } /// DELETE /search/cache — clears the search results cache. - pub async fn clear_search_cache(State(state): State) -> impl IntoResponse { + pub async fn clear_search_cache(State(state): State>) -> impl IntoResponse { info!("API: Clearing search cache"); let search_service = match &state.applications.search_service { diff --git a/src/interfaces/api/handlers/trash_handler.rs b/src/interfaces/api/handlers/trash_handler.rs index 136e68e7..f8dee010 100644 --- a/src/interfaces/api/handlers/trash_handler.rs +++ b/src/interfaces/api/handlers/trash_handler.rs @@ -7,11 +7,12 @@ use tracing::{debug, error, instrument, warn}; // use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser}; +use std::sync::Arc; /// Gets all items in the trash for the current user #[instrument(skip_all)] pub async fn get_trash_items( - State(state): State, + State(state): State>, auth_user: AuthUser, ) -> (StatusCode, Json) { // SECURITY: Always use the authenticated user's ID from the JWT token. @@ -55,7 +56,7 @@ pub async fn get_trash_items( /// Moves an item (file or folder) to the trash (generic function, not used directly in routes) #[instrument(skip_all)] pub async fn move_to_trash( - State(state): State, + State(state): State>, OptionalAuthUser(auth_user): OptionalAuthUser, Path((item_type, item_id)): Path<(String, String)>, ) -> (StatusCode, Json) { @@ -109,7 +110,7 @@ pub async fn move_to_trash( /// Moves a file to the trash #[instrument(skip_all)] pub async fn move_file_to_trash( - State(state): State, + State(state): State>, OptionalAuthUser(auth_user): OptionalAuthUser, Path(item_id): Path, ) -> (StatusCode, Json) { @@ -163,7 +164,7 @@ pub async fn move_file_to_trash( /// Moves a folder to the trash #[instrument(skip_all)] pub async fn move_folder_to_trash( - State(state): State, + State(state): State>, OptionalAuthUser(auth_user): OptionalAuthUser, Path(item_id): Path, ) -> (StatusCode, Json) { @@ -219,7 +220,7 @@ pub async fn move_folder_to_trash( /// Restores an item from the trash to its original location #[instrument(skip_all)] pub async fn restore_from_trash( - State(state): State, + State(state): State>, auth_user: AuthUser, Path(trash_id): Path, ) -> (StatusCode, Json) { @@ -280,7 +281,7 @@ pub async fn restore_from_trash( /// Permanently deletes an item from the trash #[instrument(skip_all)] pub async fn delete_permanently( - State(state): State, + State(state): State>, auth_user: AuthUser, Path(trash_id): Path, ) -> (StatusCode, Json) { @@ -343,7 +344,7 @@ pub async fn delete_permanently( /// Empties the trash completely for the current user #[instrument(skip_all)] pub async fn empty_trash( - State(state): State, + State(state): State>, auth_user: AuthUser, ) -> (StatusCode, Json) { debug!("Request to empty trash for user {}", auth_user.id); diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index af2b8bb3..f8cac7b2 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -12,17 +12,22 @@ use axum::{ http::{HeaderName, Request, StatusCode, header}, response::Response, }; -use bytes::Buf; +use bytes::{Buf, Bytes}; use chrono::Utc; +use quick_xml::Writer; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ LockInfo, LockScope, LockType, PropFindRequest, WebDavAdapter, }; +use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; +use crate::application::ports::file_ports::FileRetrievalUseCase; +use crate::application::ports::inbound::FolderUseCase; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; +use std::sync::Arc; // Create a custom DAV header since it's not in the standard headers const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); @@ -36,6 +41,10 @@ const MAX_XML_BODY: usize = 1_048_576; /// Maximum body size for MKCOL requests (RFC 4918: body must be empty). const MAX_MKCOL_BODY: usize = 4096; +/// Batch size for streaming PROPFIND — files and folders are fetched in pages +/// of this size to keep memory constant regardless of folder contents. +const PROPFIND_BATCH_SIZE: i64 = 500; + /** * Creates and returns the WebDAV router with all required endpoints. * @@ -44,7 +53,7 @@ const MAX_MKCOL_BODY: usize = 4096; * * @return Router configured with WebDAV endpoints */ -pub fn webdav_routes() -> Router { +pub fn webdav_routes() -> Router> { // Three explicit routes to avoid Axum trailing-slash gaps // (same pattern used for CalDAV/CardDAV) Router::new() @@ -72,14 +81,14 @@ fn extract_webdav_path(uri: &axum::http::Uri) -> String { } async fn handle_webdav_methods_root( - axum::extract::State(state): axum::extract::State, + axum::extract::State(state): axum::extract::State>, req: Request, ) -> Result, AppError> { handle_webdav_dispatch(state, req, String::new()).await } async fn handle_webdav_methods( - axum::extract::State(state): axum::extract::State, + axum::extract::State(state): axum::extract::State>, req: Request, ) -> Result, AppError> { let path = extract_webdav_path(req.uri()); @@ -87,7 +96,7 @@ async fn handle_webdav_methods( } async fn handle_webdav_dispatch( - state: AppState, + state: Arc, req: Request, path: String, ) -> Result, AppError> { @@ -140,27 +149,54 @@ async fn handle_options(_path: String) -> Result, AppError> { * * This handler processes WebDAV PROPFIND requests according to RFC 4918, * retrieving properties of files and folders in the specified path. - * It supports the Depth header to control recursion depth. + * + * **Security hardening (Sol.2):** `Depth: infinity` is rejected with + * `403 Forbidden` and the RFC 4918 `propfind-finite-depth` precondition + * error body. The default depth when the header is absent is `1`. + * + * **Streaming response (Sol.3):** For `Depth: 1`, files and sub-folders + * are fetched in batches of `PROPFIND_BATCH_SIZE` and the XML response + * is written incrementally to a streaming body. Memory usage is O(batch) + * regardless of how many children the folder contains. * * @param state The application state containing service dependencies - * @param user The authenticated user information - * @param path The requested resource path - * @param req The HTTP request containing the PROPFIND XML body - * @return XML response with resource properties + * @param req The HTTP request containing the PROPFIND XML body + * @param path The requested resource path + * @return 207 Multi-Status XML response with resource properties */ async fn handle_propfind( - state: AppState, + state: Arc, req: Request, path: String, ) -> Result, AppError> { - // Extract depth header (cloning to avoid borrowing issues) + // ── 1. Extract and validate Depth header ───────────────────── let depth = req .headers() .get("Depth") .and_then(|v| v.to_str().ok()) - .unwrap_or("infinity") - .to_string(); + .unwrap_or("1"); + // RFC 4918 §9.1: servers MAY reject Depth:infinity with 403 + if depth == "infinity" { + let body = r#" + + +"#; + return Ok(Response::builder() + .status(StatusCode::FORBIDDEN) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(body)) + .unwrap()); + } + + // Normalize: anything other than "0" or "1" is treated as "0" + let depth = match depth { + "0" | "1" => depth, + _ => "0", + }; + let depth_owned = depth.to_string(); + + // ── 2. Authenticate ────────────────────────────────────────── let _user = { let user_ref = req .extensions() @@ -169,50 +205,37 @@ async fn handle_propfind( user_ref.clone() }; - // Extract the body separately to avoid borrow issues + // ── 3. Parse PROPFIND XML body ─────────────────────────────── let body_bytes = { - // Convert the request into a body let body = req.into_body(); - - // Read request body (PROPFIND is XML, 1 MB is more than enough) body::to_bytes(body, MAX_XML_BODY) .await .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? }; - // Parse PROPFIND request let propfind_request = if body_bytes.is_empty() { - // Empty body means get all properties PropFindRequest { prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp, } } else { - // Parse XML body WebDavAdapter::parse_propfind(body_bytes.reader()).map_err(|e| { AppError::bad_request(format!("Failed to parse PROPFIND request: {}", e)) })? }; - // Get folder service from state - let folder_service = &state.applications.folder_service; - let file_retrieval_service = &state.applications.file_retrieval_service; + // ── 4. Services ────────────────────────────────────────────── + let folder_service = state.applications.folder_service.clone(); + let file_retrieval_service = state.applications.file_retrieval_service.clone(); - // Determine base HREF - let base_href = format!("/webdav/{}/", path); + let base_href = if path.is_empty() || path == "/" { + "/webdav/".to_string() + } else { + format!("/webdav/{}/", path) + }; - // Check if path exists as a file or folder + // ── 5. Determine target resource ───────────────────────────── if path.is_empty() || path == "/" { - // Root folder — run both queries concurrently - let (subfolders_result, files_result) = tokio::join!( - folder_service.list_folders(None), - file_retrieval_service.list_files(None) - ); - let subfolders = subfolders_result - .map_err(|e| AppError::internal_error(format!("Failed to get subfolders: {}", e)))?; - let files = files_result - .map_err(|e| AppError::internal_error(format!("Failed to get files: {}", e)))?; - - // Create root folder DTO for response + // Root folder let root_folder = FolderDto { id: "root".to_string(), name: "".to_string(), @@ -227,94 +250,182 @@ async fn handle_propfind( category: "Folder".to_string(), }; - // Generate response - let mut response_body = Vec::new(); - WebDavAdapter::generate_propfind_response( - &mut response_body, - Some(&root_folder), - &files, - &subfolders, - &propfind_request, - &depth, + return build_streaming_propfind_response( + root_folder, + None, // folder_id = None → root children + &depth_owned, &base_href, + propfind_request, + folder_service, + file_retrieval_service, ) - .map_err(|e| { - AppError::internal_error(format!("Failed to generate PROPFIND response: {}", e)) - })?; + .await; + } - Ok(Response::builder() - .status(StatusCode::MULTI_STATUS) - .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .body(Body::from(response_body)) - .unwrap()) - } else { - // Check if path is a folder - let folder_result = folder_service.get_folder_by_path(&path).await; + // Try folder first + if let Ok(folder) = folder_service.get_folder_by_path(&path).await { + let folder_id = folder.id.clone(); + return build_streaming_propfind_response( + folder, + Some(folder_id), + &depth_owned, + &base_href, + propfind_request, + folder_service, + file_retrieval_service, + ) + .await; + } - if let Ok(folder) = folder_result { - // Path is a folder — run both queries concurrently - let (files, subfolders) = if depth != "0" { - let (files_r, folders_r) = tokio::join!( - file_retrieval_service.list_files(Some(&folder.id)), - folder_service.list_folders(Some(&folder.id)) - ); - ( - files_r.map_err(|e| AppError::internal_error(format!("Failed to get files: {}", e)))?, - folders_r.map_err(|e| AppError::internal_error(format!("Failed to get subfolders: {}", e)))?, - ) - } else { - (vec![], vec![]) - }; - - // Generate response - let mut response_body = Vec::new(); - WebDavAdapter::generate_propfind_response( - &mut response_body, - Some(&folder), - &files, - &subfolders, + // Try file + if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await { + let mut buf = Vec::with_capacity(1024); + { + let mut xml_writer = Writer::new(&mut buf); + WebDavAdapter::write_multistatus_start(&mut xml_writer) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + WebDavAdapter::write_file_entry( + &mut xml_writer, + &file, &propfind_request, - &depth, &base_href, ) - .map_err(|e| { - AppError::internal_error(format!("Failed to generate PROPFIND response: {}", e)) - })?; + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + WebDavAdapter::write_multistatus_end(&mut xml_writer) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + } + return Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(buf)) + .unwrap()); + } - Ok(Response::builder() - .status(StatusCode::MULTI_STATUS) - .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .body(Body::from(response_body)) - .unwrap()) - } else { - // Check if path is a file - let file_result = file_retrieval_service.get_file_by_path(&path).await; + Err(AppError::not_found(format!("Resource not found: {}", path))) +} - if let Ok(file) = file_result { - // Path is a file - let mut response_body = Vec::new(); - WebDavAdapter::generate_propfind_response_for_file( - &mut response_body, - &file, - &propfind_request, - &depth, - &base_href, - ) - .map_err(|e| { - AppError::internal_error(format!("Failed to generate PROPFIND response: {}", e)) - })?; +/// Builds a streaming 207 Multi-Status PROPFIND response. +/// +/// The XML is written incrementally: first the folder itself, then children +/// (sub-folders and files) are fetched in batches of `PROPFIND_BATCH_SIZE`. +/// Each batch is serialised to XML and sent as a chunk, so memory stays +/// constant at O(batch_size) regardless of the total number of children. +async fn build_streaming_propfind_response( + folder: FolderDto, + folder_id: Option, + depth: &str, + base_href: &str, + propfind_request: PropFindRequest, + folder_service: std::sync::Arc, + file_retrieval_service: std::sync::Arc, +) -> Result, AppError> { + let depth = depth.to_string(); + let base_href = base_href.to_string(); + let propfind_request = Arc::new(propfind_request); - Ok(Response::builder() - .status(StatusCode::MULTI_STATUS) - .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .body(Body::from(response_body)) - .unwrap()) - } else { - // Path does not exist - Err(AppError::not_found(format!("Resource not found: {}", path))) + let stream = async_stream::try_stream! { + // ── XML header + + folder entry ────────── + let mut buf = Vec::with_capacity(4096); + { + let mut w = Writer::new(&mut buf); + WebDavAdapter::write_multistatus_start(&mut w) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + WebDavAdapter::write_folder_entry(&mut w, &folder, &propfind_request, &base_href) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + } + yield Bytes::from(buf); + + // ── Children (only if Depth == 1) ──────────────────────── + if depth == "1" { + let pagination = crate::application::dtos::pagination::PaginationRequestDto { + page: 0, + page_size: PROPFIND_BATCH_SIZE as usize, + }; + let fid_ref = folder_id.as_deref(); + + // Stream sub-folders in pages + let mut page = 0usize; + loop { + let pag = crate::application::dtos::pagination::PaginationRequestDto { + page, + page_size: pagination.page_size, + }; + let result = folder_service + .list_folders_paginated(fid_ref, &pag) + .await + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + + if result.items.is_empty() { + break; + } + + let mut chunk = Vec::with_capacity(result.items.len() * 800); + { + let mut w = Writer::new(&mut chunk); + for subfolder in &result.items { + let href = format!("{}{}/", base_href, subfolder.name); + WebDavAdapter::write_folder_entry(&mut w, subfolder, &propfind_request, &href) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + } + } + let has_more = result.pagination.has_next; + yield Bytes::from(chunk); + + if !has_more { + break; + } + page += 1; + } + + // Stream files in pages + let mut offset: i64 = 0; + loop { + let batch: Vec = file_retrieval_service + .list_files_batch(fid_ref, offset, PROPFIND_BATCH_SIZE) + .await + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + + if batch.is_empty() { + break; + } + + let batch_len = batch.len(); + let mut chunk = Vec::with_capacity(batch_len * 800); + { + let mut w = Writer::new(&mut chunk); + for file in &batch { + let href = format!("{}{}", base_href, file.name); + WebDavAdapter::write_file_entry(&mut w, file, &propfind_request, &href) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + } + } + yield Bytes::from(chunk); + + if (batch_len as i64) < PROPFIND_BATCH_SIZE { + break; + } + offset += batch_len as i64; } } - } + + // ── Close ─────────────────────────────── + let mut buf = Vec::with_capacity(32); + { + let mut w = Writer::new(&mut buf); + WebDavAdapter::write_multistatus_end(&mut w) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + } + yield Bytes::from(buf); + }; + + use futures::TryStreamExt; + let stream = stream.map_err(|e: std::io::Error| -> Box { Box::new(e) }); + + Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from_stream(stream)) + .unwrap()) } /** @@ -330,7 +441,7 @@ async fn handle_propfind( * @return XML response with property modification results */ async fn handle_proppatch( - _state: AppState, + _state: Arc, req: Request, path: String, ) -> Result, AppError> { @@ -387,7 +498,7 @@ async fn handle_proppatch( * @return HTTP response with file contents */ async fn handle_get( - state: AppState, + state: Arc, _req: Request, path: String, ) -> Result, AppError> { @@ -431,7 +542,7 @@ async fn handle_get( * Handles HEAD requests — same as GET but returns only headers, no body. */ async fn handle_head( - state: AppState, + state: Arc, _req: Request, path: String, ) -> Result, AppError> { @@ -494,7 +605,7 @@ async fn handle_head( * @return HTTP response indicating success */ async fn handle_put( - state: AppState, + state: Arc, req: Request, path: String, ) -> Result, AppError> { @@ -598,7 +709,7 @@ async fn handle_put( * @return HTTP response indicating success */ async fn handle_mkcol( - state: AppState, + state: Arc, req: Request, path: String, ) -> Result, AppError> { @@ -673,7 +784,7 @@ async fn handle_mkcol( * @return HTTP response indicating success */ async fn handle_delete( - state: AppState, + state: Arc, _req: Request, path: String, ) -> Result, AppError> { @@ -728,7 +839,7 @@ async fn handle_delete( * @return HTTP response indicating success */ async fn handle_move( - state: AppState, + state: Arc, req: Request, path: String, ) -> Result, AppError> { @@ -889,7 +1000,7 @@ async fn handle_move( * @return HTTP response indicating success */ async fn handle_copy( - state: AppState, + state: Arc, req: Request, path: String, ) -> Result, AppError> { @@ -1059,7 +1170,7 @@ async fn handle_copy( * @return XML response with lock information */ async fn handle_lock( - _state: AppState, + _state: Arc, req: Request, path: String, ) -> Result, AppError> { @@ -1185,7 +1296,7 @@ async fn handle_lock( * @return HTTP response indicating success */ async fn handle_unlock( - _state: AppState, + _state: Arc, req: Request, _path: String, ) -> Result, AppError> { diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index f4ab0086..5a694aef 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -30,7 +30,7 @@ pub struct WopiState { pub token_service: Arc, pub lock_service: Arc, pub discovery_service: Arc, - pub app_state: crate::common::di::AppState, + pub app_state: Arc, /// Public base URL for host page origin and postMessage origin pub public_base_url: String, /// Base URL used for WOPISrc callbacks from Collabora to OxiCloud @@ -506,8 +506,8 @@ async fn get_supported_extensions(State(state): State) -> Response { pub fn wopi_routes( wopi_state: WopiState, ) -> ( - Router, - Router, + Router>, + Router>, ) { let protocol_router = Router::new() // CheckFileInfo diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 253fab04..c9412d90 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -28,12 +28,7 @@ use crate::interfaces::api::handlers::i18n_handler::I18nHandler; use crate::interfaces::api::handlers::trash_handler; /// Creates public API routes that should NOT require authentication. -/// -/// Currently this includes: -/// - `/s/{token}` — public access to shared items via share link -/// - `/s/{token}/verify` — password verification for protected share links -/// - `/i18n/*` — internationalization/translation endpoints -pub fn create_public_api_routes(app_state: &AppState) -> Router { +pub fn create_public_api_routes(app_state: &Arc) -> Router> { let share_service = app_state.share_service.clone(); let i18n_service = Some(app_state.applications.i18n_service.clone()); @@ -79,7 +74,7 @@ pub fn create_public_api_routes(app_state: &AppState) -> Router { /// These routes require authentication when auth is enabled. /// Receives the fully-assembled `AppState` and extracts all needed services /// from it, avoiding a long parameter list. -pub fn create_api_routes(app_state: &AppState) -> Router { +pub fn create_api_routes(app_state: &Arc) -> Router> { // Extract services from the pre-built AppState let folder_service = app_state.applications.folder_service_concrete.clone(); let file_retrieval_service = app_state.applications.file_retrieval_service.clone(); @@ -276,7 +271,7 @@ pub fn create_api_routes(app_state: &AppState) -> Router { post(ChunkedUploadHandler::complete_upload), ) .route("/{upload_id}", delete(ChunkedUploadHandler::cancel_upload)) - .with_state(Arc::new(app_state.clone())); + .with_state(app_state.clone()); // Create routes for deduplication endpoints let dedup_router = Router::new() diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index b25246d5..87d061e7 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -2,12 +2,13 @@ use crate::common::config::AppConfig; use crate::common::di::AppState; use axum::http::header::{CACHE_CONTROL, HeaderValue}; use axum::{Router, response::Html, routing::get}; +use std::sync::Arc; use tower_http::compression::CompressionLayer; use tower_http::services::ServeDir; use tower_http::set_header::SetResponseHeaderLayer; /// Creates web routes for serving static files -pub fn create_web_routes() -> Router { +pub fn create_web_routes() -> Router> { // Get config to access static path let config = AppConfig::from_env(); let static_path = config.static_path.clone(); diff --git a/src/main.rs b/src/main.rs index 57b7cb86..1531a2f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,6 +92,10 @@ async fn main() -> Result<(), Box> { let app_state = factory.build_app_state(db_pool).await .expect("Failed to build application state. If running in Docker, ensure the storage volume is writable by the oxicloud user (UID 1001)"); + // Wrap in Arc so that Axum clones a single refcount per request + // instead of deep-copying ~42 Arc fields + 16 String/PathBuf allocations. + let app_state = Arc::new(app_state); + // Build application router let api_routes = create_api_routes(&app_state); let public_api_routes = create_public_api_routes(&app_state); @@ -161,26 +165,25 @@ async fn main() -> Result<(), Box> { use interfaces::api::handlers::auth_handler::auth_routes; use oxicloud::interfaces::middleware::auth::auth_middleware; - let app_state_arc = Arc::new(app_state.clone()); - let auth_router = auth_routes().with_state(app_state_arc.clone()); + let auth_router = auth_routes().with_state(app_state.clone()); // Protected API routes — require valid JWT token let protected_api = api_routes.layer(axum::middleware::from_fn_with_state( - app_state_arc.clone(), + app_state.clone(), auth_middleware, )); // CalDAV/CardDAV/WebDAV with auth middleware (merged, not nested) let caldav_protected = caldav_router.layer(axum::middleware::from_fn_with_state( - app_state_arc.clone(), + app_state.clone(), auth_middleware, )); let carddav_protected = carddav_router.layer(axum::middleware::from_fn_with_state( - app_state_arc.clone(), + app_state.clone(), auth_middleware, )); let webdav_protected = webdav_router.layer(axum::middleware::from_fn_with_state( - app_state_arc, + app_state.clone(), auth_middleware, )); @@ -201,7 +204,7 @@ async fn main() -> Result<(), Box> { // Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware) if let Some((wopi_protocol, wopi_api)) = wopi_routes { let wopi_api_protected = wopi_api.layer(axum::middleware::from_fn_with_state( - Arc::new(app_state.clone()), + app_state.clone(), auth_middleware, )); app = app