Files
Oxicloud/src/interfaces/api/handlers/recent_handler.rs
T
Claude 63cf6646d0 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
2026-07-17 15:19:00 +00:00

302 lines
11 KiB
Rust

use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
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, intern_display,
intern_mime,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::dtos::recent_dto::{
RecentResourceItemDto, RecentResourcesDto, RecentResourcesQuery,
};
use crate::application::ports::recent_ports::RecentItemsUseCase;
use crate::application::services::recent_service::RecentService;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use uuid::Uuid;
/// Record access to an item
#[utoipa::path(
post,
path = "/api/recent/{item_type}/{item_id}",
params(
("item_type" = String, Path, description = "Item type (file or folder)"),
("item_id" = String, Path, description = "Item ID")
),
responses(
(status = 200, description = "Access recorded"),
(status = 400, description = "Invalid item type")
),
security(("bearerAuth" = [])),
tag = "recent"
)]
pub async fn record_item_access(
State(recent_service): State<Arc<RecentService>>,
auth_user: AuthUser,
Path((item_type, item_id)): Path<(String, Uuid)>,
) -> impl IntoResponse {
let user_id = auth_user.id;
// Validate item type
if item_type != "file" && item_type != "folder" {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Item type must be 'file' or 'folder'"
})),
)
.into_response();
}
match recent_service
.record_item_access(user_id, &item_id.to_string(), &item_type)
.await
{
Ok(_) => {
info!("Recorded access to {} '{}' in recents", item_type, item_id);
(
StatusCode::OK,
Json(serde_json::json!({
"message": "Access recorded successfully"
})),
)
.into_response()
}
// Preserve DomainError→HTTP status mapping — the Round 1
// AuthZ fix relies on the NotFound from `authz.require`
// propagating as 404 (anti-enum), not being masked as 500.
Err(err) => AppError::from(err).into_response(),
}
}
/// Remove an item from recents
#[utoipa::path(
delete,
path = "/api/recent/{item_type}/{item_id}",
params(
("item_type" = String, Path, description = "Item type (file or folder)"),
("item_id" = String, Path, description = "Item ID")
),
responses(
(status = 200, description = "Item removed from recents"),
(status = 404, description = "Item not in recents")
),
security(("bearerAuth" = [])),
tag = "recent"
)]
pub async fn remove_from_recent(
State(recent_service): State<Arc<RecentService>>,
auth_user: AuthUser,
Path((item_type, item_id)): Path<(String, Uuid)>,
) -> impl IntoResponse {
let user_id = auth_user.id;
match recent_service
.remove_from_recent(user_id, &item_id.to_string(), &item_type)
.await
{
Ok(removed) => {
if removed {
info!("Removed {} '{}' from recents", item_type, item_id);
(
StatusCode::OK,
Json(serde_json::json!({
"message": "Item removed from recents"
})),
)
.into_response()
} else {
info!("Item {} '{}' was not in recents", item_type, item_id);
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"message": "Item was not in recents"
})),
)
.into_response()
}
}
// Same rationale as `record_item_access` — preserve the
// DomainError→HTTP mapping instead of collapsing to 500.
Err(err) => AppError::from(err).into_response(),
}
}
/// Clear all recent items
#[utoipa::path(
delete,
path = "/api/recent/clear",
responses(
(status = 200, description = "Recent items cleared")
),
security(("bearerAuth" = [])),
tag = "recent"
)]
pub async fn clear_recent_items(
State(recent_service): State<Arc<RecentService>>,
auth_user: AuthUser,
) -> impl IntoResponse {
let user_id = auth_user.id;
match recent_service.clear_recent_items(user_id).await {
Ok(_) => {
info!("Cleared all recent items for user");
(
StatusCode::OK,
Json(serde_json::json!({
"message": "Recent items cleared successfully"
})),
)
.into_response()
}
// Same rationale as `record_item_access` — preserve the
// DomainError→HTTP mapping instead of collapsing to 500.
Err(err) => AppError::from(err).into_response(),
}
}
/// List recently accessed resources with cursor pagination.
///
/// Sorted by `accessed_at` DESC by default (most recently accessed first).
/// `path` is cleared when the resource is not owned by the requesting user.
#[utoipa::path(
get,
path = "/api/recent/resources",
params(RecentResourcesQuery),
responses(
(status = 200, description = "Paginated list of recently accessed resources",
body = RecentResourcesDto),
(status = 400, description = "Invalid cursor or query parameters"),
),
security(("bearerAuth" = [])),
tag = "recent"
)]
pub async fn list_recent_resources(
State(recent_service): State<Arc<RecentService>>,
auth_user: AuthUser,
Query(q): Query<RecentResourcesQuery>,
) -> impl IntoResponse {
let user_id = auth_user.id;
let order_by = q.order_by.as_deref().unwrap_or("accessed_at").to_owned();
// If a cursor exists, validate that it matches the requested sort/direction.
let cursor = q
.decode_cursor()
.filter(|c| c.order_by == order_by && c.reverse == q.reverse);
let kinds = q.resource_kinds();
match recent_service
.list_resources_paged(
user_id,
q.limit_clamped(),
cursor,
&order_by,
kinds.as_deref(),
q.reverse,
)
.await
{
Ok((rows, next_cursor)) => {
let items: Vec<RecentResourceItemDto> = rows
.into_iter()
.map(|row| {
// Path is only shown to the owner; non-owners see ""
// to avoid leaking another user's folder hierarchy.
let path = if row.is_owner {
row.path.clone().unwrap_or_default()
} else {
String::new()
};
if row.resource_type == "folder" {
let resource_id = row.resource_id.to_string();
let dto = FolderDto {
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
path,
parent_id: row.parent_id.map(|u| u.to_string()),
drive_id: row.drive_id,
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
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,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::Folder,
accessed_at: row.accessed_at,
resource: ResourceContentDto::Folder(dto),
}
} else {
let mime = row
.mime_type
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
// Route ETag through `File::compute_etag` so this
// listing matches GET/HEAD/PROPFIND byte-for-byte
// for the same file.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
path,
size: size_bytes,
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: 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,
etag,
// §14 provenance not selected by the recents query.
created_by: None,
updated_by: None,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::File,
accessed_at: row.accessed_at,
resource: ResourceContentDto::File(dto),
}
}
})
.collect();
(
StatusCode::OK,
Json(RecentResourcesDto::with_cursor(items, next_cursor)),
)
.into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}