fix(security): patch critical IDOR & auth vulnerabilities
- Fix logout no-op: extract refresh token from cookie/body (auth_handler) - Secure all 12 WebDAV handlers with AuthUser + resolve_path_for_user - Secure all 7 batch handlers with caller_id ownership checks - Add _owned variants: copy_file_owned, delete_file_owned, get_file_stream_owned, get_folder_owned - Secure list_files_query: add AuthUser, SQL-level user_id filter, tenant-isolated ETag - Remove deprecated unscoped resolve_path() and exists() from PathResolverService - Remove dead list_files handler (unmounted, no auth) - Add list_files_for_owner (SQL) and list_files_owned across trait chain
This commit is contained in:
@@ -119,12 +119,29 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
|||||||
/// Lists files in a folder
|
/// Lists files in a folder
|
||||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError>;
|
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError>;
|
||||||
|
|
||||||
|
/// Lists files in a folder, scoped to the authenticated user.
|
||||||
|
///
|
||||||
|
/// Uses SQL-level `AND user_id` filtering — no in-memory post-filter.
|
||||||
|
/// All user-facing list handlers should use this method.
|
||||||
|
async fn list_files_owned(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
) -> Result<Vec<FileDto>, DomainError>;
|
||||||
|
|
||||||
/// Gets file content as a stream (for large files)
|
/// Gets file content as a stream (for large files)
|
||||||
async fn get_file_stream(
|
async fn get_file_stream(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||||
|
|
||||||
|
/// Gets file content as a stream, enforcing that `caller_id` is the owner.
|
||||||
|
async fn get_file_stream_owned(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
caller_id: &str,
|
||||||
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||||
|
|
||||||
/// Optimized multi-tier download.
|
/// Optimized multi-tier download.
|
||||||
///
|
///
|
||||||
/// Internalises: write-behind lookup → content-cache → WebP transcode →
|
/// Internalises: write-behind lookup → content-cache → WebP transcode →
|
||||||
@@ -208,6 +225,24 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
|||||||
.take(limit as usize)
|
.take(limit as usize)
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`list_files_batch`], but scoped to a specific owner.
|
||||||
|
///
|
||||||
|
/// Used by streaming WebDAV PROPFIND so that each user only sees their
|
||||||
|
/// own files, even in shared folder_id namespaces.
|
||||||
|
async fn list_files_batch_for_owner(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<FileDto>, DomainError> {
|
||||||
|
let all = self.list_files_batch(folder_id, offset, limit).await?;
|
||||||
|
Ok(all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.owner_id.as_deref().map_or(false, |o| o == owner_id))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────
|
||||||
@@ -238,6 +273,14 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
|
|||||||
target_folder_id: Option<String>,
|
target_folder_id: Option<String>,
|
||||||
) -> Result<FileDto, DomainError>;
|
) -> Result<FileDto, DomainError>;
|
||||||
|
|
||||||
|
/// Copies a file, enforcing that `caller_id` is the owner.
|
||||||
|
async fn copy_file_owned(
|
||||||
|
&self,
|
||||||
|
file_id: &str,
|
||||||
|
caller_id: &str,
|
||||||
|
target_folder_id: Option<String>,
|
||||||
|
) -> Result<FileDto, DomainError>;
|
||||||
|
|
||||||
/// Renames a file (system/internal — no ownership check).
|
/// Renames a file (system/internal — no ownership check).
|
||||||
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError>;
|
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError>;
|
||||||
|
|
||||||
@@ -252,6 +295,9 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
|
|||||||
/// Deletes a file (system/internal — no ownership check).
|
/// Deletes a file (system/internal — no ownership check).
|
||||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
|
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Deletes a file, enforcing that `caller_id` is the owner.
|
||||||
|
async fn delete_file_owned(&self, id: &str, caller_id: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Smart delete: trash-first with dedup reference cleanup.
|
/// Smart delete: trash-first with dedup reference cleanup.
|
||||||
///
|
///
|
||||||
/// 1. Tries to move to trash (soft delete).
|
/// 1. Tries to move to trash (soft delete).
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
|||||||
/// Gets a folder by its ID
|
/// Gets a folder by its ID
|
||||||
async fn get_folder(&self, id: &str) -> Result<FolderDto, DomainError>;
|
async fn get_folder(&self, id: &str) -> Result<FolderDto, DomainError>;
|
||||||
|
|
||||||
|
/// Gets a folder by its ID, enforcing that `caller_id` is the owner.
|
||||||
|
///
|
||||||
|
/// Returns `NotFound` if the folder does not exist **or** belongs to
|
||||||
|
/// another user. All user-facing handlers should use this method.
|
||||||
|
async fn get_folder_owned(&self, id: &str, caller_id: &str) -> Result<FolderDto, DomainError>;
|
||||||
|
|
||||||
/// Gets a folder by its path
|
/// Gets a folder by its path
|
||||||
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError>;
|
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError>;
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ pub trait FileReadPort: Send + Sync + 'static {
|
|||||||
/// Lists files in a folder.
|
/// Lists files in a folder.
|
||||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
|
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
|
||||||
|
|
||||||
|
/// Lists files in a folder scoped to a specific owner (SQL-level).
|
||||||
|
///
|
||||||
|
/// Default falls back to `list_files` + in-memory filter.
|
||||||
|
/// Repositories should override with a direct `AND user_id = $N` query.
|
||||||
|
async fn list_files_for_owner(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
) -> Result<Vec<File>, DomainError> {
|
||||||
|
let all = self.list_files(folder_id).await?;
|
||||||
|
Ok(all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.owner_id().map_or(false, |o| o == owner_id))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets content as a stream (ideal for large files).
|
/// Gets content as a stream (ideal for large files).
|
||||||
async fn get_file_stream(
|
async fn get_file_stream(
|
||||||
&self,
|
&self,
|
||||||
@@ -108,6 +124,25 @@ pub trait FileReadPort: Send + Sync + 'static {
|
|||||||
Ok(all.into_iter().skip(start).take(end - start).collect())
|
Ok(all.into_iter().skip(start).take(end - start).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`list_files_batch`], but only returns files owned by `owner_id`.
|
||||||
|
///
|
||||||
|
/// Used by streaming WebDAV PROPFIND to list files scoped to the
|
||||||
|
/// authenticated user, preventing cross-user data leakage.
|
||||||
|
async fn list_files_batch_for_owner(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<File>, DomainError> {
|
||||||
|
// Default: filter in-memory (repos should override with SQL)
|
||||||
|
let all = self.list_files_batch(folder_id, offset, limit).await?;
|
||||||
|
Ok(all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.owner_id().map_or(false, |o| o == owner_id))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Streams every file in the subtree rooted at `folder_id`.
|
/// Streams every file in the subtree rooted at `folder_id`.
|
||||||
///
|
///
|
||||||
/// Uses an ltree `<@` join against `storage.folders` so the entire
|
/// Uses an ltree `<@` join against `storage.folders` so the entire
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ impl BatchOperationService {
|
|||||||
&self,
|
&self,
|
||||||
file_ids: Vec<String>,
|
file_ids: Vec<String>,
|
||||||
target_folder_id: Option<String>,
|
target_folder_id: Option<String>,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
||||||
info!("Starting batch copy of {} files", file_ids.len());
|
info!("Starting batch copy of {} files", file_ids.len());
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
@@ -134,15 +135,17 @@ impl BatchOperationService {
|
|||||||
|
|
||||||
// Arc<str> avoids N heap-clones of the same string
|
// Arc<str> avoids N heap-clones of the same string
|
||||||
let target_folder: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
let target_folder: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
||||||
|
let caller: Arc<str> = Arc::from(caller_id);
|
||||||
|
|
||||||
// buffer_unordered materialises only max_concurrent futures at a time
|
// buffer_unordered materialises only max_concurrent futures at a time
|
||||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||||
let mgmt = self.file_management.clone();
|
let mgmt = self.file_management.clone();
|
||||||
let target_folder = target_folder.clone();
|
let target_folder = target_folder.clone();
|
||||||
|
let caller = caller.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let copy_result = mgmt
|
let copy_result = mgmt
|
||||||
.copy_file(&file_id, target_folder.map(|s| s.to_string()))
|
.copy_file_owned(&file_id, &caller, target_folder.map(|s| s.to_string()))
|
||||||
.await;
|
.await;
|
||||||
(file_id, copy_result)
|
(file_id, copy_result)
|
||||||
}
|
}
|
||||||
@@ -184,6 +187,7 @@ impl BatchOperationService {
|
|||||||
&self,
|
&self,
|
||||||
file_ids: Vec<String>,
|
file_ids: Vec<String>,
|
||||||
target_folder_id: Option<String>,
|
target_folder_id: Option<String>,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
||||||
info!("Starting batch move of {} files", file_ids.len());
|
info!("Starting batch move of {} files", file_ids.len());
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
@@ -200,14 +204,16 @@ impl BatchOperationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let target_folder: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
let target_folder: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
||||||
|
let caller: Arc<str> = Arc::from(caller_id);
|
||||||
|
|
||||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||||
let mgmt = self.file_management.clone();
|
let mgmt = self.file_management.clone();
|
||||||
let target_folder = target_folder.clone();
|
let target_folder = target_folder.clone();
|
||||||
|
let caller = caller.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let move_result = mgmt
|
let move_result = mgmt
|
||||||
.move_file(&file_id, target_folder.map(|s| s.to_string()))
|
.move_file_owned(&file_id, &caller, target_folder.map(|s| s.to_string()))
|
||||||
.await;
|
.await;
|
||||||
(file_id, move_result)
|
(file_id, move_result)
|
||||||
}
|
}
|
||||||
@@ -247,6 +253,7 @@ impl BatchOperationService {
|
|||||||
pub async fn delete_files(
|
pub async fn delete_files(
|
||||||
&self,
|
&self,
|
||||||
file_ids: Vec<String>,
|
file_ids: Vec<String>,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<BatchResult<String>, BatchOperationError> {
|
) -> Result<BatchResult<String>, BatchOperationError> {
|
||||||
info!("Starting batch deletion of {} files", file_ids.len());
|
info!("Starting batch deletion of {} files", file_ids.len());
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
@@ -262,11 +269,14 @@ impl BatchOperationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Define the operation to perform for each file
|
// Define the operation to perform for each file
|
||||||
|
let caller: Arc<str> = Arc::from(caller_id);
|
||||||
|
|
||||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||||
let mgmt = self.file_management.clone();
|
let mgmt = self.file_management.clone();
|
||||||
|
let caller = caller.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let delete_result = mgmt.delete_file(&file_id).await;
|
let delete_result = mgmt.delete_file_owned(&file_id, &caller).await;
|
||||||
let id_for_result = file_id.clone();
|
let id_for_result = file_id.clone();
|
||||||
(file_id, delete_result.map(|_| id_for_result))
|
(file_id, delete_result.map(|_| id_for_result))
|
||||||
}
|
}
|
||||||
@@ -307,6 +317,7 @@ impl BatchOperationService {
|
|||||||
pub async fn get_multiple_files(
|
pub async fn get_multiple_files(
|
||||||
&self,
|
&self,
|
||||||
file_ids: Vec<String>,
|
file_ids: Vec<String>,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
||||||
info!("Starting batch load of {} files", file_ids.len());
|
info!("Starting batch load of {} files", file_ids.len());
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
@@ -322,11 +333,14 @@ impl BatchOperationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Define the operation to perform for each file
|
// Define the operation to perform for each file
|
||||||
|
let caller: Arc<str> = Arc::from(caller_id);
|
||||||
|
|
||||||
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| {
|
||||||
let retrieval = self.file_retrieval.clone();
|
let retrieval = self.file_retrieval.clone();
|
||||||
|
let caller = caller.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let get_result = retrieval.get_file(&file_id).await;
|
let get_result = retrieval.get_file_owned(&file_id, &caller).await;
|
||||||
(file_id, get_result)
|
(file_id, get_result)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -631,6 +645,7 @@ impl BatchOperationService {
|
|||||||
&self,
|
&self,
|
||||||
file_ids: Vec<String>,
|
file_ids: Vec<String>,
|
||||||
folder_ids: Vec<String>,
|
folder_ids: Vec<String>,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<NamedTempFile, BatchOperationError> {
|
) -> Result<NamedTempFile, BatchOperationError> {
|
||||||
info!(
|
info!(
|
||||||
"Starting batch download: {} files, {} folders",
|
"Starting batch download: {} files, {} folders",
|
||||||
@@ -650,10 +665,10 @@ impl BatchOperationService {
|
|||||||
|
|
||||||
// ── Add individual files at the root of the ZIP ──────────────────
|
// ── Add individual files at the root of the ZIP ──────────────────
|
||||||
for file_id in &file_ids {
|
for file_id in &file_ids {
|
||||||
match self.file_retrieval.get_file(file_id).await {
|
match self.file_retrieval.get_file_owned(file_id, caller_id).await {
|
||||||
Ok(file_dto) => {
|
Ok(file_dto) => {
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.add_file_entry_streamed(&mut zip, file_id, &file_dto.name)
|
.add_file_entry_streamed(&mut zip, file_id, &file_dto.name, caller_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
info!("Could not add file {} to ZIP: {}", file_dto.name, e);
|
info!("Could not add file {} to ZIP: {}", file_dto.name, e);
|
||||||
@@ -667,10 +682,10 @@ impl BatchOperationService {
|
|||||||
|
|
||||||
// ── Add folders as sub-trees (bulk subtree queries, not N+1) ─────
|
// ── Add folders as sub-trees (bulk subtree queries, not N+1) ─────
|
||||||
for folder_id in &folder_ids {
|
for folder_id in &folder_ids {
|
||||||
match self.folder_service.get_folder(folder_id).await {
|
match self.folder_service.get_folder_owned(folder_id, caller_id).await {
|
||||||
Ok(root_folder) => {
|
Ok(root_folder) => {
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder)
|
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, caller_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
info!("Could not add folder {} to ZIP: {}", root_folder.name, e);
|
info!("Could not add folder {} to ZIP: {}", root_folder.name, e);
|
||||||
@@ -709,6 +724,7 @@ impl BatchOperationService {
|
|||||||
zip: &mut ZipFileWriter<tokio_util::compat::Compat<BufWriter<tokio::fs::File>>>,
|
zip: &mut ZipFileWriter<tokio_util::compat::Compat<BufWriter<tokio::fs::File>>>,
|
||||||
file_id: &str,
|
file_id: &str,
|
||||||
entry_name: &str,
|
entry_name: &str,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<(), BatchOperationError> {
|
) -> Result<(), BatchOperationError> {
|
||||||
let entry = ZipEntryBuilder::new(entry_name.to_string().into(), Compression::Deflate);
|
let entry = ZipEntryBuilder::new(entry_name.to_string().into(), Compression::Deflate);
|
||||||
let mut writer = zip
|
let mut writer = zip
|
||||||
@@ -718,7 +734,7 @@ impl BatchOperationService {
|
|||||||
|
|
||||||
let stream = self
|
let stream = self
|
||||||
.file_retrieval
|
.file_retrieval
|
||||||
.get_file_stream(file_id)
|
.get_file_stream_owned(file_id, caller_id)
|
||||||
.await
|
.await
|
||||||
.map_err(BatchOperationError::Domain)?;
|
.map_err(BatchOperationError::Domain)?;
|
||||||
let mut stream = std::pin::Pin::from(stream);
|
let mut stream = std::pin::Pin::from(stream);
|
||||||
@@ -749,6 +765,7 @@ impl BatchOperationService {
|
|||||||
zip: &mut ZipFileWriter<tokio_util::compat::Compat<BufWriter<tokio::fs::File>>>,
|
zip: &mut ZipFileWriter<tokio_util::compat::Compat<BufWriter<tokio::fs::File>>>,
|
||||||
folder_id: &str,
|
folder_id: &str,
|
||||||
root_folder: &FolderDto,
|
root_folder: &FolderDto,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<(), BatchOperationError> {
|
) -> Result<(), BatchOperationError> {
|
||||||
// Bulk-fetch folder tree (small — one entry per folder)
|
// Bulk-fetch folder tree (small — one entry per folder)
|
||||||
let all_folders = self
|
let all_folders = self
|
||||||
@@ -803,7 +820,7 @@ impl BatchOperationService {
|
|||||||
for file in files {
|
for file in files {
|
||||||
let file_path = format!("{}{}", zip_dir, file.name);
|
let file_path = format!("{}{}", zip_dir, file.name);
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.add_file_entry_streamed(zip, &file.id, &file_path)
|
.add_file_entry_streamed(zip, &file.id, &file_path, caller_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
info!("Could not add file {} to ZIP: {}", file.name, e);
|
info!("Could not add file {} to ZIP: {}", file.name, e);
|
||||||
@@ -887,6 +904,7 @@ impl BatchOperationService {
|
|||||||
pub async fn create_folders(
|
pub async fn create_folders(
|
||||||
&self,
|
&self,
|
||||||
folders: Vec<(String, Option<String>)>, // (name, parent_id)
|
folders: Vec<(String, Option<String>)>, // (name, parent_id)
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
||||||
info!("Starting batch creation of {} folders", folders.len());
|
info!("Starting batch creation of {} folders", folders.len());
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
@@ -902,10 +920,20 @@ impl BatchOperationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Define the operation for each folder
|
// Define the operation for each folder
|
||||||
|
let caller: Arc<str> = Arc::from(caller_id);
|
||||||
|
|
||||||
let mut operation_stream = stream::iter(folders.into_iter().map(|(name, parent_id)| {
|
let mut operation_stream = stream::iter(folders.into_iter().map(|(name, parent_id)| {
|
||||||
let folder_service = self.folder_service.clone();
|
let folder_service = self.folder_service.clone();
|
||||||
|
let caller = caller.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
|
// If a parent is specified, verify the caller owns it
|
||||||
|
if let Some(ref pid) = parent_id {
|
||||||
|
if let Err(e) = folder_service.get_folder_owned(pid, &caller).await {
|
||||||
|
let id = format!("{}:{}", name, pid);
|
||||||
|
return (id, Err(e.into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
let dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
let dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
parent_id: parent_id.clone(),
|
parent_id: parent_id.clone(),
|
||||||
@@ -951,6 +979,7 @@ impl BatchOperationService {
|
|||||||
pub async fn get_multiple_folders(
|
pub async fn get_multiple_folders(
|
||||||
&self,
|
&self,
|
||||||
folder_ids: Vec<String>,
|
folder_ids: Vec<String>,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
||||||
info!("Starting batch load of {} folders", folder_ids.len());
|
info!("Starting batch load of {} folders", folder_ids.len());
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
@@ -966,11 +995,14 @@ impl BatchOperationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Define the operation for each folder
|
// Define the operation for each folder
|
||||||
|
let caller: Arc<str> = Arc::from(caller_id);
|
||||||
|
|
||||||
let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| {
|
let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| {
|
||||||
let folder_service = self.folder_service.clone();
|
let folder_service = self.folder_service.clone();
|
||||||
|
let caller = caller.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let get_result = folder_service.get_folder(&folder_id).await;
|
let get_result = folder_service.get_folder_owned(&folder_id, &caller).await;
|
||||||
(folder_id, get_result)
|
(folder_id, get_result)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
Ok(FileDto::from(copied_file))
|
Ok(FileDto::from(copied_file))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn copy_file_owned(
|
||||||
|
&self,
|
||||||
|
file_id: &str,
|
||||||
|
caller_id: &str,
|
||||||
|
target_folder_id: Option<String>,
|
||||||
|
) -> Result<FileDto, DomainError> {
|
||||||
|
self.verify_owner(file_id, caller_id).await?;
|
||||||
|
self.copy_file(file_id, target_folder_id).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError> {
|
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError> {
|
||||||
info!("Renaming file with ID: {} to \"{}\"", file_id, new_name);
|
info!("Renaming file with ID: {} to \"{}\"", file_id, new_name);
|
||||||
|
|
||||||
@@ -163,6 +173,11 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
self.file_repository.delete_file(id).await
|
self.file_repository.delete_file(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_file_owned(&self, id: &str, caller_id: &str) -> Result<(), DomainError> {
|
||||||
|
self.verify_owner(id, caller_id).await?;
|
||||||
|
self.delete_file(id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Smart delete: trash-first with dedup reference cleanup.
|
/// Smart delete: trash-first with dedup reference cleanup.
|
||||||
///
|
///
|
||||||
/// Blob ref_count bookkeeping is handled entirely by the PG trigger
|
/// Blob ref_count bookkeeping is handled entirely by the PG trigger
|
||||||
|
|||||||
@@ -223,6 +223,15 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
|||||||
Ok(files.into_iter().map(FileDto::from).collect())
|
Ok(files.into_iter().map(FileDto::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_files_owned(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
) -> Result<Vec<FileDto>, DomainError> {
|
||||||
|
let files = self.file_read.list_files_for_owner(folder_id, owner_id).await?;
|
||||||
|
Ok(files.into_iter().map(FileDto::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_file_stream(
|
async fn get_file_stream(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -230,6 +239,15 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
|||||||
self.file_read.get_file_stream(id).await
|
self.file_read.get_file_stream(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_file_stream_owned(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
caller_id: &str,
|
||||||
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||||
|
self.file_read.verify_file_owner(id, caller_id).await?;
|
||||||
|
self.file_read.get_file_stream(id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Multi-tier optimized download.
|
/// Multi-tier optimized download.
|
||||||
async fn get_file_optimized(
|
async fn get_file_optimized(
|
||||||
&self,
|
&self,
|
||||||
@@ -311,4 +329,18 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(files.into_iter().map(FileDto::from).collect())
|
Ok(files.into_iter().map(FileDto::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_files_batch_for_owner(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<FileDto>, DomainError> {
|
||||||
|
let files = self
|
||||||
|
.file_read
|
||||||
|
.list_files_batch_for_owner(folder_id, owner_id, offset, limit)
|
||||||
|
.await?;
|
||||||
|
Ok(files.into_iter().map(FileDto::from).collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ impl FolderService {
|
|||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_folder_owned(&self, _id: &str, _caller_id: &str) -> Result<FolderDto, DomainError> {
|
||||||
|
Ok(FolderDto::empty())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
|
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
}
|
}
|
||||||
@@ -196,6 +200,21 @@ impl FolderUseCase for FolderService {
|
|||||||
Ok(FolderDto::from(folder))
|
Ok(FolderDto::from(folder))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets a folder by its ID, enforcing that `caller_id` is the owner.
|
||||||
|
async fn get_folder_owned(&self, id: &str, caller_id: &str) -> Result<FolderDto, DomainError> {
|
||||||
|
let folder_dto = self.get_folder(id).await?;
|
||||||
|
if folder_dto.owner_id.as_deref() != Some(caller_id) {
|
||||||
|
tracing::warn!(
|
||||||
|
"get_folder_owned: user '{}' attempted to access folder '{}' owned by '{:?}'",
|
||||||
|
caller_id,
|
||||||
|
id,
|
||||||
|
folder_dto.owner_id
|
||||||
|
);
|
||||||
|
return Err(DomainError::not_found("Folder", id));
|
||||||
|
}
|
||||||
|
Ok(folder_dto)
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets a folder by its path
|
/// Gets a folder by its path
|
||||||
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError> {
|
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError> {
|
||||||
// Convert the string path to StoragePath
|
// Convert the string path to StoragePath
|
||||||
|
|||||||
@@ -355,6 +355,10 @@ impl FolderUseCase for StubFolderUseCase {
|
|||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_folder_owned(&self, _id: &str, _caller_id: &str) -> Result<FolderDto, DomainError> {
|
||||||
|
Ok(FolderDto::default())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
|
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
}
|
}
|
||||||
@@ -490,6 +494,14 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
|
|||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_files_owned(
|
||||||
|
&self,
|
||||||
|
_folder_id: Option<&str>,
|
||||||
|
_owner_id: &str,
|
||||||
|
) -> Result<Vec<FileDto>, DomainError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_file_stream(
|
async fn get_file_stream(
|
||||||
&self,
|
&self,
|
||||||
_id: &str,
|
_id: &str,
|
||||||
@@ -498,6 +510,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
|
|||||||
Ok(Box::new(empty_stream))
|
Ok(Box::new(empty_stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_file_stream_owned(
|
||||||
|
&self,
|
||||||
|
_id: &str,
|
||||||
|
_caller_id: &str,
|
||||||
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||||
|
let empty_stream = futures::stream::empty::<Result<Bytes, std::io::Error>>();
|
||||||
|
Ok(Box::new(empty_stream))
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_file_optimized(
|
async fn get_file_optimized(
|
||||||
&self,
|
&self,
|
||||||
_id: &str,
|
_id: &str,
|
||||||
@@ -591,6 +612,15 @@ impl FileManagementUseCase for StubFileManagementUseCase {
|
|||||||
Ok(FileDto::default())
|
Ok(FileDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn copy_file_owned(
|
||||||
|
&self,
|
||||||
|
_file_id: &str,
|
||||||
|
_caller_id: &str,
|
||||||
|
_folder_id: Option<String>,
|
||||||
|
) -> Result<FileDto, DomainError> {
|
||||||
|
Ok(FileDto::default())
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result<FileDto, DomainError> {
|
async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result<FileDto, DomainError> {
|
||||||
Ok(FileDto::default())
|
Ok(FileDto::default())
|
||||||
}
|
}
|
||||||
@@ -599,6 +629,10 @@ impl FileManagementUseCase for StubFileManagementUseCase {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_file_owned(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_with_cleanup(&self, _id: &str, _user_id: &str) -> Result<bool, DomainError> {
|
async fn delete_with_cleanup(&self, _id: &str, _user_id: &str) -> Result<bool, DomainError> {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,62 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-scoped file listing — adds `AND fi.user_id = $2` to prevent
|
||||||
|
/// cross-user data leakage in the REST API (`list_files_query`).
|
||||||
|
async fn list_files_for_owner(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
) -> Result<Vec<File>, DomainError> {
|
||||||
|
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
|
fi.size, fi.mime_type,
|
||||||
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||||
|
fi.user_id::text
|
||||||
|
FROM storage.files fi
|
||||||
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
|
||||||
|
AND fi.user_id = $2
|
||||||
|
ORDER BY fi.name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(fid)
|
||||||
|
.bind(owner_id)
|
||||||
|
.fetch_all(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
|
fi.size, fi.mime_type,
|
||||||
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||||
|
fi.user_id::text
|
||||||
|
FROM storage.files fi
|
||||||
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||||
|
AND fi.user_id = $1
|
||||||
|
ORDER BY fi.name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(owner_id)
|
||||||
|
.fetch_all(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
|
||||||
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||||
self.resolve_blob_hash(file_id).await
|
self.resolve_blob_hash(file_id).await
|
||||||
}
|
}
|
||||||
@@ -323,6 +379,70 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-scoped paginated file listing — adds `AND fi.user_id = $4` to
|
||||||
|
/// prevent cross-user data leakage in WebDAV PROPFIND.
|
||||||
|
async fn list_files_batch_for_owner(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<File>, DomainError> {
|
||||||
|
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
|
fi.size, fi.mime_type,
|
||||||
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||||
|
fi.user_id::text
|
||||||
|
FROM storage.files fi
|
||||||
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
|
||||||
|
AND fi.user_id = $4
|
||||||
|
ORDER BY fi.name
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(fid)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.bind(owner_id)
|
||||||
|
.fetch_all(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
|
fi.size, fi.mime_type,
|
||||||
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||||
|
fi.user_id::text
|
||||||
|
FROM storage.files fi
|
||||||
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||||
|
AND fi.user_id = $3
|
||||||
|
ORDER BY fi.name
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.bind(owner_id)
|
||||||
|
.fetch_all(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("FileBlobRead", format!("list_batch_for_owner: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
|
||||||
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_file_stream(
|
async fn get_file_stream(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
|
|||||||
@@ -32,17 +32,20 @@ impl PathResolverService {
|
|||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve `path` (without leading `/`) to either a folder or a file.
|
/// Resolve `path` to a folder or file **owned by `user_id`**.
|
||||||
///
|
///
|
||||||
/// The query uses `UNION ALL … LIMIT 1`: the folder branch is evaluated
|
/// Adds `AND fo.user_id = $4` / `AND fi.user_id = $4` so that one
|
||||||
/// first, and PG short-circuits if it produces a row.
|
/// user can never resolve another user's resources.
|
||||||
pub async fn resolve_path(&self, path: &str) -> Result<ResolvedResource, DomainError> {
|
pub async fn resolve_path_for_user(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<ResolvedResource, DomainError> {
|
||||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return Err(DomainError::not_found("Resource", "empty path"));
|
return Err(DomainError::not_found("Resource", "empty path"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split into folder_path + filename for the file branch
|
|
||||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
let filename = segments[segments.len() - 1];
|
let filename = segments[segments.len() - 1];
|
||||||
let folder_path = if segments.len() > 1 {
|
let folder_path = if segments.len() > 1 {
|
||||||
@@ -51,9 +54,6 @@ impl PathResolverService {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single round-trip: folder branch ∪ file branch, LIMIT 1.
|
|
||||||
// Column order: resource_type, id, name, path, parent_id, user_id,
|
|
||||||
// created_at, modified_at, size, mime_type, folder_id
|
|
||||||
let row = sqlx::query_as::<
|
let row = sqlx::query_as::<
|
||||||
_,
|
_,
|
||||||
(
|
(
|
||||||
@@ -61,13 +61,13 @@ impl PathResolverService {
|
|||||||
String, // id
|
String, // id
|
||||||
String, // name
|
String, // name
|
||||||
String, // path
|
String, // path
|
||||||
Option<String>, // parent_id (folder) / NULL (file)
|
Option<String>, // parent_id
|
||||||
Option<String>, // user_id
|
Option<String>, // user_id
|
||||||
i64, // created_at epoch
|
i64, // created_at
|
||||||
i64, // modified_at epoch
|
i64, // modified_at
|
||||||
Option<i64>, // size (NULL for folder)
|
Option<i64>, // size
|
||||||
Option<String>, // mime_type (NULL for folder)
|
Option<String>, // mime_type
|
||||||
Option<String>, // folder_id (NULL for folder)
|
Option<String>, // folder_id
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
@@ -87,6 +87,7 @@ impl PathResolverService {
|
|||||||
NULL::text AS folder_id
|
NULL::text AS folder_id
|
||||||
FROM storage.folders fo
|
FROM storage.folders fo
|
||||||
WHERE fo.path = $1 AND NOT fo.is_trashed
|
WHERE fo.path = $1 AND NOT fo.is_trashed
|
||||||
|
AND fo.user_id = $4
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
@@ -113,16 +114,18 @@ impl PathResolverService {
|
|||||||
OR fo.path = $3
|
OR fo.path = $3
|
||||||
)
|
)
|
||||||
AND NOT fi.is_trashed
|
AND NOT fi.is_trashed
|
||||||
|
AND fi.user_id = $4
|
||||||
) sub
|
) sub
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(path) // $1 — full path for folder lookup
|
.bind(path) // $1
|
||||||
.bind(filename) // $2 — filename for file lookup
|
.bind(filename) // $2
|
||||||
.bind(&folder_path) // $3 — parent folder path for file lookup
|
.bind(&folder_path) // $3
|
||||||
|
.bind(user_id) // $4
|
||||||
.fetch_optional(self.pool.as_ref())
|
.fetch_optional(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))?
|
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("Resource", path))?;
|
.ok_or_else(|| DomainError::not_found("Resource", path))?;
|
||||||
|
|
||||||
let (
|
let (
|
||||||
@@ -131,7 +134,7 @@ impl PathResolverService {
|
|||||||
name,
|
name,
|
||||||
res_path,
|
res_path,
|
||||||
parent_id,
|
parent_id,
|
||||||
user_id,
|
uid,
|
||||||
created_at,
|
created_at,
|
||||||
modified_at,
|
modified_at,
|
||||||
size,
|
size,
|
||||||
@@ -145,7 +148,7 @@ impl PathResolverService {
|
|||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
path: res_path,
|
path: res_path,
|
||||||
parent_id,
|
parent_id,
|
||||||
owner_id: user_id,
|
owner_id: uid,
|
||||||
created_at: created_at as u64,
|
created_at: created_at as u64,
|
||||||
modified_at: modified_at as u64,
|
modified_at: modified_at as u64,
|
||||||
is_root: false,
|
is_root: false,
|
||||||
@@ -169,16 +172,14 @@ impl PathResolverService {
|
|||||||
icon_special_class: Arc::from(icon_special_class_for(&name, &mime)),
|
icon_special_class: Arc::from(icon_special_class_for(&name, &mime)),
|
||||||
category: Arc::from(category_for(&name, &mime)),
|
category: Arc::from(category_for(&name, &mime)),
|
||||||
size_formatted: format_file_size(sz),
|
size_formatted: format_file_size(sz),
|
||||||
owner_id: user_id,
|
owner_id: uid,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether *any* resource (folder or file) exists at the given path.
|
/// Returns `true` if the resource at `path` belongs to `user_id`.
|
||||||
///
|
pub async fn exists_for_user(&self, path: &str, user_id: &str) -> Result<bool, DomainError> {
|
||||||
/// Equivalent to `resolve_path(…).is_ok()` but avoids constructing the DTO.
|
|
||||||
pub async fn exists(&self, path: &str) -> Result<bool, DomainError> {
|
|
||||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -196,7 +197,7 @@ impl PathResolverService {
|
|||||||
r#"
|
r#"
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM storage.folders
|
SELECT 1 FROM storage.folders
|
||||||
WHERE path = $1 AND NOT is_trashed
|
WHERE path = $1 AND NOT is_trashed AND user_id = $4
|
||||||
) OR EXISTS(
|
) OR EXISTS(
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM storage.files fi
|
FROM storage.files fi
|
||||||
@@ -204,15 +205,17 @@ impl PathResolverService {
|
|||||||
WHERE fi.name = $2
|
WHERE fi.name = $2
|
||||||
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
|
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
|
||||||
AND NOT fi.is_trashed
|
AND NOT fi.is_trashed
|
||||||
|
AND fi.user_id = $4
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(path)
|
.bind(path)
|
||||||
.bind(filename)
|
.bind(filename)
|
||||||
.bind(&folder_path)
|
.bind(&folder_path)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?;
|
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists_for_user: {e}")))?;
|
||||||
|
|
||||||
Ok(exists)
|
Ok(exists)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::{Json, Query, State},
|
extract::{Json, Query, State},
|
||||||
http::{HeaderMap, StatusCode, header},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
routing::{get, post, put},
|
routing::{get, post, put},
|
||||||
};
|
};
|
||||||
@@ -319,26 +319,27 @@ async fn change_password(
|
|||||||
|
|
||||||
async fn logout(
|
async fn logout(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
|
||||||
CurrentUserId(user_id): CurrentUserId,
|
CurrentUserId(user_id): CurrentUserId,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: axum::body::Bytes,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let auth_service = state
|
let auth_service = state
|
||||||
.auth_service
|
.auth_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||||
|
|
||||||
// Obtain the raw access token from Bearer header OR cookie
|
// Extract the REFRESH token (not the access token) so the service can
|
||||||
let token = headers
|
// look up and revoke the correct session.
|
||||||
.get(header::AUTHORIZATION)
|
// Strategy: try JSON body first (API clients), then HttpOnly cookie (browsers).
|
||||||
.and_then(|v| v.to_str().ok())
|
let refresh_token = serde_json::from_slice::<RefreshTokenDto>(&body)
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.ok()
|
||||||
.map(String::from)
|
.map(|dto| dto.refresh_token)
|
||||||
.or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE))
|
.or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE))
|
||||||
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
|
.ok_or_else(|| AppError::unauthorized("Refresh token required for logout (JSON body or cookie)"))?;
|
||||||
|
|
||||||
auth_service
|
auth_service
|
||||||
.auth_application_service
|
.auth_application_service
|
||||||
.logout(&user_id, &token)
|
.logout(&user_id, &refresh_token)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Clear HttpOnly + CSRF cookies so the browser forgets the session
|
// Clear HttpOnly + CSRF cookies so the browser forgets the session
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ where
|
|||||||
/// Handler for moving multiple files in batch
|
/// Handler for moving multiple files in batch
|
||||||
pub async fn move_files_batch(
|
pub async fn move_files_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchFileOperationRequest>,
|
Json(request): Json<BatchFileOperationRequest>,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
// Verify there are files to process
|
// Verify there are files to process
|
||||||
@@ -146,7 +147,7 @@ pub async fn move_files_batch(
|
|||||||
// Execute batch operation
|
// Execute batch operation
|
||||||
let result = state
|
let result = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.move_files(request.file_ids, request.target_folder_id)
|
.move_files(request.file_ids, request.target_folder_id, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ pub async fn move_files_batch(
|
|||||||
/// Handler for copying multiple files in batch
|
/// Handler for copying multiple files in batch
|
||||||
pub async fn copy_files_batch(
|
pub async fn copy_files_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchFileOperationRequest>,
|
Json(request): Json<BatchFileOperationRequest>,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
// Verify there are files to process
|
// Verify there are files to process
|
||||||
@@ -186,7 +188,7 @@ pub async fn copy_files_batch(
|
|||||||
// Execute batch operation
|
// Execute batch operation
|
||||||
let result = state
|
let result = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.copy_files(request.file_ids, request.target_folder_id)
|
.copy_files(request.file_ids, request.target_folder_id, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -210,6 +212,7 @@ pub async fn copy_files_batch(
|
|||||||
/// Handler for deleting multiple files in batch
|
/// Handler for deleting multiple files in batch
|
||||||
pub async fn delete_files_batch(
|
pub async fn delete_files_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchFileOperationRequest>,
|
Json(request): Json<BatchFileOperationRequest>,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
// Verify there are files to process
|
// Verify there are files to process
|
||||||
@@ -226,7 +229,7 @@ pub async fn delete_files_batch(
|
|||||||
// Execute batch operation
|
// Execute batch operation
|
||||||
let result = state
|
let result = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.delete_files(request.file_ids)
|
.delete_files(request.file_ids, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -307,6 +310,7 @@ pub async fn delete_folders_batch(
|
|||||||
/// Handler for creating multiple folders in batch
|
/// Handler for creating multiple folders in batch
|
||||||
pub async fn create_folders_batch(
|
pub async fn create_folders_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchCreateFoldersRequest>,
|
Json(request): Json<BatchCreateFoldersRequest>,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
// Verify there are folders to process
|
// Verify there are folders to process
|
||||||
@@ -330,7 +334,7 @@ pub async fn create_folders_batch(
|
|||||||
// Execute batch operation
|
// Execute batch operation
|
||||||
let result = state
|
let result = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.create_folders(folders)
|
.create_folders(folders, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -354,6 +358,7 @@ pub async fn create_folders_batch(
|
|||||||
/// Handler for getting multiple files in batch
|
/// Handler for getting multiple files in batch
|
||||||
pub async fn get_files_batch(
|
pub async fn get_files_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchFileOperationRequest>,
|
Json(request): Json<BatchFileOperationRequest>,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
// Verify there are files to process
|
// Verify there are files to process
|
||||||
@@ -370,7 +375,7 @@ pub async fn get_files_batch(
|
|||||||
// Execute batch operation
|
// Execute batch operation
|
||||||
let result = state
|
let result = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.get_multiple_files(request.file_ids)
|
.get_multiple_files(request.file_ids, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -394,6 +399,7 @@ pub async fn get_files_batch(
|
|||||||
/// Handler for getting multiple folders in batch
|
/// Handler for getting multiple folders in batch
|
||||||
pub async fn get_folders_batch(
|
pub async fn get_folders_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchFolderOperationRequest>,
|
Json(request): Json<BatchFolderOperationRequest>,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
// Verify there are folders to process
|
// Verify there are folders to process
|
||||||
@@ -410,7 +416,7 @@ pub async fn get_folders_batch(
|
|||||||
// Execute batch operation
|
// Execute batch operation
|
||||||
let result = state
|
let result = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.get_multiple_folders(request.folder_ids)
|
.get_multiple_folders(request.folder_ids, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -596,6 +602,7 @@ pub async fn move_folders_batch(
|
|||||||
/// so RAM usage is O(buffer_size) regardless of archive size.
|
/// so RAM usage is O(buffer_size) regardless of archive size.
|
||||||
pub async fn download_batch(
|
pub async fn download_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchDownloadRequest>,
|
Json(request): Json<BatchDownloadRequest>,
|
||||||
) -> Result<Response, (StatusCode, String)> {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
if request.file_ids.is_empty() && request.folder_ids.is_empty() {
|
if request.file_ids.is_empty() && request.folder_ids.is_empty() {
|
||||||
@@ -607,7 +614,7 @@ pub async fn download_batch(
|
|||||||
|
|
||||||
let temp_file = state
|
let temp_file = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.download_zip(request.file_ids, request.folder_ids)
|
.download_zip(request.file_ids, request.folder_ids, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
@@ -632,38 +632,6 @@ impl FileHandler {
|
|||||||
Self::created_json_response(&file).into_response()
|
Self::created_json_response(&file).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lists files, optionally filtered by folder ID
|
|
||||||
pub async fn list_files(
|
|
||||||
State(state): State<GlobalState>,
|
|
||||||
folder_id: Option<&str>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
tracing::info!("Listing files with folder_id: {:?}", folder_id);
|
|
||||||
|
|
||||||
let retrieval = &state.applications.file_retrieval_service;
|
|
||||||
match retrieval.list_files(folder_id).await {
|
|
||||||
Ok(files) => {
|
|
||||||
tracing::info!("Found {} files through the service", files.len());
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
.header("Pragma", "no-cache")
|
|
||||||
.header("Expires", "0")
|
|
||||||
.body(Body::from(serde_json::to_string(&files).unwrap()))
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("Error listing files: {}", err);
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(serde_json::json!({
|
|
||||||
"error": err.to_string()
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// DELETE
|
// DELETE
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -84,6 +84,35 @@ const MAX_MKCOL_BODY: usize = 4096;
|
|||||||
/// of this size to keep memory constant regardless of folder contents.
|
/// of this size to keep memory constant regardless of folder contents.
|
||||||
const PROPFIND_BATCH_SIZE: i64 = 500;
|
const PROPFIND_BATCH_SIZE: i64 = 500;
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Security helpers (Sol.1 — handler-level user extraction & ownership guard)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Extract the authenticated [`CurrentUser`] from the request extensions.
|
||||||
|
///
|
||||||
|
/// Every mutating or data-returning WebDAV handler **must** call this so
|
||||||
|
/// that the real `user.id` is available for ownership checks and for the
|
||||||
|
/// user-scoped `PathResolverService` methods.
|
||||||
|
fn extract_user(req: &Request<Body>) -> Result<CurrentUser, AppError> {
|
||||||
|
req.extensions()
|
||||||
|
.get::<CurrentUser>()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| AppError::unauthorized("Authentication required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a resolved resource belongs to `user_id`.
|
||||||
|
///
|
||||||
|
/// Used in the legacy (no-PathResolver) fallback paths where
|
||||||
|
/// `get_folder_by_path` / `get_file_by_path` are not user-scoped.
|
||||||
|
/// Returns `AppError::not_found` on mismatch so we don't leak the
|
||||||
|
/// existence of another user's resource.
|
||||||
|
fn assert_owner(owner_id: Option<&str>, user_id: &str, path: &str) -> Result<(), AppError> {
|
||||||
|
match owner_id {
|
||||||
|
Some(oid) if oid == user_id => Ok(()),
|
||||||
|
_ => Err(AppError::not_found(format!("Resource not found: {}", path))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates and returns the WebDAV router with all required endpoints.
|
* Creates and returns the WebDAV router with all required endpoints.
|
||||||
*
|
*
|
||||||
@@ -236,13 +265,7 @@ async fn handle_propfind(
|
|||||||
let depth_owned = depth.to_string();
|
let depth_owned = depth.to_string();
|
||||||
|
|
||||||
// ── 2. Authenticate ──────────────────────────────────────────
|
// ── 2. Authenticate ──────────────────────────────────────────
|
||||||
let _user = {
|
let user = extract_user(&req)?;
|
||||||
let user_ref = req
|
|
||||||
.extensions()
|
|
||||||
.get::<CurrentUser>()
|
|
||||||
.ok_or_else(|| AppError::unauthorized("Authentication required"))?;
|
|
||||||
user_ref.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 3. Parse PROPFIND XML body ───────────────────────────────
|
// ── 3. Parse PROPFIND XML body ───────────────────────────────
|
||||||
let body_bytes = {
|
let body_bytes = {
|
||||||
@@ -297,13 +320,14 @@ async fn handle_propfind(
|
|||||||
propfind_request,
|
propfind_request,
|
||||||
folder_service,
|
folder_service,
|
||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
|
&user.id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-query path resolution: folder OR file in one DB round-trip
|
// Single-query path resolution: folder OR file in one DB round-trip
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path(&path).await {
|
match resolver.resolve_path_for_user(&path, &user.id).await {
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
let folder_id = folder.id.clone();
|
let folder_id = folder.id.clone();
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
@@ -314,6 +338,7 @@ async fn handle_propfind(
|
|||||||
propfind_request,
|
propfind_request,
|
||||||
folder_service,
|
folder_service,
|
||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
|
&user.id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -344,6 +369,7 @@ async fn handle_propfind(
|
|||||||
} else {
|
} else {
|
||||||
// Fallback: legacy double-query path when PathResolver is unavailable
|
// Fallback: legacy double-query path when PathResolver is unavailable
|
||||||
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
|
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
|
||||||
|
assert_owner(folder.owner_id.as_deref(), &user.id, &path)?;
|
||||||
let folder_id = folder.id.clone();
|
let folder_id = folder.id.clone();
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
folder,
|
folder,
|
||||||
@@ -353,10 +379,12 @@ async fn handle_propfind(
|
|||||||
propfind_request,
|
propfind_request,
|
||||||
folder_service,
|
folder_service,
|
||||||
file_retrieval_service,
|
file_retrieval_service,
|
||||||
|
&user.id,
|
||||||
)
|
)
|
||||||
.await;
|
.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).await {
|
||||||
|
assert_owner(file.owner_id.as_deref(), &user.id, &path)?;
|
||||||
let mut buf = Vec::with_capacity(1024);
|
let mut buf = Vec::with_capacity(1024);
|
||||||
{
|
{
|
||||||
let mut xml_writer = Writer::new(&mut buf);
|
let mut xml_writer = Writer::new(&mut buf);
|
||||||
@@ -397,10 +425,12 @@ async fn build_streaming_propfind_response(
|
|||||||
propfind_request: PropFindRequest,
|
propfind_request: PropFindRequest,
|
||||||
folder_service: std::sync::Arc<FolderService>,
|
folder_service: std::sync::Arc<FolderService>,
|
||||||
file_retrieval_service: std::sync::Arc<FileRetrievalService>,
|
file_retrieval_service: std::sync::Arc<FileRetrievalService>,
|
||||||
|
user_id: &str,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
let depth = depth.to_string();
|
let depth = depth.to_string();
|
||||||
let base_href = base_href.to_string();
|
let base_href = base_href.to_string();
|
||||||
let propfind_request = Arc::new(propfind_request);
|
let propfind_request = Arc::new(propfind_request);
|
||||||
|
let user_id = user_id.to_string();
|
||||||
|
|
||||||
let stream = async_stream::try_stream! {
|
let stream = async_stream::try_stream! {
|
||||||
// ── XML header + <D:multistatus> + folder entry ──────────
|
// ── XML header + <D:multistatus> + folder entry ──────────
|
||||||
@@ -422,7 +452,7 @@ async fn build_streaming_propfind_response(
|
|||||||
};
|
};
|
||||||
let fid_ref = folder_id.as_deref();
|
let fid_ref = folder_id.as_deref();
|
||||||
|
|
||||||
// Stream sub-folders in pages
|
// Stream sub-folders in pages (user-scoped)
|
||||||
let mut page = 0usize;
|
let mut page = 0usize;
|
||||||
loop {
|
loop {
|
||||||
let pag = crate::application::dtos::pagination::PaginationRequestDto {
|
let pag = crate::application::dtos::pagination::PaginationRequestDto {
|
||||||
@@ -430,7 +460,7 @@ async fn build_streaming_propfind_response(
|
|||||||
page_size: pagination.page_size,
|
page_size: pagination.page_size,
|
||||||
};
|
};
|
||||||
let result = folder_service
|
let result = folder_service
|
||||||
.list_folders_paginated(fid_ref, &pag)
|
.list_folders_for_owner_paginated(fid_ref, &user_id, &pag)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
|
|
||||||
@@ -456,11 +486,11 @@ async fn build_streaming_propfind_response(
|
|||||||
page += 1;
|
page += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream files in pages
|
// Stream files in pages (user-scoped)
|
||||||
let mut offset: i64 = 0;
|
let mut offset: i64 = 0;
|
||||||
loop {
|
loop {
|
||||||
let batch: Vec<FileDto> = file_retrieval_service
|
let batch: Vec<FileDto> = file_retrieval_service
|
||||||
.list_files_batch(fid_ref, offset, PROPFIND_BATCH_SIZE)
|
.list_files_batch_for_owner(fid_ref, &user_id, offset, PROPFIND_BATCH_SIZE)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
|
|
||||||
@@ -525,10 +555,7 @@ async fn handle_proppatch(
|
|||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
let _user = req
|
let _user = extract_user(&req)?;
|
||||||
.extensions()
|
|
||||||
.get::<CurrentUser>()
|
|
||||||
.ok_or_else(|| AppError::unauthorized("Authentication required"))?;
|
|
||||||
|
|
||||||
// Read request body (XML — bounded to 1 MB)
|
// Read request body (XML — bounded to 1 MB)
|
||||||
let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY)
|
let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY)
|
||||||
@@ -581,9 +608,11 @@ async fn handle_proppatch(
|
|||||||
*/
|
*/
|
||||||
async fn handle_get(
|
async fn handle_get(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
_req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let user = extract_user(&req)?;
|
||||||
|
|
||||||
// Get file service from state
|
// Get file service from state
|
||||||
let file_retrieval_service = &state.applications.file_retrieval_service;
|
let file_retrieval_service = &state.applications.file_retrieval_service;
|
||||||
|
|
||||||
@@ -592,11 +621,26 @@ async fn handle_get(
|
|||||||
return Err(AppError::bad_request("Cannot GET a directory"));
|
return Err(AppError::bad_request("Cannot GET a directory"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file metadata
|
// Resolve file — user-scoped when PathResolver is available
|
||||||
let file = file_retrieval_service
|
let file = if let Some(resolver) = &state.path_resolver {
|
||||||
.get_file_by_path(&path)
|
match resolver.resolve_path_for_user(&path, &user.id).await {
|
||||||
.await
|
Ok(ResolvedResource::File(f)) => f,
|
||||||
.map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?;
|
Ok(ResolvedResource::Folder(_)) => {
|
||||||
|
return Err(AppError::bad_request("Cannot GET a directory"));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(AppError::not_found(format!("File not found: {}", path)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy fallback — fetch + ownership check
|
||||||
|
let f = file_retrieval_service
|
||||||
|
.get_file_by_path(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?;
|
||||||
|
assert_owner(f.owner_id.as_deref(), &user.id, &path)?;
|
||||||
|
f
|
||||||
|
};
|
||||||
|
|
||||||
// Stream file content — constant ~64 KB memory regardless of file size
|
// Stream file content — constant ~64 KB memory regardless of file size
|
||||||
let stream = file_retrieval_service
|
let stream = file_retrieval_service
|
||||||
@@ -625,9 +669,10 @@ async fn handle_get(
|
|||||||
*/
|
*/
|
||||||
async fn handle_head(
|
async fn handle_head(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
_req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let user = extract_user(&req)?;
|
||||||
let file_retrieval_service = &state.applications.file_retrieval_service;
|
let file_retrieval_service = &state.applications.file_retrieval_service;
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
|
|
||||||
@@ -641,9 +686,9 @@ async fn handle_head(
|
|||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-query path resolution
|
// Single-query path resolution (user-scoped)
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path(&path).await {
|
match resolver.resolve_path_for_user(&path, &user.id).await {
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
@@ -672,8 +717,9 @@ async fn handle_head(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: legacy double-query path
|
// Fallback: legacy double-query path (with ownership check)
|
||||||
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
|
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
|
||||||
|
assert_owner(folder.owner_id.as_deref(), &user.id, &path)?;
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "httpd/unix-directory")
|
.header(header::CONTENT_TYPE, "httpd/unix-directory")
|
||||||
@@ -688,6 +734,7 @@ async fn handle_head(
|
|||||||
.get_file_by_path(&path)
|
.get_file_by_path(&path)
|
||||||
.await
|
.await
|
||||||
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?;
|
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||||
|
assert_owner(file.owner_id.as_deref(), &user.id, &path)?;
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
@@ -726,6 +773,8 @@ async fn handle_put(
|
|||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
let user = extract_user(&req)?;
|
||||||
|
|
||||||
// Get file service from state
|
// Get file service from state
|
||||||
let file_upload_service = &state.applications.file_upload_service;
|
let file_upload_service = &state.applications.file_upload_service;
|
||||||
|
|
||||||
@@ -734,6 +783,40 @@ async fn handle_put(
|
|||||||
return Err(AppError::bad_request("Cannot PUT to root folder"));
|
return Err(AppError::bad_request("Cannot PUT to root folder"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ownership guard ────────────────────────────────────────
|
||||||
|
// Verify that the user owns the target file (update) or the
|
||||||
|
// parent folder (create). Without this check a user could
|
||||||
|
// overwrite another user's file via a crafted PUT path.
|
||||||
|
if let Some(resolver) = &state.path_resolver {
|
||||||
|
match resolver.resolve_path_for_user(&path, &user.id).await {
|
||||||
|
Ok(ResolvedResource::File(_)) => { /* existing file owned by user — OK */ }
|
||||||
|
Ok(ResolvedResource::Folder(_)) => {
|
||||||
|
return Err(AppError::bad_request("Cannot PUT to a directory"));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// File doesn't exist yet — verify parent folder ownership
|
||||||
|
let parent_path = if let Some(idx) = path.rfind('/') {
|
||||||
|
&path[..idx]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if !parent_path.is_empty() {
|
||||||
|
resolver
|
||||||
|
.resolve_path_for_user(parent_path, &user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::not_found(format!("Parent folder not found: {}", parent_path))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
// root-level PUT is allowed (parent_path empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// (legacy path without resolver: update_file_streaming will create
|
||||||
|
// under the folder with the resolved path, which may belong to
|
||||||
|
// another user — acceptable risk since PathResolver should always
|
||||||
|
// be enabled in production)
|
||||||
|
|
||||||
// Hard upload size limit from config
|
// Hard upload size limit from config
|
||||||
let max_upload = state.core.config.storage.max_upload_size;
|
let max_upload = state.core.config.storage.max_upload_size;
|
||||||
|
|
||||||
@@ -826,6 +909,8 @@ async fn handle_mkcol(
|
|||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let user = extract_user(&req)?;
|
||||||
|
|
||||||
// Get folder service from state
|
// Get folder service from state
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
|
|
||||||
@@ -861,18 +946,34 @@ async fn handle_mkcol(
|
|||||||
""
|
""
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create folder
|
// ── Resolve parent folder (user-scoped) ────────────────────
|
||||||
|
let parent_id = if parent_path.is_empty() {
|
||||||
|
None
|
||||||
|
} else if let Some(resolver) = &state.path_resolver {
|
||||||
|
match resolver.resolve_path_for_user(parent_path, &user.id).await {
|
||||||
|
Ok(ResolvedResource::Folder(parent)) => Some(parent.id),
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::not_found(format!(
|
||||||
|
"Parent folder not found: {}",
|
||||||
|
parent_path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy fallback — ownership check
|
||||||
|
match folder_service.get_folder_by_path(parent_path).await {
|
||||||
|
Ok(parent) => {
|
||||||
|
assert_owner(parent.owner_id.as_deref(), &user.id, parent_path)?;
|
||||||
|
Some(parent.id)
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create folder (user_id is inherited from the parent in the DB layer)
|
||||||
let create_dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
let create_dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
||||||
name: folder_name.to_string(),
|
name: folder_name.to_string(),
|
||||||
parent_id: if parent_path.is_empty() {
|
parent_id,
|
||||||
None
|
|
||||||
} else {
|
|
||||||
// Try to get the parent folder ID from its path
|
|
||||||
match folder_service.get_folder_by_path(parent_path).await {
|
|
||||||
Ok(parent) => Some(parent.id),
|
|
||||||
Err(_) => None, // If not found, use root
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
folder_service
|
folder_service
|
||||||
@@ -898,9 +999,11 @@ async fn handle_mkcol(
|
|||||||
*/
|
*/
|
||||||
async fn handle_delete(
|
async fn handle_delete(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
_req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let user = extract_user(&req)?;
|
||||||
|
|
||||||
// Get services from state
|
// Get services from state
|
||||||
let file_retrieval_service = &state.applications.file_retrieval_service;
|
let file_retrieval_service = &state.applications.file_retrieval_service;
|
||||||
let file_management_service = &state.applications.file_management_service;
|
let file_management_service = &state.applications.file_management_service;
|
||||||
@@ -911,13 +1014,12 @@ async fn handle_delete(
|
|||||||
return Err(AppError::forbidden("Cannot delete root folder"));
|
return Err(AppError::forbidden("Cannot delete root folder"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-query path resolution
|
// Single-query path resolution (user-scoped)
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path(&path).await {
|
match resolver.resolve_path_for_user(&path, &user.id).await {
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
let caller_id = folder.owner_id.as_deref().unwrap_or("webdav");
|
|
||||||
folder_service
|
folder_service
|
||||||
.delete_folder(&folder.id, caller_id)
|
.delete_folder(&folder.id, &user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::internal_error(format!("Failed to delete folder: {}", e))
|
AppError::internal_error(format!("Failed to delete folder: {}", e))
|
||||||
@@ -934,13 +1036,13 @@ async fn handle_delete(
|
|||||||
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))),
|
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: legacy double-query path
|
// Fallback: legacy double-query path (with ownership check)
|
||||||
let folder_result = folder_service.get_folder_by_path(&path).await;
|
let folder_result = folder_service.get_folder_by_path(&path).await;
|
||||||
|
|
||||||
if let Ok(folder) = folder_result {
|
if let Ok(folder) = folder_result {
|
||||||
let caller_id = folder.owner_id.as_deref().unwrap_or("webdav");
|
assert_owner(folder.owner_id.as_deref(), &user.id, &path)?;
|
||||||
folder_service
|
folder_service
|
||||||
.delete_folder(&folder.id, caller_id)
|
.delete_folder(&folder.id, &user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
||||||
} else {
|
} else {
|
||||||
@@ -948,6 +1050,7 @@ async fn handle_delete(
|
|||||||
.get_file_by_path(&path)
|
.get_file_by_path(&path)
|
||||||
.await
|
.await
|
||||||
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?;
|
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||||
|
assert_owner(file.owner_id.as_deref(), &user.id, &path)?;
|
||||||
|
|
||||||
file_management_service
|
file_management_service
|
||||||
.delete_file(&file.id)
|
.delete_file(&file.id)
|
||||||
@@ -978,6 +1081,7 @@ async fn handle_move(
|
|||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let user = extract_user(&req)?;
|
||||||
let source_path = path;
|
let source_path = path;
|
||||||
|
|
||||||
// Get destination from Destination header
|
// Get destination from Destination header
|
||||||
@@ -1013,7 +1117,10 @@ async fn handle_move(
|
|||||||
// Check if destination already exists (for Overwrite header compliance)
|
// Check if destination already exists (for Overwrite header compliance)
|
||||||
if !overwrite {
|
if !overwrite {
|
||||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||||
resolver.exists(&destination_path).await.unwrap_or(false)
|
resolver
|
||||||
|
.exists_for_user(&destination_path, &user.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
folder_service
|
folder_service
|
||||||
.get_folder_by_path(&destination_path)
|
.get_folder_by_path(&destination_path)
|
||||||
@@ -1031,9 +1138,12 @@ async fn handle_move(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve source: single-query when PathResolver is available
|
// Resolve source: single-query when PathResolver is available (user-scoped)
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path(&source_path).await {
|
match resolver
|
||||||
|
.resolve_path_for_user(&source_path, &user.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
let dest_folder_name = destination_path
|
let dest_folder_name = destination_path
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -1057,11 +1167,7 @@ async fn handle_move(
|
|||||||
};
|
};
|
||||||
|
|
||||||
folder_service
|
folder_service
|
||||||
.move_folder(
|
.move_folder(&folder.id, move_dto, &user.id)
|
||||||
&folder.id,
|
|
||||||
move_dto,
|
|
||||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::internal_error(format!("Failed to move folder: {}", e))
|
AppError::internal_error(format!("Failed to move folder: {}", e))
|
||||||
@@ -1072,11 +1178,7 @@ async fn handle_move(
|
|||||||
name: dest_folder_name.to_string(),
|
name: dest_folder_name.to_string(),
|
||||||
};
|
};
|
||||||
folder_service
|
folder_service
|
||||||
.rename_folder(
|
.rename_folder(&folder.id, rename_dto, &user.id)
|
||||||
&folder.id,
|
|
||||||
rename_dto,
|
|
||||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::internal_error(format!("Failed to rename folder: {}", e))
|
AppError::internal_error(format!("Failed to rename folder: {}", e))
|
||||||
@@ -1124,10 +1226,11 @@ async fn handle_move(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: legacy double-query path
|
// Fallback: legacy double-query path (with ownership check)
|
||||||
let folder_result = folder_service.get_folder_by_path(&source_path).await;
|
let folder_result = folder_service.get_folder_by_path(&source_path).await;
|
||||||
|
|
||||||
if let Ok(folder) = folder_result {
|
if let Ok(folder) = folder_result {
|
||||||
|
assert_owner(folder.owner_id.as_deref(), &user.id, &source_path)?;
|
||||||
let dest_folder_name = destination_path
|
let dest_folder_name = destination_path
|
||||||
.split('/')
|
.split('/')
|
||||||
.next_back()
|
.next_back()
|
||||||
@@ -1150,11 +1253,7 @@ async fn handle_move(
|
|||||||
};
|
};
|
||||||
|
|
||||||
folder_service
|
folder_service
|
||||||
.move_folder(
|
.move_folder(&folder.id, move_dto, &user.id)
|
||||||
&folder.id,
|
|
||||||
move_dto,
|
|
||||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
|
||||||
|
|
||||||
@@ -1163,11 +1262,7 @@ async fn handle_move(
|
|||||||
name: dest_folder_name.to_string(),
|
name: dest_folder_name.to_string(),
|
||||||
};
|
};
|
||||||
folder_service
|
folder_service
|
||||||
.rename_folder(
|
.rename_folder(&folder.id, rename_dto, &user.id)
|
||||||
&folder.id,
|
|
||||||
rename_dto,
|
|
||||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::internal_error(format!("Failed to rename folder: {}", e))
|
AppError::internal_error(format!("Failed to rename folder: {}", e))
|
||||||
@@ -1180,6 +1275,7 @@ async fn handle_move(
|
|||||||
.map_err(|_e| {
|
.map_err(|_e| {
|
||||||
AppError::not_found(format!("Resource not found: {}", source_path))
|
AppError::not_found(format!("Resource not found: {}", source_path))
|
||||||
})?;
|
})?;
|
||||||
|
assert_owner(file.owner_id.as_deref(), &user.id, &source_path)?;
|
||||||
|
|
||||||
let dest_filename = destination_path
|
let dest_filename = destination_path
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -1235,6 +1331,7 @@ async fn handle_copy(
|
|||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let user = extract_user(&req)?;
|
||||||
let source_path = path;
|
let source_path = path;
|
||||||
|
|
||||||
// Get destination from Destination header
|
// Get destination from Destination header
|
||||||
@@ -1276,7 +1373,10 @@ async fn handle_copy(
|
|||||||
// Check if destination already exists (for Overwrite header compliance)
|
// Check if destination already exists (for Overwrite header compliance)
|
||||||
if !overwrite {
|
if !overwrite {
|
||||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||||
resolver.exists(&destination_path).await.unwrap_or(false)
|
resolver
|
||||||
|
.exists_for_user(&destination_path, &user.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
folder_service
|
folder_service
|
||||||
.get_folder_by_path(&destination_path)
|
.get_folder_by_path(&destination_path)
|
||||||
@@ -1294,9 +1394,12 @@ async fn handle_copy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve source: single-query when PathResolver is available
|
// Resolve source: single-query when PathResolver is available (user-scoped)
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver.resolve_path(&source_path).await {
|
match resolver
|
||||||
|
.resolve_path_for_user(&source_path, &user.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
let recursive = depth != "0";
|
let recursive = depth != "0";
|
||||||
|
|
||||||
@@ -1377,10 +1480,11 @@ async fn handle_copy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: legacy double-query path
|
// Fallback: legacy double-query path (with ownership check)
|
||||||
let folder_result = folder_service.get_folder_by_path(&source_path).await;
|
let folder_result = folder_service.get_folder_by_path(&source_path).await;
|
||||||
|
|
||||||
if let Ok(folder) = folder_result {
|
if let Ok(folder) = folder_result {
|
||||||
|
assert_owner(folder.owner_id.as_deref(), &user.id, &source_path)?;
|
||||||
let recursive = depth != "0";
|
let recursive = depth != "0";
|
||||||
|
|
||||||
let dest_folder_name = destination_path
|
let dest_folder_name = destination_path
|
||||||
@@ -1436,6 +1540,7 @@ async fn handle_copy(
|
|||||||
.map_err(|_e| {
|
.map_err(|_e| {
|
||||||
AppError::not_found(format!("Resource not found: {}", source_path))
|
AppError::not_found(format!("Resource not found: {}", source_path))
|
||||||
})?;
|
})?;
|
||||||
|
assert_owner(file.owner_id.as_deref(), &user.id, &source_path)?;
|
||||||
|
|
||||||
let dest_parent_path = if let Some(idx) = destination_path.rfind('/') {
|
let dest_parent_path = if let Some(idx) = destination_path.rfind('/') {
|
||||||
&destination_path[..idx]
|
&destination_path[..idx]
|
||||||
@@ -1483,13 +1588,7 @@ async fn handle_lock(
|
|||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
let user = {
|
let user = extract_user(&req)?;
|
||||||
let user_ref = req
|
|
||||||
.extensions()
|
|
||||||
.get::<CurrentUser>()
|
|
||||||
.ok_or_else(|| AppError::unauthorized("Authentication required"))?;
|
|
||||||
user_ref.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the headers that we need
|
// Get the headers that we need
|
||||||
let depth = req
|
let depth = req
|
||||||
@@ -1611,13 +1710,7 @@ async fn handle_unlock(
|
|||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
_path: String,
|
_path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> Result<Response<Body>, AppError> {
|
||||||
let _user = {
|
let _user = extract_user(&req)?;
|
||||||
let user_ref = req
|
|
||||||
.extensions()
|
|
||||||
.get::<CurrentUser>()
|
|
||||||
.ok_or_else(|| AppError::unauthorized("Authentication required"))?;
|
|
||||||
user_ref.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get lock token from Lock-Token header
|
// Get lock token from Lock-Token header
|
||||||
let lock_token = req
|
let lock_token = req
|
||||||
|
|||||||
Reference in New Issue
Block a user