perf: round 5 — CalDAV cursor streaming, SPA interning gaps, NC href prefix, per-request micro-allocs

Seven benchmark-gated changes (benches/ROUND5.md; BEFORE/AFTER bench +
equivalence gate each, rollback rule as ROUND2-4 — two intermediate
CalDAV shapes measured worse and were themselves rolled back before
shipping):

- CalDAV whole-calendar responses (REPORT no-range/sync-collection,
  depth-1 collection PROPFIND, .ics GET): buffered double-residency →
  ONE window-ordered scan (MIN(start_time) OVER (PARTITION BY ical_uid))
  streamed through a PG cursor, pages cut at UID boundaries. TTFB
  23.3→11.0 ms (2.1x), peak heap 14.2→8.0 MiB at 4k events / 45→24 MiB
  at 12k, wall +9-15% (documented trade, ZIP-streaming class); both
  multistatus and ICS byte-identical to the buffered output. Rejected
  shapes kept in the doc: per-page GROUP-BY keyset (3-4x wall) and
  per-uid ANY hydration (~20 µs/index descent).
- SPA listing interning gaps: folder/recent/favorites resources handlers
  (and the WebDAV pseudo-root) called raw Arc::from per row for the
  closed display set ROUND3 interned — now intern_display/intern_mime,
  4→0 allocs/row, byte-identical Arc contents.
- NC PROPFIND child hrefs: username + parent path encoded once per
  request instead of per child (543→165 ns/row, 13→4 allocs); native
  WebDAV href drops its intermediate encode String.
- suggest enrichment: entity clone + field re-clones per keystroke row →
  consume + move (166.5→126.8 µs/200 rows, 20→7 allocs/row).
- list_readable_by returns the cache's Arc (246→128 ns warm hit, 4→0
  allocs) — deep Vec clone per DAV-selector request removed.
- CardDAV REPORT: borrowed props, reused href buffer, exact-size etag
  quoting (3.04→2.34 ms per 5k-contact getetag poll).
- Auth span records: user_id.to_string() per request ×3 →
  tracing::field::display.

