Files
Oxicloud/src/application/services/share_service.rs
T

1184 lines
40 KiB
Rust
Raw Normal View History

2025-03-28 12:38:48 +01:00
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::Semaphore;
use uuid::Uuid;
2025-03-28 12:38:48 +01:00
use crate::domain::repositories::folder_repository::FolderRepository;
2026-06-18 01:42:32 +02:00
use crate::domain::services::authorization::{Resource, Role, Subject};
use crate::infrastructure::repositories::pg::SharePgRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
2025-03-28 12:38:48 +01:00
use crate::{
application::{
dtos::{
pagination::PaginatedResponseDto,
share_dto::{CreateShareDto, ShareDto, UpdateShareDto},
2025-03-28 12:38:48 +01:00
},
ports::{
auth_ports::PasswordHasherPort,
authorization_ports::AuthorizationEngine,
2025-03-28 12:38:48 +01:00
share_ports::{ShareStoragePort, ShareUseCase},
storage_ports::FileReadPort,
2025-03-28 12:38:48 +01:00
},
},
2026-03-05 21:28:51 +01:00
common::{
config::AppConfig,
errors::{DomainError, ErrorKind},
},
domain::entities::share::{Share, ShareItemType},
2025-03-28 12:38:48 +01:00
};
#[derive(Debug, Error)]
pub enum ShareServiceError {
#[error("Share not found: {0}")]
NotFound(String),
#[error("Item not found: {0}")]
ItemNotFound(String),
#[error("Access denied: {0}")]
AccessDenied(String),
#[error("Invalid password: {0}")]
InvalidPassword(String),
#[error("Share expired")]
Expired,
#[error("Repository error: {0}")]
Repository(String),
#[error("Invalid item type: {0}")]
InvalidItemType(String),
#[error("Validation error: {0}")]
Validation(String),
}
impl From<ShareServiceError> for DomainError {
fn from(error: ShareServiceError) -> Self {
match error {
ShareServiceError::NotFound(s) => DomainError::not_found("Share", s),
ShareServiceError::ItemNotFound(s) => DomainError::not_found("Item", s),
ShareServiceError::AccessDenied(s) => DomainError::access_denied("Share", s),
ShareServiceError::InvalidPassword(s) => DomainError::access_denied("Share", s),
2026-02-14 01:29:34 +01:00
ShareServiceError::Expired => {
DomainError::access_denied("Share", "Share has expired".to_string())
}
2025-03-28 12:38:48 +01:00
ShareServiceError::Repository(s) => DomainError::internal_error("Share", s),
2025-04-13 01:04:04 +02:00
ShareServiceError::InvalidItemType(s) => DomainError::validation_error(s),
ShareServiceError::Validation(s) => DomainError::validation_error(s),
2025-03-28 12:38:48 +01:00
}
}
}
/// Maximum number of concurrent Argon2 hashing operations.
///
/// Each Argon2id hash consumes ~19 MB of RAM and ~300 ms of CPU.
/// Limiting concurrency prevents RAM exhaustion and thread-pool saturation
/// under burst traffic (e.g. many share-creation requests with passwords).
const MAX_CONCURRENT_HASHES: usize = 2;
2025-03-28 12:38:48 +01:00
pub struct ShareService {
config: Arc<AppConfig>,
share_repository: Arc<SharePgRepository>,
file_repository: Arc<FileBlobReadRepository>,
folder_repository: Arc<FolderDbRepository>,
password_hasher: Arc<Argon2PasswordHasher>,
/// ReBAC engine — used to create/revoke token grants that mirror public
/// share links so that `GET /api/grants/outgoing` reflects them.
authorization: Arc<PgAclEngine>,
/// Bounds the number of in-flight Argon2 password hashes to avoid
/// saturating the blocking thread pool and consuming excessive RAM.
hash_semaphore: Arc<Semaphore>,
2025-03-28 12:38:48 +01:00
}
impl ShareService {
pub fn new(
config: Arc<AppConfig>,
share_repository: Arc<SharePgRepository>,
file_repository: Arc<FileBlobReadRepository>,
folder_repository: Arc<FolderDbRepository>,
password_hasher: Arc<Argon2PasswordHasher>,
authorization: Arc<PgAclEngine>,
2025-03-28 12:38:48 +01:00
) -> Self {
Self {
config,
share_repository,
file_repository,
folder_repository,
password_hasher,
authorization,
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
2025-03-28 12:38:48 +01:00
}
}
/// Verifies that the item to share exists
2025-03-28 12:38:48 +01:00
async fn verify_item_exists(
&self,
item_id: &str,
item_type: &ShareItemType,
) -> Result<(), ShareServiceError> {
match item_type {
ShareItemType::File => {
self.file_repository
.get_file(item_id) // Using the correct method from the FileStoragePort trait
2025-03-28 12:38:48 +01:00
.await
2026-02-14 01:29:34 +01:00
.map_err(|_| {
ShareServiceError::ItemNotFound(format!(
"File with ID {} not found",
item_id
))
})?;
2025-03-28 12:38:48 +01:00
}
ShareItemType::Folder => {
self.folder_repository
.get_folder(item_id) // Using the correct method from the FolderStoragePort trait
2025-03-28 12:38:48 +01:00
.await
2026-02-14 01:29:34 +01:00
.map_err(|_| {
ShareServiceError::ItemNotFound(format!(
"Folder with ID {} not found",
item_id
))
})?;
2025-03-28 12:38:48 +01:00
}
}
Ok(())
}
/// Hash a password via the injected `PasswordHasherPort`, bounded by a
/// semaphore so at most `MAX_CONCURRENT_HASHES` Argon2 operations run
/// concurrently. This keeps RAM usage predictable (~19 MB × 2 = ~38 MB max)
/// and avoids starving the Tokio blocking thread pool.
async fn hash_password_async(&self, password: &str) -> Result<String, DomainError> {
let _permit = self.hash_semaphore.acquire().await.map_err(|_| {
DomainError::internal_error("ShareService", "Hash semaphore closed".to_string())
})?;
self.password_hasher.hash_password(password).await
2025-03-28 12:38:48 +01:00
}
/// Fetch a share and verify that `requester_id` owns it.
///
/// SECURITY: returns `NotFound` (not `Forbidden`) when the share exists
/// but belongs to a different user — this prevents share-ID enumeration
/// attacks where an attacker probes IDs and uses 403-vs-404 to learn
/// which ones are valid.
async fn fetch_owned_share(&self, id: Uuid, requester_id: Uuid) -> Result<Share, DomainError> {
let share = self
.share_repository
.find_share_by_id_for_user(id, requester_id)
.await?;
Ok(share)
}
/// `allow_password_protected = true` only after the caller's right to
/// bypass has been verified (e.g. via an unlock cookie).
async fn fetch_share_resolved(
&self,
token: &str,
allow_password_protected: bool,
) -> Result<ShareDto, DomainError> {
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
if share.has_password() && !allow_password_protected {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Share",
"This share is password protected",
));
}
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
pub fn issue_unlock_jwt(&self, share_token: &str) -> Result<String, DomainError> {
crate::infrastructure::services::share_unlock_cookie::issue_jwt(
&self.config.auth.jwt_secret,
share_token,
crate::infrastructure::services::share_unlock_cookie::DEFAULT_TTL_SECS,
)
}
pub async fn get_shared_link_with_unlock(
&self,
token: &str,
unlock_jwt: Option<&str>,
) -> Result<ShareDto, DomainError> {
let unlocked = match unlock_jwt {
Some(jwt) => crate::infrastructure::services::share_unlock_cookie::verify_jwt(
&self.config.auth.jwt_secret,
token,
jwt,
),
None => false,
};
self.fetch_share_resolved(token, unlocked).await
}
2025-03-28 12:38:48 +01:00
}
impl ShareUseCase for ShareService {
async fn create_shared_link(
&self,
user_id: Uuid,
2025-03-28 12:38:48 +01:00
dto: CreateShareDto,
) -> Result<ShareDto, DomainError> {
let item_type = ShareItemType::try_from(dto.item_type.as_str())
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
self.verify_item_exists(&dto.item_id, &item_type).await?;
let password_hash = match dto.password {
Some(p) => Some(self.hash_password_async(&p).await?),
None => None,
};
2025-03-28 12:38:48 +01:00
let share = Share::new(
dto.item_id.clone(),
dto.item_name.clone(),
2025-03-28 12:38:48 +01:00
item_type,
user_id,
2025-03-28 12:38:48 +01:00
password_hash,
)
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
let saved_share = self
.share_repository
.save_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
2026-06-18 01:42:32 +02:00
// Anonymous link tokens always get the Viewer role (read-only).
// The `trg_cleanup_grants_token` trigger cleans up this grant when
// the share row is later deleted.
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
let resource = match saved_share.item_type() {
ShareItemType::File => Resource::File(item_id_uuid),
ShareItemType::Folder => Resource::Folder(item_id_uuid),
};
let expires_dt = dto
.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0));
self.authorization
2026-06-18 01:42:32 +02:00
.set_role(
user_id,
Subject::Token(saved_share.id()),
2026-06-18 01:42:32 +02:00
Role::Viewer,
resource,
expires_dt,
)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Return DTO with the requested expires_at (grant subquery on the share
// row would return NULL at this point since INSERT ran before the grant).
let mut response = ShareDto::from_entity(&saved_share, &self.config.base_url());
response.expires_at = dto.expires_at;
Ok(response)
2025-03-28 12:38:48 +01:00
}
async fn get_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<ShareDto, DomainError> {
// SECURITY: ownership-verified lookup — returns 404 if the share
// doesn't exist OR belongs to another user.
let share = self.fetch_owned_share(id, requester_id).await?;
2025-03-28 12:38:48 +01:00
// Check if it has expired
2025-03-28 12:38:48 +01:00
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
// Convert the entity to DTO for the response
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
2025-03-28 12:38:48 +01:00
}
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
self.fetch_share_resolved(token, false).await
2025-03-28 12:38:48 +01:00
}
async fn get_shared_links_for_item(
&self,
item_id: &str,
item_type: &ShareItemType,
requester_id: Uuid,
2025-03-28 12:38:48 +01:00
) -> Result<Vec<ShareDto>, DomainError> {
// SECURITY: only return shares created by the requester
2025-03-28 12:38:48 +01:00
let shares = self
.share_repository
.find_shares_by_item_for_user(item_id, item_type, requester_id)
2025-03-28 12:38:48 +01:00
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Filter out expired links
2025-03-28 12:38:48 +01:00
let active_shares: Vec<Share> = shares.into_iter().filter(|s| !s.is_expired()).collect();
// Convert the entities to DTOs for the response
2025-03-28 12:38:48 +01:00
let share_dtos = active_shares
.iter()
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
2025-03-28 12:38:48 +01:00
.collect();
Ok(share_dtos)
}
async fn update_shared_link(
&self,
id: Uuid,
requester_id: Uuid,
2025-03-28 12:38:48 +01:00
dto: UpdateShareDto,
) -> Result<ShareDto, DomainError> {
// SECURITY: ownership-verified lookup — prevents IDOR
let mut share = self.fetch_owned_share(id, requester_id).await?;
2025-03-28 12:38:48 +01:00
// Update password if provided (async, semaphore-bounded)
2025-03-28 12:38:48 +01:00
if let Some(password) = dto.password {
let password_hash = if password.is_empty() {
None
} else {
Some(self.hash_password_async(&password).await?)
2025-03-28 12:38:48 +01:00
};
share = share.with_password(password_hash);
}
// Expiry is managed at the grant level; update all grants for this token.
let new_expires_at = if dto.expires_at.is_some() {
dto.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0))
} else {
None
};
2025-03-28 12:38:48 +01:00
if dto.expires_at.is_some() {
self.authorization
.set_expiry_for_subject(Subject::Token(share.id()), new_expires_at)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
2025-03-28 12:38:48 +01:00
}
let updated_share = self
.share_repository
.update_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Use the requested expires_at for the response (subquery in update_share
// runs before set_expiry_for_subject committed, so entity may lag).
let mut response = ShareDto::from_entity(&updated_share, &self.config.base_url());
if dto.expires_at.is_some() {
response.expires_at = dto.expires_at;
}
Ok(response)
2025-03-28 12:38:48 +01:00
}
async fn delete_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<(), DomainError> {
// SECURITY: ownership-verified delete — only the creator can remove
2025-03-28 12:38:48 +01:00
self.share_repository
.delete_share_for_user(id, requester_id)
.await?;
2025-03-28 12:38:48 +01:00
Ok(())
}
async fn get_user_shared_links(
&self,
user_id: Uuid,
2025-03-28 12:38:48 +01:00
page: usize,
per_page: usize,
) -> Result<PaginatedResponseDto<ShareDto>, DomainError> {
// Calculate offset for pagination
2025-03-28 12:38:48 +01:00
let offset = (page - 1) * per_page;
// Find the user's shared links
2025-03-28 12:38:48 +01:00
let (shares, total) = self
.share_repository
.find_shares_by_user(user_id, offset, per_page)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Convert the entities to DTOs
2025-03-28 12:38:48 +01:00
let share_dtos: Vec<ShareDto> = shares
.iter()
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
2025-03-28 12:38:48 +01:00
.collect();
// Create the paginated result
2026-02-14 01:29:34 +01:00
let paginated = PaginatedResponseDto::new(share_dtos, page, per_page, total);
2025-03-28 12:38:48 +01:00
Ok(paginated)
}
async fn verify_shared_link_password(
&self,
token: &str,
password: &str,
) -> Result<ShareDto, DomainError> {
// Find the shared link by its token
2025-03-28 12:38:48 +01:00
let share = self
.share_repository
.find_share_by_token(token)
.await
2026-02-14 01:29:34 +01:00
.map_err(|e| {
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
})?;
2025-03-28 12:38:48 +01:00
// Check if it has expired
2025-03-28 12:38:48 +01:00
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
// Verify the password using the infrastructure port
match share.password_hash() {
Some(hash) => {
let is_valid = self.password_hasher.verify_password(password, hash).await?;
if !is_valid {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Share",
"Invalid share password",
));
}
}
None => { /* No password required — allow access */ }
}
// Password verified (or not required) — return full share metadata
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
2025-03-28 12:38:48 +01:00
}
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
// Find the shared link by its token
2025-03-28 12:38:48 +01:00
let share = self
.share_repository
.find_share_by_token(token)
.await
2026-02-14 01:29:34 +01:00
.map_err(|e| {
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
})?;
2025-03-28 12:38:48 +01:00
// Check if it has expired
2025-03-28 12:38:48 +01:00
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
// Increment the access counter
2025-03-28 12:38:48 +01:00
let updated_share = share.increment_access_count();
// Save the changes
2025-03-28 12:38:48 +01:00
self.share_repository
.update_share(&updated_share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(())
}
}
#[cfg(feature = "integration_tests")]
#[allow(dead_code)]
2025-03-28 12:38:48 +01:00
mod tests {
use super::*;
2026-02-14 01:29:34 +01:00
use crate::application::ports::auth_ports::PasswordHasherPort;
use crate::application::ports::share_ports::ShareStoragePort;
use crate::application::ports::storage_ports::FileReadPort;
use crate::common::config::AppConfig;
use crate::domain::repositories::folder_repository::FolderRepository;
2025-03-28 12:38:48 +01:00
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// Test-only service that mirrors `ShareService` logic but accepts generic repos.
struct ShareServiceForTest<SR, FR, FoR, PH> {
config: Arc<AppConfig>,
share_repository: Arc<SR>,
file_repository: Arc<FR>,
folder_repository: Arc<FoR>,
password_hasher: Arc<PH>,
hash_semaphore: Arc<Semaphore>,
}
impl<SR, FR, FoR, PH> ShareServiceForTest<SR, FR, FoR, PH>
where
SR: ShareStoragePort,
FR: FileReadPort,
FoR: FolderRepository,
PH: PasswordHasherPort,
{
fn new(
config: Arc<AppConfig>,
share_repository: Arc<SR>,
file_repository: Arc<FR>,
folder_repository: Arc<FoR>,
password_hasher: Arc<PH>,
) -> Self {
Self {
config,
share_repository,
file_repository,
folder_repository,
password_hasher,
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
}
}
async fn verify_item_exists(
&self,
item_id: &str,
item_type: &ShareItemType,
) -> Result<(), ShareServiceError> {
match item_type {
ShareItemType::File => {
self.file_repository.get_file(item_id).await.map_err(|_| {
ShareServiceError::ItemNotFound(format!(
"File with ID {} not found",
item_id
))
})?;
}
ShareItemType::Folder => {
self.folder_repository
.get_folder(item_id)
.await
.map_err(|_| {
ShareServiceError::ItemNotFound(format!(
"Folder with ID {} not found",
item_id
))
})?;
}
}
Ok(())
}
async fn hash_password_async(&self, password: &str) -> Result<String, DomainError> {
let _permit = self.hash_semaphore.acquire().await.map_err(|_| {
DomainError::internal_error("ShareService", "Hash semaphore closed".to_string())
})?;
self.password_hasher.hash_password(password).await
}
}
impl<SR, FR, FoR, PH> ShareUseCase for ShareServiceForTest<SR, FR, FoR, PH>
where
SR: ShareStoragePort,
FR: FileReadPort,
FoR: FolderRepository,
PH: PasswordHasherPort,
{
async fn create_shared_link(
&self,
user_id: Uuid,
dto: CreateShareDto,
) -> Result<ShareDto, DomainError> {
let item_type = ShareItemType::try_from(dto.item_type.as_str())
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
self.verify_item_exists(&dto.item_id, &item_type).await?;
let password_hash = match dto.password {
Some(p) => Some(self.hash_password_async(&p).await?),
None => None,
};
let share = Share::new(
dto.item_id.clone(),
dto.item_name.clone(),
item_type,
user_id,
password_hash,
)
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
let saved_share = self
.share_repository
.save_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
}
2026-03-05 21:28:51 +01:00
async fn get_shared_link(
&self,
id: Uuid,
requester_id: Uuid,
2026-03-05 21:28:51 +01:00
) -> Result<ShareDto, DomainError> {
let share = self
.share_repository
.find_share_by_id_for_user(id, requester_id)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
async fn get_shared_links_for_item(
&self,
item_id: &str,
item_type: &ShareItemType,
requester_id: Uuid,
) -> Result<Vec<ShareDto>, DomainError> {
let shares = self
.share_repository
.find_shares_by_item_for_user(item_id, item_type, requester_id)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(shares
.into_iter()
.filter(|s| !s.is_expired())
.map(|s| ShareDto::from_entity(&s, &self.config.base_url()))
.collect())
}
async fn update_shared_link(
&self,
id: Uuid,
requester_id: Uuid,
dto: UpdateShareDto,
) -> Result<ShareDto, DomainError> {
let mut share = self
.share_repository
.find_share_by_id_for_user(id, requester_id)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
})?;
if let Some(password) = dto.password {
let hash = if password.is_empty() {
None
} else {
Some(self.hash_password_async(&password).await?)
};
share = share.with_password(hash);
}
let updated = self
.share_repository
.update_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(ShareDto::from_entity(&updated, &self.config.base_url()))
}
2026-03-05 21:28:51 +01:00
async fn delete_shared_link(
&self,
id: Uuid,
requester_id: Uuid,
2026-03-05 21:28:51 +01:00
) -> Result<(), DomainError> {
self.share_repository
.delete_share_for_user(id, requester_id)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(())
}
async fn get_user_shared_links(
&self,
user_id: Uuid,
page: usize,
per_page: usize,
) -> Result<PaginatedResponseDto<ShareDto>, DomainError> {
let offset = (page - 1) * per_page;
let (shares, total) = self
.share_repository
.find_shares_by_user(user_id, offset, per_page)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
let dtos = shares
.iter()
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
.collect();
Ok(PaginatedResponseDto::new(dtos, page, per_page, total))
}
async fn verify_shared_link_password(
&self,
token: &str,
password: &str,
) -> Result<ShareDto, DomainError> {
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
match share.password_hash() {
Some(hash) => {
let valid = self.password_hasher.verify_password(password, hash).await?;
if !valid {
return Err(DomainError::new(
crate::common::errors::ErrorKind::AccessDenied,
"Share",
"Invalid share password",
));
}
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
None => Ok(ShareDto::from_entity(&share, &self.config.base_url())),
}
}
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
let updated = share.increment_access_count();
self.share_repository
.update_share(&updated)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(())
}
}
2025-03-28 12:38:48 +01:00
struct MockPasswordHasher;
impl PasswordHasherPort for MockPasswordHasher {
async fn hash_password(&self, password: &str) -> Result<String, DomainError> {
Ok(format!("hashed_{}", password))
}
async fn verify_password(&self, _password: &str, _hash: &str) -> Result<bool, DomainError> {
Ok(true)
}
}
2025-03-28 12:38:48 +01:00
struct MockFileRepository;
struct MockFolderRepository;
impl FileReadPort for MockFileRepository {
2026-02-14 01:29:34 +01:00
async fn get_file(
&self,
id: &str,
) -> Result<crate::domain::entities::file::File, DomainError> {
2025-03-28 12:38:48 +01:00
if id == "test_file_id" {
let file = crate::domain::entities::file::File::new(
id.to_string(),
"test.txt".to_string(),
2026-02-14 01:29:34 +01:00
crate::domain::services::path_service::StoragePath::from_string(
"/path/to/test.txt",
),
2025-03-28 12:38:48 +01:00
123,
"text/plain".to_string(),
None,
)
.unwrap();
Ok(file)
} else {
Err(DomainError::not_found("File", id))
2025-03-28 12:38:48 +01:00
}
}
2026-02-14 01:29:34 +01:00
async fn get_file_or_trashed(
&self,
_id: &str,
) -> Result<crate::domain::entities::file::File, DomainError> {
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn list_files(
&self,
_folder_id: Option<&str>,
) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
2025-03-28 12:38:48 +01:00
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn get_file_stream(
&self,
_id: &str,
2026-02-14 01:29:34 +01:00
) -> Result<
Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>,
DomainError,
> {
2025-03-28 12:38:48 +01:00
unimplemented!()
}
async fn get_file_range_stream(
&self,
_id: &str,
_start: u64,
_end: Option<u64>,
2026-02-14 01:29:34 +01:00
) -> Result<
Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>,
DomainError,
> {
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn get_file_path(
&self,
_id: &str,
) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
2025-03-28 12:38:48 +01:00
unimplemented!()
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
unimplemented!()
}
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
Ok(String::new())
}
async fn search_files_paginated(
&self,
_folder_id: Option<&str>,
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
_user_id: Uuid,
) -> Result<(Vec<crate::domain::entities::file::File>, usize), DomainError> {
Ok((Vec::new(), 0))
}
async fn count_files(
&self,
_folder_id: Option<&str>,
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
_user_id: Uuid,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn stream_files_in_subtree(
&self,
_folder_id: &str,
) -> Result<
2026-02-26 00:47:32 +01:00
std::pin::Pin<
Box<
dyn futures::Stream<
Item = Result<crate::domain::entities::file::File, DomainError>,
> + Send,
>,
>,
DomainError,
> {
Ok(Box::pin(futures::stream::empty()))
}
async fn get_file_for_owner(
&self,
id: &str,
_owner_id: Uuid,
) -> Result<crate::domain::entities::file::File, DomainError> {
self.get_file(id).await
}
2025-03-28 12:38:48 +01:00
}
impl FolderRepository for MockFolderRepository {
2026-02-14 01:29:34 +01:00
async fn create_folder(
&self,
_name: String,
_parent_id: Option<String>,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn get_folder(
&self,
id: &str,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
2025-03-28 12:38:48 +01:00
if id == "test_folder_id" {
let folder = crate::domain::entities::folder::Folder::new(
id.to_string(),
"test".to_string(),
2026-02-14 01:29:34 +01:00
crate::domain::services::path_service::StoragePath::from_string(
"/path/to/test",
),
2025-03-28 12:38:48 +01:00
None,
)
.unwrap();
Ok(folder)
} else {
Err(DomainError::not_found("Folder", id))
2025-03-28 12:38:48 +01:00
}
}
2026-02-14 01:29:34 +01:00
async fn get_folder_by_path(
&self,
_storage_path: &crate::domain::services::path_service::StoragePath,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
2025-03-28 12:38:48 +01:00
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn list_folders(
&self,
_parent_id: Option<&str>,
) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
unimplemented!()
}
async fn list_folders_by_owner(
&self,
_parent_id: Option<&str>,
_owner_id: Uuid,
) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn list_folders_paginated(
&self,
_parent_id: Option<&str>,
_offset: usize,
_limit: usize,
_include_total: bool,
) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError>
{
unimplemented!()
}
async fn list_folders_by_owner_paginated(
&self,
_parent_id: Option<&str>,
_owner_id: Uuid,
_offset: usize,
_limit: usize,
_include_total: bool,
) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError>
{
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn rename_folder(
&self,
_id: &str,
_new_name: String,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn move_folder(
&self,
_id: &str,
_new_parent_id: Option<&str>,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
2025-03-28 12:38:48 +01:00
unimplemented!()
}
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn folder_exists(
&self,
_storage_path: &crate::domain::services::path_service::StoragePath,
) -> Result<bool, DomainError> {
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn get_folder_path(
&self,
_id: &str,
) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
unimplemented!()
}
async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> {
unimplemented!()
}
2026-02-14 01:29:34 +01:00
async fn restore_from_trash(
&self,
_folder_id: &str,
_original_path: &str,
) -> Result<(), DomainError> {
unimplemented!()
}
async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> {
2025-03-28 12:38:48 +01:00
unimplemented!()
}
async fn create_home_folder(
&self,
_user_id: Uuid,
_name: String,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
2025-03-28 12:38:48 +01:00
}
struct MockShareRepository {
shares: Mutex<HashMap<String, Share>>,
tokens: Mutex<HashMap<String, String>>, // token -> id mapping
}
impl MockShareRepository {
fn new() -> Self {
Self {
shares: Mutex::new(HashMap::new()),
tokens: Mutex::new(HashMap::new()),
}
}
}
impl ShareStoragePort for MockShareRepository {
async fn save_share(&self, share: &Share) -> Result<Share, DomainError> {
let mut shares = self.shares.lock().unwrap();
let mut tokens = self.tokens.lock().unwrap();
2026-02-14 01:29:34 +01:00
shares.insert(share.id().to_string(), share.clone());
tokens.insert(share.token().to_string(), share.id().to_string());
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
Ok(share.clone())
}
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
let tokens = self.tokens.lock().unwrap();
let shares = self.shares.lock().unwrap();
2026-02-14 01:29:34 +01:00
let id = tokens
.get(token)
.ok_or_else(|| DomainError::not_found("Share", token))?;
2026-02-14 01:29:34 +01:00
shares
.get(id)
2025-03-28 12:38:48 +01:00
.cloned()
.ok_or_else(|| DomainError::not_found("Share", id.as_str()))
2025-03-28 12:38:48 +01:00
}
2026-02-14 01:29:34 +01:00
async fn find_share_by_id_for_user(
&self,
id: Uuid,
user_id: Uuid,
) -> Result<Share, DomainError> {
let shares = self.shares.lock().unwrap();
let id_str = id.to_string();
shares
.get(&id_str)
.filter(|s| s.created_by() == user_id)
.cloned()
.ok_or_else(|| DomainError::not_found("Share", &id_str))
}
async fn delete_share_for_user(&self, id: Uuid, user_id: Uuid) -> Result<(), DomainError> {
let mut shares = self.shares.lock().unwrap();
let mut tokens = self.tokens.lock().unwrap();
let id_str = id.to_string();
let share = shares
.get(&id_str)
.filter(|s| s.created_by() == user_id)
.ok_or_else(|| DomainError::not_found("Share", &id_str))?;
tokens.remove(share.token());
shares.remove(&id_str);
Ok(())
}
async fn find_shares_by_item_for_user(
2026-02-14 01:29:34 +01:00
&self,
item_id: &str,
item_type: &ShareItemType,
user_id: Uuid,
2026-02-14 01:29:34 +01:00
) -> Result<Vec<Share>, DomainError> {
2025-03-28 12:38:48 +01:00
let shares = self.shares.lock().unwrap();
let type_str = item_type.to_string();
2026-02-14 01:29:34 +01:00
let result: Vec<Share> = shares
.values()
.filter(|s| {
s.item_id() == item_id
&& s.item_type().to_string() == type_str
&& s.created_by() == user_id
})
2025-03-28 12:38:48 +01:00
.cloned()
.collect();
Ok(result)
}
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
let mut shares = self.shares.lock().unwrap();
2026-02-14 01:29:34 +01:00
let id_str = share.id().to_string();
if !shares.contains_key(&id_str) {
return Err(DomainError::not_found("Share", &id_str));
2025-03-28 12:38:48 +01:00
}
2026-02-14 01:29:34 +01:00
shares.insert(id_str, share.clone());
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
Ok(share.clone())
}
2026-02-14 01:29:34 +01:00
async fn find_shares_by_user(
&self,
user_id: Uuid,
2026-02-14 01:29:34 +01:00
offset: usize,
limit: usize,
) -> Result<(Vec<Share>, usize), DomainError> {
2025-03-28 12:38:48 +01:00
let shares = self.shares.lock().unwrap();
2026-02-14 01:29:34 +01:00
let user_shares: Vec<Share> = shares
.values()
.filter(|s| s.created_by() == user_id)
2025-03-28 12:38:48 +01:00
.cloned()
.collect();
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
let total = user_shares.len();
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
// Apply pagination
2026-02-14 01:29:34 +01:00
let paginated = user_shares.into_iter().skip(offset).take(limit).collect();
2025-03-28 12:38:48 +01:00
Ok((paginated, total))
}
}
#[tokio::test]
async fn test_create_shared_link() {
let config = Arc::new(AppConfig::default());
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
let share_repo = Arc::new(MockShareRepository::new());
let file_repo = Arc::new(MockFileRepository);
let folder_repo = Arc::new(MockFolderRepository);
let password_hasher = Arc::new(MockPasswordHasher);
2026-02-14 01:29:34 +01:00
let service =
ShareServiceForTest::new(config, share_repo, file_repo, folder_repo, password_hasher);
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
// Test creating a file share
let dto = CreateShareDto {
item_id: "test_file_id".to_string(),
item_name: Some("test_file.txt".to_string()),
2025-03-28 12:38:48 +01:00
item_type: "file".to_string(),
password: Some("secret".to_string()),
expires_at: None,
};
2026-02-14 01:29:34 +01:00
let result = service.create_shared_link(Uuid::new_v4(), dto).await;
2025-03-28 12:38:48 +01:00
assert!(result.is_ok());
2026-02-14 01:29:34 +01:00
2025-03-28 12:38:48 +01:00
let share_dto = result.unwrap();
assert_eq!(share_dto.item_id, "test_file_id");
assert_eq!(share_dto.item_type, "file");
assert!(share_dto.has_password);
assert!(share_dto.url.starts_with("http://127.0.0.1:8086/s/"));
2025-03-28 12:38:48 +01:00
}
2026-02-14 01:29:34 +01:00
}