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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user