perf: round 22 — hot-GET HeaderMap borrow, native-WebDAV/CalDAV etag borrowed quotes, FileDto content_hash move, CalendarEvent stamp, ShareItemType case-fold

Benchmark-gated, same rule as ROUND2-21: every change ships with a
BEFORE/AFTER counting-allocator benchmark and a byte/-value equivalence
gate; an AFTER that fails to reduce allocations exits non-zero (rollback).
See benches/ROUND22.md and examples/bench_round22_micro.rs. All arms
no-Postgres.

- H1: the hot GET handlers (get_thumbnail, download_file, list_files_query,
  list_photos, NextCloud preview, public-share download/access) take
  `req: Request` last and read `req.headers()` by borrow instead of axum's
  HeaderMap extractor, whose FromRequestParts impl clones the whole request
  header table just to read 1-3 headers (the ROUND14 §A4 middleware pattern,
  finally propagated to the handlers). 2 -> 0 allocs/req · 9.95x wall.
- W1: native WebDAV write_etag_quoted — the etag emitter for every /webdav/
  PROPFIND row (per file AND per folder, up to 500/page) — emits the quotes
  as borrowed pre-escaped " text events instead of escaping a "{etag}"
  String (the ROUND20 §C1 / ROUND21 §R4 pattern). 3 -> 0 allocs/row.
- C1: CalDAV getetag routed through a shared write_quoted_etag helper across
  all 5 sites (3 per-event + 2 per-calendar); the now-dead etag: &mut String
  buffer threaded through write_event_response/standard/requested props + the
  two per-page buffers removed. 2 -> 0 allocs/row.
- D1: FileDto::from reuses the moved parts.blob_hash instead of cloning it
  via the content_hash() getter (the ROUND19/20 move-not-clone sweep missed
  it — hash/etag are read before into_parts()). Per file row of every
  listing. 1 -> 0 allocs/row.
- E1: CalendarEvent::update_time_range/update_all_day stamp timed
  DTSTART/DTEND via fmt::compact_ical_utc stack render (chrono fallback out
  of range) instead of the %Y%m%dT%H%M%SZ strftime interpreter. 4 -> 0.
- S1: ShareItemType::try_from uses eq_ignore_ascii_case instead of a
  throwaway to_lowercase() String. 1 -> 0 allocs/parse.

