feat(drive): limit NC request by disk
This commit is contained in:
@@ -52,6 +52,12 @@ pub struct FolderDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
/// Drive that owns this folder. The scope axis for path-based
|
||||
/// lookups across REST / WebDAV / NextCloud / CalDAV / CardDAV.
|
||||
/// Post-D0 `storage.folders.drive_id` is `NOT NULL`; stub /
|
||||
/// DTO-reconstructed folders carry `Uuid::nil()`.
|
||||
pub drive_id: Uuid,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
|
||||
@@ -93,6 +99,7 @@ impl From<Folder> for FolderDto {
|
||||
path: folder.path_string().to_string(),
|
||||
parent_id: folder.parent_id().map(String::from),
|
||||
owner_id: folder.owner_id().map(|u| u.to_string()),
|
||||
drive_id: folder.drive_id(),
|
||||
created_at: folder.created_at(),
|
||||
modified_at: folder.modified_at(),
|
||||
is_root,
|
||||
@@ -144,6 +151,7 @@ impl FolderDto {
|
||||
path: "/stub/path".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
is_root: true,
|
||||
|
||||
@@ -56,9 +56,15 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
/// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT).
|
||||
///
|
||||
/// Takes ownership of the blob's reference (released on failure).
|
||||
///
|
||||
/// `drive_id` scopes both the existence probe (`find_file_by_path`)
|
||||
/// and the parent-folder resolution (`get_parent_folder_id`) — the
|
||||
/// handler is responsible for deriving it from its protocol context
|
||||
/// (NC chroot, native default-drive lookup, WOPI default-drive).
|
||||
async fn update_file_streaming(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
blob: StoredBlob,
|
||||
content_type: &str,
|
||||
modified_at: Option<i64>,
|
||||
@@ -104,8 +110,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Gets a file by its path (for WebDAV)
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
|
||||
/// Gets a file by its path (for WebDAV), scoped to a drive.
|
||||
///
|
||||
/// Post-D0, `storage.files.path` is unique only within a single
|
||||
/// drive. The `drive_id` filter scopes the lookup to a specific
|
||||
/// drive (caller derives it from its protocol context: NC chroot,
|
||||
/// native default-drive lookup, WOPI default-drive lookup).
|
||||
async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Lists files in a folder
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError>;
|
||||
|
||||
@@ -38,14 +38,18 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
|
||||
/// Gets a folder by its path within the caller's tree.
|
||||
///
|
||||
/// Scoped by `user_id` because `storage.folders.path` is unique
|
||||
/// only within a single user's drive after D0 — multiple users
|
||||
/// share names like `"Personal"` for their default-drive root
|
||||
/// folder (docs/plan/drive.md §10). Pre-D0 the wrapper name
|
||||
/// embedded the username and made the path globally unique;
|
||||
/// post-D0 the caller_id filter is required.
|
||||
async fn get_folder_by_path(&self, path: &str, user_id: Uuid)
|
||||
-> Result<FolderDto, DomainError>;
|
||||
/// Scoped by `drive_id` because `storage.folders.path` is unique
|
||||
/// only within a single drive after D0 — multiple drives (whether
|
||||
/// owned by the same user or different users) share names like
|
||||
/// `"Personal"` for their root folder (docs/plan/drive.md §10).
|
||||
/// Pre-D0 the wrapper name embedded the username; post-D0 the
|
||||
/// caller derives a `drive_id` from its protocol context (NC
|
||||
/// chroot, native default-drive lookup, WOPI default-drive).
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
) -> Result<FolderDto, DomainError>;
|
||||
|
||||
/// Lists folders within a parent folder
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
|
||||
|
||||
@@ -82,11 +82,25 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
/// Gets the logical storage path of a file.
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
/// Gets the parent folder ID from a path (WebDAV).
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
||||
/// Gets the parent folder ID from a path (WebDAV), scoped to a drive.
|
||||
///
|
||||
/// Post-D0, `storage.folders.path` is unique only within a single
|
||||
/// drive. The `drive_id` filter scopes the lookup to a specific
|
||||
/// drive (caller derives it from its protocol context: NC chroot,
|
||||
/// native default-drive lookup, WOPI default-drive lookup).
|
||||
async fn get_parent_folder_id(&self, path: &str, drive_id: Uuid)
|
||||
-> Result<String, DomainError>;
|
||||
|
||||
/// Gets a folder ID by its path.
|
||||
async fn get_folder_id_by_path(&self, folder_path: &str) -> Result<String, DomainError>;
|
||||
/// Gets a folder ID by its path, scoped to a drive.
|
||||
///
|
||||
/// Post-D0 same scoping rule as `get_parent_folder_id` — names like
|
||||
/// `"Personal"` repeat across drives, so the `drive_id` filter is
|
||||
/// required to disambiguate.
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
folder_path: &str,
|
||||
drive_id: Uuid,
|
||||
) -> Result<String, DomainError>;
|
||||
|
||||
/// Gets the content-addressable blob hash for a file (O(1) DB lookup).
|
||||
///
|
||||
@@ -94,11 +108,22 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
/// Used for dedup reference tracking without loading file content.
|
||||
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError>;
|
||||
|
||||
/// Find a file by its logical path (folder_name/.../file_name).
|
||||
/// Find a file by its logical path (folder_name/.../file_name),
|
||||
/// scoped to a drive.
|
||||
///
|
||||
/// Post-D0 `storage.files.path` is unique only within a single
|
||||
/// drive. The `drive_id` filter prevents non-deterministic
|
||||
/// resolution when the same path exists in multiple drives.
|
||||
///
|
||||
/// The default implementation falls back to `list_files(None)` + linear
|
||||
/// scan (O(N)). Repositories should override with a direct SQL query.
|
||||
async fn find_file_by_path(&self, path: &str) -> Result<Option<File>, DomainError> {
|
||||
/// scan (O(N)) and ignores the drive filter — only used by stubs.
|
||||
/// Repositories should override with a direct SQL query that applies
|
||||
/// the filter.
|
||||
async fn find_file_by_path(
|
||||
&self,
|
||||
path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<Option<File>, DomainError> {
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
let all_files = self.list_files(None).await?;
|
||||
for file in all_files {
|
||||
|
||||
@@ -286,13 +286,16 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
}
|
||||
|
||||
// FIXME no authorisation at all
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
|
||||
async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result<FileDto, DomainError> {
|
||||
// Direct SQL lookup — O(folder_depth) queries instead of O(total_files)
|
||||
// NOTE: This method does NOT perform any authorization check. Callers
|
||||
// that surface its result to a user-driven request MUST resolve the
|
||||
// file via get_file_owned afterwards, or call authz.require directly.
|
||||
// (Tracked in the audit punch-list under "path-based lookups".)
|
||||
if let Some(file) = self.file_read.find_file_by_path(path).await? {
|
||||
// `drive_id` scope axis prevents cross-drive resolution — without
|
||||
// it, `find_file_by_path` would return a non-deterministic row
|
||||
// when the same path exists in multiple drives.
|
||||
if let Some(file) = self.file_read.find_file_by_path(path, drive_id).await? {
|
||||
return Ok(FileDto::from(file));
|
||||
}
|
||||
|
||||
|
||||
@@ -348,13 +348,14 @@ impl FileUploadUseCase for FileUploadService {
|
||||
async fn update_file_streaming(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
blob: StoredBlob,
|
||||
content_type: &str,
|
||||
modified_at: Option<i64>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// Try to find the existing file first
|
||||
if let Some(file_read) = &self.file_read
|
||||
&& let Some(file) = file_read.find_file_by_path(path).await?
|
||||
&& let Some(file) = file_read.find_file_by_path(path, drive_id).await?
|
||||
{
|
||||
let file_id = file.id().to_string();
|
||||
let (new_hash, updated_at) = self
|
||||
@@ -402,9 +403,15 @@ impl FileUploadUseCase for FileUploadService {
|
||||
|
||||
// get_parent_folder_id expects the full file path — it strips the
|
||||
// last segment (filename) internally to find the parent folder.
|
||||
// `drive_id` scopes the parent lookup to the same drive as the
|
||||
// incoming write (post-D0 `storage.folders.path` repeats across
|
||||
// drives).
|
||||
let parent_id = if path_normalized.contains('/') {
|
||||
if let Some(file_read) = &self.file_read {
|
||||
file_read.get_parent_folder_id(path_normalized).await.ok()
|
||||
file_read
|
||||
.get_parent_folder_id(path_normalized, drive_id)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ impl FolderService {
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_path: &str,
|
||||
_user_id: Uuid,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
@@ -297,17 +297,17 @@ impl FolderUseCase for FolderService {
|
||||
self.get_folder(id).await
|
||||
}
|
||||
|
||||
/// Gets a folder by its path, scoped to the caller's tree.
|
||||
/// Gets a folder by its path, scoped to a drive.
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
path: &str,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
let storage_path = StoragePath::from_string(path);
|
||||
|
||||
let folder = self
|
||||
.folder_storage
|
||||
.get_folder_by_path(&storage_path, user_id)
|
||||
.get_folder_by_path(&storage_path, drive_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
|
||||
@@ -101,7 +101,11 @@ impl FileReadPort for MockFileReadPort {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
||||
async fn get_parent_folder_id(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -127,7 +131,11 @@ impl FileReadPort for MockFileReadPort {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
_folder_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
|
||||
@@ -838,11 +838,19 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
||||
async fn get_parent_folder_id(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: uuid::Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
_folder_path: &str,
|
||||
_drive_id: uuid::Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -925,7 +933,7 @@ mod tests {
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_storage_path: &crate::domain::services::path_service::StoragePath,
|
||||
_user_id: uuid::Uuid,
|
||||
_drive_id: uuid::Uuid,
|
||||
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -991,6 +999,7 @@ mod tests {
|
||||
async fn folder_exists(
|
||||
&self,
|
||||
_storage_path: &crate::domain::services::path_service::StoragePath,
|
||||
_drive_id: uuid::Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -821,6 +821,10 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// Trash listing — drive_id is informational and the trash
|
||||
// row doesn't currently SELECT it. Path-based lookups
|
||||
// never enter this code path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
|
||||
@@ -500,13 +500,18 @@ impl FileReadPort for MockFileRepository {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result<String, DomainError> {
|
||||
async fn get_parent_folder_id(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> std::result::Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
_folder_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> std::result::Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -709,7 +714,7 @@ impl FolderRepository for MockFolderRepository {
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
_user_id: Uuid,
|
||||
_drive_id: Uuid,
|
||||
) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -773,6 +778,7 @@ impl FolderRepository for MockFolderRepository {
|
||||
async fn folder_exists(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
_drive_id: Uuid,
|
||||
) -> std::result::Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
+19
-6
@@ -101,11 +101,19 @@ impl FileReadPort for StubFileReadPort {
|
||||
Ok(StoragePath::from_string("/"))
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
||||
async fn get_parent_folder_id(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
Ok("root".to_string())
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
_folder_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
Ok("stub-folder-id".to_string())
|
||||
}
|
||||
|
||||
@@ -245,7 +253,7 @@ impl FolderRepository for StubFolderStoragePort {
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
_user_id: Uuid,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<Folder, DomainError> {
|
||||
Ok(Folder::default())
|
||||
}
|
||||
@@ -299,7 +307,11 @@ impl FolderRepository for StubFolderStoragePort {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn folder_exists(&self, _storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||
async fn folder_exists(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -405,7 +417,7 @@ impl FolderUseCase for StubFolderUseCase {
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_path: &str,
|
||||
_user_id: Uuid,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
@@ -495,6 +507,7 @@ impl FileUploadUseCase for StubFileUploadUseCase {
|
||||
async fn update_file_streaming(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
_blob: StoredBlob,
|
||||
_content_type: &str,
|
||||
_modified_at: Option<i64>,
|
||||
@@ -577,7 +590,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
|
||||
Ok(Box::new(empty_stream))
|
||||
}
|
||||
|
||||
async fn get_file_by_path(&self, _path: &str) -> Result<FileDto, DomainError> {
|
||||
async fn get_file_by_path(&self, _path: &str, _drive_id: Uuid) -> Result<FileDto, DomainError> {
|
||||
Err(DomainError::not_found("File", "stub"))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ pub struct Folder {
|
||||
/// `None` only for legacy/stub folders; real folders always have an owner.
|
||||
owner_id: Option<Uuid>,
|
||||
|
||||
/// Drive that owns this folder. Post-D0 every `storage.folders` row
|
||||
/// has `drive_id NOT NULL` (M3 migration). Path-based lookups scope
|
||||
/// by this axis (not by `user_id`, which is dropped in D7).
|
||||
/// `Uuid::nil()` only for stub/legacy in-memory folders that never
|
||||
/// touched the DB.
|
||||
drive_id: Uuid,
|
||||
|
||||
/// Creation timestamp
|
||||
created_at: u64,
|
||||
|
||||
@@ -56,6 +63,7 @@ impl Default for Folder {
|
||||
path_string: "/".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
tree_modified_at: 0,
|
||||
@@ -103,6 +111,11 @@ impl Folder {
|
||||
path_string,
|
||||
parent_id,
|
||||
owner_id,
|
||||
// In-memory constructor: callers that don't supply a
|
||||
// drive_id are by definition stub/legacy paths (tests,
|
||||
// pre-D0 fixtures, DTO round-trips). Real DB-backed
|
||||
// folders flow through `with_timestamps_and_tree`.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
tree_modified_at: now,
|
||||
@@ -128,6 +141,7 @@ impl Folder {
|
||||
storage_path,
|
||||
parent_id,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
created_at,
|
||||
modified_at,
|
||||
modified_at,
|
||||
@@ -154,6 +168,7 @@ impl Folder {
|
||||
storage_path,
|
||||
parent_id,
|
||||
owner_id,
|
||||
Uuid::nil(),
|
||||
created_at,
|
||||
modified_at,
|
||||
modified_at,
|
||||
@@ -162,7 +177,9 @@ impl Folder {
|
||||
|
||||
/// Full constructor used by the PG repository when reading rows.
|
||||
/// `tree_modified_at` comes from the trigger-maintained column on
|
||||
/// `storage.folders` and feeds [`Folder::etag`].
|
||||
/// `storage.folders` and feeds [`Folder::etag`]. `drive_id` is the
|
||||
/// post-D0 `storage.folders.drive_id NOT NULL` column — every
|
||||
/// path-based lookup scopes by this axis.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn with_timestamps_and_tree(
|
||||
id: String,
|
||||
@@ -170,6 +187,7 @@ impl Folder {
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
owner_id: Option<Uuid>,
|
||||
drive_id: Uuid,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
tree_modified_at: u64,
|
||||
@@ -188,6 +206,7 @@ impl Folder {
|
||||
path_string,
|
||||
parent_id,
|
||||
owner_id,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
tree_modified_at,
|
||||
@@ -227,6 +246,13 @@ impl Folder {
|
||||
self.owner_id
|
||||
}
|
||||
|
||||
/// Drive that owns this folder. Path-based lookups scope by
|
||||
/// this axis (post-D0 invariant: `storage.folders.drive_id`
|
||||
/// is `NOT NULL`).
|
||||
pub fn drive_id(&self) -> Uuid {
|
||||
self.drive_id
|
||||
}
|
||||
|
||||
/// Latest descendant-write timestamp. Statement-level Postgres
|
||||
/// triggers enqueue every file/folder write into
|
||||
/// `storage.tree_etag_dirty`; the background `TreeEtagFlushService`
|
||||
@@ -314,6 +340,11 @@ impl Folder {
|
||||
path_string: path,
|
||||
parent_id,
|
||||
owner_id: None,
|
||||
// DTO round-trips lose drive_id (FolderDto carries it,
|
||||
// but the legacy `from_dto` signature predates this
|
||||
// change). Callers that need real scoping must reload
|
||||
// through the repository.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at,
|
||||
modified_at,
|
||||
tree_modified_at: modified_at,
|
||||
@@ -353,6 +384,7 @@ impl Folder {
|
||||
path_string: new_path_string,
|
||||
parent_id: self.parent_id.clone(),
|
||||
owner_id: self.owner_id,
|
||||
drive_id: self.drive_id,
|
||||
created_at: self.created_at,
|
||||
modified_at: now,
|
||||
// Renaming bumps both self and descendant rollup —
|
||||
@@ -389,6 +421,7 @@ impl Folder {
|
||||
path_string: new_path_string,
|
||||
parent_id,
|
||||
owner_id: self.owner_id,
|
||||
drive_id: self.drive_id,
|
||||
created_at: self.created_at,
|
||||
modified_at: now,
|
||||
tree_modified_at: now,
|
||||
@@ -478,6 +511,7 @@ mod tests {
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
5_000,
|
||||
@@ -499,6 +533,7 @@ mod tests {
|
||||
StoragePath::from_string("/a"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
0,
|
||||
0,
|
||||
42,
|
||||
@@ -510,6 +545,7 @@ mod tests {
|
||||
StoragePath::from_string("/b"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
0,
|
||||
0,
|
||||
42,
|
||||
@@ -532,6 +568,7 @@ mod tests {
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
3_000,
|
||||
@@ -543,6 +580,7 @@ mod tests {
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
4_000,
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::path::PathBuf;
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
@@ -49,8 +50,12 @@ pub trait FileReadRepository: Send + Sync + 'static {
|
||||
/// Gets the logical storage path of a file.
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
/// Gets the parent folder ID from a path (WebDAV).
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
||||
/// Gets the parent folder ID from a path (WebDAV), scoped to a drive.
|
||||
///
|
||||
/// Post-D0, `storage.folders.path` is unique only within a single
|
||||
/// drive — the `drive_id` filter scopes the lookup.
|
||||
async fn get_parent_folder_id(&self, path: &str, drive_id: Uuid)
|
||||
-> Result<String, DomainError>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,17 +28,17 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
/// Gets a folder by its ID
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Gets a folder by its storage path within the caller's tree.
|
||||
/// Gets a folder by its storage path within a drive's tree.
|
||||
///
|
||||
/// Post-D0, `storage.folders.path` is no longer globally unique —
|
||||
/// multiple users share root-folder names like `"Personal"`. The
|
||||
/// `user_id` filter scopes the lookup to the caller's own folders
|
||||
/// (the equivalent of the pre-D0 implicit user-namespacing that
|
||||
/// came from `My Folder - <username>` paths).
|
||||
/// Post-D0, `storage.folders.path` is unique only within a single
|
||||
/// drive — root-folder names like `"Personal"` repeat across drives.
|
||||
/// The `drive_id` filter scopes the lookup to a specific drive
|
||||
/// (caller derives it from its protocol context: NC chroot, native
|
||||
/// default-drive lookup, WOPI default-drive lookup).
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
) -> Result<Folder, DomainError>;
|
||||
|
||||
/// Lists folders within a parent folder
|
||||
@@ -87,8 +87,15 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
/// Deletes a folder
|
||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Checks if a folder exists at the given path
|
||||
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
|
||||
/// Checks if a folder exists at the given path within a drive.
|
||||
///
|
||||
/// Post-D0 `storage.folders.path` is unique only within a single
|
||||
/// drive — the `drive_id` filter scopes the existence check.
|
||||
async fn folder_exists(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
drive_id: Uuid,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
/// Gets the path of a folder
|
||||
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
@@ -935,7 +935,11 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
Ok(Self::make_file_path(row.1.as_deref(), &row.0))
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
|
||||
async fn get_parent_folder_id(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
@@ -955,29 +959,49 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
));
|
||||
}
|
||||
|
||||
self.get_folder_id_by_path(&folder_path).await
|
||||
self.get_folder_id_by_path(&folder_path, drive_id).await
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(&self, folder_path: &str) -> Result<String, DomainError> {
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
folder_path: &str,
|
||||
drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
let folder_path = folder_path.trim_start_matches('/').trim_end_matches('/');
|
||||
|
||||
if folder_path.is_empty() {
|
||||
return Err(DomainError::not_found("Folder", "empty path"));
|
||||
}
|
||||
|
||||
// Post-D0 `storage.folders.path` repeats across drives —
|
||||
// filter by `drive_id` to scope the lookup.
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT id::text FROM storage.folders WHERE path = $1 AND NOT is_trashed",
|
||||
"SELECT id::text FROM storage.folders \
|
||||
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed",
|
||||
)
|
||||
.bind(folder_path)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("folder lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", format!("path: {folder_path}")))
|
||||
}
|
||||
|
||||
/// Direct SQL lookup using materialized folder paths.
|
||||
/// Direct SQL lookup using materialized folder paths, scoped to a drive.
|
||||
/// O(1) query instead of O(depth) folder walk.
|
||||
async fn find_file_by_path(&self, path: &str) -> Result<Option<File>, DomainError> {
|
||||
///
|
||||
/// Post-D0 `storage.folders.path` repeats across drives (each drive
|
||||
/// has its own root with a name like `"Personal"`). Without the
|
||||
/// `drive_id` filter the lookup would be non-deterministic. The
|
||||
/// root-level branch filters on `fi.drive_id`; the nested branch
|
||||
/// filters on the parent folder's `fo.drive_id` (which closes the
|
||||
/// leak cleanly and matches the path semantics — see Step 2 of
|
||||
/// the path-lookup refactor).
|
||||
async fn find_file_by_path(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
) -> Result<Option<File>, DomainError> {
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
@@ -996,7 +1020,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
let folder_path = segments[..segments.len() - 1].join("/");
|
||||
|
||||
let row = if folder_path.is_empty() {
|
||||
// File at root level (no parent folder)
|
||||
// File at root level (no parent folder) — filter on
|
||||
// `fi.drive_id` because there's no folder row to join through.
|
||||
sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -1021,14 +1046,19 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
fi.user_id
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||
WHERE fi.name = $1 AND fi.folder_id IS NULL
|
||||
AND fi.drive_id = $2 AND NOT fi.is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(filename)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
// File inside a folder — look up by folder path + filename
|
||||
// File inside a folder — look up by folder path + filename,
|
||||
// filtered by the parent folder's drive_id (path semantics
|
||||
// are folder-scoped, so this also catches mis-pointed file
|
||||
// rows during D0/D7's dual-write window).
|
||||
sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -1053,11 +1083,13 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
fi.user_id
|
||||
FROM storage.files fi
|
||||
JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed
|
||||
WHERE fo.path = $1 AND fi.name = $2
|
||||
AND fo.drive_id = $3 AND NOT fi.is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(&folder_path)
|
||||
.bind(filename)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -21,11 +21,23 @@ use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
/// Type alias for folder metadata rows from SQL queries.
|
||||
/// Tuple order: id, name, path, parent_id, user_id, created_at,
|
||||
/// modified_at, tree_modified_at. The trailing `tree_modified_at`
|
||||
/// feeds [`Folder::etag`] — every SELECT here must include
|
||||
/// `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
|
||||
type FolderRow = (String, String, String, Option<String>, Uuid, i64, i64, i64);
|
||||
/// Tuple order: id, name, path, parent_id, user_id, drive_id,
|
||||
/// created_at, modified_at, tree_modified_at. The trailing
|
||||
/// `tree_modified_at` feeds [`Folder::etag`] — every SELECT here
|
||||
/// must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
|
||||
/// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based
|
||||
/// lookups.
|
||||
type FolderRow = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Uuid,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
);
|
||||
|
||||
/// Type alias for paginated folder rows (includes total_count as
|
||||
/// the last element after `tree_modified_at`).
|
||||
@@ -35,6 +47,7 @@ type FolderRowPaginated = (
|
||||
String,
|
||||
Option<String>,
|
||||
Uuid,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
@@ -48,6 +61,7 @@ type FolderRowOptUser = (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<Uuid>,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
@@ -92,6 +106,7 @@ impl FolderDbRepository {
|
||||
path: String,
|
||||
parent_id: Option<String>,
|
||||
user_id: Option<Uuid>,
|
||||
drive_id: Uuid,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
tree_modified_at: i64,
|
||||
@@ -103,6 +118,7 @@ impl FolderDbRepository {
|
||||
storage_path,
|
||||
parent_id,
|
||||
user_id,
|
||||
drive_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
tree_modified_at as u64,
|
||||
@@ -210,6 +226,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
row.1,
|
||||
parent_id,
|
||||
Some(user_id),
|
||||
drive_id,
|
||||
row.2,
|
||||
row.3,
|
||||
row.4,
|
||||
@@ -219,7 +236,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||
let row = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -233,13 +250,23 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
row.3,
|
||||
Some(row.4),
|
||||
row.5,
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
) -> Result<Folder, DomainError> {
|
||||
let path_str = storage_path.to_string();
|
||||
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
|
||||
@@ -249,31 +276,41 @@ impl FolderRepository for FolderDbRepository {
|
||||
return Err(DomainError::not_found("Folder", "empty path"));
|
||||
}
|
||||
|
||||
// Scoped by user_id: post-D0 the wrapper folder is named
|
||||
// "Personal" for every user, so `path = 'Personal'` matches
|
||||
// every user's root folder. Without the user_id filter, this
|
||||
// returns a non-deterministic row (whichever the planner emits
|
||||
// first) — which broke owner-short-circuit checks for the
|
||||
// caller whose folder wasn't returned. See bug-fix on rewind
|
||||
// commit.
|
||||
// Scoped by drive_id: post-D0 `storage.folders.path` is unique
|
||||
// only within a single drive. Root-folder names like
|
||||
// `"Personal"` repeat across drives, so without the drive_id
|
||||
// filter the planner returns a non-deterministic row — which
|
||||
// breaks owner-short-circuit checks and crosses drive
|
||||
// boundaries (the AuthZ axis that replaces the old per-user
|
||||
// wrapper scoping post-D0).
|
||||
let row = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE path = $1 AND user_id = $2 AND NOT is_trashed
|
||||
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(lookup)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
|
||||
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
row.3,
|
||||
Some(row.4),
|
||||
row.5,
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -281,7 +318,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -296,7 +333,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -311,8 +348,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -326,7 +363,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -342,7 +379,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -358,8 +395,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -378,7 +415,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -397,7 +434,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -417,15 +454,15 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
// total_count is identical in every row; 0 when the result set is empty.
|
||||
let total = if include_total {
|
||||
Some(rows.first().map_or(0, |r| r.8) as usize)
|
||||
Some(rows.first().map_or(0, |r| r.9) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
})
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
@@ -445,7 +482,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -465,7 +502,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -485,15 +522,15 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
||||
|
||||
let total = if include_total {
|
||||
Some(rows.first().map_or(0, |r| r.8) as usize)
|
||||
Some(rows.first().map_or(0, |r| r.9) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
})
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
@@ -512,7 +549,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
UPDATE storage.folders
|
||||
SET name = $1, updated_at = NOW(), updated_by = user_id
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, user_id,
|
||||
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -533,7 +570,17 @@ impl FolderRepository for FolderDbRepository {
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
row.3,
|
||||
Some(row.4),
|
||||
row.5,
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
)
|
||||
}
|
||||
|
||||
async fn move_folder(
|
||||
@@ -551,7 +598,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
UPDATE storage.folders
|
||||
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = user_id
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, user_id,
|
||||
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -565,7 +612,17 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
row.3,
|
||||
Some(row.4),
|
||||
row.5,
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
)
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
|
||||
@@ -603,14 +660,22 @@ impl FolderRepository for FolderDbRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||
async fn folder_exists(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
drive_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
let path_str = storage_path.to_string();
|
||||
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
||||
|
||||
// Post-D0 `storage.folders.path` repeats across drives —
|
||||
// filter by `drive_id` to scope the existence check.
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM storage.folders WHERE path = $1 AND NOT is_trashed)",
|
||||
"SELECT EXISTS(SELECT 1 FROM storage.folders \
|
||||
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed)",
|
||||
)
|
||||
.bind(lookup)
|
||||
.bind(drive_id)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("exists: {e}")))?;
|
||||
@@ -836,9 +901,17 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
||||
|
||||
match row {
|
||||
Some((id, path, ca, ma, tma)) => {
|
||||
Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma, tma)
|
||||
}
|
||||
Some((id, path, ca, ma, tma)) => Self::row_to_folder(
|
||||
id,
|
||||
name.clone(),
|
||||
path,
|
||||
None,
|
||||
Some(user_id),
|
||||
drive_id,
|
||||
ca,
|
||||
ma,
|
||||
tma,
|
||||
),
|
||||
None => {
|
||||
// Already exists — fetch it
|
||||
let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
||||
@@ -863,6 +936,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
existing.1,
|
||||
None,
|
||||
Some(user_id),
|
||||
drive_id,
|
||||
existing.2,
|
||||
existing.3,
|
||||
existing.4,
|
||||
@@ -878,7 +952,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
|
||||
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
@@ -896,8 +970,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -941,7 +1015,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
// Recursive, no folder scope → ALL user folders
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
@@ -968,8 +1042,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
return rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
@@ -978,7 +1052,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let sql = if parent_id.is_some() {
|
||||
format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
@@ -997,7 +1071,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
};
|
||||
format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
@@ -1040,8 +1114,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1066,7 +1140,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
@@ -1096,8 +1170,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1115,7 +1189,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -1141,7 +1215,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
@@ -1167,8 +1241,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ impl PathResolverService {
|
||||
String, // path
|
||||
Option<String>, // parent_id
|
||||
Option<String>, // user_id
|
||||
Uuid, // drive_id
|
||||
i64, // created_at
|
||||
i64, // modified_at
|
||||
Option<i64>, // size
|
||||
@@ -72,7 +73,7 @@ impl PathResolverService {
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT resource_type, id, name, path, parent_id, user_id,
|
||||
SELECT resource_type, id, name, path, parent_id, user_id, drive_id,
|
||||
created_at, modified_at, size, mime_type, folder_id
|
||||
FROM (
|
||||
SELECT 'folder'::text AS resource_type,
|
||||
@@ -81,6 +82,7 @@ impl PathResolverService {
|
||||
fo.path,
|
||||
fo.parent_id::text,
|
||||
fo.user_id::text,
|
||||
fo.drive_id,
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at,
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
|
||||
NULL::bigint AS size,
|
||||
@@ -102,6 +104,7 @@ impl PathResolverService {
|
||||
END AS path,
|
||||
NULL::text AS parent_id,
|
||||
fi.user_id::text,
|
||||
fi.drive_id,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
|
||||
fi.size,
|
||||
@@ -136,6 +139,7 @@ impl PathResolverService {
|
||||
res_path,
|
||||
parent_id,
|
||||
uid,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
size,
|
||||
@@ -151,6 +155,7 @@ impl PathResolverService {
|
||||
path: res_path,
|
||||
parent_id,
|
||||
owner_id: uid,
|
||||
drive_id,
|
||||
created_at: created_at as u64,
|
||||
modified_at: modified_at as u64,
|
||||
is_root: false,
|
||||
|
||||
@@ -262,6 +262,11 @@ pub async fn list_favorites_resources(
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// Listing handler — drive_id is informational
|
||||
// and the favorites row doesn't currently
|
||||
// SELECT it. Path-based lookups never enter
|
||||
// this code path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
|
||||
@@ -701,6 +701,10 @@ pub async fn list_folder_resources(
|
||||
path: String::new(), // cleared — share recipients must not see hierarchy
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// Resources listing — drive_id is informational
|
||||
// here; not selected by the underlying query.
|
||||
// Path-based lookups never enter this code path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: row.created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
|
||||
@@ -292,6 +292,11 @@ pub async fn list_recent_resources(
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// Listing handler — drive_id is informational
|
||||
// and the recents row doesn't currently SELECT
|
||||
// it. Path-based lookups never enter this code
|
||||
// path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||
@@ -247,6 +248,29 @@ async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Native WebDAV protocol entry: resolve the caller's default drive
|
||||
/// once per handler so every downstream path-based lookup
|
||||
/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`)
|
||||
/// can pass the same `drive_id` scope.
|
||||
///
|
||||
/// Post-D0 `storage.{folders,files}.path` repeats across drives — the
|
||||
/// scope is mandatory. Native WebDAV today lives in a single-drive
|
||||
/// surface (one default drive per user), so the lookup is unambiguous.
|
||||
/// Multi-drive support via path segments (`/webdav/drives/<uuid>/…`)
|
||||
/// is tracked separately and will derive `drive_id` directly from the
|
||||
/// URL instead of going through `find_default_for_user`.
|
||||
async fn resolve_drive_id_for_native_webdav(
|
||||
state: &Arc<AppState>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Uuid, AppError> {
|
||||
state
|
||||
.drive_repo
|
||||
.find_default_for_user(user_id)
|
||||
.await
|
||||
.map(|d| d.drive.id)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e)))
|
||||
}
|
||||
|
||||
async fn handle_webdav_dispatch(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
@@ -405,6 +429,9 @@ async fn handle_propfind(
|
||||
path: "".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
// Synthetic root folder for PROPFIND on `/`; not an
|
||||
// actual DB row, so drive_id has no meaningful value.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: Utc::now().timestamp() as u64,
|
||||
modified_at: Utc::now().timestamp() as u64,
|
||||
is_root: true,
|
||||
@@ -468,8 +495,11 @@ async fn handle_propfind(
|
||||
Err(_) => {}
|
||||
}
|
||||
} else {
|
||||
// Fallback: legacy double-query path when PathResolver is unavailable
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(&path, user.id).await {
|
||||
// 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 {
|
||||
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
let folder_id = folder.id.clone();
|
||||
return build_streaming_propfind_response(
|
||||
@@ -484,7 +514,10 @@ async fn handle_propfind(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await {
|
||||
if let Ok(file) = file_retrieval_service
|
||||
.get_file_by_path(&path, drive_id)
|
||||
.await
|
||||
{
|
||||
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
{
|
||||
@@ -688,10 +721,11 @@ async fn handle_proppatch(
|
||||
let is_collection = if path.is_empty() || path == "/" {
|
||||
true
|
||||
} else {
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(&path, user.id)
|
||||
.get_folder_by_path(&path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -776,9 +810,12 @@ async fn handle_get(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback — fetch + ownership check
|
||||
// Legacy fallback — fetch + ownership check. `drive_id` is the
|
||||
// path-lookup scope post-D0 (`storage.files.path` repeats across
|
||||
// drives), derived once from the caller's default drive.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let f = file_retrieval_service
|
||||
.get_file_by_path(&path)
|
||||
.get_file_by_path(&path, drive_id)
|
||||
.await
|
||||
.map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?;
|
||||
assert_owner(f.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
@@ -876,8 +913,11 @@ async fn handle_head(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: legacy double-query path (with ownership check)
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(&path, user.id).await {
|
||||
// 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 {
|
||||
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
@@ -890,7 +930,7 @@ async fn handle_head(
|
||||
|
||||
// Try as file — use metadata only, never load content for HEAD
|
||||
let file = file_retrieval_service
|
||||
.get_file_by_path(&path)
|
||||
.get_file_by_path(&path, drive_id)
|
||||
.await
|
||||
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
@@ -939,15 +979,27 @@ async fn resolve_or_legacy(
|
||||
return Some(r);
|
||||
}
|
||||
|
||||
// Path-lookup scope post-D0 — derive the caller's default drive
|
||||
// for both legacy probes. `find_default_for_user` returning Err
|
||||
// (e.g. external user, or boot before the lifecycle hook fired)
|
||||
// means no fallback resolution is possible: return None.
|
||||
let drive_id = state
|
||||
.drive_repo
|
||||
.find_default_for_user(user_id)
|
||||
.await
|
||||
.ok()?
|
||||
.drive
|
||||
.id;
|
||||
|
||||
let user_id_str = user_id.to_string();
|
||||
let folder_service = &state.applications.folder_service;
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(path, user_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(ResolvedResource::Folder(folder));
|
||||
}
|
||||
let file_retrieval = &state.applications.file_retrieval_service;
|
||||
if let Ok(file) = file_retrieval.get_file_by_path(path).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));
|
||||
@@ -1143,8 +1195,9 @@ async fn handle_put(
|
||||
|
||||
// ── Atomic store: swap the file row onto the ingested blob ──
|
||||
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
|
||||
.update_file_streaming(&path, ingested.stored(), &content_type, None)
|
||||
.update_file_streaming(&path, drive_id, ingested.stored(), &content_type, None)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@@ -1198,6 +1251,9 @@ async fn handle_mkcol(
|
||||
// Path is already translated by dispatch (e.g. "My Folder - jared/03/01").
|
||||
// Walk each segment: the first is the home folder (already exists),
|
||||
// subsequent segments are created as needed with proper parent_id.
|
||||
// `drive_id` scopes each per-segment path probe to the caller's default
|
||||
// drive (post-D0 invariant: `storage.folders.path` repeats across drives).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut parent_id: Option<String> = None;
|
||||
let mut accumulated_path = String::new();
|
||||
@@ -1209,7 +1265,7 @@ async fn handle_mkcol(
|
||||
accumulated_path.push_str(segment);
|
||||
|
||||
match folder_service
|
||||
.get_folder_by_path(&accumulated_path, user.id)
|
||||
.get_folder_by_path(&accumulated_path, drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(existing) => {
|
||||
@@ -1395,6 +1451,11 @@ async fn handle_move(
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// `drive_id` scopes every path-based lookup below to the caller's
|
||||
// default drive (post-D0 invariant: `storage.{files,folders}.path`
|
||||
// repeats across drives).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
|
||||
// Check if destination already exists (for Overwrite header compliance)
|
||||
if !overwrite {
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
@@ -1404,11 +1465,11 @@ async fn handle_move(
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, user.id)
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path)
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -1447,7 +1508,7 @@ async fn handle_move(
|
||||
parent_id: if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else if let Ok(parent) = folder_service
|
||||
.get_folder_by_path(dest_parent_path, user.id)
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
{
|
||||
assert_owner(
|
||||
@@ -1488,7 +1549,7 @@ async fn handle_move(
|
||||
None
|
||||
} else {
|
||||
let parent = folder_service
|
||||
.get_folder_by_path(dest_parent_path, user.id)
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::not_found(format!(
|
||||
@@ -1606,6 +1667,11 @@ async fn handle_copy(
|
||||
let file_retrieval_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// `drive_id` scopes every path-based lookup below to the caller's
|
||||
// default drive (post-D0 invariant: `storage.{files,folders}.path`
|
||||
// repeats across drives).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
|
||||
// Check if destination already exists (for Overwrite header compliance)
|
||||
if !overwrite {
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
@@ -1615,11 +1681,11 @@ async fn handle_copy(
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, user.id)
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path)
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -1650,7 +1716,7 @@ async fn handle_copy(
|
||||
let target_parent_id = if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else if let Ok(parent) = folder_service
|
||||
.get_folder_by_path(dest_parent_path, user.id)
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
{
|
||||
assert_owner(
|
||||
@@ -1748,10 +1814,11 @@ async fn handle_lock(
|
||||
let is_collection = if path.is_empty() || path == "/" {
|
||||
true
|
||||
} else {
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(&path, user.id)
|
||||
.get_folder_by_path(&path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::sync::Arc;
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
|
||||
use crate::application::services::wopi_lock_service::WopiLockService;
|
||||
use crate::application::services::wopi_token_service::WopiTokenService;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
|
||||
|
||||
/// Shared state for WOPI handlers.
|
||||
@@ -233,11 +234,31 @@ async fn put_file(
|
||||
};
|
||||
|
||||
// ── Atomic store: swap the file row onto the ingested blob ──
|
||||
// `drive_id` scopes the path-based lookups in `update_file_streaming`
|
||||
// post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve
|
||||
// that to the caller's default drive (WOPI today is a single-drive
|
||||
// editing surface — no drive marker travels in the token).
|
||||
let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
let drive_id = match state
|
||||
.app_state
|
||||
.drive_repo
|
||||
.find_default_for_user(claims_sub_uuid)
|
||||
.await
|
||||
{
|
||||
Ok(d) => d.drive.id,
|
||||
Err(e) => {
|
||||
tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
let result = state
|
||||
.app_state
|
||||
.applications
|
||||
.file_upload_service
|
||||
.update_file_streaming(&file.path, ingested.stored(), &content_type, None)
|
||||
.update_file_streaming(&file.path, drive_id, ingested.stored(), &content_type, None)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
|
||||
@@ -186,7 +186,11 @@ pub async fn basic_auth_middleware(
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
let chroot = match drive_marker.as_deref() {
|
||||
None => {
|
||||
match state.drive_repo.find_default_for_user(current_user.id).await {
|
||||
match state
|
||||
.drive_repo
|
||||
.find_default_for_user(current_user.id)
|
||||
.await
|
||||
{
|
||||
Ok(drive_with_name) => state
|
||||
.applications
|
||||
.folder_service
|
||||
|
||||
@@ -358,6 +358,10 @@ fn folder_dto_from_search(
|
||||
path: sr.path.clone(),
|
||||
parent_id: sr.parent_id.clone(),
|
||||
owner_id: None,
|
||||
// Search result — drive_id is informational. The search row
|
||||
// doesn't currently SELECT it, and path-based lookups never
|
||||
// enter this code path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: sr.created_at,
|
||||
modified_at: sr.modified_at,
|
||||
is_root: sr.is_root,
|
||||
@@ -490,7 +494,6 @@ async fn resolve_scope_folder(
|
||||
body: &str,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Option<String> {
|
||||
let user = &session.user;
|
||||
let chroot = session.require_chroot().ok()?;
|
||||
let url_user = &session.raw_username;
|
||||
let href = parse_scope_href(body)?;
|
||||
@@ -511,7 +514,7 @@ async fn resolve_scope_folder(
|
||||
|
||||
let folder_service = &state.applications.folder_service;
|
||||
folder_service
|
||||
.get_folder_by_path(&internal_path, user.id)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.ok()
|
||||
.map(|f| f.id)
|
||||
|
||||
@@ -137,9 +137,12 @@ async fn handle_restore(
|
||||
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let dest_taken = file_service.get_file_by_path(&dest_internal).await.is_ok()
|
||||
let dest_taken = file_service
|
||||
.get_file_by_path(&dest_internal, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| folder_service
|
||||
.get_folder_by_path(&dest_internal, user.id)
|
||||
.get_folder_by_path(&dest_internal, chroot.drive_id)
|
||||
.await
|
||||
.is_ok();
|
||||
if dest_taken {
|
||||
|
||||
@@ -259,6 +259,13 @@ async fn handle_assemble(
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// Path-based lookups below scope by `drive_id`. The NC session's
|
||||
// chroot is always populated for path-scoped handlers (see
|
||||
// `NcSession::require_chroot`); the FolderDto carries `drive_id`
|
||||
// post-D0.
|
||||
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
|
||||
@@ -279,11 +286,19 @@ async fn handle_assemble(
|
||||
let content_type = ingested.content_type.clone();
|
||||
|
||||
// Check if file exists (update vs create).
|
||||
let existing = file_service.get_file_by_path(&internal_path).await;
|
||||
let existing = file_service
|
||||
.get_file_by_path(&internal_path, drive_id)
|
||||
.await;
|
||||
|
||||
let etag: Option<String> = if existing.is_ok() {
|
||||
let dto = upload_service
|
||||
.update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime)
|
||||
.update_file_streaming(
|
||||
&internal_path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
oc_mtime,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
|
||||
|
||||
@@ -300,7 +315,7 @@ async fn handle_assemble(
|
||||
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
let parent_folder = match folder_service
|
||||
.get_folder_by_path(parent_internal, user.id)
|
||||
.get_folder_by_path(parent_internal, drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(folder) => folder,
|
||||
|
||||
@@ -235,7 +235,7 @@ async fn handle_propfind(
|
||||
|
||||
// Try to resolve as folder first.
|
||||
let folder_result = folder_service
|
||||
.get_folder_by_path(&internal_path, user.id)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await;
|
||||
|
||||
if let Ok(folder) = folder_result {
|
||||
@@ -260,7 +260,9 @@ async fn handle_propfind(
|
||||
}
|
||||
|
||||
// Not a folder — try as a file.
|
||||
let file_result = file_service.get_file_by_path(&internal_path).await;
|
||||
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() {
|
||||
@@ -307,7 +309,6 @@ async fn handle_get(
|
||||
subpath: &str,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let chroot = session.require_chroot()?;
|
||||
// GET on root folder — NC clients use this as an existence check
|
||||
if subpath.is_empty() || subpath == "/" {
|
||||
@@ -324,7 +325,7 @@ async fn handle_get(
|
||||
|
||||
// Check if path is a folder first (NC clients use GET as existence check)
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path, user.id)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -336,7 +337,7 @@ async fn handle_get(
|
||||
}
|
||||
|
||||
let file = file_service
|
||||
.get_file_by_path(&internal_path)
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("File not found"))?;
|
||||
|
||||
@@ -386,7 +387,6 @@ async fn handle_head(
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let chroot = session.require_chroot()?;
|
||||
// HEAD on root folder — NC clients use this as an existence check
|
||||
if subpath.is_empty() || subpath == "/" {
|
||||
@@ -403,7 +403,7 @@ async fn handle_head(
|
||||
|
||||
// Check if path is a folder (NC clients use HEAD as existence check)
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path, user.id)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -415,7 +415,7 @@ async fn handle_head(
|
||||
}
|
||||
|
||||
let file = file_service
|
||||
.get_file_by_path(&internal_path)
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("File not found"))?;
|
||||
|
||||
@@ -479,10 +479,13 @@ async fn handle_proppatch(
|
||||
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 = if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
|
||||
let resource = if let Ok(file) = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
Some((file.id, "file"))
|
||||
} else if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, user.id)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
Some((folder.id, "folder"))
|
||||
@@ -687,7 +690,10 @@ async fn handle_put(
|
||||
// bandwidth or disk I/O on a body the server is going to throw away.
|
||||
// The lookup is reused for the create-vs-update distinction below,
|
||||
// so this is also free of an extra DB hit.
|
||||
let existing = file_service.get_file_by_path(&internal_path).await.ok();
|
||||
let existing = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.ok();
|
||||
let current_etag = existing.as_ref().map(|f| f.etag.as_str());
|
||||
|
||||
if let Some(value) = req
|
||||
@@ -742,7 +748,13 @@ 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(&internal_path, ingested.stored(), &content_type, oc_mtime)
|
||||
.update_file_streaming(
|
||||
&internal_path,
|
||||
chroot.drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
oc_mtime,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?;
|
||||
|
||||
@@ -787,7 +799,7 @@ async fn handle_mkcol(
|
||||
// auto-create doesn't break real clients.
|
||||
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path, user.id)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -817,7 +829,7 @@ async fn handle_mkcol(
|
||||
};
|
||||
|
||||
let parent_folder = match folder_service
|
||||
.get_folder_by_path(&parent_path, user.id)
|
||||
.get_folder_by_path(&parent_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(folder) => folder,
|
||||
@@ -861,7 +873,7 @@ async fn handle_delete(
|
||||
// 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, user.id)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
trash_svc
|
||||
@@ -873,7 +885,10 @@ async fn handle_delete(
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
|
||||
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
|
||||
@@ -890,7 +905,7 @@ async fn handle_delete(
|
||||
let file_mgmt = &state.applications.file_management_service;
|
||||
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, user.id)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
folder_service
|
||||
@@ -904,7 +919,10 @@ async fn handle_delete(
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
|
||||
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
|
||||
@@ -969,11 +987,11 @@ async fn handle_move(
|
||||
// 204-vs-201 selector at response time.
|
||||
let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?;
|
||||
let dest_existing_file = file_service
|
||||
.get_file_by_path(&dest_internal_precheck)
|
||||
.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, user.id)
|
||||
.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();
|
||||
@@ -1016,7 +1034,10 @@ async fn handle_move(
|
||||
};
|
||||
|
||||
// Try as file first.
|
||||
if let Ok(file) = file_service.get_file_by_path(&src_internal).await {
|
||||
if let Ok(file) = file_service
|
||||
.get_file_by_path(&src_internal, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
|
||||
Some((parent, name)) => (parent, name),
|
||||
None => ("", dest_subpath.as_str()),
|
||||
@@ -1038,7 +1059,7 @@ async fn handle_move(
|
||||
} else {
|
||||
// Different parent → move.
|
||||
let dest_parent = folder_service
|
||||
.get_folder_by_path(&dest_parent_internal, user.id)
|
||||
.get_folder_by_path(&dest_parent_internal, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("Destination folder not found"))?;
|
||||
|
||||
@@ -1062,7 +1083,10 @@ async fn handle_move(
|
||||
// existed — RFC 4918 §9.9.4 distinguishes create vs overwrite).
|
||||
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
|
||||
let mut builder = Response::builder().status(final_status);
|
||||
if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await {
|
||||
if let Ok(moved) = file_service
|
||||
.get_file_by_path(&dest_internal, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
// Route through `FileDto::etag` so the MOVE response
|
||||
// matches what a subsequent PROPFIND on the destination
|
||||
// will return — `moved.id` (UUID) would differ from the
|
||||
@@ -1077,7 +1101,7 @@ async fn handle_move(
|
||||
|
||||
// Try as folder.
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&src_internal, user.id)
|
||||
.get_folder_by_path(&src_internal, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
|
||||
@@ -1107,7 +1131,7 @@ async fn handle_move(
|
||||
} else {
|
||||
// Different parent → move.
|
||||
let dest_parent = folder_service
|
||||
.get_folder_by_path(&dest_parent_internal, user.id)
|
||||
.get_folder_by_path(&dest_parent_internal, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("Destination parent not found"))?;
|
||||
|
||||
@@ -1634,6 +1658,8 @@ mod tests {
|
||||
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,
|
||||
modified_at: 0,
|
||||
is_root: false,
|
||||
|
||||
Reference in New Issue
Block a user