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
+17 -16
View File
@@ -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<AppState> {
pub fn admin_routes() -> Router<Arc<AppState>> {
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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<SaveOidcSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<TestOidcConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>,
) -> Result<impl IntoResponse, AppError> {
@@ -278,7 +279,7 @@ async fn list_users(
/// GET /api/admin/users/:id — get single user
async fn get_user(
State(state): State<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
@@ -300,7 +301,7 @@ async fn get_user(
/// DELETE /api/admin/users/:id — delete a user
async fn delete_user(
State(state): State<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserRoleDto>,
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserActiveDto>,
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserQuotaDto>,
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>,
) -> 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)
async fn reset_user_password(
State(state): State<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<AdminResetPasswordDto>,
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> 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.
/// Registers `/caldav`, `/caldav/`, and `/caldav/{*path}` explicitly.
pub fn caldav_routes() -> Router<AppState> {
pub fn caldav_routes() -> Router<Arc<AppState>> {
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<AppState> {
}
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>,
) -> Result<Response<Body>, AppError> {
handle_caldav_methods_inner(state, req, String::new()).await
}
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>,
) -> Result<Response<Body>, 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<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
let state = Arc::new(state);
match method.as_str() {
"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.
/// Registers `/carddav`, `/carddav/`, and `/carddav/{*path}` explicitly.
pub fn carddav_routes() -> Router<AppState> {
pub fn carddav_routes() -> Router<Arc<AppState>> {
Router::new()
.route(
"/carddav/{*path}",
@@ -53,14 +53,14 @@ pub fn carddav_routes() -> Router<AppState> {
}
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>,
) -> Result<Response<Body>, AppError> {
handle_carddav_methods_inner(state, req, String::new()).await
}
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>,
) -> Result<Response<Body>, 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<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
let state = Arc::new(state);
let method = req.method().clone();
match method.as_str() {
+2 -1
View File
@@ -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<AppState>;
/// Response for hash check endpoint
#[derive(Debug, Serialize)]
+2 -1
View File
@@ -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<AppState>;
/**
* API handler for file-related operations.
@@ -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<GlobalAppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
headers: HeaderMap,
Path(id): Path<String>,
@@ -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<GlobalAppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> 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<GlobalAppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Query(_params): Query<HashMap<String, String>>,
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
Query(params): Query<SearchParams>,
) -> 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<AppState>,
State(state): State<Arc<AppState>>,
Json(criteria): Json<SearchCriteriaDto>,
) -> 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<AppState>,
State(state): State<Arc<AppState>>,
Query(params): Query<SuggestParams>,
) -> 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<AppState>) -> impl IntoResponse {
pub async fn clear_search_cache(State(state): State<Arc<AppState>>) -> impl IntoResponse {
info!("API: Clearing search cache");
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::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<AppState>,
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
) -> (StatusCode, Json<serde_json::Value>) {
// 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<AppState>,
State(state): State<Arc<AppState>>,
OptionalAuthUser(auth_user): OptionalAuthUser,
Path((item_type, item_id)): Path<(String, String)>,
) -> (StatusCode, Json<serde_json::Value>) {
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
OptionalAuthUser(auth_user): OptionalAuthUser,
Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
OptionalAuthUser(auth_user): OptionalAuthUser,
Path(item_id): Path<String>,
) -> (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
#[instrument(skip_all)]
pub async fn restore_from_trash(
State(state): State<AppState>,
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
@@ -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<AppState>,
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
) -> (StatusCode, Json<serde_json::Value>) {
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},
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<AppState> {
pub fn webdav_routes() -> Router<Arc<AppState>> {
// 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<AppState>,
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>,
) -> Result<Response<Body>, AppError> {
handle_webdav_dispatch(state, req, String::new()).await
}
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>,
) -> Result<Response<Body>, 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<AppState>,
req: Request<Body>,
path: String,
) -> 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,
* 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<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, 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#"<?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_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<String>,
depth: &str,
base_href: &str,
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()
.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 + <D:multistatus> + 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<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
*/
async fn handle_proppatch(
_state: AppState,
_state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
@@ -387,7 +498,7 @@ async fn handle_proppatch(
* @return HTTP response with file contents
*/
async fn handle_get(
state: AppState,
state: Arc<AppState>,
_req: Request<Body>,
path: String,
) -> Result<Response<Body>, 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<AppState>,
_req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
@@ -494,7 +605,7 @@ async fn handle_head(
* @return HTTP response indicating success
*/
async fn handle_put(
state: AppState,
state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
@@ -598,7 +709,7 @@ async fn handle_put(
* @return HTTP response indicating success
*/
async fn handle_mkcol(
state: AppState,
state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
@@ -673,7 +784,7 @@ async fn handle_mkcol(
* @return HTTP response indicating success
*/
async fn handle_delete(
state: AppState,
state: Arc<AppState>,
_req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
@@ -728,7 +839,7 @@ async fn handle_delete(
* @return HTTP response indicating success
*/
async fn handle_move(
state: AppState,
state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
@@ -889,7 +1000,7 @@ async fn handle_move(
* @return HTTP response indicating success
*/
async fn handle_copy(
state: AppState,
state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
@@ -1059,7 +1170,7 @@ async fn handle_copy(
* @return XML response with lock information
*/
async fn handle_lock(
_state: AppState,
_state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
@@ -1185,7 +1296,7 @@ async fn handle_lock(
* @return HTTP response indicating success
*/
async fn handle_unlock(
_state: AppState,
_state: Arc<AppState>,
req: Request<Body>,
_path: String,
) -> Result<Response<Body>, AppError> {
+3 -3
View File
@@ -30,7 +30,7 @@ pub struct WopiState {
pub token_service: Arc<WopiTokenService>,
pub lock_service: Arc<WopiLockService>,
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
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<WopiState>) -> Response {
pub fn wopi_routes(
wopi_state: WopiState,
) -> (
Router<crate::common::di::AppState>,
Router<crate::common::di::AppState>,
Router<Arc<crate::common::di::AppState>>,
Router<Arc<crate::common::di::AppState>>,
) {
let protocol_router = Router::new()
// CheckFileInfo
+3 -8
View File
@@ -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<AppState> {
pub fn create_public_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
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<AppState> {
/// 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<AppState> {
pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// 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<AppState> {
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()