Merge branch 'main' into rfc-4331-quota-properties
# Conflicts: # src/interfaces/nextcloud/report_handler.rs # src/interfaces/nextcloud/webdav_handler.rs # tests/api/run.sh
This commit is contained in:
@@ -332,11 +332,30 @@ async fn complete_flow(
|
||||
base_url = %base_url,
|
||||
"Login Flow v2: flow completed successfully"
|
||||
);
|
||||
let nc_url = format!(
|
||||
"nc://login/server:{}&user:{}&password:{}",
|
||||
base_url, login_name, app_password
|
||||
);
|
||||
axum::response::Redirect::to(&nc_url).into_response()
|
||||
// Redirect the browser to a visible success page. NC clients
|
||||
// that use the LFv2 poll endpoint (the standard pattern) have
|
||||
// already received the credentials server-to-server through
|
||||
// `login_flow.complete()` above — they don't need any browser
|
||||
// hand-off.
|
||||
//
|
||||
// We deliberately do NOT redirect to `nc://login/…` here:
|
||||
// 1. Plain browsers can't follow it → the tab looks stuck
|
||||
// on the picker → user clicks Continue again → second
|
||||
// click hits an already-consumed flow token → ends up
|
||||
// on `/nextcloud/error?type=session-expired`.
|
||||
// 2. NC desktop clients that pick it up while their poll
|
||||
// has already succeeded try to complete the flow a
|
||||
// second time, which fails validation ("Impossible de
|
||||
// valider la requête") — the poll session is fine, the
|
||||
// dialog is spurious noise.
|
||||
//
|
||||
// If a client ever needs a frontchannel `nc://` handoff
|
||||
// (older NC releases, mobile), reintroduce the URL as a
|
||||
// client-side-only fragment (`#target=…`) and add a manual
|
||||
// "Open Nextcloud" fallback on the success page. Keep the
|
||||
// credentials out of the query string either way — the query
|
||||
// string reaches server access logs.
|
||||
axum::response::Redirect::to("/nextcloud/success").into_response()
|
||||
} else {
|
||||
tracing::error!(
|
||||
user = %user.username,
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{file_dead_props, folder_dead_props};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
|
||||
@@ -178,14 +179,15 @@ async fn handle_filter_files(
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = file_dead_props(&state, file).await;
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -203,18 +205,19 @@ async fn handle_filter_files(
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = folder_dead_props(&state.webdav_dead_props, folder).await;
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
// 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,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -312,14 +315,15 @@ async fn handle_search(
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = file_dead_props(&state, file).await;
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -338,18 +342,19 @@ async fn handle_search(
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = folder_dead_props(&state.webdav_dead_props, folder).await;
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
// 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,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
@@ -102,8 +102,16 @@ async fn handle_propfind(
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
// Emit hrefs with `session.raw_username` (composite `admin~<uuid>` on
|
||||
// non-home drives), NOT `user.username` (bare `admin`). The
|
||||
// `NcSession` extractor cross-checks the URL `{user}` segment
|
||||
// against `raw_username` and 403s on mismatch (see
|
||||
// `session.rs::from_request_parts`). Emitting the bare form here
|
||||
// would make every follow-up MOVE/DELETE from a non-home client
|
||||
// 403 before the handler runs — the composite-credential Hurl
|
||||
// regression caught this (B5 in `nc_multidrive_move_regression`).
|
||||
let mut buf = Vec::new();
|
||||
write_trashbin_multistatus(&mut buf, &items, &user.username, chroot, file_id_svc)
|
||||
write_trashbin_multistatus(&mut buf, &items, &session.raw_username, chroot, file_id_svc)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
|
||||
@@ -139,8 +147,17 @@ async fn handle_restore(
|
||||
// with 412 — there is no `Overwrite: T` workflow for trash restore in
|
||||
// either Sabre/DAV or the NC desktop client (a live file being
|
||||
// silently replaced by an undeleted one would be a footgun).
|
||||
// Use `session.raw_username` (composite `admin~<drive-uuid>` on
|
||||
// non-home drives) to strip the destination prefix, NOT
|
||||
// `user.username` (bare `admin`). NC clients send `Destination:
|
||||
// /remote.php/dav/files/{raw_username}/…`; passing the bare
|
||||
// username would leave the `~<uuid>/` marker glued to the leading
|
||||
// subpath segment and turn the collision-check into a lookup at
|
||||
// a fabricated path. See `uploads_handler::handle_assemble` for
|
||||
// the same fix in the chunked-upload MOVE.
|
||||
if let Some(dest_header) = dest_header
|
||||
&& let Some(dest_subpath) = extract_nc_subpath_from_dest(&dest_header, &user.username)
|
||||
&& let Some(dest_subpath) =
|
||||
extract_nc_subpath_from_dest(&dest_header, &session.raw_username)
|
||||
{
|
||||
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
@@ -150,7 +150,20 @@ async fn handle_propfind_session(
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?
|
||||
.ok_or_else(|| AppError::not_found("Upload session not found"))?;
|
||||
|
||||
let session_href = format!("/remote.php/dav/uploads/{}/{}/", user.username, upload_id);
|
||||
// Href MUST use `session.raw_username` (composite `admin~<uuid>` on
|
||||
// non-home drives), NOT `user.username` (bare `admin`). The
|
||||
// `NcSession` extractor cross-checks the URL `{user}` segment
|
||||
// against `raw_username` and 403s on mismatch — a composite-cred
|
||||
// client that PROPFINDs, then MOVEs a chunk href back to us, would
|
||||
// otherwise 403 at the extractor before any handler runs. Same
|
||||
// fix shape as `trashbin_handler::handle_propfind` and
|
||||
// `handle_assemble`'s destination-URL parsing. Storage-side keying
|
||||
// stays on `user.username` — upload sessions are per-user, not
|
||||
// per-drive.
|
||||
let session_href = format!(
|
||||
"/remote.php/dav/uploads/{}/{}/",
|
||||
session.raw_username, upload_id
|
||||
);
|
||||
let session_last_modified =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(listing.session_mtime as i64, 0)
|
||||
.unwrap_or_else(chrono::Utc::now)
|
||||
@@ -176,7 +189,7 @@ async fn handle_propfind_session(
|
||||
for chunk in &listing.chunks {
|
||||
let chunk_href = format!(
|
||||
"/remote.php/dav/uploads/{}/{}/{}",
|
||||
user.username, upload_id, chunk.name
|
||||
session.raw_username, upload_id, chunk.name
|
||||
);
|
||||
let chunk_modified = chrono::DateTime::<chrono::Utc>::from_timestamp(chunk.mtime as i64, 0)
|
||||
.unwrap_or_else(chrono::Utc::now)
|
||||
@@ -342,7 +355,18 @@ async fn handle_assemble(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<i64>().ok());
|
||||
|
||||
let dest_subpath = extract_files_subpath(&destination, &user.username)
|
||||
// Strip the destination URL prefix using the SESSION's raw username
|
||||
// (`admin~<drive-uuid>` on non-home drives), NOT `user.username`
|
||||
// (bare `admin`). NC clients send `Destination: /remote.php/dav/files/
|
||||
// {raw_username}/…` — the URL user-segment mirrors the credential
|
||||
// they authenticated with. Passing bare `admin` here strips only
|
||||
// `admin/` from a `admin~<uuid>/…` destination, leaving the tilde
|
||||
// marker glued to the leading path segment; the write then targets
|
||||
// `<drive-root>/~<uuid>/…` and fails with a parent-folder lookup
|
||||
// error. Matches `webdav_handler::handle_move`'s call to
|
||||
// `extract_nc_subpath_from_dest(&destination, url_user)` where
|
||||
// `url_user = &session.raw_username` (webdav_handler.rs:1177).
|
||||
let dest_subpath = extract_files_subpath(&destination, &session.raw_username)
|
||||
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
|
||||
|
||||
// Stream the chunk parts, in order, straight into the CDC chunk store —
|
||||
|
||||
@@ -13,7 +13,9 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
|
||||
use crate::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
|
||||
};
|
||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||
@@ -26,7 +28,10 @@ use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::filename_from_path;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE;
|
||||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{
|
||||
PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||
use crate::interfaces::upload_ingest::ingest_body_to_cas;
|
||||
@@ -373,6 +378,8 @@ async fn handle_propfind(
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
let dead_props = file_dead_props(&state, &file).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
@@ -381,7 +388,7 @@ async fn handle_propfind(
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
&favorite_ids,
|
||||
(&favorite_ids, &dead_props),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
@@ -622,6 +629,12 @@ async fn handle_head(
|
||||
|
||||
// ──────────────────── PROPPATCH ────────────────────
|
||||
|
||||
/// The `oc:favorite` element is live server state routed through the
|
||||
/// favorites service, not a dead property — every other
|
||||
/// namespace/local-name pair PROPPATCH sends is stored verbatim via
|
||||
/// `DeadPropertyStore`.
|
||||
const OC_FAVORITE_NS: &str = "http://owncloud.org/ns";
|
||||
|
||||
async fn handle_proppatch(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
@@ -635,171 +648,139 @@ async fn handle_proppatch(
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
// Resolve the target resource once — needed for two things:
|
||||
// 1. Applying the oc:favorite mutation when the PROPPATCH body
|
||||
// carries one (`item_type` distinguishes file vs folder rows
|
||||
// in the favorites table).
|
||||
// 2. Picking the right `<d:href>` shape in the multi-status
|
||||
// Resolve the target resource — needed for three things:
|
||||
// 1. The dead-property store key is the resource id (folder_id
|
||||
// XOR file_id), so we need a `ResourceRef`.
|
||||
// 2. Applying the oc:favorite mutation (`item_type` distinguishes
|
||||
// file vs folder rows in the favorites table).
|
||||
// 3. Picking the right `<d:href>` shape in the multi-status
|
||||
// response: collection (folder) hrefs MUST end in `/` per
|
||||
// RFC 4918 §5.2 — see `nc_collection_href` for the full
|
||||
// reasoning. Without this distinction the NC desktop client
|
||||
// parser aborted on PROPFIND; PROPPATCH would hit the same
|
||||
// wall the moment the user favourited a folder.
|
||||
// reasoning.
|
||||
//
|
||||
// When the resource is missing we tolerate it for the no-op
|
||||
// PROPPATCH path (no favorite directive in the body) — matches
|
||||
// the prior behaviour. A PROPPATCH that *does* try to set
|
||||
// favorite on a missing resource still returns NotFound.
|
||||
// A missing resource is now always a 404: unlike the previous
|
||||
// favorite-only implementation (which merely re-declared success
|
||||
// without doing anything), this handler performs real writes, so
|
||||
// silently no-opping on a nonexistent path would be a foot-gun —
|
||||
// matches the native `/webdav/` handler's contract.
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
|
||||
// Single-query path resolution — PROPPATCH may target either a
|
||||
// folder or a file. Post-D7 the resolver is drive-scoped, so we
|
||||
// `authz.require(Read, …)` on the returned resource before
|
||||
// reading its type. The favorite mutation below itself doesn't
|
||||
// require additional authz (favorites are per-user; the caller can
|
||||
// favourite any resource they can see).
|
||||
let resource = match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||
Some(ResolvedResource::File(f)) => {
|
||||
let file_uuid =
|
||||
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
Some((f.id, "file"))
|
||||
}
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
Some((folder.id, "folder"))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let is_collection = matches!(resource, Some((_, "folder")));
|
||||
|
||||
// Parse oc:favorite value from PROPPATCH XML.
|
||||
let favorite_value = parse_proppatch_favorite(&body_str);
|
||||
|
||||
if let Some(value) = favorite_value {
|
||||
let Some((item_id, item_type)) = resource else {
|
||||
return Err(AppError::not_found("Resource not found"));
|
||||
let (resource_ref, item_id, item_type, is_collection) =
|
||||
match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||
Some(ResolvedResource::File(file)) => {
|
||||
let id = Uuid::parse_str(&file.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(Subject::User(user.id), Permission::Read, Resource::File(id))
|
||||
.await?;
|
||||
(ResourceRef::File(id), file.id, "file", false)
|
||||
}
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
let id = Uuid::parse_str(&folder.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(id),
|
||||
)
|
||||
.await?;
|
||||
(ResourceRef::Folder(id), folder.id, "folder", true)
|
||||
}
|
||||
None => return Err(AppError::not_found("Resource not found")),
|
||||
};
|
||||
|
||||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
if value == 1 {
|
||||
fav_svc
|
||||
.add_to_favorites(user.id, &item_id, item_type)
|
||||
let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
|
||||
|
||||
let dead_props = &state.webdav_dead_props;
|
||||
let mut results: Vec<(&QualifiedName, bool)> = Vec::new();
|
||||
for op in &ops {
|
||||
let is_favorite =
|
||||
|name: &QualifiedName| name.namespace == OC_FAVORITE_NS && name.name == "favorite";
|
||||
match op {
|
||||
PropPatchOp::Set(pv) if is_favorite(&pv.name) => {
|
||||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
if pv.value.as_deref().map(str::trim) == Some("1") {
|
||||
fav_svc
|
||||
.add_to_favorites(user.id, &item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to add favorite: {e}"))
|
||||
})?;
|
||||
} else {
|
||||
fav_svc
|
||||
.remove_from_favorites(user.id, &item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove favorite: {e}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
results.push((&pv.name, true));
|
||||
}
|
||||
PropPatchOp::Remove(name) if is_favorite(name) => {
|
||||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
fav_svc
|
||||
.remove_from_favorites(user.id, &item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove favorite: {e}"))
|
||||
})?;
|
||||
}
|
||||
results.push((name, true));
|
||||
}
|
||||
PropPatchOp::Set(pv) if is_protected_property(&pv.name) => {
|
||||
results.push((&pv.name, false));
|
||||
}
|
||||
PropPatchOp::Remove(name) if is_protected_property(name) => {
|
||||
results.push((name, false));
|
||||
}
|
||||
PropPatchOp::Set(pv) => {
|
||||
dead_props
|
||||
.set(resource_ref, pv.name.clone(), pv.value.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to add favorite: {}", e))
|
||||
})?;
|
||||
} else {
|
||||
fav_svc
|
||||
.remove_from_favorites(user.id, &item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove favorite: {}", e))
|
||||
AppError::internal_error(format!("Failed to store dead property: {e}"))
|
||||
})?;
|
||||
results.push((&pv.name, true));
|
||||
}
|
||||
PropPatchOp::Remove(name) => {
|
||||
dead_props.remove(resource_ref, name).await.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove dead property: {e}"))
|
||||
})?;
|
||||
results.push((name, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return 207 Multi-Status with success response using quick_xml
|
||||
// for safe escaping. Collection vs file href chosen by resource
|
||||
// type to satisfy the RFC 4918 §5.2 trailing-slash invariant —
|
||||
// see the comment block at the top of this function.
|
||||
// Collection vs file href chosen by resource type to satisfy the
|
||||
// RFC 4918 §5.2 trailing-slash invariant — see the comment block
|
||||
// at the top of this function.
|
||||
let href = if is_collection {
|
||||
nc_collection_href(url_user, subpath)
|
||||
} else {
|
||||
nc_href(url_user, subpath)
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut xml = Writer::new(&mut buf);
|
||||
xml.write_event(Event::Text(BytesText::new(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
|
||||
)))
|
||||
.map_err(|e| AppError::internal_error(format!("XML write failed: {}", e)))?;
|
||||
|
||||
let mut ms = BytesStart::new("d:multistatus");
|
||||
ms.push_attribute(("xmlns:d", "DAV:"));
|
||||
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
|
||||
xml.write_event(Event::Start(ms))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
write_text_element(&mut xml, "d:href", &href)
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:prop")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::Empty(BytesStart::new("oc:favorite")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:prop")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
write_text_element(&mut xml, "d:status", "HTTP/1.1 200 OK")
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
}
|
||||
let mut response_body = Vec::new();
|
||||
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|
||||
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
|
||||
)?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.body(Body::from(response_body))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// Parse the oc:favorite value from a PROPPATCH XML body using quick_xml.
|
||||
fn parse_proppatch_favorite(body: &str) -> Option<u8> {
|
||||
use quick_xml::Reader;
|
||||
|
||||
let mut reader = Reader::from_str(body);
|
||||
let mut inside_favorite = false;
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(ref e)) => {
|
||||
let local = e.local_name();
|
||||
if local.as_ref() == b"favorite" {
|
||||
inside_favorite = true;
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(ref e)) if inside_favorite => {
|
||||
let text = e.decode().ok()?;
|
||||
return text.trim().parse::<u8>().ok();
|
||||
}
|
||||
Ok(Event::End(ref e)) if e.local_name().as_ref() == b"favorite" => {
|
||||
inside_favorite = false;
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ──────────────────── PUT ────────────────────
|
||||
|
||||
/// Strip the optional `W/` weak prefix and surrounding double-quotes
|
||||
@@ -1443,6 +1424,10 @@ fn write_nc_multistatus_open<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(
|
||||
|
||||
/// Generate the multistatus XML for a single-file PROPFIND. The folder
|
||||
/// case streams via [`build_nc_streaming_propfind`] instead.
|
||||
///
|
||||
/// `extras` bundles `(favorite_ids, dead_props)` — both are per-resource
|
||||
/// decorations fetched by the caller — to stay under clippy's
|
||||
/// argument-count lint.
|
||||
async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
writer: W,
|
||||
file: &FileDto,
|
||||
@@ -1450,8 +1435,9 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
username: &str,
|
||||
subpath: &str,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
favorite_ids: &HashSet<String>,
|
||||
extras: (&HashSet<String>, &[(QualifiedName, Option<String>)]),
|
||||
) -> Result<(), String> {
|
||||
let (favorite_ids, dead_props) = extras;
|
||||
let (file_id_map, _) =
|
||||
batch_resolve_ids(file_id_svc, std::slice::from_ref(&file.id), &[]).await;
|
||||
|
||||
@@ -1470,10 +1456,10 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
file_id,
|
||||
oc_id.as_deref(),
|
||||
(file_id, oc_id.as_deref()),
|
||||
username,
|
||||
favorite_ids,
|
||||
dead_props,
|
||||
)?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
@@ -1517,6 +1503,7 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let (_, folder_id_map) =
|
||||
batch_resolve_ids(file_id_svc, &[], std::slice::from_ref(&folder.id)).await;
|
||||
let folder_dead = folder_dead_props(&state.webdav_dead_props, &folder).await;
|
||||
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
{
|
||||
@@ -1525,7 +1512,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_collection_href(&username, &subpath);
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
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(), &username, &folder_favs, quota)
|
||||
write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, quota, &folder_dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
yield Bytes::from(buf);
|
||||
@@ -1554,11 +1541,15 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let file_uuids: Vec<String> = batch.iter().map(|f| f.id.clone()).collect();
|
||||
let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await;
|
||||
let mut file_deads = Vec::with_capacity(batch_len);
|
||||
for file in &batch {
|
||||
file_deads.push(streamed_file_dead_props(&state.webdav_dead_props, file).await);
|
||||
}
|
||||
|
||||
let mut chunk = Vec::with_capacity(batch_len * 1024);
|
||||
{
|
||||
let mut xml = Writer::new(&mut chunk);
|
||||
for file in &batch {
|
||||
for (file, dead) in batch.iter().zip(file_deads.iter()) {
|
||||
let child_sub = if subpath.is_empty() {
|
||||
file.name.clone()
|
||||
} else {
|
||||
@@ -1567,7 +1558,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_href(&username, &child_sub);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
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(), &username, &favs)
|
||||
write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
@@ -1603,11 +1594,15 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let folder_uuids: Vec<String> = result.items.iter().map(|sf| sf.id.clone()).collect();
|
||||
let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await;
|
||||
let mut sub_deads = Vec::with_capacity(result.items.len());
|
||||
for sf in &result.items {
|
||||
sub_deads.push(folder_dead_props(&state.webdav_dead_props, sf).await);
|
||||
}
|
||||
|
||||
let mut chunk = Vec::with_capacity(result.items.len() * 1024);
|
||||
{
|
||||
let mut xml = Writer::new(&mut chunk);
|
||||
for sf in &result.items {
|
||||
for (sf, dead) in result.items.iter().zip(sub_deads.iter()) {
|
||||
let child_sub = if subpath.is_empty() {
|
||||
sf.name.clone()
|
||||
} else {
|
||||
@@ -1616,7 +1611,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_collection_href(&username, &child_sub);
|
||||
let fid = sub_id_map.get(&sf.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(&mut xml, sf, &href, fid, oc_id.as_deref(), &username, &favs, quota)
|
||||
write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, quota, dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
@@ -1651,17 +1646,22 @@ fn build_nc_streaming_propfind(
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `oc_ids` bundles `(file_id, oc_id)` — always fetched and passed
|
||||
/// together (`oc_id` is derived from `file_id`) — to stay under
|
||||
/// clippy's argument-count lint now that `dead_props` is also threaded
|
||||
/// through.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn write_folder_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
href: &str,
|
||||
file_id: Option<i64>,
|
||||
oc_id: Option<&str>,
|
||||
oc_ids: (Option<i64>, Option<&str>),
|
||||
owner: &str,
|
||||
favorite_ids: &HashSet<String>,
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<(), String> {
|
||||
let (file_id, oc_id) = oc_ids;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
@@ -1742,21 +1742,26 @@ pub fn write_folder_response<W: std::io::Write>(
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.xml_err()?;
|
||||
|
||||
WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// See `write_folder_response` for why `(file_id, oc_id)` are bundled
|
||||
/// into `oc_ids`.
|
||||
pub fn write_file_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
file: &FileDto,
|
||||
href: &str,
|
||||
file_id: Option<i64>,
|
||||
oc_id: Option<&str>,
|
||||
oc_ids: (Option<i64>, Option<&str>),
|
||||
owner: &str,
|
||||
favorite_ids: &HashSet<String>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<(), String> {
|
||||
let (file_id, oc_id) = oc_ids;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
@@ -1831,6 +1836,8 @@ pub fn write_file_response<W: std::io::Write>(
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.xml_err()?;
|
||||
|
||||
WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user