Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/application/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod nextcloud_file_id_service;
pub mod nextcloud_login_flow_service;
pub mod recent_service;
pub mod search_service;
pub mod share_browse_service;
pub mod share_service;
pub mod storage_settings_service;
pub mod storage_usage_service;
Expand Down
191 changes: 191 additions & 0 deletions src/application/services/share_browse_service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//! Share-scoped folder browsing for public folder shares.

use std::sync::Arc;

use uuid::Uuid;

use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::inbound::FolderUseCase;
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
use crate::application::services::share_service::ShareService;
use crate::common::errors::DomainError;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;

struct ResolvedFolderShare {
root_folder_id: String,
owner_id: Uuid,
display_name: String,
}

pub struct ZipTarget {
pub folder_id: String,
pub display_name: String,
}

pub struct ShareBrowseService {
share_service: Arc<ShareService>,
folder_service: Arc<FolderService>,
file_retrieval: Arc<FileRetrievalService>,
folder_repo: Arc<FolderDbRepository>,
}

impl ShareBrowseService {
pub fn new(
share_service: Arc<ShareService>,
folder_service: Arc<FolderService>,
file_retrieval: Arc<FileRetrievalService>,
folder_repo: Arc<FolderDbRepository>,
) -> Self {
Self {
share_service,
folder_service,
file_retrieval,
folder_repo,
}
}

async fn resolve_folder_share(
&self,
token: &str,
unlock_jwt: Option<&str>,
) -> Result<ResolvedFolderShare, DomainError> {
let share = self
.share_service
.get_shared_link_with_unlock(token, unlock_jwt)
.await?;

if share.item_type != "folder" {
return Err(DomainError::validation_error(
"This endpoint is only valid for folder shares",
));
}

let owner_id = Uuid::parse_str(&share.created_by).map_err(|_| {
DomainError::internal_error(
"Share",
format!("Share has invalid created_by UUID: {}", share.created_by),
)
})?;

let display_name = match share.item_name {
Some(name) => name,
None => self
.folder_service
.get_folder(&share.item_id)
.await
.map(|f| f.name)
.unwrap_or_else(|_| "Shared folder".to_string()),
};

Ok(ResolvedFolderShare {
root_folder_id: share.item_id,
owner_id,
display_name,
})
}

pub async fn list_root(
&self,
token: &str,
unlock_jwt: Option<&str>,
) -> Result<FolderListingDto, DomainError> {
let resolved = self.resolve_folder_share(token, unlock_jwt).await?;
self.list_inner(&resolved.root_folder_id, resolved.owner_id)
.await
}

pub async fn list_subfolder(
&self,
token: &str,
folder_id: &str,
unlock_jwt: Option<&str>,
) -> Result<FolderListingDto, DomainError> {
let resolved = self.resolve_folder_share(token, unlock_jwt).await?;

if !self
.folder_repo
.is_folder_in_subtree(folder_id, &resolved.root_folder_id)
.await?
{
return Err(DomainError::not_found("Folder", folder_id));
}

self.list_inner(folder_id, resolved.owner_id).await
}

pub async fn assert_file_in_share(
&self,
token: &str,
file_id: &str,
unlock_jwt: Option<&str>,
) -> Result<(), DomainError> {
let resolved = self.resolve_folder_share(token, unlock_jwt).await?;

if !self
.folder_repo
.is_file_in_subtree(file_id, &resolved.root_folder_id)
.await?
{
return Err(DomainError::not_found("File", file_id));
}
Ok(())
}

pub async fn resolve_zip_target(
&self,
token: &str,
folder_id: Option<&str>,
unlock_jwt: Option<&str>,
) -> Result<ZipTarget, DomainError> {
let resolved = self.resolve_folder_share(token, unlock_jwt).await?;

let target_folder_id = match folder_id {
None => resolved.root_folder_id.clone(),
Some(id) => {
if !self
.folder_repo
.is_folder_in_subtree(id, &resolved.root_folder_id)
.await?
{
return Err(DomainError::not_found("Folder", id));
}
id.to_string()
}
};

let display_name = if folder_id.is_none() {
resolved.display_name
} else {
self.folder_service
.get_folder(&target_folder_id)
.await
.map(|f| f.name)
.unwrap_or_else(|_| "shared".to_string())
};

Ok(ZipTarget {
folder_id: target_folder_id,
display_name,
})
}

async fn list_inner(
&self,
parent_folder_id: &str,
owner_id: Uuid,
) -> Result<FolderListingDto, DomainError> {
let (folders_res, files_res) = tokio::join!(
self.folder_service
.list_folders_for_owner(Some(parent_folder_id), owner_id),
self.file_retrieval
.list_files_owned(Some(parent_folder_id), owner_id),
);
Ok(FolderListingDto {
folders: folders_res?,
files: files_res?,
})
}
}
12 changes: 12 additions & 0 deletions src/common/di.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use crate::application::services::nextcloud_file_id_service::NextcloudFileIdServ
use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService;
use crate::application::services::recent_service::RecentService;
use crate::application::services::search_service::SearchService;
use crate::application::services::share_browse_service::ShareBrowseService;
use crate::application::services::share_service::ShareService;
use crate::application::services::trash_service::TrashService;
use crate::application::services::{
Expand Down Expand Up @@ -602,6 +603,15 @@ impl AppServiceFactory {
let share_service = self.create_share_service(&repos, &pool);
apps.share_service = share_service.clone();

let share_browse_service = share_service.as_ref().map(|s| {
Arc::new(ShareBrowseService::new(
s.clone(),
apps.folder_service.clone(),
apps.file_retrieval_service.clone(),
repos.folder_repository.clone(),
))
});

// 6. Database-dependent services (PgPool always available in blob model)
let favorites_service: Option<Arc<FavoritesService>>;
let recent_service: Option<Arc<RecentService>>;
Expand Down Expand Up @@ -739,6 +749,7 @@ impl AppServiceFactory {
migration_state: Arc::new(tokio::sync::RwLock::new(MigrationState::default())),
trash_service,
share_service,
share_browse_service,
favorites_service,
recent_service,
storage_usage_service,
Expand Down Expand Up @@ -1047,6 +1058,7 @@ pub struct AppState {
pub migration_state: Arc<tokio::sync::RwLock<MigrationState>>,
pub trash_service: Option<Arc<TrashService>>,
pub share_service: Option<Arc<ShareService>>,
pub share_browse_service: Option<Arc<ShareBrowseService>>,
pub favorites_service: Option<Arc<FavoritesService>>,
pub recent_service: Option<Arc<RecentService>>,
pub storage_usage_service: Option<Arc<StorageUsageService>>,
Expand Down
23 changes: 23 additions & 0 deletions src/domain/repositories/folder_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,27 @@ pub trait FolderRepository: Send + Sync + 'static {
matched.truncate(limit);
Ok(matched)
}

/// `true` if `candidate_folder_id` is `root_folder_id` itself or any
/// (transitive) descendant. Default impl fails closed so stubs deny
/// access by default.
async fn is_folder_in_subtree(
&self,
candidate_folder_id: &str,
root_folder_id: &str,
) -> Result<bool, DomainError> {
let _ = (candidate_folder_id, root_folder_id);
Ok(false)
}

/// `true` if `file_id`'s parent folder lies within the subtree rooted
/// at `root_folder_id`.
async fn is_file_in_subtree(
&self,
file_id: &str,
root_folder_id: &str,
) -> Result<bool, DomainError> {
let _ = (file_id, root_folder_id);
Ok(false)
}
}
52 changes: 52 additions & 0 deletions src/infrastructure/repositories/pg/folder_db_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,58 @@ impl FolderRepository for FolderDbRepository {
})
.collect()
}

