From 6f30ac761f8564879e6b75991ddf446cdc930ed1 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Tue, 3 Mar 2026 11:49:52 +0100 Subject: [PATCH] refactor: replace WebDAV LOCK stub with moka-backed lock store (auto-expire TTL) --- src/common/di.rs | 3 + src/infrastructure/services/mod.rs | 1 + .../services/webdav_lock_service.rs | 218 ++++++++++++++++++ src/interfaces/api/handlers/webdav_handler.rs | 56 +++-- src/interfaces/errors.rs | 5 + 5 files changed, 259 insertions(+), 24 deletions(-) create mode 100644 src/infrastructure/services/webdav_lock_service.rs diff --git a/src/common/di.rs b/src/common/di.rs index 713048fe..496f29d2 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -543,6 +543,7 @@ impl AppServiceFactory { device_auth_service: None, app_password_service: None, path_resolver: None, + webdav_lock_store: crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(), }; // 9b. Wire admin settings service when auth is available @@ -860,6 +861,8 @@ pub struct AppState { Option>, pub path_resolver: Option>, + pub webdav_lock_store: + Arc, } // All AppState construction is done via struct literal in build_app_state(). diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 37c3dde9..fbaef4dd 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -12,5 +12,6 @@ pub mod path_resolver_service; pub mod path_service; pub mod thumbnail_service; pub mod trash_cleanup_service; +pub mod webdav_lock_service; pub mod wopi_discovery_service; pub mod zip_service; diff --git a/src/infrastructure/services/webdav_lock_service.rs b/src/infrastructure/services/webdav_lock_service.rs new file mode 100644 index 00000000..bde0ebc9 --- /dev/null +++ b/src/infrastructure/services/webdav_lock_service.rs @@ -0,0 +1,218 @@ +//! WebDAV lock store backed by Moka (in-memory cache with per-entry TTL). +//! +//! Locks are automatically evicted when their timeout expires, preventing +//! orphaned locks from accumulating. Two caches are maintained: +//! +//! - `by_path` : path → `LockEntry` (for LOCK conflict detection) +//! - `by_token` : token → path (for fast UNLOCK / refresh lookups) +//! +//! Both caches share the same TTL so entries disappear together. + +use std::sync::Arc; +use std::time::Duration; + +use crate::application::adapters::webdav_adapter::{LockInfo, LockScope}; + +/// Default lock timeout when the client does not specify one (RFC 4918 §10.7). +const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 1800; // 30 minutes + +/// Absolute maximum TTL a client may request. +const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours + +/// A stored lock entry. +#[derive(Clone, Debug)] +pub struct LockEntry { + pub info: LockInfo, + pub path: String, +} + +/// In-memory WebDAV lock store with automatic TTL-based expiration. +/// +/// Uses Moka's `sync::Cache` — lock-free (sharded) reads, bounded size, +/// and per-entry TTL via `policy::Expiry`. +pub struct WebDavLockStore { + /// path → `LockEntry` + by_path: moka::sync::Cache, + /// token → path (reverse index) + by_token: moka::sync::Cache, +} + +impl WebDavLockStore { + /// Create a new lock store. + /// + /// * `max_capacity` — upper bound on simultaneous locks (evicts LRU on overflow). + pub fn new(max_capacity: u64) -> Self { + // We use `expire_after` (per-entry TTL) via insert with explicit ttl, + // so we configure a generous global time_to_live as a safety net. + let by_path = moka::sync::Cache::builder() + .max_capacity(max_capacity) + .time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS)) + .build(); + + let by_token = moka::sync::Cache::builder() + .max_capacity(max_capacity) + .time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS)) + .build(); + + Self { by_path, by_token } + } + + // ── Public API ────────────────────────────────────────────── + + /// Attempt to acquire a lock on `path`. + /// + /// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource + /// is already exclusively locked by a different token. + pub fn acquire( + &self, + path: &str, + info: LockInfo, + ) -> Result { + // Check for existing conflicting lock + if let Some(existing) = self.by_path.get(path) { + if existing.info.scope == LockScope::Exclusive { + return Err(existing); + } + } + + let ttl = Self::parse_timeout(info.timeout.as_deref()); + let entry = LockEntry { + info, + path: path.to_owned(), + }; + + self.by_path + .insert(path.to_owned(), entry.clone()); + self.by_token + .insert(entry.info.token.clone(), path.to_owned()); + + // Moka 0.12 does not expose per-entry set_expiration_after_insert at + // insert time. We rely on the global `time_to_live` as an upper bound + // and use the `invalidate_after` helper below for custom TTL. + // + // To implement shorter-than-max TTL we schedule an async invalidation. + if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS { + let by_path = self.by_path.clone(); + let by_token = self.by_token.clone(); + let token = entry.info.token.clone(); + let path_owned = path.to_owned(); + tokio::spawn(async move { + tokio::time::sleep(ttl).await; + // Only remove if the entry still matches (wasn't refreshed/replaced) + if let Some(e) = by_path.get(&path_owned) { + if e.info.token == token { + by_path.invalidate(&path_owned); + by_token.invalidate(&token); + } + } + }); + } + + Ok(entry) + } + + /// Refresh an existing lock (extend its timeout). + /// + /// Returns `Some(LockEntry)` with updated timeout, or `None` if the token + /// is unknown (expired or never existed). + pub fn refresh(&self, token: &str, new_timeout: Option<&str>) -> Option { + let path = self.by_token.get(token)?; + let mut entry = self.by_path.get(&path)?; + + if entry.info.token != token { + return None; // token mismatch — lock was replaced + } + + let ttl = Self::parse_timeout(new_timeout.or(entry.info.timeout.as_deref())); + let timeout_str = format!("Second-{}", ttl.as_secs()); + entry.info.timeout = Some(timeout_str.clone()); + + // Re-insert to reset the TTL + self.by_path.insert(path.clone(), entry.clone()); + self.by_token.insert(token.to_owned(), path.clone()); + + if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS { + let by_path = self.by_path.clone(); + let by_token = self.by_token.clone(); + let token_owned = token.to_owned(); + let path_owned = path.clone(); + tokio::spawn(async move { + tokio::time::sleep(ttl).await; + if let Some(e) = by_path.get(&path_owned) { + if e.info.token == token_owned { + by_path.invalidate(&path_owned); + by_token.invalidate(&token_owned); + } + } + }); + } + + Some(entry) + } + + /// Release a lock by its token. + /// + /// Returns `true` if the lock existed and was removed. + pub fn release(&self, token: &str) -> bool { + if let Some(path) = self.by_token.get(token) { + // Only remove from by_path if the token still matches + if let Some(entry) = self.by_path.get(&path) { + if entry.info.token == token { + self.by_path.invalidate(&path); + } + } + self.by_token.invalidate(token); + true + } else { + false + } + } + + /// Look up a lock by resource path. + pub fn get_by_path(&self, path: &str) -> Option { + self.by_path.get(path) + } + + /// Look up a lock by token. + pub fn get_by_token(&self, token: &str) -> Option { + let path = self.by_token.get(token)?; + self.by_path.get(&path) + } + + // ── Helpers ───────────────────────────────────────────────── + + /// Parse a WebDAV `Timeout` header value into a [`Duration`]. + /// + /// Accepted formats (RFC 4918 §10.7): + /// - `Second-NNN` + /// - `Infinite` (clamped to `MAX_LOCK_TIMEOUT_SECS`) + /// - Comma-separated list (first value wins) + fn parse_timeout(header: Option<&str>) -> Duration { + let raw = match header { + Some(v) if !v.is_empty() => v, + _ => return Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS), + }; + + // Take the first value in a comma-separated list + let first = raw.split(',').next().unwrap_or(raw).trim(); + + if first.eq_ignore_ascii_case("Infinite") { + return Duration::from_secs(MAX_LOCK_TIMEOUT_SECS); + } + + if let Some(secs_str) = first.strip_prefix("Second-") { + if let Ok(secs) = secs_str.trim().parse::() { + return Duration::from_secs(secs.min(MAX_LOCK_TIMEOUT_SECS)); + } + } + + Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS) + } +} + +/// Create a shared lock store wrapped in `Arc` for embedding in `AppState`. +pub fn create_webdav_lock_store() -> Arc { + // 10 000 simultaneous locks should be more than enough; Moka evicts LRU + // if the cap is reached, so stale entries are cleaned automatically. + Arc::new(WebDavLockStore::new(10_000)) +} diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 1957bb46..d4b9ed3b 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -18,7 +18,7 @@ use quick_xml::Writer; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ - LockInfo, LockScope, LockType, PropFindRequest, WebDavAdapter, + LockInfo, PropFindRequest, WebDavAdapter, }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; @@ -1478,7 +1478,7 @@ async fn handle_copy( * @return XML response with lock information */ async fn handle_lock( - _state: Arc, + state: Arc, req: Request, path: String, ) -> Result, AppError> { @@ -1521,9 +1521,10 @@ async fn handle_lock( .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? }; + let lock_store = &state.webdav_lock_store; + // Check if this is a lock refresh (If header with a lock token) if let Some(if_header) = if_header_value { - // This is a lock refresh request // Extract lock token from If header let token = if_header .trim() @@ -1531,30 +1532,24 @@ async fn handle_lock( .trim_end_matches(">)") .to_string(); - // In a full implementation, we would look up the lock in a database - // and refresh its timeout. For now, just respond as if we did. - - // Generate lock token and owner (for a real implementation, we'd store these) - let lock_info = LockInfo { - token, - owner: Some(user.id.clone()), - depth: depth.to_string(), - timeout, - scope: LockScope::Exclusive, // Default to exclusive - type_: LockType::Write, // Default to write - }; + // Refresh the lock in the store (extends TTL) + let entry = lock_store + .refresh(&token, timeout.as_deref()) + .ok_or_else(|| { + AppError::precondition_failed(format!("Lock token not found or expired: {}", token)) + })?; // Generate response let href = format!("/webdav/{}", encode_uri_path(&path)); let mut response_body = Vec::new(); - WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err( + WebDavAdapter::generate_lock_response(&mut response_body, &entry.info, &href).map_err( |e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)), )?; Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .header(HEADER_LOCK_TOKEN, format!("<{}>", lock_info.token)) + .header(HEADER_LOCK_TOKEN, format!("<{}>", entry.info.token)) .body(Body::from(response_body)) .unwrap()) } else if !body_bytes.is_empty() { @@ -1562,7 +1557,6 @@ async fn handle_lock( let (scope, type_, owner) = WebDavAdapter::parse_lockinfo(body_bytes.reader()) .map_err(|e| AppError::bad_request(format!("Failed to parse LOCK request: {}", e)))?; - // Generate lock token and owner (for a real implementation, we'd store these) let token = format!("opaquelocktoken:{}", Uuid::new_v4()); let lock_info = LockInfo { token, @@ -1573,17 +1567,25 @@ async fn handle_lock( type_, }; + // Try to acquire the lock (conflict detection via moka store) + let entry = lock_store.acquire(&path, lock_info).map_err(|existing| { + AppError::locked(format!( + "Resource already locked by token {}", + existing.info.token + )) + })?; + // Generate response let href = format!("/webdav/{}", encode_uri_path(&path)); let mut response_body = Vec::new(); - WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err( + WebDavAdapter::generate_lock_response(&mut response_body, &entry.info, &href).map_err( |e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)), )?; Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .header(HEADER_LOCK_TOKEN, format!("<{}>", lock_info.token)) + .header(HEADER_LOCK_TOKEN, format!("<{}>", entry.info.token)) .body(Body::from(response_body)) .unwrap()) } else { @@ -1604,7 +1606,7 @@ async fn handle_lock( * @return HTTP response indicating success */ async fn handle_unlock( - _state: Arc, + state: Arc, req: Request, _path: String, ) -> Result, AppError> { @@ -1624,14 +1626,20 @@ async fn handle_unlock( .ok_or_else(|| AppError::bad_request("Lock-Token header required"))?; // Extract token from header value (format: ) - let _token = lock_token + let token = lock_token .trim() .trim_start_matches('<') .trim_end_matches('>') .to_string(); - // In a full implementation, we would look up the lock in a database - // and remove it. For now, just respond as if we did. + // Remove the lock from the store + if !state.webdav_lock_store.release(&token) { + // RFC 4918 §9.11.1: If the lock does not exist, return 409 Conflict + return Err(AppError::conflict(format!( + "Lock token not found or already expired: {}", + token + ))); + } Ok(Response::builder() .status(StatusCode::NO_CONTENT) diff --git a/src/interfaces/errors.rs b/src/interfaces/errors.rs index 47b9053a..9e55ef92 100644 --- a/src/interfaces/errors.rs +++ b/src/interfaces/errors.rs @@ -78,6 +78,11 @@ impl AppError { Self::new(StatusCode::CONFLICT, message, "Conflict") } + /// Create a 423 Locked error (WebDAV). + pub fn locked(message: impl Into) -> Self { + Self::new(StatusCode::LOCKED, message, "Locked") + } + /// Create a 415 Unsupported Media Type error. pub fn unsupported_media_type(message: impl Into) -> Self { Self::new(