2026-03-04 14:02:15 +01:00
|
|
|
use axum::{
|
|
|
|
|
body::{self, Body},
|
|
|
|
|
http::{Request, StatusCode, header},
|
|
|
|
|
response::Response,
|
|
|
|
|
};
|
|
|
|
|
use quick_xml::{
|
|
|
|
|
Reader, Writer,
|
|
|
|
|
events::{BytesEnd, BytesStart, Event},
|
|
|
|
|
};
|
2026-06-19 11:27:11 +00:00
|
|
|
use std::collections::{HashMap, HashSet};
|
2026-03-04 14:02:15 +01:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-07-19 17:30:22 +00:00
|
|
|
use crate::application::dtos::display_helpers::{format_file_size, intern_display};
|
2026-03-04 14:02:15 +01:00
|
|
|
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;
|
2026-05-20 15:39:53 +02:00
|
|
|
use crate::application::ports::folder_ports::FolderUseCase;
|
|
|
|
|
use crate::application::ports::inbound::SearchUseCase;
|
2026-03-04 14:02:15 +01:00
|
|
|
use crate::common::di::AppState;
|
2026-06-06 19:51:51 +02:00
|
|
|
use crate::domain::entities::file::File;
|
2026-07-16 14:20:20 +00:00
|
|
|
use crate::interfaces::api::handlers::webdav_handler::{
|
|
|
|
|
dead_props_for, files_dead_props_map, folders_dead_props_map,
|
|
|
|
|
};
|
2026-03-04 14:02:15 +01:00
|
|
|
use crate::interfaces::errors::AppError;
|
|
|
|
|
use crate::interfaces::nextcloud::webdav_handler::{
|
2026-07-21 10:25:36 +00:00
|
|
|
batch_resolve_ids, format_oc_id_into, nc_collection_href_into, nc_href_into, nc_id_of,
|
|
|
|
|
write_file_response, write_folder_response,
|
2026-03-04 14:02:15 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// 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<AppState>,
|
|
|
|
|
req: Request<Body>,
|
2026-06-15 22:59:34 +02:00
|
|
|
session: &crate::interfaces::nextcloud::session::NcSession,
|
2026-03-04 14:02:15 +01:00
|
|
|
_subpath: &str,
|
|
|
|
|
) -> Result<Response<Body>, 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") {
|
2026-06-15 22:59:34 +02:00
|
|
|
handle_filter_files(state, &body_str, session).await
|
2026-03-04 14:02:15 +01:00
|
|
|
} else if body_str.contains("searchrequest") {
|
2026-06-15 22:59:34 +02:00
|
|
|
handle_search(state, &body_str, session).await
|
2026-03-04 14:02:15 +01:00
|
|
|
} else {
|
|
|
|
|
// Unknown REPORT type -- return empty multistatus.
|
|
|
|
|
Ok(empty_multistatus())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ──────────────────── Favorites filter (oc:filter-files) ────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_filter_files(
|
|
|
|
|
state: Arc<AppState>,
|
|
|
|
|
_body: &str,
|
2026-06-15 22:59:34 +02:00
|
|
|
session: &crate::interfaces::nextcloud::session::NcSession,
|
2026-03-04 14:02:15 +01:00
|
|
|
) -> Result<Response<Body>, AppError> {
|
2026-06-15 22:59:34 +02:00
|
|
|
let user = &session.user;
|
|
|
|
|
let url_user = &session.raw_username;
|
2026-07-05 22:52:26 +02:00
|
|
|
// Chroot-scope the response: NC's `oc:filter-files` REPORT is a
|
|
|
|
|
// single-drive surface (the client PROPFINDs favorites under its
|
|
|
|
|
// "home" URL and has no cross-drive concept). Favorites that live
|
|
|
|
|
// in another drive the caller is a member of are dropped from
|
|
|
|
|
// this response; they're still reachable via REST
|
|
|
|
|
// `/api/favorites/resources`. `session.require_chroot()` is safe
|
|
|
|
|
// here — the REPORT verb only reaches this handler through a
|
|
|
|
|
// path-scoped route.
|
|
|
|
|
let chroot = session.require_chroot()?;
|
2026-03-04 14:02:15 +01:00
|
|
|
let fav_svc = match state.favorites_service.as_ref() {
|
|
|
|
|
Some(svc) => svc,
|
|
|
|
|
None => return Ok(empty_multistatus()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let favorites = fav_svc
|
2026-03-07 14:59:32 +01:00
|
|
|
.get_favorites(user.id)
|
2026-03-04 14:02:15 +01:00
|
|
|
.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<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
|
|
|
|
|
|
2026-07-05 22:52:26 +02:00
|
|
|
// `home_prefix` is unused after the chroot-aware strip
|
|
|
|
|
// (see `strip_home_prefix`); kept as a positional argument in
|
|
|
|
|
// the emit calls below for signature stability with the
|
|
|
|
|
// report-handler tests and the parallel search-pass caller.
|
|
|
|
|
let home_prefix = "";
|
2026-03-04 14:02:15 +01:00
|
|
|
|
2026-06-19 11:27:11 +00:00
|
|
|
// Pass 1: resolve the favorited DTOs in two batch queries (was one
|
|
|
|
|
// get_* per favorite — up to N serial round-trips on a sync client's
|
|
|
|
|
// REPORT). Results are looked up by id so the response keeps favorites
|
|
|
|
|
// order; missing/trashed favorites simply drop out (as before).
|
|
|
|
|
let mut file_ids: Vec<String> = Vec::new();
|
|
|
|
|
let mut folder_ids: Vec<String> = Vec::new();
|
|
|
|
|
for fav in &favorites {
|
|
|
|
|
match fav.item_type.as_str() {
|
|
|
|
|
"file" => file_ids.push(fav.item_id.clone()),
|
|
|
|
|
"folder" => folder_ids.push(fav.item_id.clone()),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 00:18:54 +00:00
|
|
|
let mut file_map: HashMap<String, FileDto> = file_service
|
2026-06-19 11:27:11 +00:00
|
|
|
.get_files_by_ids(&file_ids)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite files: {e}")))?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|f| (f.id.clone(), f))
|
|
|
|
|
.collect();
|
2026-07-20 00:18:54 +00:00
|
|
|
let mut folder_map: HashMap<String, FolderDto> = folder_service
|
2026-06-19 11:27:11 +00:00
|
|
|
.get_folders_by_ids(&folder_ids)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite folders: {e}")))?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|f| (f.id.clone(), f))
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-06-10 08:39:06 +00:00
|
|
|
let mut files: Vec<FileDto> = Vec::new();
|
|
|
|
|
let mut folders: Vec<FolderDto> = Vec::new();
|
2026-07-20 00:18:54 +00:00
|
|
|
// Move the DTO out of the map instead of cloning it: the maps are built
|
|
|
|
|
// just above solely to hydrate `files`/`folders` in favorites order and are
|
|
|
|
|
// dropped at fn end, so the clone was pure waste. `favorites.item_id` is
|
|
|
|
|
// unique per user, so `remove` drops nothing needed and the favorites order
|
|
|
|
|
// is preserved (benches/ROUND20.md §C3).
|
2026-06-10 08:39:06 +00:00
|
|
|
for fav in &favorites {
|
|
|
|
|
match fav.item_type.as_str() {
|
|
|
|
|
"file" => {
|
2026-07-20 00:18:54 +00:00
|
|
|
if let Some(f) = file_map.remove(&fav.item_id) {
|
|
|
|
|
files.push(f);
|
2026-06-10 08:39:06 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"folder" => {
|
2026-07-20 00:18:54 +00:00
|
|
|
if let Some(f) = folder_map.remove(&fav.item_id) {
|
|
|
|
|
folders.push(f);
|
2026-06-10 08:39:06 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pass 2: resolve every oc:fileid in two batch queries (was one per item).
|
2026-07-18 09:03:33 +00:00
|
|
|
let file_uuids: Vec<&str> = files.iter().map(|f| f.id.as_str()).collect();
|
|
|
|
|
let folder_uuids: Vec<&str> = folders.iter().map(|f| f.id.as_str()).collect();
|
2026-06-10 08:39:06 +00:00
|
|
|
let (file_id_map, folder_id_map) =
|
|
|
|
|
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
|
|
|
|
|
|
|
|
|
|
// Pass 3: write the multistatus XML (pure synchronous map lookups).
|
2026-03-04 14:02:15 +01:00
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
{
|
|
|
|
|
let mut xml = Writer::new(&mut buf);
|
|
|
|
|
|
|
|
|
|
write_multistatus_start(&mut xml)?;
|
|
|
|
|
|
2026-07-16 14:20:20 +00:00
|
|
|
// Batched dead-props: one = ANY($1) query per type, not one per
|
|
|
|
|
// result (benches/DEAD-PROPS.md).
|
|
|
|
|
let file_deads = files_dead_props_map(&state.webdav_dead_props, &files).await;
|
|
|
|
|
let folder_deads = folders_dead_props_map(&state.webdav_dead_props, &folders).await;
|
|
|
|
|
|
2026-06-15 22:59:34 +02:00
|
|
|
// Keep main's batched-resolution structure (one batch query
|
|
|
|
|
// per type, not 2N round-trips). Hrefs use `url_user` so the
|
|
|
|
|
// multi-drive `~{drive}` form is echoed back to the client;
|
|
|
|
|
// owner-id stays canonical via `&user.username`.
|
2026-07-21 01:49:37 +00:00
|
|
|
// One oc:id buffer reused across both emit loops (benches/ROUND27.md §H1).
|
|
|
|
|
let mut oc_buf = String::new();
|
2026-07-21 10:25:36 +00:00
|
|
|
// One href buffer reused across both emit loops, with the URL-encoded
|
|
|
|
|
// user computed once for the page instead of re-encoded per row — the
|
|
|
|
|
// reused-buffer shape the PROPFIND child loop already uses
|
|
|
|
|
// (benches/ROUND29.md §A).
|
|
|
|
|
let encoded_user = urlencoding::encode(url_user);
|
|
|
|
|
let mut href_buf = String::new();
|
2026-06-10 08:39:06 +00:00
|
|
|
for file in &files {
|
2026-07-05 22:52:26 +02:00
|
|
|
// Skip favorites that live outside the caller's chroot
|
|
|
|
|
// (other-drive favorites); reachable via REST if needed.
|
|
|
|
|
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
target: "oxicloud::nc",
|
|
|
|
|
"REPORT filter-files: dropping cross-chroot favorite '{}' at '{}'",
|
|
|
|
|
file.id,
|
|
|
|
|
file.path,
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-07-21 10:25:36 +00:00
|
|
|
nc_href_into(&mut href_buf, &encoded_user, subpath);
|
2026-07-18 09:03:33 +00:00
|
|
|
let fid = nc_id_of(&file_id_map, &file.id);
|
2026-07-21 01:49:37 +00:00
|
|
|
let oc_id: Option<&str> = match fid {
|
|
|
|
|
Some(id) => {
|
|
|
|
|
format_oc_id_into(&mut oc_buf, id, file_id_svc);
|
|
|
|
|
Some(oc_buf.as_str())
|
|
|
|
|
}
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
2026-07-16 14:20:20 +00:00
|
|
|
let dead = dead_props_for(&file.id, &file_deads);
|
2026-06-10 08:39:06 +00:00
|
|
|
write_file_response(
|
|
|
|
|
&mut xml,
|
|
|
|
|
file,
|
2026-07-21 10:25:36 +00:00
|
|
|
&href_buf,
|
2026-07-21 01:49:37 +00:00
|
|
|
(fid, oc_id),
|
2026-06-10 08:39:06 +00:00
|
|
|
&user.username,
|
|
|
|
|
&favorite_ids,
|
2026-07-16 14:20:20 +00:00
|
|
|
dead,
|
2026-06-10 08:39:06 +00:00
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for folder in &folders {
|
2026-07-05 22:52:26 +02:00
|
|
|
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
target: "oxicloud::nc",
|
|
|
|
|
"REPORT filter-files: dropping cross-chroot favorite folder '{}' at '{}'",
|
|
|
|
|
folder.id,
|
|
|
|
|
folder.path,
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-07-21 10:25:36 +00:00
|
|
|
nc_collection_href_into(&mut href_buf, &encoded_user, subpath);
|
2026-07-18 09:03:33 +00:00
|
|
|
let fid = nc_id_of(&folder_id_map, &folder.id);
|
2026-07-21 01:49:37 +00:00
|
|
|
let oc_id: Option<&str> = match fid {
|
|
|
|
|
Some(id) => {
|
|
|
|
|
format_oc_id_into(&mut oc_buf, id, file_id_svc);
|
|
|
|
|
Some(oc_buf.as_str())
|
|
|
|
|
}
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
2026-07-16 14:20:20 +00:00
|
|
|
let dead = dead_props_for(&folder.id, &folder_deads);
|
2026-06-10 08:39:06 +00:00
|
|
|
write_folder_response(
|
|
|
|
|
&mut xml,
|
|
|
|
|
folder,
|
2026-07-21 10:25:36 +00:00
|
|
|
&href_buf,
|
2026-07-21 01:49:37 +00:00
|
|
|
(fid, oc_id),
|
2026-06-10 08:39:06 +00:00
|
|
|
&user.username,
|
|
|
|
|
&favorite_ids,
|
2026-07-13 00:34:17 +02:00
|
|
|
// REPORT results are a flat filter/search listing, not a
|
|
|
|
|
// PROPFIND on a specific collection — quota isn't
|
|
|
|
|
// meaningful here (see `AppState::resolve_webdav_quota`).
|
|
|
|
|
None,
|
2026-07-16 14:20:20 +00:00
|
|
|
dead,
|
2026-06-10 08:39:06 +00:00
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
2026-03-04 14:02:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<AppState>,
|
|
|
|
|
body: &str,
|
2026-06-15 22:59:34 +02:00
|
|
|
session: &crate::interfaces::nextcloud::session::NcSession,
|
2026-03-04 14:02:15 +01:00
|
|
|
) -> Result<Response<Body>, AppError> {
|
2026-06-15 22:59:34 +02:00
|
|
|
let user = &session.user;
|
2026-07-05 22:52:26 +02:00
|
|
|
// Chroot-scope the response: NC's search REPORT is a single-drive
|
|
|
|
|
// surface. Results that live outside the chroot (other drives the
|
|
|
|
|
// caller is a member of) are dropped from the multistatus and
|
|
|
|
|
// recorded at debug — reachable via REST search if needed.
|
|
|
|
|
// `resolve_scope_folder` below re-pulls chroot from the session
|
|
|
|
|
// for the path-mapping step.
|
|
|
|
|
let chroot = session.require_chroot()?;
|
2026-06-15 22:59:34 +02:00
|
|
|
let url_user = &session.raw_username;
|
2026-03-04 14:02:15 +01:00
|
|
|
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 <d:href> inside <d:scope>.
|
2026-06-15 22:59:34 +02:00
|
|
|
let folder_id = resolve_scope_folder(&state, body, session).await;
|
2026-03-04 14:02:15 +01:00
|
|
|
|
|
|
|
|
let criteria = SearchCriteriaDto {
|
|
|
|
|
name_contains: Some(term),
|
|
|
|
|
recursive: true,
|
|
|
|
|
limit: nresults,
|
|
|
|
|
folder_id,
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let results = search_svc
|
2026-03-07 14:59:32 +01:00
|
|
|
.search(criteria, user.id)
|
2026-03-04 14:02:15 +01:00
|
|
|
.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);
|
2026-07-05 22:52:26 +02:00
|
|
|
// See the favorites pass above: `home_prefix` is unused after the
|
|
|
|
|
// chroot-aware strip, kept only for signature stability.
|
|
|
|
|
let home_prefix = "";
|
2026-03-04 14:02:15 +01:00
|
|
|
|
|
|
|
|
// No favorite checking for search results -- pass an empty set.
|
|
|
|
|
let favorite_ids: HashSet<String> = HashSet::new();
|
|
|
|
|
|
2026-06-10 08:39:06 +00:00
|
|
|
// Materialize DTOs, then resolve every oc:fileid in two batch queries
|
|
|
|
|
// (was one INSERT round-trip per result).
|
|
|
|
|
let files: Vec<FileDto> = results.files.iter().map(file_dto_from_search).collect();
|
|
|
|
|
let folders: Vec<FolderDto> = results.folders.iter().map(folder_dto_from_search).collect();
|
2026-07-18 09:03:33 +00:00
|
|
|
let file_uuids: Vec<&str> = files.iter().map(|f| f.id.as_str()).collect();
|
|
|
|
|
let folder_uuids: Vec<&str> = folders.iter().map(|f| f.id.as_str()).collect();
|
2026-06-10 08:39:06 +00:00
|
|
|
let (file_id_map, folder_id_map) =
|
|
|
|
|
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
|
|
|
|
|
|
2026-03-04 14:02:15 +01:00
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
{
|
|
|
|
|
let mut xml = Writer::new(&mut buf);
|
|
|
|
|
|
|
|
|
|
write_multistatus_start(&mut xml)?;
|
|
|
|
|
|
2026-07-16 14:20:20 +00:00
|
|
|
// Batched dead-props: one = ANY($1) query per type, not one per
|
|
|
|
|
// result (benches/DEAD-PROPS.md).
|
|
|
|
|
let file_deads = files_dead_props_map(&state.webdav_dead_props, &files).await;
|
|
|
|
|
let folder_deads = folders_dead_props_map(&state.webdav_dead_props, &folders).await;
|
|
|
|
|
|
2026-03-04 14:02:15 +01:00
|
|
|
// Files.
|
2026-07-21 01:49:37 +00:00
|
|
|
// One oc:id buffer reused across both emit loops (benches/ROUND27.md §H1).
|
|
|
|
|
let mut oc_buf = String::new();
|
2026-07-21 10:25:36 +00:00
|
|
|
// One href buffer reused across both emit loops, with the URL-encoded
|
|
|
|
|
// user computed once for the page instead of re-encoded per row — the
|
|
|
|
|
// reused-buffer shape the PROPFIND child loop already uses
|
|
|
|
|
// (benches/ROUND29.md §A).
|
|
|
|
|
let encoded_user = urlencoding::encode(url_user);
|
|
|
|
|
let mut href_buf = String::new();
|
2026-06-10 08:39:06 +00:00
|
|
|
for file in &files {
|
2026-07-05 22:52:26 +02:00
|
|
|
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
target: "oxicloud::nc",
|
|
|
|
|
"REPORT search: dropping cross-chroot file '{}' at '{}'",
|
|
|
|
|
file.id,
|
|
|
|
|
file.path,
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-07-21 10:25:36 +00:00
|
|
|
nc_href_into(&mut href_buf, &encoded_user, subpath);
|
2026-07-18 09:03:33 +00:00
|
|
|
let fid = nc_id_of(&file_id_map, &file.id);
|
2026-07-21 01:49:37 +00:00
|
|
|
let oc_id: Option<&str> = match fid {
|
|
|
|
|
Some(id) => {
|
|
|
|
|
format_oc_id_into(&mut oc_buf, id, file_id_svc);
|
|
|
|
|
Some(oc_buf.as_str())
|
|
|
|
|
}
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
2026-07-16 14:20:20 +00:00
|
|
|
let dead = dead_props_for(&file.id, &file_deads);
|
2026-03-04 14:02:15 +01:00
|
|
|
write_file_response(
|
|
|
|
|
&mut xml,
|
2026-06-10 08:39:06 +00:00
|
|
|
file,
|
2026-07-21 10:25:36 +00:00
|
|
|
&href_buf,
|
2026-07-21 01:49:37 +00:00
|
|
|
(fid, oc_id),
|
2026-03-04 14:02:15 +01:00
|
|
|
&user.username,
|
|
|
|
|
&favorite_ids,
|
2026-07-16 14:20:20 +00:00
|
|
|
dead,
|
2026-03-04 14:02:15 +01:00
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Folders.
|
2026-06-10 08:39:06 +00:00
|
|
|
for folder in &folders {
|
2026-07-05 22:52:26 +02:00
|
|
|
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
target: "oxicloud::nc",
|
|
|
|
|
"REPORT search: dropping cross-chroot folder '{}' at '{}'",
|
|
|
|
|
folder.id,
|
|
|
|
|
folder.path,
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-07-21 10:25:36 +00:00
|
|
|
nc_collection_href_into(&mut href_buf, &encoded_user, subpath);
|
2026-07-18 09:03:33 +00:00
|
|
|
let fid = nc_id_of(&folder_id_map, &folder.id);
|
2026-07-21 01:49:37 +00:00
|
|
|
let oc_id: Option<&str> = match fid {
|
|
|
|
|
Some(id) => {
|
|
|
|
|
format_oc_id_into(&mut oc_buf, id, file_id_svc);
|
|
|
|
|
Some(oc_buf.as_str())
|
|
|
|
|
}
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
2026-07-16 14:20:20 +00:00
|
|
|
let dead = dead_props_for(&folder.id, &folder_deads);
|
2026-03-04 14:02:15 +01:00
|
|
|
write_folder_response(
|
|
|
|
|
&mut xml,
|
2026-06-10 08:39:06 +00:00
|
|
|
folder,
|
2026-07-21 10:25:36 +00:00
|
|
|
&href_buf,
|
2026-07-21 01:49:37 +00:00
|
|
|
(fid, oc_id),
|
2026-03-04 14:02:15 +01:00
|
|
|
&user.username,
|
|
|
|
|
&favorite_ids,
|
2026-07-13 00:34:17 +02:00
|
|
|
// REPORT results are a flat filter/search listing, not a
|
|
|
|
|
// PROPFIND on a specific collection — quota isn't
|
|
|
|
|
// meaningful here (see `AppState::resolve_webdav_quota`).
|
|
|
|
|
None,
|
2026-07-16 14:20:20 +00:00
|
|
|
dead,
|
2026-03-04 14:02:15 +01:00
|
|
|
)
|
|
|
|
|
.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 {
|
2026-06-06 19:51:51 +02:00
|
|
|
// Route ETag through `File::compute_etag` so REPORT/SEARCH hits
|
|
|
|
|
// emit the same opaque token NC's sync client cached from the
|
|
|
|
|
// earlier PROPFIND walk — without this, NC's conditional-request
|
|
|
|
|
// logic on search results disagrees with its own cached state
|
|
|
|
|
// and triggers a spurious re-fetch.
|
|
|
|
|
let etag = if fr.blob_hash.is_empty() {
|
|
|
|
|
String::new()
|
|
|
|
|
} else {
|
|
|
|
|
File::compute_etag(&fr.blob_hash, fr.modified_at)
|
|
|
|
|
};
|
2026-03-04 14:02:15 +01:00
|
|
|
FileDto {
|
|
|
|
|
id: fr.id.clone(),
|
|
|
|
|
name: fr.name.clone(),
|
|
|
|
|
path: fr.path.clone(),
|
|
|
|
|
size: fr.size,
|
2026-07-18 16:12:04 +00:00
|
|
|
// Interned `Arc<str>` carried through from enrichment — refcount
|
|
|
|
|
// bumps; the old code re-ran all three display classifiers and
|
|
|
|
|
// re-allocated each value per converted search row.
|
|
|
|
|
mime_type: fr.mime_type.clone(),
|
2026-03-04 14:02:15 +01:00
|
|
|
folder_id: fr.folder_id.clone(),
|
|
|
|
|
created_at: fr.created_at,
|
|
|
|
|
modified_at: fr.modified_at,
|
2026-07-18 16:12:04 +00:00
|
|
|
icon_class: fr.icon_class.clone(),
|
|
|
|
|
icon_special_class: fr.icon_special_class.clone(),
|
|
|
|
|
category: fr.category.clone(),
|
2026-03-04 14:02:15 +01:00
|
|
|
size_formatted: format_file_size(fr.size),
|
2026-03-05 14:46:30 -05:00
|
|
|
sort_date: None,
|
2026-06-06 19:51:51 +02:00
|
|
|
content_hash: fr.blob_hash.clone(),
|
|
|
|
|
etag,
|
2026-06-19 10:51:13 +02:00
|
|
|
// §14 provenance not selected by the search result DTO.
|
|
|
|
|
created_by: None,
|
|
|
|
|
updated_by: None,
|
2026-03-04 14:02:15 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 16:12:04 +00:00
|
|
|
/// Bench-only public wrapper (feature = "bench") over the private
|
|
|
|
|
/// search→FileDto conversion so `examples/bench_search_enrich.rs` can
|
|
|
|
|
/// measure and equivalence-gate it.
|
|
|
|
|
#[cfg(feature = "bench")]
|
|
|
|
|
pub fn file_dto_from_search_for_bench(
|
|
|
|
|
fr: &crate::application::dtos::search_dto::SearchFileResultDto,
|
|
|
|
|
) -> FileDto {
|
|
|
|
|
file_dto_from_search(fr)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:02:15 +01:00
|
|
|
/// Build a `FolderDto` from a search folder result.
|
|
|
|
|
fn folder_dto_from_search(
|
|
|
|
|
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
|
|
|
|
|
) -> FolderDto {
|
|
|
|
|
FolderDto {
|
2026-06-06 15:37:27 +02:00
|
|
|
etag: sr.id.clone(),
|
2026-03-04 14:02:15 +01:00
|
|
|
id: sr.id.clone(),
|
|
|
|
|
name: sr.name.clone(),
|
|
|
|
|
path: sr.path.clone(),
|
|
|
|
|
parent_id: sr.parent_id.clone(),
|
2026-06-29 21:55:47 +02:00
|
|
|
drive_id: sr.drive_id,
|
2026-03-04 14:02:15 +01:00
|
|
|
created_at: sr.created_at,
|
|
|
|
|
modified_at: sr.modified_at,
|
|
|
|
|
is_root: sr.is_root,
|
2026-07-19 17:30:22 +00:00
|
|
|
icon_class: intern_display("fas fa-folder"),
|
|
|
|
|
icon_special_class: intern_display("folder-icon"),
|
|
|
|
|
category: intern_display("Folder"),
|
2026-06-19 10:51:13 +02:00
|
|
|
// §14 provenance not selected by search results.
|
|
|
|
|
created_by: None,
|
|
|
|
|
updated_by: None,
|
2026-03-04 14:02:15 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ──────────────────── XML helpers ────────────────────
|
|
|
|
|
|
|
|
|
|
/// Write the opening `<d:multistatus>` element with namespace declarations.
|
|
|
|
|
fn write_multistatus_start<W: std::io::Write>(xml: &mut Writer<W>) -> 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<Body> {
|
|
|
|
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
|
|
|
|
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
|
|
|
|
</d:multistatus>"#;
|
|
|
|
|
|
|
|
|
|
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 `<d:literal>%term%</d:literal>` using quick_xml.
|
|
|
|
|
fn parse_literal(body: &str) -> Option<String> {
|
|
|
|
|
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 `<d:nresults>100</d:nresults>` using quick_xml.
|
|
|
|
|
fn parse_nresults(body: &str) -> Option<usize> {
|
|
|
|
|
let text = xml_extract_text(body, b"nresults")?;
|
|
|
|
|
text.trim().parse::<usize>().ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract the scope href from `<d:href>` inside `<d:scope>` using quick_xml.
|
|
|
|
|
fn parse_scope_href(body: &str) -> Option<String> {
|
|
|
|
|
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<String> {
|
|
|
|
|
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.
|
2026-06-15 22:59:34 +02:00
|
|
|
///
|
|
|
|
|
/// Pulls everything it needs from the `NcSession`: the caller's id (so
|
|
|
|
|
/// `get_folder_by_path` can be user-scoped — post-D0 paths like
|
|
|
|
|
/// `Personal/Docs` are not globally unique), the chroot (provides the
|
|
|
|
|
/// path prefix that `nc_to_internal_path` prepends), and the raw wire
|
|
|
|
|
/// `{user}` segment (bare or `admin~{uuid}`) so we strip the prefix the
|
|
|
|
|
/// NC client actually sent.
|
2026-06-18 23:02:17 +02:00
|
|
|
async fn resolve_scope_folder(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
body: &str,
|
2026-06-15 22:59:34 +02:00
|
|
|
session: &crate::interfaces::nextcloud::session::NcSession,
|
2026-06-18 23:02:17 +02:00
|
|
|
) -> Option<String> {
|
2026-06-15 22:59:34 +02:00
|
|
|
let chroot = session.require_chroot().ok()?;
|
|
|
|
|
let url_user = &session.raw_username;
|
2026-03-04 14:02:15 +01:00
|
|
|
let href = parse_scope_href(body)?;
|
|
|
|
|
|
2026-06-15 22:59:34 +02:00
|
|
|
// The href is typically `/files/{url_user}/subpath` or
|
|
|
|
|
// `/remote.php/dav/files/{url_user}/subpath`. On a multi-drive
|
|
|
|
|
// session the `{url_user}` segment carries the `~{uuid}` marker,
|
|
|
|
|
// so we strip with the composite to find the real subpath. Using
|
|
|
|
|
// `user.username` here would fail to match for non-home drives.
|
|
|
|
|
let subpath = extract_subpath_from_scope(&href, url_user)?;
|
2026-03-04 14:02:15 +01:00
|
|
|
if subpath.is_empty() {
|
|
|
|
|
// Root scope -- no folder_id filter needed.
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let internal_path =
|
2026-06-15 22:59:34 +02:00
|
|
|
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &subpath).ok()?;
|
2026-03-04 14:02:15 +01:00
|
|
|
|
|
|
|
|
let folder_service = &state.applications.folder_service;
|
|
|
|
|
folder_service
|
2026-06-19 07:49:33 +02:00
|
|
|
.get_folder_by_path(&internal_path, chroot.drive_id)
|
2026-03-04 14:02:15 +01:00
|
|
|
.await
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|f| f.id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract the subpath portion from a scope href.
|
|
|
|
|
///
|
|
|
|
|
/// Handles both short form `/files/{user}/sub` and full
|
2026-06-15 22:59:34 +02:00
|
|
|
/// `/remote.php/dav/files/{user}/sub`. `url_user` is the literal URL
|
|
|
|
|
/// `{user}` segment — bare for legacy single-drive sync, composite
|
|
|
|
|
/// `admin~{uuid}` for multi-drive — so this matches whichever shape
|
|
|
|
|
/// the NC client actually sent.
|
|
|
|
|
fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option<String> {
|
2026-03-04 14:02:15 +01:00
|
|
|
let patterns = [
|
2026-06-15 22:59:34 +02:00
|
|
|
format!("/remote.php/dav/files/{}/", url_user),
|
|
|
|
|
format!("/files/{}/", url_user),
|
|
|
|
|
format!("/remote.php/dav/files/{}", url_user),
|
|
|
|
|
format!("/files/{}", url_user),
|
2026-03-04 14:02:15 +01:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for pat in &patterns {
|
|
|
|
|
if let Some(rest) = href.strip_prefix(pat.as_str()) {
|
|
|
|
|
return Some(rest.trim_matches('/').to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 22:52:26 +02:00
|
|
|
/// Strip the caller's chroot prefix from an internal path so the
|
|
|
|
|
/// caller-facing DAV subpath is chroot-relative. Delegates to
|
|
|
|
|
/// `webdav_handler::strip_chroot_prefix` — chroot-aware, multi-segment
|
|
|
|
|
/// safe, and rejects items outside the chroot. Callers must decide
|
|
|
|
|
/// per-response whether an out-of-chroot item is dropped or falls
|
|
|
|
|
/// back to the naive strip.
|
|
|
|
|
///
|
|
|
|
|
/// See `strip_chroot_prefix` for the full contract. The `_prefix`
|
|
|
|
|
/// legacy arg stays for signature stability with the emit helpers.
|
|
|
|
|
fn strip_home_prefix<'a>(
|
|
|
|
|
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
|
|
|
|
path: &'a str,
|
|
|
|
|
_prefix: &str,
|
|
|
|
|
) -> Option<&'a str> {
|
|
|
|
|
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, path)
|
2026-03-04 14:02:15 +01:00
|
|
|
}
|