use axum::{ body::{self, Body}, http::{Request, StatusCode, header}, response::Response, }; use quick_xml::{ Reader, Writer, events::{BytesEnd, BytesStart, Event}, }; use std::collections::HashSet; use std::sync::Arc; use crate::application::dtos::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::nextcloud::webdav_handler::{ format_oc_id, nc_href, resolve_file_id, resolve_folder_id, write_file_response, write_folder_response, }; /// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility. /// /// Dispatches based on the XML body: /// - `oc:filter-files` -- list favorited items (REPORT) /// - `d:searchrequest` -- search files by name (SEARCH) pub async fn handle_nc_report( state: Arc, req: Request, user: &CurrentUser, _subpath: &str, ) -> Result, AppError> { let body_bytes = body::to_bytes(req.into_body(), 64 * 1024) .await .map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?; let body_str = String::from_utf8_lossy(&body_bytes); if body_str.contains("filter-files") { handle_filter_files(state, &body_str, user).await } else if body_str.contains("searchrequest") { handle_search(state, &body_str, user).await } else { // Unknown REPORT type -- return empty multistatus. Ok(empty_multistatus()) } } // ──────────────────── Favorites filter (oc:filter-files) ──────────────────── async fn handle_filter_files( state: Arc, _body: &str, user: &CurrentUser, ) -> Result, AppError> { let fav_svc = match state.favorites_service.as_ref() { Some(svc) => svc, None => return Ok(empty_multistatus()), }; let favorites = fav_svc .get_favorites(user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to get favorites: {}", e)))?; if favorites.is_empty() { return Ok(empty_multistatus()); } let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; let nc = state.nextcloud.as_ref(); let file_id_svc = nc.map(|n| &n.file_ids); // All items in this response are favorites. let favorite_ids: HashSet = favorites.iter().map(|f| f.item_id.clone()).collect(); let home_prefix = format!("My Folder - {}/", user.username); let mut buf = Vec::new(); { let mut xml = Writer::new(&mut buf); write_multistatus_start(&mut xml)?; for fav in &favorites { match fav.item_type.as_str() { "file" => { let file = match file_service.get_file(&fav.item_id).await { Ok(f) => f, Err(_) => continue, // Deleted or inaccessible -- skip. }; let subpath = strip_home_prefix(&file.path, &home_prefix); let href = nc_href(&user.username, subpath); let fid = resolve_file_id(file_id_svc, &file.id).await; let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_file_response( &mut xml, &file, &href, fid, oc_id.as_deref(), &user.username, &favorite_ids, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } "folder" => { let folder = match folder_service.get_folder(&fav.item_id).await { Ok(f) => f, Err(_) => continue, }; let subpath = strip_home_prefix(&folder.path, &home_prefix); let href = format!("{}/", nc_href(&user.username, subpath)); let fid = resolve_folder_id(file_id_svc, &folder.id).await; let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_folder_response( &mut xml, &folder, &href, fid, oc_id.as_deref(), &user.username, &favorite_ids, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } _ => continue, } } xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .body(Body::from(buf)) .unwrap()) } // ──────────────────── Search (d:searchrequest) ──────────────────── async fn handle_search( state: Arc, body: &str, user: &CurrentUser, ) -> Result, AppError> { let search_svc = match state.applications.search_service.as_ref() { Some(svc) => svc, None => return Ok(empty_multistatus()), }; let term = parse_literal(body).unwrap_or_default(); if term.is_empty() { return Ok(empty_multistatus()); } let nresults = parse_nresults(body).unwrap_or(100); // Resolve folder scope from inside . let folder_id = resolve_scope_folder(&state, body, &user.username).await; let criteria = SearchCriteriaDto { name_contains: Some(term), recursive: true, limit: nresults, folder_id, ..Default::default() }; let results = search_svc .search(criteria, user.id) .await .map_err(|e| AppError::internal_error(format!("Search failed: {}", e)))?; let nc = state.nextcloud.as_ref(); let file_id_svc = nc.map(|n| &n.file_ids); let home_prefix = format!("My Folder - {}/", user.username); // No favorite checking for search results -- pass an empty set. let favorite_ids: HashSet = HashSet::new(); let mut buf = Vec::new(); { let mut xml = Writer::new(&mut buf); write_multistatus_start(&mut xml)?; // Files. for fr in &results.files { let file = file_dto_from_search(fr); let subpath = strip_home_prefix(&file.path, &home_prefix); let href = nc_href(&user.username, subpath); let fid = resolve_file_id(file_id_svc, &file.id).await; let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_file_response( &mut xml, &file, &href, fid, oc_id.as_deref(), &user.username, &favorite_ids, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } // Folders. for sr in &results.folders { let folder = folder_dto_from_search(sr); let subpath = strip_home_prefix(&folder.path, &home_prefix); let href = format!("{}/", nc_href(&user.username, subpath)); let fid = resolve_folder_id(file_id_svc, &folder.id).await; let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_folder_response( &mut xml, &folder, &href, fid, oc_id.as_deref(), &user.username, &favorite_ids, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .body(Body::from(buf)) .unwrap()) } // ──────────────────── DTO conversions ──────────────────── /// Build a `FileDto` from a search file result. fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto { FileDto { id: fr.id.clone(), name: fr.name.clone(), path: fr.path.clone(), size: fr.size, mime_type: fr.mime_type.clone().into(), folder_id: fr.folder_id.clone(), created_at: fr.created_at, modified_at: fr.modified_at, icon_class: icon_class_for(&fr.name, &fr.mime_type).to_string().into(), icon_special_class: icon_special_class_for(&fr.name, &fr.mime_type) .to_string() .into(), category: category_for(&fr.name, &fr.mime_type).to_string().into(), size_formatted: format_file_size(fr.size), owner_id: None, sort_date: None, etag: String::new(), } } /// Build a `FolderDto` from a search folder result. fn folder_dto_from_search( sr: &crate::application::dtos::search_dto::SearchFolderResultDto, ) -> FolderDto { FolderDto { id: sr.id.clone(), name: sr.name.clone(), path: sr.path.clone(), parent_id: sr.parent_id.clone(), owner_id: None, created_at: sr.created_at, modified_at: sr.modified_at, is_root: sr.is_root, icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), } } // ──────────────────── XML helpers ──────────────────── /// Write the opening `` element with namespace declarations. fn write_multistatus_start(xml: &mut Writer) -> Result<(), AppError> { let mut ms = BytesStart::new("d:multistatus"); ms.push_attribute(("xmlns:d", "DAV:")); ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns")); ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns")); xml.write_event(Event::Start(ms)) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; Ok(()) } /// Build an empty 207 Multi-Status response. fn empty_multistatus() -> Response { let xml = r#" "#; Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .body(Body::from(xml)) .unwrap() } // ──────────────────── XML parsing helpers ──────────────────── /// Extract the search term from `%term%` using quick_xml. fn parse_literal(body: &str) -> Option { let text = xml_extract_text(body, b"literal")?; // Strip SQL-style % wildcards. let term = text.trim_matches('%').trim(); if term.is_empty() { None } else { Some(term.to_string()) } } /// Extract the result limit from `100` using quick_xml. fn parse_nresults(body: &str) -> Option { let text = xml_extract_text(body, b"nresults")?; text.trim().parse::().ok() } /// Extract the scope href from `` inside `` using quick_xml. fn parse_scope_href(body: &str) -> Option { let mut reader = Reader::from_str(body); let mut inside_scope = false; let mut inside_href = false; loop { match reader.read_event() { Ok(Event::Start(ref e)) => { let local = e.local_name(); if local.as_ref() == b"scope" { inside_scope = true; } else if inside_scope && local.as_ref() == b"href" { inside_href = true; } } Ok(Event::Text(ref e)) if inside_href => { let text = e.decode().ok()?; let href = text.trim(); if href.is_empty() { return None; } return Some(href.to_string()); } Ok(Event::End(ref e)) => { let local = e.local_name(); if local.as_ref() == b"scope" { inside_scope = false; } else if local.as_ref() == b"href" { inside_href = false; } } Ok(Event::Eof) => break, Err(_) => break, _ => {} } } None } /// Generic helper: extract text content from the first element matching a local name. fn xml_extract_text(body: &str, local_name: &[u8]) -> Option { let mut reader = Reader::from_str(body); let mut inside = false; loop { match reader.read_event() { Ok(Event::Start(ref e)) if e.local_name().as_ref() == local_name => { inside = true; } Ok(Event::Text(ref e)) if inside => { return e.decode().ok().map(|s| s.to_string()); } Ok(Event::End(ref e)) if e.local_name().as_ref() == local_name => { inside = false; } Ok(Event::Eof) => break, Err(_) => break, _ => {} } } None } /// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID. async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> Option { let href = parse_scope_href(body)?; // The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`. let subpath = extract_subpath_from_scope(&href, username)?; if subpath.is_empty() { // Root scope -- no folder_id filter needed. return None; } let internal_path = crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(username, &subpath) .ok()?; let folder_service = &state.applications.folder_service; folder_service .get_folder_by_path(&internal_path) .await .ok() .map(|f| f.id) } /// Extract the subpath portion from a scope href. /// /// Handles both short form `/files/{user}/sub` and full /// `/remote.php/dav/files/{user}/sub`. fn extract_subpath_from_scope(href: &str, username: &str) -> Option { let patterns = [ format!("/remote.php/dav/files/{}/", username), format!("/files/{}/", username), format!("/remote.php/dav/files/{}", username), format!("/files/{}", username), ]; for pat in &patterns { if let Some(rest) = href.strip_prefix(pat.as_str()) { return Some(rest.trim_matches('/').to_string()); } } None } /// Strip the `My Folder - {username}/` prefix to get the DAV subpath. fn strip_home_prefix<'a>(path: &'a str, prefix: &str) -> &'a str { path.strip_prefix(prefix).unwrap_or(path) }