feat(drive): fix webdav back-compat
add env variable `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`
which is by default:
`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`
so `/webdav/` -> points to user's personal drive (**backward compatibilit**y)
`/web/dav/@drive/{uuid|drive name}/` points to the respective drive
if admins want directly `/webdav/` pointing to list of drives they need to:
`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`
+ ensure lock is per user (RFC 4918 §9.11)
fix: #554
This commit is contained in:
@@ -222,45 +222,170 @@ async fn handle_webdav_methods(
|
||||
handle_webdav_dispatch(state, req, path).await
|
||||
}
|
||||
|
||||
/// If `path` doesn't already start with the user's home folder name, prepend
|
||||
/// the home folder path so downstream services can find the resource in the DB.
|
||||
/// Returns `None` when the path already includes the prefix or resolution fails.
|
||||
async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -> Option<String> {
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let home_folders = folder_service
|
||||
.list_folders_with_perms(None, user_id)
|
||||
.await
|
||||
.ok()?;
|
||||
let home = home_folders.first()?;
|
||||
|
||||
if path.starts_with(&home.name) {
|
||||
None // Already prefixed
|
||||
} else {
|
||||
Some(format!("{}/{}", home.path, path))
|
||||
}
|
||||
/// Native WebDAV URL scheme (drive.md §9):
|
||||
///
|
||||
/// The exact wire shape depends on
|
||||
/// `FeaturesConfig::webdav_drive_listing_prefix` (env
|
||||
/// `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`, default `"@drive"`):
|
||||
///
|
||||
/// | Config | URL | Target |
|
||||
/// |---|---|---|
|
||||
/// | `"@drive"` | `/webdav/…` | default drive (back-compat) |
|
||||
/// | `"@drive"` | `/webdav/@drive/` | drive listing |
|
||||
/// | `"@drive"` | `/webdav/@drive/<sel>/…` | explicit drive |
|
||||
/// | `""` | `/webdav/` | drive listing |
|
||||
/// | `""` | `/webdav/<sel>/…` | explicit drive |
|
||||
/// | `"drives"` | `/webdav/…` | default drive |
|
||||
/// | `"drives"` | `/webdav/drives/<sel>/…` | explicit drive |
|
||||
///
|
||||
/// `<sel>` is a drive UUID **or** the drive's display name (matched
|
||||
/// against `storage.folders.name` of the drive root). Only drives the
|
||||
/// caller has Read on via `role_grants` resolve.
|
||||
///
|
||||
/// Legacy tolerance for the default-drive branch: bookmarks that
|
||||
/// already contain the drive-root name as their first segment
|
||||
/// (`/webdav/Personal/foo` under a Personal-default user) are passed
|
||||
/// through instead of double-prepended.
|
||||
enum WebdavTarget {
|
||||
/// Render the synthetic drive-listing pseudo-root. Only PROPFIND
|
||||
/// treats this as a real target; other verbs 405.
|
||||
ListDrives,
|
||||
/// Descend into a concrete drive.
|
||||
Scope(DriveScope),
|
||||
}
|
||||
|
||||
/// Native WebDAV protocol entry: resolve the caller's default drive
|
||||
/// once per handler so every downstream path-based lookup
|
||||
/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`)
|
||||
/// can pass the same `drive_id` scope.
|
||||
///
|
||||
/// Post-D0 `storage.{folders,files}.path` repeats across drives — the
|
||||
/// scope is mandatory. Native WebDAV today lives in a single-drive
|
||||
/// surface (one default drive per user), so the lookup is unambiguous.
|
||||
/// Multi-drive support via path segments (`/webdav/drives/<uuid>/…`)
|
||||
/// is tracked separately and will derive `drive_id` directly from the
|
||||
/// URL instead of going through `find_default_for_user`.
|
||||
async fn resolve_drive_id_for_native_webdav(
|
||||
struct DriveScope {
|
||||
drive_id: Uuid,
|
||||
/// Path in `storage.folders.path` format (drive-root name is the
|
||||
/// leading segment; that prefix is stored per D7).
|
||||
db_path: String,
|
||||
}
|
||||
|
||||
async fn resolve_webdav_scope(
|
||||
state: &Arc<AppState>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Uuid, AppError> {
|
||||
state
|
||||
url_path: &str,
|
||||
) -> Result<WebdavTarget, AppError> {
|
||||
let drive_prefix = state
|
||||
.core
|
||||
.config
|
||||
.features
|
||||
.webdav_drive_listing_prefix
|
||||
.as_str();
|
||||
let normalized = url_path.trim_matches('/');
|
||||
|
||||
// Mode A: empty prefix. `/webdav/` IS the drive listing.
|
||||
if drive_prefix.is_empty() {
|
||||
if normalized.is_empty() {
|
||||
return Ok(WebdavTarget::ListDrives);
|
||||
}
|
||||
let (selector, subpath) = normalized.split_once('/').unwrap_or((normalized, ""));
|
||||
let drive = lookup_drive_selector(state, user_id, selector).await?;
|
||||
return Ok(WebdavTarget::Scope(DriveScope {
|
||||
drive_id: drive.drive.id,
|
||||
db_path: join_drive_path(&drive.root_folder_name, subpath),
|
||||
}));
|
||||
}
|
||||
|
||||
// Mode B: non-empty prefix (default `@drive`). Bare `/webdav/` is
|
||||
// the caller's default drive; drive listing lives at
|
||||
// `/webdav/<prefix>/`.
|
||||
let listing_marker = drive_prefix;
|
||||
if normalized == listing_marker {
|
||||
return Ok(WebdavTarget::ListDrives);
|
||||
}
|
||||
let with_slash = format!("{}/", listing_marker);
|
||||
if let Some(after_prefix) = normalized.strip_prefix(&with_slash) {
|
||||
if after_prefix.is_empty() {
|
||||
return Ok(WebdavTarget::ListDrives);
|
||||
}
|
||||
let (selector, subpath) = after_prefix.split_once('/').unwrap_or((after_prefix, ""));
|
||||
let drive = lookup_drive_selector(state, user_id, selector).await?;
|
||||
return Ok(WebdavTarget::Scope(DriveScope {
|
||||
drive_id: drive.drive.id,
|
||||
db_path: join_drive_path(&drive.root_folder_name, subpath),
|
||||
}));
|
||||
}
|
||||
|
||||
// Default-drive back-compat.
|
||||
let default = state
|
||||
.drive_repo
|
||||
.find_default_for_user(user_id)
|
||||
.await
|
||||
.map(|d| d.drive.id)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e)))
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to resolve default drive: {:?}", e))
|
||||
})?;
|
||||
let root_name = default.root_folder_name.as_str();
|
||||
let db_path = if normalized.is_empty() {
|
||||
root_name.to_string()
|
||||
} else if normalized == root_name || normalized.starts_with(&format!("{}/", root_name)) {
|
||||
// Pre-refactor bookmark already carried the drive-root prefix.
|
||||
normalized.to_string()
|
||||
} else {
|
||||
join_drive_path(root_name, normalized)
|
||||
};
|
||||
Ok(WebdavTarget::Scope(DriveScope {
|
||||
drive_id: default.drive.id,
|
||||
db_path,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Convenience: unwrap the common Scope branch or map ListDrives to a
|
||||
/// 405-shape error. Used by every write verb (PUT/DELETE/MOVE/COPY/…)
|
||||
/// that can't sensibly operate on the drive-listing pseudo-root.
|
||||
async fn resolve_webdav_scope_or_405(
|
||||
state: &Arc<AppState>,
|
||||
user_id: Uuid,
|
||||
url_path: &str,
|
||||
) -> Result<DriveScope, AppError> {
|
||||
match resolve_webdav_scope(state, user_id, url_path).await? {
|
||||
WebdavTarget::Scope(s) => Ok(s),
|
||||
WebdavTarget::ListDrives => Err(AppError::method_not_allowed(
|
||||
"Method not supported on the drive-listing pseudo-root",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn join_drive_path(root_name: &str, subpath: &str) -> String {
|
||||
let subpath = subpath.trim_start_matches('/').trim_end_matches('/');
|
||||
if subpath.is_empty() {
|
||||
root_name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", root_name, subpath)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `@drive/<selector>`: try the selector as a UUID first, then
|
||||
/// fall back to matching the drive-root folder's display name. Only
|
||||
/// drives the caller has Read access to via `role_grants` are
|
||||
/// considered — an unknown selector and a permission denial return the
|
||||
/// same `NotFound` to preserve anti-enumeration.
|
||||
async fn lookup_drive_selector(
|
||||
state: &Arc<AppState>,
|
||||
user_id: Uuid,
|
||||
selector: &str,
|
||||
) -> Result<crate::domain::repositories::drive_repository::DriveWithRootName, AppError> {
|
||||
let selector_decoded = percent_decode_str(selector).decode_utf8_lossy();
|
||||
let uuid_opt = Uuid::parse_str(selector_decoded.as_ref()).ok();
|
||||
let visible = state
|
||||
.drive_repo
|
||||
.list_readable_by(user_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list drives: {:?}", e)))?;
|
||||
for d in visible {
|
||||
if let Some(uuid) = uuid_opt
|
||||
&& d.drive.id == uuid
|
||||
{
|
||||
return Ok(d);
|
||||
}
|
||||
if d.root_folder_name == selector_decoded.as_ref() {
|
||||
return Ok(d);
|
||||
}
|
||||
}
|
||||
Err(AppError::not_found(format!(
|
||||
"Drive '{}' not found",
|
||||
selector_decoded
|
||||
)))
|
||||
}
|
||||
|
||||
async fn handle_webdav_dispatch(
|
||||
@@ -270,21 +395,9 @@ async fn handle_webdav_dispatch(
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let method = req.method().clone();
|
||||
|
||||
// Translate WebDAV path → DB path by prepending user's home folder
|
||||
// prefix when the path doesn't already include it.
|
||||
// Extract user_id before any async call to keep the future Send.
|
||||
let path = if !path.is_empty() && method.as_str() != "OPTIONS" {
|
||||
let user_id = req.extensions().get::<Arc<CurrentUser>>().map(|u| u.id);
|
||||
if let Some(uid) = user_id {
|
||||
resolve_webdav_path(&state, uid, &path)
|
||||
.await
|
||||
.unwrap_or(path)
|
||||
} else {
|
||||
path
|
||||
}
|
||||
} else {
|
||||
path
|
||||
};
|
||||
// Path is left as the raw URL path (post-`/webdav/`). Every handler
|
||||
// that touches storage calls `resolve_webdav_scope` to translate the
|
||||
// URL → (drive_id, db_path).
|
||||
|
||||
match method.as_str() {
|
||||
"OPTIONS" => handle_options(path).await,
|
||||
@@ -419,46 +532,46 @@ async fn handle_propfind(
|
||||
};
|
||||
|
||||
// ── 5. Determine target resource ─────────────────────────────
|
||||
if path.is_empty() || path == "/" {
|
||||
// Root folder
|
||||
let root_folder = FolderDto {
|
||||
id: "root".to_string(),
|
||||
etag: "root".to_string(),
|
||||
name: "".to_string(),
|
||||
path: "".to_string(),
|
||||
parent_id: None,
|
||||
// Synthetic root folder for PROPFIND on `/`; not an
|
||||
// actual DB row, so drive_id has no meaningful value.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: Utc::now().timestamp() as u64,
|
||||
modified_at: Utc::now().timestamp() as u64,
|
||||
is_root: true,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// §14 provenance not applicable to the synthetic root.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
|
||||
return build_streaming_propfind_response(
|
||||
root_folder,
|
||||
None, // folder_id = None → root children
|
||||
&depth_owned,
|
||||
&base_href,
|
||||
propfind_request,
|
||||
folder_service,
|
||||
file_retrieval_service,
|
||||
user.id,
|
||||
state.webdav_dead_props.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// `drive_id` is mandatory post-D0 for path-based lookups. Native
|
||||
// WebDAV resolves it once from the caller's default drive and
|
||||
// reuses it for the resolver / fallback probes below.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
//
|
||||
// `resolve_webdav_scope` handles the URL → scope translation using
|
||||
// `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. It can return either a concrete
|
||||
// drive scope or the synthetic drive-listing pseudo-root. Only
|
||||
// PROPFIND treats `ListDrives` as a valid target — other verbs use
|
||||
// `resolve_webdav_scope_or_405` which errors on that branch.
|
||||
let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? {
|
||||
WebdavTarget::ListDrives => {
|
||||
let root_folder = FolderDto {
|
||||
id: "root".to_string(),
|
||||
etag: "root".to_string(),
|
||||
name: "".to_string(),
|
||||
path: "".to_string(),
|
||||
parent_id: None,
|
||||
// Synthetic root — not a real DB row.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: Utc::now().timestamp() as u64,
|
||||
modified_at: Utc::now().timestamp() as u64,
|
||||
is_root: true,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
return build_streaming_propfind_response(
|
||||
root_folder,
|
||||
None, // folder_id = None → root children (drive-root folders)
|
||||
&depth_owned,
|
||||
&base_href,
|
||||
propfind_request,
|
||||
folder_service,
|
||||
file_retrieval_service,
|
||||
user.id,
|
||||
state.webdav_dead_props.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path),
|
||||
};
|
||||
|
||||
// Single-query path resolution: folder OR file in one DB round-trip.
|
||||
//
|
||||
@@ -772,6 +885,13 @@ async fn handle_proppatch(
|
||||
let user = extract_user(&req)?;
|
||||
// Client-facing path for href construction (without home folder prefix).
|
||||
let client_path = extract_webdav_path(req.uri());
|
||||
// Scope the URL → (drive_id, db_path). The synthetic drive-listing
|
||||
// pseudo-root has no DB row to anchor dead properties on; treat
|
||||
// it as an empty target and reject the PROPPATCH itself below.
|
||||
let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? {
|
||||
WebdavTarget::ListDrives => (Uuid::nil(), String::new()),
|
||||
WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path),
|
||||
};
|
||||
|
||||
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
|
||||
// so a lock on the target must release them via `If:`. Captured
|
||||
@@ -813,7 +933,7 @@ async fn handle_proppatch(
|
||||
// PROPPATCH itself below so we don't fabricate a target.
|
||||
(None, true)
|
||||
} else {
|
||||
match resolve_or_legacy(&state, &path, user.id).await {
|
||||
match resolve_or_legacy(&state, &path, drive_id).await {
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
let id = Uuid::parse_str(&folder.id).map_err(|e| {
|
||||
AppError::internal_error(format!("Folder id is not a UUID: {e}"))
|
||||
@@ -831,6 +951,21 @@ async fn handle_proppatch(
|
||||
let resource_ref = resource_ref
|
||||
.ok_or_else(|| AppError::forbidden("PROPPATCH on the WebDAV root is not supported"))?;
|
||||
|
||||
// AuthZ: PROPPATCH writes dead properties on the target — that's
|
||||
// a mutation, requires `Update`. Without this check any caller who
|
||||
// can Read (e.g. a Viewer-role grant) could persist dead-prop rows
|
||||
// on someone else's file. Anti-enum-preserving: `require` maps
|
||||
// denial to `NotFound`, matching the anonymous-not-found response
|
||||
// above.
|
||||
let resource = match resource_ref {
|
||||
ResourceRef::Folder(id) => Resource::Folder(id),
|
||||
ResourceRef::File(id) => Resource::File(id),
|
||||
};
|
||||
state
|
||||
.authorization
|
||||
.require(Subject::User(user.id), Permission::Update, resource)
|
||||
.await?;
|
||||
|
||||
// Read request body (XML — bounded to 1 MB)
|
||||
let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY)
|
||||
.await
|
||||
@@ -909,7 +1044,9 @@ async fn handle_get(
|
||||
// `drive_id` is the path-lookup scope post-D0 (paths repeat across
|
||||
// drives), derived once from the caller's default drive and reused
|
||||
// by both the resolver + legacy fallback.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
|
||||
// Resolve file — drive-scoped when PathResolver is available.
|
||||
// Post-D7 both branches enforce `Read` on the resolved file
|
||||
@@ -1034,7 +1171,9 @@ async fn handle_head(
|
||||
|
||||
// `drive_id` is the path-lookup scope post-D0 — derive once and
|
||||
// reuse across the resolver + fallback branches below.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
|
||||
// Single-query path resolution (drive-scoped). Both branches
|
||||
// enforce `Read` on the resolved resource before emitting the
|
||||
@@ -1165,20 +1304,12 @@ async fn handle_head(
|
||||
async fn resolve_or_legacy(
|
||||
state: &Arc<AppState>,
|
||||
path: &str,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
) -> Option<ResolvedResource> {
|
||||
// Path-lookup scope post-D0 — derive the caller's default drive
|
||||
// once and reuse across both probes. `find_default_for_user`
|
||||
// returning Err (e.g. external user, or boot before the lifecycle
|
||||
// hook fired) means no resolution is possible: return None.
|
||||
let drive_id = state
|
||||
.drive_repo
|
||||
.find_default_for_user(user_id)
|
||||
.await
|
||||
.ok()?
|
||||
.drive
|
||||
.id;
|
||||
|
||||
// `drive_id` is now passed in by the caller (already computed by
|
||||
// `resolve_webdav_scope`) so the fallback probes stay consistent
|
||||
// with the primary resolver — cross-drive URLs no longer silently
|
||||
// fall back to the caller's default drive.
|
||||
if let Some(resolver) = &state.path_resolver
|
||||
&& let Ok(r) = resolver.resolve_path_in_drive(path, drive_id).await
|
||||
{
|
||||
@@ -1594,7 +1725,9 @@ async fn handle_put(
|
||||
// `drive_id` is the path-lookup scope post-D0 — resolve once from
|
||||
// the caller's default drive, reused by the resolver checks below
|
||||
// and by the atomic-store call further down.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
|
||||
// ── Existence check ───────────────────────────────────────────────
|
||||
// Resolves to: File(existing), Folder(wrong), or Err(new file).
|
||||
@@ -1782,10 +1915,11 @@ async fn handle_put(
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => Err(AppError::internal_error(format!(
|
||||
"Failed to put file: {}",
|
||||
e
|
||||
))),
|
||||
// Propagate DomainError kinds — NotFound (authz denial via
|
||||
// `require_target_folder_perm`), Conflict (missing parent) etc.
|
||||
// Wrapping everything as InternalError swallowed 404s from the
|
||||
// service's own AuthZ, surfacing them to callers as 500.
|
||||
Err(e) => Err(AppError::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1807,9 +1941,13 @@ async fn handle_mkcol(
|
||||
let user = extract_user(&req)?;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
if path.is_empty() || path == "/" {
|
||||
return Err(AppError::conflict("Root folder already exists"));
|
||||
}
|
||||
// Bare `/webdav/` handling: routed through `resolve_webdav_scope_or_405`
|
||||
// below. In the empty-drive-path config that resolves to the
|
||||
// drive-listing pseudo-root (405 method-not-allowed); in the
|
||||
// default `@drive` config it resolves to the default drive's root
|
||||
// folder (which already exists — the existence probe at
|
||||
// `exists_in_drive` further down returns 405 per RFC 4918 §9.3.1).
|
||||
// Both configs end at 405 without a special-case.
|
||||
|
||||
// Extract content-type before consuming the body.
|
||||
let req_content_type = req
|
||||
@@ -1848,7 +1986,9 @@ async fn handle_mkcol(
|
||||
// This handler only creates a single collection (the last path segment).
|
||||
// It does NOT auto-create intermediate ancestors ("mkdir -p" semantics
|
||||
// violate the RFC and were causing the test failures).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
if segments.is_empty() {
|
||||
@@ -1977,6 +2117,22 @@ async fn handle_delete(
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = extract_user(&req)?;
|
||||
|
||||
// Refuse DELETE on the pseudo-root before any scope work — bare
|
||||
// `/webdav/` (empty-config drive listing OR classic-config default
|
||||
// drive root) can't be deleted from the WebDAV surface.
|
||||
if path.is_empty() || path == "/" {
|
||||
return Err(AppError::forbidden("Cannot delete root folder"));
|
||||
}
|
||||
|
||||
// Scope resolution BEFORE the lock guard so `enforce_native_lock`
|
||||
// keys on the same DB path that `handle_lock` used when it
|
||||
// registered the lock. Doing it in the reverse order (as before
|
||||
// the drive-scope refactor) silently defeated every LOCK because
|
||||
// the lock-store key mismatch made every DELETE look unlocked.
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
|
||||
// Active-lock guard (RFC 4918 §9.10.4).
|
||||
let if_header_owned = req
|
||||
.headers()
|
||||
@@ -1997,17 +2153,12 @@ async fn handle_delete(
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// Check if path is empty (root folder)
|
||||
if path.is_empty() || path == "/" {
|
||||
return Err(AppError::forbidden("Cannot delete root folder"));
|
||||
}
|
||||
|
||||
// Resolve via optimized resolver, falling back to the legacy
|
||||
// double-query lookup (the one GET uses). Necessary because the
|
||||
// optimized resolver and the read repositories disagree on path
|
||||
// shape for some files; see `resolve_or_legacy` docs.
|
||||
let _ = file_retrieval_service; // present for legacy fallback if needed elsewhere
|
||||
match resolve_or_legacy(&state, &path, user.id).await {
|
||||
match resolve_or_legacy(&state, &path, drive_id).await {
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&folder.id, user.id)
|
||||
@@ -2062,17 +2213,6 @@ async fn handle_move(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move
|
||||
// removes the source resource, which counts as modifying it.
|
||||
if let Some(resp) = enforce_native_lock(
|
||||
&state.webdav_lock_store,
|
||||
if_header_owned.as_deref(),
|
||||
&source_path,
|
||||
None,
|
||||
) {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// Get destination from Destination header
|
||||
let destination = req
|
||||
.headers()
|
||||
@@ -2101,22 +2241,41 @@ async fn handle_move(
|
||||
// SECURITY: reject path-traversal in destination
|
||||
reject_path_traversal(&destination_path)?;
|
||||
|
||||
// Normalize destination through the SAME path-prefixing that
|
||||
// `resolve_webdav_path` applied to `source_path` during dispatch.
|
||||
// Without this, comparing source_parent_path (already prefixed with
|
||||
// the user's home folder name) against dest_parent_path (raw from
|
||||
// the URL, no prefix) always reports "different parent" — even for a
|
||||
// pure rename at the same level — and breaks the move/rename branch
|
||||
// selection below.
|
||||
let destination_path = resolve_webdav_path(&state, user.id, &destination_path)
|
||||
.await
|
||||
.unwrap_or(destination_path);
|
||||
// Resolve BOTH source and destination scope. Cross-drive MOVE is
|
||||
// permitted: the underlying service methods
|
||||
// (`move_folder_with_perms` / `move_file_with_perms`) support it
|
||||
// natively — they enforce the D5 `forbid_cross_drive_move` policy
|
||||
// per drive and emit a D6 `resource.moved_between_drives` audit
|
||||
// line when the move crosses a boundary. Downstream probes that
|
||||
// walk `storage.{folders,files}.path` need the RIGHT drive scope
|
||||
// for each side; we thread `src_drive_id` for source probes and
|
||||
// `dst_drive_id` for destination probes.
|
||||
let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?;
|
||||
let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?;
|
||||
let src_drive_id = src_scope.drive_id;
|
||||
let dst_drive_id = dst_scope.drive_id;
|
||||
let source_path = src_scope.db_path;
|
||||
let path = source_path.clone();
|
||||
let destination_path = dst_scope.db_path;
|
||||
|
||||
// RFC 4918 §9.9.3: MOVE to self MUST return 403 Forbidden.
|
||||
if destination_path == source_path {
|
||||
if destination_path == path {
|
||||
return Err(AppError::forbidden("Cannot MOVE a resource to itself"));
|
||||
}
|
||||
|
||||
// Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move
|
||||
// removes the source resource, which counts as modifying it. The
|
||||
// guard runs AFTER scope resolution so its lookup keys on the DB
|
||||
// path — same key `handle_lock` used when it registered the lock.
|
||||
if let Some(resp) = enforce_native_lock(
|
||||
&state.webdav_lock_store,
|
||||
if_header_owned.as_deref(),
|
||||
&source_path,
|
||||
None,
|
||||
) {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// Destination lock guard: MOVE also creates/replaces a resource at
|
||||
// the destination. If that path is locked, the same If: header must
|
||||
// satisfy it.
|
||||
@@ -2133,21 +2292,19 @@ async fn handle_move(
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
|
||||
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
||||
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
||||
resolver
|
||||
.exists_in_drive(&destination_path, drive_id)
|
||||
.exists_in_drive(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.get_folder_by_path(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.get_file_by_path(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -2161,7 +2318,7 @@ async fn handle_move(
|
||||
// RFC 4918 §9.9.3: when Overwrite: T, perform a DELETE on the
|
||||
// destination before moving. Without this the rename/move fails
|
||||
// on a unique-index conflict (same name in same parent).
|
||||
match resolve_or_legacy(&state, &destination_path, user.id).await {
|
||||
match resolve_or_legacy(&state, &destination_path, dst_drive_id).await {
|
||||
Some(ResolvedResource::Folder(f)) => {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&f.id, user.id)
|
||||
@@ -2189,7 +2346,7 @@ async fn handle_move(
|
||||
}
|
||||
|
||||
let _ = file_retrieval_service;
|
||||
let resolved = resolve_or_legacy(&state, &source_path, user.id)
|
||||
let resolved = resolve_or_legacy(&state, &source_path, src_drive_id)
|
||||
.await
|
||||
.ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
|
||||
@@ -2213,7 +2370,7 @@ async fn handle_move(
|
||||
None
|
||||
} else {
|
||||
match folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.get_folder_by_path(dest_parent_path, dst_drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(parent) => {
|
||||
@@ -2262,13 +2419,19 @@ async fn handle_move(
|
||||
}
|
||||
}
|
||||
ResolvedResource::File(file) => {
|
||||
if source_parent_path != dest_parent_path {
|
||||
// A cross-drive move always changes the parent folder id even
|
||||
// if the RELATIVE path within each drive looks the same, so
|
||||
// we key the "same-parent rename" fast-path off drive id
|
||||
// agreement as well.
|
||||
let is_same_parent =
|
||||
src_drive_id == dst_drive_id && source_parent_path == dest_parent_path;
|
||||
if !is_same_parent {
|
||||
// RFC 4918 §9.9.5: missing destination parent → 409 Conflict.
|
||||
let target_parent_id = if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let parent = folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.get_folder_by_path(dest_parent_path, dst_drive_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::conflict(format!(
|
||||
@@ -2382,12 +2545,20 @@ async fn handle_copy(
|
||||
// SECURITY: reject path-traversal in destination
|
||||
reject_path_traversal(&destination_path)?;
|
||||
|
||||
// Normalize through the same path-prefixing the dispatcher applied
|
||||
// to source_path. See the long comment in handle_move for why this
|
||||
// matters — same root-cause class of asymmetric-path bugs.
|
||||
let destination_path = resolve_webdav_path(&state, user.id, &destination_path)
|
||||
.await
|
||||
.unwrap_or(destination_path);
|
||||
// Resolve BOTH source and destination scope. Cross-drive COPY is
|
||||
// permitted: `copy_file_with_perms` / `copy_folder_tree_with_perms`
|
||||
// take a target folder id and don't care which drive it lives in;
|
||||
// the D5 `forbid_cross_drive_move` policy applies to MOVE only,
|
||||
// never to COPY (copying is non-destructive on the source side).
|
||||
// Downstream probes need the right drive per side, so we thread
|
||||
// `src_drive_id` for source probes and `dst_drive_id` for
|
||||
// destination probes.
|
||||
let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?;
|
||||
let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?;
|
||||
let src_drive_id = src_scope.drive_id;
|
||||
let dst_drive_id = dst_scope.drive_id;
|
||||
let source_path = src_scope.db_path;
|
||||
let destination_path = dst_scope.db_path;
|
||||
|
||||
// RFC 4918 §9.8.5: COPY to self MUST return 403 Forbidden.
|
||||
if destination_path == source_path {
|
||||
@@ -2416,21 +2587,23 @@ async fn handle_copy(
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
// Scope already resolved above; keep `path` alias for downstream code
|
||||
// that still reads `path` under its original name.
|
||||
let _path = source_path.clone();
|
||||
|
||||
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
||||
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
||||
resolver
|
||||
.exists_in_drive(&destination_path, drive_id)
|
||||
.exists_in_drive(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.get_folder_by_path(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.get_file_by_path(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -2444,7 +2617,7 @@ async fn handle_copy(
|
||||
// RFC 4918 §9.8.4: when Overwrite: T, the server MUST perform a
|
||||
// DELETE on the destination before the copy. Without this the copy
|
||||
// service returns a unique-index conflict (500).
|
||||
match resolve_or_legacy(&state, &destination_path, user.id).await {
|
||||
match resolve_or_legacy(&state, &destination_path, dst_drive_id).await {
|
||||
Some(ResolvedResource::Folder(f)) => {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&f.id, user.id)
|
||||
@@ -2472,7 +2645,7 @@ async fn handle_copy(
|
||||
}
|
||||
|
||||
let _ = file_retrieval_service;
|
||||
let resolved = resolve_or_legacy(&state, &source_path, user.id)
|
||||
let resolved = resolve_or_legacy(&state, &source_path, src_drive_id)
|
||||
.await
|
||||
.ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
|
||||
@@ -2490,7 +2663,7 @@ async fn handle_copy(
|
||||
None
|
||||
} else {
|
||||
match folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.get_folder_by_path(dest_parent_path, dst_drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(parent) => {
|
||||
@@ -2590,25 +2763,101 @@ async fn handle_lock(
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = extract_user(&req)?;
|
||||
|
||||
// Determine collection-vs-file for href shape. Root + known
|
||||
// folders → collection; everything else (existing files,
|
||||
// lock-null on a non-existent path) → file. RFC 4918 §9.10.1
|
||||
// allows LOCK on a non-existent resource (the "lock-null
|
||||
// resource" pattern used by Office save flows) — that arm
|
||||
// falls through to the file href shape, matching the
|
||||
// request-line shape clients send.
|
||||
let is_collection = if path.is_empty() || path == "/" {
|
||||
true
|
||||
// Scope resolution BEFORE the collection probe so `path` becomes
|
||||
// the drive-scoped DB path everywhere downstream — critically the
|
||||
// `lock_store.acquire(&path, …)` call must use the SAME key that
|
||||
// `enforce_native_lock` will look up from the write verbs
|
||||
// (PUT/DELETE/MOVE/COPY/PROPPATCH), all of which pass the DB path.
|
||||
// Locking the URL path here and looking up the DB path in PUT
|
||||
// would silently defeat the lock — that's the regression this
|
||||
// shape prevents.
|
||||
let (drive_id, path) = if path.is_empty() || path == "/" {
|
||||
(Uuid::nil(), path)
|
||||
} else {
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(&path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
(scope.drive_id, scope.db_path)
|
||||
};
|
||||
|
||||
// Determine collection-vs-file for href shape AND resolve the
|
||||
// target for AuthZ. Root + known folders → collection; existing
|
||||
// files → file; missing path → lock-null (RFC 4918 §7.3 /
|
||||
// §9.10.1, used by Office save flows). AuthZ per case:
|
||||
// * Existing folder / file → `Update` on the resource.
|
||||
// * Lock-null (target doesn't exist yet) → `Create` on the
|
||||
// parent folder (the lock reserves the URL for a future PUT
|
||||
// that would need `Create` anyway; deny here so a Viewer
|
||||
// can't create a lock-null placeholder on someone else's
|
||||
// namespace).
|
||||
// Denial routes through `NotFound` (anti-enum), matching the
|
||||
// rest of the WebDAV surface.
|
||||
let (is_collection, lockable_resource) = if path.is_empty() {
|
||||
(true, None)
|
||||
} else if let Ok(folder) = state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(&path, drive_id)
|
||||
.await
|
||||
{
|
||||
let uuid = Uuid::parse_str(&folder.id)
|
||||
.map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Update,
|
||||
Resource::Folder(uuid),
|
||||
)
|
||||
.await?;
|
||||
(true, Some(Resource::Folder(uuid)))
|
||||
} else if let Ok(file) = state
|
||||
.applications
|
||||
.file_retrieval_service
|
||||
.get_file_by_path(&path, drive_id)
|
||||
.await
|
||||
{
|
||||
let uuid = Uuid::parse_str(&file.id)
|
||||
.map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Update,
|
||||
Resource::File(uuid),
|
||||
)
|
||||
.await?;
|
||||
(false, Some(Resource::File(uuid)))
|
||||
} else {
|
||||
// Lock-null: authorise on the parent folder. The last `/` in
|
||||
// `path` splits parent from name; empty parent means the drive
|
||||
// root (which itself was already resolved above — the caller
|
||||
// must have Read on it to have gotten this far via
|
||||
// `resolve_webdav_scope`).
|
||||
let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or("");
|
||||
if !parent_path.is_empty() {
|
||||
let parent = state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(parent_path, drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::conflict("Parent folder not found for lock-null"))?;
|
||||
let parent_uuid = Uuid::parse_str(&parent.id).map_err(|e| {
|
||||
AppError::internal_error(format!("Parent folder id is not a UUID: {e}"))
|
||||
})?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Create,
|
||||
Resource::Folder(parent_uuid),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// No resource to authorise directly — the lock reserves the URL,
|
||||
// downstream PUT will re-authorise via its own Create/Update.
|
||||
(false, None)
|
||||
};
|
||||
let _ = lockable_resource;
|
||||
|
||||
// Get the headers that we need
|
||||
let depth = req
|
||||
.headers()
|
||||
@@ -2690,13 +2939,17 @@ 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
|
||||
))
|
||||
})?;
|
||||
// Try to acquire the lock (conflict detection via moka store).
|
||||
// `caller_user_id` is stamped on the entry so `handle_unlock`
|
||||
// can enforce RFC 4918 §9.11's owner-only rule.
|
||||
let entry = lock_store
|
||||
.acquire(&path, lock_info, Some(user.id))
|
||||
.map_err(|existing| {
|
||||
AppError::locked(format!(
|
||||
"Resource already locked by token {}",
|
||||
existing.info.token
|
||||
))
|
||||
})?;
|
||||
|
||||
// Generate response — collection vs file href chosen above.
|
||||
let href = if is_collection {
|
||||
@@ -2735,9 +2988,9 @@ async fn handle_lock(
|
||||
async fn handle_unlock(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
_path: String,
|
||||
path: String,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let _user = extract_user(&req)?;
|
||||
let user = extract_user(&req)?;
|
||||
|
||||
// Get lock token from Lock-Token header
|
||||
let lock_token = req
|
||||
@@ -2753,6 +3006,65 @@ async fn handle_unlock(
|
||||
.trim_end_matches('>')
|
||||
.to_string();
|
||||
|
||||
// RFC 4918 §9.11 owner-only check. `LockEntry.caller_user_id`
|
||||
// was stamped by `handle_lock` at acquire time. When the lock
|
||||
// exists AND we know the acquirer, only that user can UNLOCK.
|
||||
// Denial routes through the standard authz `NotFound` anti-enum
|
||||
// — a caller who neither holds the lock nor has any perm on the
|
||||
// resource shouldn't learn whether the lock exists.
|
||||
//
|
||||
// Approximations preserved:
|
||||
// * Lock entries seeded by tests (`caller_user_id = None`) fall
|
||||
// through to the Update-based check below — they were never
|
||||
// bound to a real user.
|
||||
// * If the token isn't in the store at all (expired, never
|
||||
// existed) we skip the owner check and let the `release`
|
||||
// call below return the RFC-standard 409.
|
||||
let lock_entry = state.webdav_lock_store.get_by_token(&token);
|
||||
if let Some(entry) = &lock_entry
|
||||
&& let Some(owner_id) = entry.caller_user_id
|
||||
&& owner_id != user.id
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "webdav.unlock_denied",
|
||||
reason = "not_lock_owner",
|
||||
caller_id = %user.id,
|
||||
lock_owner_id = %owner_id,
|
||||
token = %token,
|
||||
"👮🏻♂️ UNLOCK refused: caller does not own the lock",
|
||||
);
|
||||
return Err(AppError::not_found(format!(
|
||||
"Lock token not found or already expired: {}",
|
||||
token
|
||||
)));
|
||||
}
|
||||
|
||||
// Defence-in-depth for the test-seeded / legacy `caller_user_id
|
||||
// = None` case: require `Update` on the target resource so a
|
||||
// Read-only grantee still can't unlock. Uses the URL path (the
|
||||
// lock's target) to resolve the resource. Missing target → skip
|
||||
// (lock-null unlock is legitimate).
|
||||
if let Some(entry) = &lock_entry
|
||||
&& entry.caller_user_id.is_none()
|
||||
&& !path.is_empty()
|
||||
&& path != "/"
|
||||
{
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let db_path = scope.db_path;
|
||||
if let Some(resource) = match resolve_or_legacy(&state, &db_path, drive_id).await {
|
||||
Some(ResolvedResource::Folder(f)) => Uuid::parse_str(&f.id).ok().map(Resource::Folder),
|
||||
Some(ResolvedResource::File(f)) => Uuid::parse_str(&f.id).ok().map(Resource::File),
|
||||
None => None,
|
||||
} {
|
||||
state
|
||||
.authorization
|
||||
.require(Subject::User(user.id), Permission::Update, resource)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user