feat(drive): webdav handler based on drive
and add authz
This commit is contained in:
@@ -15,6 +15,8 @@ use crate::application::dtos::display_helpers::{
|
|||||||
use crate::application::dtos::file_dto::FileDto;
|
use crate::application::dtos::file_dto::FileDto;
|
||||||
use crate::application::dtos::folder_dto::FolderDto;
|
use crate::application::dtos::folder_dto::FolderDto;
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
|
use crate::domain::entities::file::File;
|
||||||
|
use crate::domain::entities::folder::Folder;
|
||||||
|
|
||||||
/// Result of resolving a WebDAV path — either a folder or a file.
|
/// Result of resolving a WebDAV path — either a folder or a file.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -33,14 +35,20 @@ impl PathResolverService {
|
|||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve `path` to a folder or file **owned by `user_id`**.
|
/// Resolve `path` to a folder or file **within the given drive**.
|
||||||
///
|
///
|
||||||
/// Adds `AND fo.user_id = $4` / `AND fi.user_id = $4` so that one
|
/// Filters on `fo.drive_id = $4` / `fi.drive_id = $4`. Callers
|
||||||
/// user can never resolve another user's resources.
|
/// pre-resolve which drive they're operating in — native WebDAV
|
||||||
pub async fn resolve_path_for_user(
|
/// derives it from the caller's default drive
|
||||||
|
/// (`resolve_drive_id_for_native_webdav`); NC WebDAV takes it from
|
||||||
|
/// the URL-selected chroot (`chroot.drive_id`). Shared by both
|
||||||
|
/// surfaces so the single-query UNION ALL optimisation lands
|
||||||
|
/// consistently and no path lookup keys on the doomed
|
||||||
|
/// `storage.{files,folders}.user_id` column.
|
||||||
|
pub async fn resolve_path_in_drive(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
user_id: Uuid,
|
drive_id: Uuid,
|
||||||
) -> Result<ResolvedResource, DomainError> {
|
) -> Result<ResolvedResource, DomainError> {
|
||||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
@@ -55,6 +63,13 @@ impl PathResolverService {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Widened SELECT: also fetches `blob_hash` (for file ETag) and
|
||||||
|
// `tree_modified_at` (for folder ETag). Both share the same
|
||||||
|
// canonical formulas as the rest of the codebase — see
|
||||||
|
// [`File::compute_etag`] and [`Folder::compute_etag`]. Without
|
||||||
|
// these two extra columns the resolver used to emit empty
|
||||||
|
// ETag strings, and NC's `If-Match` round-trips broke
|
||||||
|
// (see the F6b regression on `test_nc_put_mkcol_blake3.sh`).
|
||||||
let row = sqlx::query_as::<
|
let row = sqlx::query_as::<
|
||||||
_,
|
_,
|
||||||
(
|
(
|
||||||
@@ -70,11 +85,14 @@ impl PathResolverService {
|
|||||||
Option<i64>, // size
|
Option<i64>, // size
|
||||||
Option<String>, // mime_type
|
Option<String>, // mime_type
|
||||||
Option<String>, // folder_id
|
Option<String>, // folder_id
|
||||||
|
Option<String>, // blob_hash (files only)
|
||||||
|
Option<i64>, // tree_modified_at (folders only)
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT resource_type, id, name, path, parent_id, user_id, drive_id,
|
SELECT resource_type, id, name, path, parent_id, user_id, drive_id,
|
||||||
created_at, modified_at, size, mime_type, folder_id
|
created_at, modified_at, size, mime_type, folder_id,
|
||||||
|
blob_hash, tree_modified_at
|
||||||
FROM (
|
FROM (
|
||||||
SELECT 'folder'::text AS resource_type,
|
SELECT 'folder'::text AS resource_type,
|
||||||
fo.id::text,
|
fo.id::text,
|
||||||
@@ -87,10 +105,12 @@ impl PathResolverService {
|
|||||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
|
||||||
NULL::bigint AS size,
|
NULL::bigint AS size,
|
||||||
NULL::text AS mime_type,
|
NULL::text AS mime_type,
|
||||||
NULL::text AS folder_id
|
NULL::text AS folder_id,
|
||||||
|
NULL::text AS blob_hash,
|
||||||
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint AS tree_modified_at
|
||||||
FROM storage.folders fo
|
FROM storage.folders fo
|
||||||
WHERE fo.path = $1 AND NOT fo.is_trashed
|
WHERE fo.path = $1 AND NOT fo.is_trashed
|
||||||
AND fo.user_id = $4
|
AND fo.drive_id = $4
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
@@ -109,7 +129,9 @@ impl PathResolverService {
|
|||||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
|
||||||
fi.size,
|
fi.size,
|
||||||
fi.mime_type,
|
fi.mime_type,
|
||||||
fi.folder_id::text
|
fi.folder_id::text,
|
||||||
|
fi.blob_hash,
|
||||||
|
NULL::bigint AS tree_modified_at
|
||||||
FROM storage.files fi
|
FROM storage.files fi
|
||||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
WHERE fi.name = $2
|
WHERE fi.name = $2
|
||||||
@@ -118,7 +140,7 @@ impl PathResolverService {
|
|||||||
OR fo.path = $3
|
OR fo.path = $3
|
||||||
)
|
)
|
||||||
AND NOT fi.is_trashed
|
AND NOT fi.is_trashed
|
||||||
AND fi.user_id = $4
|
AND fi.drive_id = $4
|
||||||
) sub
|
) sub
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
@@ -126,10 +148,10 @@ impl PathResolverService {
|
|||||||
.bind(path) // $1
|
.bind(path) // $1
|
||||||
.bind(filename) // $2
|
.bind(filename) // $2
|
||||||
.bind(&folder_path) // $3
|
.bind(&folder_path) // $3
|
||||||
.bind(user_id) // $4
|
.bind(drive_id) // $4
|
||||||
.fetch_optional(self.pool.as_ref())
|
.fetch_optional(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))?
|
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_in_drive: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("Resource", path))?;
|
.ok_or_else(|| DomainError::not_found("Resource", path))?;
|
||||||
|
|
||||||
let (
|
let (
|
||||||
@@ -145,38 +167,41 @@ impl PathResolverService {
|
|||||||
size,
|
size,
|
||||||
mime_type,
|
mime_type,
|
||||||
folder_id,
|
folder_id,
|
||||||
|
blob_hash,
|
||||||
|
tree_modified_at,
|
||||||
) = row;
|
) = row;
|
||||||
|
|
||||||
match resource_type.as_str() {
|
match resource_type.as_str() {
|
||||||
"folder" => Ok(ResolvedResource::Folder(FolderDto {
|
"folder" => {
|
||||||
etag: id.clone(),
|
let tree_mod = tree_modified_at.unwrap_or(modified_at) as u64;
|
||||||
id,
|
Ok(ResolvedResource::Folder(FolderDto {
|
||||||
name: name.clone(),
|
etag: Folder::compute_etag(&id, tree_mod),
|
||||||
path: res_path,
|
id,
|
||||||
parent_id,
|
name: name.clone(),
|
||||||
owner_id: uid,
|
path: res_path,
|
||||||
drive_id,
|
parent_id,
|
||||||
created_at: created_at as u64,
|
owner_id: uid,
|
||||||
modified_at: modified_at as u64,
|
drive_id,
|
||||||
is_root: false,
|
created_at: created_at as u64,
|
||||||
icon_class: Arc::from("fas fa-folder"),
|
modified_at: modified_at as u64,
|
||||||
icon_special_class: Arc::from("folder-icon"),
|
is_root: false,
|
||||||
category: Arc::from("Folder"),
|
icon_class: Arc::from("fas fa-folder"),
|
||||||
// §14 provenance not selected by this resolver path —
|
icon_special_class: Arc::from("folder-icon"),
|
||||||
// it's used for existence/type discrimination, not
|
category: Arc::from("Folder"),
|
||||||
// detailed DTO emission. Callers that need provenance
|
// §14 provenance not selected by this resolver path —
|
||||||
// reload through the repo.
|
// it's used for existence/type discrimination, not
|
||||||
created_by: None,
|
// detailed DTO emission. Callers that need provenance
|
||||||
updated_by: None,
|
// reload through the repo.
|
||||||
})),
|
created_by: None,
|
||||||
|
updated_by: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
let sz = size.unwrap_or(0) as u64;
|
let sz = size.unwrap_or(0) as u64;
|
||||||
// `content_hash`/`etag` are empty here: this resolver
|
let hash = blob_hash.unwrap_or_default();
|
||||||
// path doesn't select `blob_hash` from SQL — callers
|
let modified_at_u = modified_at as u64;
|
||||||
// are doing existence/type discrimination, not ETag
|
let etag = File::compute_etag(&hash, modified_at_u);
|
||||||
// emission. If a caller ever needs an ETag from this
|
|
||||||
// codepath, widen the SELECT and populate properly.
|
|
||||||
Ok(ResolvedResource::File(FileDto {
|
Ok(ResolvedResource::File(FileDto {
|
||||||
id,
|
id,
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@@ -185,15 +210,15 @@ impl PathResolverService {
|
|||||||
mime_type: Arc::from(&*mime),
|
mime_type: Arc::from(&*mime),
|
||||||
folder_id,
|
folder_id,
|
||||||
created_at: created_at as u64,
|
created_at: created_at as u64,
|
||||||
modified_at: modified_at as u64,
|
modified_at: modified_at_u,
|
||||||
icon_class: Arc::from(icon_class_for(&name, &mime)),
|
icon_class: Arc::from(icon_class_for(&name, &mime)),
|
||||||
icon_special_class: Arc::from(icon_special_class_for(&name, &mime)),
|
icon_special_class: Arc::from(icon_special_class_for(&name, &mime)),
|
||||||
category: Arc::from(category_for(&name, &mime)),
|
category: Arc::from(category_for(&name, &mime)),
|
||||||
size_formatted: format_file_size(sz),
|
size_formatted: format_file_size(sz),
|
||||||
owner_id: uid,
|
owner_id: uid,
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
content_hash: String::new(),
|
content_hash: hash,
|
||||||
etag: String::new(),
|
etag,
|
||||||
// §14 provenance not selected by this resolver path
|
// §14 provenance not selected by this resolver path
|
||||||
created_by: None,
|
created_by: None,
|
||||||
updated_by: None,
|
updated_by: None,
|
||||||
@@ -203,7 +228,10 @@ impl PathResolverService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the resource at `path` belongs to `user_id`.
|
/// Returns `true` if the resource at `path` belongs to `user_id`.
|
||||||
pub async fn exists_for_user(&self, path: &str, user_id: Uuid) -> Result<bool, DomainError> {
|
/// Check whether `path` resolves to a folder or file within the
|
||||||
|
/// given drive. Companion to `resolve_path_in_drive` — same scope
|
||||||
|
/// filter, existence-only projection.
|
||||||
|
pub async fn exists_in_drive(&self, path: &str, drive_id: Uuid) -> Result<bool, DomainError> {
|
||||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -221,7 +249,7 @@ impl PathResolverService {
|
|||||||
r#"
|
r#"
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM storage.folders
|
SELECT 1 FROM storage.folders
|
||||||
WHERE path = $1 AND NOT is_trashed AND user_id = $4
|
WHERE path = $1 AND NOT is_trashed AND drive_id = $4
|
||||||
) OR EXISTS(
|
) OR EXISTS(
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM storage.files fi
|
FROM storage.files fi
|
||||||
@@ -229,18 +257,18 @@ impl PathResolverService {
|
|||||||
WHERE fi.name = $2
|
WHERE fi.name = $2
|
||||||
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
|
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
|
||||||
AND NOT fi.is_trashed
|
AND NOT fi.is_trashed
|
||||||
AND fi.user_id = $4
|
AND fi.drive_id = $4
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(path)
|
.bind(path)
|
||||||
.bind(filename)
|
.bind(filename)
|
||||||
.bind(&folder_path)
|
.bind(&folder_path)
|
||||||
.bind(user_id)
|
.bind(drive_id)
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::internal_error("PathResolver", format!("exists_for_user: {e}"))
|
DomainError::internal_error("PathResolver", format!("exists_in_drive: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(exists)
|
Ok(exists)
|
||||||
|
|||||||
@@ -456,10 +456,32 @@ async fn handle_propfind(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-query path resolution: folder OR file in one DB round-trip
|
// `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?;
|
||||||
|
|
||||||
|
// Single-query path resolution: folder OR file in one DB round-trip.
|
||||||
|
//
|
||||||
|
// Post-D7 the resolver is drive-scoped (not owner-scoped), so we
|
||||||
|
// explicitly `authz.require(Read, …)` on the returned resource
|
||||||
|
// before rendering the multistatus. The streaming children of a
|
||||||
|
// Folder branch are separately per-item authorised inside
|
||||||
|
// `build_streaming_propfind_response` via `_with_perms` service
|
||||||
|
// methods.
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path_for_user(&path, user.id).await {
|
match resolver.resolve_path_in_drive(&path, drive_id).await {
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
|
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||||
|
.map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let folder_id = folder.id.clone();
|
let folder_id = folder.id.clone();
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
folder,
|
folder,
|
||||||
@@ -475,6 +497,16 @@ async fn handle_propfind(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Ok(ResolvedResource::File(file)) => {
|
Ok(ResolvedResource::File(file)) => {
|
||||||
|
let file_uuid = Uuid::parse_str(&file.id)
|
||||||
|
.map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let dead_props = file_dead_props(&state, &file).await;
|
let dead_props = file_dead_props(&state, &file).await;
|
||||||
let file_href = webdav_href(&client_path);
|
let file_href = webdav_href(&client_path);
|
||||||
let mut buf = Vec::with_capacity(1024);
|
let mut buf = Vec::with_capacity(1024);
|
||||||
@@ -503,9 +535,6 @@ async fn handle_propfind(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: legacy double-query path when PathResolver is unavailable.
|
// Fallback: legacy double-query path when PathResolver is unavailable.
|
||||||
// `drive_id` is mandatory post-D0 for path-based lookups — derive
|
|
||||||
// the caller's default drive once and reuse it for both probes.
|
|
||||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
|
||||||
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
|
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
|
||||||
let folder_uuid = Uuid::parse_str(&folder.id)
|
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||||
.map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?;
|
.map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||||
@@ -875,10 +904,38 @@ async fn handle_get(
|
|||||||
return Err(AppError::bad_request("Cannot GET a directory"));
|
return Err(AppError::bad_request("Cannot GET a directory"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve file — user-scoped when PathResolver is available
|
// `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?;
|
||||||
|
|
||||||
|
// Resolve file — drive-scoped when PathResolver is available.
|
||||||
|
// Post-D7 both branches enforce `Read` on the resolved file
|
||||||
|
// explicitly. The download stream call below also passes
|
||||||
|
// `caller_id`, so `get_file_stream_with_perms` re-verifies as
|
||||||
|
// defence-in-depth.
|
||||||
|
//
|
||||||
|
// (Fix, 2026-07-02: the legacy fallback branch previously used
|
||||||
|
// `Permission::Update`, a stale mapping from the retired
|
||||||
|
// `assert_owner` helper. That would have locked Viewers out of
|
||||||
|
// downloads once shared drives were exposed via WebDAV. Both
|
||||||
|
// branches now share the correct `Read` permission — see the
|
||||||
|
// post-D7 second AuthZ audit memo.)
|
||||||
let file = if let Some(resolver) = &state.path_resolver {
|
let file = if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path_for_user(&path, user.id).await {
|
match resolver.resolve_path_in_drive(&path, drive_id).await {
|
||||||
Ok(ResolvedResource::File(f)) => f,
|
Ok(ResolvedResource::File(f)) => {
|
||||||
|
let file_uuid = Uuid::parse_str(&f.id)
|
||||||
|
.map_err(|_| AppError::not_found(format!("File not found: {}", path)))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
f
|
||||||
|
}
|
||||||
Ok(ResolvedResource::Folder(_)) => {
|
Ok(ResolvedResource::Folder(_)) => {
|
||||||
return Err(AppError::bad_request("Cannot GET a directory"));
|
return Err(AppError::bad_request("Cannot GET a directory"));
|
||||||
}
|
}
|
||||||
@@ -887,11 +944,7 @@ async fn handle_get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Legacy fallback — fetch + AuthZ check. `drive_id` is the
|
// Legacy fallback — fetch + AuthZ check.
|
||||||
// path-lookup scope post-D0 (`storage.files.path` repeats across
|
|
||||||
// drives), derived once from the caller's default drive. PUT
|
|
||||||
// overwrite = Update on the existing file.
|
|
||||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
|
||||||
let f = file_retrieval_service
|
let f = file_retrieval_service
|
||||||
.get_file_by_path(&path, drive_id)
|
.get_file_by_path(&path, drive_id)
|
||||||
.await
|
.await
|
||||||
@@ -902,7 +955,7 @@ async fn handle_get(
|
|||||||
.authorization
|
.authorization
|
||||||
.require(
|
.require(
|
||||||
Subject::User(user.id),
|
Subject::User(user.id),
|
||||||
Permission::Update,
|
Permission::Read,
|
||||||
Resource::File(file_uuid),
|
Resource::File(file_uuid),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -977,10 +1030,26 @@ async fn handle_head(
|
|||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-query path resolution (user-scoped)
|
// `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?;
|
||||||
|
|
||||||
|
// Single-query path resolution (drive-scoped). Both branches
|
||||||
|
// enforce `Read` on the resolved resource before emitting the
|
||||||
|
// metadata response — same permission as the legacy fallback below.
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path_for_user(&path, user.id).await {
|
match resolver.resolve_path_in_drive(&path, drive_id).await {
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
|
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||||
|
.map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "httpd/unix-directory")
|
.header(header::CONTENT_TYPE, "httpd/unix-directory")
|
||||||
@@ -990,6 +1059,16 @@ async fn handle_head(
|
|||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
Ok(ResolvedResource::File(file)) => {
|
Ok(ResolvedResource::File(file)) => {
|
||||||
|
let file_uuid = Uuid::parse_str(&file.id)
|
||||||
|
.map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, &*file.mime_type)
|
.header(header::CONTENT_TYPE, &*file.mime_type)
|
||||||
@@ -1009,9 +1088,6 @@ async fn handle_head(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: legacy double-query path (with ownership check).
|
// Fallback: legacy double-query path (with ownership check).
|
||||||
// `drive_id` is the path-lookup scope post-D0 — derive once and
|
|
||||||
// reuse for both the folder and file probes.
|
|
||||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
|
||||||
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
|
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
|
||||||
let folder_uuid = Uuid::parse_str(&folder.id)
|
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||||
.map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?;
|
.map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||||
@@ -1089,16 +1165,10 @@ async fn resolve_or_legacy(
|
|||||||
path: &str,
|
path: &str,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Option<ResolvedResource> {
|
) -> Option<ResolvedResource> {
|
||||||
if let Some(resolver) = &state.path_resolver
|
|
||||||
&& let Ok(r) = resolver.resolve_path_for_user(path, user_id).await
|
|
||||||
{
|
|
||||||
return Some(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path-lookup scope post-D0 — derive the caller's default drive
|
// Path-lookup scope post-D0 — derive the caller's default drive
|
||||||
// for both legacy probes. `find_default_for_user` returning Err
|
// once and reuse across both probes. `find_default_for_user`
|
||||||
// (e.g. external user, or boot before the lifecycle hook fired)
|
// returning Err (e.g. external user, or boot before the lifecycle
|
||||||
// means no fallback resolution is possible: return None.
|
// hook fired) means no resolution is possible: return None.
|
||||||
let drive_id = state
|
let drive_id = state
|
||||||
.drive_repo
|
.drive_repo
|
||||||
.find_default_for_user(user_id)
|
.find_default_for_user(user_id)
|
||||||
@@ -1107,17 +1177,18 @@ async fn resolve_or_legacy(
|
|||||||
.drive
|
.drive
|
||||||
.id;
|
.id;
|
||||||
|
|
||||||
let user_id_str = user_id.to_string();
|
if let Some(resolver) = &state.path_resolver
|
||||||
let folder_service = &state.applications.folder_service;
|
&& let Ok(r) = resolver.resolve_path_in_drive(path, drive_id).await
|
||||||
if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await
|
|
||||||
&& folder.owner_id.as_deref() == Some(&user_id_str)
|
|
||||||
{
|
{
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder_service = &state.applications.folder_service;
|
||||||
|
if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await {
|
||||||
return Some(ResolvedResource::Folder(folder));
|
return Some(ResolvedResource::Folder(folder));
|
||||||
}
|
}
|
||||||
let file_retrieval = &state.applications.file_retrieval_service;
|
let file_retrieval = &state.applications.file_retrieval_service;
|
||||||
if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await
|
if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await {
|
||||||
&& file.owner_id.as_deref() == Some(&user_id_str)
|
|
||||||
{
|
|
||||||
return Some(ResolvedResource::File(file));
|
return Some(ResolvedResource::File(file));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -1350,14 +1421,35 @@ async fn handle_put(
|
|||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ownership / existence check ───────────────────────────────────
|
// `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?;
|
||||||
|
|
||||||
|
// ── Existence check ───────────────────────────────────────────────
|
||||||
// Resolves to: File(existing), Folder(wrong), or Err(new file).
|
// Resolves to: File(existing), Folder(wrong), or Err(new file).
|
||||||
// Sets `file_existed` for 201 vs 204 and `current_etag` for If-Match.
|
// Sets `file_existed` for 201 vs 204 and `current_etag` for If-Match.
|
||||||
|
//
|
||||||
|
// Post-D7 the resolver is drive-scoped, not owner-scoped, so we
|
||||||
|
// explicitly `authz.require(Read, …)` on every returned resource
|
||||||
|
// before consuming it. The actual overwrite is authorised as
|
||||||
|
// `Update` inside `update_file_streaming`; this Read check is the
|
||||||
|
// defence-in-depth existence-proof (see project_webdav_authz_second_audit).
|
||||||
let mut file_existed = false;
|
let mut file_existed = false;
|
||||||
let mut current_etag: Option<String> = None;
|
let mut current_etag: Option<String> = None;
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path_for_user(&path, user.id).await {
|
match resolver.resolve_path_in_drive(&path, drive_id).await {
|
||||||
Ok(ResolvedResource::File(f)) => {
|
Ok(ResolvedResource::File(f)) => {
|
||||||
|
let file_uuid = Uuid::parse_str(&f.id)
|
||||||
|
.map_err(|_| AppError::not_found(format!("File not found: {}", path)))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
file_existed = true;
|
file_existed = true;
|
||||||
current_etag = Some(f.etag.clone());
|
current_etag = Some(f.etag.clone());
|
||||||
}
|
}
|
||||||
@@ -1369,12 +1461,36 @@ async fn handle_put(
|
|||||||
// parent MUST produce 409 Conflict, not 404.
|
// parent MUST produce 409 Conflict, not 404.
|
||||||
let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or("");
|
let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or("");
|
||||||
if !parent_path.is_empty() {
|
if !parent_path.is_empty() {
|
||||||
resolver
|
let parent = resolver
|
||||||
.resolve_path_for_user(parent_path, user.id)
|
.resolve_path_in_drive(parent_path, drive_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
AppError::conflict(format!("Parent folder not found: {}", parent_path))
|
AppError::conflict(format!("Parent folder not found: {}", parent_path))
|
||||||
})?;
|
})?;
|
||||||
|
if let ResolvedResource::Folder(folder) = parent {
|
||||||
|
let folder_uuid = Uuid::parse_str(&folder.id).map_err(|_| {
|
||||||
|
AppError::conflict(format!("Parent folder not found: {}", parent_path))
|
||||||
|
})?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::conflict(format!(
|
||||||
|
"Parent folder not found: {}",
|
||||||
|
parent_path
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
return Err(AppError::conflict(format!(
|
||||||
|
"Parent path is a file, not a collection: {}",
|
||||||
|
parent_path
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1449,8 +1565,11 @@ async fn handle_put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Atomic store ──────────────────────────────────────────────────
|
// ── Atomic store ──────────────────────────────────────────────────
|
||||||
|
// `drive_id` was resolved above (existence-check block) and is
|
||||||
|
// reused here — the same drive that scoped the resolver scopes the
|
||||||
|
// write. `update_file_streaming` enforces `Permission::Update`
|
||||||
|
// internally via its `_with_perms` shape.
|
||||||
let content_type = ingested.content_type.clone();
|
let content_type = ingested.content_type.clone();
|
||||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
|
||||||
let result = file_upload_service
|
let result = file_upload_service
|
||||||
.update_file_streaming(
|
.update_file_streaming(
|
||||||
&path,
|
&path,
|
||||||
@@ -1552,9 +1671,12 @@ async fn handle_mkcol(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check whether the target itself already exists (file or folder → 405).
|
// Check whether the target itself already exists (file or folder → 405).
|
||||||
|
// `exists_in_drive` is an existence-only probe (returns a bool), so no
|
||||||
|
// per-resource authz is applicable here — the create call below is
|
||||||
|
// authorised via `Permission::Create` on the parent.
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
if resolver
|
if resolver
|
||||||
.exists_for_user(&path, user.id)
|
.exists_in_drive(&path, drive_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
@@ -1587,10 +1709,29 @@ async fn handle_mkcol(
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let parent_path = parent_segments.join("/");
|
let parent_path = parent_segments.join("/");
|
||||||
// Parent must be a folder, not a file.
|
// Parent must be a folder, not a file. Post-D7 the resolver is
|
||||||
|
// drive-scoped so we explicitly `authz.require(Read, Folder)` on the
|
||||||
|
// returned parent — the actual create then runs under
|
||||||
|
// `Permission::Create` on the same folder inside `create_folder_with_perms`.
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path_for_user(&parent_path, user.id).await {
|
match resolver.resolve_path_in_drive(&parent_path, drive_id).await {
|
||||||
Ok(ResolvedResource::Folder(f)) => Some(f.id),
|
Ok(ResolvedResource::Folder(f)) => {
|
||||||
|
let folder_uuid = Uuid::parse_str(&f.id).map_err(|_| {
|
||||||
|
AppError::conflict(format!("Parent folder not found: {}", parent_path))
|
||||||
|
})?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::conflict(format!("Parent folder not found: {}", parent_path))
|
||||||
|
})?;
|
||||||
|
Some(f.id)
|
||||||
|
}
|
||||||
Ok(ResolvedResource::File(_)) => {
|
Ok(ResolvedResource::File(_)) => {
|
||||||
return Err(AppError::conflict(
|
return Err(AppError::conflict(
|
||||||
"Parent path is a file, not a collection",
|
"Parent path is a file, not a collection",
|
||||||
@@ -1807,7 +1948,7 @@ async fn handle_move(
|
|||||||
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
||||||
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
||||||
resolver
|
resolver
|
||||||
.exists_for_user(&destination_path, user.id)
|
.exists_in_drive(&destination_path, drive_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -2089,7 +2230,7 @@ async fn handle_copy(
|
|||||||
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
||||||
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
||||||
resolver
|
resolver
|
||||||
.exists_for_user(&destination_path, user.id)
|
.exists_in_drive(&destination_path, drive_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
|
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
|
||||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||||
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||||
use crate::application::ports::file_ports::{
|
use crate::application::ports::file_ports::{
|
||||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
|
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
|
||||||
@@ -23,6 +24,8 @@ use crate::application::ports::folder_ports::FolderUseCase;
|
|||||||
use crate::application::ports::trash_ports::TrashUseCase;
|
use crate::application::ports::trash_ports::TrashUseCase;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::common::mime_detect::filename_from_path;
|
use crate::common::mime_detect::filename_from_path;
|
||||||
|
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||||
|
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||||
use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE;
|
use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||||
@@ -230,75 +233,122 @@ async fn handle_propfind(
|
|||||||
|
|
||||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||||
|
|
||||||
let folder_service = &state.applications.folder_service;
|
// Single-query path resolution (drive-scoped) — same shared
|
||||||
let file_service = &state.applications.file_retrieval_service;
|
// resolver as native `/webdav/…`. Post-D7 the resolver is not
|
||||||
|
// owner-scoped, so we `authz.require(Read, …)` on the returned
|
||||||
// Try to resolve as folder first.
|
// resource explicitly before emitting the multistatus.
|
||||||
let folder_result = folder_service
|
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(folder) = folder_result {
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// Multi-drive POC: the hrefs in the response must echo the
|
|
||||||
// wire form (`{user}~{drive}`) the client requested, so we
|
|
||||||
// pass `url_user` (not `user.username`) as the streaming
|
|
||||||
// function's username arg. Refining the owner-id usages
|
|
||||||
// back to the canonical username is deferred to the
|
|
||||||
// NcSession commit.
|
|
||||||
return Ok(build_nc_streaming_propfind(
|
|
||||||
state.clone(),
|
|
||||||
folder,
|
|
||||||
depth,
|
|
||||||
user.id,
|
|
||||||
url_user.to_string(),
|
|
||||||
subpath.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not a folder — try as a file.
|
|
||||||
let file_result = file_service
|
|
||||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
|
||||||
.await;
|
|
||||||
if let Ok(file) = file_result {
|
|
||||||
// Batch-check favorites for this single file.
|
|
||||||
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
|
|
||||||
let items: Vec<(&str, &str)> = vec![(&file.id, "file")];
|
|
||||||
fav_svc
|
|
||||||
.batch_check_favorites(user.id, &items)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
HashSet::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let nc = state.nextcloud.as_ref();
|
|
||||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
write_nc_file_multistatus(
|
|
||||||
&mut buf,
|
|
||||||
&file,
|
|
||||||
url_user,
|
|
||||||
&user.username,
|
|
||||||
subpath,
|
|
||||||
file_id_svc,
|
|
||||||
&favorite_ids,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
.ok_or_else(|| AppError::not_found("Resource not found"))?;
|
||||||
|
|
||||||
return Ok(Response::builder()
|
match resolved {
|
||||||
.status(StatusCode::MULTI_STATUS)
|
ResolvedResource::Folder(folder) => {
|
||||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||||
.body(Body::from(buf))
|
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||||
.unwrap());
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Multi-drive POC: the hrefs in the response must echo the
|
||||||
|
// wire form (`{user}~{drive}`) the client requested, so we
|
||||||
|
// pass `url_user` (not `user.username`) as the streaming
|
||||||
|
// function's username arg. Refining the owner-id usages
|
||||||
|
// back to the canonical username is deferred to the
|
||||||
|
// NcSession commit.
|
||||||
|
Ok(build_nc_streaming_propfind(
|
||||||
|
state.clone(),
|
||||||
|
folder,
|
||||||
|
depth,
|
||||||
|
user.id,
|
||||||
|
url_user.to_string(),
|
||||||
|
subpath.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
ResolvedResource::File(file) => {
|
||||||
|
let file_uuid =
|
||||||
|
Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Batch-check favorites for this single file.
|
||||||
|
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||||
|
let items: Vec<(&str, &str)> = vec![(&file.id, "file")];
|
||||||
|
fav_svc
|
||||||
|
.batch_check_favorites(user.id, &items)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
HashSet::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let nc = state.nextcloud.as_ref();
|
||||||
|
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write_nc_file_multistatus(
|
||||||
|
&mut buf,
|
||||||
|
&file,
|
||||||
|
url_user,
|
||||||
|
&user.username,
|
||||||
|
subpath,
|
||||||
|
file_id_svc,
|
||||||
|
&favorite_ids,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::MULTI_STATUS)
|
||||||
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||||
|
.body(Body::from(buf))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Err(AppError::not_found("Resource not found"))
|
/// NC-surface path resolution: try the single-query resolver, fall back
|
||||||
|
/// to the double-query `get_*_by_path` pair when the resolver isn't
|
||||||
|
/// configured. Same shape and drive-scope as the native surface —
|
||||||
|
/// callers `authz.require(…)` on the returned resource.
|
||||||
|
async fn nc_resolve_or_fallback(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
internal_path: &str,
|
||||||
|
drive_id: Uuid,
|
||||||
|
) -> Option<ResolvedResource> {
|
||||||
|
if let Some(resolver) = &state.path_resolver
|
||||||
|
&& let Ok(r) = resolver
|
||||||
|
.resolve_path_in_drive(internal_path, drive_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
let folder_service = &state.applications.folder_service;
|
||||||
|
if let Ok(folder) = folder_service
|
||||||
|
.get_folder_by_path(internal_path, drive_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Some(ResolvedResource::Folder(folder));
|
||||||
|
}
|
||||||
|
let file_service = &state.applications.file_retrieval_service;
|
||||||
|
if let Ok(file) = file_service.get_file_by_path(internal_path, drive_id).await {
|
||||||
|
return Some(ResolvedResource::File(file));
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── GET ────────────────────
|
// ──────────────────── GET ────────────────────
|
||||||
@@ -319,27 +369,50 @@ async fn handle_get(
|
|||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let user = &session.user;
|
||||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||||
let file_service = &state.applications.file_retrieval_service;
|
let file_service = &state.applications.file_retrieval_service;
|
||||||
let folder_service = &state.applications.folder_service;
|
|
||||||
|
|
||||||
// Check if path is a folder first (NC clients use GET as existence check)
|
// Single-query path resolution. NC clients use GET on a folder as
|
||||||
if folder_service
|
// an existence probe (returns 200 empty); file GETs serve content.
|
||||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
// Post-D7 the resolver is drive-scoped, so both branches
|
||||||
|
// `authz.require(Read, …)` before responding.
|
||||||
|
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.ok_or_else(|| AppError::not_found("File not found"))?;
|
||||||
{
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("DAV", "1, 3")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = file_service
|
let file = match resolved {
|
||||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
ResolvedResource::Folder(folder) => {
|
||||||
.await
|
let folder_uuid =
|
||||||
.map_err(|_| AppError::not_found("File not found"))?;
|
Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("DAV", "1, 3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
ResolvedResource::File(f) => {
|
||||||
|
let file_uuid =
|
||||||
|
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ETag comes from `FileDto::etag` (populated from `File::etag()`
|
// ETag comes from `FileDto::etag` (populated from `File::etag()`
|
||||||
// in the `From<File>` impl) — single source of truth, so GET,
|
// in the `From<File>` impl) — single source of truth, so GET,
|
||||||
@@ -406,27 +479,47 @@ async fn handle_head(
|
|||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let user = &session.user;
|
||||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||||
let file_service = &state.applications.file_retrieval_service;
|
|
||||||
let folder_service = &state.applications.folder_service;
|
|
||||||
|
|
||||||
// Check if path is a folder (NC clients use HEAD as existence check)
|
// Single-query path resolution. Both branches `authz.require(Read, …)`
|
||||||
if folder_service
|
// on the returned resource before responding.
|
||||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.ok_or_else(|| AppError::not_found("File not found"))?;
|
||||||
{
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("DAV", "1, 3")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = file_service
|
let file = match resolved {
|
||||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
ResolvedResource::Folder(folder) => {
|
||||||
.await
|
let folder_uuid =
|
||||||
.map_err(|_| AppError::not_found("File not found"))?;
|
Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("DAV", "1, 3")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
ResolvedResource::File(f) => {
|
||||||
|
let file_uuid =
|
||||||
|
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let modified_at =
|
let modified_at =
|
||||||
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
||||||
@@ -486,20 +579,41 @@ async fn handle_proppatch(
|
|||||||
// the prior behaviour. A PROPPATCH that *does* try to set
|
// the prior behaviour. A PROPPATCH that *does* try to set
|
||||||
// favorite on a missing resource still returns NotFound.
|
// favorite on a missing resource still returns NotFound.
|
||||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||||
let file_service = &state.applications.file_retrieval_service;
|
|
||||||
let folder_service = &state.applications.folder_service;
|
// Single-query path resolution — PROPPATCH may target either a
|
||||||
let resource = if let Ok(file) = file_service
|
// folder or a file. Post-D7 the resolver is drive-scoped, so we
|
||||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
// `authz.require(Read, …)` on the returned resource before
|
||||||
.await
|
// reading its type. The favorite mutation below itself doesn't
|
||||||
{
|
// require additional authz (favorites are per-user; the caller can
|
||||||
Some((file.id, "file"))
|
// favourite any resource they can see).
|
||||||
} else if let Ok(folder) = folder_service
|
let resource = match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
Some(ResolvedResource::File(f)) => {
|
||||||
.await
|
let file_uuid =
|
||||||
{
|
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||||||
Some((folder.id, "folder"))
|
state
|
||||||
} else {
|
.authorization
|
||||||
None
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Some((f.id, "file"))
|
||||||
|
}
|
||||||
|
Some(ResolvedResource::Folder(folder)) => {
|
||||||
|
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||||
|
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Some((folder.id, "folder"))
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
};
|
};
|
||||||
let is_collection = matches!(resource, Some((_, "folder")));
|
let is_collection = matches!(resource, Some((_, "folder")));
|
||||||
|
|
||||||
@@ -877,74 +991,79 @@ async fn handle_delete(
|
|||||||
let chroot = session.require_chroot()?;
|
let chroot = session.require_chroot()?;
|
||||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
let file_service = &state.applications.file_retrieval_service;
|
|
||||||
|
|
||||||
// Prefer soft-delete (move to trash) when trash service is available.
|
// Single-query path resolution. Post-D7 the resolver is drive-scoped,
|
||||||
// This is what Nextcloud clients expect — items appear in the trashbin.
|
// so we `authz.require(Read, …)` on the returned resource before
|
||||||
if let Some(trash_svc) = state.trash_service.as_ref() {
|
// dispatching. The actual delete is authorised as `Permission::Delete`
|
||||||
if let Ok(folder) = folder_service
|
// inside the downstream service (`trash_svc.move_to_trash` /
|
||||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
// `delete_folder_with_perms` / `delete_file_with_perms` all take
|
||||||
.await
|
// `caller_id`).
|
||||||
{
|
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||||
trash_svc
|
|
||||||
.move_to_trash(&folder.id, "folder", user.id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to trash folder: {}", e)))?;
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::NO_CONTENT)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
if let Ok(file) = file_service
|
|
||||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
trash_svc
|
|
||||||
.move_to_trash(&file.id, "file", user.id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to trash file: {}", e)))?;
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::NO_CONTENT)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
return Err(AppError::not_found("Resource not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: hard delete when trash service is not available.
|
|
||||||
let file_mgmt = &state.applications.file_management_service;
|
|
||||||
|
|
||||||
if let Ok(folder) = folder_service
|
|
||||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
|
||||||
.await
|
.await
|
||||||
{
|
.ok_or_else(|| AppError::not_found("Resource not found"))?;
|
||||||
folder_service
|
|
||||||
.delete_folder_with_perms(&folder.id, user.id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
|
||||||
|
|
||||||
return Ok(Response::builder()
|
match resolved {
|
||||||
.status(StatusCode::NO_CONTENT)
|
ResolvedResource::Folder(folder) => {
|
||||||
.body(Body::empty())
|
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||||
.unwrap());
|
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some(trash_svc) = state.trash_service.as_ref() {
|
||||||
|
trash_svc
|
||||||
|
.move_to_trash(&folder.id, "folder", user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::internal_error(format!("Failed to trash folder: {}", e))
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
folder_service
|
||||||
|
.delete_folder_with_perms(&folder.id, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::internal_error(format!("Failed to delete folder: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResolvedResource::File(file) => {
|
||||||
|
let file_uuid =
|
||||||
|
Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::File(file_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some(trash_svc) = state.trash_service.as_ref() {
|
||||||
|
trash_svc
|
||||||
|
.move_to_trash(&file.id, "file", user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::internal_error(format!("Failed to trash file: {}", e))
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
let file_mgmt = &state.applications.file_management_service;
|
||||||
|
file_mgmt
|
||||||
|
.delete_file_with_perms(&file.id, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::internal_error(format!("Failed to delete file: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(file) = file_service
|
Ok(Response::builder()
|
||||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
.status(StatusCode::NO_CONTENT)
|
||||||
.await
|
.body(Body::empty())
|
||||||
{
|
.unwrap())
|
||||||
file_mgmt
|
|
||||||
.delete_file_with_perms(&file.id, user.id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
|
|
||||||
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::NO_CONTENT)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(AppError::not_found("Resource not found"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── MOVE ────────────────────
|
// ──────────────────── MOVE ────────────────────
|
||||||
@@ -992,21 +1111,18 @@ async fn handle_move(
|
|||||||
let file_mgmt = &state.applications.file_management_service;
|
let file_mgmt = &state.applications.file_management_service;
|
||||||
|
|
||||||
// ── Destination-collision precondition (RFC 4918 §9.9.4) ──────────
|
// ── Destination-collision precondition (RFC 4918 §9.9.4) ──────────
|
||||||
// Resolved once up-front so the file/folder branches below don't
|
// Single-query probe via the shared resolver — the destination is
|
||||||
// each have to repeat the check. `dest_existed_before` becomes the
|
// either a file, a folder, or absent. `dest_existed_before`
|
||||||
// 204-vs-201 selector at response time.
|
// becomes the 204-vs-201 selector at response time. Post-D7 the
|
||||||
|
// resolver is drive-scoped; on the overwrite path we
|
||||||
|
// `authz.require(Read, …)` explicitly and the downstream delete
|
||||||
|
// enforces `Permission::Delete`.
|
||||||
let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?;
|
let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?;
|
||||||
let dest_existing_file = file_service
|
let dest_existing =
|
||||||
.get_file_by_path(&dest_internal_precheck, chroot.drive_id)
|
nc_resolve_or_fallback(&state, &dest_internal_precheck, chroot.drive_id).await;
|
||||||
.await
|
let dest_existed_before = dest_existing.is_some();
|
||||||
.ok();
|
|
||||||
let dest_existing_folder = folder_service
|
|
||||||
.get_folder_by_path(&dest_internal_precheck, chroot.drive_id)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
|
|
||||||
|
|
||||||
if dest_existed_before {
|
if let Some(existing) = dest_existing {
|
||||||
if overwrite_forbidden {
|
if overwrite_forbidden {
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::PRECONDITION_FAILED)
|
.status(StatusCode::PRECONDITION_FAILED)
|
||||||
@@ -1017,23 +1133,51 @@ async fn handle_move(
|
|||||||
// then proceed with the move. Trashing is fine: per RFC the source
|
// then proceed with the move. Trashing is fine: per RFC the source
|
||||||
// resource appears at the destination URI; what happens to the
|
// resource appears at the destination URI; what happens to the
|
||||||
// overwritten one is up to the server.
|
// overwritten one is up to the server.
|
||||||
if let Some(existing_file) = &dest_existing_file {
|
match existing {
|
||||||
file_mgmt
|
ResolvedResource::File(existing_file) => {
|
||||||
.delete_and_cleanup_with_perms(&existing_file.id, user.id)
|
let file_uuid = Uuid::parse_str(&existing_file.id).map_err(|_| {
|
||||||
.await
|
AppError::internal_error("Failed to overwrite destination file")
|
||||||
.map_err(|e| {
|
|
||||||
AppError::internal_error(format!("Failed to overwrite destination file: {}", e))
|
|
||||||
})?;
|
})?;
|
||||||
} else if let Some(existing_folder) = &dest_existing_folder {
|
state
|
||||||
folder_service
|
.authorization
|
||||||
.delete_folder_with_perms(&existing_folder.id, user.id)
|
.require(
|
||||||
.await
|
Subject::User(user.id),
|
||||||
.map_err(|e| {
|
Permission::Read,
|
||||||
AppError::internal_error(format!(
|
Resource::File(file_uuid),
|
||||||
"Failed to overwrite destination folder: {}",
|
)
|
||||||
e
|
.await?;
|
||||||
))
|
file_mgmt
|
||||||
|
.delete_and_cleanup_with_perms(&existing_file.id, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::internal_error(format!(
|
||||||
|
"Failed to overwrite destination file: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
ResolvedResource::Folder(existing_folder) => {
|
||||||
|
let folder_uuid = Uuid::parse_str(&existing_folder.id).map_err(|_| {
|
||||||
|
AppError::internal_error("Failed to overwrite destination folder")
|
||||||
})?;
|
})?;
|
||||||
|
state
|
||||||
|
.authorization
|
||||||
|
.require(
|
||||||
|
Subject::User(user.id),
|
||||||
|
Permission::Read,
|
||||||
|
Resource::Folder(folder_uuid),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
folder_service
|
||||||
|
.delete_folder_with_perms(&existing_folder.id, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::internal_error(format!(
|
||||||
|
"Failed to overwrite destination folder: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user