Merge origin/main into webdav-litmus-compliance
This commit is contained in:
@@ -422,13 +422,17 @@ pub async fn handle_search(
|
||||
|
||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Map file results
|
||||
// TODO(D1): drop the hardcoded "Personal/" prefix and read the
|
||||
// caller's default-drive root folder name from `drives.root_folder_id`
|
||||
// instead. Correct for D0-provisioned default drives; secondary
|
||||
// drives keep their original root name.
|
||||
// Map file results.
|
||||
//
|
||||
// `strip_drive_root_segment` handles both default and secondary
|
||||
// drives — post-D0 the first path segment is the drive's root
|
||||
// folder name (`"Personal"` for D0-provisioned defaults, the
|
||||
// original sibling-root name for M2 backfilled secondaries).
|
||||
// Read-scope is upstream in `state.applications.search_service`;
|
||||
// this handler only formats display paths.
|
||||
for file in &results.files {
|
||||
let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path);
|
||||
let display_path =
|
||||
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&file.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
let numeric_id = file_id_map.get(&file.id).copied();
|
||||
@@ -452,12 +456,10 @@ pub async fn handle_search(
|
||||
}));
|
||||
}
|
||||
|
||||
// Map folder results — same TODO(D1) as above.
|
||||
// Map folder results — same drive-agnostic strip as above.
|
||||
for folder in &results.folders {
|
||||
let display_path = folder
|
||||
.path
|
||||
.strip_prefix("Personal/")
|
||||
.unwrap_or(&folder.path);
|
||||
let display_path =
|
||||
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&folder.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
entries.push(json!({
|
||||
|
||||
@@ -11,11 +11,14 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize};
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PreviewParams {
|
||||
@@ -89,9 +92,28 @@ pub async fn handle_preview(
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the authenticated user owns this file
|
||||
let user_id_str = user.id.to_string();
|
||||
if file.owner_id.as_deref() != Some(user_id_str.as_str()) {
|
||||
// Verify the authenticated user can Read this file. Anti-enum: any
|
||||
// AuthZ denial surfaces as 404 (same shape as "unknown file" above),
|
||||
// and the engine emits an `authz.denied` audit line internally.
|
||||
let file_uuid = match Uuid::parse_str(&file.id) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File not found"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
if state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File not found"))
|
||||
|
||||
@@ -63,6 +63,15 @@ async fn handle_filter_files(
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let url_user = &session.raw_username;
|
||||
// Chroot-scope the response: NC's `oc:filter-files` REPORT is a
|
||||
// single-drive surface (the client PROPFINDs favorites under its
|
||||
// "home" URL and has no cross-drive concept). Favorites that live
|
||||
// in another drive the caller is a member of are dropped from
|
||||
// this response; they're still reachable via REST
|
||||
// `/api/favorites/resources`. `session.require_chroot()` is safe
|
||||
// here — the REPORT verb only reaches this handler through a
|
||||
// path-scoped route.
|
||||
let chroot = session.require_chroot()?;
|
||||
let fav_svc = match state.favorites_service.as_ref() {
|
||||
Some(svc) => svc,
|
||||
None => return Ok(empty_multistatus()),
|
||||
@@ -85,11 +94,11 @@ async fn handle_filter_files(
|
||||
// All items in this response are favorites.
|
||||
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
|
||||
|
||||
// TODO(D1): replace the hardcoded "Personal/" prefix with the
|
||||
// caller's default-drive root folder name read from
|
||||
// `drives.root_folder_id`. Correct for D0-provisioned default
|
||||
// drives; secondary drives keep their original root name.
|
||||
let home_prefix = "Personal/";
|
||||
// `home_prefix` is unused after the chroot-aware strip
|
||||
// (see `strip_home_prefix`); kept as a positional argument in
|
||||
// the emit calls below for signature stability with the
|
||||
// report-handler tests and the parallel search-pass caller.
|
||||
let home_prefix = "";
|
||||
|
||||
// Pass 1: resolve the favorited DTOs in two batch queries (was one
|
||||
// get_* per favorite — up to N serial round-trips on a sync client's
|
||||
@@ -156,7 +165,17 @@ async fn handle_filter_files(
|
||||
// multi-drive `~{drive}` form is echoed back to the client;
|
||||
// owner-id stays canonical via `&user.username`.
|
||||
for file in &files {
|
||||
let subpath = strip_home_prefix(&file.path, home_prefix);
|
||||
// Skip favorites that live outside the caller's chroot
|
||||
// (other-drive favorites); reachable via REST if needed.
|
||||
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"REPORT filter-files: dropping cross-chroot favorite '{}' at '{}'",
|
||||
file.id,
|
||||
file.path,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
@@ -174,7 +193,15 @@ async fn handle_filter_files(
|
||||
}
|
||||
|
||||
for folder in &folders {
|
||||
let subpath = strip_home_prefix(&folder.path, home_prefix);
|
||||
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"REPORT filter-files: dropping cross-chroot favorite folder '{}' at '{}'",
|
||||
folder.id,
|
||||
folder.path,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
@@ -210,9 +237,13 @@ async fn handle_search(
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
// Validate chroot up-front (path-scoped handler); `resolve_scope_folder`
|
||||
// below re-pulls it from the session for the path-mapping step.
|
||||
session.require_chroot()?;
|
||||
// Chroot-scope the response: NC's search REPORT is a single-drive
|
||||
// surface. Results that live outside the chroot (other drives the
|
||||
// caller is a member of) are dropped from the multistatus and
|
||||
// recorded at debug — reachable via REST search if needed.
|
||||
// `resolve_scope_folder` below re-pulls chroot from the session
|
||||
// for the path-mapping step.
|
||||
let chroot = session.require_chroot()?;
|
||||
let url_user = &session.raw_username;
|
||||
let search_svc = match state.applications.search_service.as_ref() {
|
||||
Some(svc) => svc,
|
||||
@@ -244,10 +275,9 @@ async fn handle_search(
|
||||
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
// TODO(D1): same as the favorites pass above — replace the
|
||||
// hardcoded "Personal/" with the caller's actual default-drive
|
||||
// root folder name from `drives.root_folder_id`.
|
||||
let home_prefix = "Personal/";
|
||||
// See the favorites pass above: `home_prefix` is unused after the
|
||||
// chroot-aware strip, kept only for signature stability.
|
||||
let home_prefix = "";
|
||||
|
||||
// No favorite checking for search results -- pass an empty set.
|
||||
let favorite_ids: HashSet<String> = HashSet::new();
|
||||
@@ -269,7 +299,15 @@ async fn handle_search(
|
||||
|
||||
// Files.
|
||||
for file in &files {
|
||||
let subpath = strip_home_prefix(&file.path, home_prefix);
|
||||
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"REPORT search: dropping cross-chroot file '{}' at '{}'",
|
||||
file.id,
|
||||
file.path,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
@@ -288,7 +326,15 @@ async fn handle_search(
|
||||
|
||||
// Folders.
|
||||
for folder in &folders {
|
||||
let subpath = strip_home_prefix(&folder.path, home_prefix);
|
||||
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"REPORT search: dropping cross-chroot folder '{}' at '{}'",
|
||||
folder.id,
|
||||
folder.path,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
@@ -345,7 +391,6 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
|
||||
.into(),
|
||||
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
|
||||
size_formatted: format_file_size(fr.size),
|
||||
owner_id: None,
|
||||
sort_date: None,
|
||||
content_hash: fr.blob_hash.clone(),
|
||||
etag,
|
||||
@@ -365,7 +410,6 @@ fn folder_dto_from_search(
|
||||
name: sr.name.clone(),
|
||||
path: sr.path.clone(),
|
||||
parent_id: sr.parent_id.clone(),
|
||||
owner_id: None,
|
||||
drive_id: sr.drive_id,
|
||||
created_at: sr.created_at,
|
||||
modified_at: sr.modified_at,
|
||||
@@ -552,7 +596,19 @@ fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip the `My Folder - {username}/` prefix to get the DAV subpath.
|
||||
fn strip_home_prefix<'a>(path: &'a str, prefix: &str) -> &'a str {
|
||||
path.strip_prefix(prefix).unwrap_or(path)
|
||||
/// Strip the caller's chroot prefix from an internal path so the
|
||||
/// caller-facing DAV subpath is chroot-relative. Delegates to
|
||||
/// `webdav_handler::strip_chroot_prefix` — chroot-aware, multi-segment
|
||||
/// safe, and rejects items outside the chroot. Callers must decide
|
||||
/// per-response whether an out-of-chroot item is dropped or falls
|
||||
/// back to the naive strip.
|
||||
///
|
||||
/// See `strip_chroot_prefix` for the full contract. The `_prefix`
|
||||
/// legacy arg stays for signature stability with the emit helpers.
|
||||
fn strip_home_prefix<'a>(
|
||||
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
||||
path: &'a str,
|
||||
_prefix: &str,
|
||||
) -> Option<&'a str> {
|
||||
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, path)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ async fn handle_propfind(
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
// Chroot-scope the trashbin view: `get_trash_items(user.id)`
|
||||
// spans every drive the caller is a member of, but NC's
|
||||
// trashbin surface is a single-drive concept from the client's
|
||||
// POV. Items outside the chroot are dropped from the multistatus
|
||||
// (see `write_trashbin_multistatus` → `strip_home_prefix` →
|
||||
// `webdav_handler::strip_chroot_prefix`) and remain reachable
|
||||
// via REST `/api/trash/resources`.
|
||||
let chroot = session.require_chroot()?;
|
||||
let trash_svc = state
|
||||
.trash_service
|
||||
.as_ref()
|
||||
@@ -95,7 +103,7 @@ async fn handle_propfind(
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc)
|
||||
write_trashbin_multistatus(&mut buf, &items, &user.username, chroot, file_id_svc)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
|
||||
@@ -259,18 +267,23 @@ fn mime_from_name(name: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Strip the home-folder prefix from an original path to produce the
|
||||
/// Nextcloud-relative original location.
|
||||
/// Strip the caller's chroot prefix from an original path to produce
|
||||
/// the Nextcloud-relative original-location value.
|
||||
///
|
||||
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
|
||||
/// default-drive root folder name read from `drives.root_folder_id`.
|
||||
/// Correct for D0-provisioned default drives; secondary drives keep
|
||||
/// their original root name. The `_username` arg stays for now so the
|
||||
/// upcoming dynamic lookup has a way to identify the caller.
|
||||
fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
|
||||
original_path
|
||||
.strip_prefix("Personal/")
|
||||
.unwrap_or(original_path)
|
||||
/// Delegates to `webdav_handler::strip_chroot_prefix` — chroot-aware,
|
||||
/// multi-segment safe, and returns `None` when the item is outside
|
||||
/// the chroot (e.g. a trashed item in another drive the caller is a
|
||||
/// member of). The `_username` arg stays for signature stability
|
||||
/// with call sites that thread it; the strip itself no longer uses it.
|
||||
///
|
||||
/// See the doc on `strip_chroot_prefix` for the AuthZ caveat — this
|
||||
/// is a display helper, not an ownership check.
|
||||
fn strip_home_prefix<'a>(
|
||||
original_path: &'a str,
|
||||
_username: &str,
|
||||
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
||||
) -> Option<&'a str> {
|
||||
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, original_path)
|
||||
}
|
||||
|
||||
// ────────────── Trashbin PROPFIND XML Generation ──────────────
|
||||
@@ -280,10 +293,16 @@ use crate::application::services::nextcloud_file_id_service::NextcloudFileIdServ
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
|
||||
///
|
||||
/// `chroot` scopes the response — items whose original path is outside
|
||||
/// the chroot (other drives the caller is a member of) are dropped
|
||||
/// silently. NC's trashbin surface is single-drive from the client's
|
||||
/// perspective; cross-drive items remain reachable via REST.
|
||||
async fn write_trashbin_multistatus<W: std::io::Write>(
|
||||
writer: W,
|
||||
items: &[TrashedItemDto],
|
||||
username: &str,
|
||||
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
) -> Result<(), String> {
|
||||
let mut xml = Writer::new(writer);
|
||||
@@ -315,9 +334,24 @@ async fn write_trashbin_multistatus<W: std::io::Write>(
|
||||
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
|
||||
id_map.extend(folder_id_map);
|
||||
|
||||
// Individual trashed items.
|
||||
// Individual trashed items — skip those whose original path is
|
||||
// outside the chroot (other-drive trash reachable via REST).
|
||||
for item in items {
|
||||
write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?;
|
||||
if crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(
|
||||
chroot,
|
||||
&item.original_path,
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"trashbin PROPFIND: dropping cross-chroot item '{}' at '{}'",
|
||||
item.id,
|
||||
item.original_path,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
write_trash_item_response(&mut xml, item, username, chroot, file_id_svc, &id_map)?;
|
||||
}
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
@@ -363,10 +397,18 @@ fn write_trash_root_response<W: std::io::Write>(
|
||||
}
|
||||
|
||||
/// Write a single trashed item as a `<d:response>` element.
|
||||
///
|
||||
/// Caller is expected to have already verified the item is inside
|
||||
/// `chroot` — see the guard in `write_trashbin_multistatus`. This
|
||||
/// function trusts the invariant and expects `strip_home_prefix` to
|
||||
/// return `Some(_)`; if it ever returns `None` (chroot drift between
|
||||
/// the guard and the emit, defensive-only), the original-location
|
||||
/// falls back to an empty string.
|
||||
fn write_trash_item_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
item: &TrashedItemDto,
|
||||
username: &str,
|
||||
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
id_map: &HashMap<String, i64>,
|
||||
) -> Result<(), String> {
|
||||
@@ -427,7 +469,7 @@ fn write_trash_item_response<W: std::io::Write>(
|
||||
write_text_element(xml, "nc:trashbin-filename", &item.name)?;
|
||||
|
||||
// nc:trashbin-original-location
|
||||
let original_location = strip_home_prefix(&item.original_path, username);
|
||||
let original_location = strip_home_prefix(&item.original_path, username, chroot).unwrap_or("");
|
||||
write_text_element(xml, "nc:trashbin-original-location", original_location)?;
|
||||
|
||||
// nc:trashbin-deletion-time
|
||||
|
||||
@@ -367,12 +367,14 @@ async fn handle_assemble(
|
||||
let chroot = session.require_chroot()?;
|
||||
let drive_id = chroot.drive_id;
|
||||
|
||||
// TODO(D1): read the caller's default-drive root folder name from
|
||||
// `drives.root_folder_id` instead of hardcoding "Personal". The
|
||||
// constant is correct for every default personal drive provisioned
|
||||
// by the D0 lifecycle hook, but secondary drives (M2 backfill from
|
||||
// SQL-created sibling root folders) keep their original name.
|
||||
let internal_path = format!("Personal/{}", dest_subpath.trim_matches('/'));
|
||||
// Route through `nc_to_internal_path(chroot, …)` so the write
|
||||
// lands under the caller's actual default-drive root (not the
|
||||
// literal "Personal" folder). Post-D3 chroot resolution puts the
|
||||
// correct FolderDto — including the drive's real root name — on
|
||||
// the NcSession; secondary drives with SQL-provisioned sibling
|
||||
// root names now work.
|
||||
let internal_path =
|
||||
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &dest_subpath)?;
|
||||
|
||||
let filename = filename_from_path(&dest_subpath).to_string();
|
||||
let ingested = ingest_stream_to_cas(
|
||||
@@ -393,7 +395,7 @@ async fn handle_assemble(
|
||||
|
||||
let etag: Option<String> = if existing.is_ok() {
|
||||
let dto = upload_service
|
||||
.update_file_streaming(
|
||||
.update_file_streaming_with_perms(
|
||||
&internal_path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
@@ -412,7 +414,8 @@ async fn handle_assemble(
|
||||
Some((p, n)) => (p, n),
|
||||
None => ("", dest_subpath.as_str()),
|
||||
};
|
||||
let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/'));
|
||||
let parent_internal =
|
||||
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, parent_sub)?;
|
||||
let parent_internal = parent_internal.trim_end_matches('/');
|
||||
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
|
||||
};
|
||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||
use crate::application::ports::file_ports::{
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
|
||||
@@ -25,6 +26,8 @@ use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::filename_from_path;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{
|
||||
PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props,
|
||||
@@ -82,6 +85,78 @@ pub fn nc_to_internal_path(chroot: &FolderDto, subpath: &str) -> Result<String,
|
||||
Ok(format!("{}/{}", chroot.path, subpath))
|
||||
}
|
||||
|
||||
/// Strip the caller's chroot prefix from an internal
|
||||
/// `storage.folders.path` so the DAV subpath surfaced to the NC
|
||||
/// client is chroot-relative. Handles multi-segment chroots
|
||||
/// correctly (e.g. a future `"Personal/folderA/subfolder"` chroot
|
||||
/// against an item at `"Personal/folderA/subfolder/file.txt"`
|
||||
/// returns `"file.txt"`, not `"folderA/subfolder/file.txt"`).
|
||||
///
|
||||
/// Returns `None` when the path is NOT inside the chroot. Callers
|
||||
/// should skip such items from the response (they belong to a
|
||||
/// different drive or the caller's read scope has drifted) — do NOT
|
||||
/// fall back to a naive segment strip, which would surface a
|
||||
/// misleading display path.
|
||||
///
|
||||
/// **Defensive but not an AuthZ boundary.** Every current caller
|
||||
/// reaches items through a `_with_perms` method upstream that
|
||||
/// already gates Read; this helper is the display-string layer
|
||||
/// that also serves as a "does this item belong under the chroot"
|
||||
/// sanity check.
|
||||
pub fn strip_chroot_prefix<'a>(chroot: &FolderDto, internal_path: &'a str) -> Option<&'a str> {
|
||||
// Normalize both sides: `FolderDto.path` comes from
|
||||
// `StoragePath::to_string()` which prepends a leading `/`
|
||||
// (e.g. `"/Personal"`), but DB-side paths coming from
|
||||
// `storage.folders.path` (composed by the `compute_folder_path`
|
||||
// trigger) never have a leading slash. Trim both so `"/Personal"`
|
||||
// vs `"Personal/g9-tree"` matches the intended prefix.
|
||||
let root = chroot.path.trim_matches('/');
|
||||
if root.is_empty() {
|
||||
// Guard against a mis-set chroot with an empty root path —
|
||||
// stripping "" from anything would return the whole path.
|
||||
return None;
|
||||
}
|
||||
let path = internal_path.trim_start_matches('/');
|
||||
let rest = path.strip_prefix(root)?;
|
||||
// Reject a partial prefix match — a chroot of "Personal" must
|
||||
// not match an item at "PersonalSecrets/…".
|
||||
match rest.strip_prefix('/') {
|
||||
Some(subpath) => Some(subpath),
|
||||
// Item path equals the chroot exactly — the chroot itself
|
||||
// (i.e. a folder) is not a legitimate response item, so
|
||||
// treat as an empty subpath.
|
||||
None if rest.is_empty() => Some(""),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Naive fallback: strip the first path segment from an internal
|
||||
/// `storage.folders.path`. Post-D0 every path starts with its drive's
|
||||
/// root folder name (single segment), so for the current schema this
|
||||
/// gives the drive-relative subpath.
|
||||
///
|
||||
/// Use this ONLY when the caller doesn't have a chroot in scope
|
||||
/// (e.g. OCS unified search, whose results legitimately span every
|
||||
/// drive the caller has Read on — no single chroot covers them all).
|
||||
/// Every path-scoped NC handler that DOES have `session` in scope
|
||||
/// should prefer [`strip_chroot_prefix`] — it validates the item
|
||||
/// belongs under the chroot instead of trusting the schema
|
||||
/// invariant, and it survives a future composed chroot like
|
||||
/// `"Personal/folderA/subfolder"`.
|
||||
///
|
||||
/// **Not an AuthZ boundary.** Same caveat as `strip_chroot_prefix`
|
||||
/// — AuthZ is enforced upstream via `_with_perms` methods; this
|
||||
/// helper only formats display strings.
|
||||
///
|
||||
/// Returns `""` when the path is a single segment (i.e. the drive
|
||||
/// root itself, which is never a legitimate item target).
|
||||
pub fn strip_drive_root_segment(internal_path: &str) -> &str {
|
||||
match internal_path.split_once('/') {
|
||||
Some((_root, rest)) => rest,
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Nextcloud DAV href for a **collection** (folder). Always
|
||||
/// terminates with `/` — RFC 4918 §5.2 requires collection URLs to end
|
||||
/// in a slash, and the Nextcloud desktop client strictly enforces this
|
||||
@@ -235,76 +310,124 @@ async fn handle_propfind(
|
||||
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
|
||||
// Try to resolve as folder first.
|
||||
let folder_result = folder_service
|
||||
.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 dead_props = file_dead_props(&state, &file).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
&file,
|
||||
url_user,
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
(&favorite_ids, &dead_props),
|
||||
)
|
||||
// Single-query path resolution (drive-scoped) — same shared
|
||||
// resolver as native `/webdav/…`. Post-D7 the resolver is not
|
||||
// owner-scoped, so we `authz.require(Read, …)` on the returned
|
||||
// resource explicitly before emitting the multistatus.
|
||||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||
.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()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.unwrap());
|
||||
match resolved {
|
||||
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?;
|
||||
|
||||
// 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 dead_props = file_dead_props(&state, &file).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
&file,
|
||||
url_user,
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
(&favorite_ids, &dead_props),
|
||||
)
|
||||
.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 ────────────────────
|
||||
@@ -325,27 +448,50 @@ async fn handle_get(
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
let user = &session.user;
|
||||
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 first (NC clients use GET as existence check)
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
// Single-query path resolution. NC clients use GET on a folder as
|
||||
// an existence probe (returns 200 empty); file GETs serve content.
|
||||
// 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
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("DAV", "1, 3")
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
.ok_or_else(|| AppError::not_found("File not found"))?;
|
||||
|
||||
let file = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("File not found"))?;
|
||||
let file = match resolved {
|
||||
ResolvedResource::Folder(folder) => {
|
||||
let folder_uuid =
|
||||
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()`
|
||||
// in the `From<File>` impl) — single source of truth, so GET,
|
||||
@@ -412,27 +558,47 @@ async fn handle_head(
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
let user = &session.user;
|
||||
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)
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
// Single-query path resolution. Both branches `authz.require(Read, …)`
|
||||
// on the returned resource before responding.
|
||||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("DAV", "1, 3")
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
.ok_or_else(|| AppError::not_found("File not found"))?;
|
||||
|
||||
let file = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("File not found"))?;
|
||||
let file = match resolved {
|
||||
ResolvedResource::Folder(folder) => {
|
||||
let folder_uuid =
|
||||
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 =
|
||||
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
||||
@@ -496,25 +662,38 @@ async fn handle_proppatch(
|
||||
// silently no-opping on a nonexistent path would be a foot-gun —
|
||||
// matches the native `/webdav/` handler's contract.
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let (resource_ref, item_id, item_type, is_collection) = if let Ok(file) = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
let id = Uuid::parse_str(&file.id)
|
||||
.map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?;
|
||||
(ResourceRef::File(id), file.id, "file", false)
|
||||
} else if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
let id = Uuid::parse_str(&folder.id)
|
||||
.map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?;
|
||||
(ResourceRef::Folder(id), folder.id, "folder", true)
|
||||
} else {
|
||||
return Err(AppError::not_found("Resource not found"));
|
||||
};
|
||||
// Single-query path resolution — PROPPATCH may target either a
|
||||
// folder or a file. Post-D7 the resolver is drive-scoped, so we
|
||||
// `authz.require(Read, …)` on the returned resource before
|
||||
// reading its type. The favorite mutation below itself doesn't
|
||||
// require additional authz (favorites are per-user; the caller can
|
||||
// favourite any resource they can see).
|
||||
let (resource_ref, item_id, item_type, is_collection) =
|
||||
match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||
Some(ResolvedResource::File(file)) => {
|
||||
let id = Uuid::parse_str(&file.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(Subject::User(user.id), Permission::Read, Resource::File(id))
|
||||
.await?;
|
||||
(ResourceRef::File(id), file.id, "file", false)
|
||||
}
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
let id = Uuid::parse_str(&folder.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(id),
|
||||
)
|
||||
.await?;
|
||||
(ResourceRef::Folder(id), folder.id, "folder", true)
|
||||
}
|
||||
None => return Err(AppError::not_found("Resource not found")),
|
||||
};
|
||||
|
||||
let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
|
||||
@@ -745,7 +924,7 @@ async fn handle_put(
|
||||
// Single streaming path — handles both update and create internally,
|
||||
// swapping the file row onto the already-ingested blob.
|
||||
let stored = upload_service
|
||||
.update_file_streaming(
|
||||
.update_file_streaming_with_perms(
|
||||
&internal_path,
|
||||
chroot.drive_id,
|
||||
ingested.stored(),
|
||||
@@ -865,74 +1044,79 @@ async fn handle_delete(
|
||||
let chroot = session.require_chroot()?;
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
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.
|
||||
// This is what Nextcloud clients expect — items appear in the trashbin.
|
||||
if let Some(trash_svc) = state.trash_service.as_ref() {
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
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)
|
||||
// Single-query path resolution. Post-D7 the resolver is drive-scoped,
|
||||
// so we `authz.require(Read, …)` on the returned resource before
|
||||
// dispatching. The actual delete is authorised as `Permission::Delete`
|
||||
// inside the downstream service (`trash_svc.move_to_trash` /
|
||||
// `delete_folder_with_perms` / `delete_file_with_perms` all take
|
||||
// `caller_id`).
|
||||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
folder_service
|
||||
.delete_folder_with_perms(&folder.id, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
||||
.ok_or_else(|| AppError::not_found("Resource not found"))?;
|
||||
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
match resolved {
|
||||
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?;
|
||||
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
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
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"))
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── MOVE ────────────────────
|
||||
@@ -980,21 +1164,18 @@ async fn handle_move(
|
||||
let file_mgmt = &state.applications.file_management_service;
|
||||
|
||||
// ── Destination-collision precondition (RFC 4918 §9.9.4) ──────────
|
||||
// Resolved once up-front so the file/folder branches below don't
|
||||
// each have to repeat the check. `dest_existed_before` becomes the
|
||||
// 204-vs-201 selector at response time.
|
||||
// Single-query probe via the shared resolver — the destination is
|
||||
// either a file, a folder, or absent. `dest_existed_before`
|
||||
// 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_existing_file = file_service
|
||||
.get_file_by_path(&dest_internal_precheck, chroot.drive_id)
|
||||
.await
|
||||
.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();
|
||||
let dest_existing =
|
||||
nc_resolve_or_fallback(&state, &dest_internal_precheck, chroot.drive_id).await;
|
||||
let dest_existed_before = dest_existing.is_some();
|
||||
|
||||
if dest_existed_before {
|
||||
if let Some(existing) = dest_existing {
|
||||
if overwrite_forbidden {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::PRECONDITION_FAILED)
|
||||
@@ -1005,23 +1186,51 @@ async fn handle_move(
|
||||
// then proceed with the move. Trashing is fine: per RFC the source
|
||||
// resource appears at the destination URI; what happens to the
|
||||
// overwritten one is up to the server.
|
||||
if let Some(existing_file) = &dest_existing_file {
|
||||
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))
|
||||
match existing {
|
||||
ResolvedResource::File(existing_file) => {
|
||||
let file_uuid = Uuid::parse_str(&existing_file.id).map_err(|_| {
|
||||
AppError::internal_error("Failed to overwrite destination file")
|
||||
})?;
|
||||
} else if let Some(existing_folder) = &dest_existing_folder {
|
||||
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
|
||||
))
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.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
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1681,7 +1890,6 @@ mod tests {
|
||||
name: path.rsplit('/').next().unwrap_or("").to_string(),
|
||||
path: path.to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
// Test stub — path mapper doesn't read drive_id.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: 0,
|
||||
@@ -1744,6 +1952,92 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── strip_chroot_prefix ──
|
||||
//
|
||||
// Regression guard for the "chroot.path has a leading slash from
|
||||
// StoragePath::to_string() but DB-side original_path doesn't" trap
|
||||
// that broke the NC trashbin PROPFIND after Round 2 rolled out.
|
||||
// Also pins the composed-chroot behaviour Ed asked about.
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_default_drive_root() {
|
||||
// FolderDto.path carries a leading slash (StoragePath Display);
|
||||
// DB paths do not. Both must normalise to the same prefix.
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "Personal/g9-tree"),
|
||||
Some("g9-tree")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_deep_path() {
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "Personal/inner/deep.txt"),
|
||||
Some("inner/deep.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_out_of_chroot_returns_none() {
|
||||
// Items on a different drive (whose root isn't "Personal")
|
||||
// must NOT be surfaced under the caller's chroot.
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(strip_chroot_prefix(&chroot, "team-drive/report.pdf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_rejects_partial_prefix_match() {
|
||||
// "Personal" is a prefix substring of "PersonalSecrets" but
|
||||
// NOT a path-segment prefix — must reject.
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "PersonalSecrets/foo.txt"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_composed_chroot() {
|
||||
// The future composed-chroot case Ed raised: chroot points at
|
||||
// a subfolder inside a drive. The strip must remove the ENTIRE
|
||||
// composed prefix, not just the first segment.
|
||||
let chroot = stub_folder("/Personal/folderA/subfolder");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "Personal/folderA/subfolder/foo.txt"),
|
||||
Some("foo.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_composed_chroot_sibling_leaks_blocked() {
|
||||
// Same composed chroot, but the item lives in a sibling
|
||||
// subfolder — must be rejected, not naively strip 1 segment.
|
||||
let chroot = stub_folder("/Personal/folderA/subfolder");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "Personal/folderA/other/foo.txt"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_chroot_root_itself() {
|
||||
// Item path equals chroot exactly — legitimate for a PROPFIND
|
||||
// Depth:0 on the chroot itself. Subpath is empty.
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(strip_chroot_prefix(&chroot, "Personal"), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_empty_chroot_returns_none() {
|
||||
// Defensive: a mis-set chroot with an empty path must not
|
||||
// strip anything (stripping "" from any path would return
|
||||
// the whole path — a silent leak).
|
||||
let chroot = stub_folder("/");
|
||||
assert_eq!(strip_chroot_prefix(&chroot, "Personal/foo.txt"), None);
|
||||
}
|
||||
|
||||
// ── nc_href ──
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user