async fn is_folder_in_subtree(
&self,
candidate_folder_id: &str,
root_folder_id: &str,
) -> Result<bool, DomainError> {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (\
SELECT 1 \
FROM storage.folders c, storage.folders r \
WHERE c.id = $1::uuid \
AND r.id = $2::uuid \
AND c.is_trashed = false \
AND r.is_trashed = false \
AND c.lpath <@ r.lpath \
)",
)
.bind(candidate_folder_id)
.bind(root_folder_id)
.fetch_one(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("is_folder_in_subtree: {e}"))
})?;
Ok(exists)
}

async fn is_file_in_subtree(
&self,
file_id: &str,
root_folder_id: &str,
) -> Result<bool, DomainError> {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (\
SELECT 1 \
FROM storage.files f \
JOIN storage.folders parent ON f.folder_id = parent.id \
JOIN storage.folders root ON root.id = $2::uuid \
WHERE f.id = $1::uuid \
AND f.is_trashed = false \
AND parent.is_trashed = false \
AND root.is_trashed = false \
AND parent.lpath <@ root.lpath \
)",
)
.bind(file_id)
.bind(root_folder_id)
.fetch_one(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("is_file_in_subtree: {e}")))?;
Ok(exists)
}
}

// ── Extra helpers for blob-storage bootstrap ──
Expand Down
Loading
Loading