perf: Arc<AppState>, streaming PROPFIND, spawn_blocking SHA-256

- Issue #4: Wrap AppState in Arc — eliminates 42 Arc::clone + 16 String::clone per request
- Issue #2: Reject Depth:infinity with 403 + streaming XML with paginated DB queries
- Issue #5: Move chunked upload assembly (SHA-256 hash-on-write) to spawn_blocking
- Remove ~270 lines dead code from di.rs (unused builders, Default impl, stubs)
- Clean up unused tokio imports in chunked_upload_service.rs
This commit is contained in:
Dionisio
2026-02-24 15:11:56 +01:00
parent cace61127f
commit 71c2cb5edb
20 changed files with 511 additions and 625 deletions
+42 -75
View File
@@ -213,81 +213,6 @@ impl WebDavAdapter {
Ok(PropFindRequest { prop_find_type }) Ok(PropFindRequest { prop_find_type })
} }
/// Generate a PROPFIND response for files and folders
pub fn generate_propfind_response<W: Write>(
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<W: Write>(
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 /// Write folder properties as a response
fn write_folder_response<W: Write>( fn write_folder_response<W: Write>(
xml_writer: &mut Writer<W>, xml_writer: &mut Writer<W>,
@@ -1102,4 +1027,46 @@ impl WebDavAdapter {
} }
name.to_string() 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 `<D:multistatus>` tag.
pub fn write_multistatus_start<W: Write>(writer: &mut Writer<W>) -> Result<()> {
writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([("xmlns:D", "DAV:")]),
))?;
Ok(())
}
/// Writes the closing `</D:multistatus>` tag.
pub fn write_multistatus_end<W: Write>(writer: &mut Writer<W>) -> Result<()> {
writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
/// Writes a single `<D:response>` element for a folder.
pub fn write_folder_entry<W: Write>(
writer: &mut Writer<W>,
folder: &FolderDto,
request: &PropFindRequest,
href: &str,
) -> Result<()> {
Self::write_folder_response(writer, folder, request, href)
}
/// Writes a single `<D:response>` element for a file.
pub fn write_file_entry<W: Write>(
writer: &mut Writer<W>,
file: &FileDto,
request: &PropFindRequest,
href: &str,
) -> Result<()> {
Self::write_file_response(writer, file, request, href)
}
} }
+14
View File
@@ -170,6 +170,20 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
) -> Result<Vec<FileDto>, DomainError> { ) -> Result<Vec<FileDto>, DomainError> {
self.list_files(Some(folder_id)).await 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<Vec<FileDto>, DomainError> {
let all = self.list_files(folder_id).await?;
Ok(all.into_iter().skip(offset as usize).take(limit as usize).collect())
}
} }
// ───────────────────────────────────────────────────── // ─────────────────────────────────────────────────────
+16
View File
@@ -78,6 +78,22 @@ pub trait FileReadPort: Send + Sync + 'static {
Ok(None) 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<Vec<File>, 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`. /// Lists every file in the subtree rooted at `folder_id`.
/// ///
/// Uses an ltree `<@` join against `storage.folders` so the entire /// Uses an ltree `<@` join against `storage.folders` so the entire
@@ -317,4 +317,14 @@ impl FileRetrievalUseCase for FileRetrievalService {
let files = self.file_read.list_files_in_subtree(folder_id).await?; let files = self.file_read.list_files_in_subtree(folder_id).await?;
Ok(files.into_iter().map(FileDto::from).collect()) Ok(files.into_iter().map(FileDto::from).collect())
} }
async fn list_files_batch(
&self,
folder_id: Option<&str>,
offset: i64,
limit: i64,
) -> Result<Vec<FileDto>, DomainError> {
let files = self.file_read.list_files_batch(folder_id, offset, limit).await?;
Ok(files.into_iter().map(FileDto::from).collect())
}
} }
+2 -319
View File
@@ -45,11 +45,7 @@ use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nSer
use crate::infrastructure::services::path_service::PathService; use crate::infrastructure::services::path_service::PathService;
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
use crate::common::stubs::{ use crate::common::stubs::StubZipPort;
StubDedupPort, StubFileManagementUseCase, StubFileReadPort,
StubFileRetrievalUseCase, StubFileUploadUseCase, StubFileUseCaseFactory, StubFileWritePort,
StubFolderStoragePort, StubFolderUseCase, StubI18nService, StubSearchUseCase, StubZipPort,
};
/// Factory for the different application components /// Factory for the different application components
/// ///
@@ -765,317 +761,4 @@ pub struct AppState {
Option<Arc<crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService>>, Option<Arc<crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService>>,
} }
impl Default for AppState { // All AppState construction is done via struct literal in build_app_state().
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<dyn crate::domain::services::i18n_service::I18nService>;
let folder_service = Arc::new(StubFolderUseCase)
as Arc<dyn crate::application::ports::inbound::FolderUseCase>;
let file_upload_service = Arc::new(StubFileUploadUseCase)
as Arc<dyn crate::application::ports::file_ports::FileUploadUseCase>;
let file_retrieval_service = Arc::new(StubFileRetrievalUseCase)
as Arc<dyn crate::application::ports::file_ports::FileRetrievalUseCase>;
let file_management_service = Arc::new(StubFileManagementUseCase)
as Arc<dyn crate::application::ports::file_ports::FileManagementUseCase>;
let file_use_case_factory = Arc::new(StubFileUseCaseFactory)
as Arc<dyn crate::application::ports::file_ports::FileUseCaseFactory>;
// 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<dyn ThumbnailPort> = 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<dyn ChunkedUploadPort> = 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<dyn ImageTranscodePort> = 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<dyn DedupPort> = 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<dyn ZipPort>,
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<dyn crate::application::ports::outbound::FolderStoragePort>,
folder_repo_concrete: dummy_folder_repo_concrete,
file_read_repository: Arc::new(StubFileReadPort)
as Arc<dyn crate::application::ports::storage_ports::FileReadPort>,
file_write_repository: Arc::new(StubFileWritePort)
as Arc<dyn crate::application::ports::storage_ports::FileWritePort>,
i18n_repository,
trash_repository: None,
};
// Dummy concrete services for compatibility
let dummy_folder_storage = Arc::new(StubFolderStoragePort)
as Arc<dyn crate::application::ports::outbound::FolderStoragePort>;
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<dyn crate::domain::services::i18n_service::I18nService>,
);
// 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<dyn crate::application::ports::inbound::SearchUseCase>),
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<PgPool>) -> 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<FolderService>,
file_retrieval_service: Arc<
dyn crate::application::ports::file_ports::FileRetrievalUseCase,
>,
file_upload_service: Arc<dyn FileUploadUseCase>,
file_management_service: Arc<dyn FileManagementUseCase>,
folder_use_case: Arc<dyn crate::application::ports::inbound::FolderUseCase>,
i18n_service: Option<
Arc<crate::application::services::i18n_application_service::I18nApplicationService>,
>,
trash_service: Option<Arc<dyn TrashUseCase>>,
search_service: Option<Arc<dyn crate::application::ports::inbound::SearchUseCase>>,
share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
favorites_service: Option<Arc<dyn FavoritesUseCase>>,
recent_service: Option<Arc<dyn RecentItemsUseCase>>,
) -> 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<dyn crate::application::ports::file_ports::FileRetrievalUseCase>,
folder_service.clone()
as Arc<dyn crate::application::ports::inbound::FolderUseCase>,
),
);
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<dyn TrashUseCase>) -> Self {
self.trash_service = Some(trash_service);
self
}
pub fn with_share_service(
mut self,
share_service: Arc<dyn crate::application::ports::share_ports::ShareUseCase>,
) -> Self {
self.share_service = Some(share_service);
self
}
pub fn with_favorites_service(mut self, favorites_service: Arc<dyn FavoritesUseCase>) -> Self {
self.favorites_service = Some(favorites_service);
self
}
pub fn with_recent_service(mut self, recent_service: Arc<dyn RecentItemsUseCase>) -> Self {
self.recent_service = Some(recent_service);
self
}
pub fn with_storage_usage_service(
mut self,
storage_usage_service: Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>,
) -> Self {
self.storage_usage_service = Some(storage_usage_service);
self
}
pub fn with_calendar_service(
mut self,
calendar_service: Arc<dyn crate::application::ports::storage_ports::StorageUseCase>,
) -> Self {
self.calendar_service = Some(calendar_service);
self
}
pub fn with_contact_service(
mut self,
contact_service: Arc<dyn crate::application::ports::storage_ports::StorageUseCase>,
) -> Self {
self.contact_service = Some(contact_service);
self
}
pub fn with_calendar_use_case(
mut self,
calendar_use_case: Arc<dyn crate::application::ports::calendar_ports::CalendarUseCase>,
) -> Self {
self.calendar_use_case = Some(calendar_use_case);
self
}
pub fn with_addressbook_use_case(
mut self,
addressbook_use_case: Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>,
) -> Self {
self.addressbook_use_case = Some(addressbook_use_case);
self
}
pub fn with_contact_use_case(
mut self,
contact_use_case: Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>,
) -> Self {
self.contact_use_case = Some(contact_use_case);
self
}
pub fn with_zip_service(mut self, zip_service: Arc<dyn ZipPort>) -> Self {
self.core.zip_service = zip_service;
self
}
}
@@ -207,6 +207,75 @@ impl FileReadPort for FileBlobReadRepository {
self.resolve_blob_hash(file_id).await 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<Vec<File>, DomainError> {
let rows: Vec<(
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
Option<String>,
)> = 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( async fn get_file_stream(
&self, &self,
id: &str, id: &str,
@@ -23,8 +23,8 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::fs::{self, File, OpenOptions}; use tokio::fs::{self, File};
use tokio::io::{AsyncWriteExt, BufWriter}; use tokio::io::AsyncWriteExt;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use uuid::Uuid; use uuid::Uuid;
@@ -633,59 +633,73 @@ impl ChunkedUploadService {
session.clone() 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 assembled_path = session.temp_dir.join("assembled");
let raw_output = OpenOptions::new() let chunks_meta: Vec<(usize, PathBuf)> = session
.create(true) .chunks
.write(true) .iter()
.truncate(true) .map(|c| (c.index, session.temp_dir.join(format!("chunk_{:06}", c.index))))
.open(&assembled_path) .collect();
.await let total_size = session.total_size;
.map_err(|e| format!("Failed to create assembled file: {e}"))?;
// Pre-allocate assembled file to reduce fragmentation let hash = tokio::task::spawn_blocking(move || -> Result<String, String> {
let _ = raw_output.set_len(session.total_size).await; use std::io::{Read, Write, BufWriter as StdBufWriter};
// 512 KB I/O buffers — 8× fewer syscalls than 64 KB let raw_output = std::fs::OpenOptions::new()
let mut output = BufWriter::with_capacity(524_288, raw_output); .create(true)
let mut hasher = Sha256::new(); .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 // Pre-allocate assembled file to reduce fragmentation
// Single 512 KB read buffer reused across all chunks (avoids N allocations) let _ = raw_output.set_len(total_size);
let mut buf = vec![0u8; 524_288];
for chunk in &session.chunks { // 512 KB I/O buffers — 8× fewer syscalls than 64 KB
let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index)); let mut output = StdBufWriter::with_capacity(524_288, raw_output);
let mut chunk_file = File::open(&chunk_path) let mut hasher = Sha256::new();
.await
.map_err(|e| format!("Failed to open chunk {}: {}", chunk.index, e))?; // Single 512 KB read buffer reused across all chunks (avoids N allocations)
loop { let mut buf = vec![0u8; 524_288];
let n = tokio::io::AsyncReadExt::read(&mut chunk_file, &mut buf) for (index, chunk_path) in &chunks_meta {
.await let mut chunk_file = std::fs::File::open(chunk_path)
.map_err(|e| format!("Failed to read chunk {}: {}", chunk.index, e))?; .map_err(|e| format!("Failed to open chunk {index}: {e}"))?;
if n == 0 { loop {
break; 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) output
.await .flush()
.map_err(|e| format!("Failed to flush assembled file: {e}"))?; .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) Ok(hex::encode(hasher.finalize()))
for chunk in &session.chunks { })
let chunk_path = session.temp_dir.join(format!("chunk_{:06}", chunk.index)); .await
let _ = fs::remove_file(&chunk_path).await; .map_err(|e| format!("Assembly task panicked: {e}"))??;
}
let assembled_path = session.temp_dir.join("assembled");
tracing::info!( tracing::info!(
"✅ Assembled chunked upload: {} ({} bytes from {} chunks)", "✅ Assembled chunked upload: {} ({} bytes from {} chunks)",
+17 -16
View File
@@ -13,9 +13,10 @@ use crate::application::dtos::settings_dto::{
}; };
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use std::sync::Arc;
/// Admin API routes — all require admin role. /// Admin API routes — all require admin role.
pub fn admin_routes() -> Router<AppState> { pub fn admin_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
// OIDC settings // OIDC settings
.route("/settings/oidc", get(get_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 /// GET /api/admin/settings/oidc — get OIDC settings for the admin panel
async fn get_oidc_settings( async fn get_oidc_settings(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?; admin_guard(&state, &headers).await?;
@@ -89,7 +90,7 @@ async fn get_oidc_settings(
/// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload /// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload
async fn save_oidc_settings( async fn save_oidc_settings(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(dto): Json<SaveOidcSettingsDto>, Json(dto): Json<SaveOidcSettingsDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -114,7 +115,7 @@ async fn save_oidc_settings(
/// POST /api/admin/settings/oidc/test — test OIDC discovery /// POST /api/admin/settings/oidc/test — test OIDC discovery
async fn test_oidc_connection( async fn test_oidc_connection(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(dto): Json<TestOidcConnectionDto>, Json(dto): Json<TestOidcConnectionDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -135,7 +136,7 @@ async fn test_oidc_connection(
/// GET /api/admin/settings/general — system overview (backward compat) /// GET /api/admin/settings/general — system overview (backward compat)
async fn get_general_settings( async fn get_general_settings(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?; admin_guard(&state, &headers).await?;
@@ -166,7 +167,7 @@ async fn get_general_settings(
/// GET /api/admin/dashboard — full dashboard statistics /// GET /api/admin/dashboard — full dashboard statistics
async fn get_dashboard_stats( async fn get_dashboard_stats(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?; 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 /// GET /api/admin/users?limit=50&offset=0 — list all users
async fn list_users( async fn list_users(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>, Query(query): Query<ListUsersQueryDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -278,7 +279,7 @@ async fn list_users(
/// GET /api/admin/users/:id — get single user /// GET /api/admin/users/:id — get single user
async fn get_user( async fn get_user(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -300,7 +301,7 @@ async fn get_user(
/// DELETE /api/admin/users/:id — delete a user /// DELETE /api/admin/users/:id — delete a user
async fn delete_user( async fn delete_user(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -335,7 +336,7 @@ async fn delete_user(
/// PUT /api/admin/users/:id/role — change user role /// PUT /api/admin/users/:id/role — change user role
async fn update_user_role( async fn update_user_role(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<UpdateUserRoleDto>, Json(dto): Json<UpdateUserRoleDto>,
@@ -371,7 +372,7 @@ async fn update_user_role(
/// PUT /api/admin/users/:id/active — activate/deactivate user /// PUT /api/admin/users/:id/active — activate/deactivate user
async fn update_user_active( async fn update_user_active(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<UpdateUserActiveDto>, Json(dto): Json<UpdateUserActiveDto>,
@@ -412,7 +413,7 @@ async fn update_user_active(
/// PUT /api/admin/users/:id/quota — update user storage quota /// PUT /api/admin/users/:id/quota — update user storage quota
async fn update_user_quota( async fn update_user_quota(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<UpdateUserQuotaDto>, Json(dto): Json<UpdateUserQuotaDto>,
@@ -444,7 +445,7 @@ async fn update_user_quota(
/// POST /api/admin/users — create a new user (admin only) /// POST /api/admin/users — create a new user (admin only)
async fn create_user( async fn create_user(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>, Json(dto): Json<AdminCreateUserDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -472,7 +473,7 @@ async fn create_user(
/// PUT /api/admin/users/:id/password — reset a user's password (admin only) /// PUT /api/admin/users/:id/password — reset a user's password (admin only)
async fn reset_user_password( async fn reset_user_password(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<AdminResetPasswordDto>, Json(dto): Json<AdminResetPasswordDto>,
@@ -509,7 +510,7 @@ async fn reset_user_password(
/// GET /api/admin/settings/registration — check if public registration is enabled /// GET /api/admin/settings/registration — check if public registration is enabled
async fn get_registration_setting( async fn get_registration_setting(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?; admin_guard(&state, &headers).await?;
@@ -528,7 +529,7 @@ async fn get_registration_setting(
/// PUT /api/admin/settings/registration — enable/disable public registration /// PUT /api/admin/settings/registration — enable/disable public registration
async fn set_registration_setting( async fn set_registration_setting(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(body): Json<serde_json::Value>, Json(body): Json<serde_json::Value>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@@ -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. /// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap.
/// Registers `/caldav`, `/caldav/`, and `/caldav/{*path}` explicitly. /// Registers `/caldav`, `/caldav/`, and `/caldav/{*path}` explicitly.
pub fn caldav_routes() -> Router<AppState> { pub fn caldav_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/caldav/{*path}", axum::routing::any(handle_caldav_methods)) .route("/caldav/{*path}", axum::routing::any(handle_caldav_methods))
.route("/caldav/", axum::routing::any(handle_caldav_methods_root)) .route("/caldav/", axum::routing::any(handle_caldav_methods_root))
@@ -48,14 +48,14 @@ pub fn caldav_routes() -> Router<AppState> {
} }
async fn handle_caldav_methods_root( async fn handle_caldav_methods_root(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>, req: Request<Body>,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
handle_caldav_methods_inner(state, req, String::new()).await handle_caldav_methods_inner(state, req, String::new()).await
} }
async fn handle_caldav_methods( async fn handle_caldav_methods(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>, req: Request<Body>,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
let uri = req.uri().clone(); let uri = req.uri().clone();
@@ -64,12 +64,11 @@ async fn handle_caldav_methods(
} }
async fn handle_caldav_methods_inner( async fn handle_caldav_methods_inner(
state: AppState, state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
let method = req.method().clone(); let method = req.method().clone();
let state = Arc::new(state);
match method.as_str() { match method.as_str() {
"OPTIONS" => handle_options().await, "OPTIONS" => handle_options().await,
@@ -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. /// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap.
/// Registers `/carddav`, `/carddav/`, and `/carddav/{*path}` explicitly. /// Registers `/carddav`, `/carddav/`, and `/carddav/{*path}` explicitly.
pub fn carddav_routes() -> Router<AppState> { pub fn carddav_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route( .route(
"/carddav/{*path}", "/carddav/{*path}",
@@ -53,14 +53,14 @@ pub fn carddav_routes() -> Router<AppState> {
} }
async fn handle_carddav_methods_root( async fn handle_carddav_methods_root(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>, req: Request<Body>,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
handle_carddav_methods_inner(state, req, String::new()).await handle_carddav_methods_inner(state, req, String::new()).await
} }
async fn handle_carddav_methods( async fn handle_carddav_methods(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>, req: Request<Body>,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
let uri = req.uri().clone(); let uri = req.uri().clone();
@@ -69,11 +69,10 @@ async fn handle_carddav_methods(
} }
async fn handle_carddav_methods_inner( async fn handle_carddav_methods_inner(
state: AppState, state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
let state = Arc::new(state);
let method = req.method().clone(); let method = req.method().clone();
match method.as_str() { match method.as_str() {
+2 -1
View File
@@ -9,9 +9,10 @@ use serde::Serialize;
use crate::application::ports::dedup_ports::DedupResultDto; use crate::application::ports::dedup_ports::DedupResultDto;
use crate::common::di::AppState; use crate::common::di::AppState;
use std::sync::Arc;
/// Global application state for dependency injection /// Global application state for dependency injection
type GlobalState = AppState; type GlobalState = Arc<AppState>;
/// Response for hash check endpoint /// Response for hash check endpoint
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
+2 -1
View File
@@ -13,12 +13,13 @@ use std::collections::HashMap;
use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::OptimizedFileContent;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::middleware::auth::{AuthUser, OptionalUserId}; use crate::interfaces::middleware::auth::{AuthUser, OptionalUserId};
use std::sync::Arc;
/** /**
* Type aliases for dependency injection state. * Type aliases for dependency injection state.
*/ */
/// Global application state for dependency injection /// Global application state for dependency injection
type GlobalState = AppState; type GlobalState = Arc<AppState>;
/** /**
* API handler for file-related operations. * API handler for file-related operations.
@@ -216,7 +216,7 @@ impl FolderHandler {
/// Both queries run concurrently via `tokio::join!`. /// Both queries run concurrently via `tokio::join!`.
/// Supports `If-None-Match` / ETag for conditional responses (304). /// Supports `If-None-Match` / ETag for conditional responses (304).
pub async fn list_folder_listing( pub async fn list_folder_listing(
State(state): State<GlobalAppState>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
@@ -339,7 +339,7 @@ impl FolderHandler {
/// Deletes a folder with trash functionality (ownership enforced by service layer) /// Deletes a folder with trash functionality (ownership enforced by service layer)
pub async fn delete_folder_with_trash( pub async fn delete_folder_with_trash(
State(state): State<GlobalAppState>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -392,7 +392,7 @@ impl FolderHandler {
/// Downloads a folder as a ZIP file (ownership enforced) /// Downloads a folder as a ZIP file (ownership enforced)
pub async fn download_folder_zip( pub async fn download_folder_zip(
State(state): State<GlobalAppState>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
Query(_params): Query<HashMap<String, String>>, Query(_params): Query<HashMap<String, String>>,
@@ -8,6 +8,7 @@ use tracing::{error, info};
use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::common::di::AppState; use crate::common::di::AppState;
use std::sync::Arc;
/** /**
* Handler for search operations through the API. * Handler for search operations through the API.
@@ -21,7 +22,7 @@ pub struct SearchHandler;
impl SearchHandler { impl SearchHandler {
/// GET /search — simple query-parameter-based search. /// GET /search — simple query-parameter-based search.
pub async fn search_files_get( pub async fn search_files_get(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
Query(params): Query<SearchParams>, Query(params): Query<SearchParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
info!("API: File search with parameters: {:?}", params); info!("API: File search with parameters: {:?}", params);
@@ -79,7 +80,7 @@ impl SearchHandler {
/// POST /search/advanced — full criteria in the request body. /// POST /search/advanced — full criteria in the request body.
pub async fn search_files_post( pub async fn search_files_post(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
Json(criteria): Json<SearchCriteriaDto>, Json(criteria): Json<SearchCriteriaDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
info!("API: Advanced file search"); info!("API: Advanced file search");
@@ -119,7 +120,7 @@ impl SearchHandler {
/// GET /search/suggest — lightweight autocomplete suggestions. /// GET /search/suggest — lightweight autocomplete suggestions.
pub async fn suggest_files( pub async fn suggest_files(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
Query(params): Query<SuggestParams>, Query(params): Query<SuggestParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
info!("API: Search suggestions for {:?}", params.query); info!("API: Search suggestions for {:?}", params.query);
@@ -162,7 +163,7 @@ impl SearchHandler {
} }
/// DELETE /search/cache — clears the search results cache. /// DELETE /search/cache — clears the search results cache.
pub async fn clear_search_cache(State(state): State<AppState>) -> impl IntoResponse { pub async fn clear_search_cache(State(state): State<Arc<AppState>>) -> impl IntoResponse {
info!("API: Clearing search cache"); info!("API: Clearing search cache");
let search_service = match &state.applications.search_service { let search_service = match &state.applications.search_service {
+8 -7
View File
@@ -7,11 +7,12 @@ use tracing::{debug, error, instrument, warn};
// use crate::application::ports::trash_ports::TrashUseCase; // use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser}; use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser};
use std::sync::Arc;
/// Gets all items in the trash for the current user /// Gets all items in the trash for the current user
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn get_trash_items( pub async fn get_trash_items(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
) -> (StatusCode, Json<serde_json::Value>) { ) -> (StatusCode, Json<serde_json::Value>) {
// SECURITY: Always use the authenticated user's ID from the JWT token. // 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) /// Moves an item (file or folder) to the trash (generic function, not used directly in routes)
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn move_to_trash( pub async fn move_to_trash(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
OptionalAuthUser(auth_user): OptionalAuthUser, OptionalAuthUser(auth_user): OptionalAuthUser,
Path((item_type, item_id)): Path<(String, String)>, Path((item_type, item_id)): Path<(String, String)>,
) -> (StatusCode, Json<serde_json::Value>) { ) -> (StatusCode, Json<serde_json::Value>) {
@@ -109,7 +110,7 @@ pub async fn move_to_trash(
/// Moves a file to the trash /// Moves a file to the trash
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn move_file_to_trash( pub async fn move_file_to_trash(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
OptionalAuthUser(auth_user): OptionalAuthUser, OptionalAuthUser(auth_user): OptionalAuthUser,
Path(item_id): Path<String>, Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) { ) -> (StatusCode, Json<serde_json::Value>) {
@@ -163,7 +164,7 @@ pub async fn move_file_to_trash(
/// Moves a folder to the trash /// Moves a folder to the trash
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn move_folder_to_trash( pub async fn move_folder_to_trash(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
OptionalAuthUser(auth_user): OptionalAuthUser, OptionalAuthUser(auth_user): OptionalAuthUser,
Path(item_id): Path<String>, Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) { ) -> (StatusCode, Json<serde_json::Value>) {
@@ -219,7 +220,7 @@ pub async fn move_folder_to_trash(
/// Restores an item from the trash to its original location /// Restores an item from the trash to its original location
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn restore_from_trash( pub async fn restore_from_trash(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(trash_id): Path<String>, Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) { ) -> (StatusCode, Json<serde_json::Value>) {
@@ -280,7 +281,7 @@ pub async fn restore_from_trash(
/// Permanently deletes an item from the trash /// Permanently deletes an item from the trash
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn delete_permanently( pub async fn delete_permanently(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(trash_id): Path<String>, Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) { ) -> (StatusCode, Json<serde_json::Value>) {
@@ -343,7 +344,7 @@ pub async fn delete_permanently(
/// Empties the trash completely for the current user /// Empties the trash completely for the current user
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn empty_trash( pub async fn empty_trash(
State(state): State<AppState>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
) -> (StatusCode, Json<serde_json::Value>) { ) -> (StatusCode, Json<serde_json::Value>) {
debug!("Request to empty trash for user {}", auth_user.id); debug!("Request to empty trash for user {}", auth_user.id);
+235 -124
View File
@@ -12,17 +12,22 @@ use axum::{
http::{HeaderName, Request, StatusCode, header}, http::{HeaderName, Request, StatusCode, header},
response::Response, response::Response,
}; };
use bytes::Buf; use bytes::{Buf, Bytes};
use chrono::Utc; use chrono::Utc;
use quick_xml::Writer;
use uuid::Uuid; use uuid::Uuid;
use crate::application::adapters::webdav_adapter::{ use crate::application::adapters::webdav_adapter::{
LockInfo, LockScope, LockType, PropFindRequest, WebDavAdapter, LockInfo, LockScope, LockType, PropFindRequest, WebDavAdapter,
}; };
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto; 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::common::di::AppState;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::middleware::auth::CurrentUser;
use std::sync::Arc;
// Create a custom DAV header since it's not in the standard headers // Create a custom DAV header since it's not in the standard headers
const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); 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). /// Maximum body size for MKCOL requests (RFC 4918: body must be empty).
const MAX_MKCOL_BODY: usize = 4096; 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. * 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 * @return Router configured with WebDAV endpoints
*/ */
pub fn webdav_routes() -> Router<AppState> { pub fn webdav_routes() -> Router<Arc<AppState>> {
// Three explicit routes to avoid Axum trailing-slash gaps // Three explicit routes to avoid Axum trailing-slash gaps
// (same pattern used for CalDAV/CardDAV) // (same pattern used for CalDAV/CardDAV)
Router::new() Router::new()
@@ -72,14 +81,14 @@ fn extract_webdav_path(uri: &axum::http::Uri) -> String {
} }
async fn handle_webdav_methods_root( async fn handle_webdav_methods_root(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>, req: Request<Body>,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
handle_webdav_dispatch(state, req, String::new()).await handle_webdav_dispatch(state, req, String::new()).await
} }
async fn handle_webdav_methods( async fn handle_webdav_methods(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>, req: Request<Body>,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
let path = extract_webdav_path(req.uri()); let path = extract_webdav_path(req.uri());
@@ -87,7 +96,7 @@ async fn handle_webdav_methods(
} }
async fn handle_webdav_dispatch( async fn handle_webdav_dispatch(
state: AppState, state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -140,27 +149,54 @@ async fn handle_options(_path: String) -> Result<Response<Body>, AppError> {
* *
* This handler processes WebDAV PROPFIND requests according to RFC 4918, * This handler processes WebDAV PROPFIND requests according to RFC 4918,
* retrieving properties of files and folders in the specified path. * 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 state The application state containing service dependencies
* @param user The authenticated user information * @param req The HTTP request containing the PROPFIND XML body
* @param path The requested resource path * @param path The requested resource path
* @param req The HTTP request containing the PROPFIND XML body * @return 207 Multi-Status XML response with resource properties
* @return XML response with resource properties
*/ */
async fn handle_propfind( async fn handle_propfind(
state: AppState, state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
// Extract depth header (cloning to avoid borrowing issues) // ── 1. Extract and validate Depth header ─────────────────────
let depth = req let depth = req
.headers() .headers()
.get("Depth") .get("Depth")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or("infinity") .unwrap_or("1");
.to_string();
// RFC 4918 §9.1: servers MAY reject Depth:infinity with 403
if depth == "infinity" {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<D:error xmlns:D="DAV:">
<D:propfind-finite-depth/>
</D:error>"#;
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 = {
let user_ref = req let user_ref = req
.extensions() .extensions()
@@ -169,50 +205,37 @@ async fn handle_propfind(
user_ref.clone() user_ref.clone()
}; };
// Extract the body separately to avoid borrow issues // ── 3. Parse PROPFIND XML body ───────────────────────────────
let body_bytes = { let body_bytes = {
// Convert the request into a body
let body = req.into_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) body::to_bytes(body, MAX_XML_BODY)
.await .await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?
}; };
// Parse PROPFIND request
let propfind_request = if body_bytes.is_empty() { let propfind_request = if body_bytes.is_empty() {
// Empty body means get all properties
PropFindRequest { PropFindRequest {
prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp, prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp,
} }
} else { } else {
// Parse XML body
WebDavAdapter::parse_propfind(body_bytes.reader()).map_err(|e| { WebDavAdapter::parse_propfind(body_bytes.reader()).map_err(|e| {
AppError::bad_request(format!("Failed to parse PROPFIND request: {}", e)) AppError::bad_request(format!("Failed to parse PROPFIND request: {}", e))
})? })?
}; };
// Get folder service from state // ── 4. Services ──────────────────────────────────────────────
let folder_service = &state.applications.folder_service; let folder_service = state.applications.folder_service.clone();
let file_retrieval_service = &state.applications.file_retrieval_service; let file_retrieval_service = state.applications.file_retrieval_service.clone();
// Determine base HREF let base_href = if path.is_empty() || path == "/" {
let base_href = format!("/webdav/{}/", 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 == "/" { if path.is_empty() || path == "/" {
// Root folder — run both queries concurrently // Root folder
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
let root_folder = FolderDto { let root_folder = FolderDto {
id: "root".to_string(), id: "root".to_string(),
name: "".to_string(), name: "".to_string(),
@@ -227,94 +250,182 @@ async fn handle_propfind(
category: "Folder".to_string(), category: "Folder".to_string(),
}; };
// Generate response return build_streaming_propfind_response(
let mut response_body = Vec::new(); root_folder,
WebDavAdapter::generate_propfind_response( None, // folder_id = None → root children
&mut response_body, &depth_owned,
Some(&root_folder),
&files,
&subfolders,
&propfind_request,
&depth,
&base_href, &base_href,
propfind_request,
folder_service,
file_retrieval_service,
) )
.map_err(|e| { .await;
AppError::internal_error(format!("Failed to generate PROPFIND response: {}", e)) }
})?;
Ok(Response::builder() // Try folder first
.status(StatusCode::MULTI_STATUS) if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8") let folder_id = folder.id.clone();
.body(Body::from(response_body)) return build_streaming_propfind_response(
.unwrap()) folder,
} else { Some(folder_id),
// Check if path is a folder &depth_owned,
let folder_result = folder_service.get_folder_by_path(&path).await; &base_href,
propfind_request,
folder_service,
file_retrieval_service,
)
.await;
}
if let Ok(folder) = folder_result { // Try file
// Path is a folder — run both queries concurrently if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await {
let (files, subfolders) = if depth != "0" { let mut buf = Vec::with_capacity(1024);
let (files_r, folders_r) = tokio::join!( {
file_retrieval_service.list_files(Some(&folder.id)), let mut xml_writer = Writer::new(&mut buf);
folder_service.list_folders(Some(&folder.id)) WebDavAdapter::write_multistatus_start(&mut xml_writer)
); .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
( WebDavAdapter::write_file_entry(
files_r.map_err(|e| AppError::internal_error(format!("Failed to get files: {}", e)))?, &mut xml_writer,
folders_r.map_err(|e| AppError::internal_error(format!("Failed to get subfolders: {}", e)))?, &file,
)
} else {
(vec![], vec![])
};
// Generate response
let mut response_body = Vec::new();
WebDavAdapter::generate_propfind_response(
&mut response_body,
Some(&folder),
&files,
&subfolders,
&propfind_request, &propfind_request,
&depth,
&base_href, &base_href,
) )
.map_err(|e| { .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
AppError::internal_error(format!("Failed to generate PROPFIND response: {}", 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() Err(AppError::not_found(format!("Resource not found: {}", path)))
.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;
if let Ok(file) = file_result { /// Builds a streaming 207 Multi-Status PROPFIND response.
// Path is a file ///
let mut response_body = Vec::new(); /// The XML is written incrementally: first the folder itself, then children
WebDavAdapter::generate_propfind_response_for_file( /// (sub-folders and files) are fetched in batches of `PROPFIND_BATCH_SIZE`.
&mut response_body, /// Each batch is serialised to XML and sent as a chunk, so memory stays
&file, /// constant at O(batch_size) regardless of the total number of children.
&propfind_request, async fn build_streaming_propfind_response(
&depth, folder: FolderDto,
&base_href, folder_id: Option<String>,
) depth: &str,
.map_err(|e| { base_href: &str,
AppError::internal_error(format!("Failed to generate PROPFIND response: {}", e)) propfind_request: PropFindRequest,
})?; folder_service: std::sync::Arc<dyn FolderUseCase>,
file_retrieval_service: std::sync::Arc<dyn FileRetrievalUseCase>,
) -> Result<Response<Body>, AppError> {
let depth = depth.to_string();
let base_href = base_href.to_string();
let propfind_request = Arc::new(propfind_request);
Ok(Response::builder() let stream = async_stream::try_stream! {
.status(StatusCode::MULTI_STATUS) // ── XML header + <D:multistatus> + folder entry ──────────
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8") let mut buf = Vec::with_capacity(4096);
.body(Body::from(response_body)) {
.unwrap()) let mut w = Writer::new(&mut buf);
} else { WebDavAdapter::write_multistatus_start(&mut w)
// Path does not exist .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
Err(AppError::not_found(format!("Resource not found: {}", path))) 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<FileDto> = 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 </D:multistatus> ───────────────────────────────
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<dyn std::error::Error + Send + Sync> { 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 * @return XML response with property modification results
*/ */
async fn handle_proppatch( async fn handle_proppatch(
_state: AppState, _state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -387,7 +498,7 @@ async fn handle_proppatch(
* @return HTTP response with file contents * @return HTTP response with file contents
*/ */
async fn handle_get( async fn handle_get(
state: AppState, state: Arc<AppState>,
_req: Request<Body>, _req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -431,7 +542,7 @@ async fn handle_get(
* Handles HEAD requests — same as GET but returns only headers, no body. * Handles HEAD requests — same as GET but returns only headers, no body.
*/ */
async fn handle_head( async fn handle_head(
state: AppState, state: Arc<AppState>,
_req: Request<Body>, _req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -494,7 +605,7 @@ async fn handle_head(
* @return HTTP response indicating success * @return HTTP response indicating success
*/ */
async fn handle_put( async fn handle_put(
state: AppState, state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -598,7 +709,7 @@ async fn handle_put(
* @return HTTP response indicating success * @return HTTP response indicating success
*/ */
async fn handle_mkcol( async fn handle_mkcol(
state: AppState, state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -673,7 +784,7 @@ async fn handle_mkcol(
* @return HTTP response indicating success * @return HTTP response indicating success
*/ */
async fn handle_delete( async fn handle_delete(
state: AppState, state: Arc<AppState>,
_req: Request<Body>, _req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -728,7 +839,7 @@ async fn handle_delete(
* @return HTTP response indicating success * @return HTTP response indicating success
*/ */
async fn handle_move( async fn handle_move(
state: AppState, state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -889,7 +1000,7 @@ async fn handle_move(
* @return HTTP response indicating success * @return HTTP response indicating success
*/ */
async fn handle_copy( async fn handle_copy(
state: AppState, state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -1059,7 +1170,7 @@ async fn handle_copy(
* @return XML response with lock information * @return XML response with lock information
*/ */
async fn handle_lock( async fn handle_lock(
_state: AppState, _state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
path: String, path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
@@ -1185,7 +1296,7 @@ async fn handle_lock(
* @return HTTP response indicating success * @return HTTP response indicating success
*/ */
async fn handle_unlock( async fn handle_unlock(
_state: AppState, _state: Arc<AppState>,
req: Request<Body>, req: Request<Body>,
_path: String, _path: String,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
+3 -3
View File
@@ -30,7 +30,7 @@ pub struct WopiState {
pub token_service: Arc<WopiTokenService>, pub token_service: Arc<WopiTokenService>,
pub lock_service: Arc<WopiLockService>, pub lock_service: Arc<WopiLockService>,
pub discovery_service: Arc<WopiDiscoveryService>, pub discovery_service: Arc<WopiDiscoveryService>,
pub app_state: crate::common::di::AppState, pub app_state: Arc<crate::common::di::AppState>,
/// Public base URL for host page origin and postMessage origin /// Public base URL for host page origin and postMessage origin
pub public_base_url: String, pub public_base_url: String,
/// Base URL used for WOPISrc callbacks from Collabora to OxiCloud /// Base URL used for WOPISrc callbacks from Collabora to OxiCloud
@@ -506,8 +506,8 @@ async fn get_supported_extensions(State(state): State<WopiState>) -> Response {
pub fn wopi_routes( pub fn wopi_routes(
wopi_state: WopiState, wopi_state: WopiState,
) -> ( ) -> (
Router<crate::common::di::AppState>, Router<Arc<crate::common::di::AppState>>,
Router<crate::common::di::AppState>, Router<Arc<crate::common::di::AppState>>,
) { ) {
let protocol_router = Router::new() let protocol_router = Router::new()
// CheckFileInfo // CheckFileInfo
+3 -8
View File
@@ -28,12 +28,7 @@ use crate::interfaces::api::handlers::i18n_handler::I18nHandler;
use crate::interfaces::api::handlers::trash_handler; use crate::interfaces::api::handlers::trash_handler;
/// Creates public API routes that should NOT require authentication. /// Creates public API routes that should NOT require authentication.
/// pub fn create_public_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
/// 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<AppState> {
let share_service = app_state.share_service.clone(); let share_service = app_state.share_service.clone();
let i18n_service = Some(app_state.applications.i18n_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<AppState> {
/// These routes require authentication when auth is enabled. /// These routes require authentication when auth is enabled.
/// Receives the fully-assembled `AppState` and extracts all needed services /// Receives the fully-assembled `AppState` and extracts all needed services
/// from it, avoiding a long parameter list. /// from it, avoiding a long parameter list.
pub fn create_api_routes(app_state: &AppState) -> Router<AppState> { pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Extract services from the pre-built AppState // Extract services from the pre-built AppState
let folder_service = app_state.applications.folder_service_concrete.clone(); let folder_service = app_state.applications.folder_service_concrete.clone();
let file_retrieval_service = app_state.applications.file_retrieval_service.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<AppState> {
post(ChunkedUploadHandler::complete_upload), post(ChunkedUploadHandler::complete_upload),
) )
.route("/{upload_id}", delete(ChunkedUploadHandler::cancel_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 // Create routes for deduplication endpoints
let dedup_router = Router::new() let dedup_router = Router::new()
+2 -1
View File
@@ -2,12 +2,13 @@ use crate::common::config::AppConfig;
use crate::common::di::AppState; use crate::common::di::AppState;
use axum::http::header::{CACHE_CONTROL, HeaderValue}; use axum::http::header::{CACHE_CONTROL, HeaderValue};
use axum::{Router, response::Html, routing::get}; use axum::{Router, response::Html, routing::get};
use std::sync::Arc;
use tower_http::compression::CompressionLayer; use tower_http::compression::CompressionLayer;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_http::set_header::SetResponseHeaderLayer; use tower_http::set_header::SetResponseHeaderLayer;
/// Creates web routes for serving static files /// Creates web routes for serving static files
pub fn create_web_routes() -> Router<AppState> { pub fn create_web_routes() -> Router<Arc<AppState>> {
// Get config to access static path // Get config to access static path
let config = AppConfig::from_env(); let config = AppConfig::from_env();
let static_path = config.static_path.clone(); let static_path = config.static_path.clone();
+10 -7
View File
@@ -92,6 +92,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app_state = factory.build_app_state(db_pool).await 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)"); .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 // Build application router
let api_routes = create_api_routes(&app_state); let api_routes = create_api_routes(&app_state);
let public_api_routes = create_public_api_routes(&app_state); let public_api_routes = create_public_api_routes(&app_state);
@@ -161,26 +165,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
use interfaces::api::handlers::auth_handler::auth_routes; use interfaces::api::handlers::auth_handler::auth_routes;
use oxicloud::interfaces::middleware::auth::auth_middleware; 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.clone());
let auth_router = auth_routes().with_state(app_state_arc.clone());
// Protected API routes — require valid JWT token // Protected API routes — require valid JWT token
let protected_api = api_routes.layer(axum::middleware::from_fn_with_state( let protected_api = api_routes.layer(axum::middleware::from_fn_with_state(
app_state_arc.clone(), app_state.clone(),
auth_middleware, auth_middleware,
)); ));
// CalDAV/CardDAV/WebDAV with auth middleware (merged, not nested) // CalDAV/CardDAV/WebDAV with auth middleware (merged, not nested)
let caldav_protected = caldav_router.layer(axum::middleware::from_fn_with_state( let caldav_protected = caldav_router.layer(axum::middleware::from_fn_with_state(
app_state_arc.clone(), app_state.clone(),
auth_middleware, auth_middleware,
)); ));
let carddav_protected = carddav_router.layer(axum::middleware::from_fn_with_state( let carddav_protected = carddav_router.layer(axum::middleware::from_fn_with_state(
app_state_arc.clone(), app_state.clone(),
auth_middleware, auth_middleware,
)); ));
let webdav_protected = webdav_router.layer(axum::middleware::from_fn_with_state( let webdav_protected = webdav_router.layer(axum::middleware::from_fn_with_state(
app_state_arc, app_state.clone(),
auth_middleware, auth_middleware,
)); ));
@@ -201,7 +204,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware) // Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware)
if let Some((wopi_protocol, wopi_api)) = wopi_routes { if let Some((wopi_protocol, wopi_api)) = wopi_routes {
let wopi_api_protected = wopi_api.layer(axum::middleware::from_fn_with_state( let wopi_api_protected = wopi_api.layer(axum::middleware::from_fn_with_state(
Arc::new(app_state.clone()), app_state.clone(),
auth_middleware, auth_middleware,
)); ));
app = app app = app