diff --git a/Cargo.toml b/Cargo.toml index 0cb22bbf..08a945c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,9 @@ socket2 = { version = "0.6.2", features = ["all"] } [features] default = [] test_utils = ["mockall"] -integration_tests = [] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } [profile.release] lto = "fat" diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 271f65db..0d29b645 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -6,10 +6,10 @@ use std::sync::Arc; use crate::application::dtos::file_dto::FileDto; 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_retrieval_service::FileRetrievalService; use crate::application::services::file_upload_service::FileUploadService; +use crate::common::errors::DomainError; // ───────────────────────────────────────────────────── // Upload port diff --git a/src/application/ports/share_ports.rs b/src/application/ports/share_ports.rs index 7f9a5943..0b0ecca2 100644 --- a/src/application/ports/share_ports.rs +++ b/src/application/ports/share_ports.rs @@ -1,4 +1,3 @@ - use crate::{ application::dtos::{ pagination::PaginatedResponseDto, diff --git a/src/application/ports/trash_ports.rs b/src/application/ports/trash_ports.rs index b09466ce..b459f981 100644 --- a/src/application/ports/trash_ports.rs +++ b/src/application/ports/trash_ports.rs @@ -1,4 +1,3 @@ - use crate::application::dtos::trash_dto::TrashedItemDto; use crate::common::errors::Result; diff --git a/src/application/services/admin_settings_service.rs b/src/application/services/admin_settings_service.rs index c267899a..8253930e 100644 --- a/src/application/services/admin_settings_service.rs +++ b/src/application/services/admin_settings_service.rs @@ -7,8 +7,8 @@ use crate::application::services::auth_application_service::AuthApplicationServi use crate::common::config::OidcConfig; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::repositories::settings_repository::SettingsRepository; -use crate::infrastructure::services::oidc_service::OidcService; use crate::infrastructure::repositories::pg::SettingsPgRepository; +use crate::infrastructure::services::oidc_service::OidcService; /// Admin settings service — manages platform configuration in the database. /// diff --git a/src/application/services/app_password_service.rs b/src/application/services/app_password_service.rs index 61e951e0..4136ce15 100644 --- a/src/application/services/app_password_service.rs +++ b/src/application/services/app_password_service.rs @@ -9,13 +9,13 @@ use crate::application::ports::auth_ports::{ }; use crate::common::errors::DomainError; 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 moka::future::Cache; use std::sync::Arc; 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). const TOKEN_LENGTH: usize = 32; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index e9df0677..5163eaed 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -6,21 +6,21 @@ use crate::application::ports::auth_ports::{ UserStoragePort, }; use crate::application::ports::inbound::FolderUseCase; +use crate::application::services::folder_service::FolderService; use crate::common::config::OidcConfig; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::entities::session::Session; 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 std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; 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) #[derive(Clone)] @@ -145,11 +145,7 @@ impl AuthApplicationService { } /// Configures the OIDC service - pub fn with_oidc( - self, - oidc_service: Arc, - oidc_config: OidcConfig, - ) -> Self { + pub fn with_oidc(self, oidc_service: Arc, oidc_config: OidcConfig) -> Self { { let mut state = self.oidc.write().unwrap(); state.service = Some(oidc_service); @@ -308,12 +304,7 @@ impl AuthApplicationService { } // Check email uniqueness - if self - .user_storage - .get_user_by_email(&email) - .await - .is_ok() - { + if self.user_storage.get_user_by_email(&email).await.is_ok() { return Err(DomainError::new( ErrorKind::AlreadyExists, "User", @@ -614,7 +605,6 @@ impl AuthApplicationService { Ok(users.into_iter().map(UserDto::from).collect()) } - // ======================================================================== // Admin User Management Methods // ======================================================================== diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 0ba9aead..93f03001 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -14,12 +14,12 @@ use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto}; use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase}; use crate::application::ports::inbound::FolderUseCase; 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_retrieval_service::FileRetrievalService; +use crate::application::services::folder_service::FolderService; use crate::application::services::trash_service::TrashService; +use crate::common::config::AppConfig; +use crate::common::errors::DomainError; /// Specific errors for batch operations #[derive(Debug, Error)] @@ -1007,7 +1007,7 @@ impl BatchOperationService { } } -#[cfg(feature = "integration_tests")] +#[cfg(integration_tests)] mod tests { use super::*; use crate::common::stubs::{StubFileManagementUseCase, StubFileRetrievalUseCase}; diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs index 2f03c950..3b95e634 100644 --- a/src/application/services/device_auth_service.rs +++ b/src/application/services/device_auth_service.rs @@ -19,9 +19,9 @@ use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus}; use crate::domain::entities::session::Session; use crate::infrastructure::repositories::pg::DeviceCodePgRepository; -use crate::infrastructure::services::jwt_service::JwtTokenService; use crate::infrastructure::repositories::pg::SessionPgRepository; 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). const DEVICE_CODE_LIFETIME_SECS: i64 = 900; diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index e286f21a..1ea736bd 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -3,9 +3,9 @@ use crate::application::dtos::favorites_dto::{ }; use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase}; use crate::common::errors::{DomainError, ErrorKind, Result}; +use crate::infrastructure::repositories::pg::FavoritesPgRepository; use std::sync::Arc; use tracing::info; -use crate::infrastructure::repositories::pg::FavoritesPgRepository; /// Implementation of the FavoritesUseCase for managing user favorites. /// diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index b17cefe9..07555e7c 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -4,11 +4,11 @@ use crate::application::dtos::file_dto::FileDto; use crate::application::ports::file_ports::FileManagementUseCase; use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileReadPort, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; +use crate::application::services::trash_service::TrashService; 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_write_repository::FileBlobWriteRepository; -use crate::application::services::trash_service::TrashService; +use tracing::{error, info, warn}; /// Service for file management operations (move, delete). /// diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 4c28fa6a..af641083 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -7,10 +7,12 @@ use crate::application::dtos::file_dto::FileDto; use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent}; use crate::application::ports::storage_ports::FileReadPort; use crate::common::errors::DomainError; -use tracing::{debug, info}; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; 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). const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024; diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 87803a91..08abab1c 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -1,15 +1,14 @@ - use std::path::Path; use std::sync::Arc; use crate::application::dtos::file_dto::FileDto; use crate::application::ports::file_ports::FileUploadUseCase; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::services::storage_usage_service::StorageUsageService; use crate::common::errors::DomainError; -use tracing::{debug, info, warn}; use crate::infrastructure::repositories::pg::FileBlobReadRepository; 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. /// 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 file_read: Option>, /// Optional storage usage tracking - storage_usage_service: - Option>, + storage_usage_service: Option>, } impl FileUploadService { diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 6bbb627d..12065275 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -3,10 +3,10 @@ use crate::application::dtos::folder_dto::{ }; use crate::application::ports::inbound::FolderUseCase; 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::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 pub struct FolderService { diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index b2458b5a..535fc4f2 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -1,272 +1,272 @@ -//! Tests for IDOR (Insecure Direct Object Reference) protection. -//! -//! Verifies that ownership checks at the repository and service layers -//! correctly reject access when the caller is not the file owner. - -use bytes::Bytes; -use futures::Stream; -use std::collections::HashMap; -use std::pin::Pin; -use std::sync::Mutex; - -use crate::application::ports::storage_ports::{ - FileReadPort, -}; -use crate::common::errors::DomainError; -use crate::domain::entities::file::File; -use crate::domain::services::path_service::StoragePath; - -// ═══════════════════════════════════════════════════════════════════════════ -// Mock repositories -// ═══════════════════════════════════════════════════════════════════════════ - -/// A simple in-memory mock that maps (file_id → (File, owner_id)). -struct MockFileReadPort { - /// file_id → (File, owner_id) - files: Mutex>, -} - -impl MockFileReadPort { - fn new() -> Self { - Self { - files: Mutex::new(HashMap::new()), - } - } - - /// Insert a test file owned by `owner_id`. - fn insert(&self, id: &str, name: &str, owner_id: &str) { - let file = File::new( - id.to_string(), - name.to_string(), - StoragePath::from_string(&format!("/{}", name)), - 42, - "text/plain".to_string(), - None, - ) - .unwrap(); - self.files - .lock() - .unwrap() - .insert(id.to_string(), (file, owner_id.to_string())); - } -} - -impl FileReadPort for MockFileReadPort { - async fn get_file(&self, id: &str) -> Result { - let files = self.files.lock().unwrap(); - files - .get(id) - .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 { - let files = self.files.lock().unwrap(); - match files.get(id) { - Some((file, actual_owner)) if actual_owner == owner_id => Ok(file.clone()), - // 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, DomainError> { - Ok(Vec::new()) - } - - async fn get_file_stream( - &self, - _id: &str, - ) -> Result> + Send>, DomainError> { - unimplemented!() - } - - async fn get_file_range_stream( - &self, - _id: &str, - _start: u64, - _end: Option, - ) -> Result> + Send>, DomainError> { - unimplemented!() - } - - async fn get_file_path(&self, _id: &str) -> Result { - unimplemented!() - } - - async fn get_parent_folder_id(&self, _path: &str) -> Result { - unimplemented!() - } - - async fn get_blob_hash(&self, _file_id: &str) -> Result { - Ok(String::new()) - } - - async fn search_files_paginated( - &self, - _folder_id: Option<&str>, - _criteria: &crate::application::dtos::search_dto::SearchCriteriaDto, - _user_id: &str, - ) -> Result<(Vec, usize), DomainError> { - Ok((Vec::new(), 0)) - } - - async fn count_files( - &self, - _folder_id: Option<&str>, - _criteria: &crate::application::dtos::search_dto::SearchCriteriaDto, - _user_id: &str, - ) -> Result { - Ok(0) - } - - async fn stream_files_in_subtree( - &self, - _folder_id: &str, - ) -> Result< - Pin> + 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() { - let repo = MockFileReadPort::new(); - repo.insert("file-1", "secret.txt", "alice"); - - let result = repo.get_file_for_owner("file-1", "alice").await; - assert!(result.is_ok(), "owner should be able to read own file"); - assert_eq!(result.unwrap().id(), "file-1"); -} - -#[tokio::test] -async fn get_file_for_owner_rejects_wrong_owner() { - let repo = MockFileReadPort::new(); - repo.insert("file-1", "secret.txt", "alice"); - - let result = repo.get_file_for_owner("file-1", "bob").await; - assert!(result.is_err(), "non-owner should be rejected"); - - // Must be NotFound, NOT Forbidden — avoids leaking existence - let err = result.unwrap_err(); - let msg = format!("{}", err); - assert!( - msg.contains("not found") || msg.contains("NotFound"), - "error must be NotFound, got: {}", - msg - ); -} - -#[tokio::test] -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; - assert!(result.is_err()); -} - -#[tokio::test] -async fn verify_file_owner_uses_default_impl() { - let repo = MockFileReadPort::new(); - 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()); - assert!(repo.verify_file_owner("file-1", "bob").await.is_err()); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Tests — FileManagementService _owned methods (Service layer, Solution B) -// ═══════════════════════════════════════════════════════════════════════════ -// -// Note: FileManagementService::with_trash takes concrete types for the write -// repository (Arc). We cannot construct real PG repos -// 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. -// -// The tests below verify the *contract*: _owned methods must call -// verify_owner before delegating, and verify_owner must fail-closed when -// no read repo is available. - -#[tokio::test] -async fn verify_file_owner_delegates_to_read_port() { - // This test verifies the FileReadPort contract that verify_file_owner - // returns Ok for the correct owner and Err for others. - let read = MockFileReadPort::new(); - read.insert("abc-123", "report.pdf", "user-42"); - - // Same user → Ok - let ok = read.verify_file_owner("abc-123", "user-42").await; - assert!(ok.is_ok(), "correct owner should pass verify_file_owner"); - - // Different user → Err - let err = read.verify_file_owner("abc-123", "attacker-99").await; - assert!(err.is_err(), "wrong owner should fail verify_file_owner"); -} - -#[tokio::test] -async fn owned_methods_require_ownership_check_first() { - // 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(); - read.insert("file-1", "data.csv", "owner-a"); - - // Step 1: verify_owner for correct owner → Ok - let step1 = read.verify_file_owner("file-1", "owner-a").await; - 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; - assert!(step2.is_err()); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Tests — Trait-level _owned method stubs (StubFileManagementUseCase) -// ═══════════════════════════════════════════════════════════════════════════ - -use crate::application::ports::file_ports::FileManagementUseCase; -use crate::common::stubs::StubFileManagementUseCase; - -#[tokio::test] -async fn stub_move_file_owned_returns_ok() { - let stub = StubFileManagementUseCase; - let result = stub - .move_file_owned("file-1", "user-1", Some("folder-2".to_string())) - .await; - assert!(result.is_ok(), "stub should return Ok for move_file_owned"); -} - -#[tokio::test] -async fn stub_rename_file_owned_returns_ok() { - let stub = StubFileManagementUseCase; - let result = stub.rename_file_owned("file-1", "user-1", "new-name.txt").await; - assert!(result.is_ok(), "stub should return Ok for rename_file_owned"); -} - -use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::common::stubs::StubFileRetrievalUseCase; - -#[tokio::test] -async fn stub_get_file_owned_returns_ok() { - let stub = StubFileRetrievalUseCase; - let result = stub.get_file_owned("file-1", "user-1").await; - assert!(result.is_ok(), "stub should return Ok for get_file_owned"); -} - -#[tokio::test] -async fn stub_get_file_optimized_owned_returns_ok() { - let stub = StubFileRetrievalUseCase; - let result = stub - .get_file_optimized_owned("file-1", "user-1", true, false) - .await; - assert!( - result.is_ok(), - "stub should return Ok for get_file_optimized_owned" - ); -} +//! Tests for IDOR (Insecure Direct Object Reference) protection. +//! +//! Verifies that ownership checks at the repository and service layers +//! correctly reject access when the caller is not the file owner. + +use bytes::Bytes; +use futures::Stream; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Mutex; + +use crate::application::ports::storage_ports::FileReadPort; +use crate::common::errors::DomainError; +use crate::domain::entities::file::File; +use crate::domain::services::path_service::StoragePath; + +// ═══════════════════════════════════════════════════════════════════════════ +// Mock repositories +// ═══════════════════════════════════════════════════════════════════════════ + +/// A simple in-memory mock that maps (file_id → (File, owner_id)). +struct MockFileReadPort { + /// file_id → (File, owner_id) + files: Mutex>, +} + +impl MockFileReadPort { + fn new() -> Self { + Self { + files: Mutex::new(HashMap::new()), + } + } + + /// Insert a test file owned by `owner_id`. + fn insert(&self, id: &str, name: &str, owner_id: &str) { + let file = File::new( + id.to_string(), + name.to_string(), + StoragePath::from_string(&format!("/{}", name)), + 42, + "text/plain".to_string(), + None, + ) + .unwrap(); + self.files + .lock() + .unwrap() + .insert(id.to_string(), (file, owner_id.to_string())); + } +} + +impl FileReadPort for MockFileReadPort { + async fn get_file(&self, id: &str) -> Result { + let files = self.files.lock().unwrap(); + files + .get(id) + .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 { + let files = self.files.lock().unwrap(); + match files.get(id) { + Some((file, actual_owner)) if actual_owner == owner_id => Ok(file.clone()), + // 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, DomainError> { + Ok(Vec::new()) + } + + async fn get_file_stream( + &self, + _id: &str, + ) -> Result> + Send>, DomainError> { + unimplemented!() + } + + async fn get_file_range_stream( + &self, + _id: &str, + _start: u64, + _end: Option, + ) -> Result> + Send>, DomainError> { + unimplemented!() + } + + async fn get_file_path(&self, _id: &str) -> Result { + unimplemented!() + } + + async fn get_parent_folder_id(&self, _path: &str) -> Result { + unimplemented!() + } + + async fn get_blob_hash(&self, _file_id: &str) -> Result { + Ok(String::new()) + } + + async fn search_files_paginated( + &self, + _folder_id: Option<&str>, + _criteria: &crate::application::dtos::search_dto::SearchCriteriaDto, + _user_id: &str, + ) -> Result<(Vec, usize), DomainError> { + Ok((Vec::new(), 0)) + } + + async fn count_files( + &self, + _folder_id: Option<&str>, + _criteria: &crate::application::dtos::search_dto::SearchCriteriaDto, + _user_id: &str, + ) -> Result { + Ok(0) + } + + async fn stream_files_in_subtree( + &self, + _folder_id: &str, + ) -> Result> + 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() { + let repo = MockFileReadPort::new(); + repo.insert("file-1", "secret.txt", "alice"); + + let result = repo.get_file_for_owner("file-1", "alice").await; + assert!(result.is_ok(), "owner should be able to read own file"); + assert_eq!(result.unwrap().id(), "file-1"); +} + +#[tokio::test] +async fn get_file_for_owner_rejects_wrong_owner() { + let repo = MockFileReadPort::new(); + repo.insert("file-1", "secret.txt", "alice"); + + let result = repo.get_file_for_owner("file-1", "bob").await; + assert!(result.is_err(), "non-owner should be rejected"); + + // Must be NotFound, NOT Forbidden — avoids leaking existence + let err = result.unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("not found") || msg.contains("NotFound"), + "error must be NotFound, got: {}", + msg + ); +} + +#[tokio::test] +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; + assert!(result.is_err()); +} + +#[tokio::test] +async fn verify_file_owner_uses_default_impl() { + let repo = MockFileReadPort::new(); + 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()); + assert!(repo.verify_file_owner("file-1", "bob").await.is_err()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests — FileManagementService _owned methods (Service layer, Solution B) +// ═══════════════════════════════════════════════════════════════════════════ +// +// Note: FileManagementService::with_trash takes concrete types for the write +// repository (Arc). We cannot construct real PG repos +// 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. +// +// The tests below verify the *contract*: _owned methods must call +// verify_owner before delegating, and verify_owner must fail-closed when +// no read repo is available. + +#[tokio::test] +async fn verify_file_owner_delegates_to_read_port() { + // This test verifies the FileReadPort contract that verify_file_owner + // returns Ok for the correct owner and Err for others. + let read = MockFileReadPort::new(); + read.insert("abc-123", "report.pdf", "user-42"); + + // Same user → Ok + let ok = read.verify_file_owner("abc-123", "user-42").await; + assert!(ok.is_ok(), "correct owner should pass verify_file_owner"); + + // Different user → Err + let err = read.verify_file_owner("abc-123", "attacker-99").await; + assert!(err.is_err(), "wrong owner should fail verify_file_owner"); +} + +#[tokio::test] +async fn owned_methods_require_ownership_check_first() { + // 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(); + read.insert("file-1", "data.csv", "owner-a"); + + // Step 1: verify_owner for correct owner → Ok + let step1 = read.verify_file_owner("file-1", "owner-a").await; + 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; + assert!(step2.is_err()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests — Trait-level _owned method stubs (StubFileManagementUseCase) +// ═══════════════════════════════════════════════════════════════════════════ + +use crate::application::ports::file_ports::FileManagementUseCase; +use crate::common::stubs::StubFileManagementUseCase; + +#[tokio::test] +async fn stub_move_file_owned_returns_ok() { + let stub = StubFileManagementUseCase; + let result = stub + .move_file_owned("file-1", "user-1", Some("folder-2".to_string())) + .await; + assert!(result.is_ok(), "stub should return Ok for move_file_owned"); +} + +#[tokio::test] +async fn stub_rename_file_owned_returns_ok() { + let stub = StubFileManagementUseCase; + let result = stub + .rename_file_owned("file-1", "user-1", "new-name.txt") + .await; + assert!( + result.is_ok(), + "stub should return Ok for rename_file_owned" + ); +} + +use crate::application::ports::file_ports::FileRetrievalUseCase; +use crate::common::stubs::StubFileRetrievalUseCase; + +#[tokio::test] +async fn stub_get_file_owned_returns_ok() { + let stub = StubFileRetrievalUseCase; + let result = stub.get_file_owned("file-1", "user-1").await; + assert!(result.is_ok(), "stub should return Ok for get_file_owned"); +} + +#[tokio::test] +async fn stub_get_file_optimized_owned_returns_ok() { + let stub = StubFileRetrievalUseCase; + let result = stub + .get_file_optimized_owned("file-1", "user-1", true, false) + .await; + assert!( + result.is_ok(), + "stub should return Ok for get_file_optimized_owned" + ); +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 1c5dd159..648299f0 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -20,10 +20,10 @@ pub mod trash_service; pub mod wopi_lock_service; pub mod wopi_token_service; -#[cfg(all(test, feature = "integration_tests"))] -mod trash_service_test; #[cfg(test)] mod idor_protection_test; +#[cfg(all(test, integration_tests))] +mod trash_service_test; // Re-exportar para facilitar acceso pub use file_management_service::FileManagementService; diff --git a/src/application/services/recent_service.rs b/src/application/services/recent_service.rs index f035f46a..0b06efe1 100644 --- a/src/application/services/recent_service.rs +++ b/src/application/services/recent_service.rs @@ -1,9 +1,9 @@ use crate::application::dtos::recent_dto::RecentItemDto; use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase}; use crate::common::errors::{DomainError, ErrorKind, Result}; +use crate::infrastructure::repositories::pg::RecentItemsPgRepository; use std::sync::Arc; use tracing::info; -use crate::infrastructure::repositories::pg::RecentItemsPgRepository; /// Implementation of the use case for managing recent items. /// diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index efc987e6..2230d559 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -15,10 +15,10 @@ use crate::application::ports::inbound::SearchUseCase; use crate::application::ports::storage_ports::FileReadPort; use crate::common::errors::Result; 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::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. diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 127e1ab4..c7b49619 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -3,6 +3,11 @@ use std::sync::Arc; use thiserror::Error; 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::{ application::{ dtos::{ @@ -18,11 +23,6 @@ use crate::{ common::{config::AppConfig, errors::DomainError}, 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)] pub enum ShareServiceError { @@ -394,7 +394,7 @@ impl ShareUseCase for ShareService { } } -#[cfg(feature = "integration_tests")] +#[cfg(integration_tests)] mod tests { use super::*; use crate::application::dtos::share_dto::SharePermissionsDto; diff --git a/src/application/services/storage_usage_service.rs b/src/application/services/storage_usage_service.rs index 09ce1bb0..9221617b 100644 --- a/src/application/services/storage_usage_service.rs +++ b/src/application/services/storage_usage_service.rs @@ -1,11 +1,11 @@ use crate::application::ports::auth_ports::UserStoragePort; use crate::application::ports::storage_ports::StorageUsagePort; use crate::common::errors::DomainError; +use crate::infrastructure::repositories::pg::UserPgRepository; use sqlx::PgPool; use std::sync::Arc; use tokio::task; use tracing::{debug, error, info}; -use crate::infrastructure::repositories::pg::UserPgRepository; /** * Service for managing and updating user storage usage statistics. diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index b3d09eef..dc572a4c 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -7,12 +7,12 @@ use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; +use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::trash_repository::TrashRepository; 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::folder_db_repository::FolderDbRepository; use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository; -use crate::domain::repositories::folder_repository::FolderRepository; /** * Application service for trash operations. diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index 3dc8bfb0..dc05e331 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -499,7 +499,7 @@ impl FolderRepository for MockFolderRepository { } } -#[cfg(feature = "integration_tests")] +#[cfg(integration_tests)] mod tests { use super::*; use crate::application::ports::trash_ports::TrashUseCase; diff --git a/src/common/di.rs b/src/common/di.rs index 4fe72f95..057fcd3e 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -31,28 +31,27 @@ use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nSer use crate::infrastructure::services::path_service::PathService; 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::calendar_service::CalendarService; +use crate::application::services::device_auth_service::DeviceAuthService; 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::repositories::DeviceCodePgRepository; use crate::infrastructure::repositories::AppPasswordPgRepository; +use crate::infrastructure::repositories::DeviceCodePgRepository; use crate::infrastructure::repositories::pg::{ AddressBookPgRepository, CalendarEventPgRepository, CalendarPgRepository, 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::dedup_service::DedupService; 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::wopi_discovery_service::WopiDiscoveryService; use crate::infrastructure::services::zip_service::ZipService; /// Factory for the different application components @@ -185,17 +184,19 @@ impl AppServiceFactory { let folder_repository: Arc = folder_repo_concrete.clone(); // File repositories — PostgreSQL metadata + blob content via DedupService - let file_read_repository: Arc = Arc::new(FileBlobReadRepository::new( - db_pool.clone(), - core.dedup_service.clone(), - folder_repo_concrete.clone(), - )); + let file_read_repository: Arc = + Arc::new(FileBlobReadRepository::new( + db_pool.clone(), + core.dedup_service.clone(), + folder_repo_concrete.clone(), + )); - let file_write_repository: Arc = Arc::new(FileBlobWriteRepository::new( - db_pool.clone(), - core.dedup_service.clone(), - folder_repo_concrete.clone(), - )); + let file_write_repository: Arc = + Arc::new(FileBlobWriteRepository::new( + db_pool.clone(), + core.dedup_service.clone(), + folder_repo_concrete.clone(), + )); // I18n repository let i18n_repository = Arc::new(FileSystemI18nService::new(self.locales_path.clone())); @@ -205,10 +206,7 @@ impl AppServiceFactory { Some(Arc::new(TrashDbRepository::new( db_pool.clone(), core.config.storage.trash_retention_days, - )) - as Arc< - TrashDbRepository, - >) + )) as Arc) } else { None }; @@ -339,14 +337,13 @@ impl AppServiceFactory { let share_repository = Arc::new(SharePgRepository::new(db_pool.clone())); // Build a password hasher for share password verification - let password_hasher: Arc = - Arc::new( - crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( - self.config.auth.hash_memory_cost, - self.config.auth.hash_time_cost, - self.config.auth.hash_parallelism, - ), - ); + let password_hasher: Arc = Arc::new( + crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( + self.config.auth.hash_memory_cost, + self.config.auth.hash_time_cost, + self.config.auth.hash_parallelism, + ), + ); let service = Arc::new(ShareService::new( Arc::new(self.config.clone()), @@ -464,9 +461,7 @@ impl AppServiceFactory { // 6. Database-dependent services (PgPool always available in blob model) let favorites_service: Option>; let recent_service: Option>; - let storage_usage_service: Option< - Arc, - >; + let storage_usage_service: Option>; let mut auth_services: Option = None; { @@ -550,7 +545,8 @@ impl AppServiceFactory { device_auth_service: None, app_password_service: 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, }; @@ -626,15 +622,11 @@ impl AppServiceFactory { // 9c. Wire Device Authorization Grant (RFC 8628) service { - let device_code_repo = Arc::new(DeviceCodePgRepository::new(pool.clone())); - let user_repo: Arc = - Arc::new(crate::infrastructure::repositories::UserPgRepository::new( - pool.clone(), - )); - let session_repo: Arc< - SessionPgRepository, - > = Arc::new( + let user_repo: Arc = Arc::new( + crate::infrastructure::repositories::UserPgRepository::new(pool.clone()), + ); + let session_repo: Arc = Arc::new( crate::infrastructure::repositories::SessionPgRepository::new(pool.clone()), ); let base_url = self.config.base_url(); @@ -652,22 +644,18 @@ impl AppServiceFactory { // 9d. Wire App Password service { - - let app_pw_repo: Arc< - AppPasswordPgRepository, - > = Arc::new(AppPasswordPgRepository::new(pool.clone())); - let hasher: Arc = - Arc::new( - crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( - self.config.auth.hash_memory_cost, - self.config.auth.hash_time_cost, - self.config.auth.hash_parallelism, - ), - ); - let user_repo: Arc = - Arc::new(crate::infrastructure::repositories::UserPgRepository::new( - pool.clone(), - )); + let app_pw_repo: Arc = + Arc::new(AppPasswordPgRepository::new(pool.clone())); + let hasher: Arc = Arc::new( + crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( + self.config.auth.hash_memory_cost, + self.config.auth.hash_time_cost, + self.config.auth.hash_parallelism, + ), + ); + let user_repo: Arc = Arc::new( + crate::infrastructure::repositories::UserPgRepository::new(pool.clone()), + ); let base_url = self.config.base_url(); let app_pw_svc = Arc::new(AppPasswordService::new( @@ -690,14 +678,10 @@ impl AppServiceFactory { // 10. Wire CalDAV/CardDAV services { // CalDAV - let calendar_repo: Arc< - CalendarPgRepository, - > = Arc::new( + let calendar_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()), ); - let event_repo: Arc< - CalendarEventPgRepository, - > = Arc::new( + let event_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::CalendarEventPgRepository::new( pool.clone(), ), @@ -713,25 +697,16 @@ impl AppServiceFactory { calendar_storage, ), ); - app_state.calendar_use_case = Some( - calendar_service - as Arc, - ); + app_state.calendar_use_case = Some(calendar_service as Arc); // CardDAV - let address_book_repo: Arc< - AddressBookPgRepository, - > = Arc::new( + let address_book_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()), ); - let contact_repo: Arc< - ContactPgRepository, - > = Arc::new( + let contact_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()), ); - let group_repo: Arc< - ContactGroupPgRepository, - > = Arc::new( + let group_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::ContactGroupPgRepository::new( pool.clone(), ), @@ -757,7 +732,6 @@ impl AppServiceFactory { "WOPI is enabled but WOPI_DISCOVERY_URL is empty — WOPI services will NOT be available" ); } else { - let wopi_secret = if self.config.wopi.secret.is_empty() { self.config.auth.jwt_secret.clone() } else { @@ -811,8 +785,7 @@ pub struct RepositoryServices { pub file_read_repository: Arc, pub file_write_repository: Arc, pub i18n_repository: Arc, - pub trash_repository: - Option>, + pub trash_repository: Option>, } /// Container for application services @@ -858,14 +831,11 @@ pub struct AppState { pub share_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, - pub storage_usage_service: - Option>, + pub storage_usage_service: Option>, pub calendar_service: Option>, pub contact_service: Option>, - pub calendar_use_case: - Option>, - pub addressbook_use_case: - Option>, + pub calendar_use_case: Option>, + pub addressbook_use_case: Option>, pub contact_use_case: Option>, pub wopi_token_service: Option>, diff --git a/src/common/stubs.rs b/src/common/stubs.rs index f4cb316f..b4306839 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -22,8 +22,7 @@ use crate::application::dtos::search_dto::{ SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto, }; use crate::application::ports::file_ports::{ - FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, - OptimizedFileContent, + FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, OptimizedFileContent, }; use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; diff --git a/src/domain/entities/device_code.rs b/src/domain/entities/device_code.rs index 7159913a..eb216658 100644 --- a/src/domain/entities/device_code.rs +++ b/src/domain/entities/device_code.rs @@ -28,7 +28,7 @@ impl DeviceCodeStatus { } } - pub fn from_str(s: &str) -> Option { + pub fn parse(s: &str) -> Option { match s { "pending" => Some(Self::Pending), "authorized" => Some(Self::Authorized), @@ -76,6 +76,7 @@ impl DeviceCode { /// * `verification_uri` — URL the user must visit /// * `expires_in_secs` — TTL for the device code /// * `poll_interval_secs` — minimum polling interval + #[allow(clippy::too_many_arguments)] pub fn new( device_code: String, user_code: String, diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index a56fc3fb..4aa8daa2 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -8,7 +8,6 @@ //! Concrete implementations (filesystem, PostgreSQL, S3, etc.) live in //! the infrastructure layer. - use crate::common::errors::DomainError; use crate::domain::entities::folder::Folder; use crate::domain::services::path_service::StoragePath; diff --git a/src/domain/services/path_service.rs b/src/domain/services/path_service.rs index c2c364cf..f3f54190 100644 --- a/src/domain/services/path_service.rs +++ b/src/domain/services/path_service.rs @@ -21,7 +21,10 @@ impl StoragePath { /// Creates a new storage path, silently dropping any traversal segments pub fn new(segments: Vec) -> 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] fn test_new_strips_traversal_segments() { - let path = StoragePath::new(vec![ - "..".into(), - "etc".into(), - ".".into(), - "passwd".into(), - ]); + let path = StoragePath::new(vec!["..".into(), "etc".into(), ".".into(), "passwd".into()]); assert_eq!(path.segments(), &["etc", "passwd"]); } diff --git a/src/infrastructure/db.rs b/src/infrastructure/db.rs index af88b24d..ab64c200 100644 --- a/src/infrastructure/db.rs +++ b/src/infrastructure/db.rs @@ -126,8 +126,7 @@ async fn create_pool_with_retries( if attempt >= MAX_ATTEMPTS { return Err(DbError(format!( "Error verifying PostgreSQL {} pool connection: {}", - label, - e + label, e ))); } } @@ -143,8 +142,7 @@ async fn create_pool_with_retries( if attempt >= MAX_ATTEMPTS { return Err(DbError(format!( "Error in PostgreSQL {} pool connection: {}", - label, - e + label, e ))); } tokio::time::sleep(Duration::from_secs(2)).await; @@ -154,8 +152,7 @@ async fn create_pool_with_retries( Err(DbError(format!( "Could not establish PostgreSQL {} pool connection after {} attempts", - label, - MAX_ATTEMPTS + label, MAX_ATTEMPTS ))) } diff --git a/src/infrastructure/repositories/pg/device_code_pg_repository.rs b/src/infrastructure/repositories/pg/device_code_pg_repository.rs index 9cab8d48..8a539e26 100644 --- a/src/infrastructure/repositories/pg/device_code_pg_repository.rs +++ b/src/infrastructure/repositories/pg/device_code_pg_repository.rs @@ -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( row.try_get("id").unwrap_or_default(), diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 3b3bc5cb..19b9ff7b 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -22,6 +22,19 @@ use crate::domain::entities::file::File; use crate::domain::services::path_service::StoragePath; use crate::infrastructure::services::dedup_service::DedupService; +/// Type alias for file metadata rows from SQL queries. +type FileRow = ( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + Option, +); + /// File read repository backed by PostgreSQL metadata + blob storage. pub struct FileBlobReadRepository { pool: Arc, @@ -204,17 +217,7 @@ impl FileReadPort for FileBlobReadRepository { } async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { - let rows: Vec<( - String, - String, - Option, - Option, - i64, - String, - i64, - i64, - Option, - )> = if let Some(fid) = folder_id { + let rows: Vec = if let Some(fid) = folder_id { sqlx::query_as( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, @@ -271,17 +274,7 @@ impl FileReadPort for FileBlobReadRepository { offset: i64, limit: i64, ) -> Result, DomainError> { - let rows: Vec<( - String, - String, - Option, - Option, - i64, - String, - i64, - i64, - Option, - )> = if let Some(fid) = folder_id { + let rows: Vec = if let Some(fid) = folder_id { sqlx::query_as( r#" 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 limit_i64 = limit as i64; - let rows: Vec<( - String, - String, - Option, - Option, - i64, - String, - i64, - i64, - Option, - )> = if let Some(fid) = folder_id { + let rows: Vec = if let Some(fid) = folder_id { sqlx::query_as( r#" 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 { use super::*; use crate::common::stubs::StubDedupPort; diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 7575895c..77072e2b 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -16,6 +16,32 @@ use crate::domain::entities::folder::Folder; use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::services::path_service::StoragePath; +/// Type alias for folder metadata rows from SQL queries. +type FolderRow = (String, String, String, Option, String, i64, i64); + +/// Type alias for paginated folder rows (includes total_count). +type FolderRowPaginated = ( + String, + String, + String, + Option, + String, + i64, + i64, + i64, +); + +/// Type alias for folder rows with optional user_id. +type FolderRowOptUser = ( + String, + String, + String, + Option, + Option, + i64, + i64, +); + /// PostgreSQL-backed folder repository. /// /// 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, DomainError> { - let rows: Vec<(String, String, String, Option, String, i64, i64)> = - if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec = if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_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 ORDER BY name "#, - ) - .bind(pid) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_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 ORDER BY name "#, - ) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; + ) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; rows.into_iter() .map(|(id, name, path, pid, uid, ca, ma)| { @@ -216,10 +241,9 @@ impl FolderRepository for FolderDbRepository { parent_id: Option<&str>, owner_id: &str, ) -> Result, DomainError> { - let rows: Vec<(String, String, String, Option, String, i64, i64)> = - if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec = if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_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 ORDER BY name "#, - ) - .bind(pid) - .bind(owner_id) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(owner_id) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_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 ORDER BY name "#, - ) - .bind(owner_id) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; + ) + .bind(owner_id) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; rows.into_iter() .map(|(id, name, path, pid, uid, ca, ma)| { @@ -266,16 +290,7 @@ impl FolderRepository for FolderDbRepository { limit: usize, include_total: bool, ) -> Result<(Vec, Option), DomainError> { - let rows: Vec<( - String, - String, - String, - Option, - String, - i64, - i64, - i64, - )> = if let Some(pid) = parent_id { + let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" SELECT id::text, name, path, parent_id::text, user_id, @@ -339,16 +354,7 @@ impl FolderRepository for FolderDbRepository { limit: usize, include_total: bool, ) -> Result<(Vec, Option), DomainError> { - let rows: Vec<( - String, - String, - String, - Option, - String, - i64, - i64, - i64, - )> = if let Some(pid) = parent_id { + let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" 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) \ ORDER BY fo.path"; - let rows: Vec<( - String, - String, - String, - Option, - Option, - i64, - i64, - )> = sqlx::query_as(sql) + let rows: Vec = sqlx::query_as(sql) .bind(folder_id) .fetch_all(self.pool()) .await @@ -760,15 +758,7 @@ impl FolderRepository for FolderDbRepository { ORDER BY fo.name" ); - let rows: Vec<( - String, - String, - String, - Option, - Option, - i64, - i64, - )> = if let Some(ref pattern) = name_pattern { + let rows: Vec = if let Some(ref pattern) = name_pattern { sqlx::query_as(&sql) .bind(user_id) .bind(pattern) @@ -824,15 +814,7 @@ impl FolderRepository for FolderDbRepository { ) }; - let rows: Vec<( - String, - String, - String, - Option, - Option, - i64, - i64, - )> = if let Some(pid) = parent_id { + let rows: Vec = if let Some(pid) = parent_id { if let Some(ref pattern) = name_pattern { sqlx::query_as(&sql) .bind(pid) @@ -897,15 +879,7 @@ impl FolderRepository for FolderDbRepository { ORDER BY fo.name" ); - let rows: Vec<( - String, - String, - String, - Option, - Option, - i64, - i64, - )> = if let Some(ref pattern) = name_pattern { + let rows: Vec = if let Some(ref pattern) = name_pattern { sqlx::query_as(&sql) .bind(user_id) .bind(folder_id) @@ -937,10 +911,9 @@ impl FolderRepository for FolderDbRepository { let pattern = format!("%{}%", query); let limit_i64 = limit as i64; - let rows: Vec<(String, String, String, Option, String, i64, i64)> = - if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec = if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -956,16 +929,16 @@ impl FolderRepository for FolderDbRepository { name LIMIT $4 "#, - ) - .bind(pid) - .bind(&pattern) - .bind(query) - .bind(limit_i64) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(&pattern) + .bind(query) + .bind(limit_i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -981,14 +954,14 @@ impl FolderRepository for FolderDbRepository { name LIMIT $3 "#, - ) - .bind(&pattern) - .bind(query) - .bind(limit_i64) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?; + ) + .bind(&pattern) + .bind(query) + .bind(limit_i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?; rows.into_iter() .map(|(id, name, path, pid, uid, ca, ma)| { diff --git a/src/infrastructure/services/chunked_upload_service.rs b/src/infrastructure/services/chunked_upload_service.rs index c8311487..220c7647 100644 --- a/src/infrastructure/services/chunked_upload_service.rs +++ b/src/infrastructure/services/chunked_upload_service.rs @@ -495,13 +495,12 @@ impl ChunkedUploadService { // worker free for other connections. if let Some(ref expected_checksum) = checksum { let data_clone = data.clone(); // Bytes::clone is O(1) — just an Arc increment - let actual_checksum = - tokio::task::spawn_blocking(move || { - use md5::{Md5, Digest}; - format!("{:x}", Md5::digest(&data_clone)) - }) - .await - .map_err(|e| format!("MD5 checksum task failed: {e}"))?; + let actual_checksum = tokio::task::spawn_blocking(move || { + use md5::{Digest, Md5}; + format!("{:x}", Md5::digest(&data_clone)) + }) + .await + .map_err(|e| format!("MD5 checksum task failed: {e}"))?; if actual_checksum != *expected_checksum { return Err(format!( diff --git a/src/infrastructure/services/login_lockout_service.rs b/src/infrastructure/services/login_lockout_service.rs index b0c586ad..23544368 100644 --- a/src/infrastructure/services/login_lockout_service.rs +++ b/src/infrastructure/services/login_lockout_service.rs @@ -60,12 +60,12 @@ impl LoginLockoutService { /// Returns `Ok(())` if the user may attempt login, or /// `Err(remaining_secs)` with the *approximate* remaining lockout time. pub fn check(&self, username: &str) -> Result<(), u64> { - if let Some(rec) = self.cache.get(&username.to_lowercase()) { - if 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. - return Err(self.lockout_secs); - } + if let Some(rec) = self.cache.get(&username.to_lowercase()) + && 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. + return Err(self.lockout_secs); } Ok(()) } diff --git a/src/infrastructure/services/path_service.rs b/src/infrastructure/services/path_service.rs index ac6b1589..6c9189d2 100644 --- a/src/infrastructure/services/path_service.rs +++ b/src/infrastructure/services/path_service.rs @@ -312,7 +312,15 @@ mod tests { #[test] fn test_validate_path_rejects_dangerous_chars() { let service = PathService::new(PathBuf::from("/storage")); - for dangerous in &["file:name", "file*name", "file?name", "filename", "file|name", "file\"name"] { + for dangerous in &[ + "file:name", + "file*name", + "file?name", + "filename", + "file|name", + "file\"name", + ] { let path = StoragePath::new(vec![dangerous.to_string()]); assert!( service.validate_path(&path).is_err(), diff --git a/src/infrastructure/services/webdav_lock_service.rs b/src/infrastructure/services/webdav_lock_service.rs index bde0ebc9..5c4cf7a3 100644 --- a/src/infrastructure/services/webdav_lock_service.rs +++ b/src/infrastructure/services/webdav_lock_service.rs @@ -1,218 +1,213 @@ -//! WebDAV lock store backed by Moka (in-memory cache with per-entry TTL). -//! -//! Locks are automatically evicted when their timeout expires, preventing -//! orphaned locks from accumulating. Two caches are maintained: -//! -//! - `by_path` : path → `LockEntry` (for LOCK conflict detection) -//! - `by_token` : token → path (for fast UNLOCK / refresh lookups) -//! -//! Both caches share the same TTL so entries disappear together. - -use std::sync::Arc; -use std::time::Duration; - -use crate::application::adapters::webdav_adapter::{LockInfo, LockScope}; - -/// Default lock timeout when the client does not specify one (RFC 4918 §10.7). -const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 1800; // 30 minutes - -/// Absolute maximum TTL a client may request. -const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours - -/// A stored lock entry. -#[derive(Clone, Debug)] -pub struct LockEntry { - pub info: LockInfo, - pub path: String, -} - -/// In-memory WebDAV lock store with automatic TTL-based expiration. -/// -/// Uses Moka's `sync::Cache` — lock-free (sharded) reads, bounded size, -/// and per-entry TTL via `policy::Expiry`. -pub struct WebDavLockStore { - /// path → `LockEntry` - by_path: moka::sync::Cache, - /// token → path (reverse index) - by_token: moka::sync::Cache, -} - -impl WebDavLockStore { - /// Create a new lock store. - /// - /// * `max_capacity` — upper bound on simultaneous locks (evicts LRU on overflow). - pub fn new(max_capacity: u64) -> Self { - // 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. - let by_path = moka::sync::Cache::builder() - .max_capacity(max_capacity) - .time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS)) - .build(); - - let by_token = moka::sync::Cache::builder() - .max_capacity(max_capacity) - .time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS)) - .build(); - - Self { by_path, by_token } - } - - // ── Public API ────────────────────────────────────────────── - - /// Attempt to acquire a lock on `path`. - /// - /// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource - /// is already exclusively locked by a different token. - pub fn acquire( - &self, - path: &str, - info: LockInfo, - ) -> Result { - // Check for existing conflicting lock - 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(), - }; - - self.by_path - .insert(path.to_owned(), entry.clone()); - self.by_token - .insert(entry.info.token.clone(), path.to_owned()); - - // Moka 0.12 does not expose per-entry set_expiration_after_insert at - // insert time. We rely on the global `time_to_live` as an upper bound - // and use the `invalidate_after` helper below for custom TTL. - // - // To implement shorter-than-max TTL we schedule an async invalidation. - if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS { - let by_path = self.by_path.clone(); - let by_token = self.by_token.clone(); - let token = entry.info.token.clone(); - let path_owned = path.to_owned(); - tokio::spawn(async move { - tokio::time::sleep(ttl).await; - // Only remove if the entry still matches (wasn't refreshed/replaced) - 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). - /// - /// 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 { - let path = self.by_token.get(token)?; - let mut entry = self.by_path.get(&path)?; - - if entry.info.token != token { - return None; // token mismatch — lock was replaced - } - - let ttl = Self::parse_timeout(new_timeout.or(entry.info.timeout.as_deref())); - let timeout_str = format!("Second-{}", ttl.as_secs()); - entry.info.timeout = Some(timeout_str.clone()); - - // Re-insert to reset the TTL - self.by_path.insert(path.clone(), entry.clone()); - self.by_token.insert(token.to_owned(), path.clone()); - - if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS { - let by_path = self.by_path.clone(); - let by_token = self.by_token.clone(); - let token_owned = token.to_owned(); - let path_owned = path.clone(); - tokio::spawn(async move { - tokio::time::sleep(ttl).await; - 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. - /// - /// 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) { - // Only remove from by_path if the token still matches - if let Some(entry) = self.by_path.get(&path) { - if entry.info.token == token { - self.by_path.invalidate(&path); - } - } - self.by_token.invalidate(token); - true - } else { - false - } - } - - /// Look up a lock by resource path. - pub fn get_by_path(&self, path: &str) -> Option { - self.by_path.get(path) - } - - /// Look up a lock by token. - pub fn get_by_token(&self, token: &str) -> Option { - let path = self.by_token.get(token)?; - self.by_path.get(&path) - } - - // ── Helpers ───────────────────────────────────────────────── - - /// Parse a WebDAV `Timeout` header value into a [`Duration`]. - /// - /// Accepted formats (RFC 4918 §10.7): - /// - `Second-NNN` - /// - `Infinite` (clamped to `MAX_LOCK_TIMEOUT_SECS`) - /// - Comma-separated list (first value wins) - fn parse_timeout(header: Option<&str>) -> Duration { - let raw = match header { - Some(v) if !v.is_empty() => v, - _ => return Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS), - }; - - // Take the first value in a comma-separated list - let first = raw.split(',').next().unwrap_or(raw).trim(); - - if first.eq_ignore_ascii_case("Infinite") { - return Duration::from_secs(MAX_LOCK_TIMEOUT_SECS); - } - - if let Some(secs_str) = first.strip_prefix("Second-") { - if let Ok(secs) = secs_str.trim().parse::() { - return Duration::from_secs(secs.min(MAX_LOCK_TIMEOUT_SECS)); - } - } - - Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS) - } -} - -/// Create a shared lock store wrapped in `Arc` for embedding in `AppState`. -pub fn create_webdav_lock_store() -> Arc { - // 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)) -} +//! WebDAV lock store backed by Moka (in-memory cache with per-entry TTL). +//! +//! Locks are automatically evicted when their timeout expires, preventing +//! orphaned locks from accumulating. Two caches are maintained: +//! +//! - `by_path` : path → `LockEntry` (for LOCK conflict detection) +//! - `by_token` : token → path (for fast UNLOCK / refresh lookups) +//! +//! Both caches share the same TTL so entries disappear together. + +use std::sync::Arc; +use std::time::Duration; + +use crate::application::adapters::webdav_adapter::{LockInfo, LockScope}; + +/// Default lock timeout when the client does not specify one (RFC 4918 §10.7). +const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 1800; // 30 minutes + +/// Absolute maximum TTL a client may request. +const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours + +/// A stored lock entry. +#[derive(Clone, Debug)] +pub struct LockEntry { + pub info: LockInfo, + pub path: String, +} + +/// In-memory WebDAV lock store with automatic TTL-based expiration. +/// +/// Uses Moka's `sync::Cache` — lock-free (sharded) reads, bounded size, +/// and per-entry TTL via `policy::Expiry`. +pub struct WebDavLockStore { + /// path → `LockEntry` + by_path: moka::sync::Cache, + /// token → path (reverse index) + by_token: moka::sync::Cache, +} + +impl WebDavLockStore { + /// Create a new lock store. + /// + /// * `max_capacity` — upper bound on simultaneous locks (evicts LRU on overflow). + pub fn new(max_capacity: u64) -> Self { + // 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. + let by_path = moka::sync::Cache::builder() + .max_capacity(max_capacity) + .time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS)) + .build(); + + let by_token = moka::sync::Cache::builder() + .max_capacity(max_capacity) + .time_to_live(Duration::from_secs(MAX_LOCK_TIMEOUT_SECS)) + .build(); + + Self { by_path, by_token } + } + + // ── Public API ────────────────────────────────────────────── + + /// Attempt to acquire a lock on `path`. + /// + /// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource + /// is already exclusively locked by a different token. + pub fn acquire(&self, path: &str, info: LockInfo) -> Result> { + // Check for existing conflicting lock + if let Some(existing) = self.by_path.get(path) + && existing.info.scope == LockScope::Exclusive + { + return Err(Box::new(existing)); + } + + let ttl = Self::parse_timeout(info.timeout.as_deref()); + let entry = LockEntry { + info, + path: path.to_owned(), + }; + + self.by_path.insert(path.to_owned(), entry.clone()); + self.by_token + .insert(entry.info.token.clone(), path.to_owned()); + + // Moka 0.12 does not expose per-entry set_expiration_after_insert at + // insert time. We rely on the global `time_to_live` as an upper bound + // and use the `invalidate_after` helper below for custom TTL. + // + // To implement shorter-than-max TTL we schedule an async invalidation. + if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS { + let by_path = self.by_path.clone(); + let by_token = self.by_token.clone(); + let token = entry.info.token.clone(); + let path_owned = path.to_owned(); + tokio::spawn(async move { + tokio::time::sleep(ttl).await; + // Only remove if the entry still matches (wasn't refreshed/replaced) + if let Some(e) = by_path.get(&path_owned) + && e.info.token == token + { + by_path.invalidate(&path_owned); + by_token.invalidate(&token); + } + }); + } + + Ok(entry) + } + + /// Refresh an existing lock (extend its timeout). + /// + /// 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 { + let path = self.by_token.get(token)?; + let mut entry = self.by_path.get(&path)?; + + if entry.info.token != token { + return None; // token mismatch — lock was replaced + } + + let ttl = Self::parse_timeout(new_timeout.or(entry.info.timeout.as_deref())); + let timeout_str = format!("Second-{}", ttl.as_secs()); + entry.info.timeout = Some(timeout_str.clone()); + + // Re-insert to reset the TTL + self.by_path.insert(path.clone(), entry.clone()); + self.by_token.insert(token.to_owned(), path.clone()); + + if ttl.as_secs() < MAX_LOCK_TIMEOUT_SECS { + let by_path = self.by_path.clone(); + let by_token = self.by_token.clone(); + let token_owned = token.to_owned(); + let path_owned = path.clone(); + tokio::spawn(async move { + tokio::time::sleep(ttl).await; + if let Some(e) = by_path.get(&path_owned) + && e.info.token == token_owned + { + by_path.invalidate(&path_owned); + by_token.invalidate(&token_owned); + } + }); + } + + Some(entry) + } + + /// Release a lock by its token. + /// + /// 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) { + // Only remove from by_path if the token still matches + if let Some(entry) = self.by_path.get(&path) + && entry.info.token == token + { + self.by_path.invalidate(&path); + } + self.by_token.invalidate(token); + true + } else { + false + } + } + + /// Look up a lock by resource path. + pub fn get_by_path(&self, path: &str) -> Option { + self.by_path.get(path) + } + + /// Look up a lock by token. + pub fn get_by_token(&self, token: &str) -> Option { + let path = self.by_token.get(token)?; + self.by_path.get(&path) + } + + // ── Helpers ───────────────────────────────────────────────── + + /// Parse a WebDAV `Timeout` header value into a [`Duration`]. + /// + /// Accepted formats (RFC 4918 §10.7): + /// - `Second-NNN` + /// - `Infinite` (clamped to `MAX_LOCK_TIMEOUT_SECS`) + /// - Comma-separated list (first value wins) + fn parse_timeout(header: Option<&str>) -> Duration { + let raw = match header { + Some(v) if !v.is_empty() => v, + _ => return Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS), + }; + + // Take the first value in a comma-separated list + let first = raw.split(',').next().unwrap_or(raw).trim(); + + if first.eq_ignore_ascii_case("Infinite") { + return Duration::from_secs(MAX_LOCK_TIMEOUT_SECS); + } + + if let Some(secs_str) = first.strip_prefix("Second-") + && let Ok(secs) = secs_str.trim().parse::() + { + return Duration::from_secs(secs.min(MAX_LOCK_TIMEOUT_SECS)); + } + + Duration::from_secs(DEFAULT_LOCK_TIMEOUT_SECS) + } +} + +/// Create a shared lock store wrapped in `Arc` for embedding in `AppState`. +pub fn create_webdav_lock_store() -> Arc { + // 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)) +} diff --git a/src/infrastructure/services/zip_service.rs b/src/infrastructure/services/zip_service.rs index a5410ae1..c92014ee 100644 --- a/src/infrastructure/services/zip_service.rs +++ b/src/infrastructure/services/zip_service.rs @@ -1,3 +1,5 @@ +use crate::application::services::file_retrieval_service::FileRetrievalService; +use crate::application::services::folder_service::FolderService; use crate::{ application::dtos::file_dto::FileDto, application::ports::file_ports::FileRetrievalUseCase, @@ -16,8 +18,6 @@ use thiserror::Error; use tokio::io::BufWriter; use tokio_util::compat::Compat; use tracing::*; -use crate::application::services::file_retrieval_service::FileRetrievalService; -use crate::application::services::folder_service::FolderService; /// Error related to ZIP file creation #[derive(Debug, Error)] diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 692162d1..1e4d1679 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -11,10 +11,10 @@ use crate::application::dtos::settings_dto::{ SaveOidcSettingsDto, TestOidcConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, }; +use crate::application::ports::auth_ports::TokenServicePort; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use std::sync::Arc; -use crate::application::ports::auth_ports::TokenServicePort; /// Admin API routes — all require admin role. pub fn admin_routes() -> Router> { diff --git a/src/interfaces/api/handlers/app_password_handler.rs b/src/interfaces/api/handlers/app_password_handler.rs index 94bb34a5..3acfa7d8 100644 --- a/src/interfaces/api/handlers/app_password_handler.rs +++ b/src/interfaces/api/handlers/app_password_handler.rs @@ -1,84 +1,81 @@ -//! HTTP handlers for App Password management. -//! -//! All endpoints require JWT authentication (the user must be logged in to -//! create/list/revoke their app passwords). - -use crate::application::dtos::app_password_dto::CreateAppPasswordRequestDto; -use crate::common::di::AppState; -use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::CurrentUser; -use axum::extract::State; -use axum::routing::{delete, get, post}; -use axum::{Json, Router}; -use std::sync::Arc; - -/// Protected routes — require JWT auth middleware. -pub fn app_password_routes() -> Router> { - Router::new() - .route("/app-passwords", post(create_app_password)) - .route("/app-passwords", get(list_app_passwords)) - .route("/app-passwords/{id}", delete(revoke_app_password)) -} - -/// POST /api/auth/app-passwords — Create a new app password. -/// -/// Returns the plain-text password ONCE. The user must copy it immediately. -async fn create_app_password( - State(state): State>, - axum::Extension(user): axum::Extension, - Json(request): Json, -) -> Result, AppError> -{ - let service = state - .app_password_service - .as_ref() - .ok_or_else(|| AppError::internal_error("App password service not configured"))?; - - let response = service - .create(&user.id, request) - .await - .map_err(|e| AppError::from(e))?; - - Ok(Json(response)) -} - -/// GET /api/auth/app-passwords — List all app passwords for the current user. -/// -/// Never returns plain-text passwords (only prefix + metadata). -async fn list_app_passwords( - State(state): State>, - axum::Extension(user): axum::Extension, -) -> Result, AppError> -{ - let service = state - .app_password_service - .as_ref() - .ok_or_else(|| AppError::internal_error("App password service not configured"))?; - - let response = service - .list(&user.id) - .await - .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>, - axum::Extension(user): axum::Extension, - axum::extract::Path(id): axum::extract::Path, -) -> Result, AppError> -{ - let service = state - .app_password_service - .as_ref() - .ok_or_else(|| AppError::internal_error("App password service not configured"))?; - - let response = service - .revoke(&user.id, &id) - .await - .map_err(|e| AppError::from(e))?; - - Ok(Json(response)) -} +//! HTTP handlers for App Password management. +//! +//! All endpoints require JWT authentication (the user must be logged in to +//! create/list/revoke their app passwords). + +use crate::application::dtos::app_password_dto::CreateAppPasswordRequestDto; +use crate::common::di::AppState; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; +use axum::extract::State; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use std::sync::Arc; + +/// Protected routes — require JWT auth middleware. +pub fn app_password_routes() -> Router> { + Router::new() + .route("/app-passwords", post(create_app_password)) + .route("/app-passwords", get(list_app_passwords)) + .route("/app-passwords/{id}", delete(revoke_app_password)) +} + +/// POST /api/auth/app-passwords — Create a new app password. +/// +/// Returns the plain-text password ONCE. The user must copy it immediately. +async fn create_app_password( + State(state): State>, + axum::Extension(user): axum::Extension, + Json(request): Json, +) -> Result, AppError> +{ + let service = state + .app_password_service + .as_ref() + .ok_or_else(|| AppError::internal_error("App password service not configured"))?; + + let response = service + .create(&user.id, request) + .await + .map_err(AppError::from)?; + + Ok(Json(response)) +} + +/// GET /api/auth/app-passwords — List all app passwords for the current user. +/// +/// Never returns plain-text passwords (only prefix + metadata). +async fn list_app_passwords( + State(state): State>, + axum::Extension(user): axum::Extension, +) -> Result, AppError> +{ + let service = state + .app_password_service + .as_ref() + .ok_or_else(|| AppError::internal_error("App password service not configured"))?; + + let response = service.list(&user.id).await.map_err(AppError::from)?; + + Ok(Json(response)) +} + +/// DELETE /api/auth/app-passwords/:id — Revoke an app password. +async fn revoke_app_password( + State(state): State>, + axum::Extension(user): axum::Extension, + axum::extract::Path(id): axum::extract::Path, +) -> Result, AppError> +{ + let service = state + .app_password_service + .as_ref() + .ok_or_else(|| AppError::internal_error("App password service not configured"))?; + + let response = service + .revoke(&user.id, &id) + .await + .map_err(AppError::from)?; + + Ok(Json(response)) +} diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 5ea4fe5e..a1413e8d 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -150,7 +150,7 @@ async fn login( ); return Err(AppError::new( StatusCode::TOO_MANY_REQUESTS, - &format!( + format!( "Account temporarily locked due to too many failed attempts. Try again in {} seconds.", lockout_secs ), diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index 56ec60b1..6cd09c9e 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -32,10 +32,10 @@ use crate::application::dtos::calendar_dto::{ CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto, }; use crate::application::ports::calendar_ports::CalendarUseCase; +use crate::application::services::calendar_service::CalendarService; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; -use crate::application::services::calendar_service::CalendarService; const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 348fe0f2..c1c3b6a0 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -33,9 +33,9 @@ use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAdd use crate::application::dtos::contact_dto::CreateContactVCardDto; use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; use crate::common::di::AppState; +use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; -use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 50c505fd..678c7578 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -17,13 +17,13 @@ use bytes::Bytes; use serde::{Deserialize, Serialize}; 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::file_ports::FileUploadUseCase; +use crate::application::ports::storage_ports::StorageUsagePort; use crate::common::di::AppState; use crate::domain::errors::ErrorKind; 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 #[derive(Debug, Deserialize)] diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index e04f829d..e213f0f6 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -9,8 +9,8 @@ use std::sync::Arc; use tracing::{error, info}; use crate::application::ports::favorites_ports::FavoritesUseCase; -use crate::interfaces::middleware::auth::AuthUser; use crate::application::services::favorites_service::FavoritesService; +use crate::interfaces::middleware::auth::AuthUser; /// Single item in a batch-add-favorites request. #[derive(Debug, Deserialize)] diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 5cec23ca..e011c7c0 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -11,12 +11,14 @@ use serde::Deserialize; use std::collections::HashMap; 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::interfaces::middleware::auth::AuthUser; 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. @@ -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, Err(err) => { return ( @@ -678,7 +683,8 @@ impl FileHandler { let mgmt = &state.applications.file_management_service; // 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 .map(|was_trashed| { if was_trashed { @@ -771,7 +777,10 @@ impl FileHandler { 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(), Err(err) => { tracing::error!("Error moving file: {}", err); diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index da1449ef..3ca2ec70 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -13,13 +13,13 @@ use tokio_util::io::ReaderStream; use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto}; use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::dtos::pagination::PaginationRequestDto; +use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState as GlobalAppState; use crate::common::errors::ErrorKind; use crate::interfaces::middleware::auth::AuthUser; -use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::application::ports::trash_ports::TrashUseCase; type AppState = Arc; diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index d103c023..5dee0fdf 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -9,8 +9,8 @@ use std::sync::Arc; use tracing::{error, info}; use crate::application::ports::recent_ports::RecentItemsUseCase; -use crate::interfaces::middleware::auth::AuthUser; use crate::application::services::recent_service::RecentService; +use crate::interfaces::middleware::auth::AuthUser; /// Query parameters for getting recent items #[derive(Deserialize)] diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index 6f370b95..0dd02e57 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -7,10 +7,10 @@ use serde_json::json; use tracing::{error, info}; use crate::application::dtos::search_dto::SearchCriteriaDto; +use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; -use crate::application::ports::inbound::SearchUseCase; /** * Handler for search operations through the API. diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index 83853381..46b3d82b 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -9,6 +9,7 @@ use axum::{ use serde::Deserialize; use serde_json::json; +use crate::application::services::share_service::ShareService; use crate::{ application::{ dtos::share_dto::{CreateShareDto, UpdateShareDto}, @@ -18,7 +19,6 @@ use crate::{ domain::entities::share::ShareItemType, interfaces::middleware::auth::OptionalAuthUser, }; -use crate::application::services::share_service::ShareService; #[derive(Debug, Deserialize)] pub struct GetSharesQuery { diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 6e8ad4b2..7cc30359 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -17,22 +17,20 @@ use chrono::Utc; use quick_xml::Writer; use uuid::Uuid; -use crate::application::adapters::webdav_adapter::{ - LockInfo, PropFindRequest, WebDavAdapter, -}; +use crate::application::adapters::webdav_adapter::{LockInfo, PropFindRequest, WebDavAdapter}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::ports::file_ports::FileRetrievalUseCase; +use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUseCase}; 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::infrastructure::services::path_resolver_service::ResolvedResource; use crate::interfaces::errors::AppError; 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 std::sync::Arc; -use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUseCase}; /// Characters that MUST NOT be percent-encoded inside a URI path segment. /// 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. pub(crate) fn encode_uri_path(path: &str) -> String { path.split('/') - .map(|seg| encode_path_segment(seg)) + .map(encode_path_segment) .collect::>() .join("/") } diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index 796a45ed..d1f4ad1c 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -20,10 +20,10 @@ use axum::{ use serde::{Deserialize, Serialize}; 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_token_service::WopiTokenService; use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; -use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase}; /// Shared state for WOPI handlers. #[derive(Clone)] diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 884f6a3e..13e5ad45 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -1,3 +1,4 @@ +use crate::application::services::batch_operations::BatchOperationService; use crate::common::di::AppState; use axum::{ Router, @@ -8,7 +9,6 @@ use axum::{ use serde_json::json; use std::sync::Arc; use tower_http::{compression::CompressionLayer, trace::TraceLayer}; -use crate::application::services::batch_operations::BatchOperationService; /// Returns the application version from Cargo.toml (compile-time constant) async fn get_version() -> AxumJson { @@ -18,7 +18,6 @@ async fn get_version() -> AxumJson { })) } - use crate::interfaces::api::handlers::admin_handler; use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState}; use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 85d53a38..1881671a 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -273,30 +273,29 @@ pub async fn auth_middleware( if let Some(token_str) = 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() { - let token_service = &auth_service.token_service; - match token_service.validate_token(&token_str) { - Ok(claims) => { - tracing::debug!("Cookie token validated for user: {}", claims.username); - let current_user = CurrentUser { - id: claims.sub, - username: claims.username, - email: claims.email, - role: claims.role, - }; - request.extensions_mut().insert(current_user); - request.extensions_mut().insert(CookieAuthenticated); - return Ok(next.run(request).await); - } - Err(e) => { - tracing::debug!("Cookie token validation failed: {}", e); - // Don't return error — fall through to "no token" so - // the browser gets a 401 and can redirect to /login. - } + if let Some(auth_service) = state.auth_service.as_ref() { + let token_service = &auth_service.token_service; + match token_service.validate_token(&token_str) { + Ok(claims) => { + tracing::debug!("Cookie token validated for user: {}", claims.username); + let current_user = CurrentUser { + id: claims.sub, + username: claims.username, + email: claims.email, + role: claims.role, + }; + request.extensions_mut().insert(current_user); + request.extensions_mut().insert(CookieAuthenticated); + return Ok(next.run(request).await); + } + Err(e) => { + tracing::debug!("Cookie token validation failed: {}", e); + // Don't return error — fall through to "no token" so + // the browser gets a 401 and can redirect to /login. } } } diff --git a/src/interfaces/middleware/rate_limit.rs b/src/interfaces/middleware/rate_limit.rs index 003738bc..d9d0789c 100644 --- a/src/interfaces/middleware/rate_limit.rs +++ b/src/interfaces/middleware/rate_limit.rs @@ -57,6 +57,7 @@ impl RateLimiter { /// Check whether the IP is allowed. Returns `Ok(current_count)` or /// `Err(StatusCode::TOO_MANY_REQUESTS)`. + #[allow(clippy::result_unit_err)] pub fn check_and_increment(&self, ip: &str) -> Result { let key = ip.to_string(); // moka's entry API lets us atomically read-modify-write. @@ -92,12 +93,12 @@ pub fn extract_client_ip(req: &Request) -> String { let headers = req.headers(); // 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(first) = xff.split(',').next() { - let ip = first.trim(); - if !ip.is_empty() { - return ip.to_string(); - } + if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) + && let Some(first) = xff.split(',').next() + { + let ip = first.trim(); + if !ip.is_empty() { + return ip.to_string(); } }