fix: resolve all clippy warnings and convert integration_tests to custom cfg

- Add type aliases (FileRow, FolderRow, FolderRowPaginated, FolderRowOptUser) to reduce type complexity
- Simplify redundant closures in app_password_handler and webdav_handler
- Remove needless borrow in auth_handler
- Collapse nested if/let chains in login_lockout, webdav_lock, auth, rate_limit
- Box LockEntry in acquire() Err variant to fix large enum variant warning
- Rename DeviceCodeStatus::from_str to parse to avoid should_implement_trait lint
- Add #[allow(clippy::too_many_arguments)] and #[allow(clippy::result_unit_err)] where appropriate
- Convert integration_tests from cargo feature to custom cfg attribute
- Add check-cfg lint config in Cargo.toml for integration_tests cfg
This commit is contained in:
Diocrafts
2026-03-04 23:55:08 +01:00
parent b81b7f7a0e
commit ee86c3a128
53 changed files with 871 additions and 956 deletions
+3 -1
View File
@@ -57,7 +57,9 @@ socket2 = { version = "0.6.2", features = ["all"] }
[features] [features]
default = [] default = []
test_utils = ["mockall"] test_utils = ["mockall"]
integration_tests = []
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
[profile.release] [profile.release]
lto = "fat" lto = "fat"
+1 -1
View File
@@ -6,10 +6,10 @@ use std::sync::Arc;
use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::storage_ports::CopyFolderTreeResult; use crate::application::ports::storage_ports::CopyFolderTreeResult;
use crate::common::errors::DomainError;
use crate::application::services::file_management_service::FileManagementService; use crate::application::services::file_management_service::FileManagementService;
use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::file_upload_service::FileUploadService; use crate::application::services::file_upload_service::FileUploadService;
use crate::common::errors::DomainError;
// ───────────────────────────────────────────────────── // ─────────────────────────────────────────────────────
// Upload port // Upload port
-1
View File
@@ -1,4 +1,3 @@
use crate::{ use crate::{
application::dtos::{ application::dtos::{
pagination::PaginatedResponseDto, pagination::PaginatedResponseDto,
-1
View File
@@ -1,4 +1,3 @@
use crate::application::dtos::trash_dto::TrashedItemDto; use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::common::errors::Result; use crate::common::errors::Result;
@@ -7,8 +7,8 @@ use crate::application::services::auth_application_service::AuthApplicationServi
use crate::common::config::OidcConfig; use crate::common::config::OidcConfig;
use crate::common::errors::{DomainError, ErrorKind}; use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::repositories::settings_repository::SettingsRepository; use crate::domain::repositories::settings_repository::SettingsRepository;
use crate::infrastructure::services::oidc_service::OidcService;
use crate::infrastructure::repositories::pg::SettingsPgRepository; use crate::infrastructure::repositories::pg::SettingsPgRepository;
use crate::infrastructure::services::oidc_service::OidcService;
/// Admin settings service — manages platform configuration in the database. /// Admin settings service — manages platform configuration in the database.
/// ///
@@ -9,13 +9,13 @@ use crate::application::ports::auth_ports::{
}; };
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::entities::app_password::AppPassword; use crate::domain::entities::app_password::AppPassword;
use crate::infrastructure::repositories::pg::AppPasswordPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use moka::future::Cache; use moka::future::Cache;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration as StdDuration; use std::time::Duration as StdDuration;
use crate::infrastructure::repositories::pg::AppPasswordPgRepository;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use crate::infrastructure::repositories::pg::UserPgRepository;
/// App password token length (32 random alphanumeric chars after prefix). /// App password token length (32 random alphanumeric chars after prefix).
const TOKEN_LENGTH: usize = 32; const TOKEN_LENGTH: usize = 32;
@@ -6,21 +6,21 @@ use crate::application::ports::auth_ports::{
UserStoragePort, UserStoragePort,
}; };
use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::inbound::FolderUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::config::OidcConfig; use crate::common::config::OidcConfig;
use crate::common::errors::{DomainError, ErrorKind}; use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::session::Session; use crate::domain::entities::session::Session;
use crate::domain::entities::user::{User, UserRole}; use crate::domain::entities::user::{User, UserRole};
use crate::infrastructure::repositories::pg::SessionPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::infrastructure::services::jwt_service::JwtTokenService;
use crate::infrastructure::services::oidc_service::OidcService;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use moka::sync::Cache; use moka::sync::Cache;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::RwLock; use std::sync::RwLock;
use std::time::Duration; use std::time::Duration;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use crate::application::services::folder_service::FolderService;
use crate::infrastructure::services::jwt_service::JwtTokenService;
use crate::infrastructure::services::oidc_service::OidcService;
use crate::infrastructure::repositories::pg::SessionPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository;
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce) /// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
#[derive(Clone)] #[derive(Clone)]
@@ -145,11 +145,7 @@ impl AuthApplicationService {
} }
/// Configures the OIDC service /// Configures the OIDC service
pub fn with_oidc( pub fn with_oidc(self, oidc_service: Arc<OidcService>, oidc_config: OidcConfig) -> Self {
self,
oidc_service: Arc<OidcService>,
oidc_config: OidcConfig,
) -> Self {
{ {
let mut state = self.oidc.write().unwrap(); let mut state = self.oidc.write().unwrap();
state.service = Some(oidc_service); state.service = Some(oidc_service);
@@ -308,12 +304,7 @@ impl AuthApplicationService {
} }
// Check email uniqueness // Check email uniqueness
if self if self.user_storage.get_user_by_email(&email).await.is_ok() {
.user_storage
.get_user_by_email(&email)
.await
.is_ok()
{
return Err(DomainError::new( return Err(DomainError::new(
ErrorKind::AlreadyExists, ErrorKind::AlreadyExists,
"User", "User",
@@ -614,7 +605,6 @@ impl AuthApplicationService {
Ok(users.into_iter().map(UserDto::from).collect()) Ok(users.into_iter().map(UserDto::from).collect())
} }
// ======================================================================== // ========================================================================
// Admin User Management Methods // Admin User Management Methods
// ======================================================================== // ========================================================================
+4 -4
View File
@@ -14,12 +14,12 @@ use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto};
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase}; use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase};
use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::config::AppConfig;
use crate::common::errors::DomainError;
use crate::application::services::file_management_service::FileManagementService; use crate::application::services::file_management_service::FileManagementService;
use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
use crate::application::services::trash_service::TrashService; use crate::application::services::trash_service::TrashService;
use crate::common::config::AppConfig;
use crate::common::errors::DomainError;
/// Specific errors for batch operations /// Specific errors for batch operations
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -1007,7 +1007,7 @@ impl BatchOperationService {
} }
} }
#[cfg(feature = "integration_tests")] #[cfg(integration_tests)]
mod tests { mod tests {
use super::*; use super::*;
use crate::common::stubs::{StubFileManagementUseCase, StubFileRetrievalUseCase}; use crate::common::stubs::{StubFileManagementUseCase, StubFileRetrievalUseCase};
@@ -19,9 +19,9 @@ use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus}; use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
use crate::domain::entities::session::Session; use crate::domain::entities::session::Session;
use crate::infrastructure::repositories::pg::DeviceCodePgRepository; use crate::infrastructure::repositories::pg::DeviceCodePgRepository;
use crate::infrastructure::services::jwt_service::JwtTokenService;
use crate::infrastructure::repositories::pg::SessionPgRepository; use crate::infrastructure::repositories::pg::SessionPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository; use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::infrastructure::services::jwt_service::JwtTokenService;
/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min). /// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min).
const DEVICE_CODE_LIFETIME_SECS: i64 = 900; const DEVICE_CODE_LIFETIME_SECS: i64 = 900;
@@ -3,9 +3,9 @@ use crate::application::dtos::favorites_dto::{
}; };
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase}; use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
use std::sync::Arc; use std::sync::Arc;
use tracing::info; use tracing::info;
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
/// Implementation of the FavoritesUseCase for managing user favorites. /// Implementation of the FavoritesUseCase for managing user favorites.
/// ///
@@ -4,11 +4,11 @@ use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::FileManagementUseCase; use crate::application::ports::file_ports::FileManagementUseCase;
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileReadPort, FileWritePort}; use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::trash_service::TrashService;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use tracing::{error, info, warn};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::application::services::trash_service::TrashService; use tracing::{error, info, warn};
/// Service for file management operations (move, delete). /// Service for file management operations (move, delete).
/// ///
@@ -7,10 +7,12 @@ use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent}; use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent};
use crate::application::ports::storage_ports::FileReadPort; use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use tracing::{debug, info};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::services::file_content_cache::FileContentCache; use crate::infrastructure::services::file_content_cache::FileContentCache;
use crate::infrastructure::services::image_transcode_service::{ImageTranscodeService, OutputFormat}; use crate::infrastructure::services::image_transcode_service::{
ImageTranscodeService, OutputFormat,
};
use tracing::{debug, info};
/// Threshold below which files are served from RAM cache (10 MB). /// Threshold below which files are served from RAM cache (10 MB).
const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024; const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024;
@@ -1,15 +1,14 @@
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::FileUploadUseCase; use crate::application::ports::file_ports::FileUploadUseCase;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::services::storage_usage_service::StorageUsageService;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use tracing::{debug, info, warn};
use crate::infrastructure::repositories::pg::FileBlobReadRepository; use crate::infrastructure::repositories::pg::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::FileBlobWriteRepository; use crate::infrastructure::repositories::pg::FileBlobWriteRepository;
use crate::application::services::storage_usage_service::StorageUsageService; use tracing::{debug, info, warn};
/// Helper function to extract username from folder path string. /// Helper function to extract username from folder path string.
/// e.g. "My Folder - user1/subfolder/file.txt" → "user1" /// e.g. "My Folder - user1/subfolder/file.txt" → "user1"
@@ -48,8 +47,7 @@ pub struct FileUploadService {
/// Read port — needed for WebDAV create_file / update_file /// Read port — needed for WebDAV create_file / update_file
file_read: Option<Arc<FileBlobReadRepository>>, file_read: Option<Arc<FileBlobReadRepository>>,
/// Optional storage usage tracking /// Optional storage usage tracking
storage_usage_service: storage_usage_service: Option<Arc<StorageUsageService>>,
Option<Arc<StorageUsageService>>,
} }
impl FileUploadService { impl FileUploadService {
+3 -3
View File
@@ -3,10 +3,10 @@ use crate::application::dtos::folder_dto::{
}; };
use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::inbound::FolderUseCase;
use crate::common::errors::{DomainError, ErrorKind}; use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::services::path_service::StoragePath;
use std::sync::Arc;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::path_service::StoragePath;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use std::sync::Arc;
/// Implementation of the use case for folder operations /// Implementation of the use case for folder operations
pub struct FolderService { pub struct FolderService {
+272 -272
View File
@@ -1,272 +1,272 @@
//! Tests for IDOR (Insecure Direct Object Reference) protection. //! Tests for IDOR (Insecure Direct Object Reference) protection.
//! //!
//! Verifies that ownership checks at the repository and service layers //! Verifies that ownership checks at the repository and service layers
//! correctly reject access when the caller is not the file owner. //! correctly reject access when the caller is not the file owner.
use bytes::Bytes; use bytes::Bytes;
use futures::Stream; use futures::Stream;
use std::collections::HashMap; use std::collections::HashMap;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Mutex; use std::sync::Mutex;
use crate::application::ports::storage_ports::{ use crate::application::ports::storage_ports::FileReadPort;
FileReadPort, use crate::common::errors::DomainError;
}; use crate::domain::entities::file::File;
use crate::common::errors::DomainError; use crate::domain::services::path_service::StoragePath;
use crate::domain::entities::file::File;
use crate::domain::services::path_service::StoragePath; // ═══════════════════════════════════════════════════════════════════════════
// Mock repositories
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Mock repositories
// ═══════════════════════════════════════════════════════════════════════════ /// A simple in-memory mock that maps (file_id → (File, owner_id)).
struct MockFileReadPort {
/// A simple in-memory mock that maps (file_id → (File, owner_id)). /// file_id → (File, owner_id)
struct MockFileReadPort { files: Mutex<HashMap<String, (File, String)>>,
/// file_id → (File, owner_id) }
files: Mutex<HashMap<String, (File, String)>>,
} impl MockFileReadPort {
fn new() -> Self {
impl MockFileReadPort { Self {
fn new() -> Self { files: Mutex::new(HashMap::new()),
Self { }
files: Mutex::new(HashMap::new()), }
}
} /// Insert a test file owned by `owner_id`.
fn insert(&self, id: &str, name: &str, owner_id: &str) {
/// Insert a test file owned by `owner_id`. let file = File::new(
fn insert(&self, id: &str, name: &str, owner_id: &str) { id.to_string(),
let file = File::new( name.to_string(),
id.to_string(), StoragePath::from_string(&format!("/{}", name)),
name.to_string(), 42,
StoragePath::from_string(&format!("/{}", name)), "text/plain".to_string(),
42, None,
"text/plain".to_string(), )
None, .unwrap();
) self.files
.unwrap(); .lock()
self.files .unwrap()
.lock() .insert(id.to_string(), (file, owner_id.to_string()));
.unwrap() }
.insert(id.to_string(), (file, owner_id.to_string())); }
}
} impl FileReadPort for MockFileReadPort {
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
impl FileReadPort for MockFileReadPort { let files = self.files.lock().unwrap();
async fn get_file(&self, id: &str) -> Result<File, DomainError> { files
let files = self.files.lock().unwrap(); .get(id)
files .map(|(f, _)| f.clone())
.get(id) .ok_or_else(|| DomainError::not_found("File", id.to_string()))
.map(|(f, _)| f.clone()) }
.ok_or_else(|| DomainError::not_found("File", id.to_string()))
} async fn get_file_for_owner(&self, id: &str, owner_id: &str) -> Result<File, DomainError> {
let files = self.files.lock().unwrap();
async fn get_file_for_owner(&self, id: &str, owner_id: &str) -> Result<File, DomainError> { match files.get(id) {
let files = self.files.lock().unwrap(); Some((file, actual_owner)) if actual_owner == owner_id => Ok(file.clone()),
match files.get(id) { // Return NotFound regardless — do not leak existence
Some((file, actual_owner)) if actual_owner == owner_id => Ok(file.clone()), _ => Err(DomainError::not_found("File", id.to_string())),
// Return NotFound regardless — do not leak existence }
_ => Err(DomainError::not_found("File", id.to_string())), }
}
} async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
Ok(Vec::new())
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<File>, DomainError> { }
Ok(Vec::new())
} async fn get_file_stream(
&self,
async fn get_file_stream( _id: &str,
&self, ) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
_id: &str, unimplemented!()
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> { }
unimplemented!()
} async fn get_file_range_stream(
&self,
async fn get_file_range_stream( _id: &str,
&self, _start: u64,
_id: &str, _end: Option<u64>,
_start: u64, ) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
_end: Option<u64>, unimplemented!()
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> { }
unimplemented!()
} async fn get_file_path(&self, _id: &str) -> Result<StoragePath, DomainError> {
unimplemented!()
async fn get_file_path(&self, _id: &str) -> Result<StoragePath, DomainError> { }
unimplemented!()
} async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
unimplemented!()
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> { }
unimplemented!()
} async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
Ok(String::new())
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> { }
Ok(String::new())
} async fn search_files_paginated(
&self,
async fn search_files_paginated( _folder_id: Option<&str>,
&self, _criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
_folder_id: Option<&str>, _user_id: &str,
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto, ) -> Result<(Vec<File>, usize), DomainError> {
_user_id: &str, Ok((Vec::new(), 0))
) -> Result<(Vec<File>, usize), DomainError> { }
Ok((Vec::new(), 0))
} async fn count_files(
&self,
async fn count_files( _folder_id: Option<&str>,
&self, _criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
_folder_id: Option<&str>, _user_id: &str,
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto, ) -> Result<usize, DomainError> {
_user_id: &str, Ok(0)
) -> Result<usize, DomainError> { }
Ok(0)
} async fn stream_files_in_subtree(
&self,
async fn stream_files_in_subtree( _folder_id: &str,
&self, ) -> Result<Pin<Box<dyn Stream<Item = Result<File, DomainError>> + Send>>, DomainError> {
_folder_id: &str, Ok(Box::pin(futures::stream::empty()))
) -> Result< }
Pin<Box<dyn Stream<Item = Result<File, DomainError>> + Send>>, }
DomainError,
> { // ═══════════════════════════════════════════════════════════════════════════
Ok(Box::pin(futures::stream::empty())) // Tests — FileReadPort::get_file_for_owner (Repository layer, Solution C)
} // ═══════════════════════════════════════════════════════════════════════════
}
#[tokio::test]
// ═══════════════════════════════════════════════════════════════════════════ async fn get_file_for_owner_returns_file_for_correct_owner() {
// Tests — FileReadPort::get_file_for_owner (Repository layer, Solution C) let repo = MockFileReadPort::new();
// ═══════════════════════════════════════════════════════════════════════════ repo.insert("file-1", "secret.txt", "alice");
#[tokio::test] let result = repo.get_file_for_owner("file-1", "alice").await;
async fn get_file_for_owner_returns_file_for_correct_owner() { assert!(result.is_ok(), "owner should be able to read own file");
let repo = MockFileReadPort::new(); assert_eq!(result.unwrap().id(), "file-1");
repo.insert("file-1", "secret.txt", "alice"); }
let result = repo.get_file_for_owner("file-1", "alice").await; #[tokio::test]
assert!(result.is_ok(), "owner should be able to read own file"); async fn get_file_for_owner_rejects_wrong_owner() {
assert_eq!(result.unwrap().id(), "file-1"); let repo = MockFileReadPort::new();
} repo.insert("file-1", "secret.txt", "alice");
#[tokio::test] let result = repo.get_file_for_owner("file-1", "bob").await;
async fn get_file_for_owner_rejects_wrong_owner() { assert!(result.is_err(), "non-owner should be rejected");
let repo = MockFileReadPort::new();
repo.insert("file-1", "secret.txt", "alice"); // Must be NotFound, NOT Forbidden — avoids leaking existence
let err = result.unwrap_err();
let result = repo.get_file_for_owner("file-1", "bob").await; let msg = format!("{}", err);
assert!(result.is_err(), "non-owner should be rejected"); assert!(
msg.contains("not found") || msg.contains("NotFound"),
// Must be NotFound, NOT Forbidden — avoids leaking existence "error must be NotFound, got: {}",
let err = result.unwrap_err(); msg
let msg = format!("{}", err); );
assert!( }
msg.contains("not found") || msg.contains("NotFound"),
"error must be NotFound, got: {}", #[tokio::test]
msg async fn get_file_for_owner_returns_not_found_for_missing_file() {
); let repo = MockFileReadPort::new();
}
let result = repo.get_file_for_owner("nonexistent", "alice").await;
#[tokio::test] assert!(result.is_err());
async fn get_file_for_owner_returns_not_found_for_missing_file() { }
let repo = MockFileReadPort::new();
#[tokio::test]
let result = repo.get_file_for_owner("nonexistent", "alice").await; async fn verify_file_owner_uses_default_impl() {
assert!(result.is_err()); let repo = MockFileReadPort::new();
} repo.insert("file-1", "secret.txt", "alice");
#[tokio::test] // Default impl delegates to get_file_for_owner and maps to ()
async fn verify_file_owner_uses_default_impl() { assert!(repo.verify_file_owner("file-1", "alice").await.is_ok());
let repo = MockFileReadPort::new(); assert!(repo.verify_file_owner("file-1", "bob").await.is_err());
repo.insert("file-1", "secret.txt", "alice"); }
// Default impl delegates to get_file_for_owner and maps to () // ═══════════════════════════════════════════════════════════════════════════
assert!(repo.verify_file_owner("file-1", "alice").await.is_ok()); // Tests — FileManagementService _owned methods (Service layer, Solution B)
assert!(repo.verify_file_owner("file-1", "bob").await.is_err()); // ═══════════════════════════════════════════════════════════════════════════
} //
// Note: FileManagementService::with_trash takes concrete types for the write
// ═══════════════════════════════════════════════════════════════════════════ // repository (Arc<FileBlobWriteRepository>). We cannot construct real PG repos
// Tests — FileManagementService _owned methods (Service layer, Solution B) // without a database. Instead, we test the verify_owner logic indirectly by
// ═══════════════════════════════════════════════════════════════════════════ // testing the mock-based trait interactions at the port level, and document
// // that integration tests hitting the real DB are the ultimate verification.
// Note: FileManagementService::with_trash takes concrete types for the write //
// repository (Arc<FileBlobWriteRepository>). We cannot construct real PG repos // The tests below verify the *contract*: _owned methods must call
// without a database. Instead, we test the verify_owner logic indirectly by // verify_owner before delegating, and verify_owner must fail-closed when
// testing the mock-based trait interactions at the port level, and document // no read repo is available.
// that integration tests hitting the real DB are the ultimate verification.
// #[tokio::test]
// The tests below verify the *contract*: _owned methods must call async fn verify_file_owner_delegates_to_read_port() {
// verify_owner before delegating, and verify_owner must fail-closed when // This test verifies the FileReadPort contract that verify_file_owner
// no read repo is available. // returns Ok for the correct owner and Err for others.
let read = MockFileReadPort::new();
#[tokio::test] read.insert("abc-123", "report.pdf", "user-42");
async fn verify_file_owner_delegates_to_read_port() {
// This test verifies the FileReadPort contract that verify_file_owner // Same user → Ok
// returns Ok for the correct owner and Err for others. let ok = read.verify_file_owner("abc-123", "user-42").await;
let read = MockFileReadPort::new(); assert!(ok.is_ok(), "correct owner should pass verify_file_owner");
read.insert("abc-123", "report.pdf", "user-42");
// Different user → Err
// Same user → Ok let err = read.verify_file_owner("abc-123", "attacker-99").await;
let ok = read.verify_file_owner("abc-123", "user-42").await; assert!(err.is_err(), "wrong owner should fail verify_file_owner");
assert!(ok.is_ok(), "correct owner should pass verify_file_owner"); }
// Different user → Err #[tokio::test]
let err = read.verify_file_owner("abc-123", "attacker-99").await; async fn owned_methods_require_ownership_check_first() {
assert!(err.is_err(), "wrong owner should fail verify_file_owner"); // Simulate what the _owned methods do: verify_owner then delegate.
} // We test with the mock read port to prove the sequence.
let read = MockFileReadPort::new();
#[tokio::test] read.insert("file-1", "data.csv", "owner-a");
async fn owned_methods_require_ownership_check_first() {
// Simulate what the _owned methods do: verify_owner then delegate. // Step 1: verify_owner for correct owner → Ok
// We test with the mock read port to prove the sequence. let step1 = read.verify_file_owner("file-1", "owner-a").await;
let read = MockFileReadPort::new(); assert!(step1.is_ok());
read.insert("file-1", "data.csv", "owner-a");
// Step 2: verify_owner for attacker → Err, so the move/rename never executes
// Step 1: verify_owner for correct owner → Ok let step2 = read.verify_file_owner("file-1", "attacker").await;
let step1 = read.verify_file_owner("file-1", "owner-a").await; assert!(step2.is_err());
assert!(step1.is_ok()); }
// Step 2: verify_owner for attacker → Err, so the move/rename never executes // ═══════════════════════════════════════════════════════════════════════════
let step2 = read.verify_file_owner("file-1", "attacker").await; // Tests — Trait-level _owned method stubs (StubFileManagementUseCase)
assert!(step2.is_err()); // ═══════════════════════════════════════════════════════════════════════════
}
use crate::application::ports::file_ports::FileManagementUseCase;
// ═══════════════════════════════════════════════════════════════════════════ use crate::common::stubs::StubFileManagementUseCase;
// Tests — Trait-level _owned method stubs (StubFileManagementUseCase)
// ═══════════════════════════════════════════════════════════════════════════ #[tokio::test]
async fn stub_move_file_owned_returns_ok() {
use crate::application::ports::file_ports::FileManagementUseCase; let stub = StubFileManagementUseCase;
use crate::common::stubs::StubFileManagementUseCase; let result = stub
.move_file_owned("file-1", "user-1", Some("folder-2".to_string()))
#[tokio::test] .await;
async fn stub_move_file_owned_returns_ok() { assert!(result.is_ok(), "stub should return Ok for move_file_owned");
let stub = StubFileManagementUseCase; }
let result = stub
.move_file_owned("file-1", "user-1", Some("folder-2".to_string())) #[tokio::test]
.await; async fn stub_rename_file_owned_returns_ok() {
assert!(result.is_ok(), "stub should return Ok for move_file_owned"); let stub = StubFileManagementUseCase;
} let result = stub
.rename_file_owned("file-1", "user-1", "new-name.txt")
#[tokio::test] .await;
async fn stub_rename_file_owned_returns_ok() { assert!(
let stub = StubFileManagementUseCase; result.is_ok(),
let result = stub.rename_file_owned("file-1", "user-1", "new-name.txt").await; "stub should return Ok for rename_file_owned"
assert!(result.is_ok(), "stub should return Ok for rename_file_owned"); );
} }
use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::common::stubs::StubFileRetrievalUseCase; use crate::common::stubs::StubFileRetrievalUseCase;
#[tokio::test] #[tokio::test]
async fn stub_get_file_owned_returns_ok() { async fn stub_get_file_owned_returns_ok() {
let stub = StubFileRetrievalUseCase; let stub = StubFileRetrievalUseCase;
let result = stub.get_file_owned("file-1", "user-1").await; let result = stub.get_file_owned("file-1", "user-1").await;
assert!(result.is_ok(), "stub should return Ok for get_file_owned"); assert!(result.is_ok(), "stub should return Ok for get_file_owned");
} }
#[tokio::test] #[tokio::test]
async fn stub_get_file_optimized_owned_returns_ok() { async fn stub_get_file_optimized_owned_returns_ok() {
let stub = StubFileRetrievalUseCase; let stub = StubFileRetrievalUseCase;
let result = stub let result = stub
.get_file_optimized_owned("file-1", "user-1", true, false) .get_file_optimized_owned("file-1", "user-1", true, false)
.await; .await;
assert!( assert!(
result.is_ok(), result.is_ok(),
"stub should return Ok for get_file_optimized_owned" "stub should return Ok for get_file_optimized_owned"
); );
} }
+2 -2
View File
@@ -20,10 +20,10 @@ pub mod trash_service;
pub mod wopi_lock_service; pub mod wopi_lock_service;
pub mod wopi_token_service; pub mod wopi_token_service;
#[cfg(all(test, feature = "integration_tests"))]
mod trash_service_test;
#[cfg(test)] #[cfg(test)]
mod idor_protection_test; mod idor_protection_test;
#[cfg(all(test, integration_tests))]
mod trash_service_test;
// Re-exportar para facilitar acceso // Re-exportar para facilitar acceso
pub use file_management_service::FileManagementService; pub use file_management_service::FileManagementService;
+1 -1
View File
@@ -1,9 +1,9 @@
use crate::application::dtos::recent_dto::RecentItemDto; use crate::application::dtos::recent_dto::RecentItemDto;
use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase}; use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase};
use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
use std::sync::Arc; use std::sync::Arc;
use tracing::info; use tracing::info;
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
/// Implementation of the use case for managing recent items. /// Implementation of the use case for managing recent items.
/// ///
+2 -2
View File
@@ -15,10 +15,10 @@ use crate::application::ports::inbound::SearchUseCase;
use crate::application::ports::storage_ports::FileReadPort; use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::Result; use crate::common::errors::Result;
use crate::domain::entities::folder::Folder; use crate::domain::entities::folder::Folder;
use std::hash::{Hash, Hasher}; use crate::domain::repositories::folder_repository::FolderRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::domain::repositories::folder_repository::FolderRepository; use std::hash::{Hash, Hasher};
/** /**
* High-performance search service implementation for files and folders. * High-performance search service implementation for files and folders.
+6 -6
View File
@@ -3,6 +3,11 @@ use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use crate::domain::repositories::folder_repository::FolderRepository;
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::{ use crate::{
application::{ application::{
dtos::{ dtos::{
@@ -18,11 +23,6 @@ use crate::{
common::{config::AppConfig, errors::DomainError}, common::{config::AppConfig, errors::DomainError},
domain::entities::share::{Share, ShareItemType, SharePermissions}, domain::entities::share::{Share, ShareItemType, SharePermissions},
}; };
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::repositories::pg::SharePgRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ShareServiceError { pub enum ShareServiceError {
@@ -394,7 +394,7 @@ impl ShareUseCase for ShareService {
} }
} }
#[cfg(feature = "integration_tests")] #[cfg(integration_tests)]
mod tests { mod tests {
use super::*; use super::*;
use crate::application::dtos::share_dto::SharePermissionsDto; use crate::application::dtos::share_dto::SharePermissionsDto;
@@ -1,11 +1,11 @@
use crate::application::ports::auth_ports::UserStoragePort; use crate::application::ports::auth_ports::UserStoragePort;
use crate::application::ports::storage_ports::StorageUsagePort; use crate::application::ports::storage_ports::StorageUsagePort;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::infrastructure::repositories::pg::UserPgRepository;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use tokio::task; use tokio::task;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
use crate::infrastructure::repositories::pg::UserPgRepository;
/** /**
* Service for managing and updating user storage usage statistics. * Service for managing and updating user storage usage statistics.
+1 -1
View File
@@ -7,12 +7,12 @@ use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository; use crate::domain::repositories::trash_repository::TrashRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository; use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
/** /**
* Application service for trash operations. * Application service for trash operations.
@@ -499,7 +499,7 @@ impl FolderRepository for MockFolderRepository {
} }
} }
#[cfg(feature = "integration_tests")] #[cfg(integration_tests)]
mod tests { mod tests {
use super::*; use super::*;
use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::trash_ports::TrashUseCase;
+57 -87
View File
@@ -31,28 +31,27 @@ use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nSer
use crate::infrastructure::services::path_service::PathService; use crate::infrastructure::services::path_service::PathService;
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
use crate::application::services::device_auth_service::DeviceAuthService;
use crate::application::services::app_password_service::AppPasswordService; use crate::application::services::app_password_service::AppPasswordService;
use crate::application::services::calendar_service::CalendarService; use crate::application::services::calendar_service::CalendarService;
use crate::application::services::device_auth_service::DeviceAuthService;
use crate::application::services::storage_usage_service::StorageUsageService; use crate::application::services::storage_usage_service::StorageUsageService;
use crate::application::services::wopi_lock_service::WopiLockService;
use crate::application::services::wopi_token_service::WopiTokenService;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
use crate::infrastructure::repositories::DeviceCodePgRepository;
use crate::infrastructure::repositories::AppPasswordPgRepository; use crate::infrastructure::repositories::AppPasswordPgRepository;
use crate::infrastructure::repositories::DeviceCodePgRepository;
use crate::infrastructure::repositories::pg::{ use crate::infrastructure::repositories::pg::{
AddressBookPgRepository, CalendarEventPgRepository, CalendarPgRepository, AddressBookPgRepository, CalendarEventPgRepository, CalendarPgRepository,
ContactGroupPgRepository, ContactPgRepository, SessionPgRepository, UserPgRepository, ContactGroupPgRepository, ContactPgRepository, SessionPgRepository, UserPgRepository,
}; };
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use crate::infrastructure::services::jwt_service::JwtTokenService;
use crate::infrastructure::services::path_resolver_service::PathResolverService;
use crate::application::services::wopi_lock_service::WopiLockService;
use crate::application::services::wopi_token_service::WopiTokenService;
use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
use crate::infrastructure::services::chunked_upload_service::ChunkedUploadService; use crate::infrastructure::services::chunked_upload_service::ChunkedUploadService;
use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::dedup_service::DedupService;
use crate::infrastructure::services::image_transcode_service::ImageTranscodeService; use crate::infrastructure::services::image_transcode_service::ImageTranscodeService;
use crate::infrastructure::services::jwt_service::JwtTokenService;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use crate::infrastructure::services::path_resolver_service::PathResolverService;
use crate::infrastructure::services::thumbnail_service::ThumbnailService; use crate::infrastructure::services::thumbnail_service::ThumbnailService;
use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
use crate::infrastructure::services::zip_service::ZipService; use crate::infrastructure::services::zip_service::ZipService;
/// Factory for the different application components /// Factory for the different application components
@@ -185,17 +184,19 @@ impl AppServiceFactory {
let folder_repository: Arc<FolderDbRepository> = folder_repo_concrete.clone(); let folder_repository: Arc<FolderDbRepository> = folder_repo_concrete.clone();
// File repositories — PostgreSQL metadata + blob content via DedupService // File repositories — PostgreSQL metadata + blob content via DedupService
let file_read_repository: Arc<FileBlobReadRepository> = Arc::new(FileBlobReadRepository::new( let file_read_repository: Arc<FileBlobReadRepository> =
db_pool.clone(), Arc::new(FileBlobReadRepository::new(
core.dedup_service.clone(), db_pool.clone(),
folder_repo_concrete.clone(), core.dedup_service.clone(),
)); folder_repo_concrete.clone(),
));
let file_write_repository: Arc<FileBlobWriteRepository> = Arc::new(FileBlobWriteRepository::new( let file_write_repository: Arc<FileBlobWriteRepository> =
db_pool.clone(), Arc::new(FileBlobWriteRepository::new(
core.dedup_service.clone(), db_pool.clone(),
folder_repo_concrete.clone(), core.dedup_service.clone(),
)); folder_repo_concrete.clone(),
));
// I18n repository // I18n repository
let i18n_repository = Arc::new(FileSystemI18nService::new(self.locales_path.clone())); let i18n_repository = Arc::new(FileSystemI18nService::new(self.locales_path.clone()));
@@ -205,10 +206,7 @@ impl AppServiceFactory {
Some(Arc::new(TrashDbRepository::new( Some(Arc::new(TrashDbRepository::new(
db_pool.clone(), db_pool.clone(),
core.config.storage.trash_retention_days, core.config.storage.trash_retention_days,
)) )) as Arc<TrashDbRepository>)
as Arc<
TrashDbRepository,
>)
} else { } else {
None None
}; };
@@ -339,14 +337,13 @@ impl AppServiceFactory {
let share_repository = Arc::new(SharePgRepository::new(db_pool.clone())); let share_repository = Arc::new(SharePgRepository::new(db_pool.clone()));
// Build a password hasher for share password verification // Build a password hasher for share password verification
let password_hasher: Arc<Argon2PasswordHasher> = let password_hasher: Arc<Argon2PasswordHasher> = Arc::new(
Arc::new( crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( self.config.auth.hash_memory_cost,
self.config.auth.hash_memory_cost, self.config.auth.hash_time_cost,
self.config.auth.hash_time_cost, self.config.auth.hash_parallelism,
self.config.auth.hash_parallelism, ),
), );
);
let service = Arc::new(ShareService::new( let service = Arc::new(ShareService::new(
Arc::new(self.config.clone()), Arc::new(self.config.clone()),
@@ -464,9 +461,7 @@ impl AppServiceFactory {
// 6. Database-dependent services (PgPool always available in blob model) // 6. Database-dependent services (PgPool always available in blob model)
let favorites_service: Option<Arc<FavoritesService>>; let favorites_service: Option<Arc<FavoritesService>>;
let recent_service: Option<Arc<RecentService>>; let recent_service: Option<Arc<RecentService>>;
let storage_usage_service: Option< let storage_usage_service: Option<Arc<StorageUsageService>>;
Arc<StorageUsageService>,
>;
let mut auth_services: Option<crate::common::di::AuthServices> = None; let mut auth_services: Option<crate::common::di::AuthServices> = None;
{ {
@@ -550,7 +545,8 @@ impl AppServiceFactory {
device_auth_service: None, device_auth_service: None,
app_password_service: None, app_password_service: None,
path_resolver: None, path_resolver: None,
webdav_lock_store: crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(), webdav_lock_store:
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
setup_token: None, setup_token: None,
}; };
@@ -626,15 +622,11 @@ impl AppServiceFactory {
// 9c. Wire Device Authorization Grant (RFC 8628) service // 9c. Wire Device Authorization Grant (RFC 8628) service
{ {
let device_code_repo = Arc::new(DeviceCodePgRepository::new(pool.clone())); let device_code_repo = Arc::new(DeviceCodePgRepository::new(pool.clone()));
let user_repo: Arc<UserPgRepository> = let user_repo: Arc<UserPgRepository> = Arc::new(
Arc::new(crate::infrastructure::repositories::UserPgRepository::new( crate::infrastructure::repositories::UserPgRepository::new(pool.clone()),
pool.clone(), );
)); let session_repo: Arc<SessionPgRepository> = Arc::new(
let session_repo: Arc<
SessionPgRepository,
> = Arc::new(
crate::infrastructure::repositories::SessionPgRepository::new(pool.clone()), crate::infrastructure::repositories::SessionPgRepository::new(pool.clone()),
); );
let base_url = self.config.base_url(); let base_url = self.config.base_url();
@@ -652,22 +644,18 @@ impl AppServiceFactory {
// 9d. Wire App Password service // 9d. Wire App Password service
{ {
let app_pw_repo: Arc<AppPasswordPgRepository> =
let app_pw_repo: Arc< Arc::new(AppPasswordPgRepository::new(pool.clone()));
AppPasswordPgRepository, let hasher: Arc<Argon2PasswordHasher> = Arc::new(
> = Arc::new(AppPasswordPgRepository::new(pool.clone())); crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
let hasher: Arc<Argon2PasswordHasher> = self.config.auth.hash_memory_cost,
Arc::new( self.config.auth.hash_time_cost,
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( self.config.auth.hash_parallelism,
self.config.auth.hash_memory_cost, ),
self.config.auth.hash_time_cost, );
self.config.auth.hash_parallelism, let user_repo: Arc<UserPgRepository> = Arc::new(
), crate::infrastructure::repositories::UserPgRepository::new(pool.clone()),
); );
let user_repo: Arc<UserPgRepository> =
Arc::new(crate::infrastructure::repositories::UserPgRepository::new(
pool.clone(),
));
let base_url = self.config.base_url(); let base_url = self.config.base_url();
let app_pw_svc = Arc::new(AppPasswordService::new( let app_pw_svc = Arc::new(AppPasswordService::new(
@@ -690,14 +678,10 @@ impl AppServiceFactory {
// 10. Wire CalDAV/CardDAV services // 10. Wire CalDAV/CardDAV services
{ {
// CalDAV // CalDAV
let calendar_repo: Arc< let calendar_repo: Arc<CalendarPgRepository> = Arc::new(
CalendarPgRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()), crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()),
); );
let event_repo: Arc< let event_repo: Arc<CalendarEventPgRepository> = Arc::new(
CalendarEventPgRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::CalendarEventPgRepository::new( crate::infrastructure::repositories::pg::CalendarEventPgRepository::new(
pool.clone(), pool.clone(),
), ),
@@ -713,25 +697,16 @@ impl AppServiceFactory {
calendar_storage, calendar_storage,
), ),
); );
app_state.calendar_use_case = Some( app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
calendar_service
as Arc<CalendarService>,
);
// CardDAV // CardDAV
let address_book_repo: Arc< let address_book_repo: Arc<AddressBookPgRepository> = Arc::new(
AddressBookPgRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()), crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()),
); );
let contact_repo: Arc< let contact_repo: Arc<ContactPgRepository> = Arc::new(
ContactPgRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()), crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()),
); );
let group_repo: Arc< let group_repo: Arc<ContactGroupPgRepository> = Arc::new(
ContactGroupPgRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::ContactGroupPgRepository::new( crate::infrastructure::repositories::pg::ContactGroupPgRepository::new(
pool.clone(), pool.clone(),
), ),
@@ -757,7 +732,6 @@ impl AppServiceFactory {
"WOPI is enabled but WOPI_DISCOVERY_URL is empty — WOPI services will NOT be available" "WOPI is enabled but WOPI_DISCOVERY_URL is empty — WOPI services will NOT be available"
); );
} else { } else {
let wopi_secret = if self.config.wopi.secret.is_empty() { let wopi_secret = if self.config.wopi.secret.is_empty() {
self.config.auth.jwt_secret.clone() self.config.auth.jwt_secret.clone()
} else { } else {
@@ -811,8 +785,7 @@ pub struct RepositoryServices {
pub file_read_repository: Arc<FileBlobReadRepository>, pub file_read_repository: Arc<FileBlobReadRepository>,
pub file_write_repository: Arc<FileBlobWriteRepository>, pub file_write_repository: Arc<FileBlobWriteRepository>,
pub i18n_repository: Arc<FileSystemI18nService>, pub i18n_repository: Arc<FileSystemI18nService>,
pub trash_repository: pub trash_repository: Option<Arc<TrashDbRepository>>,
Option<Arc<TrashDbRepository>>,
} }
/// Container for application services /// Container for application services
@@ -858,14 +831,11 @@ pub struct AppState {
pub share_service: Option<Arc<ShareService>>, pub share_service: Option<Arc<ShareService>>,
pub favorites_service: Option<Arc<FavoritesService>>, pub favorites_service: Option<Arc<FavoritesService>>,
pub recent_service: Option<Arc<RecentService>>, pub recent_service: Option<Arc<RecentService>>,
pub storage_usage_service: pub storage_usage_service: Option<Arc<StorageUsageService>>,
Option<Arc<StorageUsageService>>,
pub calendar_service: Option<Arc<CalendarService>>, pub calendar_service: Option<Arc<CalendarService>>,
pub contact_service: Option<Arc<ContactStorageAdapter>>, pub contact_service: Option<Arc<ContactStorageAdapter>>,
pub calendar_use_case: pub calendar_use_case: Option<Arc<CalendarService>>,
Option<Arc<CalendarService>>, pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
pub addressbook_use_case:
Option<Arc<ContactStorageAdapter>>,
pub contact_use_case: Option<Arc<ContactStorageAdapter>>, pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
pub wopi_token_service: pub wopi_token_service:
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>, Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
+1 -2
View File
@@ -22,8 +22,7 @@ use crate::application::dtos::search_dto::{
SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto, SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto,
}; };
use crate::application::ports::file_ports::{ use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, OptimizedFileContent,
OptimizedFileContent,
}; };
use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; use crate::application::ports::inbound::{FolderUseCase, SearchUseCase};
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
+2 -1
View File
@@ -28,7 +28,7 @@ impl DeviceCodeStatus {
} }
} }
pub fn from_str(s: &str) -> Option<Self> { pub fn parse(s: &str) -> Option<Self> {
match s { match s {
"pending" => Some(Self::Pending), "pending" => Some(Self::Pending),
"authorized" => Some(Self::Authorized), "authorized" => Some(Self::Authorized),
@@ -76,6 +76,7 @@ impl DeviceCode {
/// * `verification_uri` — URL the user must visit /// * `verification_uri` — URL the user must visit
/// * `expires_in_secs` — TTL for the device code /// * `expires_in_secs` — TTL for the device code
/// * `poll_interval_secs` — minimum polling interval /// * `poll_interval_secs` — minimum polling interval
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
device_code: String, device_code: String,
user_code: String, user_code: String,
@@ -8,7 +8,6 @@
//! Concrete implementations (filesystem, PostgreSQL, S3, etc.) live in //! Concrete implementations (filesystem, PostgreSQL, S3, etc.) live in
//! the infrastructure layer. //! the infrastructure layer.
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::entities::folder::Folder; use crate::domain::entities::folder::Folder;
use crate::domain::services::path_service::StoragePath; use crate::domain::services::path_service::StoragePath;
+5 -7
View File
@@ -21,7 +21,10 @@ impl StoragePath {
/// Creates a new storage path, silently dropping any traversal segments /// Creates a new storage path, silently dropping any traversal segments
pub fn new(segments: Vec<String>) -> Self { pub fn new(segments: Vec<String>) -> Self {
Self { Self {
segments: segments.into_iter().filter(|s| Self::is_safe_segment(s)).collect(), segments: segments
.into_iter()
.filter(|s| Self::is_safe_segment(s))
.collect(),
} }
} }
@@ -186,12 +189,7 @@ mod tests {
#[test] #[test]
fn test_new_strips_traversal_segments() { fn test_new_strips_traversal_segments() {
let path = StoragePath::new(vec![ let path = StoragePath::new(vec!["..".into(), "etc".into(), ".".into(), "passwd".into()]);
"..".into(),
"etc".into(),
".".into(),
"passwd".into(),
]);
assert_eq!(path.segments(), &["etc", "passwd"]); assert_eq!(path.segments(), &["etc", "passwd"]);
} }
+3 -6
View File
@@ -126,8 +126,7 @@ async fn create_pool_with_retries(
if attempt >= MAX_ATTEMPTS { if attempt >= MAX_ATTEMPTS {
return Err(DbError(format!( return Err(DbError(format!(
"Error verifying PostgreSQL {} pool connection: {}", "Error verifying PostgreSQL {} pool connection: {}",
label, label, e
e
))); )));
} }
} }
@@ -143,8 +142,7 @@ async fn create_pool_with_retries(
if attempt >= MAX_ATTEMPTS { if attempt >= MAX_ATTEMPTS {
return Err(DbError(format!( return Err(DbError(format!(
"Error in PostgreSQL {} pool connection: {}", "Error in PostgreSQL {} pool connection: {}",
label, label, e
e
))); )));
} }
tokio::time::sleep(Duration::from_secs(2)).await; tokio::time::sleep(Duration::from_secs(2)).await;
@@ -154,8 +152,7 @@ async fn create_pool_with_retries(
Err(DbError(format!( Err(DbError(format!(
"Could not establish PostgreSQL {} pool connection after {} attempts", "Could not establish PostgreSQL {} pool connection after {} attempts",
label, label, MAX_ATTEMPTS
MAX_ATTEMPTS
))) )))
} }
@@ -25,7 +25,7 @@ impl DeviceCodePgRepository {
) )
})?; })?;
let status = DeviceCodeStatus::from_str(&status_str).unwrap_or(DeviceCodeStatus::Expired); let status = DeviceCodeStatus::parse(&status_str).unwrap_or(DeviceCodeStatus::Expired);
Ok(DeviceCode::from_raw( Ok(DeviceCode::from_raw(
row.try_get("id").unwrap_or_default(), row.try_get("id").unwrap_or_default(),
@@ -22,6 +22,19 @@ use crate::domain::entities::file::File;
use crate::domain::services::path_service::StoragePath; use crate::domain::services::path_service::StoragePath;
use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::dedup_service::DedupService;
/// Type alias for file metadata rows from SQL queries.
type FileRow = (
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
Option<String>,
);
/// File read repository backed by PostgreSQL metadata + blob storage. /// File read repository backed by PostgreSQL metadata + blob storage.
pub struct FileBlobReadRepository { pub struct FileBlobReadRepository {
pool: Arc<PgPool>, pool: Arc<PgPool>,
@@ -204,17 +217,7 @@ impl FileReadPort for FileBlobReadRepository {
} }
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> {
let rows: Vec<( let rows: Vec<FileRow> = if let Some(fid) = folder_id {
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
Option<String>,
)> = if let Some(fid) = folder_id {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
@@ -271,17 +274,7 @@ impl FileReadPort for FileBlobReadRepository {
offset: i64, offset: i64,
limit: i64, limit: i64,
) -> Result<Vec<File>, DomainError> { ) -> Result<Vec<File>, DomainError> {
let rows: Vec<( let rows: Vec<FileRow> = if let Some(fid) = folder_id {
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
Option<String>,
)> = if let Some(fid) = folder_id {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
@@ -853,17 +846,7 @@ impl FileReadPort for FileBlobReadRepository {
let pattern = format!("%{}%", query); let pattern = format!("%{}%", query);
let limit_i64 = limit as i64; let limit_i64 = limit as i64;
let rows: Vec<( let rows: Vec<FileRow> = if let Some(fid) = folder_id {
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
Option<String>,
)> = if let Some(fid) = folder_id {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
@@ -929,7 +912,7 @@ impl FileReadPort for FileBlobReadRepository {
} }
} }
#[cfg(feature = "integration_tests")] #[cfg(integration_tests)]
mod tests { mod tests {
use super::*; use super::*;
use crate::common::stubs::StubDedupPort; use crate::common::stubs::StubDedupPort;
@@ -16,6 +16,32 @@ use crate::domain::entities::folder::Folder;
use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::path_service::StoragePath; use crate::domain::services::path_service::StoragePath;
/// Type alias for folder metadata rows from SQL queries.
type FolderRow = (String, String, String, Option<String>, String, i64, i64);
/// Type alias for paginated folder rows (includes total_count).
type FolderRowPaginated = (
String,
String,
String,
Option<String>,
String,
i64,
i64,
i64,
);
/// Type alias for folder rows with optional user_id.
type FolderRowOptUser = (
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
);
/// PostgreSQL-backed folder repository. /// PostgreSQL-backed folder repository.
/// ///
/// All folder metadata lives in the `storage.folders` table. The physical /// All folder metadata lives in the `storage.folders` table. The physical
@@ -173,10 +199,9 @@ impl FolderRepository for FolderDbRepository {
} }
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> { async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
if let Some(pid) = parent_id { sqlx::query_as(
sqlx::query_as( r#"
r#"
SELECT id::text, name, path, parent_id::text, user_id, SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint EXTRACT(EPOCH FROM updated_at)::bigint
@@ -184,13 +209,13 @@ impl FolderRepository for FolderDbRepository {
WHERE parent_id = $1::uuid AND NOT is_trashed WHERE parent_id = $1::uuid AND NOT is_trashed
ORDER BY name ORDER BY name
"#, "#,
) )
.bind(pid) .bind(pid)
.fetch_all(self.pool()) .fetch_all(self.pool())
.await .await
} else { } else {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT id::text, name, path, parent_id::text, user_id, SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint EXTRACT(EPOCH FROM updated_at)::bigint
@@ -198,11 +223,11 @@ impl FolderRepository for FolderDbRepository {
WHERE parent_id IS NULL AND NOT is_trashed WHERE parent_id IS NULL AND NOT is_trashed
ORDER BY name ORDER BY name
"#, "#,
) )
.fetch_all(self.pool()) .fetch_all(self.pool())
.await .await
} }
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
rows.into_iter() rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| { .map(|(id, name, path, pid, uid, ca, ma)| {
@@ -216,10 +241,9 @@ impl FolderRepository for FolderDbRepository {
parent_id: Option<&str>, parent_id: Option<&str>,
owner_id: &str, owner_id: &str,
) -> Result<Vec<Folder>, DomainError> { ) -> Result<Vec<Folder>, DomainError> {
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
if let Some(pid) = parent_id { sqlx::query_as(
sqlx::query_as( r#"
r#"
SELECT id::text, name, path, parent_id::text, user_id, SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint EXTRACT(EPOCH FROM updated_at)::bigint
@@ -227,14 +251,14 @@ impl FolderRepository for FolderDbRepository {
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
ORDER BY name ORDER BY name
"#, "#,
) )
.bind(pid) .bind(pid)
.bind(owner_id) .bind(owner_id)
.fetch_all(self.pool()) .fetch_all(self.pool())
.await .await
} else { } else {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT id::text, name, path, parent_id::text, user_id, SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint EXTRACT(EPOCH FROM updated_at)::bigint
@@ -242,12 +266,12 @@ impl FolderRepository for FolderDbRepository {
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
ORDER BY name ORDER BY name
"#, "#,
) )
.bind(owner_id) .bind(owner_id)
.fetch_all(self.pool()) .fetch_all(self.pool())
.await .await
} }
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
rows.into_iter() rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| { .map(|(id, name, path, pid, uid, ca, ma)| {
@@ -266,16 +290,7 @@ impl FolderRepository for FolderDbRepository {
limit: usize, limit: usize,
include_total: bool, include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError> { ) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
let rows: Vec<( let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
String,
String,
String,
Option<String>,
String,
i64,
i64,
i64,
)> = if let Some(pid) = parent_id {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT id::text, name, path, parent_id::text, user_id, SELECT id::text, name, path, parent_id::text, user_id,
@@ -339,16 +354,7 @@ impl FolderRepository for FolderDbRepository {
limit: usize, limit: usize,
include_total: bool, include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError> { ) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
let rows: Vec<( let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
String,
String,
String,
Option<String>,
String,
i64,
i64,
i64,
)> = if let Some(pid) = parent_id {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT id::text, name, path, parent_id::text, user_id, SELECT id::text, name, path, parent_id::text, user_id,
@@ -689,15 +695,7 @@ impl FolderRepository for FolderDbRepository {
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \ AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
ORDER BY fo.path"; ORDER BY fo.path";
let rows: Vec<( let rows: Vec<FolderRowOptUser> = sqlx::query_as(sql)
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
)> = sqlx::query_as(sql)
.bind(folder_id) .bind(folder_id)
.fetch_all(self.pool()) .fetch_all(self.pool())
.await .await
@@ -760,15 +758,7 @@ impl FolderRepository for FolderDbRepository {
ORDER BY fo.name" ORDER BY fo.name"
); );
let rows: Vec<( let rows: Vec<FolderRowOptUser> = if let Some(ref pattern) = name_pattern {
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
)> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql) sqlx::query_as(&sql)
.bind(user_id) .bind(user_id)
.bind(pattern) .bind(pattern)
@@ -824,15 +814,7 @@ impl FolderRepository for FolderDbRepository {
) )
}; };
let rows: Vec<( let rows: Vec<FolderRowOptUser> = if let Some(pid) = parent_id {
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
)> = if let Some(pid) = parent_id {
if let Some(ref pattern) = name_pattern { if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql) sqlx::query_as(&sql)
.bind(pid) .bind(pid)
@@ -897,15 +879,7 @@ impl FolderRepository for FolderDbRepository {
ORDER BY fo.name" ORDER BY fo.name"
); );
let rows: Vec<( let rows: Vec<FolderRowOptUser> = if let Some(ref pattern) = name_pattern {
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
)> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql) sqlx::query_as(&sql)
.bind(user_id) .bind(user_id)
.bind(folder_id) .bind(folder_id)
@@ -937,10 +911,9 @@ impl FolderRepository for FolderDbRepository {
let pattern = format!("%{}%", query); let pattern = format!("%{}%", query);
let limit_i64 = limit as i64; let limit_i64 = limit as i64;
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
if let Some(pid) = parent_id { sqlx::query_as(
sqlx::query_as( r#"
r#"
SELECT id::text, name, path, parent_id::text, user_id, SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint EXTRACT(EPOCH FROM updated_at)::bigint
@@ -956,16 +929,16 @@ impl FolderRepository for FolderDbRepository {
name name
LIMIT $4 LIMIT $4
"#, "#,
) )
.bind(pid) .bind(pid)
.bind(&pattern) .bind(&pattern)
.bind(query) .bind(query)
.bind(limit_i64) .bind(limit_i64)
.fetch_all(self.pool()) .fetch_all(self.pool())
.await .await
} else { } else {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT id::text, name, path, parent_id::text, user_id, SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint EXTRACT(EPOCH FROM updated_at)::bigint
@@ -981,14 +954,14 @@ impl FolderRepository for FolderDbRepository {
name name
LIMIT $3 LIMIT $3
"#, "#,
) )
.bind(&pattern) .bind(&pattern)
.bind(query) .bind(query)
.bind(limit_i64) .bind(limit_i64)
.fetch_all(self.pool()) .fetch_all(self.pool())
.await .await
} }
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?; .map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
rows.into_iter() rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| { .map(|(id, name, path, pid, uid, ca, ma)| {
@@ -495,13 +495,12 @@ impl ChunkedUploadService {
// worker free for other connections. // worker free for other connections.
if let Some(ref expected_checksum) = checksum { if let Some(ref expected_checksum) = checksum {
let data_clone = data.clone(); // Bytes::clone is O(1) — just an Arc increment let data_clone = data.clone(); // Bytes::clone is O(1) — just an Arc increment
let actual_checksum = let actual_checksum = tokio::task::spawn_blocking(move || {
tokio::task::spawn_blocking(move || { use md5::{Digest, Md5};
use md5::{Md5, Digest}; format!("{:x}", Md5::digest(&data_clone))
format!("{:x}", Md5::digest(&data_clone)) })
}) .await
.await .map_err(|e| format!("MD5 checksum task failed: {e}"))?;
.map_err(|e| format!("MD5 checksum task failed: {e}"))?;
if actual_checksum != *expected_checksum { if actual_checksum != *expected_checksum {
return Err(format!( return Err(format!(
@@ -60,12 +60,12 @@ impl LoginLockoutService {
/// Returns `Ok(())` if the user may attempt login, or /// Returns `Ok(())` if the user may attempt login, or
/// `Err(remaining_secs)` with the *approximate* remaining lockout time. /// `Err(remaining_secs)` with the *approximate* remaining lockout time.
pub fn check(&self, username: &str) -> Result<(), u64> { pub fn check(&self, username: &str) -> Result<(), u64> {
if let Some(rec) = self.cache.get(&username.to_lowercase()) { if let Some(rec) = self.cache.get(&username.to_lowercase())
if rec.count >= self.max_failures { && rec.count >= self.max_failures
// The entry exists and is over the threshold. Because moka {
// evicts at TTL we know the lockout window has not yet elapsed. // The entry exists and is over the threshold. Because moka
return Err(self.lockout_secs); // evicts at TTL we know the lockout window has not yet elapsed.
} return Err(self.lockout_secs);
} }
Ok(()) Ok(())
} }
+9 -1
View File
@@ -312,7 +312,15 @@ mod tests {
#[test] #[test]
fn test_validate_path_rejects_dangerous_chars() { fn test_validate_path_rejects_dangerous_chars() {
let service = PathService::new(PathBuf::from("/storage")); let service = PathService::new(PathBuf::from("/storage"));
for dangerous in &["file:name", "file*name", "file?name", "file<name", "file>name", "file|name", "file\"name"] { for dangerous in &[
"file:name",
"file*name",
"file?name",
"file<name",
"file>name",
"file|name",
"file\"name",
] {
let path = StoragePath::new(vec![dangerous.to_string()]); let path = StoragePath::new(vec![dangerous.to_string()]);
assert!( assert!(
service.validate_path(&path).is_err(), service.validate_path(&path).is_err(),
+213 -218
View File
@@ -1,218 +1,213 @@
//! WebDAV lock store backed by Moka (in-memory cache with per-entry TTL). //! WebDAV lock store backed by Moka (in-memory cache with per-entry TTL).
//! //!
//! Locks are automatically evicted when their timeout expires, preventing //! Locks are automatically evicted when their timeout expires, preventing
//! orphaned locks from accumulating. Two caches are maintained: //! orphaned locks from accumulating. Two caches are maintained:
//! //!
//! - `by_path` : path → `LockEntry` (for LOCK conflict detection) //! - `by_path` : path → `LockEntry` (for LOCK conflict detection)
//! - `by_token` : token → path (for fast UNLOCK / refresh lookups) //! - `by_token` : token → path (for fast UNLOCK / refresh lookups)
//! //!
//! Both caches share the same TTL so entries disappear together. //! Both caches share the same TTL so entries disappear together.
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::application::adapters::webdav_adapter::{LockInfo, LockScope}; use crate::application::adapters::webdav_adapter::{LockInfo, LockScope};
/// Default lock timeout when the client does not specify one (RFC 4918 §10.7). /// Default lock timeout when the client does not specify one (RFC 4918 §10.7).
const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 1800; // 30 minutes const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 1800; // 30 minutes
/// Absolute maximum TTL a client may request. /// Absolute maximum TTL a client may request.
const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours
/// A stored lock entry. /// A stored lock entry.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct LockEntry { pub struct LockEntry {
pub info: LockInfo, pub info: LockInfo,
pub path: String, pub path: String,
} }
/// In-memory WebDAV lock store with automatic TTL-based expiration. /// In-memory WebDAV lock store with automatic TTL-based expiration.
/// ///
/// Uses Moka's `sync::Cache` — lock-free (sharded) reads, bounded size, /// Uses Moka's `sync::Cache` — lock-free (sharded) reads, bounded size,
/// and per-entry TTL via `policy::Expiry`. /// and per-entry TTL via `policy::Expiry`.
pub struct WebDavLockStore { pub struct WebDavLockStore {
/// path → `LockEntry` /// path → `LockEntry`
by_path: moka::sync::Cache<String, LockEntry>, by_path: moka::sync::Cache<String, LockEntry>,
/// token → path (reverse index) /// token → path (reverse index)
by_token: moka::sync::Cache<String, String>, by_token: moka::sync::Cache<String, String>,
} }
impl WebDavLockStore { impl WebDavLockStore {
/// Create a new lock store. /// Create a new lock store.
/// ///
/// * `max_capacity` — upper bound on simultaneous locks (evicts LRU on overflow). /// * `max_capacity` — upper bound on simultaneous locks (evicts LRU on overflow).
pub fn new(max_capacity: u64) -> Self { pub fn new(max_capacity: u64) -> Self {
// We use `expire_after` (per-entry TTL) via insert with explicit ttl, // We use `expire_after` (per-entry TTL) via insert with explicit ttl,
// so we configure a generous global time_to_live as a safety net. // so we configure a generous global time_to_live as a safety net.
let by_path = moka::sync::Cache::builder() let by_path = moka::sync::Cache::builder()
.max_capacity(max_capacity) .max_capacity(max_capacity)
.time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS)) .time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS))
.build(); .build();
let by_token = moka::sync::Cache::builder() let by_token = moka::sync::Cache::builder()
.max_capacity(max_capacity) .max_capacity(max_capacity)
.time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS)) .time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS))
.build(); .build();
Self { by_path, by_token } Self { by_path, by_token }
} }
// ── Public API ────────────────────────────────────────────── // ── Public API ──────────────────────────────────────────────
/// Attempt to acquire a lock on `path`. /// Attempt to acquire a lock on `path`.
/// ///
/// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource /// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource
/// is already exclusively locked by a different token. /// is already exclusively locked by a different token.
pub fn acquire( pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, Box<LockEntry>> {
&self, // Check for existing conflicting lock
path: &str, if let Some(existing) = self.by_path.get(path)
info: LockInfo, && existing.info.scope == LockScope::Exclusive
) -> Result<LockEntry, LockEntry> { {
// Check for existing conflicting lock return Err(Box::new(existing));
if let Some(existing) = self.by_path.get(path) { }
if existing.info.scope == LockScope::Exclusive {
return Err(existing); let ttl = Self::parse_timeout(info.timeout.as_deref());
} let entry = LockEntry {
} info,
path: path.to_owned(),
let ttl = Self::parse_timeout(info.timeout.as_deref()); };
let entry = LockEntry {
info, self.by_path.insert(path.to_owned(), entry.clone());
path: path.to_owned(), self.by_token
}; .insert(entry.info.token.clone(), path.to_owned());
self.by_path // Moka 0.12 does not expose per-entry set_expiration_after_insert at
.insert(path.to_owned(), entry.clone()); // insert time. We rely on the global `time_to_live` as an upper bound
self.by_token // and use the `invalidate_after` helper below for custom TTL.
.insert(entry.info.token.clone(), path.to_owned()); //
// To implement shorter-than-max TTL we schedule an async invalidation.
// Moka 0.12 does not expose per-entry set_expiration_after_insert at if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS {
// insert time. We rely on the global `time_to_live` as an upper bound let by_path = self.by_path.clone();
// and use the `invalidate_after` helper below for custom TTL. let by_token = self.by_token.clone();
// let token = entry.info.token.clone();
// To implement shorter-than-max TTL we schedule an async invalidation. let path_owned = path.to_owned();
if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS { tokio::spawn(async move {
let by_path = self.by_path.clone(); tokio::time::sleep(ttl).await;
let by_token = self.by_token.clone(); // Only remove if the entry still matches (wasn't refreshed/replaced)
let token = entry.info.token.clone(); if let Some(e) = by_path.get(&path_owned)
let path_owned = path.to_owned(); && e.info.token == token
tokio::spawn(async move { {
tokio::time::sleep(ttl).await; by_path.invalidate(&path_owned);
// Only remove if the entry still matches (wasn't refreshed/replaced) by_token.invalidate(&token);
if let Some(e) = by_path.get(&path_owned) { }
if e.info.token == token { });
by_path.invalidate(&path_owned); }
by_token.invalidate(&token);
} Ok(entry)
} }
});
} /// Refresh an existing lock (extend its timeout).
///
Ok(entry) /// Returns `Some(LockEntry)` with updated timeout, or `None` if the token
} /// is unknown (expired or never existed).
pub fn refresh(&self, token: &str, new_timeout: Option<&str>) -> Option<LockEntry> {
/// Refresh an existing lock (extend its timeout). let path = self.by_token.get(token)?;
/// let mut entry = self.by_path.get(&path)?;
/// Returns `Some(LockEntry)` with updated timeout, or `None` if the token
/// is unknown (expired or never existed). if entry.info.token != token {
pub fn refresh(&self, token: &str, new_timeout: Option<&str>) -> Option<LockEntry> { return None; // token mismatch — lock was replaced
let path = self.by_token.get(token)?; }
let mut entry = self.by_path.get(&path)?;
let ttl = Self::parse_timeout(new_timeout.or(entry.info.timeout.as_deref()));
if entry.info.token != token { let timeout_str = format!("Second-{}", ttl.as_secs());
return None; // token mismatch — lock was replaced entry.info.timeout = Some(timeout_str.clone());
}
// Re-insert to reset the TTL
let ttl = Self::parse_timeout(new_timeout.or(entry.info.timeout.as_deref())); self.by_path.insert(path.clone(), entry.clone());
let timeout_str = format!("Second-{}", ttl.as_secs()); self.by_token.insert(token.to_owned(), path.clone());
entry.info.timeout = Some(timeout_str.clone());
if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS {
// Re-insert to reset the TTL let by_path = self.by_path.clone();
self.by_path.insert(path.clone(), entry.clone()); let by_token = self.by_token.clone();
self.by_token.insert(token.to_owned(), path.clone()); let token_owned = token.to_owned();
let path_owned = path.clone();
if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS { tokio::spawn(async move {
let by_path = self.by_path.clone(); tokio::time::sleep(ttl).await;
let by_token = self.by_token.clone(); if let Some(e) = by_path.get(&path_owned)
let token_owned = token.to_owned(); && e.info.token == token_owned
let path_owned = path.clone(); {
tokio::spawn(async move { by_path.invalidate(&path_owned);
tokio::time::sleep(ttl).await; by_token.invalidate(&token_owned);
if let Some(e) = by_path.get(&path_owned) { }
if e.info.token == token_owned { });
by_path.invalidate(&path_owned); }
by_token.invalidate(&token_owned);
} Some(entry)
} }
});
} /// Release a lock by its token.
///
Some(entry) /// Returns `true` if the lock existed and was removed.
} pub fn release(&self, token: &str) -> bool {
if let Some(path) = self.by_token.get(token) {
/// Release a lock by its token. // Only remove from by_path if the token still matches
/// if let Some(entry) = self.by_path.get(&path)
/// Returns `true` if the lock existed and was removed. && entry.info.token == token
pub fn release(&self, token: &str) -> bool { {
if let Some(path) = self.by_token.get(token) { self.by_path.invalidate(&path);
// Only remove from by_path if the token still matches }
if let Some(entry) = self.by_path.get(&path) { self.by_token.invalidate(token);
if entry.info.token == token { true
self.by_path.invalidate(&path); } else {
} false
} }
self.by_token.invalidate(token); }
true
} else { /// Look up a lock by resource path.
false pub fn get_by_path(&self, path: &str) -> Option<LockEntry> {
} self.by_path.get(path)
} }
/// Look up a lock by resource path. /// Look up a lock by token.
pub fn get_by_path(&self, path: &str) -> Option<LockEntry> { pub fn get_by_token(&self, token: &str) -> Option<LockEntry> {
self.by_path.get(path) let path = self.by_token.get(token)?;
} self.by_path.get(&path)
}
/// Look up a lock by token.
pub fn get_by_token(&self, token: &str) -> Option<LockEntry> { // ── Helpers ─────────────────────────────────────────────────
let path = self.by_token.get(token)?;
self.by_path.get(&path) /// Parse a WebDAV `Timeout` header value into a [`Duration`].
} ///
/// Accepted formats (RFC 4918 §10.7):
// ── Helpers ───────────────────────────────────────────────── /// - `Second-NNN`
/// - `Infinite` (clamped to `MAX_LOCK_TIMEOUT_SECS`)
/// Parse a WebDAV `Timeout` header value into a [`Duration`]. /// - Comma-separated list (first value wins)
/// fn parse_timeout(header: Option<&str>) -> Duration {
/// Accepted formats (RFC 4918 §10.7): let raw = match header {
/// - `Second-NNN` Some(v) if !v.is_empty() => v,
/// - `Infinite` (clamped to `MAX_LOCK_TIMEOUT_SECS`) _ => return Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS),
/// - Comma-separated list (first value wins) };
fn parse_timeout(header: Option<&str>) -> Duration {
let raw = match header { // Take the first value in a comma-separated list
Some(v) if !v.is_empty() => v, let first = raw.split(',').next().unwrap_or(raw).trim();
_ => return Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS),
}; if first.eq_ignore_ascii_case("Infinite") {
return Duration::from_secs(MAX_LOCK_TIMEOUT_SECS);
// Take the first value in a comma-separated list }
let first = raw.split(',').next().unwrap_or(raw).trim();
if let Some(secs_str) = first.strip_prefix("Second-")
if first.eq_ignore_ascii_case("Infinite") { && let Ok(secs) = secs_str.trim().parse::<u64>()
return Duration::from_secs(MAX_LOCK_TIMEOUT_SECS); {
} return Duration::from_secs(secs.min(MAX_LOCK_TIMEOUT_SECS));
}
if let Some(secs_str) = first.strip_prefix("Second-") {
if let Ok(secs) = secs_str.trim().parse::<u64>() { Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS)
return Duration::from_secs(secs.min(MAX_LOCK_TIMEOUT_SECS)); }
} }
}
/// Create a shared lock store wrapped in `Arc` for embedding in `AppState`.
Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS) pub fn create_webdav_lock_store() -> Arc<WebDavLockStore> {
} // 10 000 simultaneous locks should be more than enough; Moka evicts LRU
} // if the cap is reached, so stale entries are cleaned automatically.
Arc::new(WebDavLockStore::new(10_000))
/// Create a shared lock store wrapped in `Arc` for embedding in `AppState`. }
pub fn create_webdav_lock_store() -> Arc<WebDavLockStore> {
// 10 000 simultaneous locks should be more than enough; Moka evicts LRU
// if the cap is reached, so stale entries are cleaned automatically.
Arc::new(WebDavLockStore::new(10_000))
}
+2 -2
View File
@@ -1,3 +1,5 @@
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
use crate::{ use crate::{
application::dtos::file_dto::FileDto, application::dtos::file_dto::FileDto,
application::ports::file_ports::FileRetrievalUseCase, application::ports::file_ports::FileRetrievalUseCase,
@@ -16,8 +18,6 @@ use thiserror::Error;
use tokio::io::BufWriter; use tokio::io::BufWriter;
use tokio_util::compat::Compat; use tokio_util::compat::Compat;
use tracing::*; use tracing::*;
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
/// Error related to ZIP file creation /// Error related to ZIP file creation
#[derive(Debug, Error)] #[derive(Debug, Error)]
+1 -1
View File
@@ -11,10 +11,10 @@ use crate::application::dtos::settings_dto::{
SaveOidcSettingsDto, TestOidcConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, SaveOidcSettingsDto, TestOidcConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
UpdateUserRoleDto, UpdateUserRoleDto,
}; };
use crate::application::ports::auth_ports::TokenServicePort;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use std::sync::Arc; use std::sync::Arc;
use crate::application::ports::auth_ports::TokenServicePort;
/// Admin API routes — all require admin role. /// Admin API routes — all require admin role.
pub fn admin_routes() -> Router<Arc<AppState>> { pub fn admin_routes() -> Router<Arc<AppState>> {
@@ -1,84 +1,81 @@
//! HTTP handlers for App Password management. //! HTTP handlers for App Password management.
//! //!
//! All endpoints require JWT authentication (the user must be logged in to //! All endpoints require JWT authentication (the user must be logged in to
//! create/list/revoke their app passwords). //! create/list/revoke their app passwords).
use crate::application::dtos::app_password_dto::CreateAppPasswordRequestDto; use crate::application::dtos::app_password_dto::CreateAppPasswordRequestDto;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::middleware::auth::CurrentUser;
use axum::extract::State; use axum::extract::State;
use axum::routing::{delete, get, post}; use axum::routing::{delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use std::sync::Arc; use std::sync::Arc;
/// Protected routes — require JWT auth middleware. /// Protected routes — require JWT auth middleware.
pub fn app_password_routes() -> Router<Arc<AppState>> { pub fn app_password_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/app-passwords", post(create_app_password)) .route("/app-passwords", post(create_app_password))
.route("/app-passwords", get(list_app_passwords)) .route("/app-passwords", get(list_app_passwords))
.route("/app-passwords/{id}", delete(revoke_app_password)) .route("/app-passwords/{id}", delete(revoke_app_password))
} }
/// POST /api/auth/app-passwords — Create a new app password. /// POST /api/auth/app-passwords — Create a new app password.
/// ///
/// Returns the plain-text password ONCE. The user must copy it immediately. /// Returns the plain-text password ONCE. The user must copy it immediately.
async fn create_app_password( async fn create_app_password(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::Extension(user): axum::Extension<CurrentUser>, axum::Extension(user): axum::Extension<CurrentUser>,
Json(request): Json<CreateAppPasswordRequestDto>, Json(request): Json<CreateAppPasswordRequestDto>,
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordCreatedResponseDto>, AppError> ) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordCreatedResponseDto>, AppError>
{ {
let service = state let service = state
.app_password_service .app_password_service
.as_ref() .as_ref()
.ok_or_else(|| AppError::internal_error("App password service not configured"))?; .ok_or_else(|| AppError::internal_error("App password service not configured"))?;
let response = service let response = service
.create(&user.id, request) .create(&user.id, request)
.await .await
.map_err(|e| AppError::from(e))?; .map_err(AppError::from)?;
Ok(Json(response)) Ok(Json(response))
} }
/// GET /api/auth/app-passwords — List all app passwords for the current user. /// GET /api/auth/app-passwords — List all app passwords for the current user.
/// ///
/// Never returns plain-text passwords (only prefix + metadata). /// Never returns plain-text passwords (only prefix + metadata).
async fn list_app_passwords( async fn list_app_passwords(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::Extension(user): axum::Extension<CurrentUser>, axum::Extension(user): axum::Extension<CurrentUser>,
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordListResponseDto>, AppError> ) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordListResponseDto>, AppError>
{ {
let service = state let service = state
.app_password_service .app_password_service
.as_ref() .as_ref()
.ok_or_else(|| AppError::internal_error("App password service not configured"))?; .ok_or_else(|| AppError::internal_error("App password service not configured"))?;
let response = service let response = service.list(&user.id).await.map_err(AppError::from)?;
.list(&user.id)
.await Ok(Json(response))
.map_err(|e| AppError::from(e))?; }
Ok(Json(response)) /// DELETE /api/auth/app-passwords/:id — Revoke an app password.
} async fn revoke_app_password(
State(state): State<Arc<AppState>>,
/// DELETE /api/auth/app-passwords/:id — Revoke an app password. axum::Extension(user): axum::Extension<CurrentUser>,
async fn revoke_app_password( axum::extract::Path(id): axum::extract::Path<String>,
State(state): State<Arc<AppState>>, ) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordRevokeResponseDto>, AppError>
axum::Extension(user): axum::Extension<CurrentUser>, {
axum::extract::Path(id): axum::extract::Path<String>, let service = state
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordRevokeResponseDto>, AppError> .app_password_service
{ .as_ref()
let service = state .ok_or_else(|| AppError::internal_error("App password service not configured"))?;
.app_password_service
.as_ref() let response = service
.ok_or_else(|| AppError::internal_error("App password service not configured"))?; .revoke(&user.id, &id)
.await
let response = service .map_err(AppError::from)?;
.revoke(&user.id, &id)
.await Ok(Json(response))
.map_err(|e| AppError::from(e))?; }
Ok(Json(response))
}
+1 -1
View File
@@ -150,7 +150,7 @@ async fn login(
); );
return Err(AppError::new( return Err(AppError::new(
StatusCode::TOO_MANY_REQUESTS, StatusCode::TOO_MANY_REQUESTS,
&format!( format!(
"Account temporarily locked due to too many failed attempts. Try again in {} seconds.", "Account temporarily locked due to too many failed attempts. Try again in {} seconds.",
lockout_secs lockout_secs
), ),
@@ -32,10 +32,10 @@ use crate::application::dtos::calendar_dto::{
CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto, CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto,
}; };
use crate::application::ports::calendar_ports::CalendarUseCase; use crate::application::ports::calendar_ports::CalendarUseCase;
use crate::application::services::calendar_service::CalendarService;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::middleware::auth::CurrentUser;
use crate::application::services::calendar_service::CalendarService;
const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
@@ -33,9 +33,9 @@ use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAdd
use crate::application::dtos::contact_dto::CreateContactVCardDto; use crate::application::dtos::contact_dto::CreateContactVCardDto;
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::middleware::auth::CurrentUser;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
@@ -17,13 +17,13 @@ use bytes::Bytes;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use crate::application::ports::chunked_upload_ports::ChunkedUploadPort;
use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE; use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE;
use crate::application::ports::file_ports::FileUploadUseCase;
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::domain::errors::ErrorKind; use crate::domain::errors::ErrorKind;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
use crate::application::ports::chunked_upload_ports::ChunkedUploadPort;
use crate::application::ports::file_ports::FileUploadUseCase;
use crate::application::ports::storage_ports::StorageUsagePort;
/// Request body for creating an upload session /// Request body for creating an upload session
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -9,8 +9,8 @@ use std::sync::Arc;
use tracing::{error, info}; use tracing::{error, info};
use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::interfaces::middleware::auth::AuthUser;
use crate::application::services::favorites_service::FavoritesService; use crate::application::services::favorites_service::FavoritesService;
use crate::interfaces::middleware::auth::AuthUser;
/// Single item in a batch-add-favorites request. /// Single item in a batch-add-favorites request.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
+15 -6
View File
@@ -11,12 +11,14 @@ use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::OptimizedFileContent;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
};
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::application::ports::thumbnail_ports::ThumbnailPort;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc; use std::sync::Arc;
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase};
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::application::ports::thumbnail_ports::ThumbnailPort;
/** /**
* Type aliases for dependency injection state. * Type aliases for dependency injection state.
@@ -283,7 +285,10 @@ impl FileHandler {
} }
}; };
let file = match file_retrieval_service.get_file_owned(&id, &auth_user.id).await { let file = match file_retrieval_service
.get_file_owned(&id, &auth_user.id)
.await
{
Ok(f) => f, Ok(f) => f,
Err(err) => { Err(err) => {
return ( return (
@@ -678,7 +683,8 @@ impl FileHandler {
let mgmt = &state.applications.file_management_service; let mgmt = &state.applications.file_management_service;
// Auth required: trash-first with dedup cleanup + ownership verification // Auth required: trash-first with dedup cleanup + ownership verification
let result = mgmt.delete_with_cleanup(&id, &auth_user.id) let result = mgmt
.delete_with_cleanup(&id, &auth_user.id)
.await .await
.map(|was_trashed| { .map(|was_trashed| {
if was_trashed { if was_trashed {
@@ -771,7 +777,10 @@ impl FileHandler {
let mgmt = &state.applications.file_management_service; let mgmt = &state.applications.file_management_service;
match mgmt.move_file_owned(&id, &auth_user.id, payload.folder_id).await { match mgmt
.move_file_owned(&id, &auth_user.id, payload.folder_id)
.await
{
Ok(file) => (StatusCode::OK, Json(file)).into_response(), Ok(file) => (StatusCode::OK, Json(file)).into_response(),
Err(err) => { Err(err) => {
tracing::error!("Error moving file: {}", err); tracing::error!("Error moving file: {}", err);
@@ -13,13 +13,13 @@ use tokio_util::io::ReaderStream;
use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto}; use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto};
use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::dtos::pagination::PaginationRequestDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::inbound::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::folder_service::FolderService; use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState as GlobalAppState; use crate::common::di::AppState as GlobalAppState;
use crate::common::errors::ErrorKind; use crate::common::errors::ErrorKind;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
type AppState = Arc<FolderService>; type AppState = Arc<FolderService>;
@@ -9,8 +9,8 @@ use std::sync::Arc;
use tracing::{error, info}; use tracing::{error, info};
use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::application::ports::recent_ports::RecentItemsUseCase;
use crate::interfaces::middleware::auth::AuthUser;
use crate::application::services::recent_service::RecentService; use crate::application::services::recent_service::RecentService;
use crate::interfaces::middleware::auth::AuthUser;
/// Query parameters for getting recent items /// Query parameters for getting recent items
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -7,10 +7,10 @@ use serde_json::json;
use tracing::{error, info}; use tracing::{error, info};
use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc; use std::sync::Arc;
use crate::application::ports::inbound::SearchUseCase;
/** /**
* Handler for search operations through the API. * Handler for search operations through the API.
+1 -1
View File
@@ -9,6 +9,7 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use crate::application::services::share_service::ShareService;
use crate::{ use crate::{
application::{ application::{
dtos::share_dto::{CreateShareDto, UpdateShareDto}, dtos::share_dto::{CreateShareDto, UpdateShareDto},
@@ -18,7 +19,6 @@ use crate::{
domain::entities::share::ShareItemType, domain::entities::share::ShareItemType,
interfaces::middleware::auth::OptionalAuthUser, interfaces::middleware::auth::OptionalAuthUser,
}; };
use crate::application::services::share_service::ShareService;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct GetSharesQuery { pub struct GetSharesQuery {
@@ -17,22 +17,20 @@ use chrono::Utc;
use quick_xml::Writer; use quick_xml::Writer;
use uuid::Uuid; use uuid::Uuid;
use crate::application::adapters::webdav_adapter::{ use crate::application::adapters::webdav_adapter::{LockInfo, PropFindRequest, WebDavAdapter};
LockInfo, PropFindRequest, WebDavAdapter,
};
use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::folder_dto::FolderDto;
use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUseCase};
use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::inbound::FolderUseCase;
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::infrastructure::services::path_resolver_service::ResolvedResource;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::middleware::auth::CurrentUser;
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use std::sync::Arc; use std::sync::Arc;
use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUseCase};
/// Characters that MUST NOT be percent-encoded inside a URI path segment. /// Characters that MUST NOT be percent-encoded inside a URI path segment.
/// RFC 3986 §3.3 pchar = unreserved / pct-encoded / sub-delims / ":" / "@" /// RFC 3986 §3.3 pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
@@ -65,7 +63,7 @@ fn encode_path_segment(segment: &str) -> String {
/// Percent-encode a full slash-separated path, encoding each segment individually. /// Percent-encode a full slash-separated path, encoding each segment individually.
pub(crate) fn encode_uri_path(path: &str) -> String { pub(crate) fn encode_uri_path(path: &str) -> String {
path.split('/') path.split('/')
.map(|seg| encode_path_segment(seg)) .map(encode_path_segment)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("/") .join("/")
} }
+1 -1
View File
@@ -20,10 +20,10 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
use crate::application::services::wopi_lock_service::WopiLockService; use crate::application::services::wopi_lock_service::WopiLockService;
use crate::application::services::wopi_token_service::WopiTokenService; use crate::application::services::wopi_token_service::WopiTokenService;
use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
/// Shared state for WOPI handlers. /// Shared state for WOPI handlers.
#[derive(Clone)] #[derive(Clone)]
+1 -2
View File
@@ -1,3 +1,4 @@
use crate::application::services::batch_operations::BatchOperationService;
use crate::common::di::AppState; use crate::common::di::AppState;
use axum::{ use axum::{
Router, Router,
@@ -8,7 +9,6 @@ use axum::{
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use tower_http::{compression::CompressionLayer, trace::TraceLayer}; use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use crate::application::services::batch_operations::BatchOperationService;
/// Returns the application version from Cargo.toml (compile-time constant) /// Returns the application version from Cargo.toml (compile-time constant)
async fn get_version() -> AxumJson<serde_json::Value> { async fn get_version() -> AxumJson<serde_json::Value> {
@@ -18,7 +18,6 @@ async fn get_version() -> AxumJson<serde_json::Value> {
})) }))
} }
use crate::interfaces::api::handlers::admin_handler; use crate::interfaces::api::handlers::admin_handler;
use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState}; use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState};
use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler;
+21 -22
View File
@@ -273,30 +273,29 @@ pub async fn auth_middleware(
if let Some(token_str) = if let Some(token_str) =
cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE) cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE)
&& !token_str.is_empty()
{ {
if !token_str.is_empty() { tracing::debug!("Processing cookie-based authentication");
tracing::debug!("Processing cookie-based authentication");
if let Some(auth_service) = state.auth_service.as_ref() { if let Some(auth_service) = state.auth_service.as_ref() {
let token_service = &auth_service.token_service; let token_service = &auth_service.token_service;
match token_service.validate_token(&token_str) { match token_service.validate_token(&token_str) {
Ok(claims) => { Ok(claims) => {
tracing::debug!("Cookie token validated for user: {}", claims.username); tracing::debug!("Cookie token validated for user: {}", claims.username);
let current_user = CurrentUser { let current_user = CurrentUser {
id: claims.sub, id: claims.sub,
username: claims.username, username: claims.username,
email: claims.email, email: claims.email,
role: claims.role, role: claims.role,
}; };
request.extensions_mut().insert(current_user); request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated); request.extensions_mut().insert(CookieAuthenticated);
return Ok(next.run(request).await); return Ok(next.run(request).await);
} }
Err(e) => { Err(e) => {
tracing::debug!("Cookie token validation failed: {}", e); tracing::debug!("Cookie token validation failed: {}", e);
// Don't return error — fall through to "no token" so // Don't return error — fall through to "no token" so
// the browser gets a 401 and can redirect to /login. // the browser gets a 401 and can redirect to /login.
}
} }
} }
} }
+7 -6
View File
@@ -57,6 +57,7 @@ impl RateLimiter {
/// Check whether the IP is allowed. Returns `Ok(current_count)` or /// Check whether the IP is allowed. Returns `Ok(current_count)` or
/// `Err(StatusCode::TOO_MANY_REQUESTS)`. /// `Err(StatusCode::TOO_MANY_REQUESTS)`.
#[allow(clippy::result_unit_err)]
pub fn check_and_increment(&self, ip: &str) -> Result<u32, ()> { pub fn check_and_increment(&self, ip: &str) -> Result<u32, ()> {
let key = ip.to_string(); let key = ip.to_string();
// moka's entry API lets us atomically read-modify-write. // moka's entry API lets us atomically read-modify-write.
@@ -92,12 +93,12 @@ pub fn extract_client_ip<B>(req: &Request<B>) -> String {
let headers = req.headers(); let headers = req.headers();
// 1. X-Forwarded-For (first entry — closest to the client) // 1. X-Forwarded-For (first entry — closest to the client)
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
if let Some(first) = xff.split(',').next() { && let Some(first) = xff.split(',').next()
let ip = first.trim(); {
if !ip.is_empty() { let ip = first.trim();
return ip.to_string(); if !ip.is_empty() {
} return ip.to_string();
} }
} }