Files
Oxicloud/src/application/services/share_service.rs
T
2026-02-08 13:40:23 +01:00

655 lines
23 KiB
Rust

use std::sync::Arc;
use async_trait::async_trait;
use thiserror::Error;
use crate::{
application::{
dtos::{
pagination::PaginatedResponseDto,
share_dto::{CreateShareDto, ShareDto, UpdateShareDto},
},
ports::{
auth_ports::PasswordHasherPort,
outbound::FolderStoragePort,
share_ports::{ShareStoragePort, ShareUseCase},
storage_ports::FileReadPort,
},
},
common::{config::AppConfig, errors::DomainError},
domain::entities::share::{Share, ShareItemType, SharePermissions},
};
#[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),
ShareServiceError::Expired => DomainError::access_denied("Share", "Share has expired".to_string()),
ShareServiceError::Repository(s) => DomainError::internal_error("Share", s),
ShareServiceError::InvalidItemType(s) => DomainError::validation_error(s),
ShareServiceError::Validation(s) => DomainError::validation_error(s),
}
}
}
pub struct ShareService {
config: Arc<AppConfig>,
share_repository: Arc<dyn ShareStoragePort>,
file_repository: Arc<dyn FileReadPort>,
folder_repository: Arc<dyn FolderStoragePort>,
password_hasher: Arc<dyn PasswordHasherPort>,
}
impl ShareService {
pub fn new(
config: Arc<AppConfig>,
share_repository: Arc<dyn ShareStoragePort>,
file_repository: Arc<dyn FileReadPort>,
folder_repository: Arc<dyn FolderStoragePort>,
password_hasher: Arc<dyn PasswordHasherPort>,
) -> Self {
Self {
config,
share_repository,
file_repository,
folder_repository,
password_hasher,
}
}
/// Verifica que el elemento a compartir existe
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) // Usando el método correcto del trait FileStoragePort
.await
.map_err(|_| ShareServiceError::ItemNotFound(format!("File with ID {} not found", item_id)))?;
}
ShareItemType::Folder => {
self.folder_repository
.get_folder(item_id) // Usando el método correcto del trait FolderStoragePort
.await
.map_err(|_| ShareServiceError::ItemNotFound(format!("Folder with ID {} not found", item_id)))?;
}
}
Ok(())
}
/// Hash de contraseña usando Argon2id (resistente a timing attacks y GPU attacks)
fn hash_password(&self, password: &str) -> String {
use argon2::{Argon2, PasswordHasher};
use argon2::password_hash::SaltString;
use rand_core::OsRng;
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.expect("Failed to hash share password")
.to_string()
}
}
#[async_trait]
impl ShareUseCase for ShareService {
async fn create_shared_link(
&self,
user_id: &str,
dto: CreateShareDto,
) -> Result<ShareDto, DomainError> {
// Convertir el tipo de elemento
let item_type = ShareItemType::try_from(dto.item_type.as_str())
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
// Verificar que el elemento existe
self.verify_item_exists(&dto.item_id, &item_type).await?;
// Convertir el DTO de permisos si existe
let permissions = dto.permissions.map(|p| p.to_entity());
// Hash de contraseña si existe
let password_hash = dto.password.map(|p| self.hash_password(&p));
// Crear la entidad Share
let share = Share::new(
dto.item_id.clone(),
item_type,
user_id.to_string(),
permissions,
password_hash,
dto.expires_at,
)
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
// Guardar en el repositorio
let saved_share = self
.share_repository
.save_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Convertir la entidad a DTO para la respuesta
Ok(ShareDto::from_entity(&saved_share, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
}
async fn get_shared_link(&self, id: &str) -> Result<ShareDto, DomainError> {
// Buscar el enlace compartido por su ID
let share = self
.share_repository
.find_share_by_id(id)
.await
.map_err(|e| ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)))?;
// Verificar si ha expirado
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
// Convertir la entidad a DTO para la respuesta
Ok(ShareDto::from_entity(&share, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
}
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
// Buscar el enlace compartido por su token
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
// Verificar si ha expirado
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
// Convertir la entidad a DTO para la respuesta
Ok(ShareDto::from_entity(&share, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
}
async fn get_shared_links_for_item(
&self,
item_id: &str,
item_type: &ShareItemType,
) -> Result<Vec<ShareDto>, DomainError> {
// Buscar todos los enlaces compartidos para el elemento
let shares = self
.share_repository
.find_shares_by_item(item_id, item_type)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Filtrar los enlaces expirados
let active_shares: Vec<Share> = shares.into_iter().filter(|s| !s.is_expired()).collect();
// Convertir las entidades a DTOs para la respuesta
let share_dtos = active_shares
.iter()
.map(|s| ShareDto::from_entity(s, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
.collect();
Ok(share_dtos)
}
async fn update_shared_link(
&self,
id: &str,
dto: UpdateShareDto,
) -> Result<ShareDto, DomainError> {
// Buscar el enlace compartido existente
let mut share = self
.share_repository
.find_share_by_id(id)
.await
.map_err(|e| ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)))?;
// Actualizar permisos si se proporcionan
if let Some(permissions_dto) = dto.permissions {
let permissions = SharePermissions::new(
permissions_dto.read,
permissions_dto.write,
permissions_dto.reshare,
);
share = share.with_permissions(permissions);
}
// Actualizar contraseña si se proporciona
if let Some(password) = dto.password {
let password_hash = if password.is_empty() {
None
} else {
Some(self.hash_password(&password))
};
share = share.with_password(password_hash);
}
// Actualizar fecha de expiración si se proporciona
if dto.expires_at.is_some() {
share = share.with_expiration(dto.expires_at);
}
// Guardar los cambios
let updated_share = self
.share_repository
.update_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Convertir la entidad a DTO para la respuesta
Ok(ShareDto::from_entity(&updated_share, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
}
async fn delete_shared_link(&self, id: &str) -> Result<(), DomainError> {
// Eliminar el enlace compartido
self.share_repository
.delete_share(id)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(())
}
async fn get_user_shared_links(
&self,
user_id: &str,
page: usize,
per_page: usize,
) -> Result<PaginatedResponseDto<ShareDto>, DomainError> {
// Calcular offset para paginación
let offset = (page - 1) * per_page;
// Buscar los enlaces compartidos del usuario
let (shares, total) = self
.share_repository
.find_shares_by_user(user_id, offset, per_page)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Convertir las entidades a DTOs
let share_dtos: Vec<ShareDto> = shares
.iter()
.map(|s| ShareDto::from_entity(s, &format!("http://{}:{}", self.config.server_host, self.config.server_port)))
.collect();
// Crear el resultado paginado
let paginated = PaginatedResponseDto::new(
share_dtos,
page,
per_page,
total
);
Ok(paginated)
}
async fn verify_shared_link_password(
&self,
token: &str,
password: &str,
) -> Result<bool, DomainError> {
// Buscar el enlace compartido por su token
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
// Verificar si ha expirado
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
// Verificar la contraseña usando el port de infraestructura
match share.password_hash() {
Some(hash) => {
self.password_hasher.verify_password(password, hash)
}
None => Ok(true), // No password required
}
}
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
// Buscar el enlace compartido por su token
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
// Verificar si ha expirado
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
// Incrementar el contador de accesos
let updated_share = share.increment_access_count();
// Guardar los cambios
self.share_repository
.update_share(&updated_share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::ports::share_ports::ShareStoragePort;
use crate::application::ports::auth_ports::PasswordHasherPort;
use crate::application::dtos::share_dto::SharePermissionsDto;
use crate::common::config::AppConfig;
use crate::domain::repositories::folder_repository::FolderRepository;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockPasswordHasher;
impl PasswordHasherPort for MockPasswordHasher {
fn hash_password(&self, password: &str) -> Result<String, DomainError> {
Ok(format!("hashed_{}", password))
}
fn verify_password(&self, _password: &str, _hash: &str) -> Result<bool, DomainError> {
Ok(true)
}
}
struct MockFileRepository;
struct MockFolderRepository;
#[async_trait]
impl FileReadPort for MockFileRepository {
async fn get_file(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
if id == "test_file_id" {
let file = crate::domain::entities::file::File::new(
id.to_string(),
"test.txt".to_string(),
crate::domain::services::path_service::StoragePath::from_string("/path/to/test.txt"),
123,
"text/plain".to_string(),
None,
)
.unwrap();
Ok(file)
} else {
Err(DomainError::not_found("File", id))
}
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
unimplemented!()
}
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
unimplemented!()
}
async fn get_file_stream(
&self,
_id: &str,
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
unimplemented!()
}
async fn get_file_range_stream(
&self,
_id: &str,
_start: u64,
_end: Option<u64>,
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
unimplemented!()
}
async fn get_file_mmap(&self, _id: &str) -> Result<bytes::Bytes, DomainError> {
unimplemented!()
}
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> {
unimplemented!()
}
}
#[async_trait]
impl FolderRepository for MockFolderRepository {
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
async fn get_folder(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
if id == "test_folder_id" {
let folder = crate::domain::entities::folder::Folder::new(
id.to_string(),
"test".to_string(),
crate::domain::services::path_service::StoragePath::from_string("/path/to/test"),
None,
)
.unwrap();
Ok(folder)
} else {
Err(DomainError::not_found("Folder", id))
}
}
async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
unimplemented!()
}
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 rename_folder(&self, _id: &str, _new_name: String) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
unimplemented!()
}
async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<bool, DomainError> {
unimplemented!()
}
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!()
}
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> {
unimplemented!()
}
}
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()),
}
}
}
#[async_trait]
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();
shares.insert(share.id().to_string(), share.clone());
tokens.insert(share.token().to_string(), share.id().to_string());
Ok(share.clone())
}
async fn find_share_by_id(&self, id: &str) -> Result<Share, DomainError> {
let shares = self.shares.lock().unwrap();
shares.get(id)
.cloned()
.ok_or_else(|| DomainError::not_found("Share", id))
}
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
let tokens = self.tokens.lock().unwrap();
let shares = self.shares.lock().unwrap();
let id = tokens.get(token)
.ok_or_else(|| DomainError::not_found("Share", token))?;
shares.get(id)
.cloned()
.ok_or_else(|| DomainError::not_found("Share", id.as_str()))
}
async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<Share>, DomainError> {
let shares = self.shares.lock().unwrap();
let type_str = item_type.to_string();
let result: Vec<Share> = shares.values()
.filter(|s| s.item_id() == item_id && s.item_type().to_string() == type_str)
.cloned()
.collect();
Ok(result)
}
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
let mut shares = self.shares.lock().unwrap();
let id_str = share.id().to_string();
if !shares.contains_key(&id_str) {
return Err(DomainError::not_found("Share", &id_str));
}
shares.insert(id_str, share.clone());
Ok(share.clone())
}
async fn delete_share(&self, id: &str) -> Result<(), DomainError> {
let mut shares = self.shares.lock().unwrap();
let mut tokens = self.tokens.lock().unwrap();
// Find the share to get the token
let share = shares.get(id)
.ok_or_else(|| DomainError::not_found("Share", id))?;
// Remove token mapping
tokens.remove(share.token());
// Remove the share
shares.remove(id);
Ok(())
}
async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize) -> Result<(Vec<Share>, usize), DomainError> {
let shares = self.shares.lock().unwrap();
let user_shares: Vec<Share> = shares.values()
.filter(|s| s.created_by() == user_id)
.cloned()
.collect();
let total = user_shares.len();
// Apply pagination
let paginated = user_shares.into_iter()
.skip(offset)
.take(limit)
.collect();
Ok((paginated, total))
}
}
#[tokio::test]
async fn test_create_shared_link() {
let config = Arc::new(AppConfig::default());
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);
let service = ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher);
// Test creating a file share
let dto = CreateShareDto {
item_id: "test_file_id".to_string(),
item_type: "file".to_string(),
password: Some("secret".to_string()),
expires_at: None,
permissions: Some(SharePermissionsDto {
read: true,
write: false,
reshare: false,
}),
};
let result = service.create_shared_link("user123", dto).await;
assert!(result.is_ok());
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:8085/s/"));
}
}