Merge pull request #613 from AtalayaLabs/claude/performance-optimization-round-5

perf: round 5 — CalDAV cursor streaming, SPA interning gaps, NC href prefix, per-request micro-allocs
This commit is contained in:
Dionisio Pozo
2026-07-18 10:30:11 +02:00
committed by GitHub
24 changed files with 2008 additions and 261 deletions
+136 -92
View File
@@ -1064,71 +1064,142 @@ impl CalDavAdapter {
// Write the calendar collection itself
Self::write_calendar_response(&mut xml_writer, calendar, request, base_href, caller_id)?;
// If depth > 0, include event resources — folded per UID
// so a recurring event's master + per-instance exception
// overrides share ONE D:response (RFC 4791 §4.1 + RFC
// 5545 §3.6.1). Pre-fix this loop emitted one D:response
// per DB row, and since master + exception share the
// same href (base + uid.ics) clients saw a duplicate
// href and deduped — the exception appeared to have
// vanished.
// If depth > 0, include event resources — see
// `write_collection_event_page`, which the streaming emitter
// reuses page by page.
if depth != "0" {
for bundle in group_events_by_uid(events) {
// The master (sorted first by group_events_by_uid)
// supplies the ETag anchor + getlastmodified. If
// the bundle is all exceptions (no master row),
// fall back to the first exception.
let anchor = match bundle.first() {
Some(e) => *e,
None => continue,
};
let event_href = format!("{}{}.ics", base_href, anchor.ical_uid);
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(&event_href)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
// resourcetype (empty for non-collection)
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
// getetag — anchor row's id
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer
.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// getcontenttype
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
xml_writer.write_event(Event::Text(BytesText::new(
"text/calendar; component=vevent",
)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
// getlastmodified — anchor row's updated_at
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer
.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
}
Self::write_collection_event_page(&mut xml_writer, events, base_href)?;
}
Self::write_caldav_multistatus_end(&mut xml_writer)?;
Ok(())
}
/// Multistatus opening + the calendar collection's own
/// `D:response` — the head of a depth-1 collection PROPFIND. The
/// streaming emitter calls this once, then
/// [`Self::write_collection_event_page`] per hydrated UID page,
/// then [`Self::write_caldav_multistatus_end`].
pub fn write_collection_head<W: Write>(
xml_writer: &mut Writer<W>,
calendar: &CalendarDto,
request: &PropFindRequest,
base_href: &str,
caller_id: &str,
) -> Result<()> {
Self::write_caldav_multistatus_start(xml_writer)?;
Self::write_calendar_response(xml_writer, calendar, request, base_href, caller_id)
}
/// One depth-1 collection page: event resources folded per UID so a
/// recurring master + per-instance exception overrides share ONE
/// `D:response` (RFC 4791 §4.1 + RFC 5545 §3.6.1) — emitting one
/// response per DB row made clients dedupe the shared href and the
/// exception appeared to vanish. Callers guarantee same-UID rows
/// arrive within a single page.
pub fn write_collection_event_page<W: Write>(
xml_writer: &mut Writer<W>,
events: &[CalendarEventDto],
base_href: &str,
) -> Result<()> {
for bundle in group_events_by_uid(events) {
// The master (sorted first by group_events_by_uid)
// supplies the ETag anchor + getlastmodified. If
// the bundle is all exceptions (no master row),
// fall back to the first exception.
let anchor = match bundle.first() {
Some(e) => *e,
None => continue,
};
let event_href = format!("{}{}.ics", base_href, anchor.ical_uid);
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(&event_href)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
// resourcetype (empty for non-collection)
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
// getetag — anchor row's id
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", anchor.id))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// getcontenttype
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
xml_writer.write_event(Event::Text(BytesText::new(
"text/calendar; component=vevent",
)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
// getlastmodified — anchor row's updated_at
xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
xml_writer.write_event(Event::Text(BytesText::new(&anchor.updated_at.to_rfc2822())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
}
Ok(())
}
/// Write the CalDAV `<D:multistatus>` opening tag (DAV + CalDAV +
/// CalendarServer namespaces). Streaming emitters call this once,
/// then [`Self::write_report_page`] per hydrated UID page, then
/// [`Self::write_caldav_multistatus_end`].
pub fn write_caldav_multistatus_start<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
xml_writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([
("xmlns:D", "DAV:"),
("xmlns:C", "urn:ietf:params:xml:ns:caldav"),
("xmlns:CS", "http://calendarserver.org/ns/"),
]),
))?;
Ok(())
}
/// Close the multistatus opened by
/// [`Self::write_caldav_multistatus_start`].
pub fn write_caldav_multistatus_end<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
/// One REPORT page: group `events` per UID and emit one
/// `D:response` per bundle. Callers guarantee same-UID rows arrive
/// within a single page (the uid-keyset pager does).
pub fn write_report_page<W: Write>(
xml_writer: &mut Writer<W>,
events: &[CalendarEventDto],
request: &CalDavReportType,
base_href: &str,
) -> Result<()> {
let props = match request {
CalDavReportType::CalendarQuery { props, .. } => props,
CalDavReportType::CalendarMultiget { props, .. } => props,
CalDavReportType::SyncCollection { props, .. } => props,
};
for bundle in group_events_by_uid(events) {
let anchor = match bundle.first() {
Some(e) => *e,
None => continue,
};
let href = format!("{}{}.ics", base_href, anchor.ical_uid);
Self::write_event_response(xml_writer, &bundle, props, &href)?;
}
Ok(())
}
/// Generate a response for calendar events
pub fn generate_calendar_events_response<W: Write>(
writer: W,
@@ -1138,42 +1209,15 @@ impl CalDavAdapter {
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
// Start multistatus response
xml_writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([
("xmlns:D", "DAV:"),
("xmlns:C", "urn:ietf:params:xml:ns:caldav"),
("xmlns:CS", "http://calendarserver.org/ns/"),
]),
))?;
Self::write_caldav_multistatus_start(&mut xml_writer)?;
// Determine which properties to include based on request type —
// borrowed straight out of the request (the old `clone()` copied
// the whole Vec of owned QualifiedName strings per REPORT).
let props = match request {
CalDavReportType::CalendarQuery { props, .. } => props,
CalDavReportType::CalendarMultiget { props, .. } => props,
CalDavReportType::SyncCollection { props, .. } => props,
};
// Responses folded per UID so a recurring master + exception
// overrides share ONE D:response (RFC 4791 §4.1) — see
// `write_report_page`, which the streaming emitters reuse
// page by page.
Self::write_report_page(&mut xml_writer, events, request, base_href)?;
// Add responses for events — folded per UID so a
// recurring master + per-instance exception overrides
// share ONE D:response with all VEVENTs concatenated
// into the calendar-data payload (RFC 4791 §4.1). Pre-
// fix this loop emitted one D:response per DB row, so
// master + exception carried duplicate hrefs and clients
// deduped, hiding the exception from the resulting sync.
for bundle in group_events_by_uid(events) {
let anchor = match bundle.first() {
Some(e) => *e,
None => continue,
};
let href = format!("{}{}.ics", base_href, anchor.ical_uid);
Self::write_event_response(&mut xml_writer, &bundle, props, &href)?;
}
// End multistatus
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Self::write_caldav_multistatus_end(&mut xml_writer)?;
Ok(())
}
+25 -13
View File
@@ -663,17 +663,27 @@ impl CardDavAdapter {
]),
))?;
// Borrowed straight out of the request — the old `clone()` copied
// the whole Vec of owned QualifiedName strings per REPORT (same
// fix the CalDAV surface got in ROUND4).
let props = match report {
CardDavReportType::AddressbookQuery { props } => props.clone(),
CardDavReportType::AddressbookMultiget { props, .. } => props.clone(),
CardDavReportType::SyncCollection { props, .. } => props.clone(),
CardDavReportType::AddressbookQuery { props } => props,
CardDavReportType::AddressbookMultiget { props, .. } => props,
CardDavReportType::SyncCollection { props, .. } => props,
};
// One reused href buffer for the whole listing instead of a
// fresh String per contact.
let mut href = String::with_capacity(base_href.len() + 48);
for contact in contacts {
let href = format!("{}{}.vcf", base_href, contact.uid);
href.clear();
let _ = std::fmt::Write::write_fmt(
&mut href,
format_args!("{}{}.vcf", base_href, contact.uid),
);
// `write_contact_response` generates the vCard on demand when (and
// only when) address-data is actually requested.
Self::write_contact_response(&mut xml_writer, contact, &props, &href)?;
Self::write_contact_response(&mut xml_writer, contact, props, &href)?;
}
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
@@ -701,10 +711,11 @@ impl CardDavAdapter {
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"\"{}\"",
contact.etag
))))?;
let mut quoted = String::with_capacity(contact.etag.len() + 2);
quoted.push('"');
quoted.push_str(&contact.etag);
quoted.push('"');
xml_writer.write_event(Event::Text(BytesText::new(&quoted)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
@@ -724,10 +735,11 @@ impl CardDavAdapter {
}
("DAV:", "getetag") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"\"{}\"",
contact.etag
))))?;
let mut quoted = String::with_capacity(contact.etag.len() + 2);
quoted.push('"');
quoted.push_str(&contact.etag);
quoted.push('"');
xml_writer.write_event(Event::Text(BytesText::new(&quoted)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
}
("DAV:", "getcontenttype") => {
+16
View File
@@ -116,6 +116,12 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
&self,
calendar_id: &str,
) -> Result<Vec<CalendarEventDto>, DomainError>;
/// Cursor stream over the calendar's events in bundle order (see
/// the repository doc) — feeds the streaming CalDAV emitters.
fn stream_events_uid_order(
&self,
calendar_id: &str,
) -> futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>;
async fn list_events_by_calendar_paginated(
&self,
calendar_id: &str,
@@ -218,6 +224,16 @@ pub trait CalendarUseCase: Send + Sync + 'static {
offset: Option<i64>,
user_id: Uuid,
) -> Result<Vec<CalendarEventDto>, DomainError>;
/// Streaming support: cursor over the calendar's events in bundle
/// order, behind the same Read authz gate as [`Self::list_events`].
async fn stream_events_uid_order(
&self,
calendar_id: &str,
user_id: Uuid,
) -> Result<
futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>,
DomainError,
>;
async fn get_events_in_range(
&self,
calendar_id: &str,
@@ -356,6 +356,28 @@ impl CalendarUseCase for CalendarService {
}
}
async fn stream_events_uid_order(
&self,
calendar_id: &str,
user_id: Uuid,
) -> Result<
futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>,
DomainError,
> {
// Same Read gate as `list_events`, checked ONCE before the
// cursor opens — the stream itself carries no further authz
// (single request, same caller, same resource).
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
let allowed = calendar.is_public
|| self
.has_calendar_perm(calendar_id, user_id, Permission::Read)
.await?;
if !allowed {
return Err(DomainError::not_found("Calendar", calendar_id));
}
Ok(self.calendar_storage.stream_events_uid_order(calendar_id))
}
async fn get_events_in_range(
&self,
calendar_id: &str,
+19 -13
View File
@@ -359,7 +359,7 @@ impl SearchService {
// grants are honoured inline by `storage.caller_group_ids` on
// the SQL side, so no Rust-side subject expansion here.
let accessible_drives: Vec<Uuid> = match drive_repo.list_readable_by(user_id).await {
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
Ok(drives) => drives.iter().map(|d| d.drive.id).collect(),
Err(e) => {
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
return Vec::new();
@@ -529,28 +529,34 @@ impl SearchService {
// Pre-compute once — avoids N heap allocations inside the loops.
let query_lower = query.to_lowercase();
for file in &files {
let file_dto = FileDto::from(file.clone());
// Consume the entities: the old loop deep-cloned every File into
// the DTO conversion and then cloned name/id/path AGAIN into the
// suggestion — 3 field clones + a full entity clone per row on
// an every-keystroke path.
for file in files {
let file_dto = FileDto::from(file);
let score = compute_relevance(&file_dto.name, &query_lower);
let icon_class = get_icon_class(&file_dto.name, &file_dto.mime_type);
let icon_special_class = get_icon_special_class(&file_dto.name, &file_dto.mime_type);
suggestions.push(SearchSuggestionItem {
name: file_dto.name.clone(),
name: file_dto.name,
item_type: "file".to_string(),
id: file_dto.id.clone(),
path: file_dto.path.clone(),
icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type),
icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type),
id: file_dto.id,
path: file_dto.path,
icon_class,
icon_special_class,
relevance_score: score,
});
}
for folder in &folders {
let folder_dto = FolderDto::from(folder.clone());
for folder in folders {
let folder_dto = FolderDto::from(folder);
let score = compute_relevance(&folder_dto.name, &query_lower);
suggestions.push(SearchSuggestionItem {
name: folder_dto.name.clone(),
name: folder_dto.name,
item_type: "folder".to_string(),
id: folder_dto.id.clone(),
path: folder_dto.path.clone(),
id: folder_dto.id,
path: folder_dto.path,
icon_class: "fas fa-folder".to_string(),
icon_special_class: "folder-icon".to_string(),
relevance_score: score,
+1 -1
View File
@@ -801,7 +801,7 @@ impl TrashService {
// role_grants on resource_type='drive', including group-mediated
// grants). Empty set → empty page without a SQL round-trip.
let drive_ids: Vec<Uuid> = match self.drive_repo.list_readable_by(user_id).await {
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
Ok(drives) => drives.iter().map(|d| d.drive.id).collect(),
Err(e) => {
return Err(DomainError::internal_error(
"Trash",
@@ -25,6 +25,18 @@ pub trait CalendarEventRepository: Send + Sync + 'static {
/// Finds a calendar event by its ID
async fn find_event_by_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<CalendarEvent>;
/// Cursor stream over every event of `calendar_id` in bundle order:
/// rows sorted by `(first occurrence per UID, uid, master-first,
/// start_time)` so a recurring master + its exception overrides
/// arrive adjacent and bundles appear in the first-appearance order
/// the buffered `start_time` listing produced. ONE scan+sort on the
/// server; the streaming CalDAV emitters cut pages at UID
/// boundaries so only a page of rows is ever resident.
fn stream_events_uid_order(
&self,
calendar_id: Uuid,
) -> futures::stream::BoxStream<'static, CalendarEventRepositoryResult<CalendarEvent>>;
/// Lists all events in a specific calendar
async fn list_events_by_calendar(
&self,
+5 -1
View File
@@ -172,10 +172,14 @@ pub trait DriveRepository: Send + Sync + 'static {
/// Returns rows in a stable order: default drive first (if any),
/// then by display name. The `/api/drives` handler relies on that
/// order for the picker UI without a follow-up sort.
/// Returned as `Arc<Vec<…>>`: warm hits are a refcount bump straight
/// off the per-user cache instead of a deep clone of every row's
/// Strings — this runs per DAV request with an explicit drive
/// selector.
async fn list_readable_by(
&self,
caller_id: Uuid,
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
) -> Result<std::sync::Arc<Vec<DriveWithRootName>>, DriveRepositoryError>;
/// `true` when the drive holds no live (non-trashed) folders other
/// than its own root and no live files at all. Used by
@@ -447,6 +447,30 @@ impl CalendarStoragePort for CalendarStorageAdapter {
Ok(events.into_iter().map(CalendarEventDto::from).collect())
}
fn stream_events_uid_order(
&self,
calendar_id: &str,
) -> futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>> {
use futures::StreamExt;
let uuid = match Uuid::parse_str(calendar_id) {
Ok(u) => u,
Err(_) => {
return Box::pin(futures::stream::once(async {
Err(DomainError::new(
ErrorKind::InvalidInput,
"Calendar",
"Invalid calendar ID format",
))
}));
}
};
Box::pin(
self.event_repository
.stream_events_uid_order(uuid)
.map(|r| r.map(CalendarEventDto::from)),
)
}
async fn list_events_by_calendar_paginated(
&self,
calendar_id: &str,
@@ -16,6 +16,31 @@ impl CalendarEventPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Shared row → entity mapping (the inline shape every listing
/// method uses, factored for the cursor stream).
fn row_to_event(row: &sqlx::postgres::PgRow) -> CalendarEventRepositoryResult<CalendarEvent> {
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
row.get::<Option<String>, _>("description"),
row.get::<Option<String>, _>("location"),
row.get("start_time"),
row.get("end_time"),
row.get("all_day"),
row.get::<Option<String>, _>("rrule"),
row.get("ical_uid"),
row.get("ical_data"),
row.get("created_at"),
row.get("updated_at"),
)
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
Ok(event)
}
}
impl CalendarEventRepository for CalendarEventPgRepository {
@@ -547,6 +572,57 @@ impl CalendarEventRepository for CalendarEventPgRepository {
Ok(result.rows_affected() as i64)
}
fn stream_events_uid_order(
&self,
calendar_id: Uuid,
) -> futures::stream::BoxStream<'static, CalendarEventRepositoryResult<CalendarEvent>> {
// ONE ordered scan for the whole calendar, served through a PG
// cursor (`fetch`) so only a window of rows is in flight. The
// window function puts every UID's rows adjacent, bundles
// ordered by first occurrence — exactly the first-appearance
// order the buffered `ORDER BY start_time` listing produced
// after grouping — with the master row first inside each UID.
//
// The first streaming shape hydrated pages via
// `ical_uid = ANY(page)`: ~20 µs per index descent made the
// total wall 3-4x the buffered single scan (measured in
// benches/ROUND5.md). This keeps the buffered path's one
// scan+sort while bounding memory to a page.
let pool = self.pool.clone();
let stream: futures::stream::BoxStream<
'static,
CalendarEventRepositoryResult<CalendarEvent>,
> = Box::pin(async_stream::try_stream! {
let mut conn = pool.acquire().await.map_err(|e| {
DomainError::database_error(format!("Failed to acquire connection: {}", e))
})?;
let mut rows = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1
ORDER BY MIN(start_time) OVER (PARTITION BY ical_uid),
ical_uid,
(recurrence_id IS NOT NULL),
start_time
"#,
)
.bind(calendar_id)
.fetch(&mut *conn);
use futures::TryStreamExt;
while let Some(row) = rows.try_next().await.map_err(|e| {
DomainError::database_error(format!("Failed to stream events: {}", e))
})? {
yield Self::row_to_event(&row)?;
}
});
stream
}
async fn list_events_by_calendar_paginated(
&self,
calendar_id: &Uuid,
@@ -621,13 +621,14 @@ impl DriveRepository for DrivePgRepository {
async fn list_readable_by(
&self,
caller_id: Uuid,
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
) -> Result<Arc<Vec<DriveWithRootName>>, DriveRepositoryError> {
// Serve from the per-user cache; concurrent misses for the same
// caller are coalesced into one join (`try_get_with`), and errors
// are never cached. See the `readable_cache` field docs for the
// freshness/invalidation contract.
let cached = self
.readable_cache
// freshness/invalidation contract. The Arc is handed to callers
// directly — a warm hit is a refcount bump, not a deep clone of
// every row's Strings.
self.readable_cache
.try_get_with(caller_id, async move {
self.query_readable_by(caller_id).await.map(Arc::new)
})
@@ -635,8 +636,7 @@ impl DriveRepository for DrivePgRepository {
.map_err(|e: Arc<DriveRepositoryError>| {
Arc::try_unwrap(e)
.unwrap_or_else(|shared| DriveRepositoryError::StorageError(shared.to_string()))
})?;
Ok((*cached).clone())
})
}
async fn list_all(&self) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
+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()))?;
}
+6 -3
View File
@@ -211,7 +211,8 @@ pub async fn auth_middleware(
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string());
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
Err(e) => {
@@ -258,7 +259,8 @@ pub async fn auth_middleware(
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string());
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
Err(e) => {
@@ -323,7 +325,8 @@ pub async fn auth_middleware(
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current().record("user_id", user_id.to_string());
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
LiveRole::Revoked => {
+23 -12
View File
@@ -1536,6 +1536,19 @@ fn build_nc_streaming_propfind(
// ── Children (only if Depth != 0) ────────────────────────────
if depth != "0" {
// Encoded href prefix for every child: username + parent
// path encode ONCE here — the old per-row `nc_href` call
// re-split and re-encoded the constant prefix for each of
// the up-to-500 children of every page.
let child_href_prefix = {
let base = nc_href(&username, &subpath);
if base.ends_with('/') {
base
} else {
format!("{base}/")
}
};
// Files in pages (keyset cursor — O(page) per page instead of
// the quadratic LIMIT/OFFSET walk).
let mut after_name: Option<String> = None;
@@ -1573,12 +1586,12 @@ fn build_nc_streaming_propfind(
let mut xml = Writer::new(&mut chunk);
for file in batch.iter() {
let dead = dead_props_for(&file.id, &file_deads);
let child_sub = if subpath.is_empty() {
file.name.clone()
} else {
format!("{}/{}", subpath.trim_end_matches('/'), file.name)
};
let href = nc_href(&username, &child_sub);
// Only the name varies per row — the encoded
// username + parent prefix is computed once
// outside the loops (the old `nc_href` call
// re-encoded both for every child).
let href =
format!("{}{}", child_href_prefix, urlencoding::encode(&file.name));
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, dead)
@@ -1630,12 +1643,10 @@ fn build_nc_streaming_propfind(
let mut xml = Writer::new(&mut chunk);
for sf in batch.iter() {
let dead = dead_props_for(&sf.id, &sub_deads);
let child_sub = if subpath.is_empty() {
sf.name.clone()
} else {
format!("{}/{}", subpath.trim_end_matches('/'), sf.name)
};
let href = nc_collection_href(&username, &child_sub);
// Collections carry the trailing slash; prefix
// precomputed once like the file loop above.
let href =
format!("{}{}/", child_href_prefix, urlencoding::encode(&sf.name));
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, dead)