refactor: replace WebDAV LOCK stub with moka-backed lock store (auto-expire TTL)

This commit is contained in:
Dionisio
2026-03-03 11:49:52 +01:00
parent 0cd544ab96
commit 6f30ac761f
5 changed files with 259 additions and 24 deletions
+3
View File
@@ -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<Arc<crate::application::services::app_password_service::AppPasswordService>>,
pub path_resolver:
Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>,
pub webdav_lock_store:
Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>,
}
// All AppState construction is done via struct literal in build_app_state().
+1
View File
@@ -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;
@@ -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<String, LockEntry>,
/// token → path (reverse index)
by_token: moka::sync::Cache<String, String>,
}
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<LockEntry, LockEntry> {
// 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<LockEntry> {
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<LockEntry> {
self.by_path.get(path)
}
/// Look up a lock by token.
pub fn get_by_token(&self, token: &str) -> Option<LockEntry> {
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::<u64>() {
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<WebDavLockStore> {
// 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))
}
+32 -24
View File
@@ -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<AppState>,
state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, 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<AppState>,
state: Arc<AppState>,
req: Request<Body>,
_path: String,
) -> Result<Response<Body>, 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: <token>)
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)
+5
View File
@@ -78,6 +78,11 @@ impl AppError {
Self::new(StatusCode::CONFLICT, message, "Conflict")
}
/// Create a 423 Locked error (WebDAV).
pub fn locked(message: impl Into<String>) -> Self {
Self::new(StatusCode::LOCKED, message, "Locked")
}
/// Create a 415 Unsupported Media Type error.
pub fn unsupported_media_type(message: impl Into<String>) -> Self {
Self::new(