Merge pull request #445 from AtalayaLabs/claude/upbeat-wright-btruto

Streaming PROPFIND, range requests, and batch ID resolution
This commit is contained in:
Dionisio Pozo
2026-06-10 13:57:11 +02:00
committed by GitHub
38 changed files with 1467 additions and 664 deletions
+17
View File
@@ -87,6 +87,14 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
) -> Result<CalendarEventDto, DomainError>; ) -> Result<CalendarEventDto, DomainError>;
async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>; async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>;
async fn get_event(&self, event_id: &str) -> Result<CalendarEventDto, DomainError>; async fn get_event(&self, event_id: &str) -> Result<CalendarEventDto, DomainError>;
/// Indexed single-row lookup by iCalendar UID — the CalDAV
/// object-resource paths must use this instead of listing the whole
/// calendar (every row + its `ical_data`) and filtering client-side.
async fn find_event_by_ical_uid(
&self,
calendar_id: &str,
ical_uid: &str,
) -> Result<Option<CalendarEventDto>, DomainError>;
async fn list_events_by_calendar( async fn list_events_by_calendar(
&self, &self,
calendar_id: &str, calendar_id: &str,
@@ -180,6 +188,15 @@ pub trait CalendarUseCase: Send + Sync + 'static {
event_id: &str, event_id: &str,
user_id: Uuid, user_id: Uuid,
) -> Result<CalendarEventDto, DomainError>; ) -> Result<CalendarEventDto, DomainError>;
/// Resolve one event by its iCalendar UID (the identifier CalDAV
/// object resources are addressed by). `Ok(None)` when no event with
/// that UID exists in the calendar.
async fn get_event_by_ical_uid(
&self,
calendar_id: &str,
ical_uid: &str,
user_id: Uuid,
) -> Result<Option<CalendarEventDto>, DomainError>;
async fn list_events( async fn list_events(
&self, &self,
calendar_id: &str, calendar_id: &str,
+11
View File
@@ -71,6 +71,17 @@ pub trait ContactUseCase: Send + Sync + 'static {
async fn delete_contact(&self, contact_id: &str, user_id: Uuid) -> Result<(), DomainError>; async fn delete_contact(&self, contact_id: &str, user_id: Uuid) -> Result<(), DomainError>;
async fn get_contact(&self, contact_id: &str, user_id: Uuid) async fn get_contact(&self, contact_id: &str, user_id: Uuid)
-> Result<ContactDto, DomainError>; -> Result<ContactDto, DomainError>;
/// Resolve one contact by its vCard UID (the identifier CardDAV
/// object resources are addressed by) with an indexed single-row
/// lookup — instead of listing the whole address book (every row
/// with its vCard + JSONB columns) and filtering client-side.
/// `Ok(None)` when no contact with that UID exists in the book.
async fn get_contact_by_uid(
&self,
address_book_id: &str,
uid: &str,
user_id: Uuid,
) -> Result<Option<ContactDto>, DomainError>;
async fn list_contacts( async fn list_contacts(
&self, &self,
address_book_id: &str, address_book_id: &str,
@@ -11,7 +11,7 @@ use crate::common::config::OidcConfig;
use crate::common::errors::{DomainError, ErrorKind}; use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus}; use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
use crate::domain::entities::session::Session; use crate::domain::entities::session::Session;
use crate::domain::entities::user::{User, UserRole}; use crate::domain::entities::user::{User, UserFlags, UserRole};
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository; use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
use crate::infrastructure::repositories::pg::SessionPgRepository; use crate::infrastructure::repositories::pg::SessionPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository; use crate::infrastructure::repositories::pg::UserPgRepository;
@@ -140,8 +140,20 @@ pub struct AuthApplicationService {
/// Magic-link token repository — populated when the magic-link feature /// Magic-link token repository — populated when the magic-link feature
/// is enabled (PR 8+). `None` means redemption endpoints return 503. /// is enabled (PR 8+). `None` means redemption endpoints return 503.
magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>, magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>,
/// Per-user authorization flags (`role` / `is_external` / `active`),
/// consulted by middleware guards on every WebDAV / CalDAV / CardDAV
/// request. The short TTL keeps the "role changes apply without token
/// rotation" property within seconds while removing one DB round-trip
/// per request; the known mutation paths (`change_user_role`,
/// `set_user_active`) also invalidate eagerly.
user_flags_cache: Cache<Uuid, UserFlags>,
} }
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
/// long a role / external / active change can take to be observed by the
/// per-request guards when it bypasses the eager invalidation paths.
const USER_FLAGS_CACHE_TTL: Duration = Duration::from_secs(30);
impl AuthApplicationService { impl AuthApplicationService {
pub fn new( pub fn new(
user_storage: Arc<UserPgRepository>, user_storage: Arc<UserPgRepository>,
@@ -170,6 +182,10 @@ impl AuthApplicationService {
.time_to_live(Duration::from_secs(60)) .time_to_live(Duration::from_secs(60))
.build(), .build(),
magic_link_repo: None, magic_link_repo: None,
user_flags_cache: Cache::builder()
.max_capacity(10_000)
.time_to_live(USER_FLAGS_CACHE_TTL)
.build(),
} }
} }
@@ -1141,6 +1157,24 @@ impl AuthApplicationService {
Ok(UserDto::from(user)) Ok(UserDto::from(user))
} }
/// Cached, image-free lookup of the caller's authorization flags
/// (`role` / `is_external` / `active`). This is the per-request fast
/// path for middleware guards: the full `get_user` row fetch drags the
/// `image` column (a data URI of up to 512 KiB) across the wire, which
/// a sync client issuing hundreds of DAV requests per minute paid on
/// every single one just to read a boolean.
///
/// Staleness is bounded by [`USER_FLAGS_CACHE_TTL`]; role and active
/// changes made through this service invalidate the entry eagerly.
pub async fn get_user_flags(&self, user_id: Uuid) -> Result<UserFlags, DomainError> {
if let Some(flags) = self.user_flags_cache.get(&user_id) {
return Ok(flags);
}
let flags = self.user_storage.get_user_flags(user_id).await?;
self.user_flags_cache.insert(user_id, flags);
Ok(flags)
}
/// Apply a profile update on behalf of the calling user (PR 24). /// Apply a profile update on behalf of the calling user (PR 24).
/// ///
/// Hard rules: /// Hard rules:
@@ -1797,7 +1831,9 @@ impl AuthApplicationService {
pub async fn set_user_active(&self, user_id: Uuid, active: bool) -> Result<(), DomainError> { pub async fn set_user_active(&self, user_id: Uuid, active: bool) -> Result<(), DomainError> {
self.user_storage self.user_storage
.set_user_active_status(user_id, active) .set_user_active_status(user_id, active)
.await .await?;
self.user_flags_cache.invalidate(&user_id);
Ok(())
} }
/// Change user role (admin only) /// Change user role (admin only)
@@ -1809,7 +1845,9 @@ impl AuthApplicationService {
format!("Invalid role: {}. Must be 'admin' or 'user'", role), format!("Invalid role: {}. Must be 'admin' or 'user'", role),
)); ));
} }
self.user_storage.change_role(user_id, role).await self.user_storage.change_role(user_id, role).await?;
self.user_flags_cache.invalidate(&user_id);
Ok(())
} }
/// Update user's storage quota (admin only) /// Update user's storage quota (admin only)
@@ -277,6 +277,29 @@ impl CalendarUseCase for CalendarService {
Ok(event) Ok(event)
} }
async fn get_event_by_ical_uid(
&self,
calendar_id: &str,
ical_uid: &str,
user_id: Uuid,
) -> Result<Option<CalendarEventDto>, DomainError> {
let has_access = self
.calendar_storage
.check_calendar_access(calendar_id, user_id)
.await?;
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
if !has_access && !calendar.is_public {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Calendar",
"You don't have permission to view events in this calendar",
));
}
self.calendar_storage
.find_event_by_ical_uid(calendar_id, ical_uid)
.await
}
async fn list_events( async fn list_events(
&self, &self,
calendar_id: &str, calendar_id: &str,
@@ -780,6 +780,22 @@ impl ContactUseCase for ContactService {
Ok(ContactDto::from(contact)) Ok(ContactDto::from(contact))
} }
async fn get_contact_by_uid(
&self,
address_book_id: &str,
uid: &str,
user_id: Uuid,
) -> Result<Option<ContactDto>, DomainError> {
let id = Uuid::parse_str(address_book_id)
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
// Check if user has access to the address book
self.check_address_book_access(&id, &user_id).await?;
let contact = self.contact_repository.get_contact_by_uid(&id, uid).await?;
Ok(contact.map(ContactDto::from))
}
async fn list_contacts( async fn list_contacts(
&self, &self,
address_book_id: &str, address_book_id: &str,
@@ -1,12 +1,25 @@
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use moka::future::Cache;
use uuid::Uuid;
use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::infrastructure::repositories::pg::NextcloudObjectIdRepository; use crate::infrastructure::repositories::pg::NextcloudObjectIdRepository;
/// Capacity of the in-memory UUID→numeric-id cache. The mapping is immutable
/// once created, so a warm entry never goes stale and eviction only costs a
/// re-query; ~100k entries is a few MB.
const ID_CACHE_CAPACITY: u64 = 100_000;
#[derive(Clone)] #[derive(Clone)]
pub struct NextcloudFileIdService { pub struct NextcloudFileIdService {
repo: Option<Arc<NextcloudObjectIdRepository>>, repo: Option<Arc<NextcloudObjectIdRepository>>,
instance_id: String, instance_id: String,
/// Object-UUID → stable numeric id. `moka` caches are `Arc`-backed, so all
/// clones of the service share one cache and the per-child resolution in a
/// listing costs zero queries once warm.
cache: Cache<Uuid, i64>,
} }
impl NextcloudFileIdService { impl NextcloudFileIdService {
@@ -14,6 +27,7 @@ impl NextcloudFileIdService {
Self { Self {
repo: Some(repo), repo: Some(repo),
instance_id, instance_id,
cache: Cache::new(ID_CACHE_CAPACITY),
} }
} }
@@ -21,29 +35,76 @@ impl NextcloudFileIdService {
Self { Self {
repo: None, repo: None,
instance_id: "ocnca".to_string(), instance_id: "ocnca".to_string(),
cache: Cache::new(ID_CACHE_CAPACITY),
} }
} }
pub async fn get_or_create_file_id(&self, file_id: &str) -> Result<i64> { /// Resolve — creating when absent — stable numeric file IDs for many
let repo = self.repo.as_ref().ok_or_else(|| { /// UUIDs at once. Cache hits cost nothing; the misses are resolved with a
DomainError::internal_error("NextcloudFileId", "Repository not initialized") /// single backing query. The returned map is keyed by the caller's
})?; /// original id strings; unresolvable inputs are simply absent (mirroring
repo.get_or_create("file", file_id).await /// the `.ok()` behaviour the callers relied on).
pub async fn get_or_create_file_ids(
&self,
file_ids: &[String],
) -> Result<HashMap<String, i64>> {
self.get_or_create_many("file", file_ids).await
} }
pub async fn get_or_create_folder_id(&self, folder_id: &str) -> Result<i64> { /// Folder counterpart of [`Self::get_or_create_file_ids`].
let repo = self.repo.as_ref().ok_or_else(|| { pub async fn get_or_create_folder_ids(
&self,
folder_ids: &[String],
) -> Result<HashMap<String, i64>> {
self.get_or_create_many("folder", folder_ids).await
}
async fn get_or_create_many(
&self,
object_type: &str,
raw_ids: &[String],
) -> Result<HashMap<String, i64>> {
let mut result = HashMap::with_capacity(raw_ids.len());
// Parsed-UUID → caller's original string; also dedupes the miss list.
let mut pending: HashMap<Uuid, String> = HashMap::new();
for raw in raw_ids {
let Ok(uuid) = Uuid::parse_str(raw) else {
continue; // Unparseable ids never had a mapping — skip silently.
};
if let Some(id) = self.cache.get(&uuid).await {
result.insert(raw.clone(), id);
} else {
pending.entry(uuid).or_insert_with(|| raw.clone());
}
}
if !pending.is_empty() {
let misses: Vec<Uuid> = pending.keys().copied().collect();
let resolved = self
.repo()?
.get_or_create_many(object_type, &misses)
.await?;
for (uuid, id) in resolved {
self.cache.insert(uuid, id).await;
if let Some(original) = pending.get(&uuid) {
result.insert(original.clone(), id);
}
}
}
Ok(result)
}
fn repo(&self) -> Result<&Arc<NextcloudObjectIdRepository>> {
self.repo.as_ref().ok_or_else(|| {
DomainError::internal_error("NextcloudFileId", "Repository not initialized") DomainError::internal_error("NextcloudFileId", "Repository not initialized")
})?; })
repo.get_or_create("folder", folder_id).await
} }
/// Get the OxiCloud file UUID from a Nextcloud numeric ID. /// Get the OxiCloud file UUID from a Nextcloud numeric ID.
pub async fn get_oxicloud_id(&self, nc_file_id: i64) -> Result<String> { pub async fn get_oxicloud_id(&self, nc_file_id: i64) -> Result<String> {
let repo = self.repo.as_ref().ok_or_else(|| { self.repo()?.get_object_id(nc_file_id, "file").await
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
})?;
repo.get_object_id(nc_file_id, "file").await
} }
pub fn format_oc_id(&self, id: i64) -> String { pub fn format_oc_id(&self, id: i64) -> String {
@@ -59,6 +120,7 @@ impl NextcloudFileIdService {
Self { Self {
repo: None, repo: None,
instance_id: instance_id.to_string(), instance_id: instance_id.to_string(),
cache: Cache::new(ID_CACHE_CAPACITY),
} }
} }
@@ -107,4 +169,25 @@ mod tests {
let svc = NextcloudFileIdService::new_stub(); let svc = NextcloudFileIdService::new_stub();
assert!(svc.ensure_ready().is_err()); assert!(svc.ensure_ready().is_err());
} }
// Empty input resolves to an empty map without ever touching the repo, so
// it succeeds even on the repo-less stub.
#[tokio::test]
async fn test_get_or_create_file_ids_empty_is_noop() {
let svc = NextcloudFileIdService::new_stub();
let map = svc.get_or_create_file_ids(&[]).await.unwrap();
assert!(map.is_empty());
}
// Unparseable ids never had a mapping, so they are skipped before any repo
// call — the stub (no repo) must not error on them.
#[tokio::test]
async fn test_get_or_create_file_ids_skips_unparseable() {
let svc = NextcloudFileIdService::new_stub();
let map = svc
.get_or_create_file_ids(&["not-a-uuid".to_string()])
.await
.unwrap();
assert!(map.is_empty());
}
} }
@@ -70,6 +70,11 @@ use crate::domain::services::authorization::{Resource, Subject};
use crate::infrastructure::repositories::pg::UserPgRepository; use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::interfaces::middleware::rate_limit::RateLimiter; use crate::interfaces::middleware::rate_limit::RateLimiter;
/// Concurrent per-recipient dispatches in flight during a group fan-out.
/// High enough to collapse a 30-member group's serial SMTP latency,
/// low enough not to flood the relay (most reject >10 parallel sessions).
const NOTIFY_DISPATCH_CONCURRENCY: usize = 6;
/// What triggered the notification — purely an audit discriminator. /// What triggered the notification — purely an audit discriminator.
/// `GrantCreated` → fired implicitly when a grant lands; `ManualResend` /// `GrantCreated` → fired implicitly when a grant lands; `ManualResend`
/// → granter explicitly clicked "Notify by email" in My Shares. /// → granter explicitly clicked "Notify by email" in My Shares.
@@ -268,13 +273,22 @@ impl RecipientNotificationService {
); );
} }
let mut outcomes = Vec::with_capacity(members.len()); // SMTP dispatch dominates each iteration (hundreds of ms per
for member in &members { // recipient) and the iterations are independent — coalescing and
let outcome = self // rate-limiting key on (granter, recipient), which is distinct per
.dispatch_to_one_user(granter, member, resource, trigger) // member. Bounded concurrency keeps a 30-member group grant from
// holding the HTTP response for 15+ s of serial sends while still
// capping the pressure on the SMTP relay. `buffered` (not
// `buffer_unordered`) preserves the member order of the outcomes.
use futures::stream::{self, StreamExt};
let outcomes: Vec<NotifyOutcome> = stream::iter(members)
.map(|member| async move {
self.dispatch_to_one_user(granter, &member, resource, trigger)
.await
})
.buffered(NOTIFY_DISPATCH_CONCURRENCY)
.collect()
.await; .await;
outcomes.push(outcome);
}
Ok(NotifyOutcomeSet { outcomes }) Ok(NotifyOutcomeSet { outcomes })
} }
+12
View File
@@ -20,6 +20,18 @@ impl std::fmt::Display for UserRole {
} }
} }
/// Authorization-relevant account flags, fetched without the heavyweight
/// profile columns. The full user row drags `image` along — a data URI of
/// up to 512 KiB — which per-request guards (`require_internal_user`,
/// `require_admin_user`, the NC Basic Auth external check) must never pay
/// for just to read a boolean or a role.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UserFlags {
pub role: UserRole,
pub is_external: bool,
pub active: bool,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct User { pub struct User {
id: Uuid, id: Uuid,
@@ -402,6 +402,26 @@ impl CalendarStoragePort for CalendarStorageAdapter {
Ok(CalendarEventDto::from(event)) Ok(CalendarEventDto::from(event))
} }
async fn find_event_by_ical_uid(
&self,
calendar_id: &str,
ical_uid: &str,
) -> Result<Option<CalendarEventDto>, DomainError> {
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
DomainError::new(
ErrorKind::InvalidInput,
"Calendar",
"Invalid calendar ID format",
)
})?;
let event = self
.event_repository
.find_event_by_ical_uid(&uuid, ical_uid)
.await?;
Ok(event.map(CalendarEventDto::from))
}
async fn list_events_by_calendar( async fn list_events_by_calendar(
&self, &self,
calendar_id: &str, calendar_id: &str,
@@ -712,6 +712,24 @@ impl ContactUseCase for ContactStorageAdapter {
Ok(ContactDto::from(contact)) Ok(ContactDto::from(contact))
} }
async fn get_contact_by_uid(
&self,
address_book_id: &str,
uid: &str,
user_id: Uuid,
) -> Result<Option<ContactDto>, DomainError> {
let uuid = Self::parse_uuid(address_book_id, "AddressBook")?;
// Check read access
self.check_address_book_access(&uuid, user_id).await?;
let contact = self
.contact_repository
.get_contact_by_uid(&uuid, uid)
.await?;
Ok(contact.map(ContactDto::from))
}
async fn list_contacts( async fn list_contacts(
&self, &self,
address_book_id: &str, address_book_id: &str,
@@ -1,5 +1,7 @@
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid;
use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::common::errors::{DomainError, ErrorKind, Result};
@@ -12,29 +14,73 @@ impl NextcloudObjectIdRepository {
Self { pool } Self { pool }
} }
pub async fn get_or_create(&self, object_type: &str, object_id: &str) -> Result<i64> { /// Resolve — creating when absent — stable numeric IDs for a batch of
let row = sqlx::query( /// object UUIDs sharing one `object_type`.
///
/// Two statements instead of one per id: an idempotent bulk insert that
/// leaves existing rows untouched (`ON CONFLICT DO NOTHING` — no row
/// rewrite, no WAL churn, no dead tuples, unlike the former `DO UPDATE`),
/// followed by a single read of every requested mapping. The insert
/// auto-commits before the read, so the read observes both our own rows
/// and any created concurrently. Returns a map keyed by object UUID;
/// unresolvable inputs are simply absent.
pub async fn get_or_create_many(
&self,
object_type: &str,
object_ids: &[Uuid],
) -> Result<HashMap<Uuid, i64>> {
if object_ids.is_empty() {
return Ok(HashMap::new());
}
// 1. Create missing mappings only. `DO NOTHING` skips the write for
// UUIDs that already map, eliminating the per-listing row rewrite.
sqlx::query(
r#" r#"
INSERT INTO storage.nextcloud_object_ids (object_type, object_id) INSERT INTO storage.nextcloud_object_ids (object_type, object_id)
VALUES ($1, $2::uuid) SELECT $1, u FROM unnest($2::uuid[]) AS u
ON CONFLICT (object_type, object_id) ON CONFLICT (object_type, object_id) DO NOTHING
DO UPDATE SET object_id = EXCLUDED.object_id
RETURNING id
"#, "#,
) )
.bind(object_type) .bind(object_type)
.bind(object_id) .bind(object_ids)
.fetch_one(&*self.pool) .execute(&*self.pool)
.await .await
.map_err(|e| { .map_err(|e| {
DomainError::new( DomainError::new(
ErrorKind::DatabaseError, ErrorKind::DatabaseError,
"NextcloudFileId", "NextcloudFileId",
format!("Failed to get/create Nextcloud ID: {}", e), format!("Failed to create Nextcloud IDs: {}", e),
) )
})?; })?;
Ok(row.get::<i64, _>("id")) // 2. Read every requested mapping back in a single round-trip.
let rows = sqlx::query(
r#"
SELECT id, object_id
FROM storage.nextcloud_object_ids
WHERE object_type = $1 AND object_id = ANY($2::uuid[])
"#,
)
.bind(object_type)
.bind(object_ids)
.fetch_all(&*self.pool)
.await
.map_err(|e| {
DomainError::new(
ErrorKind::DatabaseError,
"NextcloudFileId",
format!("Failed to load Nextcloud IDs: {}", e),
)
})?;
let mut map = HashMap::with_capacity(rows.len());
for row in rows {
let object_id: Uuid = row.get("object_id");
let id: i64 = row.get("id");
map.insert(object_id, id);
}
Ok(map)
} }
/// Get the OxiCloud object ID from a Nextcloud numeric ID. /// Get the OxiCloud object ID from a Nextcloud numeric ID.
@@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::application::ports::auth_ports::UserStoragePort; use crate::application::ports::auth_ports::UserStoragePort;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::entities::user::{User, UserRole}; use crate::domain::entities::user::{User, UserFlags, UserRole};
use crate::domain::repositories::user_repository::{ use crate::domain::repositories::user_repository::{
StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult, StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult,
}; };
@@ -51,6 +51,40 @@ impl UserPgRepository {
} }
} }
/// Fetch only the authorization-relevant flags of a user. Not part of
/// the `UserRepository` trait — called directly from
/// `AuthApplicationService::get_user_flags`.
///
/// Deliberately selects three tiny columns instead of the full row:
/// the full-row SELECT includes `image` (a data URI of up to 512 KiB),
/// which per-request middleware guards were paying on every WebDAV /
/// CalDAV / CardDAV request just to read `is_external` or `role`.
pub async fn get_user_flags(&self, id: Uuid) -> UserRepositoryResult<UserFlags> {
let row = sqlx::query(
r#"
SELECT role::text as role_text, is_external, active
FROM auth.users
WHERE id = $1
"#,
)
.bind(id)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
let role = match role_str.as_deref() {
Some("admin") => UserRole::Admin,
_ => UserRole::User,
};
Ok(UserFlags {
role,
is_external: row.get("is_external"),
active: row.get("active"),
})
}
/// Updates a user's profile image (URL or data URI). Not part of the /// Updates a user's profile image (URL or data URI). Not part of the
/// `UserRepository` trait — called directly from `AuthApplicationService`. /// `UserRepository` trait — called directly from `AuthApplicationService`.
pub async fn update_image( pub async fn update_image(
@@ -7,8 +7,11 @@
//! - **Dedicated `rayon` thread pool** for CPU-bound transcoding (never blocks Tokio) //! - **Dedicated `rayon` thread pool** for CPU-bound transcoding (never blocks Tokio)
//! - **`moka` lock-free cache** for hot transcoded images (no write-lock on reads) //! - **`moka` lock-free cache** for hot transcoded images (no write-lock on reads)
//! - Disk cache for persistence across restarts //! - Disk cache for persistence across restarts
//! - Supports JPEG, PNG, GIF → WebP conversion //! - Supports PNG, GIF → WebP conversion (JPEG excluded — the encoder is
//! - Falls back to original if conversion fails or result is larger //! lossless-only, so photos would come out larger; see `can_transcode`)
//! - Falls back to original if conversion fails or result is larger, and
//! remembers that negative verdict (memory sentinel + disk marker) so the
//! decode + encode is never repeated for the same file
use bytes::Bytes; use bytes::Bytes;
use image::ImageFormat; use image::ImageFormat;
@@ -188,12 +191,16 @@ impl ImageTranscodeService {
Ok(()) Ok(())
} }
/// Check if a mime type can be transcoded /// Check if a mime type can be transcoded.
///
/// JPEG is deliberately excluded: the `image` crate's WebP encoder is
/// lossless-only, and losslessly re-encoding an already-lossy photo
/// almost always produces a LARGER file — so every JPEG download paid
/// a full decode + encode (hundreds of ms of CPU) only to discard the
/// result. PNG/GIF → lossless WebP genuinely shrinks. Re-add JPEG only
/// together with a lossy WebP encoder.
pub fn can_transcode(mime_type: &str) -> bool { pub fn can_transcode(mime_type: &str) -> bool {
matches!( matches!(mime_type, "image/png" | "image/gif")
mime_type,
"image/jpeg" | "image/jpg" | "image/png" | "image/gif"
)
} }
/// Check if transcoding should be attempted based on file size and type /// Check if transcoding should be attempted based on file size and type
@@ -216,8 +223,16 @@ impl ImageTranscodeService {
let cache_key = format!("{}:{}", file_id, target_format.extension()); let cache_key = format!("{}:{}", file_id, target_format.extension());
// ── 1. Check moka memory cache (lock-free read) ── // ── 1. Check moka memory cache (lock-free read) ──
// An empty-Bytes entry is the negative sentinel: "transcoding this
// file is not beneficial — serve the original". Without it, every
// GET of such an image repeated the full decode + encode just to
// discard the result again.
if let Some(cached) = self.memory_cache.get(&cache_key).await { if let Some(cached) = self.memory_cache.get(&cache_key).await {
self.stats.cache_hits.fetch_add(1, Ordering::Relaxed); self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
if cached.is_empty() {
tracing::debug!("🔥 Transcode negative cache HIT: {}", file_id);
return Ok((original_content, original_mime.to_string(), false));
}
tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id); tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id);
return Ok((cached, target_format.mime_type().to_string(), true)); return Ok((cached, target_format.mime_type().to_string(), true));
} }
@@ -241,6 +256,15 @@ impl ImageTranscodeService {
} }
} }
// ── 2b. Negative verdict persisted on disk (survives restarts) ──
let skip_marker = self.get_skip_marker_path(file_id, target_format);
if tokio::fs::try_exists(&skip_marker).await.unwrap_or(false) {
self.memory_cache.insert(cache_key, Bytes::new()).await;
self.stats.disk_hits.fetch_add(1, Ordering::Relaxed);
tracing::debug!("💾 Transcode negative disk marker HIT: {}", file_id);
return Ok((original_content, original_mime.to_string(), false));
}
// ── 3. Transcode on dedicated rayon pool (never blocks Tokio) ── // ── 3. Transcode on dedicated rayon pool (never blocks Tokio) ──
let content_for_rayon = original_content.clone(); // O(1) ref-count bump let content_for_rayon = original_content.clone(); // O(1) ref-count bump
let mime_owned = original_mime.to_string(); let mime_owned = original_mime.to_string();
@@ -269,6 +293,20 @@ impl ImageTranscodeService {
original_size, original_size,
transcoded_size transcoded_size
); );
// Remember the negative verdict so the next GET doesn't repeat
// the decode + encode: empty-Bytes sentinel in memory (expires
// with the cache TTL) + zero-byte marker on disk (survives
// restarts; removed by `invalidate` when the file changes).
self.memory_cache.insert(cache_key, Bytes::new()).await;
let marker = self.get_skip_marker_path(file_id, target_format);
tokio::spawn(async move {
if let Some(parent) = marker.parent() {
let _ = fs::create_dir_all(parent).await;
}
if let Err(e) = fs::write(&marker, b"").await {
tracing::warn!("Failed to persist transcode skip marker: {}", e);
}
});
return Ok((original_content, original_mime.to_string(), false)); return Ok((original_content, original_mime.to_string(), false));
} }
@@ -319,6 +357,16 @@ impl ImageTranscodeService {
.join(format!("{}.{}", file_id, format.extension())) .join(format!("{}.{}", file_id, format.extension()))
} }
/// Path of the zero-byte marker recording a negative transcode verdict
/// ("result was not smaller — serve the original").
fn get_skip_marker_path(&self, file_id: &str, format: OutputFormat) -> PathBuf {
self.cache_dir.join(format.extension()).join(format!(
"{}.{}.skip",
file_id,
format.extension()
))
}
/// Invalidate cached transcodes for a file /// Invalidate cached transcodes for a file
pub async fn invalidate(&self, file_id: &str) { pub async fn invalidate(&self, file_id: &str) {
let cache_key = format!("{}:{}", file_id, OutputFormat::WebP.extension()); let cache_key = format!("{}:{}", file_id, OutputFormat::WebP.extension());
@@ -326,6 +374,8 @@ impl ImageTranscodeService {
let cache_path = self.get_cache_path(file_id, OutputFormat::WebP); let cache_path = self.get_cache_path(file_id, OutputFormat::WebP);
let _ = fs::remove_file(&cache_path).await; let _ = fs::remove_file(&cache_path).await;
let skip_marker = self.get_skip_marker_path(file_id, OutputFormat::WebP);
let _ = fs::remove_file(&skip_marker).await;
} }
/// Get transcoding statistics /// Get transcoding statistics
@@ -463,9 +513,10 @@ mod tests {
#[test] #[test]
fn test_can_transcode() { fn test_can_transcode() {
assert!(ImageTranscodeService::can_transcode("image/jpeg"));
assert!(ImageTranscodeService::can_transcode("image/png")); assert!(ImageTranscodeService::can_transcode("image/png"));
assert!(ImageTranscodeService::can_transcode("image/gif")); assert!(ImageTranscodeService::can_transcode("image/gif"));
// JPEG excluded: the lossless-only WebP encoder makes photos LARGER
assert!(!ImageTranscodeService::can_transcode("image/jpeg"));
assert!(!ImageTranscodeService::can_transcode("image/webp")); assert!(!ImageTranscodeService::can_transcode("image/webp"));
assert!(!ImageTranscodeService::can_transcode("image/svg+xml")); assert!(!ImageTranscodeService::can_transcode("image/svg+xml"));
assert!(!ImageTranscodeService::can_transcode("application/pdf")); assert!(!ImageTranscodeService::can_transcode("application/pdf"));
@@ -473,16 +524,22 @@ mod tests {
#[test] #[test]
fn test_should_transcode() { fn test_should_transcode() {
// Small JPEG - yes // Small PNG - yes
assert!(ImageTranscodeService::should_transcode( assert!(ImageTranscodeService::should_transcode(
"image/jpeg", "image/png",
1024 * 1024 1024 * 1024
)); ));
// Large JPEG - no (too big) // Large PNG - no (too big)
assert!(!ImageTranscodeService::should_transcode(
"image/png",
10 * 1024 * 1024
));
// JPEG - no (lossless-only encoder, result would be larger)
assert!(!ImageTranscodeService::should_transcode( assert!(!ImageTranscodeService::should_transcode(
"image/jpeg", "image/jpeg",
10 * 1024 * 1024 1024 * 1024
)); ));
// WebP - no (already optimal) // WebP - no (already optimal)
+1 -4
View File
@@ -290,10 +290,7 @@ pub async fn login(
// same account from a different address (issue #323). The check runs // same account from a different address (issue #323). The check runs
// BEFORE Argon2 to save CPU under brute-force attacks. // BEFORE Argon2 to save CPU under brute-force attacks.
let client_ip = client_ip_from_parts(&headers, Some(peer), false); let client_ip = client_ip_from_parts(&headers, Some(peer), false);
if let Err(lockout_secs) = auth_service if let Err(lockout_secs) = auth_service.login_lockout.check(&dto.username, &client_ip) {
.login_lockout
.check(&dto.username, &client_ip)
{
tracing::warn!( tracing::warn!(
target: "audit", target: "audit",
event = "auth.login", event = "auth.login",
+14 -20
View File
@@ -608,12 +608,13 @@ async fn handle_put(
let ical_uid = extract_uid_from_ical(&ical_data); let ical_uid = extract_uid_from_ical(&ical_data);
// Indexed single-row lookup — listing the whole calendar (every row
// with its ical_data) to find one UID made imports O(N²).
let existing = if let Some(ref uid) = ical_uid { let existing = if let Some(ref uid) = ical_uid {
let events = calendar_service calendar_service
.list_events(calendar_id, None, None, user.id) .get_event_by_ical_uid(calendar_id, uid, user.id)
.await .await
.unwrap_or_default(); .unwrap_or_default()
events.into_iter().find(|e| e.ical_uid == *uid)
} else { } else {
None None
}; };
@@ -704,21 +705,17 @@ async fn handle_get(
.body(Body::from(ical)) .body(Body::from(ical))
.unwrap()) .unwrap())
} else { } else {
// GET on individual event // GET on individual event — indexed lookup by iCalendar UID.
let event_file = parts[1]; let event_file = parts[1];
let ical_uid = event_file.trim_end_matches(".ics"); let ical_uid = event_file.trim_end_matches(".ics");
let events = calendar_service let event = calendar_service
.list_events(calendar_id, None, None, user.id) .get_event_by_ical_uid(calendar_id, ical_uid, user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to look up event: {}", e)))?
let event = events
.iter()
.find(|e| e.ical_uid == ical_uid)
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
let ical = generate_event_ical(event); let ical = generate_event_ical(&event);
Ok(Response::builder() Ok(Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
@@ -813,14 +810,11 @@ async fn handle_delete(
let event_file = parts[1]; let event_file = parts[1];
let ical_uid = event_file.trim_end_matches(".ics"); let ical_uid = event_file.trim_end_matches(".ics");
let events = calendar_service // Indexed lookup by iCalendar UID instead of listing the calendar.
.list_events(calendar_id, None, None, user.id) let event = calendar_service
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to look up event: {}", e)))?
let event = events
.iter()
.find(|e| e.ical_uid == ical_uid)
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
calendar_service calendar_service
+20 -33
View File
@@ -295,19 +295,14 @@ async fn handle_propfind(
.body(Body::from(response_body)) .body(Body::from(response_body))
.unwrap()) .unwrap())
} else { } else {
// Individual contact .vcf // Individual contact .vcf — indexed lookup by vCard UID.
let contact_file = parts[1]; let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf"); let contact_uid = contact_file.trim_end_matches(".vcf");
// Look up by UID across all contacts in this address book let contact = contact_svc
let contacts = contact_svc .get_contact_by_uid(address_book_id, contact_uid, user.id)
.list_contacts(address_book_id, user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
let contact = contacts
.iter()
.find(|c| c.uid == contact_uid)
.ok_or_else(|| { .ok_or_else(|| {
AppError::not_found(format!("Contact not found: {}", contact_uid)) AppError::not_found(format!("Contact not found: {}", contact_uid))
})?; })?;
@@ -322,8 +317,8 @@ async fn handle_propfind(
let mut response_body = Vec::new(); let mut response_body = Vec::new();
CardDavAdapter::generate_contacts_response( CardDavAdapter::generate_contacts_response(
&mut response_body, &mut response_body,
std::slice::from_ref(contact), std::slice::from_ref(&contact),
&[(contact.uid.clone(), contact_to_vcard(contact))], &[(contact.uid.clone(), contact_to_vcard(&contact))],
&report, &report,
base_href, base_href,
) )
@@ -483,13 +478,13 @@ async fn handle_put(
// Extract UID from vCard // Extract UID from vCard
let vcard_uid = extract_uid_from_vcard(&vcard_data); let vcard_uid = extract_uid_from_vcard(&vcard_data);
// Check if contact already exists // Check if contact already exists — indexed single-row lookup
// (listing the whole address book made imports O(N²)).
let existing = if let Some(ref uid) = vcard_uid { let existing = if let Some(ref uid) = vcard_uid {
let contacts = contact_svc contact_svc
.list_contacts(address_book_id, user.id) .get_contact_by_uid(address_book_id, uid, user.id)
.await .await
.unwrap_or_default(); .unwrap_or_default()
contacts.into_iter().find(|c| c.uid == *uid)
} else { } else {
None None
}; };
@@ -579,21 +574,17 @@ async fn handle_get(
.body(Body::from(vcf_data)) .body(Body::from(vcf_data))
.unwrap()) .unwrap())
} else { } else {
// GET on individual contact // GET on individual contact — indexed lookup by vCard UID.
let contact_file = parts[1]; let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf"); let contact_uid = contact_file.trim_end_matches(".vcf");
let contacts = contact_svc let contact = contact_svc
.list_contacts(address_book_id, user.id) .get_contact_by_uid(address_book_id, contact_uid, user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
let contact = contacts
.iter()
.find(|c| c.uid == contact_uid)
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?; .ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
let vcard = contact_to_vcard(contact); let vcard = contact_to_vcard(&contact);
Ok(Response::builder() Ok(Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
@@ -632,18 +623,14 @@ async fn handle_delete(
AppError::internal_error(format!("Failed to delete address book: {}", e)) AppError::internal_error(format!("Failed to delete address book: {}", e))
})?; })?;
} else { } else {
// Delete contact // Delete contact — indexed lookup by vCard UID.
let contact_file = parts[1]; let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf"); let contact_uid = contact_file.trim_end_matches(".vcf");
let contacts = contact_svc let contact = contact_svc
.list_contacts(address_book_id, user.id) .get_contact_by_uid(address_book_id, contact_uid, user.id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
let contact = contacts
.iter()
.find(|c| c.uid == contact_uid)
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?; .ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
contact_svc contact_svc
+3 -10
View File
@@ -20,6 +20,7 @@ use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
use crate::interfaces::range_requests::not_modified_response;
use crate::{application::dtos::file_dto::FileDto, domain::services::authorization::Permission}; use crate::{application::dtos::file_dto::FileDto, domain::services::authorization::Permission};
use std::sync::Arc; use std::sync::Arc;
@@ -600,16 +601,8 @@ impl FileHandler {
let etag = format!("\"{}\"", file_dto.etag); let etag = format!("\"{}\"", file_dto.etag);
// ── ETag (304 Not Modified) ────────────────────────────────── // ── ETag (304 Not Modified) ──────────────────────────────────
if let Some(inm) = headers.get(header::IF_NONE_MATCH) if let Some(resp) = not_modified_response(&headers, &etag) {
&& let Ok(client_etag) = inm.to_str() return resp.into_response();
&& (client_etag == etag || client_etag == "*")
{
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, &etag)
.body(Body::empty())
.unwrap()
.into_response();
} }
// ── Range Requests ─────────────────────────────────────────── // ── Range Requests ───────────────────────────────────────────
+21 -2
View File
@@ -30,6 +30,7 @@ use crate::common::di::AppState;
use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::infrastructure::services::path_resolver_service::ResolvedResource;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::range_requests::{not_modified_response, range_response};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use std::sync::Arc; use std::sync::Arc;
@@ -127,7 +128,9 @@ const MAX_MKCOL_BODY: usize = 4096;
/// Batch size for streaming PROPFIND — files and folders are fetched in pages /// Batch size for streaming PROPFIND — files and folders are fetched in pages
/// of this size to keep memory constant regardless of folder contents. /// of this size to keep memory constant regardless of folder contents.
const PROPFIND_BATCH_SIZE: i64 = 500; /// `pub(crate)` so the NextCloud PROPFIND handler streams with the same
/// page size.
pub(crate) const PROPFIND_BATCH_SIZE: i64 = 500;
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
// Security helpers (Sol.1 — handler-level user extraction & ownership guard) // Security helpers (Sol.1 — handler-level user extraction & ownership guard)
@@ -767,6 +770,21 @@ async fn handle_get(
f f
}; };
let etag = format!("\"{}\"", file.etag);
// Conditional GET — clients revalidating a cached copy get a 304
// instead of the full body.
if let Some(resp) = not_modified_response(req.headers(), &etag) {
return Ok(resp);
}
// Range Requests — mount-style clients (rclone, davfs2, Finder) read
// by ranges; serve 206/416 instead of re-sending the whole file on
// every seek or resume.
if let Some(resp) = range_response(req.headers(), &file, &etag, file_retrieval_service).await {
return Ok(resp);
}
// Stream file content — constant ~64 KB memory regardless of file size // Stream file content — constant ~64 KB memory regardless of file size
let stream = file_retrieval_service let stream = file_retrieval_service
.get_file_stream(&file.id) .get_file_stream(&file.id)
@@ -778,7 +796,8 @@ async fn handle_get(
.status(StatusCode::OK) .status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file.mime_type) .header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, file.size) .header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.etag)) .header(header::ETAG, etag)
.header(header::ACCEPT_RANGES, "bytes")
.header( .header(
header::LAST_MODIFIED, header::LAST_MODIFIED,
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0) chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
+1 -3
View File
@@ -158,9 +158,7 @@ pub fn client_ip_from_parts(
if let Some(peer_addr) = peer { if let Some(peer_addr) = peer {
if is_trusted_proxy(peer_addr.ip()) { if is_trusted_proxy(peer_addr.ip()) {
// Try X-Forwarded-For first (leftmost = original client) // Try X-Forwarded-For first (leftmost = original client)
if let Some(xff) = headers if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
&& let Some(ip) = xff && let Some(ip) = xff
.split(',') .split(',')
.next() .next()
+16 -11
View File
@@ -4,9 +4,11 @@
//! so handlers compose them uniformly as one-liners. They assume the //! so handlers compose them uniformly as one-liners. They assume the
//! caller has already been authenticated by the //! caller has already been authenticated by the
//! [`AuthUser`](super::auth::AuthUser) extractor, and pull the current //! [`AuthUser`](super::auth::AuthUser) extractor, and pull the current
//! user state from the database via `AuthApplicationService` so role / //! user flags via `AuthApplicationService::get_user_flags` — a
//! external-flag changes take effect on the next request without //! lightweight, short-TTL-cached lookup (no `image` column) — so role /
//! waiting for token rotation. //! external-flag changes take effect within seconds without waiting
//! for token rotation, while the hot DAV paths stop paying one full-row
//! DB fetch per request.
//! //!
//! ```ignore //! ```ignore
//! let caller_id = auth_user.id; //! let caller_id = auth_user.id;
@@ -30,6 +32,7 @@ use uuid::Uuid;
use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::auth_application_service::AuthApplicationService;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::domain::entities::user::UserRole;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::middleware::auth::CurrentUser;
@@ -56,8 +59,8 @@ pub async fn require_internal_user(
auth: &AuthApplicationService, auth: &AuthApplicationService,
caller_id: Uuid, caller_id: Uuid,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
match auth.get_user_by_id(caller_id).await { match auth.get_user_flags(caller_id).await {
Ok(dto) if dto.is_external => Err(AppError::new( Ok(flags) if flags.is_external => Err(AppError::new(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"External users cannot access this endpoint", "External users cannot access this endpoint",
"Forbidden", "Forbidden",
@@ -70,9 +73,11 @@ pub async fn require_internal_user(
/// admins, `Err(403)` otherwise. /// admins, `Err(403)` otherwise.
/// ///
/// The check pulls the role from the user record (not from JWT /// The check pulls the role from the user record (not from JWT
/// claims) so a role change takes effect on the next request without /// claims) so a role change takes effect within the flags-cache TTL —
/// waiting for token rotation. Mirrors [`require_internal_user`]'s /// or immediately when changed through `change_user_role`, which
/// shape so handlers compose either of them as a one-liner via `?`. /// invalidates the entry — without waiting for token rotation. Mirrors
/// [`require_internal_user`]'s shape so handlers compose either of
/// them as a one-liner via `?`.
/// ///
/// Use this in handlers that already have an /// Use this in handlers that already have an
/// [`AuthUser`](super::auth::AuthUser) extractor (and thus a validated /// [`AuthUser`](super::auth::AuthUser) extractor (and thus a validated
@@ -82,12 +87,12 @@ pub async fn require_admin_user(
auth: &AuthApplicationService, auth: &AuthApplicationService,
caller_id: Uuid, caller_id: Uuid,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let user = auth let flags = auth
.get_user_by_id(caller_id) .get_user_flags(caller_id)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
if user.role != "admin" { if flags.role != UserRole::Admin {
return Err(AppError::new( return Err(AppError::new(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"Admin access required", "Admin access required",
+1
View File
@@ -2,6 +2,7 @@ pub mod api;
pub mod errors; pub mod errors;
pub mod middleware; pub mod middleware;
pub mod nextcloud; pub mod nextcloud;
pub mod range_requests;
pub mod upload_spool; pub mod upload_spool;
pub mod web; pub mod web;
@@ -64,8 +64,7 @@ pub async fn basic_auth_middleware(
// Check account lockout before attempting password verification (saves CPU). // Check account lockout before attempting password verification (saves CPU).
// The lockout is per (account, IP), see #323 for rationale. // The lockout is per (account, IP), see #323 for rationale.
let client_ip = let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
if let Some(auth_svc) = state.auth_service.as_ref() if let Some(auth_svc) = state.auth_service.as_ref()
&& let Err(secs) = auth_svc.login_lockout.check(&username, &client_ip) && let Err(secs) = auth_svc.login_lockout.check(&username, &client_ip)
{ {
@@ -103,11 +102,11 @@ pub async fn basic_auth_middleware(
// this is the belt-and-braces check in case one slipped // this is the belt-and-braces check in case one slipped
// through (e.g. user later flipped to is_external). // through (e.g. user later flipped to is_external).
if let Some(auth_svc) = state.auth_service.as_ref() if let Some(auth_svc) = state.auth_service.as_ref()
&& let Ok(user) = auth_svc && let Ok(flags) = auth_svc
.auth_application_service .auth_application_service
.get_user_by_id(user_id) .get_user_flags(user_id)
.await .await
&& user.is_external && flags.is_external
{ {
tracing::info!( tracing::info!(
target: "audit", target: "audit",
+13 -5
View File
@@ -5,6 +5,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde_json::json; use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::dtos::search_dto::SearchCriteriaDto;
@@ -384,6 +385,17 @@ pub async fn handle_search(
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids); let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
// Pre-resolve numeric ids for every file result in a single batch query
// (was one INSERT round-trip per result).
let file_uuids: Vec<String> = results.files.iter().map(|f| f.id.clone()).collect();
let file_id_map: HashMap<String, i64> = match file_id_svc {
Some(svc) => svc
.get_or_create_file_ids(&file_uuids)
.await
.unwrap_or_default(),
None => HashMap::new(),
};
let mut entries: Vec<serde_json::Value> = Vec::new(); let mut entries: Vec<serde_json::Value> = Vec::new();
// Map file results // Map file results
@@ -394,11 +406,7 @@ pub async fn handle_search(
.unwrap_or(&file.path); .unwrap_or(&file.path);
let display_path = format!("/{}", display_path); let display_path = format!("/{}", display_path);
let numeric_id = if let Some(svc) = file_id_svc { let numeric_id = file_id_map.get(&file.id).copied();
svc.get_or_create_file_id(&file.id).await.ok()
} else {
None
};
let thumbnail_url = match numeric_id { let thumbnail_url = match numeric_id {
Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid), Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid),
+50 -29
View File
@@ -25,8 +25,7 @@ use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::middleware::auth::CurrentUser;
use crate::interfaces::nextcloud::webdav_handler::{ use crate::interfaces::nextcloud::webdav_handler::{
format_oc_id, nc_href, resolve_file_id, resolve_folder_id, write_file_response, batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
write_folder_response,
}; };
/// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility. /// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility.
@@ -87,26 +86,47 @@ async fn handle_filter_files(
let home_prefix = format!("My Folder - {}/", user.username); let home_prefix = format!("My Folder - {}/", user.username);
// Pass 1: fetch the favorited DTOs (the per-item fetch is a separate
// concern from the oc:fileid resolution batched below).
let mut files: Vec<FileDto> = Vec::new();
let mut folders: Vec<FolderDto> = Vec::new();
for fav in &favorites {
match fav.item_type.as_str() {
"file" => {
if let Ok(f) = file_service.get_file(&fav.item_id).await {
files.push(f);
}
}
"folder" => {
if let Ok(f) = folder_service.get_folder(&fav.item_id).await {
folders.push(f);
}
}
_ => {}
}
}
// Pass 2: resolve every oc:fileid in two batch queries (was one per item).
let file_uuids: Vec<String> = files.iter().map(|f| f.id.clone()).collect();
let folder_uuids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
let (file_id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
// Pass 3: write the multistatus XML (pure synchronous map lookups).
let mut buf = Vec::new(); let mut buf = Vec::new();
{ {
let mut xml = Writer::new(&mut buf); let mut xml = Writer::new(&mut buf);
write_multistatus_start(&mut xml)?; write_multistatus_start(&mut xml)?;
for fav in &favorites { for file in &files {
match fav.item_type.as_str() {
"file" => {
let file = match file_service.get_file(&fav.item_id).await {
Ok(f) => f,
Err(_) => continue, // Deleted or inaccessible -- skip.
};
let subpath = strip_home_prefix(&file.path, &home_prefix); let subpath = strip_home_prefix(&file.path, &home_prefix);
let href = nc_href(&user.username, subpath); let href = nc_href(&user.username, subpath);
let fid = resolve_file_id(file_id_svc, &file.id).await; let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_file_response( write_file_response(
&mut xml, &mut xml,
&file, file,
&href, &href,
fid, fid,
oc_id.as_deref(), oc_id.as_deref(),
@@ -115,18 +135,15 @@ async fn handle_filter_files(
) )
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
} }
"folder" => {
let folder = match folder_service.get_folder(&fav.item_id).await { for folder in &folders {
Ok(f) => f,
Err(_) => continue,
};
let subpath = strip_home_prefix(&folder.path, &home_prefix); let subpath = strip_home_prefix(&folder.path, &home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath)); let href = format!("{}/", nc_href(&user.username, subpath));
let fid = resolve_folder_id(file_id_svc, &folder.id).await; let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response( write_folder_response(
&mut xml, &mut xml,
&folder, folder,
&href, &href,
fid, fid,
oc_id.as_deref(), oc_id.as_deref(),
@@ -135,9 +152,6 @@ async fn handle_filter_files(
) )
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
} }
_ => continue,
}
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
@@ -192,6 +206,15 @@ async fn handle_search(
// No favorite checking for search results -- pass an empty set. // No favorite checking for search results -- pass an empty set.
let favorite_ids: HashSet<String> = HashSet::new(); let favorite_ids: HashSet<String> = HashSet::new();
// Materialize DTOs, then resolve every oc:fileid in two batch queries
// (was one INSERT round-trip per result).
let files: Vec<FileDto> = results.files.iter().map(file_dto_from_search).collect();
let folders: Vec<FolderDto> = results.folders.iter().map(folder_dto_from_search).collect();
let file_uuids: Vec<String> = files.iter().map(|f| f.id.clone()).collect();
let folder_uuids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
let (file_id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
let mut buf = Vec::new(); let mut buf = Vec::new();
{ {
let mut xml = Writer::new(&mut buf); let mut xml = Writer::new(&mut buf);
@@ -199,15 +222,14 @@ async fn handle_search(
write_multistatus_start(&mut xml)?; write_multistatus_start(&mut xml)?;
// Files. // Files.
for fr in &results.files { for file in &files {
let file = file_dto_from_search(fr);
let subpath = strip_home_prefix(&file.path, &home_prefix); let subpath = strip_home_prefix(&file.path, &home_prefix);
let href = nc_href(&user.username, subpath); let href = nc_href(&user.username, subpath);
let fid = resolve_file_id(file_id_svc, &file.id).await; let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_file_response( write_file_response(
&mut xml, &mut xml,
&file, file,
&href, &href,
fid, fid,
oc_id.as_deref(), oc_id.as_deref(),
@@ -218,15 +240,14 @@ async fn handle_search(
} }
// Folders. // Folders.
for sr in &results.folders { for folder in &folders {
let folder = folder_dto_from_search(sr);
let subpath = strip_home_prefix(&folder.path, &home_prefix); let subpath = strip_home_prefix(&folder.path, &home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath)); let href = format!("{}/", nc_href(&user.username, subpath));
let fid = resolve_folder_id(file_id_svc, &folder.id).await; let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response( write_folder_response(
&mut xml, &mut xml,
&folder, folder,
&href, &href,
fid, fid,
oc_id.as_deref(), oc_id.as_deref(),
+23 -9
View File
@@ -14,7 +14,7 @@ use crate::common::di::AppState;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::nextcloud::webdav_handler::{ use crate::interfaces::nextcloud::webdav_handler::{
format_oc_id, resolve_file_id, resolve_folder_id, write_text_element, batch_resolve_ids, format_oc_id, write_text_element,
}; };
const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
@@ -198,6 +198,7 @@ fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
use crate::application::dtos::trash_dto::TrashedItemDto; use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService; use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
use std::collections::HashMap;
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin. /// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
async fn write_trashbin_multistatus<W: std::io::Write>( async fn write_trashbin_multistatus<W: std::io::Write>(
@@ -219,9 +220,25 @@ async fn write_trashbin_multistatus<W: std::io::Write>(
// Root container entry for the trash collection itself. // Root container entry for the trash collection itself.
write_trash_root_response(&mut xml, username)?; write_trash_root_response(&mut xml, username)?;
// Pre-resolve every oc:fileid in two batch queries by object type (was one
// INSERT round-trip per item). File and folder UUIDs are disjoint, so the
// two maps merge cleanly into one keyed by original_id.
let mut file_uuids: Vec<String> = Vec::new();
let mut folder_uuids: Vec<String> = Vec::new();
for item in items {
if item.item_type == "folder" {
folder_uuids.push(item.original_id.clone());
} else {
file_uuids.push(item.original_id.clone());
}
}
let (mut id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
id_map.extend(folder_id_map);
// Individual trashed items. // Individual trashed items.
for item in items { for item in items {
write_trash_item_response(&mut xml, item, username, file_id_svc).await?; write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?;
} }
xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
@@ -267,11 +284,12 @@ fn write_trash_root_response<W: std::io::Write>(
} }
/// Write a single trashed item as a `<d:response>` element. /// Write a single trashed item as a `<d:response>` element.
async fn write_trash_item_response<W: std::io::Write>( fn write_trash_item_response<W: std::io::Write>(
xml: &mut Writer<W>, xml: &mut Writer<W>,
item: &TrashedItemDto, item: &TrashedItemDto,
username: &str, username: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>, file_id_svc: Option<&Arc<NextcloudFileIdService>>,
id_map: &HashMap<String, i64>,
) -> Result<(), String> { ) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new("d:response"))) xml.write_event(Event::Start(BytesStart::new("d:response")))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -318,12 +336,8 @@ async fn write_trash_item_response<W: std::io::Write>(
// d:getcontentlength // d:getcontentlength
write_text_element(xml, "d:getcontentlength", "0")?; write_text_element(xml, "d:getcontentlength", "0")?;
// oc:fileid and oc:id — resolve numeric ID via file_id service // oc:fileid and oc:id — resolved up front in a batch query.
let file_id = if item.item_type == "folder" { let file_id = id_map.get(&item.original_id).copied();
resolve_folder_id(file_id_svc, &item.original_id).await
} else {
resolve_file_id(file_id_svc, &item.original_id).await
};
if let Some(id) = file_id { if let Some(id) = file_id {
write_text_element(xml, "oc:fileid", &id.to_string())?; write_text_element(xml, "oc:fileid", &id.to_string())?;
let oc_id = format_oc_id(id, file_id_svc); let oc_id = format_oc_id(id, file_id_svc);
+253 -162
View File
@@ -3,16 +3,18 @@ use axum::{
http::{HeaderName, Request, StatusCode, header}, http::{HeaderName, Request, StatusCode, header},
response::Response, response::Response,
}; };
use bytes::Buf; use bytes::{Buf, Bytes};
use chrono::Utc; use chrono::Utc;
use quick_xml::{ use quick_xml::{
Writer, Writer,
events::{BytesEnd, BytesStart, BytesText, Event}, events::{BytesEnd, BytesStart, BytesText, Event},
}; };
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid;
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter}; use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
use crate::application::dtos::pagination::PaginationRequestDto;
use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::file_ports::{ use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
@@ -21,8 +23,10 @@ use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::common::mime_detect::{filename_from_path, refine_content_type_from_file}; use crate::common::mime_detect::{filename_from_path, refine_content_type_from_file};
use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::range_requests::{not_modified_response, range_response};
use crate::interfaces::upload_spool::spool_body_to_temp; use crate::interfaces::upload_spool::spool_body_to_temp;
/// Extension trait to map XML write errors to `String` concisely. /// Extension trait to map XML write errors to `String` concisely.
@@ -118,8 +122,8 @@ pub async fn handle_nc_webdav(
let method = req.method().clone(); let method = req.method().clone();
match method.as_str() { match method.as_str() {
"OPTIONS" => handle_options(), "OPTIONS" => handle_options(),
"GET" => handle_get(state, &user, &subpath, req.headers()).await,
"PROPFIND" => handle_propfind(state, req, &user, &subpath).await, "PROPFIND" => handle_propfind(state, req, &user, &subpath).await,
"GET" => handle_get(state, &user, &subpath).await,
"PUT" => handle_put(state, req, &user, &subpath).await, "PUT" => handle_put(state, req, &user, &subpath).await,
"MKCOL" => handle_mkcol(state, &user, &subpath).await, "MKCOL" => handle_mkcol(state, &user, &subpath).await,
"DELETE" => handle_delete(state, &user, &subpath).await, "DELETE" => handle_delete(state, &user, &subpath).await,
@@ -182,7 +186,10 @@ async fn handle_propfind(
.await .await
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?; .map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
let propfind = if body_bytes.is_empty() { // Parse (and thereby validate) the PROPFIND body. The NC response
// always emits the full property set, so the parsed request is not
// consulted further — but malformed XML must still fail with 400.
let _propfind = if body_bytes.is_empty() {
PropFindRequest { PropFindRequest {
prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp, prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp,
} }
@@ -199,64 +206,17 @@ async fn handle_propfind(
let folder_result = folder_service.get_folder_by_path(&internal_path).await; let folder_result = folder_service.get_folder_by_path(&internal_path).await;
if let Ok(folder) = folder_result { if let Ok(folder) = folder_result {
// It's a folder. // It's a folder — stream the multistatus: children are fetched in
let (files, subfolders) = if depth != "0" { // pages and serialized chunk by chunk, so memory stays O(batch)
let files = file_service // regardless of how many entries the folder holds.
.list_files(Some(&folder.id)) return Ok(build_nc_streaming_propfind(
.await state.clone(),
.unwrap_or_default(); folder,
let subfolders = folder_service depth,
.list_folders(Some(&folder.id)) user.id,
.await user.username.clone(),
.unwrap_or_default(); subpath.to_string(),
(files, subfolders) ));
} else {
(vec![], vec![])
};
// Batch-check favorites for all items in this listing.
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
let mut items: Vec<(&str, &str)> = Vec::new();
items.push((&folder.id, "folder"));
for f in &files {
items.push((&f.id, "file"));
}
for sf in &subfolders {
items.push((&sf.id, "folder"));
}
fav_svc
.batch_check_favorites(user.id, &items)
.await
.unwrap_or_default()
} else {
HashSet::new()
};
// Generate Nextcloud-aware XML.
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let mut buf = Vec::new();
write_nc_multistatus(
&mut buf,
Some(&folder),
&files,
&subfolders,
&propfind,
&depth,
&user.username,
subpath,
file_id_svc,
&favorite_ids,
)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap());
} }
// Not a folder — try as a file. // Not a folder — try as a file.
@@ -277,13 +237,9 @@ async fn handle_propfind(
let file_id_svc = nc.map(|n| &n.file_ids); let file_id_svc = nc.map(|n| &n.file_ids);
let mut buf = Vec::new(); let mut buf = Vec::new();
write_nc_multistatus( write_nc_file_multistatus(
&mut buf, &mut buf,
None, &file,
&[file],
&[],
&propfind,
"0",
&user.username, &user.username,
subpath, subpath,
file_id_svc, file_id_svc,
@@ -308,6 +264,7 @@ async fn handle_get(
state: Arc<AppState>, state: Arc<AppState>,
user: &CurrentUser, user: &CurrentUser,
subpath: &str, subpath: &str,
headers: &axum::http::HeaderMap,
) -> Result<Response<Body>, AppError> { ) -> Result<Response<Body>, AppError> {
// GET on root folder — NC clients use this as an existence check // GET on root folder — NC clients use this as an existence check
if subpath.is_empty() || subpath == "/" { if subpath.is_empty() || subpath == "/" {
@@ -340,6 +297,25 @@ async fn handle_get(
.await .await
.map_err(|_| AppError::not_found("File not found"))?; .map_err(|_| AppError::not_found("File not found"))?;
// ETag comes from `FileDto::etag` (populated from `File::etag()`
// in the `From<File>` impl) — single source of truth, so GET,
// HEAD, PUT-response, MOVE, and PROPFIND all emit byte-identical
// values for the same file. NC's sync engine compares cached
// PROPFIND ETags against GET/HEAD responses; using `file.id` here
// (a UUID) while PROPFIND emitted the blob hash made NC see
// every file as "remotely changed" on first descent.
let etag = format!("\"{}\"", file.etag);
// Conditional GET — sync clients revalidating get a 304, not the body.
if let Some(resp) = not_modified_response(headers, &etag) {
return Ok(resp);
}
// Range Requests — serve 206/416 instead of the whole file on seeks.
if let Some(resp) = range_response(headers, &file, &etag, file_service).await {
return Ok(resp);
}
let stream = file_service let stream = file_service
.get_file_stream(&file.id) .get_file_stream(&file.id)
.await .await
@@ -349,18 +325,12 @@ async fn handle_get(
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0) chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
// ETag comes from `FileDto::etag` (populated from `File::etag()`
// in the `From<File>` impl) — single source of truth, so GET,
// HEAD, PUT-response, MOVE, and PROPFIND all emit byte-identical
// values for the same file. NC's sync engine compares cached
// PROPFIND ETags against GET/HEAD responses; using `file.id` here
// (a UUID) while PROPFIND emitted the blob hash made NC see
// every file as "remotely changed" on first descent.
Ok(Response::builder() Ok(Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(header::CONTENT_TYPE, file.mime_type.as_ref()) .header(header::CONTENT_TYPE, file.mime_type.as_ref())
.header(header::CONTENT_LENGTH, file.size) .header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.etag)) .header(header::ETAG, etag)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::LAST_MODIFIED, modified_at.to_rfc2822()) .header(header::LAST_MODIFIED, modified_at.to_rfc2822())
.body(Body::from_stream(std::pin::Pin::from(stream))) .body(Body::from_stream(std::pin::Pin::from(stream)))
.unwrap()) .unwrap())
@@ -987,65 +957,37 @@ use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::folder_dto::FolderDto;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService; use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
/// Generate a complete Nextcloud-compatible multistatus XML response. /// Write the `<d:multistatus>` opening tag with the full NC namespace set.
#[allow(clippy::too_many_arguments)] /// Shared by the streaming folder PROPFIND and the single-file variant so
async fn write_nc_multistatus<W: std::io::Write>( /// the namespace list can never diverge between the two.
writer: W, fn write_nc_multistatus_open<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(), String> {
folder: Option<&FolderDto>,
files: &[FileDto],
subfolders: &[FolderDto],
_request: &PropFindRequest,
depth: &str,
username: &str,
subpath: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
favorite_ids: &HashSet<String>,
) -> Result<(), String> {
let mut xml = Writer::new(writer);
// Root element with all required namespaces.
let mut ms = BytesStart::new("d:multistatus"); let mut ms = BytesStart::new("d:multistatus");
ms.push_attribute(("xmlns:d", "DAV:")); ms.push_attribute(("xmlns:d", "DAV:"));
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns")); ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns")); ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns"));
ms.push_attribute(("xmlns:ocs", "http://open-collaboration-services.org/ns")); ms.push_attribute(("xmlns:ocs", "http://open-collaboration-services.org/ns"));
xml.write_event(Event::Start(ms)).xml_err()?; xml.write_event(Event::Start(ms)).xml_err()
// Current folder entry. Collection hrefs MUST end in `/` (RFC 4918
// §5.2 + strict NC-client enforcement — see `nc_collection_href`).
if let Some(f) = folder {
let href = nc_collection_href(username, subpath);
let file_id = resolve_folder_id(file_id_svc, &f.id).await;
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
&mut xml,
f,
&href,
file_id,
oc_id.as_deref(),
username,
favorite_ids,
)?;
} }
// When folder is None, files are the target resource itself (single-file /// Generate the multistatus XML for a single-file PROPFIND. The folder
// PROPFIND) and must always be emitted. When folder is Some, files/subfolders /// case streams via [`build_nc_streaming_propfind`] instead.
// are children and should only be listed when depth > 0. async fn write_nc_file_multistatus<W: std::io::Write>(
let emit_children = folder.is_none() || depth != "0"; writer: W,
file: &FileDto,
username: &str,
subpath: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
favorite_ids: &HashSet<String>,
) -> Result<(), String> {
let (file_id_map, _) =
batch_resolve_ids(file_id_svc, std::slice::from_ref(&file.id), &[]).await;
let mut xml = Writer::new(writer);
write_nc_multistatus_open(&mut xml)?;
if emit_children {
// Files.
for file in files {
let child_sub = if folder.is_none() {
// Single-file PROPFIND — subpath already points to the file. // Single-file PROPFIND — subpath already points to the file.
subpath.to_string() let href = nc_href(username, subpath);
} else if subpath.is_empty() { let file_id = file_id_map.get(&file.id).copied();
file.name.clone()
} else {
format!("{}/{}", subpath.trim_end_matches('/'), file.name)
};
let href = nc_href(username, &child_sub);
let file_id = resolve_file_id(file_id_svc, &file.id).await;
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc)); let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_file_response( write_file_response(
&mut xml, &mut xml,
@@ -1056,29 +998,6 @@ async fn write_nc_multistatus<W: std::io::Write>(
username, username,
favorite_ids, favorite_ids,
)?; )?;
}
// Subfolders — also collections, same trailing-slash rule.
for sf in subfolders {
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);
let file_id = resolve_folder_id(file_id_svc, &sf.id).await;
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
&mut xml,
sf,
&href,
file_id,
oc_id.as_deref(),
username,
favorite_ids,
)?;
}
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.xml_err()?; .xml_err()?;
@@ -1086,6 +1005,174 @@ async fn write_nc_multistatus<W: std::io::Write>(
Ok(()) Ok(())
} }
/// Build a streaming 207 Multi-Status response for a folder PROPFIND.
///
/// Mirrors the native WebDAV handler's `build_streaming_propfind_response`:
/// children are fetched in pages of [`PROPFIND_BATCH_SIZE`], each page's
/// favorites and `oc:fileid`s are resolved with two batch queries, and the
/// XML is yielded chunk by chunk — memory stays O(batch) and the response
/// starts flowing immediately, instead of materializing the full listing
/// plus its entire multistatus (~2 KB/entry) in RAM before the first byte.
fn build_nc_streaming_propfind(
state: Arc<AppState>,
folder: FolderDto,
depth: String,
user_id: Uuid,
username: String,
subpath: String,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
let fav_svc = state.favorites_service.as_ref();
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// ── <d:multistatus> + the folder's own entry ─────────────────
// Collection hrefs MUST end in `/` (RFC 4918 §5.2 + strict
// NC-client enforcement — see `nc_collection_href`).
let folder_favs = if let Some(fav) = fav_svc {
fav.batch_check_favorites(user_id, &[(folder.id.as_str(), "folder")])
.await
.unwrap_or_default()
} else {
HashSet::new()
};
let (_, folder_id_map) =
batch_resolve_ids(file_id_svc, &[], std::slice::from_ref(&folder.id)).await;
let mut buf = Vec::with_capacity(4096);
{
let mut xml = Writer::new(&mut buf);
write_nc_multistatus_open(&mut xml).map_err(std::io::Error::other)?;
let href = nc_collection_href(&username, &subpath);
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(&mut xml, &folder, &href, fid, oc_id.as_deref(), &username, &folder_favs)
.map_err(std::io::Error::other)?;
}
yield Bytes::from(buf);
// ── Children (only if Depth != 0) ────────────────────────────
if depth != "0" {
// Files in pages.
let mut offset: i64 = 0;
loop {
let batch = file_service
.list_files_batch_with_perms(Some(&folder.id), user_id, offset, PROPFIND_BATCH_SIZE)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
if batch.is_empty() {
break;
}
let batch_len = batch.len();
// Per-page enrichment: favorites + oc:fileids, two batch queries.
let favs = if let Some(fav) = fav_svc {
let items: Vec<(&str, &str)> =
batch.iter().map(|f| (f.id.as_str(), "file")).collect();
fav.batch_check_favorites(user_id, &items).await.unwrap_or_default()
} else {
HashSet::new()
};
let file_uuids: Vec<String> = batch.iter().map(|f| f.id.clone()).collect();
let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await;
let mut chunk = Vec::with_capacity(batch_len * 1024);
{
let mut xml = Writer::new(&mut chunk);
for file in &batch {
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);
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)
.map_err(std::io::Error::other)?;
}
}
yield Bytes::from(chunk);
if (batch_len as i64) < PROPFIND_BATCH_SIZE {
break;
}
offset += batch_len as i64;
}
// Subfolders in pages — also collections, same trailing-slash rule.
let mut page = 0usize;
loop {
let pag = PaginationRequestDto {
page,
page_size: PROPFIND_BATCH_SIZE as usize,
};
let result = folder_service
.list_folders_paginated_with_perms(Some(&folder.id), user_id, &pag)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
if result.items.is_empty() {
break;
}
let favs = if let Some(fav) = fav_svc {
let items: Vec<(&str, &str)> =
result.items.iter().map(|sf| (sf.id.as_str(), "folder")).collect();
fav.batch_check_favorites(user_id, &items).await.unwrap_or_default()
} else {
HashSet::new()
};
let folder_uuids: Vec<String> = result.items.iter().map(|sf| sf.id.clone()).collect();
let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await;
let mut chunk = Vec::with_capacity(result.items.len() * 1024);
{
let mut xml = Writer::new(&mut chunk);
for sf in &result.items {
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);
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)
.map_err(std::io::Error::other)?;
}
}
let has_more = result.pagination.has_next;
yield Bytes::from(chunk);
if !has_more {
break;
}
page += 1;
}
}
// ── </d:multistatus> ─────────────────────────────────────────
let mut buf = Vec::with_capacity(32);
{
let mut xml = Writer::new(&mut buf);
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.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()
}
pub fn write_folder_response<W: std::io::Write>( pub fn write_folder_response<W: std::io::Write>(
xml: &mut Writer<W>, xml: &mut Writer<W>,
folder: &FolderDto, folder: &FolderDto,
@@ -1273,20 +1360,24 @@ pub fn write_text_element<W: std::io::Write>(
Ok(()) Ok(())
} }
pub async fn resolve_file_id( /// Resolve every `oc:fileid` for a listing in two batch queries (one per
/// object type) instead of one INSERT round-trip per child. Returns
/// `(file_map, folder_map)` keyed by object UUID; entries are absent when the
/// service is disabled or an id can't be resolved, mirroring the previous
/// per-call `Option` behaviour. The two batches run concurrently.
pub async fn batch_resolve_ids(
svc: Option<&Arc<NextcloudFileIdService>>, svc: Option<&Arc<NextcloudFileIdService>>,
file_uuid: &str, file_uuids: &[String],
) -> Option<i64> { folder_uuids: &[String],
let svc = svc?; ) -> (HashMap<String, i64>, HashMap<String, i64>) {
svc.get_or_create_file_id(file_uuid).await.ok() let Some(svc) = svc else {
} return (HashMap::new(), HashMap::new());
};
pub async fn resolve_folder_id( let (files, folders) = tokio::join!(
svc: Option<&Arc<NextcloudFileIdService>>, svc.get_or_create_file_ids(file_uuids),
folder_uuid: &str, svc.get_or_create_folder_ids(folder_uuids),
) -> Option<i64> { );
let svc = svc?; (files.unwrap_or_default(), folders.unwrap_or_default())
svc.get_or_create_folder_id(folder_uuid).await.ok()
} }
pub fn format_oc_id(id: i64, svc: Option<&Arc<NextcloudFileIdService>>) -> String { pub fn format_oc_id(id: i64, svc: Option<&Arc<NextcloudFileIdService>>) -> String {
+97
View File
@@ -0,0 +1,97 @@
//! Conditional-request (`If-None-Match` → 304) and `Range` (→ 206/416)
//! helpers shared by the native WebDAV and NextCloud GET handlers.
//!
//! Mount-style WebDAV clients (rclone, davfs2, Finder, video players)
//! read by ranges; without 206 support every seek or resume transfers
//! the whole file. The REST download handler keeps its own richer
//! variant in `file_handler.rs` (Content-Disposition, compression,
//! permission-scoped streams) but follows the same header semantics.
use axum::body::Body;
use axum::http::{HeaderMap, Response, StatusCode, header};
use http_range_header::parse_range_header;
use std::sync::Arc;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::services::file_retrieval_service::FileRetrievalService;
/// `If-None-Match` short-circuit: returns a `304 Not Modified` response
/// when the client already holds the current representation. `etag` must
/// be the quoted form the GET would emit (same comparison as the REST
/// download endpoint: exact match or `*`).
pub fn not_modified_response(headers: &HeaderMap, etag: &str) -> Option<Response<Body>> {
let client_etag = headers.get(header::IF_NONE_MATCH)?.to_str().ok()?;
if client_etag == etag || client_etag == "*" {
return Some(
Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag)
.body(Body::empty())
.unwrap(),
);
}
None
}
/// `Range` short-circuit for a streaming download.
///
/// Returns `Some(206)` with the requested byte range, `Some(416)` when
/// the range cannot be satisfied, or `None` when no (parseable) range
/// was requested or the range stream could not be created — callers
/// fall through to the full-body response, mirroring the REST handler.
///
/// The caller has already resolved access to the file (path-resolver /
/// ownership), so this uses the unscoped range stream — same contract
/// as the `get_file_stream` call in the surrounding handlers.
pub async fn range_response(
headers: &HeaderMap,
file: &FileDto,
etag: &str,
retrieval: &Arc<FileRetrievalService>,
) -> Option<Response<Body>> {
let range_str = headers.get(header::RANGE)?.to_str().ok()?;
let ranges = parse_range_header(range_str).ok()?;
let valid_ranges = match ranges.validate(file.size) {
Ok(v) => v,
Err(_) => {
return Some(
Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", file.size))
.body(Body::empty())
.unwrap(),
);
}
};
let range = valid_ranges.first()?;
let start = *range.start();
let end = *range.end();
let range_length = end - start + 1;
match retrieval
.get_file_range_stream(&file.id, start, Some(end + 1))
.await
{
Ok(stream) => Some(
Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, range_length)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file.size),
)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, etag)
.body(Body::from_stream(Box::into_pin(stream)))
.unwrap(),
),
Err(err) => {
tracing::error!("Error creating range stream: {}", err);
None // fall through to the full download
}
}
}
+36 -2
View File
@@ -438,9 +438,17 @@ async function loadFiles(options = { insertHistory: true }) {
} }
} }
await rebuildBreadCrumb(); // Rebuild the breadcrumb concurrently with the first page fetch —
// the listing does not depend on it, and awaiting the ancestor
// chain first added one round-trip per depth level before the
// content even started loading. Crumbs, history and title update
// when it resolves (skipped when superseded by a newer navigation).
const requestedPath = app.currentPath;
const breadcrumbReady = rebuildBreadCrumb().then((committed) => {
if (!committed) return;
ui.updateBreadcrumb(); ui.updateBreadcrumb();
updateHistory(options.insertHistory ?? true); updateHistory(options.insertHistory ?? true);
});
clearTimeout(spinnerTimeout); clearTimeout(spinnerTimeout);
@@ -457,6 +465,17 @@ async function loadFiles(options = { insertHistory: true }) {
// Hand off to _loadPage (re-use cursor/groupBy state just reset above). // Hand off to _loadPage (re-use cursor/groupBy state just reset above).
_loading = false; // _loadPage sets its own guard _loading = false; // _loadPage sets its own guard
await _loadPage({ isFirstPage: true }); await _loadPage({ isFirstPage: true });
await breadcrumbReady;
// rebuildBreadCrumb falls back to the home folder when the
// requested folder is inaccessible (deleted / permission revoked).
// The old sequential flow got the home listing for free; reload to
// match it.
if (app.currentPath !== requestedPath) {
ui.resetFilesList();
_nextCursor = null;
await _loadPage({ isFirstPage: true });
}
// Deep-link: open a specific file if requested via app.viewFile. // Deep-link: open a specific file if requested via app.viewFile.
// We don't have a flat file list anymore (cursor pages), so only try // We don't have a flat file list anymore (cursor pages), so only try
@@ -496,6 +515,21 @@ async function loadFiles(options = { insertHistory: true }) {
} }
} }
/**
* Replace the list contents with a whole result set in one batched render
* (single DocumentFragment pass — no per-item DOM scans, smooth-scrolls or
* highlight pulses). Used by search to display results; optimistic
* single-item inserts should keep using `addItem`.
*
* @param {Array<FileItem|FolderItem>} items
*/
function renderItems(items) {
const component = _ensureComponent();
if (!component) return;
document.getElementById('files-container-error')?.classList.add('hidden');
component.render(items);
}
/** /**
* Re-evaluate the shared badge for every item currently rendered in the Files list. * Re-evaluate the shared badge for every item currently rendered in the Files list.
* Call this after the outgoing grants cache has been refreshed. * Call this after the outgoing grants cache has been refreshed.
@@ -504,4 +538,4 @@ function refreshSharedBadges() {
_component?.refreshSharedBadges(); _component?.refreshSharedBadges();
} }
export { addItem, filesView, loadFiles, refreshSharedBadges }; export { addItem, filesView, loadFiles, refreshSharedBadges, renderItems };
+82 -28
View File
@@ -14,6 +14,7 @@ import { fileOps } from '../features/files/fileOperations.js';
import { inlineViewer } from '../features/files/inlineViewer.js'; import { inlineViewer } from '../features/files/inlineViewer.js';
import { wopiEditor } from '../features/files/wopiEditor.js'; import { wopiEditor } from '../features/files/wopiEditor.js';
import { recent } from '../features/library/recent.js'; import { recent } from '../features/library/recent.js';
import { buildBatchDownloadUrl } from '../utils/download.js';
import { positionMenu } from '../utils/menuPosition.js'; import { positionMenu } from '../utils/menuPosition.js';
import { loadFiles } from './filesView.js'; import { loadFiles } from './filesView.js';
import { updateHistory } from './main.js'; import { updateHistory } from './main.js';
@@ -774,7 +775,7 @@ const ui = {
if (item?.type === 'file') fileIds.push(item.id); if (item?.type === 'file') fileIds.push(item.id);
else if (item) folderIds.push(item.id); else if (item) folderIds.push(item.id);
}); });
downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`; downloadUrl = `${window.location.origin}${buildBatchDownloadUrl(fileIds, folderIds)}`;
} }
e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`);
@@ -1099,6 +1100,69 @@ function initRubberBandSelection() {
let active = false; let active = false;
let startX = 0, let startX = 0,
startY = 0; startY = 0;
let curX = 0,
curY = 0;
let rafId = 0;
/**
* Card geometry snapshot taken once per drag (and rebuilt on scroll).
* Comparing the lasso against these cached rects means the per-frame
* pass performs zero DOM reads — no forced reflow per card.
* @type {Array<{el: HTMLElement, left: number, top: number, right: number,
* bottom: number, info: ReturnType<typeof batchToolbar._extractInfo>,
* selected: boolean}> | null}
*/
let cardCache = null;
const buildCardCache = () => {
cardCache = [];
document.querySelectorAll('#files-list .file-item').forEach((card) => {
const el = /** @type {HTMLElement} */ (card);
const r = el.getBoundingClientRect();
cardCache.push({
el,
left: r.left,
top: r.top,
right: r.right,
bottom: r.bottom,
info: batchToolbar ? batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (el)) : null,
selected: el.classList.contains('selected')
});
});
};
// Scrolling mid-drag shifts every viewport rect — drop the snapshot so
// the next frame rebuilds it.
const invalidateCardCache = () => {
cardCache = null;
};
/** One classification pass per animation frame (cached rects only). */
const classifyCards = () => {
rafId = 0;
if (!cardCache) buildCardCache();
const left = Math.min(startX, curX);
const top = Math.min(startY, curY);
const right = Math.max(startX, curX);
const bottom = Math.max(startY, curY);
for (const entry of cardCache) {
const intersects = entry.left < right && entry.right > left && entry.top < bottom && entry.bottom > top;
if (intersects === entry.selected) continue;
entry.selected = intersects;
entry.el.classList.toggle('selected', intersects);
// Sync with batchToolbar module (only on state change)
if (batchToolbar && entry.info) {
if (intersects) {
batchToolbar.select(entry.info.id, entry.info.name, entry.info.type, entry.info.parentId);
} else {
batchToolbar.deselect(entry.info.id);
}
}
}
};
// We listen on the whole files-container (covers grid + empty space) // We listen on the whole files-container (covers grid + empty space)
const container = document.querySelector('.files-container') || document.getElementById('files-list'); const container = document.querySelector('.files-container') || document.getElementById('files-list');
@@ -1123,6 +1187,10 @@ function initRubberBandSelection() {
active = true; active = true;
startX = e.clientX; startX = e.clientX;
startY = e.clientY; startY = e.clientY;
curX = startX;
curY = startY;
cardCache = null; // built lazily on the first classification frame
document.addEventListener('scroll', invalidateCardCache, { capture: true, passive: true });
selRect.style.left = `${startX}px`; selRect.style.left = `${startX}px`;
selRect.style.top = `${startY}px`; selRect.style.top = `${startY}px`;
@@ -1136,8 +1204,8 @@ function initRubberBandSelection() {
document.addEventListener('mousemove', (e) => { document.addEventListener('mousemove', (e) => {
if (!active) return; if (!active) return;
const curX = e.clientX; curX = e.clientX;
const curY = e.clientY; curY = e.clientY;
const left = Math.min(startX, curX); const left = Math.min(startX, curX);
const top = Math.min(startY, curY); const top = Math.min(startY, curY);
@@ -1149,41 +1217,27 @@ function initRubberBandSelection() {
selRect.style.display = 'block'; selRect.style.display = 'block';
} }
// Style writes only — no layout reads here. The card highlighting
// runs at most once per frame against the cached geometry.
selRect.style.left = `${left}px`; selRect.style.left = `${left}px`;
selRect.style.top = `${top}px`; selRect.style.top = `${top}px`;
selRect.style.width = `${width}px`; selRect.style.width = `${width}px`;
selRect.style.height = `${height}px`; selRect.style.height = `${height}px`;
// Highlight cards that intersect with the rectangle if (!rafId) rafId = requestAnimationFrame(classifyCards);
const rectBounds = { left, top, right: left + width, bottom: top + height };
document.querySelectorAll('#files-list .file-item').forEach((card) => {
const cardRect = card.getBoundingClientRect();
const intersects =
cardRect.left < rectBounds.right && cardRect.right > rectBounds.left && cardRect.top < rectBounds.bottom && cardRect.bottom > rectBounds.top;
if (intersects) {
card.classList.add('selected');
// Sync with batchToolbar module
if (batchToolbar) {
const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) batchToolbar.select(info.id, info.name, info.type, info.parentId);
}
} else {
card.classList.remove('selected');
// Deselect from batchToolbar module
if (batchToolbar) {
const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) batchToolbar.deselect(info.id);
}
}
});
}); });
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
if (!active) return; if (!active) return;
active = false; active = false;
document.removeEventListener('scroll', invalidateCardCache, { capture: true });
// Apply the still-pending classification so the final lasso
// position is what determines the selection.
if (rafId) {
cancelAnimationFrame(rafId);
classifyCards();
}
cardCache = null;
const hadSelection = selRect.style.display === 'block'; const hadSelection = selRect.style.display === 'block';
selRect.style.display = 'none'; selRect.style.display = 'none';
// Update the batch bar after rubber band selection completes // Update the batch bar after rubber band selection completes
+33 -56
View File
@@ -643,7 +643,6 @@ export class ResourceListComponent {
`; `;
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder')); el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
this._bindItemEvents(el, folder);
return el; return el;
} }
@@ -693,7 +692,6 @@ export class ResourceListComponent {
`; `;
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file')); el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file'));
this._bindItemEvents(el, file);
return el; return el;
} }
@@ -716,57 +714,6 @@ export class ResourceListComponent {
.join(''); .join('');
} }
/**
* Attach direct event listeners to interactive elements inside a .file-item.
* This covers buttons that must stop propagation before the delegated listener runs.
* @param {HTMLElement} el
* @param {FileItem|FolderItem} item
*/
_bindItemEvents(el, item) {
const cfg = this._cfg;
// Favorite-star — direct click, stopPropagation so the card open doesn't fire
if (cfg.showFavorite && cfg.onFavoriteToggle) {
const star = el.querySelector('.favorite-star');
star?.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
cfg.onFavoriteToggle?.(item);
});
}
// Custom inline actions (e.g. restore / delete-permanently on trash) —
// bound directly so they stop propagation before the card-open handler.
if (cfg.customActions?.length) {
el.querySelectorAll('button[data-custom-action]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
const idx = Number(/** @type {HTMLElement} */ (btn).dataset.customAction);
const action = cfg.customActions?.[idx];
if (action) action.onClick(item);
});
});
}
// Shared-badge click → open share modal (or fall back to context menu)
if (cfg.showShareBadge && (cfg.onShareBadgeClick || cfg.onContextMenu)) {
const badge = el.querySelector('.file-badge-shared');
badge?.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
if (cfg.onShareBadgeClick) {
cfg.onShareBadgeClick(item);
} else {
cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
}
});
}
}
/** Wire one delegated listener for all pointer events in this container. */ /** Wire one delegated listener for all pointer events in this container. */
_initDelegation() { _initDelegation() {
const container = this._container; const container = this._container;
@@ -791,6 +738,39 @@ export class ResourceListComponent {
return; return;
} }
// Favorite-star button — never opens the card
if (target.closest('.favorite-star')) {
e.preventDefault();
const item = this._itemFromCard(card);
if (item) cfg.onFavoriteToggle?.(item);
return;
}
// Custom inline actions (e.g. restore / delete-permanently on trash)
const customBtn = /** @type {HTMLElement | null} */ (target.closest('button[data-custom-action]'));
if (customBtn) {
e.preventDefault();
const idx = Number(customBtn.dataset.customAction);
const action = cfg.customActions?.[idx];
const item = this._itemFromCard(card);
if (action && item) action.onClick(item);
return;
}
// Shared-badge click → open share modal (or fall back to context menu)
if (target.closest('.file-badge-shared')) {
e.preventDefault();
const item = this._itemFromCard(card);
if (item) {
if (cfg.onShareBadgeClick) {
cfg.onShareBadgeClick(item);
} else {
cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
}
}
return;
}
// Checkbox cell → selection (shift extends range) // Checkbox cell → selection (shift extends range)
if (cfg.selectable && target.closest('.checkbox-cell')) { if (cfg.selectable && target.closest('.checkbox-cell')) {
if (e.shiftKey) { if (e.shiftKey) {
@@ -801,9 +781,6 @@ export class ResourceListComponent {
return; return;
} }
// Favorite star is handled by the direct listener in _bindItemEvents
if (target.closest('.favorite-star')) return;
// Modifier-key click → selection toggle // Modifier-key click → selection toggle
if (e.metaKey || e.altKey || e.ctrlKey) { if (e.metaKey || e.altKey || e.ctrlKey) {
if (cfg.selectable) this._toggleSelection(card); if (cfg.selectable) this._toggleSelection(card);
+35 -9
View File
@@ -618,27 +618,53 @@ function replaceIconsInElement(container) {
function oxiIconsInit() { function oxiIconsInit() {
let raf = 0; let raf = 0;
/**
* Subtree roots added since the last animation frame. Scanning only
* these (instead of the whole document) keeps the cost proportional
* to what was inserted, not to the total DOM size.
* @type {Set<Element>}
*/
let pendingRoots = new Set();
const scan = () => { const scan = () => {
raf = 0; raf = 0;
replaceIconsInElement(); const roots = pendingRoots;
pendingRoots = new Set();
for (const root of roots) {
if (!root.isConnected) continue; // removed (or replaced) meanwhile
if (root.matches('i[class*="fa-"]')) {
// The inserted node IS the icon — scan via its parent so the
// descendant selector pass picks it up.
replaceIconsInElement(root.parentElement || document.body);
} else {
replaceIconsInElement(root);
}
}
}; };
// Initial sweep once the DOM is ready // Initial sweep once the DOM is ready
const fullSweep = () => replaceIconsInElement();
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan); document.addEventListener('DOMContentLoaded', fullSweep);
} else { } else {
scan(); fullSweep();
} }
// Observe future mutations (dynamic renders, modals, etc.) // Observe future mutations (dynamic renders, modals, etc.). Only element
// insertions can carry icons — text-node churn (progress counters,
// notification text) no longer triggers any scan at all.
new MutationObserver((mutations) => { new MutationObserver((mutations) => {
if (raf) return; for (const mutation of mutations) {
for (let i = 0; i < mutations.length; i++) { for (let i = 0; i < mutation.addedNodes.length; i++) {
if (mutations[i].addedNodes.length) { const node = mutation.addedNodes[i];
raf = requestAnimationFrame(scan); if (node.nodeType === Node.ELEMENT_NODE) {
return; pendingRoots.add(/** @type {Element} */ (node));
} }
} }
}
if (!raf && pendingRoots.size) {
raf = requestAnimationFrame(scan);
}
}).observe(document.documentElement, { childList: true, subtree: true }); }).observe(document.documentElement, { childList: true, subtree: true });
} }
+33 -15
View File
@@ -15,6 +15,7 @@ import { loadFiles } from '../../app/filesView.js';
import { app } from '../../app/state.js'; import { app } from '../../app/state.js';
import { showConfirmDialog, ui } from '../../app/ui.js'; import { showConfirmDialog, ui } from '../../app/ui.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { buildBatchDownloadUrl, triggerBrowserDownload } from '../../utils/download.js';
import { favorites } from '../library/favorites.js'; import { favorites } from '../library/favorites.js';
import { contextMenus } from './contextMenus.js'; import { contextMenus } from './contextMenus.js';
import { getAuthHeaders } from './fileOperations.js'; import { getAuthHeaders } from './fileOperations.js';
@@ -126,10 +127,14 @@ const batchToolbar = {
clear() { clear() {
this._selected.clear(); this._selected.clear();
this._lastClickedIndex = -1; this._lastClickedIndex = -1;
document.querySelectorAll('.file-item.selected').forEach((el) => { // Scope the DOM sweep to the files list — the only container this
// toolbar manages (see `selectAll`) — instead of the whole document,
// and only touch checkboxes that are actually checked.
const list = document.getElementById('files-list');
list?.querySelectorAll('.file-item.selected').forEach((el) => {
el.classList.remove('selected'); el.classList.remove('selected');
}); });
document.querySelectorAll('.item-checkbox').forEach((cb) => { list?.querySelectorAll('.item-checkbox:checked').forEach((cb) => {
/** @type {HTMLInputElement} */ (cb).checked = false; /** @type {HTMLInputElement} */ (cb).checked = false;
}); });
// Reset the active component's internal selection state without going // Reset the active component's internal selection state without going
@@ -173,15 +178,16 @@ const batchToolbar = {
/** @type {Array<string>} */ /** @type {Array<string>} */
const folderIds = []; const folderIds = [];
// TODO optimize & check if _selected is a better use // `_selected` is the source of truth (every selection path keeps it
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => { // in sync) — no need to re-derive the selection from a DOM scan.
if (item.dataset.fileId) { for (const sel of this._selected.values()) {
fileIds.push(item.dataset.fileId); if (sel.type === 'file') {
} else { fileIds.push(sel.id);
// ignore selectedItem if this is the target } else if (targtFolderId && targtFolderId !== sel.id) {
if (targtFolderId && targtFolderId !== item.dataset.folderId) folderIds.push(item.dataset.folderId); // ignore the selected folder if it is the drop target itself
folderIds.push(sel.id);
}
} }
});
return { return {
fileIds: fileIds, fileIds: fileIds,
@@ -454,10 +460,22 @@ const batchToolbar = {
ui.showNotification('Preparing download', 'Creating ZIP archive...'); ui.showNotification('Preparing download', 'Creating ZIP archive...');
try {
const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id); const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id);
const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id); const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id);
const zipName = `oxicloud-download-${Date.now()}.zip`;
// Browser-native download via the GET variant of the endpoint: the
// ZIP streams to disk instead of being buffered whole in the tab's
// memory (a multi-GB selection used to risk crashing the tab).
const url = buildBatchDownloadUrl(fileIds, folderIds);
if (url.length <= 4000) {
triggerBrowserDownload(url, zipName);
return;
}
// Selections too large for a URL (~100+ items) keep the buffered
// POST path — the id list only fits in a request body.
try {
const response = await fetch('/api/batch/download', { const response = await fetch('/api/batch/download', {
method: 'POST', method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
@@ -467,14 +485,14 @@ const batchToolbar = {
if (!response.ok) throw new Error(`Server returned ${response.status}`); if (!response.ok) throw new Error(`Server returned ${response.status}`);
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = blobUrl;
link.download = `oxicloud-download-${Date.now()}.zip`; link.download = zipName;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(blobUrl);
} catch (e) { } catch (e) {
console.error('Batch download error:', e); console.error('Batch download error:', e);
ui.showNotification('Error', 'Could not download selected items'); ui.showNotification('Error', 'Could not download selected items');
+49 -51
View File
@@ -10,6 +10,8 @@ import { showConfirmDialog, ui } from '../../app/ui.js';
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js'; import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { notifications } from '../../core/notifications.js'; import { notifications } from '../../core/notifications.js';
import { invalidateFolderMeta } from '../../model/filesModel.js';
import { triggerBrowserDownload } from '../../utils/download.js';
/** /**
* @typedef {Object} BatchResult * @typedef {Object} BatchResult
@@ -349,7 +351,9 @@ const fileOps = {
} }
// Filter out unreadable entries (typically dropped folders/placeholders) // Filter out unreadable entries (typically dropped folders/placeholders)
/** @type {File[]} */
const readableFiles = []; const readableFiles = [];
/** @type {string[]} */
const skippedEntries = []; const skippedEntries = [];
for (const f of originalFiles) { for (const f of originalFiles) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
@@ -386,13 +390,21 @@ const fileOps = {
let uploadedCount = 0; let uploadedCount = 0;
let successCount = 0; let successCount = 0;
let quotaStop = false;
for (let i = 0; i < totalFiles; i++) {
const file = readableFiles[i];
const formData = new FormData();
const targetFolderId = app.currentPath || app.userHomeFolderId; const targetFolderId = app.currentPath || app.userHomeFolderId;
/**
* Upload a single readable file by index. Shared counters are
* mutated here; safe because JS runs the workers cooperatively
* (no true parallelism between awaits).
* @param {number} idx
*/
const uploadOneFile = async (idx) => {
if (quotaStop) return;
const file = readableFiles[idx];
const formData = new FormData();
if (targetFolderId) formData.append('folder_id', targetFolderId); if (targetFolderId) formData.append('folder_id', targetFolderId);
formData.append('file', file); formData.append('file', file);
@@ -436,6 +448,8 @@ const fileOps = {
}); });
} }
if (result.isQuotaError) { if (result.isQuotaError) {
// Stop pulling new files; in-flight uploads still finish.
quotaStop = true;
const msg = result.errorMsg || i18n.t('storage_quota_exceeded'); const msg = result.errorMsg || i18n.t('storage_quota_exceeded');
if (notifications) { if (notifications) {
notifications.addNotification({ notifications.addNotification({
@@ -445,10 +459,27 @@ const fileOps = {
text: msg text: msg
}); });
} }
break;
} }
} }
};
// Pool-based concurrency: keep up to CONCURRENCY uploads in flight
// instead of one at a time (mirrors uploadFolderEntries). Files are
// independent, so this is ~CONCURRENCY× faster for many small files.
const CONCURRENCY = 10;
let nextIdx = 0;
const runNext = async () => {
while (nextIdx < totalFiles && !quotaStop) {
const idx = nextIdx++;
await uploadOneFile(idx);
} }
};
const workers = [];
for (let w = 0; w < Math.min(CONCURRENCY, totalFiles); w++) {
workers.push(runNext());
}
await Promise.all(workers);
// All done // All done
this._finishUploadToast(successCount, totalFiles); this._finishUploadToast(successCount, totalFiles);
@@ -862,6 +893,8 @@ const fileOps = {
}); });
if (response.ok) { if (response.ok) {
// Parent changed — drop the cached breadcrumb metadata
invalidateFolderMeta(folderId);
// Reload files after moving // Reload files after moving
await loadFiles(); await loadFiles();
ui.showNotification('Folder moved', 'Folder moved successfully'); ui.showNotification('Folder moved', 'Folder moved successfully');
@@ -925,6 +958,8 @@ const fileOps = {
const data = await res.json(); const data = await res.json();
success += data.stats?.successful || 0; success += data.stats?.successful || 0;
errors += data.stats?.failed || 0; errors += data.stats?.failed || 0;
// Parents changed — drop the cached breadcrumb metadata
for (const id of folderIds) invalidateFolderMeta(id);
} }
} catch (err) { } catch (err) {
console.error('Batch move error:', err); console.error('Batch move error:', err);
@@ -1117,6 +1152,8 @@ const fileOps = {
console.log('Response status:', response.status); console.log('Response status:', response.status);
if (response.ok) { if (response.ok) {
// Name changed — drop the cached breadcrumb metadata
invalidateFolderMeta(folderId);
ui.showNotification('Folder renamed', `Folder renamed to "${newName}"`); ui.showNotification('Folder renamed', `Folder renamed to "${newName}"`);
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
@@ -1340,32 +1377,13 @@ const fileOps = {
}, },
/** /**
* Download a file * Download a file — handed to the browser so it streams to disk with
* its native download UI instead of buffering the file in memory.
* @param {string} fileId - File ID * @param {string} fileId - File ID
* @param {string} fileName - File name * @param {string} fileName - File name
*/ */
async downloadFile(fileId, fileName) { async downloadFile(fileId, fileName) {
try { triggerBrowserDownload(`/api/files/${fileId}`, fileName);
const response = await fetch(`/api/files/${fileId}`, {
headers: getAuthHeaders()
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} else {
ui.showNotification('Error', 'Error downloading the file');
}
} catch (error) {
console.error('Error downloading file:', error);
ui.showNotification('Error', 'Error downloading the file');
}
}, },
/** /**
@@ -1374,30 +1392,10 @@ const fileOps = {
* @param {string} folderName - Folder name * @param {string} folderName - Folder name
*/ */
async downloadFolder(folderId, folderName) { async downloadFolder(folderId, folderName) {
try { // Show notification to user (the server still has to assemble the
// Show notification to user // ZIP before the browser's own download UI takes over).
ui.showNotification('Preparing download', 'Preparing the folder for download...'); ui.showNotification('Preparing download', 'Preparing the folder for download...');
triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`);
const response = await fetch(`/api/folders/${folderId}/download?format=zip`, {
headers: getAuthHeaders()
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${folderName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} else {
ui.showNotification('Error', 'Error downloading the folder');
}
} catch (error) {
console.error('Error downloading folder:', error);
ui.showNotification('Error', 'Error downloading the folder');
}
} }
}; };
+16 -48
View File
@@ -6,6 +6,7 @@
import { updateHistory } from '../../app/main.js'; import { updateHistory } from '../../app/main.js';
import { app } from '../../app/state.js'; import { app } from '../../app/state.js';
import { isTextViewable } from '../../core/formatters.js'; import { isTextViewable } from '../../core/formatters.js';
import { triggerBrowserDownload } from '../../utils/download.js';
import { wopiEditor } from './wopiEditor.js'; import { wopiEditor } from './wopiEditor.js';
/** @import {FileItem} from '../../core/types.js' */ /** @import {FileItem} from '../../core/types.js' */
@@ -397,29 +398,22 @@ class InlineViewer {
} }
/** /**
* Creates an audio or video player using blob URL (authenticated fetch) * Creates an audio or video player that streams straight from the API.
* The element's `src` points at the same-origin endpoint (cookies are
* sent automatically), so the browser issues Range requests and starts
* playback progressively — the file is never materialized in memory,
* and seeking works without downloading everything first.
* @param {FileItem} file * @param {FileItem} file
* @param {string} mediaType * @param {string} mediaType
* @param {HTMLDivElement} container * @param {HTMLDivElement} container
* @param {HTMLDivElement} loader * @param {HTMLDivElement} loader
*/ */
async createMediaViewer(file, mediaType, container, loader) { createMediaViewer(file, mediaType, container, loader) {
try {
console.log(`Creating ${mediaType} player for:`, file.name); console.log(`Creating ${mediaType} player for:`, file.name);
// Fetch file (cookie auto-sent) const streamUrl = `/api/files/${file.id}?inline=true`;
const response = await fetch(`/api/files/${file.id}?inline=true`, {
credentials: 'same-origin'
});
if (!response.ok) { // The native player has its own buffering UI — drop our spinner now.
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Remove loader
if (loader?.parentNode) { if (loader?.parentNode) {
loader.parentNode.removeChild(loader); loader.parentNode.removeChild(loader);
} }
@@ -443,10 +437,10 @@ class InlineViewer {
audio.className = 'inline-viewer-audio'; audio.className = 'inline-viewer-audio';
audio.controls = true; audio.controls = true;
audio.preload = 'metadata'; audio.preload = 'metadata';
audio.src = blobUrl; audio.src = streamUrl;
wrapper.appendChild(audio); wrapper.appendChild(audio);
// Fallback message for unsupported codecs // Fallback message for unsupported codecs / failed loads
audio.addEventListener('error', () => { audio.addEventListener('error', () => {
console.warn('Audio playback error — codec may not be supported'); console.warn('Audio playback error — codec may not be supported');
wrapper.innerHTML = ''; wrapper.innerHTML = '';
@@ -468,10 +462,10 @@ class InlineViewer {
video.className = 'inline-viewer-video'; video.className = 'inline-viewer-video';
video.controls = true; video.controls = true;
video.preload = 'metadata'; video.preload = 'metadata';
video.src = blobUrl; video.src = streamUrl;
video.setAttribute('playsinline', 'true'); video.setAttribute('playsinline', 'true');
// Fallback message for unsupported codecs // Fallback message for unsupported codecs / failed loads
video.addEventListener('error', () => { video.addEventListener('error', () => {
console.warn('Video playback error — codec may not be supported'); console.warn('Video playback error — codec may not be supported');
if (video.parentNode) { if (video.parentNode) {
@@ -491,18 +485,6 @@ class InlineViewer {
container.appendChild(video); container.appendChild(video);
} }
// Store blob URL for cleanup on close
this.currentBlobUrl = blobUrl;
} catch (error) {
console.error(`Error creating ${mediaType} viewer:`, error);
if (loader?.parentNode) {
loader.parentNode.removeChild(loader);
}
this.showErrorMessage(container);
}
} }
/** /**
@@ -549,26 +531,12 @@ class InlineViewer {
} }
/** /**
* * Download the file via a browser-native download (streams to disk,
* nothing is buffered in page memory).
* @param {FileItem} file * @param {FileItem} file
*/ */
downloadFile(file) { downloadFile(file) {
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' }) triggerBrowserDownload(`/api/files/${file.id}`, file.name);
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch((err) => console.error('Download error:', err));
} }
/** /**
+5 -10
View File
@@ -7,7 +7,7 @@
* displays the enriched results returned by the server. * displays the enriched results returned by the server.
*/ */
import { addItem as filesViewAddItem, loadFiles } from '../../app/filesView.js'; import { loadFiles, renderItems } from '../../app/filesView.js';
import { app } from '../../app/state.js'; import { app } from '../../app/state.js';
import { ui } from '../../app/ui.js'; import { ui } from '../../app/ui.js';
import { getAuthHeaders } from './fileOperations.js'; import { getAuthHeaders } from './fileOperations.js';
@@ -198,15 +198,10 @@ const search = {
return; return;
} }
// Render folders (server-provided enriched data) // Render the whole result set (server-provided enriched data) in one
results.folders.forEach((folder) => { // batched pass — per-item inserts would trigger a DOM scan, a smooth
filesViewAddItem(folder); // scroll and a highlight pulse for each of up to 100 rows.
}); renderItems([...results.folders, ...results.files]);
// Render files (server-provided enriched data)
results.files.forEach((file) => {
filesViewAddItem(file);
});
}, },
/** /**
+30 -9
View File
@@ -472,22 +472,43 @@ const photosView = {
if (bar_delete) { if (bar_delete) {
bar_delete.onclick = async () => { bar_delete.onclick = async () => {
if (!confirm('Delete selected items?')) return; if (!confirm('Delete selected items?')) return;
for (const fid of this.selected) {
// One batch request per chunk instead of one DELETE per photo.
// The photos view is files-only, so every id is a file id.
const ids = [...this.selected];
const CHUNK_SIZE = 1000; // backend MAX_BATCH_SIZE
const trashed = new Set();
try { try {
await fetch(`/api/files/${fid}`, { for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
method: 'DELETE', const chunk = ids.slice(i, i + CHUNK_SIZE);
const response = await fetch('/api/batch/trash', {
method: 'POST',
credentials: 'include', credentials: 'include',
headers: this._headers() headers: this._headers(true),
body: JSON.stringify({ file_ids: chunk, folder_ids: [] })
}); });
// 200 = all trashed, 206 = partial; both carry `successful`.
if (!response.ok && response.status !== 206) {
console.error('Batch trash failed:', response.status);
continue;
}
const data = await response.json();
const ok = Array.isArray(data?.successful) ? data.successful : chunk;
for (const id of ok) trashed.add(id);
}
} catch (err) { } catch (err) {
console.error('Delete failed:', fid, err); console.error('Batch trash error:', err);
} }
}
this.items = this.items.filter((f) => !this.selected.has(f.id)); if (trashed.size > 0) {
this.selected.clear(); this.items = this.items.filter((f) => !trashed.has(f.id));
this._hideSelectionBar(); for (const id of trashed) this.selected.delete(id);
this._renderedCount = 0; this._renderedCount = 0;
this._renderFull(); this._renderFull();
}
// Refresh (or hide) the bar to reflect any items left selected.
this._updateSelectionBar();
}; };
} }
+62 -7
View File
@@ -33,6 +33,48 @@ async function getFolder(id) {
return Promise.reject(null); return Promise.reject(null);
} }
/**
* Session cache of folder metadata for breadcrumb resolution (`id →
* FolderItem`). Ancestors of the current folder have almost always been
* visited already, so a warm navigation rebuilds the whole crumb trail
* with zero fetches instead of one round-trip per depth level.
* Invalidated on rename/move via {@link invalidateFolderMeta}.
* @type {Map<string, FolderItem>}
*/
const _folderMetaCache = new Map();
/**
* Resolve breadcrumb metadata for one folder, consulting the session
* cache first.
* @param {string} id
* @returns {Promise<FolderItem>}
*/
async function _getFolderMeta(id) {
const cached = _folderMetaCache.get(id);
if (cached) return cached;
const info = await getFolder(id);
_folderMetaCache.set(id, info);
return info;
}
/**
* Drop the cached breadcrumb metadata for a folder. Call after any
* operation that changes its name or parent (rename, move) so the next
* breadcrumb rebuild re-fetches the fresh row.
* @param {string} folderId
*/
function invalidateFolderMeta(folderId) {
_folderMetaCache.delete(folderId);
}
/**
* Monotonic token identifying the most recent {@link rebuildBreadCrumb}
* call. Rebuilds now run concurrently with the listing fetch, so a rapid
* second navigation can supersede one still in flight — the superseded
* run must not commit its (stale) trail over the newer one.
*/
let _breadcrumbGeneration = 0;
/** /**
* Walk up the folder hierarchy to rebuild `app.breadcrumbPath`. * Walk up the folder hierarchy to rebuild `app.breadcrumbPath`.
* *
@@ -41,29 +83,39 @@ async function getFolder(id) {
* handles shared folders the user cannot traverse beyond. * handles shared folders the user cannot traverse beyond.
* *
* An error on the target folder itself is treated as a real error and falls * An error on the target folder itself is treated as a real error and falls
* back to the home folder. * back to the home folder (resetting `app.currentPath`).
* *
* @returns {Promise<void>} * The trail is built locally and committed to `app` atomically at the end,
* and only when this call is still the most recent one — callers run this
* concurrently with the listing fetch.
*
* @returns {Promise<boolean>} `true` when the trail was committed; `false`
* when this rebuild was superseded by a newer navigation.
*/ */
async function rebuildBreadCrumb() { async function rebuildBreadCrumb() {
const generation = ++_breadcrumbGeneration;
/** @type {FolderItem|null} */ /** @type {FolderItem|null} */
let currentFolderInfo = null; let currentFolderInfo = null;
app.breadcrumbPath = []; /** @type {Array<{id: string, name: string}>} */
const crumbs = [];
/** @type {string|null} */ /** @type {string|null} */
let id = app.currentPath; let id = app.currentPath;
while (id !== null) { while (id !== null) {
try { try {
const folderInfo = await getFolder(id); const folderInfo = await _getFolderMeta(id);
if (generation !== _breadcrumbGeneration) return false;
if (currentFolderInfo === null) currentFolderInfo = folderInfo; if (currentFolderInfo === null) currentFolderInfo = folderInfo;
app.breadcrumbPath.unshift({ id: folderInfo.id, name: folderInfo.name }); crumbs.unshift({ id: folderInfo.id, name: folderInfo.name });
id = folderInfo.parent_id; id = folderInfo.parent_id;
} catch (_e) { } catch (_e) {
if (generation !== _breadcrumbGeneration) return false;
if (currentFolderInfo === null) { if (currentFolderInfo === null) {
console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`); console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`);
uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights'); uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights');
app.breadcrumbPath = []; crumbs.length = 0;
id = app.userHomeFolderId; id = app.userHomeFolderId;
if (id) app.currentPath = id; if (id) app.currentPath = id;
} else { } else {
@@ -73,7 +125,10 @@ async function rebuildBreadCrumb() {
} }
} }
if (generation !== _breadcrumbGeneration) return false;
app.breadcrumbPath = crumbs;
app.currentFolderInfo = currentFolderInfo; app.currentFolderInfo = currentFolderInfo;
return true;
} }
/** /**
@@ -175,4 +230,4 @@ async function fetchResourcesPage(folderId, { cursor = null, orderBy = 'name', l
return { items, nextCursor: data.next_cursor ?? null }; return { items, nextCursor: data.next_cursor ?? null };
} }
export { fetchListing, fetchResourcesPage, getFolder, rebuildBreadCrumb }; export { fetchListing, fetchResourcesPage, getFolder, invalidateFolderMeta, rebuildBreadCrumb };
+44
View File
@@ -0,0 +1,44 @@
// @ts-check
/**
* Browser-native download helpers.
*
* Downloads are handed to the browser as same-origin navigations: the
* response streams straight to disk with the browser's own progress UI,
* and auth cookies travel automatically. Nothing is buffered in page
* memory — unlike the old `fetch → blob → objectURL` pattern, which
* materialized the entire payload in the tab's heap before the save
* dialog could even appear.
*/
/**
* Trigger a browser-native download for a same-origin URL.
*
* @param {string} url - Same-origin URL of the resource to download
* @param {string} [filename] - Suggested file name. The server's
* `Content-Disposition` filename wins when present; an empty string
* keeps whatever the server (or URL) provides.
*/
export function triggerBrowserDownload(url, filename = '') {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Build the GET URL for the batch ZIP download endpoint
* (`GET /api/batch/download` accepts comma-separated id lists).
* Shared by the batch toolbar download and the drag-out `DownloadURL`
* builder so both stay in sync with the endpoint's query contract.
*
* @param {string[]} fileIds
* @param {string[]} folderIds
* @returns {string} Root-relative URL (prepend `window.location.origin`
* when an absolute URL is required, e.g. for `DataTransfer.setData`).
*/
export function buildBatchDownloadUrl(fileIds, folderIds) {
return `/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`;
}