perf(dav): stream NC PROPFIND in batches; Range + 304 on WebDAV GETs

Two DAV-surface fixes that replicate patterns the codebase already had:

NC PROPFIND (folder case) previously loaded EVERY child via unbounded
list_files/list_folders and serialized the entire multistatus into one
Vec (~2 KB per entry — a 50k-file folder meant ~100 MB of buffer per
request, repeated constantly by sync clients). It now mirrors the native
WebDAV handler's streaming builder: children are fetched in pages of
PROPFIND_BATCH_SIZE (500), each page's favorites and oc:fileids are
resolved with two batch queries, and the XML is yielded chunk by chunk —
memory stays O(batch) and the first byte flows immediately. The
single-file PROPFIND keeps a small buffered variant; the multistatus
opening tag is factored into a shared helper so the namespace set cannot
diverge.

WebDAV GET (native and NC) ignored the Range header and never compared
the ETag it emitted, so mount-style clients (rclone, davfs2, Finder)
re-transferred whole files on every seek, resume, or revalidation. New
shared `interfaces::range_requests` helpers — same semantics as the REST
download endpoint, which now reuses the 304 helper too — give both GETs
If-None-Match → 304, Range → 206/416, and Accept-Ranges advertising.

https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
This commit is contained in:
Claude
2026-06-10 09:27:48 +00:00
parent 8a42b07cbe
commit 1912a17fa2
5 changed files with 368 additions and 186 deletions
+21 -2
View File
@@ -30,6 +30,7 @@ use crate::common::di::AppState;
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::range_requests::{not_modified_response, range_response};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use std::sync::Arc;
@@ -127,7 +128,9 @@ 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;
/// `pub(crate)` so the NextCloud PROPFIND handler streams with the same
/// page size.
pub(crate) const PROPFIND_BATCH_SIZE: i64 = 500;
// ────────────────────────────────────────────────────────────────────────
// Security helpers (Sol.1 — handler-level user extraction & ownership guard)
@@ -767,6 +770,21 @@ async fn handle_get(
f
};
let etag = format!("\"{}\"", file.etag);
// Conditional GET — clients revalidating a cached copy get a 304
// instead of the full body.
if let Some(resp) = not_modified_response(req.headers(), &etag) {
return Ok(resp);
}
// Range Requests — mount-style clients (rclone, davfs2, Finder) read
// by ranges; serve 206/416 instead of re-sending the whole file on
// every seek or resume.
if let Some(resp) = range_response(req.headers(), &file, &etag, file_retrieval_service).await {
return Ok(resp);
}
// Stream file content — constant ~64 KB memory regardless of file size
let stream = file_retrieval_service
.get_file_stream(&file.id)
@@ -778,7 +796,8 @@ async fn handle_get(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.etag))
.header(header::ETAG, etag)
.header(header::ACCEPT_RANGES, "bytes")
.header(
header::LAST_MODIFIED,
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)