Verified: cargo clippy --features bench --all-targets -D warnings clean,
cargo fmt --all --check clean, cargo test --lib --features bench = 529
passed / 0 failed (incl. the OpenAPI-spec-validity test guarding the H1
utoipa-handler signature change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKyQ4AnYtgp1JtjzweyMeo
This commit is contained in:
Claude
2026-07-20 13:48:47 +00:00
parent 4663b06f37
commit 992bdae898
12 changed files with 918 additions and 99 deletions
+37 -37
View File
@@ -17,6 +17,23 @@ use crate::application::adapters::webdav_adapter::{
};
use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto};
/// Emit a WebDAV `getetag` body as `"…"` with the surrounding quotes written as
/// borrowed pre-escaped `&quot;` text events around the escaped etag body.
///
/// Byte-identical to escaping a `"{etag}"` String — `quick_xml`'s
/// `BytesText::new` escapes a literal `"` → `&quot;`, re-allocating an owned
/// `Cow` — but with 0 heap allocs (the NextCloud ROUND20 §C1 / CardDAV
/// ROUND21 §R4 pattern, applied to the CalDAV emitter it missed). The caller
/// writes the surrounding `<D:getetag>…</D:getetag>` tags. Every `etag` body
/// here is a bare `Uuid` (`calendar.id` / `anchor.id`), so the escaped body is
/// itself a borrow — 0 allocs/row.
fn write_quoted_etag<W: Write>(xml_writer: &mut Writer<W>, etag: &str) -> Result<()> {
xml_writer.write_event(Event::Text(BytesText::from_escaped("&quot;")))?;
xml_writer.write_event(Event::Text(BytesText::new(etag)))?;
xml_writer.write_event(Event::Text(BytesText::from_escaped("&quot;")))?;
Ok(())
}
/// Parse a CalDAV `time-range` element's `start` / `end` attribute
/// value into a UTC `DateTime`.
///
@@ -794,9 +811,9 @@ impl CalDavAdapter {
Self::write_lastmodified_text(xml_writer, calendar.updated_at)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
// ETag
// ETag (borrowed pre-escaped quotes, §C1/§R4 — was format! + escape)
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", calendar.id))))?;
write_quoted_etag(xml_writer, &calendar.id)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// Content type for calendar collection
@@ -924,10 +941,7 @@ impl CalDavAdapter {
}
("DAV:", "getetag") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"\"{}\"",
calendar.id
))))?;
write_quoted_etag(xml_writer, &calendar.id)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
}
("DAV:", "getcontenttype") => {
@@ -1117,11 +1131,11 @@ impl CalDavAdapter {
events: &[CalendarEventDto],
base_href: &str,
) -> Result<()> {
// Reused per-event buffers (cleared each iteration) so a whole PROPFIND
// page allocates the href/etag storage once instead of twice per event
// (benches/ROUND14.md §A6).
// Reused per-event href buffer (cleared each iteration) so a whole
// PROPFIND page allocates the href storage once instead of per event
// (benches/ROUND14.md §A6). The etag no longer needs a buffer — it is
// emitted via `write_quoted_etag` (borrowed pre-escaped quotes).
let mut event_href = String::with_capacity(base_href.len() + 48);
let mut etag = String::new();
for bundle in group_events_by_uid(events) {
// The master (sorted first by group_events_by_uid)
// supplies the ETag anchor + getlastmodified. If
@@ -1148,11 +1162,9 @@ impl CalDavAdapter {
// resourcetype (empty for non-collection)
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
// getetag — anchor row's id (reused buffer, benches/ROUND14.md §A6)
// getetag — anchor row's id (borrowed pre-escaped quotes, §C1/§R4)
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
etag.clear();
let _ = std::fmt::Write::write_fmt(&mut etag, format_args!("\"{}\"", anchor.id));
xml_writer.write_event(Event::Text(BytesText::new(&etag)))?;
write_quoted_etag(xml_writer, &anchor.id)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// getcontenttype
@@ -1215,10 +1227,10 @@ impl CalDavAdapter {
CalDavReportType::CalendarMultiget { props, .. } => props,
CalDavReportType::SyncCollection { props, .. } => props,
};
// Reused per-event href + etag buffers for the whole REPORT page
// (benches/ROUND14.md §A6).
// Reused per-event href buffer for the whole REPORT page
// (benches/ROUND14.md §A6). The etag is emitted via `write_quoted_etag`
// (borrowed pre-escaped quotes) and no longer needs a buffer.
let mut href = String::with_capacity(base_href.len() + 48);
let mut etag = String::new();
for bundle in group_events_by_uid(events) {
let anchor = match bundle.first() {
Some(e) => *e,
@@ -1229,7 +1241,7 @@ impl CalDavAdapter {
&mut href,
format_args!("{}{}.ics", base_href, anchor.ical_uid),
);
Self::write_event_response(xml_writer, &bundle, props, &href, &mut etag)?;
Self::write_event_response(xml_writer, &bundle, props, &href)?;
}
Ok(())
}
@@ -1266,7 +1278,6 @@ impl CalDavAdapter {
bundle: &[&CalendarEventDto],
props: &[QualifiedName],
href: &str,
etag: &mut String,
) -> Result<()> {
let anchor = bundle
.first()
@@ -1289,10 +1300,10 @@ impl CalDavAdapter {
// If no specific props requested, return all common ones
if props.is_empty() {
Self::write_event_standard_props(xml_writer, anchor, bundle, etag)?;
Self::write_event_standard_props(xml_writer, anchor, bundle)?;
} else {
// Write specifically requested properties
Self::write_event_requested_props(xml_writer, anchor, bundle, props, etag)?;
Self::write_event_requested_props(xml_writer, anchor, bundle, props)?;
}
// End prop
@@ -1320,22 +1331,17 @@ impl CalDavAdapter {
xml_writer: &mut Writer<W>,
anchor: &CalendarEventDto,
bundle: &[&CalendarEventDto],
etag: &mut String,
) -> Result<()> {
// Common WebDAV properties
// Resource type (empty for non-collection)
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
// ETag anchored on the master (or first exception in
// a master-less bundle — pathological state today).
// Reused buffer (benches/ROUND14.md §A6).
// ETag anchored on the master (or first exception in a master-less
// bundle — pathological state today). Borrowed pre-escaped quotes
// (§C1/§R4), 0 allocs/event.
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
etag.clear();
etag.push('"');
etag.push_str(&anchor.id);
etag.push('"');
xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?;
write_quoted_etag(xml_writer, &anchor.id)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// Content type
@@ -1370,7 +1376,6 @@ impl CalDavAdapter {
anchor: &CalendarEventDto,
bundle: &[&CalendarEventDto],
props: &[QualifiedName],
etag: &mut String,
) -> Result<()> {
for prop in props {
match (prop.namespace.as_str(), prop.name.as_str()) {
@@ -1380,12 +1385,7 @@ impl CalDavAdapter {
}
("DAV:", "getetag") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
// Reused buffer (benches/ROUND14.md §A6).
etag.clear();
etag.push('"');
etag.push_str(&anchor.id);
etag.push('"');
xml_writer.write_event(Event::Text(BytesText::new(etag.as_str())))?;
write_quoted_etag(xml_writer, &anchor.id)?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
}
("DAV:", "getcontenttype") => {
+11 -6
View File
@@ -809,12 +809,17 @@ impl WebDavAdapter {
fn write_etag_quoted<W: Write>(xml_writer: &mut Writer<W>, etag: &str) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
// One exactly-sized allocation instead of format!'s grow-from-empty.
let mut quoted = String::with_capacity(etag.len() + 2);
quoted.push('"');
quoted.push_str(etag);
quoted.push('"');
xml_writer.write_event(Event::Text(BytesText::new(&quoted)))?;
// Borrowed pre-escaped quotes (the ROUND20 §C1 NextCloud / ROUND21 §R4
// CardDAV pattern the native WebDAV adapter never got): `BytesText::new`
// escapes a literal `"` → `&quot;`, re-allocating an owned `Cow`, so the
// old `"{etag}"` String paid TWO allocs/row (the sized buffer + the
// escape). Emit the two quotes as borrowed pre-escaped `&quot;` text
// events around the escaped body — byte-identical output, 0 allocs/row
// on the hottest native-WebDAV PROPFIND path (per file AND per folder,
// up to PROPFIND_BATCH_SIZE=500 rows/page).
xml_writer.write_event(Event::Text(BytesText::from_escaped("&quot;")))?;
xml_writer.write_event(Event::Text(BytesText::new(etag)))?;
xml_writer.write_event(Event::Text(BytesText::from_escaped("&quot;")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
Ok(())
}
+8 -3
View File
@@ -93,10 +93,15 @@ impl From<File> for FileDto {
// already-extracted parts. `content_hash` is just the raw
// blob hash; `etag` is the cache token derived from it.
let etag = file.etag();
let content_hash = file.content_hash().to_string();
// Consume the entity by moving all fields — zero heap allocations
// for id, name, path, folder_id (previously 4× .to_string()).
// for id, name, path, folder_id (previously 4× .to_string()), and now
// for `content_hash` too: `into_parts()` moves `blob_hash` out, so it is
// reused verbatim below instead of cloning it through the
// `content_hash()` getter. The moved `parts.blob_hash` was previously
// dropped unused while the getter clone paid 1 alloc/row on every file
// listing (folder browse, streaming PROPFIND, search/favorites/recent
// hydration). `etag` is still computed first from the live entity.
let parts = file.into_parts();
// Display fields come from closed static tables and MIME values
@@ -123,7 +128,7 @@ impl From<File> for FileDto {
category,
size_formatted,
sort_date: None,
content_hash,
content_hash: parts.blob_hash,
etag,
created_by: parts.created_by,
updated_by: parts.updated_by,
+57 -24
View File
@@ -575,21 +575,38 @@ impl CalendarEvent {
self.end_time = end_time;
self.updated_at = Utc::now();
// Update iCalendar data
let start_str = if self.all_day {
format!("{}T000000Z", start_time.format("%Y%m%d"))
// Update iCalendar data. Timed events stack-render the compact UTC
// stamp via `fmt::compact_ical_utc` (the ROUND19 §V2 pattern: drops
// chrono's `%Y%m%dT%H%M%SZ` strftime interpreter — ~3 → 0 allocs each),
// with chrono kept as the out-of-range fallback. All-day keeps its
// `%Y%m%d` + literal-suffix form. Byte-identical output either way.
let (mut sbuf, mut ebuf) = ([0u8; 16], [0u8; 16]);
let (start_owned, end_owned);
let start_str: &str = if self.all_day {
start_owned = format!("{}T000000Z", start_time.format("%Y%m%d"));
&start_owned
} else if let Some(s) =
crate::common::fmt::compact_ical_utc(&mut sbuf, start_time.timestamp())
{
s
} else {
format!("{}", start_time.format("%Y%m%dT%H%M%SZ"))
start_owned = format!("{}", start_time.format("%Y%m%dT%H%M%SZ"));
&start_owned
};
let end_str: &str = if self.all_day {
end_owned = format!("{}T000000Z", end_time.format("%Y%m%d"));
&end_owned
} else if let Some(e) =
crate::common::fmt::compact_ical_utc(&mut ebuf, end_time.timestamp())
{
e
} else {
end_owned = format!("{}", end_time.format("%Y%m%dT%H%M%SZ"));
&end_owned
};
let end_str = if self.all_day {
format!("{}T000000Z", end_time.format("%Y%m%d"))
} else {
format!("{}", end_time.format("%Y%m%dT%H%M%SZ"))
};
self.update_ical_property("DTSTART", &start_str);
self.update_ical_property("DTEND", &end_str);
self.update_ical_property("DTSTART", start_str);
self.update_ical_property("DTEND", end_str);
Ok(())
}
@@ -603,21 +620,37 @@ impl CalendarEvent {
self.all_day = all_day;
self.updated_at = Utc::now();
// Update iCalendar data
let start_str = if all_day {
format!("VALUE=DATE:{}", self.start_time.format("%Y%m%d"))
// Update iCalendar data. Timed events stack-render the compact UTC
// stamp via `fmt::compact_ical_utc` (drops chrono's `%Y%m%dT%H%M%SZ`
// strftime interpreter — ~3 → 0 allocs each), chrono fallback out of
// range. All-day keeps its `VALUE=DATE:` + `%Y%m%d` form. Byte-identical.
let (mut sbuf, mut ebuf) = ([0u8; 16], [0u8; 16]);
let (start_owned, end_owned);
let start_str: &str = if all_day {
start_owned = format!("VALUE=DATE:{}", self.start_time.format("%Y%m%d"));
&start_owned
} else if let Some(s) =
crate::common::fmt::compact_ical_utc(&mut sbuf, self.start_time.timestamp())
{
s
} else {
format!("{}", self.start_time.format("%Y%m%dT%H%M%SZ"))
start_owned = format!("{}", self.start_time.format("%Y%m%dT%H%M%SZ"));
&start_owned
};
let end_str: &str = if all_day {
end_owned = format!("VALUE=DATE:{}", self.end_time.format("%Y%m%d"));
&end_owned
} else if let Some(e) =
crate::common::fmt::compact_ical_utc(&mut ebuf, self.end_time.timestamp())
{
e
} else {
end_owned = format!("{}", self.end_time.format("%Y%m%dT%H%M%SZ"));
&end_owned
};
let end_str = if all_day {
format!("VALUE=DATE:{}", self.end_time.format("%Y%m%d"))
} else {
format!("{}", self.end_time.format("%Y%m%dT%H%M%SZ"))
};
self.update_ical_property("DTSTART", &start_str);
self.update_ical_property("DTEND", &end_str);
self.update_ical_property("DTSTART", start_str);
self.update_ical_property("DTEND", end_str);
}
/**
+10 -5
View File
@@ -180,13 +180,18 @@ impl TryFrom<&str> for ShareItemType {
type Error = ShareError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"file" => Ok(ShareItemType::File),
"folder" => Ok(ShareItemType::Folder),
_ => Err(ShareError::ValidationError(format!(
// ASCII case-insensitive compare against the two literals instead of a
// throwaway Unicode `to_lowercase()` String — byte-identical acceptance
// for the ASCII targets "file"/"folder" (1 → 0 allocs/call).
if s.eq_ignore_ascii_case("file") {
Ok(ShareItemType::File)
} else if s.eq_ignore_ascii_case("folder") {
Ok(ShareItemType::Folder)
} else {
Err(ShareError::ValidationError(format!(
"Invalid item type: {}",
s
))),
)))
}
}
}
+23 -13
View File
@@ -361,9 +361,9 @@ impl FileHandler {
pub(super) async fn get_thumbnail_impl(
State(state): State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
headers: &HeaderMap,
Path((id, size)): Path<(String, String)>,
) -> impl IntoResponse {
) -> impl IntoResponse + use<> {
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailSize};
// check first that user can access this resource
@@ -665,8 +665,8 @@ impl FileHandler {
auth_user: AuthUser,
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> impl IntoResponse {
headers: &HeaderMap,
) -> impl IntoResponse + use<> {
let retrieval = &state.applications.file_retrieval_service;
// ── Get file metadata (ownership-scoped) ────────────────────────
@@ -705,7 +705,7 @@ impl FileHandler {
let etag = format!("\"{}\"", file_dto.etag);
// ── ETag (304 Not Modified) ──────────────────────────────────
if let Some(resp) = not_modified_response(&headers, &etag) {
if let Some(resp) = not_modified_response(headers, &etag) {
return resp.into_response();
}
@@ -830,9 +830,9 @@ impl FileHandler {
pub(super) async fn list_files_query_impl(
State(state): State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
headers: &HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
) -> impl IntoResponse + use<> {
let folder_id = params.get("folder_id").map(|id| id.as_str());
tracing::info!("API: Listing files with folder_id: {:?}", folder_id);
@@ -1217,10 +1217,14 @@ pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bo
pub async fn list_files_query(
state: State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
query: Query<HashMap<String, String>>,
req: axum::extract::Request,
) -> impl IntoResponse {
FileHandler::list_files_query_impl(state, auth_user, headers, query).await
// Read headers by borrow (`req.headers()`) instead of the `HeaderMap`
// extractor, which clones the whole request header table (~2 allocs) just to
// read one If-None-Match — the ROUND14 §A4 middleware pattern applied to the
// hot listing handler (benches/ROUND22.md §H1).
FileHandler::list_files_query_impl(state, auth_user, req.headers(), query).await
}
#[utoipa::path(
@@ -1299,9 +1303,12 @@ pub async fn download_file(
auth_user: AuthUser,
path: Path<String>,
query: Query<HashMap<String, String>>,
headers: HeaderMap,
req: axum::extract::Request,
) -> impl IntoResponse {
FileHandler::download_file_impl(state, auth_user, path, query, headers).await
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
// full clone — every download AND every media Range seek hit this path
// (benches/ROUND22.md §H1).
FileHandler::download_file_impl(state, auth_user, path, query, req.headers()).await
}
#[utoipa::path(
@@ -1323,10 +1330,13 @@ pub async fn download_file(
pub async fn get_thumbnail(
state: State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
path: Path<(String, String)>,
req: axum::extract::Request,
) -> impl IntoResponse {
FileHandler::get_thumbnail_impl(state, auth_user, headers, path).await
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
// full clone — thumbnails are the highest-frequency GET (one per grid tile),
// and this handler reads only Accept + If-None-Match (benches/ROUND22.md §H1).
FileHandler::get_thumbnail_impl(state, auth_user, req.headers(), path).await
}
#[utoipa::path(
@@ -2,7 +2,7 @@ use axum::{
Json,
body::Body,
extract::{Query, State},
http::{HeaderMap, Response, StatusCode, header},
http::{Response, StatusCode, header},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
@@ -60,9 +60,12 @@ struct PhotoDto {
pub async fn list_photos(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
headers: HeaderMap,
Query(params): Query<PhotosQueryParams>,
req: axum::extract::Request,
) -> impl IntoResponse {
// Borrow headers (`req.headers()`) instead of cloning the whole request
// header table via the `HeaderMap` extractor to read one If-None-Match — the
// gallery open + every pagination page hit this (benches/ROUND22.md §H1).
let caller_id = auth_user.id;
let limit = params.limit.unwrap_or(200).clamp(1, 500);
@@ -88,7 +91,7 @@ pub async fn list_photos(
std::hash::Hash::hash(&count, &mut hasher);
let etag = format!("\"{:x}\"", std::hash::Hasher::finish(&hasher));
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
if let Some(inm) = req.headers().get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
&& client_etag == etag
{
+10 -5
View File
@@ -230,10 +230,12 @@ pub async fn delete_shared_link(
pub async fn access_shared_item(
State(share_use_case): State<Arc<ShareService>>,
Path(token): Path<String>,
headers: HeaderMap,
req: axum::extract::Request,
) -> impl IntoResponse {
// Honour an unlock cookie if one was issued by a prior `/verify` call.
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
// full clone to read the unlock cookie (benches/ROUND22.md §H1).
let unlock_jwt = unlock_jwt_from_headers(req.headers(), &token);
// The access-count increment doesn't gate the fetch — run both
// round-trips concurrently instead of serially (one RTT saved on
@@ -333,8 +335,11 @@ pub async fn verify_shared_item_password(
pub async fn download_shared_file(
State(state): State<Arc<AppState>>,
Path(token): Path<String>,
headers: HeaderMap,
req: axum::extract::Request,
) -> impl IntoResponse {
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
// full clone — the public-share download + Range path (benches/ROUND22.md §H1).
let headers = req.headers();
// 1. Resolve share service
let share_service = match &state.share_service {
Some(s) => s.clone(),
@@ -349,7 +354,7 @@ pub async fn download_shared_file(
};
// 2. Validate the share token (handles expiry + password checks)
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
let unlock_jwt = unlock_jwt_from_headers(headers, &token);
let share_dto = match share_service
.get_shared_link_with_unlock(&token, unlock_jwt.as_deref())
.await
@@ -385,7 +390,7 @@ pub async fn download_shared_file(
&state,
&share_dto.item_id,
share_dto.item_name.as_deref(),
&headers,
headers,
)
.await
}
+3 -3
View File
@@ -5,7 +5,7 @@
use axum::{
body::Body,
extract::{Query, State},
http::{HeaderMap, StatusCode, header},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use serde::Deserialize;
@@ -39,7 +39,7 @@ pub async fn handle_preview(
State(state): State<Arc<AppState>>,
user: AuthUser,
Query(params): Query<PreviewParams>,
headers: HeaderMap,
req: axum::extract::Request,
) -> impl IntoResponse {
// Parse the Nextcloud file ID — the NC app may append an instance suffix
// (e.g. "00000326ocnca"), so strip non-digit characters first.
@@ -155,7 +155,7 @@ pub async fn handle_preview(
e.push('"');
e
};
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
if let Some(inm) = req.headers().get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
&& (client_etag == etag || client_etag == "*")
{