Checks: cargo fmt, clippy --all-features --all-targets -D warnings,
cargo test --workspace (523 passed). Follow-ups (CardDAV streaming,
&[&str] id batches, ::text UUID casts A/B, share-landing join) recorded
in benches/ROUND5.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
Claude
2026-07-17 15:19:00 +00:00
parent 12dc648cff
commit 63cf6646d0
24 changed files with 2008 additions and 261 deletions
+324 -79
View File
@@ -21,8 +21,9 @@ use axum::{
http::{HeaderName, Request, StatusCode, header},
response::Response,
};
use bytes::Buf;
use bytes::{Buf, Bytes};
use percent_encoding::percent_decode_str;
use quick_xml::Writer;
use std::fmt::Write;
use std::sync::Arc;
@@ -33,7 +34,7 @@ use crate::application::adapters::caldav_adapter::{
use crate::application::adapters::uid_from_multiget_href;
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
use crate::application::dtos::calendar_dto::{
CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto,
CalendarEventDto, CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto,
};
use crate::application::ports::calendar_ports::CalendarUseCase;
use crate::application::services::calendar_service::CalendarService;
@@ -47,6 +48,249 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Prevents OOM/DoS via unbounded body buffering.
const MAX_CALDAV_BODY: usize = 1_048_576;
/// Minimum rows per emitted page for the streaming CalDAV emitters.
/// Pages only cut at UID boundaries (the cursor delivers same-UID rows
/// adjacent), so a master + its exception overrides always land in one
/// chunk and peak memory is one page of DTOs + its XML instead of the
/// whole calendar twice.
const CALDAV_STREAM_PAGE_EVENTS: usize = 500;
/// Streamed multistatus REPORT: header chunk, one chunk per hydrated
/// UID page, footer chunk. Byte-compatible with the buffered
/// `generate_calendar_events_response` output (same bundle order:
/// `(MIN(start_time), uid)` = first appearance in the start_time
/// listing). TTFB becomes the first page instead of the full
/// generation; the whole-calendar DTO Vec is never materialised.
fn build_streaming_report_response(
calendar_service: Arc<CalendarService>,
calendar_id: String,
report: CalDavReportType,
base_href: String,
user_id: uuid::Uuid,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let mut buf = Vec::with_capacity(256);
{
let mut w = Writer::new(&mut buf);
CalDavAdapter::write_caldav_multistatus_start(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
// ONE server-side scan+sort in bundle order streamed through a
// cursor — the same aggregate work the buffered path paid, but
// only a page of rows resident. Pages cut at UID boundaries.
{
use futures::TryStreamExt;
let mut rows = calendar_service
.stream_events_uid_order(&calendar_id, user_id)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut page: Vec<CalendarEventDto> =
Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32);
loop {
let next = rows
.try_next()
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let flush = match &next {
Some(ev) => {
page.len() >= CALDAV_STREAM_PAGE_EVENTS
&& page.last().is_some_and(|p| p.ical_uid != ev.ical_uid)
}
None => !page.is_empty(),
};
if flush {
let mut chunk = Vec::with_capacity(page.len() * 1024 + 128);
{
let mut w = Writer::new(&mut chunk);
CalDavAdapter::write_report_page(&mut w, &page, &report, &base_href)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
page.clear();
yield Bytes::from(chunk);
}
match next {
Some(ev) => page.push(ev),
None => break,
}
}
}
let mut buf = Vec::with_capacity(32);
{
let mut w = Writer::new(&mut buf);
CalDavAdapter::write_caldav_multistatus_end(&mut w)
.map_err(|e| std::io::Error::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) });
Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from_stream(stream))
.unwrap()
}
/// Streamed depth-1 collection PROPFIND: head (multistatus + the
/// calendar's own response), one chunk per hydrated UID page, footer.
#[allow(clippy::too_many_arguments)]
fn build_streaming_collection_propfind(
calendar_service: Arc<CalendarService>,
calendar: crate::application::dtos::calendar_dto::CalendarDto,
propfind_request: PropFindRequest,
calendar_id: String,
base_href: String,
caller_id: String,
user_id: uuid::Uuid,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let mut buf = Vec::with_capacity(2048);
{
let mut w = Writer::new(&mut buf);
CalDavAdapter::write_collection_head(&mut w, &calendar, &propfind_request, &base_href, &caller_id)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
{
use futures::TryStreamExt;
let mut rows = calendar_service
.stream_events_uid_order(&calendar_id, user_id)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut page: Vec<CalendarEventDto> =
Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32);
loop {
let next = rows
.try_next()
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let flush = match &next {
Some(ev) => {
page.len() >= CALDAV_STREAM_PAGE_EVENTS
&& page.last().is_some_and(|p| p.ical_uid != ev.ical_uid)
}
None => !page.is_empty(),
};
if flush {
let mut chunk = Vec::with_capacity(page.len() * 512 + 128);
{
let mut w = Writer::new(&mut chunk);
CalDavAdapter::write_collection_event_page(&mut w, &page, &base_href)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
page.clear();
yield Bytes::from(chunk);
}
match next {
Some(ev) => page.push(ev),
None => break,
}
}
}
let mut buf = Vec::with_capacity(32);
{
let mut w = Writer::new(&mut buf);
CalDavAdapter::write_caldav_multistatus_end(&mut w)
.map_err(|e| std::io::Error::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) });
Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from_stream(stream))
.unwrap()
}
/// Streamed whole-calendar `.ics` GET: VCALENDAR header, one chunk per
/// hydrated UID page (each row's stored VEVENT chunk served verbatim),
/// `END:VCALENDAR` footer.
fn build_streaming_calendar_ics(
calendar_service: Arc<CalendarService>,
calendar_id: String,
calendar_name: String,
calendar_etag: String,
user_id: uuid::Uuid,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let mut head = String::with_capacity(128);
let _ = write!(
head,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n",
calendar_name
);
yield Bytes::from(head);
{
use futures::TryStreamExt;
let mut rows = calendar_service
.stream_events_uid_order(&calendar_id, user_id)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut page: Vec<CalendarEventDto> =
Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32);
loop {
let next = rows
.try_next()
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let flush = match &next {
Some(ev) => {
page.len() >= CALDAV_STREAM_PAGE_EVENTS
&& page.last().is_some_and(|p| p.ical_uid != ev.ical_uid)
}
None => !page.is_empty(),
};
if flush {
let mut chunk = String::with_capacity(page.len() * 384);
for group in group_events_by_uid(&page) {
for event in group {
if let Some(vevent) = extract_vevent_chunk(&event.ical_data) {
chunk.push_str(vevent);
if !chunk.ends_with('\n') {
chunk.push_str("\r\n");
}
}
}
}
page.clear();
yield Bytes::from(chunk);
}
match next {
Some(ev) => page.push(ev),
None => break,
}
}
}
yield Bytes::from_static(b"END:VCALENDAR\r\n");
};
use futures::TryStreamExt;
let stream = stream
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
.header(header::ETAG, format!("\"{}\"", calendar_etag))
.body(Body::from_stream(stream))
.unwrap()
}
/// Creates CalDAV routes with full path prefixes.
///
/// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap.
@@ -320,15 +564,23 @@ async fn handle_propfind(
};
if let Ok(calendar) = calendar_result {
// Valid calendar ID — return calendar collection
let events = if depth != "0" {
calendar_service
.list_events(first_segment, None, None, user.id)
.await
.unwrap_or_default()
} else {
vec![]
};
// Valid calendar ID — return calendar collection.
// Depth-1 streams the event listing page by page
// (whole-calendar responses used to materialise every
// DTO + the full multistatus in RAM); depth-0 has no
// event section and keeps the tiny buffered path.
if depth != "0" {
let base_href = format!("/caldav/{}/", first_segment);
return Ok(build_streaming_collection_propfind(
calendar_service.clone(),
calendar,
propfind_request,
first_segment.to_string(),
base_href,
caller_id.clone(),
user.id,
));
}
let base_href = &format!("/caldav/{}/", first_segment);
let mut response_body = Vec::new();
@@ -336,7 +588,7 @@ async fn handle_propfind(
CalDavAdapter::generate_calendar_collection_propfind(
&mut response_body,
&calendar,
&events,
&[],
&propfind_request,
base_href,
&depth,
@@ -407,14 +659,20 @@ async fn handle_propfind(
.await
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
let events = if depth != "0" {
calendar_service
.list_events(sub_parts[0], None, None, user.id)
.await
.unwrap_or_default()
} else {
vec![]
};
// Same streaming/buffered split as the
// single-segment collection branch above.
if depth != "0" {
let base_href = format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
return Ok(build_streaming_collection_propfind(
calendar_service.clone(),
cal,
propfind_request,
sub_parts[0].to_string(),
base_href,
caller_id.clone(),
user.id,
));
}
let base_href = &format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
let mut response_body = Vec::new();
@@ -422,7 +680,7 @@ async fn handle_propfind(
CalDavAdapter::generate_calendar_collection_propfind(
&mut response_body,
&cal,
&events,
&[],
&propfind_request,
base_href,
&depth,
@@ -500,6 +758,33 @@ async fn handle_report(
return Err(AppError::bad_request("Calendar ID required in path"));
}
// Whole-calendar shapes (no-range calendar-query, sync-collection)
// stream: header + one chunk per hydrated UID page + footer, instead
// of materialising every DTO AND the full multistatus in RAM with
// TTFB = complete generation. Bounded shapes (time-range query,
// multiget) keep the buffered path.
if matches!(
&report,
CalDavReportType::CalendarQuery {
time_range: None,
..
} | CalDavReportType::SyncCollection { .. }
) {
// Surface not-found / authz before committing to a 207 stream.
calendar_service
.get_calendar(calendar_id, user.id)
.await
.map_err(AppError::from)?;
let base_href = format!("/caldav/{}/", calendar_id);
return Ok(build_streaming_report_response(
calendar_service.clone(),
calendar_id.to_string(),
report,
base_href,
user.id,
));
}
let events = match &report {
CalDavReportType::CalendarQuery { time_range, .. } => {
if let Some((start, end)) = time_range {
@@ -508,10 +793,7 @@ async fn handle_report(
.await
.map_err(AppError::from)?
} else {
calendar_service
.list_events(calendar_id, None, None, user.id)
.await
.map_err(AppError::from)?
unreachable!("no-range calendar-query streams above")
}
}
CalDavReportType::CalendarMultiget { hrefs, .. } => {
@@ -528,10 +810,9 @@ async fn handle_report(
.await
.map_err(AppError::from)?
}
CalDavReportType::SyncCollection { .. } => calendar_service
.list_events(calendar_id, None, None, user.id)
.await
.map_err(AppError::from)?,
CalDavReportType::SyncCollection { .. } => {
unreachable!("sync-collection streams above")
}
};
let base_href = &format!("/caldav/{}/", calendar_id);
@@ -686,31 +967,27 @@ async fn handle_get(
let calendar_id = parts[0];
if parts.len() < 2 {
// GET on calendar collection — return all events, folded
// GET on calendar collection — stream all events, folded
// per UID so master + exception overrides live in ONE
// VCALENDAR body per resource (RFC 4791 §4.1 + RFC 5545
// §3.6.1). Serves each row's stored `ical_data` verbatim
// via `bundle_to_calendar_body`; VTIMEZONE / VALARM /
// ATTENDEE / CATEGORIES / X-* survive because we no
// longer regenerate the body from DTO fields.
let events = calendar_service
.list_events(calendar_id, None, None, user.id)
.await
.map_err(AppError::from)?;
// §3.6.1). Each row's stored `ical_data` VEVENT chunk is
// served verbatim; VTIMEZONE / VALARM / ATTENDEE /
// CATEGORIES / X-* survive because the body is never
// regenerated from DTO fields. Streaming (header + one
// chunk per hydrated UID page + footer) replaces the old
// whole-calendar String build.
let calendar = calendar_service
.get_calendar(calendar_id, user.id)
.await
.map_err(AppError::from)?;
let ical = generate_full_calendar_ical(&calendar.name, &events);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
.header(header::ETAG, format!("\"{}\"", calendar.id))
.body(Body::from(ical))
.unwrap())
Ok(build_streaming_calendar_ics(
calendar_service.clone(),
calendar_id.to_string(),
calendar.name,
calendar.id,
user.id,
))
} else {
// GET on individual event resource — fetch ALL rows for
// this UID (master + any exception overrides) and emit
@@ -754,38 +1031,6 @@ async fn handle_get(
}
}
/// Emit a full VCALENDAR body for the entire calendar, with rows
/// grouped by UID so each recurring event's master + exception
/// overrides live under one iCalendar resource. Each row's stored
/// `ical_data` VEVENT chunk is served verbatim.
fn generate_full_calendar_ical(
calendar_name: &str,
events: &[crate::application::dtos::calendar_dto::CalendarEventDto],
) -> String {
// Pre-estimate: ~200 bytes header + ~320 bytes per event.
let mut buf = String::with_capacity(256 + events.len() * 320);
let _ = write!(
buf,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n",
calendar_name
);
// Group + append each row's stored VEVENT chunk. Malformed
// rows are silently skipped (defensive) — the bulk-GET body
// survives the rest.
for group in group_events_by_uid(events) {
for event in group {
if let Some(chunk) = extract_vevent_chunk(&event.ical_data) {
buf.push_str(chunk);
if !buf.ends_with('\n') {
buf.push_str("\r\n");
}
}
}
}
buf.push_str("END:VCALENDAR\r\n");
buf
}
// NOTE: the pre-phase-4 `generate_event_ical` + `write_vevent`
// helpers were removed. They regenerated the response body from
// DTO fields, which (a) silently dropped every property outside
+1 -1
View File
@@ -50,7 +50,7 @@ pub async fn list_drives(
match state.drive_repo.list_readable_by(caller_id).await {
Ok(drives) => {
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
let dtos: Vec<DriveDto> = drives.iter().cloned().map(DriveDto::from).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => {
@@ -10,7 +10,8 @@ use tracing::info;
use utoipa::ToSchema;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display,
intern_mime,
};
use crate::application::dtos::favorites_dto::{
FavoritesResourceItemDto, FavoritesResourcesDto, FavoritesResourcesQuery,
@@ -214,9 +215,9 @@ pub async fn list_favorites_resources(
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: std::sync::Arc::from("fas fa-folder"),
icon_special_class: std::sync::Arc::from("folder-icon"),
category: std::sync::Arc::from("Folder"),
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by the favorites query.
created_by: None,
updated_by: None,
@@ -249,15 +250,15 @@ pub async fn list_favorites_resources(
name: row.name.clone(),
path,
size: size_bytes,
mime_type: std::sync::Arc::from(mime),
mime_type: intern_mime(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: modified_at_u,
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for(
icon_class: intern_display(icon_class_for(&row.name, mime)),
icon_special_class: intern_display(icon_special_class_for(
&row.name, mime,
)),
category: std::sync::Arc::from(category_for(&row.name, mime)),
category: intern_display(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
sort_date: None,
content_hash,
+11 -8
View File
@@ -8,7 +8,8 @@ use std::collections::HashMap;
use std::sync::Arc;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display,
intern_mime,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
@@ -482,9 +483,9 @@ pub async fn list_folder_resources(
created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by the resources query.
created_by: None,
updated_by: None,
@@ -518,13 +519,15 @@ pub async fn list_folder_resources(
name: row.name.clone(),
path: String::new(),
size: size_bytes,
mime_type: Arc::from(mime),
mime_type: intern_mime(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
icon_class: Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: Arc::from(icon_special_class_for(&row.name, mime)),
category: Arc::from(category_for(&row.name, mime)),
icon_class: intern_display(icon_class_for(&row.name, mime)),
icon_special_class: intern_display(icon_special_class_for(
&row.name, mime,
)),
category: intern_display(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
sort_date: None,
content_hash,
@@ -8,7 +8,8 @@ use std::sync::Arc;
use tracing::info;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
category_for, format_file_size, icon_class_for, icon_special_class_for, intern_display,
intern_mime,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
@@ -230,9 +231,9 @@ pub async fn list_recent_resources(
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: std::sync::Arc::from("fas fa-folder"),
icon_special_class: std::sync::Arc::from("folder-icon"),
category: std::sync::Arc::from("Folder"),
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by the recents query.
created_by: None,
updated_by: None,
@@ -263,15 +264,15 @@ pub async fn list_recent_resources(
name: row.name.clone(),
path,
size: size_bytes,
mime_type: std::sync::Arc::from(mime),
mime_type: intern_mime(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: modified_at_u,
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for(
icon_class: intern_display(icon_class_for(&row.name, mime)),
icon_special_class: intern_display(icon_special_class_for(
&row.name, mime,
)),
category: std::sync::Arc::from(category_for(&row.name, mime)),
category: intern_display(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
sort_date: None,
content_hash,
+17 -12
View File
@@ -20,6 +20,7 @@ use uuid::Uuid;
use crate::application::adapters::webdav_adapter::{
LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
};
use crate::application::dtos::display_helpers::intern_display;
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::ports::authorization_ports::AuthorizationEngine;
@@ -65,10 +66,6 @@ const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'@');
/// Percent-encode a single URI path segment (folder/file name).
fn encode_path_segment(segment: &str) -> String {
utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string()
}
/// Percent-encode a full slash-separated path, encoding each segment individually.
pub(crate) fn encode_uri_path(path: &str) -> String {
use std::fmt::Write as _;
@@ -373,14 +370,14 @@ async fn lookup_drive_selector(
.list_readable_by(user_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list drives: {:?}", e)))?;
for d in visible {
for d in visible.iter() {
if let Some(uuid) = uuid_opt
&& d.drive.id == uuid
{
return Ok(d);
return Ok(d.clone());
}
if d.root_folder_name == selector_decoded.as_ref() {
return Ok(d);
return Ok(d.clone());
}
}
Err(AppError::not_found(format!(
@@ -552,9 +549,9 @@ async fn handle_propfind(
created_at: Utc::now().timestamp() as u64,
modified_at: Utc::now().timestamp() as u64,
is_root: true,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
created_by: None,
updated_by: None,
};
@@ -815,7 +812,11 @@ async fn build_streaming_propfind_response(
let mut w = Writer::new(&mut chunk);
for subfolder in batch.iter() {
let child_dead = dead_props_for(&subfolder.id, &subfolder_deads);
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
let href = format!(
"{}{}/",
base_href,
utf8_percent_encode(&subfolder.name, PATH_SEGMENT_ENCODE_SET)
);
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, child_dead, quota)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
@@ -856,7 +857,11 @@ async fn build_streaming_propfind_response(
let mut w = Writer::new(&mut chunk);
for file in batch.iter() {
let child_dead = dead_props_for(&file.id, &file_deads);
let href = format!("{}{}", base_href, encode_path_segment(&file.name));
let href = format!(
"{}{}",
base_href,
utf8_percent_encode(&file.name, PATH_SEGMENT_ENCODE_SET)
);
WebDavAdapter::write_file_entry_with_dead_props(&mut w, file, &propfind_request, &href, child_dead)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}