Merge pull request #445 from AtalayaLabs/claude/upbeat-wright-btruto
Streaming PROPFIND, range requests, and batch ID resolution
This commit is contained in:
@@ -87,6 +87,14 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn delete_event(&self, event_id: &str) -> Result<(), 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(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
@@ -180,6 +188,15 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
event_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> 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(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
|
||||
@@ -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 get_contact(&self, contact_id: &str, user_id: Uuid)
|
||||
-> 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(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::common::config::OidcConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
|
||||
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::infrastructure::repositories::pg::SessionPgRepository;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
@@ -140,8 +140,20 @@ pub struct AuthApplicationService {
|
||||
/// Magic-link token repository — populated when the magic-link feature
|
||||
/// is enabled (PR 8+). `None` means redemption endpoints return 503.
|
||||
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 {
|
||||
pub fn new(
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
@@ -170,6 +182,10 @@ impl AuthApplicationService {
|
||||
.time_to_live(Duration::from_secs(60))
|
||||
.build(),
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// Hard rules:
|
||||
@@ -1797,7 +1831,9 @@ impl AuthApplicationService {
|
||||
pub async fn set_user_active(&self, user_id: Uuid, active: bool) -> Result<(), DomainError> {
|
||||
self.user_storage
|
||||
.set_user_active_status(user_id, active)
|
||||
.await
|
||||
.await?;
|
||||
self.user_flags_cache.invalidate(&user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change user role (admin only)
|
||||
@@ -1809,7 +1845,9 @@ impl AuthApplicationService {
|
||||
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)
|
||||
|
||||
@@ -277,6 +277,29 @@ impl CalendarUseCase for CalendarService {
|
||||
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(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
|
||||
@@ -780,6 +780,22 @@ impl ContactUseCase for ContactService {
|
||||
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(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use moka::future::Cache;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
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)]
|
||||
pub struct NextcloudFileIdService {
|
||||
repo: Option<Arc<NextcloudObjectIdRepository>>,
|
||||
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 {
|
||||
@@ -14,6 +27,7 @@ impl NextcloudFileIdService {
|
||||
Self {
|
||||
repo: Some(repo),
|
||||
instance_id,
|
||||
cache: Cache::new(ID_CACHE_CAPACITY),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,29 +35,76 @@ impl NextcloudFileIdService {
|
||||
Self {
|
||||
repo: None,
|
||||
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> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
||||
})?;
|
||||
repo.get_or_create("file", file_id).await
|
||||
/// Resolve — creating when absent — stable numeric file IDs for many
|
||||
/// UUIDs at once. Cache hits cost nothing; the misses are resolved with a
|
||||
/// single backing query. The returned map is keyed by the caller's
|
||||
/// original id strings; unresolvable inputs are simply absent (mirroring
|
||||
/// 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> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
/// Folder counterpart of [`Self::get_or_create_file_ids`].
|
||||
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")
|
||||
})?;
|
||||
repo.get_or_create("folder", folder_id).await
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the OxiCloud file UUID from a Nextcloud numeric ID.
|
||||
pub async fn get_oxicloud_id(&self, nc_file_id: i64) -> Result<String> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
||||
})?;
|
||||
repo.get_object_id(nc_file_id, "file").await
|
||||
self.repo()?.get_object_id(nc_file_id, "file").await
|
||||
}
|
||||
|
||||
pub fn format_oc_id(&self, id: i64) -> String {
|
||||
@@ -59,6 +120,7 @@ impl NextcloudFileIdService {
|
||||
Self {
|
||||
repo: None,
|
||||
instance_id: instance_id.to_string(),
|
||||
cache: Cache::new(ID_CACHE_CAPACITY),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,4 +169,25 @@ mod tests {
|
||||
let svc = NextcloudFileIdService::new_stub();
|
||||
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::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.
|
||||
/// `GrantCreated` → fired implicitly when a grant lands; `ManualResend`
|
||||
/// → granter explicitly clicked "Notify by email" in My Shares.
|
||||
@@ -268,13 +273,22 @@ impl RecipientNotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
let mut outcomes = Vec::with_capacity(members.len());
|
||||
for member in &members {
|
||||
let outcome = self
|
||||
.dispatch_to_one_user(granter, member, resource, trigger)
|
||||
// SMTP dispatch dominates each iteration (hundreds of ms per
|
||||
// recipient) and the iterations are independent — coalescing and
|
||||
// rate-limiting key on (granter, recipient), which is distinct per
|
||||
// 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;
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
Ok(NotifyOutcomeSet { outcomes })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
pub struct User {
|
||||
id: Uuid,
|
||||
|
||||
@@ -402,6 +402,26 @@ impl CalendarStoragePort for CalendarStorageAdapter {
|
||||
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(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
|
||||
@@ -712,6 +712,24 @@ impl ContactUseCase for ContactStorageAdapter {
|
||||
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(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
|
||||
@@ -12,29 +14,73 @@ impl NextcloudObjectIdRepository {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn get_or_create(&self, object_type: &str, object_id: &str) -> Result<i64> {
|
||||
let row = sqlx::query(
|
||||
/// Resolve — creating when absent — stable numeric IDs for a batch of
|
||||
/// 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#"
|
||||
INSERT INTO storage.nextcloud_object_ids (object_type, object_id)
|
||||
VALUES ($1, $2::uuid)
|
||||
ON CONFLICT (object_type, object_id)
|
||||
DO UPDATE SET object_id = EXCLUDED.object_id
|
||||
RETURNING id
|
||||
SELECT $1, u FROM unnest($2::uuid[]) AS u
|
||||
ON CONFLICT (object_type, object_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(object_type)
|
||||
.bind(object_id)
|
||||
.fetch_one(&*self.pool)
|
||||
.bind(object_ids)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"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.
|
||||
|
||||
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::auth_ports::UserStoragePort;
|
||||
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::{
|
||||
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
|
||||
/// `UserRepository` trait — called directly from `AuthApplicationService`.
|
||||
pub async fn update_image(
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
//! - **Dedicated `rayon` thread pool** for CPU-bound transcoding (never blocks Tokio)
|
||||
//! - **`moka` lock-free cache** for hot transcoded images (no write-lock on reads)
|
||||
//! - Disk cache for persistence across restarts
|
||||
//! - Supports JPEG, PNG, GIF → WebP conversion
|
||||
//! - Falls back to original if conversion fails or result is larger
|
||||
//! - Supports PNG, GIF → WebP conversion (JPEG excluded — the encoder is
|
||||
//! 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 image::ImageFormat;
|
||||
@@ -188,12 +191,16 @@ impl ImageTranscodeService {
|
||||
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 {
|
||||
matches!(
|
||||
mime_type,
|
||||
"image/jpeg" | "image/jpg" | "image/png" | "image/gif"
|
||||
)
|
||||
matches!(mime_type, "image/png" | "image/gif")
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
||||
// ── 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 {
|
||||
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);
|
||||
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) ──
|
||||
let content_for_rayon = original_content.clone(); // O(1) ref-count bump
|
||||
let mime_owned = original_mime.to_string();
|
||||
@@ -269,6 +293,20 @@ impl ImageTranscodeService {
|
||||
original_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));
|
||||
}
|
||||
|
||||
@@ -319,6 +357,16 @@ impl ImageTranscodeService {
|
||||
.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
|
||||
pub async fn invalidate(&self, file_id: &str) {
|
||||
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 _ = 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
|
||||
@@ -463,9 +513,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_can_transcode() {
|
||||
assert!(ImageTranscodeService::can_transcode("image/jpeg"));
|
||||
assert!(ImageTranscodeService::can_transcode("image/png"));
|
||||
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/svg+xml"));
|
||||
assert!(!ImageTranscodeService::can_transcode("application/pdf"));
|
||||
@@ -473,16 +524,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_should_transcode() {
|
||||
// Small JPEG - yes
|
||||
// Small PNG - yes
|
||||
assert!(ImageTranscodeService::should_transcode(
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
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(
|
||||
"image/jpeg",
|
||||
10 * 1024 * 1024
|
||||
1024 * 1024
|
||||
));
|
||||
|
||||
// WebP - no (already optimal)
|
||||
|
||||
@@ -290,10 +290,7 @@ pub async fn login(
|
||||
// same account from a different address (issue #323). The check runs
|
||||
// BEFORE Argon2 to save CPU under brute-force attacks.
|
||||
let client_ip = client_ip_from_parts(&headers, Some(peer), false);
|
||||
if let Err(lockout_secs) = auth_service
|
||||
.login_lockout
|
||||
.check(&dto.username, &client_ip)
|
||||
{
|
||||
if let Err(lockout_secs) = auth_service.login_lockout.check(&dto.username, &client_ip) {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.login",
|
||||
|
||||
@@ -608,12 +608,13 @@ async fn handle_put(
|
||||
|
||||
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 events = calendar_service
|
||||
.list_events(calendar_id, None, None, user.id)
|
||||
calendar_service
|
||||
.get_event_by_ical_uid(calendar_id, uid, user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
events.into_iter().find(|e| e.ical_uid == *uid)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -704,21 +705,17 @@ async fn handle_get(
|
||||
.body(Body::from(ical))
|
||||
.unwrap())
|
||||
} else {
|
||||
// GET on individual event
|
||||
// GET on individual event — indexed lookup by iCalendar UID.
|
||||
let event_file = parts[1];
|
||||
let ical_uid = event_file.trim_end_matches(".ics");
|
||||
|
||||
let events = calendar_service
|
||||
.list_events(calendar_id, None, None, user.id)
|
||||
let event = calendar_service
|
||||
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
|
||||
|
||||
let event = events
|
||||
.iter()
|
||||
.find(|e| e.ical_uid == ical_uid)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to look up event: {}", e)))?
|
||||
.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()
|
||||
.status(StatusCode::OK)
|
||||
@@ -813,14 +810,11 @@ async fn handle_delete(
|
||||
let event_file = parts[1];
|
||||
let ical_uid = event_file.trim_end_matches(".ics");
|
||||
|
||||
let events = calendar_service
|
||||
.list_events(calendar_id, None, None, user.id)
|
||||
// Indexed lookup by iCalendar UID instead of listing the calendar.
|
||||
let event = calendar_service
|
||||
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
|
||||
|
||||
let event = events
|
||||
.iter()
|
||||
.find(|e| e.ical_uid == ical_uid)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to look up event: {}", e)))?
|
||||
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
|
||||
|
||||
calendar_service
|
||||
|
||||
@@ -295,19 +295,14 @@ async fn handle_propfind(
|
||||
.body(Body::from(response_body))
|
||||
.unwrap())
|
||||
} else {
|
||||
// Individual contact .vcf
|
||||
// Individual contact .vcf — indexed lookup by vCard UID.
|
||||
let contact_file = parts[1];
|
||||
let contact_uid = contact_file.trim_end_matches(".vcf");
|
||||
|
||||
// Look up by UID across all contacts in this address book
|
||||
let contacts = contact_svc
|
||||
.list_contacts(address_book_id, user.id)
|
||||
let contact = contact_svc
|
||||
.get_contact_by_uid(address_book_id, contact_uid, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
|
||||
|
||||
let contact = contacts
|
||||
.iter()
|
||||
.find(|c| c.uid == contact_uid)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
|
||||
.ok_or_else(|| {
|
||||
AppError::not_found(format!("Contact not found: {}", contact_uid))
|
||||
})?;
|
||||
@@ -322,8 +317,8 @@ async fn handle_propfind(
|
||||
let mut response_body = Vec::new();
|
||||
CardDavAdapter::generate_contacts_response(
|
||||
&mut response_body,
|
||||
std::slice::from_ref(contact),
|
||||
&[(contact.uid.clone(), contact_to_vcard(contact))],
|
||||
std::slice::from_ref(&contact),
|
||||
&[(contact.uid.clone(), contact_to_vcard(&contact))],
|
||||
&report,
|
||||
base_href,
|
||||
)
|
||||
@@ -483,13 +478,13 @@ async fn handle_put(
|
||||
// Extract UID from vCard
|
||||
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 contacts = contact_svc
|
||||
.list_contacts(address_book_id, user.id)
|
||||
contact_svc
|
||||
.get_contact_by_uid(address_book_id, uid, user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
contacts.into_iter().find(|c| c.uid == *uid)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -579,21 +574,17 @@ async fn handle_get(
|
||||
.body(Body::from(vcf_data))
|
||||
.unwrap())
|
||||
} else {
|
||||
// GET on individual contact
|
||||
// GET on individual contact — indexed lookup by vCard UID.
|
||||
let contact_file = parts[1];
|
||||
let contact_uid = contact_file.trim_end_matches(".vcf");
|
||||
|
||||
let contacts = contact_svc
|
||||
.list_contacts(address_book_id, user.id)
|
||||
let contact = contact_svc
|
||||
.get_contact_by_uid(address_book_id, contact_uid, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
|
||||
|
||||
let contact = contacts
|
||||
.iter()
|
||||
.find(|c| c.uid == contact_uid)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
|
||||
.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()
|
||||
.status(StatusCode::OK)
|
||||
@@ -632,18 +623,14 @@ async fn handle_delete(
|
||||
AppError::internal_error(format!("Failed to delete address book: {}", e))
|
||||
})?;
|
||||
} else {
|
||||
// Delete contact
|
||||
// Delete contact — indexed lookup by vCard UID.
|
||||
let contact_file = parts[1];
|
||||
let contact_uid = contact_file.trim_end_matches(".vcf");
|
||||
|
||||
let contacts = contact_svc
|
||||
.list_contacts(address_book_id, user.id)
|
||||
let contact = contact_svc
|
||||
.get_contact_by_uid(address_book_id, contact_uid, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
|
||||
|
||||
let contact = contacts
|
||||
.iter()
|
||||
.find(|c| c.uid == contact_uid)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
|
||||
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
|
||||
|
||||
contact_svc
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
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 std::sync::Arc;
|
||||
|
||||
@@ -600,16 +601,8 @@ impl FileHandler {
|
||||
let etag = format!("\"{}\"", file_dto.etag);
|
||||
|
||||
// ── ETag (304 Not Modified) ──────────────────────────────────
|
||||
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
|
||||
&& let Ok(client_etag) = inm.to_str()
|
||||
&& (client_etag == etag || client_etag == "*")
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.header(header::ETAG, &etag)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
.into_response();
|
||||
if let Some(resp) = not_modified_response(&headers, &etag) {
|
||||
return resp.into_response();
|
||||
}
|
||||
|
||||
// ── Range Requests ───────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::common::di::AppState;
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::interfaces::errors::AppError;
|
||||
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 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
|
||||
/// 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)
|
||||
@@ -767,6 +770,21 @@ async fn handle_get(
|
||||
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
|
||||
let stream = file_retrieval_service
|
||||
.get_file_stream(&file.id)
|
||||
@@ -778,7 +796,8 @@ async fn handle_get(
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &*file.mime_type)
|
||||
.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,
|
||||
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
||||
|
||||
@@ -158,9 +158,7 @@ pub fn client_ip_from_parts(
|
||||
if let Some(peer_addr) = peer {
|
||||
if is_trusted_proxy(peer_addr.ip()) {
|
||||
// Try X-Forwarded-For first (leftmost = original client)
|
||||
if let Some(xff) = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||
&& let Some(ip) = xff
|
||||
.split(',')
|
||||
.next()
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
//! so handlers compose them uniformly as one-liners. They assume the
|
||||
//! caller has already been authenticated by the
|
||||
//! [`AuthUser`](super::auth::AuthUser) extractor, and pull the current
|
||||
//! user state from the database via `AuthApplicationService` so role /
|
||||
//! external-flag changes take effect on the next request without
|
||||
//! waiting for token rotation.
|
||||
//! user flags via `AuthApplicationService::get_user_flags` — a
|
||||
//! lightweight, short-TTL-cached lookup (no `image` column) — so role /
|
||||
//! 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
|
||||
//! let caller_id = auth_user.id;
|
||||
@@ -30,6 +32,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::entities::user::UserRole;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
|
||||
@@ -56,8 +59,8 @@ pub async fn require_internal_user(
|
||||
auth: &AuthApplicationService,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
match auth.get_user_by_id(caller_id).await {
|
||||
Ok(dto) if dto.is_external => Err(AppError::new(
|
||||
match auth.get_user_flags(caller_id).await {
|
||||
Ok(flags) if flags.is_external => Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"External users cannot access this endpoint",
|
||||
"Forbidden",
|
||||
@@ -70,9 +73,11 @@ pub async fn require_internal_user(
|
||||
/// admins, `Err(403)` otherwise.
|
||||
///
|
||||
/// The check pulls the role from the user record (not from JWT
|
||||
/// claims) so a role change takes effect on the next request without
|
||||
/// waiting for token rotation. Mirrors [`require_internal_user`]'s
|
||||
/// shape so handlers compose either of them as a one-liner via `?`.
|
||||
/// claims) so a role change takes effect within the flags-cache TTL —
|
||||
/// or immediately when changed through `change_user_role`, which
|
||||
/// 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
|
||||
/// [`AuthUser`](super::auth::AuthUser) extractor (and thus a validated
|
||||
@@ -82,12 +87,12 @@ pub async fn require_admin_user(
|
||||
auth: &AuthApplicationService,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user = auth
|
||||
.get_user_by_id(caller_id)
|
||||
let flags = auth
|
||||
.get_user_flags(caller_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if user.role != "admin" {
|
||||
if flags.role != UserRole::Admin {
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Admin access required",
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod api;
|
||||
pub mod errors;
|
||||
pub mod middleware;
|
||||
pub mod nextcloud;
|
||||
pub mod range_requests;
|
||||
pub mod upload_spool;
|
||||
pub mod web;
|
||||
|
||||
|
||||
@@ -64,8 +64,7 @@ pub async fn basic_auth_middleware(
|
||||
|
||||
// Check account lockout before attempting password verification (saves CPU).
|
||||
// The lockout is per (account, IP), see #323 for rationale.
|
||||
let client_ip =
|
||||
crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
|
||||
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
|
||||
if let Some(auth_svc) = state.auth_service.as_ref()
|
||||
&& 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
|
||||
// through (e.g. user later flipped to is_external).
|
||||
if let Some(auth_svc) = state.auth_service.as_ref()
|
||||
&& let Ok(user) = auth_svc
|
||||
&& let Ok(flags) = auth_svc
|
||||
.auth_application_service
|
||||
.get_user_by_id(user_id)
|
||||
.get_user_flags(user_id)
|
||||
.await
|
||||
&& user.is_external
|
||||
&& flags.is_external
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
|
||||
@@ -5,6 +5,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
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);
|
||||
|
||||
// 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();
|
||||
|
||||
// Map file results
|
||||
@@ -394,11 +406,7 @@ pub async fn handle_search(
|
||||
.unwrap_or(&file.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
let numeric_id = if let Some(svc) = file_id_svc {
|
||||
svc.get_or_create_file_id(&file.id).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let numeric_id = file_id_map.get(&file.id).copied();
|
||||
|
||||
let thumbnail_url = match numeric_id {
|
||||
Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid),
|
||||
|
||||
@@ -25,8 +25,7 @@ use crate::domain::entities::file::File;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
format_oc_id, nc_href, resolve_file_id, resolve_folder_id, write_file_response,
|
||||
write_folder_response,
|
||||
batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
|
||||
};
|
||||
|
||||
/// 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);
|
||||
|
||||
// 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 xml = Writer::new(&mut buf);
|
||||
|
||||
write_multistatus_start(&mut xml)?;
|
||||
|
||||
for fav in &favorites {
|
||||
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.
|
||||
};
|
||||
for file in &files {
|
||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||
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));
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
&file,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
@@ -115,18 +135,15 @@ async fn handle_filter_files(
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
"folder" => {
|
||||
let folder = match folder_service.get_folder(&fav.item_id).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for folder in &folders {
|
||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||
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));
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
&folder,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
@@ -135,9 +152,6 @@ async fn handle_filter_files(
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
.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.
|
||||
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 xml = Writer::new(&mut buf);
|
||||
@@ -199,15 +222,14 @@ async fn handle_search(
|
||||
write_multistatus_start(&mut xml)?;
|
||||
|
||||
// Files.
|
||||
for fr in &results.files {
|
||||
let file = file_dto_from_search(fr);
|
||||
for file in &files {
|
||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||
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));
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
&file,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
@@ -218,15 +240,14 @@ async fn handle_search(
|
||||
}
|
||||
|
||||
// Folders.
|
||||
for sr in &results.folders {
|
||||
let folder = folder_dto_from_search(sr);
|
||||
for folder in &folders {
|
||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||
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));
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
&folder,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||
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");
|
||||
@@ -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::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
|
||||
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.
|
||||
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.
|
||||
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")))
|
||||
@@ -267,11 +284,12 @@ fn write_trash_root_response<W: std::io::Write>(
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
item: &TrashedItemDto,
|
||||
username: &str,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
id_map: &HashMap<String, i64>,
|
||||
) -> Result<(), String> {
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -318,12 +336,8 @@ async fn write_trash_item_response<W: std::io::Write>(
|
||||
// d:getcontentlength
|
||||
write_text_element(xml, "d:getcontentlength", "0")?;
|
||||
|
||||
// oc:fileid and oc:id — resolve numeric ID via file_id service
|
||||
let file_id = if item.item_type == "folder" {
|
||||
resolve_folder_id(file_id_svc, &item.original_id).await
|
||||
} else {
|
||||
resolve_file_id(file_id_svc, &item.original_id).await
|
||||
};
|
||||
// oc:fileid and oc:id — resolved up front in a batch query.
|
||||
let file_id = id_map.get(&item.original_id).copied();
|
||||
if let Some(id) = file_id {
|
||||
write_text_element(xml, "oc:fileid", &id.to_string())?;
|
||||
let oc_id = format_oc_id(id, file_id_svc);
|
||||
|
||||
@@ -3,16 +3,18 @@ use axum::{
|
||||
http::{HeaderName, Request, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use bytes::Buf;
|
||||
use bytes::{Buf, Bytes};
|
||||
use chrono::Utc;
|
||||
use quick_xml::{
|
||||
Writer,
|
||||
events::{BytesEnd, BytesStart, BytesText, Event},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
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::file_ports::{
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
|
||||
@@ -21,8 +23,10 @@ use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::di::AppState;
|
||||
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::middleware::auth::{AuthUser, CurrentUser};
|
||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||
use crate::interfaces::upload_spool::spool_body_to_temp;
|
||||
|
||||
/// 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();
|
||||
match method.as_str() {
|
||||
"OPTIONS" => handle_options(),
|
||||
"GET" => handle_get(state, &user, &subpath, req.headers()).await,
|
||||
"PROPFIND" => handle_propfind(state, req, &user, &subpath).await,
|
||||
"GET" => handle_get(state, &user, &subpath).await,
|
||||
"PUT" => handle_put(state, req, &user, &subpath).await,
|
||||
"MKCOL" => handle_mkcol(state, &user, &subpath).await,
|
||||
"DELETE" => handle_delete(state, &user, &subpath).await,
|
||||
@@ -182,7 +186,10 @@ async fn handle_propfind(
|
||||
.await
|
||||
.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 {
|
||||
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;
|
||||
|
||||
if let Ok(folder) = folder_result {
|
||||
// It's a folder.
|
||||
let (files, subfolders) = if depth != "0" {
|
||||
let files = file_service
|
||||
.list_files(Some(&folder.id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let subfolders = folder_service
|
||||
.list_folders(Some(&folder.id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
(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());
|
||||
// It's a folder — stream the multistatus: children are fetched in
|
||||
// pages and serialized chunk by chunk, so memory stays O(batch)
|
||||
// regardless of how many entries the folder holds.
|
||||
return Ok(build_nc_streaming_propfind(
|
||||
state.clone(),
|
||||
folder,
|
||||
depth,
|
||||
user.id,
|
||||
user.username.clone(),
|
||||
subpath.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 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 mut buf = Vec::new();
|
||||
write_nc_multistatus(
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
None,
|
||||
&[file],
|
||||
&[],
|
||||
&propfind,
|
||||
"0",
|
||||
&file,
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
@@ -308,6 +264,7 @@ async fn handle_get(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
subpath: &str,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
// GET on root folder — NC clients use this as an existence check
|
||||
if subpath.is_empty() || subpath == "/" {
|
||||
@@ -340,6 +297,25 @@ async fn handle_get(
|
||||
.await
|
||||
.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
|
||||
.get_file_stream(&file.id)
|
||||
.await
|
||||
@@ -349,18 +325,12 @@ async fn handle_get(
|
||||
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
||||
.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()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
|
||||
.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())
|
||||
.body(Body::from_stream(std::pin::Pin::from(stream)))
|
||||
.unwrap())
|
||||
@@ -987,65 +957,37 @@ use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||||
|
||||
/// Generate a complete Nextcloud-compatible multistatus XML response.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn write_nc_multistatus<W: std::io::Write>(
|
||||
writer: W,
|
||||
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.
|
||||
/// Write the `<d:multistatus>` opening tag with the full NC namespace set.
|
||||
/// Shared by the streaming folder PROPFIND and the single-file variant so
|
||||
/// the namespace list can never diverge between the two.
|
||||
fn write_nc_multistatus_open<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(), String> {
|
||||
let mut ms = BytesStart::new("d:multistatus");
|
||||
ms.push_attribute(("xmlns:d", "DAV:"));
|
||||
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
|
||||
ms.push_attribute(("xmlns:nc", "http://nextcloud.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,
|
||||
)?;
|
||||
}
|
||||
/// Generate the multistatus XML for a single-file PROPFIND. The folder
|
||||
/// case streams via [`build_nc_streaming_propfind`] instead.
|
||||
async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
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;
|
||||
|
||||
// When folder is None, files are the target resource itself (single-file
|
||||
// PROPFIND) and must always be emitted. When folder is Some, files/subfolders
|
||||
// are children and should only be listed when depth > 0.
|
||||
let emit_children = folder.is_none() || depth != "0";
|
||||
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.
|
||||
subpath.to_string()
|
||||
} else if subpath.is_empty() {
|
||||
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 href = nc_href(username, subpath);
|
||||
let file_id = file_id_map.get(&file.id).copied();
|
||||
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
@@ -1056,29 +998,6 @@ async fn write_nc_multistatus<W: std::io::Write>(
|
||||
username,
|
||||
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_err()?;
|
||||
@@ -1086,6 +1005,174 @@ async fn write_nc_multistatus<W: std::io::Write>(
|
||||
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>(
|
||||
xml: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
@@ -1273,20 +1360,24 @@ pub fn write_text_element<W: std::io::Write>(
|
||||
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>>,
|
||||
file_uuid: &str,
|
||||
) -> Option<i64> {
|
||||
let svc = svc?;
|
||||
svc.get_or_create_file_id(file_uuid).await.ok()
|
||||
}
|
||||
|
||||
pub async fn resolve_folder_id(
|
||||
svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
folder_uuid: &str,
|
||||
) -> Option<i64> {
|
||||
let svc = svc?;
|
||||
svc.get_or_create_folder_id(folder_uuid).await.ok()
|
||||
file_uuids: &[String],
|
||||
folder_uuids: &[String],
|
||||
) -> (HashMap<String, i64>, HashMap<String, i64>) {
|
||||
let Some(svc) = svc else {
|
||||
return (HashMap::new(), HashMap::new());
|
||||
};
|
||||
let (files, folders) = tokio::join!(
|
||||
svc.get_or_create_file_ids(file_uuids),
|
||||
svc.get_or_create_folder_ids(folder_uuids),
|
||||
);
|
||||
(files.unwrap_or_default(), folders.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn format_oc_id(id: i64, svc: Option<&Arc<NextcloudFileIdService>>) -> String {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
updateHistory(options.insertHistory ?? true);
|
||||
});
|
||||
|
||||
clearTimeout(spinnerTimeout);
|
||||
|
||||
@@ -457,6 +465,17 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
// Hand off to _loadPage (re-use cursor/groupBy state just reset above).
|
||||
_loading = false; // _loadPage sets its own guard
|
||||
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.
|
||||
// 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.
|
||||
* Call this after the outgoing grants cache has been refreshed.
|
||||
@@ -504,4 +538,4 @@ function refreshSharedBadges() {
|
||||
_component?.refreshSharedBadges();
|
||||
}
|
||||
|
||||
export { addItem, filesView, loadFiles, refreshSharedBadges };
|
||||
export { addItem, filesView, loadFiles, refreshSharedBadges, renderItems };
|
||||
|
||||
+82
-28
@@ -14,6 +14,7 @@ import { fileOps } from '../features/files/fileOperations.js';
|
||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||
import { wopiEditor } from '../features/files/wopiEditor.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { buildBatchDownloadUrl } from '../utils/download.js';
|
||||
import { positionMenu } from '../utils/menuPosition.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
import { updateHistory } from './main.js';
|
||||
@@ -774,7 +775,7 @@ const ui = {
|
||||
if (item?.type === 'file') fileIds.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}`);
|
||||
@@ -1099,6 +1100,69 @@ function initRubberBandSelection() {
|
||||
let active = false;
|
||||
let startX = 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)
|
||||
const container = document.querySelector('.files-container') || document.getElementById('files-list');
|
||||
@@ -1123,6 +1187,10 @@ function initRubberBandSelection() {
|
||||
active = true;
|
||||
startX = e.clientX;
|
||||
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.top = `${startY}px`;
|
||||
@@ -1136,8 +1204,8 @@ function initRubberBandSelection() {
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!active) return;
|
||||
|
||||
const curX = e.clientX;
|
||||
const curY = e.clientY;
|
||||
curX = e.clientX;
|
||||
curY = e.clientY;
|
||||
|
||||
const left = Math.min(startX, curX);
|
||||
const top = Math.min(startY, curY);
|
||||
@@ -1149,41 +1217,27 @@ function initRubberBandSelection() {
|
||||
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.top = `${top}px`;
|
||||
selRect.style.width = `${width}px`;
|
||||
selRect.style.height = `${height}px`;
|
||||
|
||||
// Highlight cards that intersect with the rectangle
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!rafId) rafId = requestAnimationFrame(classifyCards);
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!active) return;
|
||||
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';
|
||||
selRect.style.display = 'none';
|
||||
// Update the batch bar after rubber band selection completes
|
||||
|
||||
@@ -643,7 +643,6 @@ export class ResourceListComponent {
|
||||
`;
|
||||
|
||||
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
|
||||
this._bindItemEvents(el, folder);
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -693,7 +692,6 @@ export class ResourceListComponent {
|
||||
`;
|
||||
|
||||
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file'));
|
||||
this._bindItemEvents(el, file);
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -716,57 +714,6 @@ export class ResourceListComponent {
|
||||
.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. */
|
||||
_initDelegation() {
|
||||
const container = this._container;
|
||||
@@ -791,6 +738,39 @@ export class ResourceListComponent {
|
||||
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)
|
||||
if (cfg.selectable && target.closest('.checkbox-cell')) {
|
||||
if (e.shiftKey) {
|
||||
@@ -801,9 +781,6 @@ export class ResourceListComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Favorite star is handled by the direct listener in _bindItemEvents
|
||||
if (target.closest('.favorite-star')) return;
|
||||
|
||||
// Modifier-key click → selection toggle
|
||||
if (e.metaKey || e.altKey || e.ctrlKey) {
|
||||
if (cfg.selectable) this._toggleSelection(card);
|
||||
|
||||
+35
-9
@@ -618,27 +618,53 @@ function replaceIconsInElement(container) {
|
||||
|
||||
function oxiIconsInit() {
|
||||
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 = () => {
|
||||
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
|
||||
const fullSweep = () => replaceIconsInElement();
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
document.addEventListener('DOMContentLoaded', fullSweep);
|
||||
} 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) => {
|
||||
if (raf) return;
|
||||
for (let i = 0; i < mutations.length; i++) {
|
||||
if (mutations[i].addedNodes.length) {
|
||||
raf = requestAnimationFrame(scan);
|
||||
return;
|
||||
for (const mutation of mutations) {
|
||||
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
||||
const node = mutation.addedNodes[i];
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
pendingRoots.add(/** @type {Element} */ (node));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!raf && pendingRoots.size) {
|
||||
raf = requestAnimationFrame(scan);
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { loadFiles } from '../../app/filesView.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { buildBatchDownloadUrl, triggerBrowserDownload } from '../../utils/download.js';
|
||||
import { favorites } from '../library/favorites.js';
|
||||
import { contextMenus } from './contextMenus.js';
|
||||
import { getAuthHeaders } from './fileOperations.js';
|
||||
@@ -126,10 +127,14 @@ const batchToolbar = {
|
||||
clear() {
|
||||
this._selected.clear();
|
||||
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');
|
||||
});
|
||||
document.querySelectorAll('.item-checkbox').forEach((cb) => {
|
||||
list?.querySelectorAll('.item-checkbox:checked').forEach((cb) => {
|
||||
/** @type {HTMLInputElement} */ (cb).checked = false;
|
||||
});
|
||||
// Reset the active component's internal selection state without going
|
||||
@@ -173,15 +178,16 @@ const batchToolbar = {
|
||||
/** @type {Array<string>} */
|
||||
const folderIds = [];
|
||||
|
||||
// TODO optimize & check if _selected is a better use
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => {
|
||||
if (item.dataset.fileId) {
|
||||
fileIds.push(item.dataset.fileId);
|
||||
} else {
|
||||
// ignore selectedItem if this is the target
|
||||
if (targtFolderId && targtFolderId !== item.dataset.folderId) folderIds.push(item.dataset.folderId);
|
||||
// `_selected` is the source of truth (every selection path keeps it
|
||||
// in sync) — no need to re-derive the selection from a DOM scan.
|
||||
for (const sel of this._selected.values()) {
|
||||
if (sel.type === 'file') {
|
||||
fileIds.push(sel.id);
|
||||
} else if (targtFolderId && targtFolderId !== sel.id) {
|
||||
// ignore the selected folder if it is the drop target itself
|
||||
folderIds.push(sel.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
fileIds: fileIds,
|
||||
@@ -454,10 +460,22 @@ const batchToolbar = {
|
||||
|
||||
ui.showNotification('Preparing download', 'Creating ZIP archive...');
|
||||
|
||||
try {
|
||||
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 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', {
|
||||
method: 'POST',
|
||||
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
||||
@@ -467,14 +485,14 @@ const batchToolbar = {
|
||||
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `oxicloud-download-${Date.now()}.zip`;
|
||||
link.href = blobUrl;
|
||||
link.download = zipName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (e) {
|
||||
console.error('Batch download error:', e);
|
||||
ui.showNotification('Error', 'Could not download selected items');
|
||||
|
||||
@@ -10,6 +10,8 @@ import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { notifications } from '../../core/notifications.js';
|
||||
import { invalidateFolderMeta } from '../../model/filesModel.js';
|
||||
import { triggerBrowserDownload } from '../../utils/download.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchResult
|
||||
@@ -349,7 +351,9 @@ const fileOps = {
|
||||
}
|
||||
|
||||
// Filter out unreadable entries (typically dropped folders/placeholders)
|
||||
/** @type {File[]} */
|
||||
const readableFiles = [];
|
||||
/** @type {string[]} */
|
||||
const skippedEntries = [];
|
||||
for (const f of originalFiles) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
@@ -386,13 +390,21 @@ const fileOps = {
|
||||
|
||||
let uploadedCount = 0;
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
const file = readableFiles[i];
|
||||
|
||||
const formData = new FormData();
|
||||
let quotaStop = false;
|
||||
|
||||
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);
|
||||
formData.append('file', file);
|
||||
|
||||
@@ -436,6 +448,8 @@ const fileOps = {
|
||||
});
|
||||
}
|
||||
if (result.isQuotaError) {
|
||||
// Stop pulling new files; in-flight uploads still finish.
|
||||
quotaStop = true;
|
||||
const msg = result.errorMsg || i18n.t('storage_quota_exceeded');
|
||||
if (notifications) {
|
||||
notifications.addNotification({
|
||||
@@ -445,10 +459,27 @@ const fileOps = {
|
||||
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
|
||||
this._finishUploadToast(successCount, totalFiles);
|
||||
@@ -862,6 +893,8 @@ const fileOps = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Parent changed — drop the cached breadcrumb metadata
|
||||
invalidateFolderMeta(folderId);
|
||||
// Reload files after moving
|
||||
await loadFiles();
|
||||
ui.showNotification('Folder moved', 'Folder moved successfully');
|
||||
@@ -925,6 +958,8 @@ const fileOps = {
|
||||
const data = await res.json();
|
||||
success += data.stats?.successful || 0;
|
||||
errors += data.stats?.failed || 0;
|
||||
// Parents changed — drop the cached breadcrumb metadata
|
||||
for (const id of folderIds) invalidateFolderMeta(id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Batch move error:', err);
|
||||
@@ -1117,6 +1152,8 @@ const fileOps = {
|
||||
console.log('Response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
// Name changed — drop the cached breadcrumb metadata
|
||||
invalidateFolderMeta(folderId);
|
||||
ui.showNotification('Folder renamed', `Folder renamed to "${newName}"`);
|
||||
} else {
|
||||
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} fileName - File name
|
||||
*/
|
||||
async downloadFile(fileId, fileName) {
|
||||
try {
|
||||
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');
|
||||
}
|
||||
triggerBrowserDownload(`/api/files/${fileId}`, fileName);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1374,30 +1392,10 @@ const fileOps = {
|
||||
* @param {string} folderName - Folder name
|
||||
*/
|
||||
async downloadFolder(folderId, folderName) {
|
||||
try {
|
||||
// Show notification to user
|
||||
// Show notification to user (the server still has to assemble the
|
||||
// ZIP before the browser's own download UI takes over).
|
||||
ui.showNotification('Preparing download', 'Preparing the folder for download...');
|
||||
|
||||
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');
|
||||
}
|
||||
triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { updateHistory } from '../../app/main.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { isTextViewable } from '../../core/formatters.js';
|
||||
import { triggerBrowserDownload } from '../../utils/download.js';
|
||||
import { wopiEditor } from './wopiEditor.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 {string} mediaType
|
||||
* @param {HTMLDivElement} container
|
||||
* @param {HTMLDivElement} loader
|
||||
*/
|
||||
async createMediaViewer(file, mediaType, container, loader) {
|
||||
try {
|
||||
createMediaViewer(file, mediaType, container, loader) {
|
||||
console.log(`Creating ${mediaType} player for:`, file.name);
|
||||
|
||||
// Fetch file (cookie auto-sent)
|
||||
const response = await fetch(`/api/files/${file.id}?inline=true`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const streamUrl = `/api/files/${file.id}?inline=true`;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Remove loader
|
||||
// The native player has its own buffering UI — drop our spinner now.
|
||||
if (loader?.parentNode) {
|
||||
loader.parentNode.removeChild(loader);
|
||||
}
|
||||
@@ -443,10 +437,10 @@ class InlineViewer {
|
||||
audio.className = 'inline-viewer-audio';
|
||||
audio.controls = true;
|
||||
audio.preload = 'metadata';
|
||||
audio.src = blobUrl;
|
||||
audio.src = streamUrl;
|
||||
wrapper.appendChild(audio);
|
||||
|
||||
// Fallback message for unsupported codecs
|
||||
// Fallback message for unsupported codecs / failed loads
|
||||
audio.addEventListener('error', () => {
|
||||
console.warn('Audio playback error — codec may not be supported');
|
||||
wrapper.innerHTML = '';
|
||||
@@ -468,10 +462,10 @@ class InlineViewer {
|
||||
video.className = 'inline-viewer-video';
|
||||
video.controls = true;
|
||||
video.preload = 'metadata';
|
||||
video.src = blobUrl;
|
||||
video.src = streamUrl;
|
||||
video.setAttribute('playsinline', 'true');
|
||||
|
||||
// Fallback message for unsupported codecs
|
||||
// Fallback message for unsupported codecs / failed loads
|
||||
video.addEventListener('error', () => {
|
||||
console.warn('Video playback error — codec may not be supported');
|
||||
if (video.parentNode) {
|
||||
@@ -491,18 +485,6 @@ class InlineViewer {
|
||||
|
||||
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
|
||||
*/
|
||||
downloadFile(file) {
|
||||
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
|
||||
.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));
|
||||
triggerBrowserDownload(`/api/files/${file.id}`, file.name);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 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 { ui } from '../../app/ui.js';
|
||||
import { getAuthHeaders } from './fileOperations.js';
|
||||
@@ -198,15 +198,10 @@ const search = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Render folders (server-provided enriched data)
|
||||
results.folders.forEach((folder) => {
|
||||
filesViewAddItem(folder);
|
||||
});
|
||||
|
||||
// Render files (server-provided enriched data)
|
||||
results.files.forEach((file) => {
|
||||
filesViewAddItem(file);
|
||||
});
|
||||
// Render the whole result set (server-provided enriched data) in one
|
||||
// batched pass — per-item inserts would trigger a DOM scan, a smooth
|
||||
// scroll and a highlight pulse for each of up to 100 rows.
|
||||
renderItems([...results.folders, ...results.files]);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -472,22 +472,43 @@ const photosView = {
|
||||
if (bar_delete) {
|
||||
bar_delete.onclick = async () => {
|
||||
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 {
|
||||
await fetch(`/api/files/${fid}`, {
|
||||
method: 'DELETE',
|
||||
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
|
||||
const chunk = ids.slice(i, i + CHUNK_SIZE);
|
||||
const response = await fetch('/api/batch/trash', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
console.error('Delete failed:', fid, err);
|
||||
console.error('Batch trash error:', err);
|
||||
}
|
||||
}
|
||||
this.items = this.items.filter((f) => !this.selected.has(f.id));
|
||||
this.selected.clear();
|
||||
this._hideSelectionBar();
|
||||
|
||||
if (trashed.size > 0) {
|
||||
this.items = this.items.filter((f) => !trashed.has(f.id));
|
||||
for (const id of trashed) this.selected.delete(id);
|
||||
this._renderedCount = 0;
|
||||
this._renderFull();
|
||||
}
|
||||
// Refresh (or hide) the bar to reflect any items left selected.
|
||||
this._updateSelectionBar();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,48 @@ async function getFolder(id) {
|
||||
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`.
|
||||
*
|
||||
@@ -41,29 +83,39 @@ async function getFolder(id) {
|
||||
* handles shared folders the user cannot traverse beyond.
|
||||
*
|
||||
* 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() {
|
||||
const generation = ++_breadcrumbGeneration;
|
||||
|
||||
/** @type {FolderItem|null} */
|
||||
let currentFolderInfo = null;
|
||||
app.breadcrumbPath = [];
|
||||
/** @type {Array<{id: string, name: string}>} */
|
||||
const crumbs = [];
|
||||
|
||||
/** @type {string|null} */
|
||||
let id = app.currentPath;
|
||||
|
||||
while (id !== null) {
|
||||
try {
|
||||
const folderInfo = await getFolder(id);
|
||||
const folderInfo = await _getFolderMeta(id);
|
||||
if (generation !== _breadcrumbGeneration) return false;
|
||||
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;
|
||||
} catch (_e) {
|
||||
if (generation !== _breadcrumbGeneration) return false;
|
||||
if (currentFolderInfo === null) {
|
||||
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');
|
||||
app.breadcrumbPath = [];
|
||||
crumbs.length = 0;
|
||||
id = app.userHomeFolderId;
|
||||
if (id) app.currentPath = id;
|
||||
} else {
|
||||
@@ -73,7 +125,10 @@ async function rebuildBreadCrumb() {
|
||||
}
|
||||
}
|
||||
|
||||
if (generation !== _breadcrumbGeneration) return false;
|
||||
app.breadcrumbPath = crumbs;
|
||||
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 };
|
||||
}
|
||||
|
||||
export { fetchListing, fetchResourcesPage, getFolder, rebuildBreadCrumb };
|
||||
export { fetchListing, fetchResourcesPage, getFolder, invalidateFolderMeta, rebuildBreadCrumb };
|
||||
|
||||
@@ -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(',')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user