diff --git a/.github/docker-hub-setup.md b/.github/docker-hub-setup.md new file mode 100644 index 00000000..dfe2341d --- /dev/null +++ b/.github/docker-hub-setup.md @@ -0,0 +1,54 @@ +# Configuración para Docker Hub y GitHub Actions + +Este documento explica cómo configurar los secretos necesarios para publicar imágenes de Docker en Docker Hub usando GitHub Actions. + +## Requisitos previos + +1. Una cuenta en [Docker Hub](https://hub.docker.com/) +2. Un repositorio en Docker Hub donde subir la imagen +3. Un token de acceso personal (PAT) de Docker Hub + +## Pasos para configurar los secretos en GitHub + +1. Genera un token de acceso en Docker Hub + - Inicia sesión en [Docker Hub](https://hub.docker.com/) + - Ve a tu perfil (esquina superior derecha) → Account Settings → Security + - Haz clic en "New Access Token" + - Proporciona una descripción como "GitHub Actions" + - Selecciona los permisos apropiados (normalmente "Read, Write, Delete") + - Haz clic en "Generate" + - **IMPORTANTE**: Copia el token generado, ya que no podrás verlo de nuevo + +2. Configura los secretos en tu repositorio de GitHub + - Ve a tu repositorio en GitHub + - Haz clic en "Settings" → "Secrets and variables" → "Actions" + - Haz clic en "New repository secret" + - Añade los siguientes secretos: + - Nombre: `DOCKERHUB_USERNAME` + Valor: Tu nombre de usuario de Docker Hub + - Nombre: `DOCKERHUB_TOKEN` + Valor: El token de acceso que generaste en el paso anterior + +## Uso + +Una vez configurados los secretos, los flujos de trabajo de GitHub Actions podrán autenticarse con Docker Hub y publicar imágenes. + +Cuando crees una nueva [release en GitHub](https://docs.github.com/es/repositories/releasing-projects-on-github/managing-releases-in-a-repository#creating-a-release), el flujo de trabajo `docker-publish.yml` se activará automáticamente y: + +1. Construirá la imagen de Docker +2. La etiquetará con el número de versión de la release +3. La subirá a Docker Hub + +## Verificación + +Para verificar que la configuración está correcta: + +1. Crea una nueva release en GitHub +2. Ve a la pestaña "Actions" y observa el progreso del flujo de trabajo +3. Una vez completado, verifica que la imagen aparezca en tu repositorio de Docker Hub + +## Notas adicionales + +- Para entornos de producción, considera usar un usuario de servicio en Docker Hub en lugar de tu cuenta personal +- Rota regularmente los tokens de acceso para mayor seguridad +- Considera agregar escaneo de vulnerabilidades en las imágenes como parte del flujo de trabajo \ No newline at end of file diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 00000000..33d1a5ce --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,56 @@ +name: Docker Build and Test + +# Trigger on push to main branch or on pull requests +on: + push: + branches: [ "main", "dev" ] + pull_request: + branches: [ "main", "dev" ] + +jobs: + # Build and test Docker image but don't push + build-and-test: + name: Build and Test Docker Image + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Set up Docker Buildx + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Extract metadata (tags, labels) for Docker + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: test/oxicloud + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + + # Build Docker image but don't push + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + load: true + tags: test/oxicloud:test + cache-from: type=gha + cache-to: type=gha,mode=max + + # Run some basic tests against the built image + - name: Test Docker image + run: | + docker run --rm test/oxicloud:test --version || true + docker run --rm test/oxicloud:test --help || true + + # Verify the image structure + echo "✅ Checking Docker image layers and size" + docker image inspect test/oxicloud:test + + echo "✅ Docker build and test completed successfully" \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..2b7b18ff --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,63 @@ +name: Docker Hub Release + +# Trigger the workflow when a release is published +on: + release: + types: [published] + +jobs: + # Build and publish Docker image + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Set up Docker Buildx for efficient builds + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Login to Docker Hub + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/oxicloud + # Generate Docker tags based on release tag, commit SHA, and latest + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch + type=sha + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + + # Build and push Docker image + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + # Build args if needed + build-args: | + VERSION=${{ github.ref_name }} + + # Post successful build notification + - name: Post Success Notification + if: success() + run: | + echo "🚢 Docker image for version ${{ github.ref_name }} has been successfully pushed to Docker Hub!" \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..75cdd725 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build/Lint/Test Commands + +### Building +- Build debug version: `cargo build` +- Build release version: `cargo build --release` +- Run the application: `cargo run` + +### Testing +- Run all tests: `cargo test` +- Run a specific test: `cargo test test_name` +- Run tests for a specific module: `cargo test module_name` +- Run tests with feature flags: `cargo test --features test_utils` + +### Linting +- Run clippy linting: `cargo clippy` +- Format code: `cargo fmt` + +## Code Style Guidelines + +### Architecture +- This project follows a hexagonal/clean architecture pattern: + - `application`: Contains services (use cases), DTOs, and ports + - `domain`: Contains core entities, repositories (interfaces), and domain services + - `infrastructure`: Contains concrete implementations of repository interfaces + - `interfaces`: Contains HTTP/API handlers and routes + +### Error Handling +- Use the `DomainError` type for domain-level errors +- Use the `AppError` type for API/HTTP-level errors +- Use the `ErrorContext` trait to add context to errors from external crates +- Follow the error factory pattern for creating common error types + +### Naming Conventions +- Types and structs: PascalCase +- Functions and methods: snake_case +- Constants and statics: SCREAMING_SNAKE_CASE +- Modules and files: snake_case +- Use descriptive names that express intent + +### Testing +- Use mock objects for dependencies in unit tests +- Use the `#[tokio::test]` attribute for async tests +- Include both positive and negative test cases +- Follow the Arrange-Act-Assert pattern in tests + +### Imports +- Group imports by source: + 1. Standard library imports + 2. External crate imports + 3. Local crate imports (with `crate::` prefix) +- Use explicit imports (no glob imports except in tests) + +### Documentation +- Document public API functions and types with doc comments +- Include examples where helpful +- Document error cases and conditions \ No newline at end of file diff --git a/REPO-DOCUMENTATION.md b/REPO-DOCUMENTATION.md new file mode 100644 index 00000000..4d9665bf --- /dev/null +++ b/REPO-DOCUMENTATION.md @@ -0,0 +1,708 @@ +# OxiCloud Code Documentation + +This document provides a detailed description of each file in the OxiCloud codebase, organized by architectural layers according to the clean hexagonal architecture pattern. + +## Domain Layer + +The domain layer forms the core of the application, containing business entities and repository interfaces. + +### Entities + +**File Entity (`src/domain/entities/file.rs`)** +- Core domain entity representing files in the system +- Implements an immutable design pattern for file operations +- Provides validation, creation, and manipulation methods for files +- Maintains both physical storage information and logical metadata +- Includes error handling via `FileError` for validation failures + +**Folder Entity (`src/domain/entities/folder.rs`)** +- Represents folders/directories in the domain model +- Supports hierarchical structure with parent-child relationships +- Provides validation, creation, and update operations +- Implements immutable pattern with methods returning new instances +- Handles path resolution for proper folder hierarchy + +**User Entity (`src/domain/entities/user.rs`)** +- Manages user accounts and authentication +- Provides secure password handling with Argon2 hashing +- Supports roles (Admin, User) with appropriate permissions +- Tracks storage usage and quotas +- Includes account management functions (activation, deactivation, login tracking) + +**Share Entity (`src/domain/entities/share.rs`)** +- Implements file and folder sharing functionality +- Supports various permission levels (read, write, reshare) +- Provides password protection for shared resources +- Handles expiration dates for temporary sharing +- Tracks access statistics for shared resources + +**Calendar Entity (`src/domain/entities/calendar.rs`)** +- Supports calendar functionality for CalDAV integration +- Manages calendar properties like name, color, and description +- Provides ownership and access control +- Supports custom properties for extended CalDAV compatibility +- Handles validation of calendar data + +**Calendar Event Entity (`src/domain/entities/calendar_event.rs`)** +- Represents calendar events with properties like title, description, location +- Handles date/time management with recurrence rules +- Supports reminders and notifications +- Provides validation for event data +- Implements custom properties for CalDAV compatibility + +**Trashed Item Entity (`src/domain/entities/trashed_item.rs`)** +- Manages files and folders in the trash +- Tracks original locations for restoration +- Implements automatic cleanup based on retention policies +- Provides restoration and permanent deletion functionality + +### Repositories (Interfaces) + +**File Repository (`src/domain/repositories/file_repository.rs`)** +- Defines the contract for file storage operations +- Abstracts storage implementation details from the domain +- Supports file creation, retrieval, updating, and deletion +- Provides methods for content streaming and file movement +- Includes trash functionality for file lifecycle management + +**Folder Repository (`src/domain/repositories/folder_repository.rs`)** +- Defines the interface for folder manipulation +- Abstracts storage implementation details for directories +- Handles folder creation, listing, and hierarchy management +- Supports moving folders and retrieving path information +- Includes trash operations for folders + +**User Repository (`src/domain/repositories/user_repository.rs`)** +- Defines the interface for user data persistence +- Supports user creation, retrieval, and management +- Provides authentication and session management +- Handles user preferences and settings storage +- Manages user quotas and storage usage tracking + +**Share Repository (`src/domain/repositories/share_repository.rs`)** +- Defines the interface for share record management +- Handles creation and validation of share records +- Tracks permissions and expiration settings +- Provides access verification for shared resources +- Manages share revocation and updates + +**Trash Repository (`src/domain/repositories/trash_repository.rs`)** +- Defines the interface for trash operations +- Manages soft deletion and restoration of resources +- Handles retention policies and automatic cleanup +- Provides listing of trashed items with metadata +- Supports permanent deletion operations + +### Domain Services + +**Auth Service (`src/domain/services/auth_service.rs`)** +- Provides domain-level authentication logic +- Implements password validation and hashing +- Defines authentication policies and rules +- Manages token generation and validation +- Handles security-related domain operations + +**I18n Service (`src/domain/services/i18n_service.rs`)** +- Defines domain-level internationalization interface +- Provides translation lookup capabilities +- Manages localization strategies +- Supports multiple languages and fallbacks +- Handles format localization for dates, numbers, etc. + +**Path Service (`src/domain/services/path_service.rs`)** +- Manages domain-level path abstractions +- Provides path validation and normalization +- Handles path traversal and resolution +- Implements path security measures +- Supports different path formats and conventions + +## Application Layer + +The application layer orchestrates use cases by coordinating domain objects and providing services to the interfaces layer. + +### Services + +**File Service (`src/application/services/file_service.rs`)** +- Implements file-related use cases +- Coordinates between repositories for file operations +- Provides file upload, download, and listing functionality +- Handles error translation between layers +- Contains business logic for file operations + +**Folder Service (`src/application/services/folder_service.rs`)** +- Implements folder management use cases +- Manages folder creation, listing, and hierarchy +- Coordinates between repositories for folder operations +- Maintains folder structure integrity +- Handles error translation for folder operations + +**Auth Application Service (`src/application/services/auth_application_service.rs`)** +- Manages user authentication flows +- Implements login, logout, and session management +- Handles token generation and validation +- Coordinates with user repository for verification +- Manages password reset and account recovery + +**File Management Service (`src/application/services/file_management_service.rs`)** +- Provides higher-level file operations +- Manages file uploads, versions, and metadata +- Handles file operations across repositories +- Coordinates transactional file operations +- Provides advanced file searching and filtering + +**File Retrieval Service (`src/application/services/file_retrieval_service.rs`)** +- Specialized service for file content retrieval +- Optimizes file reading operations +- Provides streaming and download functionality +- Implements read-specific error handling +- Supports different retrieval patterns (whole file, ranges) + +**File Upload Service (`src/application/services/file_upload_service.rs`)** +- Specialized service for handling file uploads +- Manages chunked and multipart uploads +- Provides validation during upload +- Handles large file uploads efficiently +- Supports upload resumption and integrity verification + +**Search Service (`src/application/services/search_service.rs`)** +- Implements file and folder search functionality +- Provides text-based content searching +- Handles metadata-based filtering +- Supports sorting and pagination of results +- Optimizes search operations for performance + +**Share Service (`src/application/services/share_service.rs`)** +- Implements file and folder sharing functionality +- Creates and manages share links +- Handles permission checking for shared resources +- Manages password protection for shares +- Processes access requests for shared content + +**Trash Service (`src/application/services/trash_service.rs`)** +- Implements trash can functionality +- Manages moving items to trash and restoration +- Handles automatic cleanup of expired trash +- Coordinates with repositories for trash operations +- Maintains metadata for trashed items + +**Recent Service (`src/application/services/recent_service.rs`)** +- Tracks recently accessed files +- Manages user-specific recent file lists +- Handles expiration of old entries +- Provides sorting and filtering of recent files +- Coordinates with file repository for metadata + +**Favorites Service (`src/application/services/favorites_service.rs`)** +- Manages user favorite files and folders +- Provides adding and removing favorites +- Handles listing and sorting of favorites +- Coordinates with repositories for data consistency +- Maintains user-specific favorite lists + +**I18n Application Service (`src/application/services/i18n_application_service.rs`)** +- Handles internationalization and localization +- Provides translation lookups for UI components +- Manages locale detection and setting +- Coordinates with i18n domain service +- Supports dynamic language switching + +**Storage Mediator (`src/application/services/storage_mediator.rs`)** +- Coordinates between different storage repositories +- Manages transaction coordination +- Handles path resolution between storage layers +- Provides unified view of storage subsystems +- Optimizes operations across storage types + +**Batch Operations (`src/application/services/batch_operations.rs`)** +- Implements batch processing for file operations +- Handles atomic multi-file operations +- Provides transaction support for batch operations +- Manages failure handling and partial success +- Optimizes performance for bulk operations + +### Ports + +**Inbound Ports (`src/application/ports/inbound.rs`)** +- Defines interfaces for external systems to use +- Contains use case interfaces for application services +- Specifies contracts for UI and API interactions +- Provides clear boundaries for application functionality +- Forms the primary API for interfaces layer + +**Outbound Ports (`src/application/ports/outbound.rs`)** +- Defines interfaces used by application services +- Specifies contracts that infrastructure must implement +- Allows swapping infrastructure implementations +- Maintains dependency inversion principle +- Protects application layer from external dependencies + +**Auth Ports (`src/application/ports/auth_ports.rs`)** +- Defines interfaces for authentication operations +- Specifies contracts for login, validation, and sessions +- Handles token generation and verification +- Provides user identity management +- Supports different authentication methods + +**Storage Ports (`src/application/ports/storage_ports.rs`)** +- Defines interfaces for storage operations +- Specifies contracts for accessing persistent storage +- Handles file system and database interactions +- Provides transaction support for storage operations +- Supports different storage backends + +**File Ports (`src/application/ports/file_ports.rs`)** +- Defines interfaces for file operations +- Contains file upload and retrieval use cases +- Specifies contracts for file management +- Handles file-specific error conditions +- Supports various file operation patterns + +**Favorites Ports (`src/application/ports/favorites_ports.rs`)** +- Defines interfaces for favorites functionality +- Specifies contracts for favorite management +- Handles favorite-specific operations +- Provides user-specific favorite management +- Supports different favorite organization structures + +**Recent Ports (`src/application/ports/recent_ports.rs`)** +- Defines interfaces for recent files functionality +- Specifies contracts for recent file tracking +- Handles history and access patterns +- Provides user-specific recent file handling +- Supports different recency algorithms + +**Share Ports (`src/application/ports/share_ports.rs`)** +- Defines interfaces for sharing functionality +- Specifies contracts for share creation and access +- Handles permission verification for shares +- Provides link generation and management +- Supports different sharing models + +**Trash Ports (`src/application/ports/trash_ports.rs`)** +- Defines interfaces for trash functionality +- Specifies contracts for trash operations +- Handles trash-specific workflows +- Provides retention and cleanup interfaces +- Supports different trash implementation strategies + +### DTOs + +**File DTO (`src/application/dtos/file_dto.rs`)** +- Data transfer object for file entities +- Provides serialization and API representation +- Translates between domain model and external interfaces +- Includes conversions to/from domain entities +- Contains file metadata for API responses + +**Folder DTO (`src/application/dtos/folder_dto.rs`)** +- Data transfer object for folder entities +- Provides folder data for API responses +- Handles serialization and API representation +- Includes conversions to/from domain entities +- Contains folder structure information + +**User DTO (`src/application/dtos/user_dto.rs`)** +- Data transfer object for user information +- Provides user data for API responses +- Handles serialization with sensitive data protection +- Includes conversions to/from domain entities +- Contains user profile information + +**Share DTO (`src/application/dtos/share_dto.rs`)** +- Data transfer object for share information +- Provides share data for API responses +- Handles serialization of sharing details +- Includes conversions to/from domain entities +- Contains share link and permission data + +**Trash DTO (`src/application/dtos/trash_dto.rs`)** +- Data transfer object for trashed items +- Provides trash information for API responses +- Handles serialization of trash metadata +- Includes conversions to/from domain entities +- Contains restoration information + +**Pagination DTO (`src/application/dtos/pagination.rs`)** +- Handles pagination for list responses +- Provides page size and number information +- Supports offset and cursor-based pagination +- Includes metadata for total items and pages +- Facilitates consistent pagination across APIs + +**Favorites DTO (`src/application/dtos/favorites_dto.rs`)** +- Data transfer object for favorites +- Provides favorites data for API responses +- Handles serialization of favorite items +- Includes conversions to/from domain entities +- Contains favorite metadata and organization + +**Recent DTO (`src/application/dtos/recent_dto.rs`)** +- Data transfer object for recent files +- Provides recent items data for API responses +- Handles serialization of access history +- Includes conversions to/from domain entities +- Contains timing and access metadata + +**Search DTO (`src/application/dtos/search_dto.rs`)** +- Data transfer object for search results +- Provides search data for API responses +- Handles serialization of search results +- Includes query and result metadata +- Contains relevance and ranking information + +**I18n DTO (`src/application/dtos/i18n_dto.rs`)** +- Data transfer object for internationalization +- Provides language and translation data +- Handles serialization of language resources +- Includes locale and preference information +- Contains translation bundle structures + +### Adapters + +**WebDAV Adapter (`src/application/adapters/webdav_adapter.rs`)** +- Adapts between OxiCloud domain models and WebDAV protocol +- Handles XML parsing and generation for WebDAV operations +- Implements property handling for WebDAV (PROPFIND, PROPPATCH) +- Provides WebDAV-specific error handling +- Translates between file operations and WebDAV methods + +### Transactions + +**Storage Transaction (`src/application/transactions/storage_transaction.rs`)** +- Manages transactional operations for storage +- Implements transaction boundaries and commits +- Provides rollback capabilities on failure +- Ensures consistency across multiple operations +- Handles transaction isolation levels + +## Infrastructure Layer + +This layer provides concrete implementations of repository interfaces and technical services. + +### Repositories (Implementations) + +**File FS Repository (`src/infrastructure/repositories/file_fs_repository.rs`)** +- Implements FileRepository interface for filesystem storage +- Manages physical file operations on disk +- Handles file content reading and writing +- Implements optimized large file handling +- Provides metadata caching for performance + +**File FS Read Repository (`src/infrastructure/repositories/file_fs_read_repository.rs`)** +- Specialized repository for read-only file operations +- Optimized for high-performance file retrieval +- Implements caching for frequently accessed files +- Supports streaming of large files +- Handles content type detection and verification + +**File FS Write Repository (`src/infrastructure/repositories/file_fs_write_repository.rs`)** +- Specialized repository for file write operations +- Handles atomic file writes with transaction support +- Implements optimized large file writes +- Manages file locking for concurrent writes +- Provides integrity verification for written files + +**File FS Repository Trash (`src/infrastructure/repositories/file_fs_repository_trash.rs`)** +- Extends file repository with trash functionality +- Implements soft delete operations for files +- Manages restoration from trash +- Handles automatic cleanup of expired trash +- Maintains metadata for trashed files + +**Folder FS Repository (`src/infrastructure/repositories/folder_fs_repository.rs`)** +- Implements FolderRepository interface for filesystem +- Creates and manages directory structures +- Handles folder listing and hierarchy traversal +- Implements folder permissions and ownership +- Provides optimization for deep folder structures + +**Folder FS Repository Trash (`src/infrastructure/repositories/folder_fs_repository_trash.rs`)** +- Extends folder repository with trash functionality +- Implements soft delete for directories +- Handles recursive trash operations for folders +- Manages restoration of folder hierarchies +- Maintains metadata for trashed folders + +**Share FS Repository (`src/infrastructure/repositories/share_fs_repository.rs`)** +- Implements ShareRepository for filesystem-based sharing +- Manages share records and permissions +- Handles link generation and validation +- Provides access control for shared resources +- Supports share expiration and revocation + +**Trash FS Repository (`src/infrastructure/repositories/trash_fs_repository.rs`)** +- Implements TrashRepository for filesystem +- Manages trash directory structure +- Handles metadata for trashed items +- Implements cleanup policies for expired trash +- Supports permanent deletion operations + +**Session PG Repository (`src/infrastructure/repositories/pg/session_pg_repository.rs`)** +- Implements session storage using PostgreSQL +- Manages user sessions and authentication state +- Handles session creation, validation, and expiration +- Provides secure token management +- Supports multiple concurrent sessions + +**User PG Repository (`src/infrastructure/repositories/pg/user_pg_repository.rs`)** +- Implements UserRepository with PostgreSQL +- Stores user accounts and profile information +- Handles user queries and updates +- Manages user roles and permissions +- Supports user search and filtering + +**File Metadata Manager (`src/infrastructure/repositories/file_metadata_manager.rs`)** +- Manages file metadata independently of content +- Handles extended attributes for files +- Provides caching for frequently accessed metadata +- Optimizes metadata operations +- Supports custom metadata fields + +**File Path Resolver (`src/infrastructure/repositories/file_path_resolver.rs`)** +- Resolves logical paths to physical storage locations +- Handles path normalization and validation +- Provides path translation between different systems +- Supports virtual paths and redirections +- Optimizes path resolution for nested structures + +**Parallel File Processor (`src/infrastructure/repositories/parallel_file_processor.rs`)** +- Implements parallel processing for large files +- Optimizes file operations with multi-threading +- Provides chunked reading and writing +- Handles load balancing for file operations +- Implements backpressure mechanisms + +### Services + +**ID Mapping Service (`src/infrastructure/services/id_mapping_service.rs`)** +- Manages mapping between UUIDs and filesystem paths +- Provides persistent ID generation and lookup +- Handles path changes while maintaining stable IDs +- Implements caching for frequently accessed mappings +- Ensures consistency between IDs and paths + +**Buffer Pool (`src/infrastructure/services/buffer_pool.rs`)** +- Manages memory buffers for file operations +- Implements pooling for optimal memory usage +- Provides buffer recycling to reduce allocations +- Handles buffer sizing for different operations +- Implements thread-safe buffer management + +**Cache Manager (`src/infrastructure/services/cache_manager.rs`)** +- Provides application-wide caching services +- Implements multiple cache levels (memory, disk) +- Handles cache invalidation and consistency +- Manages cache size limits and eviction +- Provides statistics for cache performance + +**Compression Service (`src/infrastructure/services/compression_service.rs`)** +- Implements data compression for files and responses +- Supports multiple compression algorithms +- Provides on-the-fly compression for API responses +- Handles selective compression based on file types +- Optimizes compression levels for different content + +**File System I18n Service (`src/infrastructure/services/file_system_i18n_service.rs`)** +- Implements I18n service using filesystem storage +- Loads translations from JSON files +- Handles language detection and fallbacks +- Provides translation lookups for UI components +- Supports dynamic language switching + +**File Metadata Cache (`src/infrastructure/services/file_metadata_cache.rs`)** +- Provides caching for file metadata +- Optimizes repeated metadata access +- Implements cache invalidation strategies +- Handles concurrent access to metadata +- Supports different cache levels (memory, persistent) + +**ID Mapping Optimizer (`src/infrastructure/services/id_mapping_optimizer.rs`)** +- Optimizes ID-to-path mapping operations +- Implements batch processing for mapping updates +- Provides preloading for frequently accessed mappings +- Handles compaction of mapping storage +- Optimizes lookup performance for large mappings + +**Zip Service (`src/infrastructure/services/zip_service.rs`)** +- Provides ZIP archive creation and extraction +- Supports on-the-fly compression for downloads +- Handles large directory archiving +- Implements streaming ZIP generation +- Provides progress tracking for large operations + +**Trash Cleanup Service (`src/infrastructure/services/trash_cleanup_service.rs`)** +- Manages automatic cleanup of expired trash items +- Implements retention policy enforcement +- Provides scheduled cleanup operations +- Handles graceful cleanup with resource limits +- Supports custom cleanup rules + +## Interfaces Layer + +This layer handles external communication, including API endpoints and web interfaces. + +### API Handlers + +**File Handler (`src/interfaces/api/handlers/file_handler.rs`)** +- Handles HTTP requests for file operations +- Processes file uploads with multipart support +- Provides file downloads with optional compression +- Implements CRUD operations for files +- Manages error responses and status codes + +**Folder Handler (`src/interfaces/api/handlers/folder_handler.rs`)** +- Handles HTTP requests for folder operations +- Processes folder creation and listing +- Implements CRUD operations for directories +- Provides folder hierarchy navigation +- Manages error responses for folder operations + +**Auth Handler (`src/interfaces/api/handlers/auth_handler.rs`)** +- Handles authentication-related API endpoints +- Processes login, logout, and registration +- Manages session tokens and refresh +- Implements password reset functionality +- Provides authentication status information + +**Share Handler (`src/interfaces/api/handlers/share_handler.rs`)** +- Handles file and folder sharing endpoints +- Processes share creation and management +- Provides access to shared resources +- Handles permission verification +- Manages share links and passwords + +**Trash Handler (`src/interfaces/api/handlers/trash_handler.rs`)** +- Handles trash-related API endpoints +- Processes moving items to trash +- Provides trash listing and filtering +- Handles restoration from trash +- Manages permanent deletion operations + +**Search Handler (`src/interfaces/api/handlers/search_handler.rs`)** +- Handles search-related API endpoints +- Processes text search queries +- Provides filtering and sorting options +- Handles pagination for search results +- Manages relevance scoring for results + +**Recent Handler (`src/interfaces/api/handlers/recent_handler.rs`)** +- Handles recently accessed files endpoints +- Provides listing and filtering of recent files +- Manages user-specific recent history +- Handles pagination for recent items +- Provides sorting options for recent files + +**Favorites Handler (`src/interfaces/api/handlers/favorites_handler.rs`)** +- Handles user favorites endpoints +- Processes adding and removing favorites +- Provides listing and filtering of favorites +- Manages user-specific favorite collections +- Handles sorting and organization of favorites + +**I18n Handler (`src/interfaces/api/handlers/i18n_handler.rs`)** +- Handles internationalization endpoints +- Provides language selection and detection +- Serves translation resources +- Manages locale settings +- Handles language preference persistence + +**Batch Handler (`src/interfaces/api/handlers/batch_handler.rs`)** +- Handles batch operation endpoints +- Processes multiple operations in a single request +- Provides transaction support for batches +- Handles partial success scenarios +- Manages comprehensive error reporting + +**WebDAV Handler (`src/interfaces/api/handlers/webdav_handler.rs`)** +- Implements WebDAV protocol (RFC 4918) endpoints +- Handles WebDAV methods (PROPFIND, PROPPATCH, etc.) +- Provides file system access via HTTP +- Manages WebDAV properties and locks +- Supports third-party WebDAV clients + +### API Routes + +**Routes (`src/interfaces/api/routes.rs`)** +- Defines API routes and URL structure +- Maps endpoints to appropriate handlers +- Configures middleware for routes +- Handles versioning for API endpoints +- Provides documentation integration + +### Middleware + +**Auth Middleware (`src/interfaces/middleware/auth.rs`)** +- Handles authentication for API requests +- Verifies tokens and sessions +- Provides user context for handlers +- Manages authentication errors +- Supports different authentication methods + +**Cache Middleware (`src/interfaces/middleware/cache.rs`)** +- Implements response caching +- Handles cache headers and validation +- Provides conditional request processing +- Manages cache invalidation +- Optimizes for different content types + +**Redirect Middleware (`src/interfaces/middleware/redirect.rs`)** +- Handles HTTP redirects +- Manages URL normalization +- Provides permanent and temporary redirects +- Handles protocol upgrades (HTTP to HTTPS) +- Supports path-based redirections + +### Web Interface + +**Web Module (`src/interfaces/web/mod.rs`)** +- Coordinates web interface components +- Manages static file serving +- Provides web application integration +- Handles web-specific middleware +- Supports single-page application routing + +## Common Layer + +This layer provides shared utilities and configurations used across the application. + +**Config (`src/common/config.rs`)** +- Manages application configuration +- Loads settings from environment and files +- Provides typed configuration access +- Handles configuration validation +- Supports different environments (dev, prod) + +**Errors (`src/common/errors.rs`)** +- Defines error types and handling +- Provides consistent error formatting +- Implements error context and wrapping +- Handles error translation between layers +- Supports error categorization and logging + +**DI (`src/common/di.rs`)** +- Implements dependency injection +- Manages service lifecycles +- Provides application state container +- Handles service resolution and registration +- Supports scoped service instances + +**DB (`src/common/db.rs`)** +- Manages database connections +- Provides connection pooling +- Handles database migrations +- Implements query helpers +- Supports transaction management + +**Cache (`src/common/cache.rs`)** +- Provides generic caching facilities +- Implements different cache strategies +- Handles cache key generation +- Manages cache invalidation +- Supports distributed caching + +**Auth Factory (`src/common/auth_factory.rs`)** +- Creates authentication components +- Configures auth providers based on settings +- Provides factory methods for auth services +- Handles auth strategy selection +- Supports multiple authentication methods \ No newline at end of file diff --git a/doc/DAV-CLIENT-SETUP.md b/doc/DAV-CLIENT-SETUP.md new file mode 100644 index 00000000..7b3eb2ae --- /dev/null +++ b/doc/DAV-CLIENT-SETUP.md @@ -0,0 +1,246 @@ +# Configuración de Clientes DAV para OxiCloud + +Esta guía proporciona instrucciones para configurar varios clientes que soportan WebDAV, CalDAV y CardDAV para conectar con OxiCloud. + +## Tabla de Contenidos + +1. [URLs de Conexión](#urls-de-conexión) +2. [Clientes WebDAV](#clientes-webdav) +3. [Clientes CalDAV](#clientes-caldav) +4. [Clientes CardDAV](#clientes-carddav) +5. [Solución de Problemas](#solución-de-problemas) + +## URLs de Conexión + +Usa las siguientes URLs para conectar tus clientes con OxiCloud: + +- **WebDAV**: `https://tu-servidor.com/webdav/` +- **CalDAV**: + - Principal: `https://tu-servidor.com/caldav/` + - Calendario específico: `https://tu-servidor.com/caldav/{nombre-calendario}/` +- **CardDAV**: + - Principal: `https://tu-servidor.com/carddav/addressbooks/` + - Libreta específica: `https://tu-servidor.com/carddav/addressbooks/{nombre-libreta}/` + +## Clientes WebDAV + +### Windows + +#### Windows Explorer + +1. Abre el Explorador de Windows +2. Haz clic derecho en "Este equipo" y selecciona "Agregar una ubicación de red" +3. Haz clic en "Siguiente" +4. Selecciona "Elegir una ubicación de red personalizada" y haz clic en "Siguiente" +5. En el campo de dirección, introduce: `https://tu-servidor.com/webdav/` +6. Haz clic en "Siguiente" +7. Introduce un nombre para la conexión (ej. "OxiCloud") +8. Haz clic en "Siguiente" y luego en "Finalizar" +9. Introduce tus credenciales cuando se te soliciten + +#### Problemas Comunes en Windows + +- **Error de SSL**: Asegúrate de que tu certificado SSL sea válido y confiable para Windows +- **Bloqueo por WebClient**: Asegúrate de que el servicio "WebClient" de Windows esté activado +- **Límite de tamaño**: Windows limita por defecto las cargas a 50MB, modifica el registro para aumentarlo: + +``` +[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters] +"FileSizeLimitInBytes"=dword:00FFFFFF +``` + +### macOS + +#### Finder + +1. En Finder, haz clic en "Ir" en la barra de menú +2. Selecciona "Conectar al servidor..." o presiona ⌘+K +3. Introduce `https://tu-servidor.com/webdav/` como dirección del servidor +4. Haz clic en "Conectar" +5. Introduce tus credenciales cuando se te soliciten +6. Selecciona si deseas guardar la contraseña en el llavero + +### Linux + +#### GNOME Files (Nautilus) + +1. Abre Nautilus (Archivos) +2. Haz clic en "Otras ubicaciones" en el panel lateral +3. En la parte inferior, introduce `davs://tu-servidor.com/webdav/` en "Conectar al servidor" +4. Haz clic en "Conectar" +5. Introduce tus credenciales cuando se te soliciten + +#### Dolphin (KDE) + +1. Abre Dolphin +2. En la barra de dirección, escribe `webdavs://tu-servidor.com/webdav/` +3. Introduce tus credenciales cuando se te soliciten + +### Clientes Multiplataforma + +#### Cyberduck + +1. Descarga e instala [Cyberduck](https://cyberduck.io/) +2. Haz clic en "Nueva conexión" +3. Selecciona "WebDAV (HTTP/SSL)" como tipo de conexión +4. Introduce los siguientes datos: + - Servidor: `tu-servidor.com` + - Puerto: `443` + - Ruta: `/webdav/` + - Nombre de usuario: tu nombre de usuario + - Contraseña: tu contraseña +5. Haz clic en "Conectar" + +## Clientes CalDAV + +### Apple Calendar (macOS/iOS) + +#### macOS + +1. Abre la aplicación Calendario +2. Haz clic en "Calendario" en la barra de menú +3. Selecciona "Añadir cuenta..." +4. Selecciona "Otra cuenta de CalDAV..." +5. Completa la información: + - Correo electrónico: tu dirección de correo + - Contraseña: tu contraseña + - Dirección del servidor: `tu-servidor.com` + - Ruta: `/caldav/` (deja en blanco si no funciona) +6. Haz clic en "Iniciar sesión" + +#### iOS + +1. Ve a Ajustes > Calendario > Cuentas > Añadir cuenta +2. Selecciona "Otra" +3. Selecciona "Añadir cuenta CalDAV" +4. Completa la información: + - Servidor: `https://tu-servidor.com/caldav/` + - Nombre de usuario: tu nombre de usuario + - Contraseña: tu contraseña + - Descripción: "OxiCloud Calendario" +5. Toca "Siguiente" y luego "Guardar" + +### Mozilla Thunderbird con Lightning + +1. Instala Thunderbird y la extensión Lightning +2. Haz clic en el botón de calendario en la barra lateral +3. Haz clic derecho en el panel izquierdo y selecciona "Nuevo calendario" +4. Selecciona "En la red" y haz clic en "Siguiente" +5. Selecciona "CalDAV" como formato +6. Introduce `https://tu-servidor.com/caldav/nombre-calendario/` como ubicación +7. Haz clic en "Siguiente", introduce un nombre para el calendario +8. Completa la configuración y haz clic en "Finalizar" +9. Introduce tus credenciales cuando se te soliciten + +### Nextcloud Desktop Sync + +1. Descarga e instala el cliente de sincronización de Nextcloud +2. Durante la configuración, selecciona "Solo sincronización de calendario y contactos" +3. Introduce `https://tu-servidor.com` como dirección del servidor +4. Introduce tus credenciales +5. En las opciones de sincronización, selecciona los calendarios que deseas sincronizar + +## Clientes CardDAV + +### Apple Contacts (macOS/iOS) + +#### macOS + +1. Abre la aplicación Contactos +2. Haz clic en "Contactos" en la barra de menú +3. Selecciona "Añadir cuenta..." +4. Selecciona "Otra cuenta de CardDAV..." +5. Completa la información: + - Correo electrónico: tu dirección de correo + - Contraseña: tu contraseña + - Dirección del servidor: `tu-servidor.com` + - Ruta: `/carddav/addressbooks/` (deja en blanco si no funciona) +6. Haz clic en "Iniciar sesión" + +#### iOS + +1. Ve a Ajustes > Contactos > Cuentas > Añadir cuenta +2. Selecciona "Otra" +3. Selecciona "Añadir cuenta CardDAV" +4. Completa la información: + - Servidor: `https://tu-servidor.com/carddav/addressbooks/` + - Nombre de usuario: tu nombre de usuario + - Contraseña: tu contraseña + - Descripción: "OxiCloud Contactos" +5. Toca "Siguiente" y luego "Guardar" + +### Mozilla Thunderbird + +1. Instala Thunderbird y la extensión CardBook +2. Abre CardBook desde el menú de Thunderbird +3. Haz clic en "Libreta de direcciones" > "Nuevo" > "Libreta de direcciones remota" +4. Selecciona "CardDAV" como tipo +5. Introduce `https://tu-servidor.com/carddav/addressbooks/nombre-libreta/` como URL +6. Introduce un nombre para la libreta de direcciones +7. Introduce tus credenciales +8. Haz clic en "Validar" y luego en "Aceptar" + +### Cliente Evolution (Linux) + +1. Abre Evolution +2. Ve a Archivo > Nuevo > Libreta de direcciones +3. Selecciona "CardDAV" como tipo +4. Introduce `https://tu-servidor.com/carddav/addressbooks/nombre-libreta/` como URL +5. Introduce un nombre para la libreta de direcciones +6. Introduce tus credenciales +7. Haz clic en "Aplicar" + +## Solución de Problemas + +### Problemas Comunes + +1. **Error de autenticación** + - Verifica que estés usando las credenciales correctas + - Asegúrate de que tu cuenta tenga acceso a los recursos DAV + - Si usas autenticación de dos factores, es posible que necesites crear una contraseña de aplicación específica + +2. **No se pueden encontrar calendarios/libretas** + - Verifica que hayas creado calendarios o libretas de direcciones en OxiCloud + - Asegúrate de estar usando la URL correcta, incluyendo la terminación con `/` + - Verifica los permisos de los recursos + +3. **Error SSL/TLS** + - Asegúrate de que tu certificado SSL sea válido y confiable + - Verifica que la fecha y hora de tu dispositivo sean correctas + - En algunos clientes, puede ser necesario confiar manualmente en el certificado + +4. **Sincronización lenta** + - Limita el número de elementos en tus calendarios y libretas de direcciones + - Verifica la calidad de tu conexión a Internet + - Algunas operaciones masivas (como importar muchos contactos) pueden tardar tiempo + +### Herramientas de Diagnóstico + +1. **Verificación de conectividad**: Prueba la conexión básica con: + ``` + curl -v https://tu-servidor.com/webdav/ + ``` + +2. **Prueba de autenticación**: + ``` + curl -v -u usuario:contraseña https://tu-servidor.com/webdav/ + ``` + +3. **Prueba de funcionalidad WebDAV**: + ``` + curl -X PROPFIND -H "Depth: 1" -u usuario:contraseña https://tu-servidor.com/webdav/ + ``` + +4. **Prueba de funcionalidad CalDAV**: + ``` + curl -X PROPFIND -H "Depth: 1" -u usuario:contraseña https://tu-servidor.com/caldav/ + ``` + +5. **Logs del servidor**: Si tienes acceso, revisa los logs del servidor para identificar problemas específicos. + +### Contacto para Soporte + +Si continúas experimentando problemas, contacta con soporte en: +- Email: soporte@ejemplo.com +- Foro: https://ejemplo.com/foro +- Sistema de tickets: https://soporte.ejemplo.com \ No newline at end of file diff --git a/doc/DAV-IMPLEMENTATION-PLAN.md b/doc/DAV-IMPLEMENTATION-PLAN.md new file mode 100644 index 00000000..56d6eca9 --- /dev/null +++ b/doc/DAV-IMPLEMENTATION-PLAN.md @@ -0,0 +1,286 @@ +# Plan de Implementación DAV para OxiCloud + +Este documento presenta un plan de implementación estructurado para añadir soporte WebDAV, CalDAV y CardDAV a OxiCloud. + +## Resumen Ejecutivo + +La implementación de los protocolos DAV (WebDAV, CalDAV y CardDAV) permitirá a OxiCloud interoperar con una amplia gama de clientes y dispositivos, aumentando significativamente su versatilidad y utilidad. Este plan propone un enfoque por fases que prioriza primero WebDAV (para acceso a archivos), seguido de CalDAV (para calendarios) y finalmente CardDAV (para contactos). + +## Fases de Implementación + +### Fase 1: Infraestructura DAV Común (Estimado: 2-3 semanas) + +**Objetivos:** +- Establecer la infraestructura básica compartida por todos los protocolos DAV +- Implementar el manejo de solicitudes XML y respuestas +- Crear adaptadores para las operaciones básicas DAV + +**Tareas:** +1. **Semana 1: Diseño y Arquitectura** + - Diseñar la arquitectura de los componentes DAV + - Definir interfaces para adaptadores DAV + - Seleccionar bibliotecas para procesamiento XML y RFC4918 + +2. **Semana 2: Implementación Base** + - Implementar manejadores de serialización/deserialización XML + - Desarrollar middleware para procesamiento de solicitudes DAV + - Crear estructuras comunes (propiedades, espacios de nombres) + - Implementar validación de solicitudes DAV + +3. **Semana 3: Framework de Pruebas** + - Configurar entorno de pruebas para protocolos DAV + - Implementar clientes de prueba automatizados + - Crear casos de prueba para operaciones DAV básicas + +**Entregables:** +- Framework de procesamiento XML para solicitudes/respuestas DAV +- Adaptadores base para las entidades existentes +- Suite de pruebas para operaciones DAV + +### Fase 2: WebDAV (Estimado: 3-4 semanas) + +**Objetivos:** +- Implementar el protocolo WebDAV completo (RFC4918) +- Permitir acceso a archivos y carpetas vía WebDAV +- Asegurar compatibilidad con clientes WebDAV comunes + +**Tareas:** +1. **Semana 1: Operaciones Básicas** + - Implementar métodos PROPFIND y PROPPATCH + - Desarrollar endpoint OPTIONS (descubrimiento de capacidades) + - Implementar operaciones GET, HEAD, PUT (lectura/escritura) + +2. **Semana 2: Operaciones Avanzadas** + - Implementar MKCOL (creación de directorios) + - Desarrollar DELETE para recursos WebDAV + - Implementar COPY y MOVE para archivos y directorios + +3. **Semana 3: Bloqueo y Características Extendidas** + - Implementar LOCK y UNLOCK para recursos + - Añadir soporte para propiedades personalizadas + - Desarrollar características de WebDAV extendidas (si es necesario) + +4. **Semana 4: Pruebas y Optimización** + - Realizar pruebas con clientes reales (Windows, macOS, Linux) + - Optimizar rendimiento para transferencias grandes + - Documentar APIs y comportamiento WebDAV + +**Entregables:** +- Implementación completa de WebDAV (RFC4918) +- Documentación de uso de WebDAV con OxiCloud +- Compatibilidad con los clientes WebDAV más comunes + +### Fase 3: CalDAV (Estimado: 4-5 semanas) + +**Objetivos:** +- Implementar el protocolo CalDAV (RFC4791) +- Crear entidades y repositorios para calendarios y eventos +- Soportar operaciones de calendario con clientes comunes + +**Tareas:** +1. **Semana 1: Modelo de Datos** + - Implementar entidades Calendar y CalendarEvent + - Desarrollar repositorios para almacenamiento de datos + - Crear DTOs y adaptadores CalDAV + +2. **Semana 2: Endpoints Básicos** + - Implementar PROPFIND para detección de calendarios + - Desarrollar MKCALENDAR para creación de calendarios + - Implementar GET/PUT para eventos individuales + +3. **Semana 3: Consultas Avanzadas** + - Implementar REPORT para consultas de calendario + - Desarrollar soporte para búsqueda por rango de fechas + - Añadir manejo de recurrencias (reglas RRULE) + +4. **Semana 4: Interoperabilidad** + - Implementar sincronización eficiente (collection-sync) + - Añadir soporte para zonas horarias + - Desarrollar manejo de alarmas y notificaciones + +5. **Semana 5: Pruebas y Refinamiento** + - Probar con clientes CalDAV populares + - Optimizar rendimiento para calendarios grandes + - Documentar APIs y comportamiento CalDAV + +**Entregables:** +- Implementación completa de CalDAV (RFC4791) +- Soporte para creación y gestión de calendarios +- Compatibilidad con clientes CalDAV populares +- Documentación de uso de CalDAV con OxiCloud + +### Fase 4: CardDAV (Estimado: 3-4 semanas) + +**Objetivos:** +- Implementar el protocolo CardDAV (RFC6352) +- Crear entidades y repositorios para libretas de direcciones y contactos +- Soportar operaciones de contactos con clientes comunes + +**Tareas:** +1. **Semana 1: Modelo de Datos** + - Implementar entidades AddressBook y Contact + - Desarrollar repositorios para almacenamiento de datos + - Crear DTOs y adaptadores CardDAV + +2. **Semana 2: Endpoints Básicos** + - Implementar PROPFIND para detección de libretas + - Desarrollar MKCOL para creación de libretas de direcciones + - Implementar GET/PUT para contactos individuales + +3. **Semana 3: Consultas y Búsqueda** + - Implementar REPORT para consultas de contactos + - Desarrollar búsqueda de contactos por criterios + - Añadir soporte para grupos de contactos + +4. **Semana 4: Pruebas y Refinamiento** + - Probar con clientes CardDAV populares + - Optimizar rendimiento para libretas grandes + - Documentar APIs y comportamiento CardDAV + +**Entregables:** +- Implementación completa de CardDAV (RFC6352) +- Soporte para creación y gestión de libretas de direcciones +- Compatibilidad con clientes CardDAV populares +- Documentación de uso de CardDAV con OxiCloud + +### Fase 5: Integración y Lanzamiento (Estimado: 2-3 semanas) + +**Objetivos:** +- Integrar todos los protocolos DAV en una solución cohesiva +- Asegurar compatibilidad cruzada entre protocolos +- Preparar la documentación y materiales para lanzamiento + +**Tareas:** +1. **Semana 1: Integración** + - Consolidar código compartido entre protocolos + - Asegurar coherencia de comportamiento + - Refinar manejo de errores y recuperación + +2. **Semana 2: Pruebas de Sistema** + - Realizar pruebas de integración end-to-end + - Validar rendimiento bajo carga + - Verificar seguridad y permisos + +3. **Semana 3: Documentación y Lanzamiento** + - Finalizar guías de usuario para clientes DAV + - Crear documentación para desarrolladores + - Preparar materiales de lanzamiento + +**Entregables:** +- Solución DAV completa e integrada +- Documentación comprensiva para usuarios y desarrolladores +- Paquete de lanzamiento listo para despliegue + +## Requisitos de Infraestructura + +### Dependencias de Bibliotecas + +```toml +# Añadir a Cargo.toml +[dependencies] +# Procesamiento XML +quick-xml = "0.30.0" +xml-rs = "0.8.14" + +# Soporte para iCalendar +icalendar = "0.15.0" + +# Soporte para vCard +vcard = "0.2.0" + +# Utilidades para DAV +http-multipart = "0.3.0" +``` + +### Esquema de Base de Datos + +Las nuevas tablas para CalDAV y CardDAV deben ser creadas como parte de la fase correspondiente. Ver el esquema completo en el documento principal de implementación. + +## Estrategia de Pruebas + +### Pruebas Unitarias + +- Pruebas de serialización/deserialización XML +- Pruebas de validación de entradas +- Pruebas de lógica de negocio para cada operación DAV + +### Pruebas de Integración + +- Pruebas end-to-end con clientes simulados +- Pruebas de flujos completos (creación, actualización, eliminación) +- Pruebas de concurrencia y manejo de conflictos + +### Pruebas de Compatibilidad + +- Matriz de pruebas con clientes reales (al menos 3 por protocolo) +- Pruebas en diferentes sistemas operativos +- Verificación de conformidad con RFCs + +## Consideraciones de Rendimiento + +1. **Optimización de Consultas** + - Implementar paginación para conjuntos grandes de resultados + - Optimizar consultas SQL para calendarios y contactos + - Utilizar índices adecuados para búsqueda rápida + +2. **Caché** + - Implementar caché de propiedades para respuestas PROPFIND + - Usar ETags para validación de caché + - Aplicar caché de consultas para reportes frecuentes + +3. **Procesamiento Eficiente** + - Procesamiento XML eficiente para solicitudes grandes + - Streaming de datos para archivos grandes + - Procesamiento asíncrono para operaciones costosas + +## Riesgos y Mitigación + +| Riesgo | Impacto | Probabilidad | Estrategia de Mitigación | +|--------|---------|--------------|--------------------------| +| Problemas de compatibilidad con clientes | Alto | Medio | Pruebas tempranas con variedad de clientes, seguir estrictamente las especificaciones | +| Rendimiento insuficiente | Medio | Bajo | Pruebas de carga desde el inicio, diseño para escalabilidad | +| Complejidad excesiva | Medio | Medio | Enfoque modular, abstracciones claras, revisiones de código frecuentes | +| Problemas de seguridad | Alto | Bajo | Revisiones de seguridad, validación estricta de entradas, pruebas de penetración | +| Retrasos en el cronograma | Medio | Medio | Planificación conservadora, hitos claros, enfoque iterativo | + +## Criterios de Éxito + +1. **Compatibilidad** + - Todos los protocolos cumplen con sus respectivos RFCs + - Compatibilidad verificada con al menos 3 clientes principales por protocolo + - Funciona en todos los sistemas operativos principales + +2. **Rendimiento** + - Tiempo de respuesta para operaciones típicas < 500ms + - Soporta calendarios con >1000 eventos sin degradación significativa + - Soporta libretas con >1000 contactos sin degradación significativa + +3. **Usabilidad** + - Proceso de configuración de cliente sencillo y documentado + - Mensajes de error claros y específicos + - Documentación completa para usuarios y desarrolladores + +## Recursos Necesarios + +1. **Equipo de Desarrollo** + - 1-2 desarrolladores de backend (Rust) + - 1 desarrollador de frontend (para integración UI si es necesario) + - 1 tester + +2. **Infraestructura** + - Entorno de pruebas con múltiples sistemas operativos + - Clientes DAV variados para pruebas + - Servidor de CI/CD para pruebas automatizadas + +3. **Habilidades** + - Experiencia con protocolos HTTP avanzados + - Conocimiento de procesamiento XML + - Familiaridad con los estándares WebDAV, CalDAV y CardDAV + +## Próximos Pasos + +1. Asignar recursos al proyecto +2. Establecer repositorio de código y estructura inicial +3. Iniciar la Fase 1 (Infraestructura DAV Común) +4. Configurar entorno de CI/CD para pruebas +5. Revisar y refinar el plan según sea necesario durante la implementación \ No newline at end of file diff --git a/doc/DAV-INTEGRATION.md b/doc/DAV-INTEGRATION.md new file mode 100644 index 00000000..e4d5959d --- /dev/null +++ b/doc/DAV-INTEGRATION.md @@ -0,0 +1,603 @@ +# Integración de WebDAV, CalDAV y CardDAV en OxiCloud + +Este documento describe el diseño e implementación de los protocolos WebDAV, CalDAV y CardDAV en OxiCloud, extendiendo la plataforma para soportar clientes y dispositivos que utilizan estos estándares. + +## Tabla de Contenidos + +1. [Introducción](#introducción) +2. [Arquitectura de la Implementación](#arquitectura-de-la-implementación) +3. [WebDAV](#webdav) +4. [CalDAV](#caldav) +5. [CardDAV](#carddav) +6. [Consideraciones de Seguridad](#consideraciones-de-seguridad) +7. [Pruebas y Compatibilidad](#pruebas-y-compatibilidad) + +## Introducción + +### WebDAV (Web Distributed Authoring and Versioning) +WebDAV es una extensión del protocolo HTTP que permite a los clientes realizar operaciones sobre archivos en un servidor remoto, como crear, modificar, mover y eliminar archivos y directorios. + +### CalDAV (Calendaring Extensions to WebDAV) +CalDAV es un protocolo basado en WebDAV que permite a los clientes acceder y gestionar datos de calendario, como eventos y tareas. + +### CardDAV (vCard Extensions to WebDAV) +CardDAV es un protocolo que extiende WebDAV para permitir el acceso y gestión de datos de contactos en formato vCard. + +## Arquitectura de la Implementación + +La implementación de los protocolos DAV se integra en la arquitectura hexagonal existente de OxiCloud: + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ INTERFACES │ +│ │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────────┐ │ +│ │ │ │ │ │ │ │ +│ │ REST API │ │ WebDAV API │ │ CalDAV/CardDAV API │ │ +│ │ │ │ │ │ │ │ +│ └───────┬───────┘ └───────┬───────┘ └───────────┬───────────┘ │ +│ │ │ │ │ +└──────────┼──────────────────┼──────────────────────┼──────────────┘ + │ │ │ + ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ APLICACIÓN │ +│ │ +│ ┌───────────┐ ┌────────────┐ ┌───────────┐ ┌──────────────┐ │ +│ │ │ │ │ │ │ │ │ │ +│ │FileService│ │FolderService│ │CalService │ │ContactService│ │ +│ │ │ │ │ │ │ │ │ │ +│ └─────┬─────┘ └──────┬─────┘ └─────┬─────┘ └──────┬───────┘ │ +│ │ │ │ │ │ +└────────┼───────────────┼──────────────┼───────────────┼─────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌────────────────────────────────────────────────────────────────┐ +│ DOMINIO │ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌────────────┐ ┌───────────────┐ │ +│ │ │ │ │ │ │ │ │ │ +│ │ File │ │ Folder │ │ Calendar │ │ Contact │ │ +│ │ │ │ │ │ │ │ │ │ +│ └─────────┘ └──────────┘ └────────────┘ └───────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────┘ +``` + +### Componentes Principales + +1. **Adaptadores DAV**: Convertirán entre las especificaciones DAV y los modelos de OxiCloud +2. **Servicios de Aplicación**: Se extenderán para incluir funcionalidades específicas DAV +3. **Modelos de Dominio**: Se añadirán nuevas entidades para Calendar y Contact +4. **Repositorios**: Implementaciones de almacenamiento para calendarios y contactos + +## WebDAV + +### Endpoints Requeridos + +| Método HTTP | Endpoint | Descripción | +|-------------|----------|-------------| +| OPTIONS | /webdav/{path} | Indica las capacidades WebDAV soportadas | +| PROPFIND | /webdav/{path} | Recupera propiedades de recursos | +| PROPPATCH | /webdav/{path} | Modifica propiedades de recursos | +| MKCOL | /webdav/{path} | Crea colecciones (directorios) | +| GET | /webdav/{path} | Recupera contenido de recursos | +| HEAD | /webdav/{path} | Recupera metadatos de recursos | +| PUT | /webdav/{path} | Crea o actualiza recursos | +| DELETE | /webdav/{path} | Elimina recursos | +| COPY | /webdav/{path} | Copia recursos | +| MOVE | /webdav/{path} | Mueve recursos | +| LOCK | /webdav/{path} | Bloquea recursos | +| UNLOCK | /webdav/{path} | Desbloquea recursos | + +### Implementación + +1. **Manejador WebDAV**: + +```rust +// src/interfaces/api/handlers/webdav_handler.rs +use std::sync::Arc; +use axum::{ + Router, + routing::get, + extract::{Path, State, Request, Extension}, + http::StatusCode, + response::Response, +}; +use http::{Method, header}; + +use crate::common::di::AppState; +use crate::interfaces::middleware::auth::CurrentUser; +use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase}; +use crate::application::ports::folder_ports::FolderUseCase; +use crate::common::errors::AppError; + +pub fn webdav_routes() -> Router> { + Router::new() + .route("/webdav/*path", get(handle_get)) + .route_with_tsr("/webdav/*path", axum::routing::on( + Method::OPTIONS, handle_options, + Method::PROPFIND, handle_propfind, + Method::PROPPATCH, handle_proppatch, + Method::MKCOL, handle_mkcol, + Method::PUT, handle_put, + Method::DELETE, handle_delete, + Method::COPY, handle_copy, + Method::MOVE, handle_move, + Method::LOCK, handle_lock, + Method::UNLOCK, handle_unlock, + )) +} + +// Implementar funciones para cada método WebDAV... +``` + +2. **Adaptador WebDAV**: + +```rust +// src/application/adapters/webdav_adapter.rs +use xml::reader::{EventReader, XmlEvent}; +use xml::writer::{EventWriter, EmitterConfig, XmlEvent as WriteEvent}; +use std::io::{Read, Write}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; + +/// Convierte entre objetos de OxiCloud y representaciones WebDAV +pub struct WebDavAdapter; + +impl WebDavAdapter { + /// Convierte una propiedad PROPFIND en XML a un objeto de solicitud + pub fn parse_propfind(reader: R) -> Result { + // Implementación... + } + + /// Genera respuesta XML para PROPFIND basada en archivos y carpetas + pub fn generate_propfind_response( + writer: W, + files: &[FileDto], + folders: &[FolderDto], + base_url: &str, + ) -> Result<(), Error> { + // Implementación... + } + + // Otros métodos para manejar diferentes operaciones WebDAV... +} +``` + +## CalDAV + +### Endpoints Requeridos + +| Método HTTP | Endpoint | Descripción | +|-------------|----------|-------------| +| PROPFIND | /caldav/{calendar} | Recupera propiedades del calendario | +| REPORT | /caldav/{calendar} | Consulta eventos del calendario | +| MKCALENDAR | /caldav/{calendar} | Crea un nuevo calendario | +| PUT | /caldav/{calendar}/{event}.ics | Crea o actualiza un evento | +| GET | /caldav/{calendar}/{event}.ics | Recupera un evento | +| DELETE | /caldav/{calendar}/{event}.ics | Elimina un evento | + +### Implementación + +1. **Nuevas Entidades de Dominio**: + +```rust +// src/domain/entities/calendar.rs +use uuid::Uuid; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct Calendar { + id: Uuid, + name: String, + owner_id: String, + description: Option, + color: Option, + created_at: DateTime, + updated_at: DateTime, +} + +// src/domain/entities/calendar_event.rs +use uuid::Uuid; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct CalendarEvent { + id: Uuid, + calendar_id: Uuid, + summary: String, + description: Option, + location: Option, + start_time: DateTime, + end_time: DateTime, + all_day: bool, + rrule: Option, // Regla de recurrencia + ical_data: String, // Datos iCalendar completos + created_at: DateTime, + updated_at: DateTime, +} +``` + +2. **Repositorios**: + +```rust +// src/domain/repositories/calendar_repository.rs +use async_trait::async_trait; +use uuid::Uuid; +use crate::domain::entities::calendar::Calendar; +use crate::common::errors::Result; + +#[async_trait] +pub trait CalendarRepository: Send + Sync { + async fn create_calendar(&self, calendar: Calendar) -> Result; + async fn get_calendar_by_id(&self, id: &Uuid) -> Result; + async fn get_calendars_by_owner(&self, owner_id: &str) -> Result>; + async fn update_calendar(&self, calendar: Calendar) -> Result; + async fn delete_calendar(&self, id: &Uuid) -> Result<()>; +} + +// src/domain/repositories/calendar_event_repository.rs +use async_trait::async_trait; +use uuid::Uuid; +use chrono::{DateTime, Utc}; +use crate::domain::entities::calendar_event::CalendarEvent; +use crate::common::errors::Result; + +#[async_trait] +pub trait CalendarEventRepository: Send + Sync { + async fn create_event(&self, event: CalendarEvent) -> Result; + async fn get_event_by_id(&self, id: &Uuid) -> Result; + async fn get_events_by_calendar(&self, calendar_id: &Uuid) -> Result>; + async fn get_events_in_timerange( + &self, + calendar_id: &Uuid, + start: &DateTime, + end: &DateTime + ) -> Result>; + async fn update_event(&self, event: CalendarEvent) -> Result; + async fn delete_event(&self, id: &Uuid) -> Result<()>; +} +``` + +3. **Servicio CalDAV**: + +```rust +// src/application/services/caldav_service.rs +use std::sync::Arc; +use uuid::Uuid; +use chrono::{DateTime, Utc}; +use crate::domain::repositories::calendar_repository::CalendarRepository; +use crate::domain::repositories::calendar_event_repository::CalendarEventRepository; +use crate::domain::entities::calendar::Calendar; +use crate::domain::entities::calendar_event::CalendarEvent; +use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto}; +use crate::common::errors::{Result, DomainError}; + +pub struct CalDavService { + calendar_repository: Arc, + event_repository: Arc, +} + +impl CalDavService { + pub fn new( + calendar_repository: Arc, + event_repository: Arc, + ) -> Self { + Self { + calendar_repository, + event_repository, + } + } + + // Implementar métodos para operaciones CalDAV... +} +``` + +4. **Manejador CalDAV**: + +```rust +// src/interfaces/api/handlers/caldav_handler.rs +use std::sync::Arc; +use axum::{ + Router, + routing::{get, put, delete}, + extract::{Path, State, Request, Extension}, + http::StatusCode, + response::Response, +}; +use http::{Method, header}; + +use crate::common::di::AppState; +use crate::interfaces::middleware::auth::CurrentUser; +use crate::application::services::caldav_service::CalDavService; +use crate::common::errors::AppError; + +pub fn caldav_routes() -> Router> { + Router::new() + .route("/caldav/", get(get_calendars)) + .route("/caldav/:calendar", get(get_calendar)) + .route_with_tsr("/caldav/:calendar", axum::routing::on( + Method::PROPFIND, handle_calendar_propfind, + Method::REPORT, handle_calendar_report, + Method::MKCALENDAR, handle_mkcalendar, + )) + .route("/caldav/:calendar/:event", get(get_event)) + .route("/caldav/:calendar/:event", put(put_event)) + .route("/caldav/:calendar/:event", delete(delete_event)) +} + +// Implementar funciones para cada método CalDAV... +``` + +## CardDAV + +### Endpoints Requeridos + +| Método HTTP | Endpoint | Descripción | +|-------------|----------|-------------| +| PROPFIND | /carddav/addressbooks/{addressbook} | Recupera propiedades de la libreta de direcciones | +| REPORT | /carddav/addressbooks/{addressbook} | Consulta contactos | +| MKCOL | /carddav/addressbooks/{addressbook} | Crea una nueva libreta de direcciones | +| PUT | /carddav/addressbooks/{addressbook}/{contact}.vcf | Crea o actualiza un contacto | +| GET | /carddav/addressbooks/{addressbook}/{contact}.vcf | Recupera un contacto | +| DELETE | /carddav/addressbooks/{addressbook}/{contact}.vcf | Elimina un contacto | + +### Implementación + +1. **Nuevas Entidades de Dominio**: + +```rust +// src/domain/entities/address_book.rs +use uuid::Uuid; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct AddressBook { + id: Uuid, + name: String, + owner_id: String, + description: Option, + created_at: DateTime, + updated_at: DateTime, +} + +// src/domain/entities/contact.rs +use uuid::Uuid; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct Contact { + id: Uuid, + address_book_id: Uuid, + full_name: String, + first_name: Option, + last_name: Option, + email: Option, + phone: Option, + address: Option, + organization: Option, + vcard_data: String, // Datos vCard completos + created_at: DateTime, + updated_at: DateTime, +} +``` + +2. **Repositorios**: + +```rust +// src/domain/repositories/address_book_repository.rs +use async_trait::async_trait; +use uuid::Uuid; +use crate::domain::entities::address_book::AddressBook; +use crate::common::errors::Result; + +#[async_trait] +pub trait AddressBookRepository: Send + Sync { + async fn create_address_book(&self, address_book: AddressBook) -> Result; + async fn get_address_book_by_id(&self, id: &Uuid) -> Result; + async fn get_address_books_by_owner(&self, owner_id: &str) -> Result>; + async fn update_address_book(&self, address_book: AddressBook) -> Result; + async fn delete_address_book(&self, id: &Uuid) -> Result<()>; +} + +// src/domain/repositories/contact_repository.rs +use async_trait::async_trait; +use uuid::Uuid; +use crate::domain::entities::contact::Contact; +use crate::common::errors::Result; + +#[async_trait] +pub trait ContactRepository: Send + Sync { + async fn create_contact(&self, contact: Contact) -> Result; + async fn get_contact_by_id(&self, id: &Uuid) -> Result; + async fn get_contacts_by_address_book(&self, address_book_id: &Uuid) -> Result>; + async fn search_contacts(&self, address_book_id: &Uuid, query: &str) -> Result>; + async fn update_contact(&self, contact: Contact) -> Result; + async fn delete_contact(&self, id: &Uuid) -> Result<()>; +} +``` + +3. **Servicio CardDAV**: + +```rust +// src/application/services/carddav_service.rs +use std::sync::Arc; +use uuid::Uuid; +use crate::domain::repositories::address_book_repository::AddressBookRepository; +use crate::domain::repositories::contact_repository::ContactRepository; +use crate::domain::entities::address_book::AddressBook; +use crate::domain::entities::contact::Contact; +use crate::application::dtos::address_book_dto::{AddressBookDto, ContactDto}; +use crate::common::errors::{Result, DomainError}; + +pub struct CardDavService { + address_book_repository: Arc, + contact_repository: Arc, +} + +impl CardDavService { + pub fn new( + address_book_repository: Arc, + contact_repository: Arc, + ) -> Self { + Self { + address_book_repository, + contact_repository, + } + } + + // Implementar métodos para operaciones CardDAV... +} +``` + +4. **Manejador CardDAV**: + +```rust +// src/interfaces/api/handlers/carddav_handler.rs +use std::sync::Arc; +use axum::{ + Router, + routing::{get, put, delete}, + extract::{Path, State, Request, Extension}, + http::StatusCode, + response::Response, +}; +use http::{Method, header}; + +use crate::common::di::AppState; +use crate::interfaces::middleware::auth::CurrentUser; +use crate::application::services::carddav_service::CardDavService; +use crate::common::errors::AppError; + +pub fn carddav_routes() -> Router> { + Router::new() + .route("/carddav/addressbooks/", get(get_address_books)) + .route("/carddav/addressbooks/:addressbook", get(get_address_book)) + .route_with_tsr("/carddav/addressbooks/:addressbook", axum::routing::on( + Method::PROPFIND, handle_addressbook_propfind, + Method::REPORT, handle_addressbook_report, + Method::MKCOL, handle_mkaddressbook, + )) + .route("/carddav/addressbooks/:addressbook/:contact", get(get_contact)) + .route("/carddav/addressbooks/:addressbook/:contact", put(put_contact)) + .route("/carddav/addressbooks/:addressbook/:contact", delete(delete_contact)) +} + +// Implementar funciones para cada método CardDAV... +``` + +## Esquema de Base de Datos + +```sql +-- Esquema para CalDAV +CREATE TABLE calendar ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + owner_id VARCHAR(255) NOT NULL, + description TEXT, + color VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE calendar_event ( + id UUID PRIMARY KEY, + calendar_id UUID NOT NULL REFERENCES calendar(id) ON DELETE CASCADE, + summary VARCHAR(255) NOT NULL, + description TEXT, + location TEXT, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + all_day BOOLEAN NOT NULL DEFAULT FALSE, + rrule TEXT, + ical_uid VARCHAR(255) NOT NULL, + ical_data TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Esquema para CardDAV +CREATE TABLE address_book ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + owner_id VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE contact ( + id UUID PRIMARY KEY, + address_book_id UUID NOT NULL REFERENCES address_book(id) ON DELETE CASCADE, + full_name VARCHAR(255) NOT NULL, + first_name VARCHAR(255), + last_name VARCHAR(255), + email VARCHAR(255), + phone VARCHAR(100), + address TEXT, + organization VARCHAR(255), + vcard_uid VARCHAR(255) NOT NULL, + vcard_data TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices para búsqueda eficiente +CREATE INDEX idx_calendar_owner ON calendar(owner_id); +CREATE INDEX idx_calendar_event_calendar ON calendar_event(calendar_id); +CREATE INDEX idx_address_book_owner ON address_book(owner_id); +CREATE INDEX idx_contact_address_book ON contact(address_book_id); +CREATE INDEX idx_contact_name ON contact(full_name); +``` + +## Consideraciones de Seguridad + +1. **Autenticación** + - Utilizar la autenticación existente de OxiCloud + - Soportar autenticación HTTP Basic para clientes DAV + - Implementar el esquema de autenticación Digest si es necesario + +2. **Autorización** + - Verificar permisos de usuario para acceder a recursos + - Implementar control de acceso basado en propietario y permisos compartidos + - Asegurar que los usuarios solo puedan acceder a sus propios calendarios y libretas de direcciones + +3. **Prevención de Ataques** + - Validar y sanitizar todas las entradas XML + - Limitar tamaño máximo de carga útil + - Implementar rate limiting en endpoints DAV + +## Pruebas y Compatibilidad + +### Clientes a Probar + +1. **WebDAV** + - Windows Explorer + - macOS Finder + - Cyberduck + - FileZilla (con extensión WebDAV) + +2. **CalDAV** + - Apple Calendar + - Mozilla Thunderbird (Lightning) + - Microsoft Outlook (con complemento CalDAV) + - Google Calendar (mediante sincronización) + +3. **CardDAV** + - Apple Contacts + - Mozilla Thunderbird + - Microsoft Outlook (con complemento CardDAV) + - Google Contacts (mediante sincronización) + +### Pruebas de Cumplimiento + +- Utilizar la suite de pruebas CalDAVTester para verificar la conformidad con el estándar +- Validar cumplimiento de RFC para cada protocolo +- Pruebas de stress para evaluar rendimiento bajo carga + +### Depuración + +- Implementar logging detallado para operaciones DAV +- Crear herramientas de diagnóstico para depurar solicitudes DAV complejas +- Proporcionar mensajes de error claros para ayudar en la resolución de problemas \ No newline at end of file diff --git a/doc/OIDC-ARCHITECTURE.md b/doc/OIDC-ARCHITECTURE.md new file mode 100644 index 00000000..25075e22 --- /dev/null +++ b/doc/OIDC-ARCHITECTURE.md @@ -0,0 +1,162 @@ +# Arquitectura de Integración OIDC en OxiCloud + +Este documento describe la arquitectura y el flujo de autenticación OpenID Connect (OIDC) en OxiCloud. + +## Diagrama de Arquitectura + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ │ +│ PROVEEDOR DE IDENTIDAD │ +│ │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ │ │ │ │ │ │ +│ │ Authentik │ │ Authelia │ │ KeyCloak │ │ +│ │ │ │ │ │ │ │ +│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │ +│ │ │ │ │ +└────────────┼──────────────────────┼──────────────────────┼─────────────┘ + │ │ │ + │ │ │ + │ │ │ + │ OIDC │ + │ │ │ + │ │ │ +┌────────────┼──────────────────────┼──────────────────────┼─────────────┐ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ OXICLOUD │ │ +│ │ │ │ +│ │ ┌───────────────┐ ┌───────────────┐ │ │ +│ │ │ │ │ │ │ │ +│ │ │ OidcService │◄────►│ AuthService │ │ │ +│ │ │ │ │ │ │ │ +│ │ └───────┬───────┘ └───────┬───────┘ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌───────────────────────────────────────────┐ │ │ +│ │ │ │ │ │ +│ │ │ AuthApplicationService │ │ │ +│ │ │ │ │ │ +│ │ └───────────────────┬───────────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌───────────────────────────────────────────┐ │ │ +│ │ │ │ │ │ +│ │ │ Auth Handler │ │ │ +│ │ │ │ │ │ +│ │ └───────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + ▲ + │ + │ HTTP/HTTPS + │ + │ +┌────────────────────────────────────────────────────────────────────────┐ +│ │ +│ NAVEGADOR WEB │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Interfaz de Usuario │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ │ │ │ │ │ +│ │ │ Login.html │ │ oidcAuth.js │ │ │ +│ │ │ │ │ │ │ │ +│ │ └──────────────┘ └──────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +## Flujo de Autenticación OIDC + +El flujo de autenticación OIDC en OxiCloud sigue el flujo de código de autorización (Authorization Code Flow): + +1. **Inicio de la Autenticación**: + - El usuario hace clic en "Login con [Proveedor]" en la página de inicio de sesión. + - El frontend genera un estado aleatorio para protección CSRF. + - El frontend solicita a OxiCloud una URL de autorización. + +2. **Redirección al Proveedor de Identidad**: + - OxiCloud genera una URL de autorización y la devuelve al frontend. + - El navegador redirige al usuario a la página de inicio de sesión del proveedor de identidad. + +3. **Autenticación en el Proveedor**: + - El usuario se autentica en el proveedor de identidad (con contraseña, 2FA, etc.). + - El proveedor redirige al usuario de vuelta a OxiCloud con un código de autorización. + +4. **Intercambio del Código de Autorización**: + - El frontend de OxiCloud recibe el código de autorización y lo envía al backend. + - OxiCloud intercambia el código por tokens de acceso e ID con el proveedor de identidad. + - OxiCloud verifica el token de ID y extrae la información del usuario. + +5. **Creación/Recuperación de Usuario**: + - OxiCloud busca un usuario existente con el ID externo del proveedor. + - Si no existe y la creación automática está habilitada, se crea un nuevo usuario. + - Si no existe y la creación automática está deshabilitada, se devuelve un error. + +6. **Generación de Tokens de Sesión**: + - OxiCloud genera sus propios tokens de acceso y actualización para el usuario. + - Estos tokens se utilizan para autenticar las solicitudes subsiguientes a la API de OxiCloud. + +7. **Respuesta al Cliente**: + - OxiCloud devuelve los tokens y la información del usuario al frontend. + - El frontend almacena los tokens y redirige al usuario a la página principal. + +## Componentes Principales + +### 1. OidcService + +Este servicio gestiona la comunicación con los proveedores OIDC: +- Descubre los endpoints OIDC de los proveedores +- Genera URLs de autorización +- Intercambia códigos de autorización por tokens +- Verifica tokens y extrae información de usuario + +### 2. AuthApplicationService + +Coordina el proceso de autenticación: +- Proporciona una interfaz entre la capa de API y los servicios de dominio +- Gestiona el proceso de creación/recuperación de usuarios +- Coordina la generación de tokens de acceso para OxiCloud + +### 3. Auth Handler + +Expone endpoints HTTP para el flujo de autenticación OIDC: +- `/api/auth/oidc/providers` - Lista los proveedores OIDC disponibles +- `/api/auth/oidc/auth` - Genera una URL de autorización para un proveedor +- `/api/auth/oidc/callback` - Procesa la respuesta del proveedor y completa la autenticación + +### 4. Frontend (oidcAuth.js) + +Gestiona la parte del cliente del flujo de autenticación: +- Muestra botones para los proveedores OIDC +- Inicia el flujo de autenticación +- Maneja la redirección de retorno del proveedor +- Procesa y almacena los tokens de sesión + +## Configuración Multi-Proveedor + +OxiCloud permite configurar múltiples proveedores OIDC simultáneamente: + +1. **Configuración Separada**: Cada proveedor tiene su propia configuración independiente. +2. **Selección de Proveedor**: Los usuarios pueden elegir con qué proveedor autenticarse. +3. **Mapeo de Identidades**: OxiCloud mapea identidades de diferentes proveedores a usuarios internos. + +## Seguridad + +La implementación OIDC en OxiCloud incluye varias medidas de seguridad: + +1. **Protección CSRF**: Utiliza un estado aleatorio para prevenir ataques CSRF. +2. **Validación de Tokens**: Verifica firmas y vigencia de los tokens JWT. +3. **Código de Autorización**: Utiliza el flujo de código de autorización, que es más seguro que el flujo implícito. +4. **HTTPS**: Requiere conexiones HTTPS para todas las comunicaciones OIDC. +5. **Secretos del Cliente**: Los secretos del cliente se almacenan de forma segura y nunca se exponen al frontend. \ No newline at end of file diff --git a/doc/OIDC-CONFIG-EXAMPLES.md b/doc/OIDC-CONFIG-EXAMPLES.md new file mode 100644 index 00000000..ff6cc9a3 --- /dev/null +++ b/doc/OIDC-CONFIG-EXAMPLES.md @@ -0,0 +1,218 @@ +# Ejemplos de Configuración de OIDC para OxiCloud + +Esta guía proporciona ejemplos de configuración para integrar OxiCloud con diferentes proveedores OIDC (OpenID Connect). + +## Índice + +1. [Configuración General de OIDC](#configuración-general-de-oidc) +2. [Authentik](#authentik) +3. [Authelia](#authelia) +4. [KeyCloak](#keycloak) +5. [Resolución de Problemas](#resolución-de-problemas) + +## Configuración General de OIDC + +Para habilitar la integración OIDC en OxiCloud, necesitará establecer las siguientes variables de entorno: + +```bash +# Habilitar OIDC +OXICLOUD_ENABLE_OIDC=true + +# Configuración para cada proveedor OIDC (puede configurar múltiples proveedores) +OXICLOUD_OIDC_PROVIDER__NAME="Nombre Visible" +OXICLOUD_OIDC_PROVIDER__CLIENT_ID="su-client-id" +OXICLOUD_OIDC_PROVIDER__CLIENT_SECRET="su-client-secret" +OXICLOUD_OIDC_PROVIDER__DISCOVERY_URL="https://proveedor.example.com/.well-known/openid-configuration" +OXICLOUD_OIDC_PROVIDER__REDIRECT_URI="https://su-oxicloud.example.com/oidc/callback/" +OXICLOUD_OIDC_PROVIDER__SCOPES="openid profile email" +OXICLOUD_OIDC_PROVIDER__USER_ID_ATTRIBUTE="sub" +OXICLOUD_OIDC_PROVIDER__DEFAULT_ROLE="user" +OXICLOUD_OIDC_PROVIDER__AUTO_CREATE_USERS="true" +``` + +## Authentik + +[Authentik](https://goauthentik.io/) es una plataforma de identidad de código abierto que proporciona autenticación, autorización y gestión de usuarios. + +### 1. Configurar una aplicación en Authentik + +1. Inicia sesión en tu panel de administración de Authentik +2. Ve a "Applications" → "Create" +3. Introduce un nombre para tu aplicación (ej. "OxiCloud") +4. Selecciona "OAuth2/OpenID Provider" como tipo de proveedor +5. En la configuración de OAuth2: + - **Redirect URI/Callback URL**: `https://su-oxicloud.example.com/oidc/callback/authentik` + - **Client Type**: Confidential + - **Client ID**: Se generará automáticamente (anótalo) + - **Client Secret**: Se generará automáticamente (anótalo) + - **Scopes**: openid, email, profile +6. En la configuración de UI: + - **Launch URL**: `https://su-oxicloud.example.com/` + - **Icon**: Opcional, puedes subir un icono para OxiCloud + +### 2. Configurar OxiCloud para Authentik + +```yaml +# docker-compose.yml +version: '3' +services: + oxicloud: + image: oxicloud:latest + environment: + # Configuración general + OXICLOUD_ENABLE_OIDC: "true" + + # Configuración de Authentik + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_NAME: "Authentik" + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_CLIENT_ID: "tu-client-id-de-authentik" + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_CLIENT_SECRET: "tu-client-secret-de-authentik" + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_DISCOVERY_URL: "https://authentik.example.com/application/o/oxicloud/.well-known/openid-configuration" + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_REDIRECT_URI: "https://oxicloud.example.com/oidc/callback/authentik" + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_SCOPES: "openid profile email" + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_USER_ID_ATTRIBUTE: "sub" + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_DEFAULT_ROLE: "user" + OXICLOUD_OIDC_PROVIDER_AUTHENTIK_AUTO_CREATE_USERS: "true" + ports: + - "8085:8085" + volumes: + - ./storage:/app/storage +``` + +## Authelia + +[Authelia](https://www.authelia.com/) es una solución de autenticación multi-factor de código abierto. + +### 1. Configurar Authelia para OxiCloud + +Edita tu configuración de Authelia (`configuration.yml`): + +```yaml +identity_providers: + oidc: + hmac_secret: tu-secreto-seguro # Cambia esto por un valor aleatorio seguro + issuer_private_key: /config/private.pem # Ruta a tu clave privada + cors: + endpoints: ['authorization', 'token', 'revocation', 'introspection'] + allowed_origins: + - https://oxicloud.example.com + clients: + - id: oxicloud + description: OxiCloud + secret: tu-client-secret-seguro # Cambia esto + public: false + authorization_policy: two_factor + redirect_uris: + - https://oxicloud.example.com/oidc/callback/authelia + scopes: ['openid', 'profile', 'email', 'groups'] + userinfo_signing_algorithm: none +``` + +### 2. Configurar OxiCloud para Authelia + +```yaml +# docker-compose.yml +version: '3' +services: + oxicloud: + image: oxicloud:latest + environment: + # Configuración general + OXICLOUD_ENABLE_OIDC: "true" + + # Configuración de Authelia + OXICLOUD_OIDC_PROVIDER_AUTHELIA_NAME: "Authelia" + OXICLOUD_OIDC_PROVIDER_AUTHELIA_CLIENT_ID: "oxicloud" + OXICLOUD_OIDC_PROVIDER_AUTHELIA_CLIENT_SECRET: "tu-client-secret-seguro" + OXICLOUD_OIDC_PROVIDER_AUTHELIA_DISCOVERY_URL: "https://authelia.example.com/.well-known/openid-configuration" + OXICLOUD_OIDC_PROVIDER_AUTHELIA_REDIRECT_URI: "https://oxicloud.example.com/oidc/callback/authelia" + OXICLOUD_OIDC_PROVIDER_AUTHELIA_SCOPES: "openid profile email groups" + OXICLOUD_OIDC_PROVIDER_AUTHELIA_USER_ID_ATTRIBUTE: "sub" + OXICLOUD_OIDC_PROVIDER_AUTHELIA_DEFAULT_ROLE: "user" + OXICLOUD_OIDC_PROVIDER_AUTHELIA_AUTO_CREATE_USERS: "true" + ports: + - "8085:8085" + volumes: + - ./storage:/app/storage +``` + +## KeyCloak + +[KeyCloak](https://www.keycloak.org/) es una solución de gestión de identidad y acceso de código abierto. + +### 1. Configurar un cliente en KeyCloak + +1. Inicia sesión en la consola de administración de KeyCloak +2. Selecciona tu Reino (Realm) +3. Ve a "Clients" → "Create" +4. Completa el formulario: + - **Client ID**: `oxicloud` + - **Client Protocol**: `openid-connect` + - **Root URL**: `https://oxicloud.example.com` +5. En la configuración del cliente: + - **Access Type**: `confidential` + - **Valid Redirect URIs**: `https://oxicloud.example.com/oidc/callback/keycloak` + - **Web Origins**: `https://oxicloud.example.com` (o `+` para permitir todos los orígenes) +6. Guarda la configuración +7. Ve a la pestaña "Credentials" y copia el "Secret" generado + +### 2. Configurar OxiCloud para KeyCloak + +```yaml +# docker-compose.yml +version: '3' +services: + oxicloud: + image: oxicloud:latest + environment: + # Configuración general + OXICLOUD_ENABLE_OIDC: "true" + + # Configuración de KeyCloak + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_NAME: "KeyCloak" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_CLIENT_ID: "oxicloud" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_CLIENT_SECRET: "tu-client-secret-de-keycloak" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_DISCOVERY_URL: "https://keycloak.example.com/realms/tu-realm/.well-known/openid-configuration" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_REDIRECT_URI: "https://oxicloud.example.com/oidc/callback/keycloak" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_SCOPES: "openid profile email" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_USER_ID_ATTRIBUTE: "sub" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_DEFAULT_ROLE: "user" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_AUTO_CREATE_USERS: "true" + ports: + - "8085:8085" + volumes: + - ./storage:/app/storage +``` + +## Resolución de Problemas + +### Error: "Failed to discover OIDC provider" + +Este error ocurre cuando OxiCloud no puede acceder al punto de descubrimiento del proveedor OIDC. + +**Soluciones:** +1. Verifica que la URL de descubrimiento sea correcta +2. Asegúrate de que OxiCloud pueda acceder a la URL (verifique firewalls, DNS, etc.) +3. Si tu proveedor utiliza un certificado autofirmado, asegúrate de configurar la confianza adecuada + +### Error: "Invalid redirect URI" + +Tu proveedor OIDC rechaza la URI de redirección. + +**Soluciones:** +1. Asegúrate de que la URI de redirección configurada en OxiCloud coincida exactamente con la registrada en tu proveedor OIDC +2. Verifica que no haya diferencias en protocolo (http vs https), puerto o ruta + +### Error: "User does not exist and auto-creation is disabled" + +**Soluciones:** +1. Habilita la creación automática de usuarios: `OXICLOUD_OIDC_PROVIDER__AUTO_CREATE_USERS="true"` +2. O crea manualmente el usuario en OxiCloud antes de intentar iniciar sesión con OIDC + +### Error: "Could not extract user ID from claim" + +OxiCloud no puede encontrar el atributo de ID de usuario especificado en los claims del token. + +**Soluciones:** +1. Verifica que el atributo configurado (`USER_ID_ATTRIBUTE`) exista en los claims del token +2. Prueba con un atributo diferente, como "sub", "email" o "preferred_username" +3. Configura tu proveedor OIDC para incluir el atributo necesario en los tokens \ No newline at end of file diff --git a/doc/OIDC-INTEGRATION.md b/doc/OIDC-INTEGRATION.md new file mode 100644 index 00000000..bc5d8e34 --- /dev/null +++ b/doc/OIDC-INTEGRATION.md @@ -0,0 +1,702 @@ +# OIDC Integration for OxiCloud + +This document outlines the implementation plan for adding OpenID Connect (OIDC) support to OxiCloud, enabling Single Sign-On (SSO) with identity providers like Authentik, Authelia, KeyCloak, and others. + +## Overview + +OpenID Connect (OIDC) is an identity layer built on top of the OAuth 2.0 protocol. It allows clients to verify the identity of end-users based on the authentication performed by an authorization server, as well as to obtain basic profile information about the end-user. + +Implementing OIDC in OxiCloud will: +1. Allow users to authenticate using their existing identity provider (IdP) credentials +2. Reduce the need for separate username/password management in OxiCloud +3. Enhance security by leveraging modern authentication best practices +4. Provide a seamless experience for users already using SSO in their environment + +## Implementation Plan + +### 1. Add OIDC Configuration Options + +Extend the `AuthConfig` struct in `src/common/config.rs`: + +```rust +pub struct AuthConfig { + pub jwt_secret: String, + pub access_token_expiry_secs: i64, + pub refresh_token_expiry_secs: i64, + pub hash_memory_cost: u32, + pub hash_time_cost: u32, + + // New OIDC configuration + pub enable_oidc: bool, + pub oidc_providers: Vec, +} + +pub struct OidcProviderConfig { + pub name: String, // Display name (e.g., "Authentik", "KeyCloak") + pub client_id: String, // OIDC client ID + pub client_secret: String, // OIDC client secret + pub discovery_url: String, // OIDC discovery URL (.well-known/openid-configuration) + pub redirect_uri: String, // Redirect URI after authentication + pub scopes: Vec, // Scopes to request + pub user_id_attribute: String, // Which claim to use as user ID + pub default_role: String, // Default role for new users + pub auto_create_users: bool, // Create users on first login +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + // Existing defaults... + + // OIDC defaults + enable_oidc: false, + oidc_providers: Vec::new(), + } + } +} +``` + +Update the environment variable handling in `AppConfig::from_env()` to include OIDC configurations. + +### 2. Create OIDC Service Implementation + +Add a new file `src/domain/services/oidc_service.rs`: + +```rust +use openid::{Client, Discovered, DiscoveredClient, Options, Token, StandardClaims}; +use std::sync::Arc; +use reqwest::Client as HttpClient; +use async_trait::async_trait; +use uuid::Uuid; + +use crate::common::config::OidcProviderConfig; +use crate::domain::entities::user::{User, UserRole}; +use crate::domain::repositories::user_repository::UserRepository; +use crate::common::errors::{DomainError, ErrorKind}; + +pub struct OidcService { + providers: Vec, + user_repository: Arc, +} + +struct OidcProvider { + config: OidcProviderConfig, + client: DiscoveredClient, +} + +impl OidcService { + pub async fn new( + configs: Vec, + user_repository: Arc, + ) -> Result { + let http_client = HttpClient::new(); + let mut providers = Vec::new(); + + for config in configs { + let client = openid::Client::discover( + http_client.clone(), + &config.client_id, + &config.client_secret, + &config.redirect_uri, + &config.discovery_url, + ) + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to discover OIDC provider {}: {}", config.name, e) + ))?; + + providers.push(OidcProvider { + config: config.clone(), + client, + }); + } + + Ok(Self { + providers, + user_repository, + }) + } + + pub fn get_provider(&self, provider_name: &str) -> Option<&OidcProvider> { + self.providers.iter().find(|p| p.config.name == provider_name) + } + + pub fn get_providers_info(&self) -> Vec { + self.providers.iter().map(|p| OidcProviderInfo { + name: p.config.name.clone(), + display_name: p.config.name.clone(), + }).collect() + } + + pub fn generate_authorization_url(&self, provider_name: &str, state: &str) -> Result { + let provider = self.get_provider(provider_name).ok_or_else(|| DomainError::new( + ErrorKind::NotFound, + "OIDC", + format!("Provider {} not found", provider_name) + ))?; + + let mut options = Options::default(); + options.scope = Some(provider.config.scopes.join(" ")); + + let auth_url = provider.client.auth_url(&options, Some(state)); + Ok(auth_url.to_string()) + } + + pub async fn process_callback( + &self, + provider_name: &str, + code: &str, + state: &str + ) -> Result<(User, Token), DomainError> { + let provider = self.get_provider(provider_name).ok_or_else(|| DomainError::new( + ErrorKind::NotFound, + "OIDC", + format!("Provider {} not found", provider_name) + ))?; + + // Exchange code for token + let token = provider.client.request_token(code).await.map_err(|e| DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + format!("Failed to exchange code for token: {}", e) + ))?; + + // Extract user information from claims + let claims = token.id_token.payload().clone(); + + // Get user ID from configured attribute + let user_id_attr = &provider.config.user_id_attribute; + let external_user_id = match user_id_attr.as_str() { + "sub" => claims.sub.clone(), + "email" => claims.email.clone().unwrap_or_default(), + // Add other standard claims as needed + _ => claims.additional_claims.get(user_id_attr) + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_default(), + }; + + if external_user_id.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "OIDC", + format!("Could not extract user ID from claim '{}'", user_id_attr) + )); + } + + // Check if user exists with this external ID + let mapped_user_id = format!("{}:{}", provider_name, external_user_id); + + let user = match self.user_repository.get_user_by_external_id(&mapped_user_id).await { + Ok(existing_user) => existing_user, + Err(_) => { + // User doesn't exist, create if allowed + if !provider.config.auto_create_users { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "User does not exist and auto-creation is disabled" + )); + } + + // Get user information from claims + let email = claims.email.clone().unwrap_or_else(|| + format!("{}@oidc.oxicloud.local", Uuid::new_v4()) + ); + + let username = claims.preferred_username.clone() + .or_else(|| claims.email.clone()) + .unwrap_or_else(|| format!("user_{}", Uuid::new_v4())); + + // Create the user + let role = match provider.config.default_role.as_str() { + "admin" => UserRole::Admin, + _ => UserRole::User, + }; + + // Default quota + let quota = 1024 * 1024 * 1024; // 1GB + + let mut new_user = User::new( + username, + email, + Uuid::new_v4().to_string(), // Random password, not used for OIDC + role, + quota, + )?; + + // Set external ID + new_user.set_external_id(Some(mapped_user_id)); + + // Save user + self.user_repository.create_user(new_user).await? + } + }; + + Ok((user, token)) + } +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct OidcProviderInfo { + pub name: String, + pub display_name: String, +} +``` + +### 3. Update User Entity + +Modify `src/domain/entities/user.rs` to support external IDs for OIDC users: + +```rust +#[derive(Debug, Clone)] +pub struct User { + // Existing fields... + external_id: Option, // For OIDC users: "provider:external_id" +} + +impl User { + // Existing methods... + + pub fn external_id(&self) -> Option<&str> { + self.external_id.as_deref() + } + + pub fn set_external_id(&mut self, external_id: Option) { + self.external_id = external_id; + } + + pub fn is_oidc_user(&self) -> bool { + self.external_id.is_some() + } +} +``` + +### 4. Update Database Schema + +Add a new column to the users table in `db/schema.sql`: + +```sql +ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS external_id VARCHAR(255) UNIQUE; +``` + +### 5. Update the Auth Application Service + +Modify `src/application/services/auth_application_service.rs` to add OIDC methods: + +```rust +use crate::domain::services::oidc_service::{OidcService, OidcProviderInfo}; +use crate::application::dtos::user_dto::{OidcAuthUrlDto, OidcCallbackDto, OidcProviderDto}; + +impl AuthApplicationService { + // Add OIDC service + pub fn with_oidc_service(mut self, oidc_service: Arc) -> Self { + self.oidc_service = Some(oidc_service); + self + } + + // Get available OIDC providers + pub fn get_oidc_providers(&self) -> Result, DomainError> { + let oidc_service = self.oidc_service.as_ref() + .ok_or_else(|| DomainError::new( + ErrorKind::UnsupportedOperation, + "Auth", + "OIDC is not configured" + ))?; + + let providers = oidc_service.get_providers_info(); + Ok(providers.into_iter().map(OidcProviderDto::from).collect()) + } + + // Generate authorization URL + pub fn generate_oidc_auth_url(&self, dto: OidcAuthUrlDto) -> Result { + let oidc_service = self.oidc_service.as_ref() + .ok_or_else(|| DomainError::new( + ErrorKind::UnsupportedOperation, + "Auth", + "OIDC is not configured" + ))?; + + oidc_service.generate_authorization_url(&dto.provider, &dto.state) + } + + // Process OIDC callback + pub async fn process_oidc_callback(&self, dto: OidcCallbackDto) -> Result { + let oidc_service = self.oidc_service.as_ref() + .ok_or_else(|| DomainError::new( + ErrorKind::UnsupportedOperation, + "Auth", + "OIDC is not configured" + ))?; + + let (user, _token) = oidc_service.process_callback( + &dto.provider, + &dto.code, + &dto.state + ).await?; + + // Generate access token + let access_token = self.auth_service.generate_access_token(&user) + .map_err(DomainError::from)?; + + // Generate refresh token + let refresh_token = self.auth_service.generate_refresh_token(); + + // Create session + let session = Session::new( + user.id().to_string(), + refresh_token.clone(), + None, + None, + self.auth_service.refresh_token_expiry_days(), + ); + + self.session_storage.create_session(session).await?; + + // Return auth response + Ok(AuthResponseDto { + user: UserDto::from(user), + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: self.auth_service.refresh_token_expiry_secs(), + }) + } +} +``` + +### 6. Add Auth Handler Routes for OIDC + +Update `src/interfaces/api/handlers/auth_handler.rs`: + +```rust +pub fn auth_routes() -> Router> { + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) + .route("/refresh", post(refresh_token)) + .route("/me", get(get_current_user)) + .route("/change-password", put(change_password)) + .route("/logout", post(logout)) + // Add OIDC routes + .route("/oidc/providers", get(get_oidc_providers)) + .route("/oidc/auth", post(generate_oidc_auth_url)) + .route("/oidc/callback", post(process_oidc_callback)) +} + +// Get available OIDC providers +async fn get_oidc_providers( + State(state): State>, +) -> Result { + let auth_service = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?; + + match auth_service.auth_application_service.get_oidc_providers() { + Ok(providers) => Ok((StatusCode::OK, Json(providers))), + Err(err) => Err(err.into()), + } +} + +// Generate OIDC authorization URL +async fn generate_oidc_auth_url( + State(state): State>, + Json(dto): Json, +) -> Result { + let auth_service = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?; + + match auth_service.auth_application_service.generate_oidc_auth_url(dto) { + Ok(url) => Ok((StatusCode::OK, Json(json!({ "url": url })))), + Err(err) => Err(err.into()), + } +} + +// Process OIDC callback +async fn process_oidc_callback( + State(state): State>, + Json(dto): Json, +) -> Result { + let auth_service = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?; + + match auth_service.auth_application_service.process_oidc_callback(dto).await { + Ok(auth_response) => Ok((StatusCode::OK, Json(auth_response))), + Err(err) => Err(err.into()), + } +} +``` + +### 7. Update DTOs for OIDC + +Create new DTOs in `src/application/dtos/user_dto.rs`: + +```rust +use crate::domain::services::oidc_service::OidcProviderInfo; + +#[derive(Debug, Clone, Serialize)] +pub struct OidcProviderDto { + pub name: String, + pub display_name: String, +} + +impl From for OidcProviderDto { + fn from(info: OidcProviderInfo) -> Self { + Self { + name: info.name, + display_name: info.display_name, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OidcAuthUrlDto { + pub provider: String, + pub state: String, + pub redirect_uri: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OidcCallbackDto { + pub provider: String, + pub code: String, + pub state: String, +} +``` + +### 8. Add Frontend Integration + +Create a new JavaScript file `static/js/oidcAuth.js`: + +```javascript +// OIDC Authentication Module +const oidcAuth = { + // Get available OIDC providers + async getProviders() { + try { + const response = await fetch('/api/auth/oidc/providers'); + if (!response.ok) { + throw new Error(`Failed to get OIDC providers: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching OIDC providers:', error); + return []; + } + }, + + // Generate random state for CSRF protection + generateState() { + const array = new Uint8Array(16); + window.crypto.getRandomValues(array); + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); + }, + + // Start OIDC authentication flow + async startAuth(providerName) { + try { + // Generate and store state + const state = this.generateState(); + localStorage.setItem('oidc_state', state); + + // Get authorization URL + const response = await fetch('/api/auth/oidc/auth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + provider: providerName, + state: state, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to get auth URL: ${response.statusText}`); + } + + const data = await response.json(); + + // Redirect to authorization URL + window.location.href = data.url; + } catch (error) { + console.error('Error starting OIDC auth:', error); + alert('Failed to start authentication. Please try again.'); + } + }, + + // Handle OIDC callback + async handleCallback() { + // Parse URL parameters + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + const error = urlParams.get('error'); + + // Check for errors + if (error) { + console.error('OIDC authentication error:', error); + alert(`Authentication failed: ${error}`); + window.location.href = '/login.html'; + return; + } + + // Verify code and state + if (!code || !state) { + console.error('Missing code or state in callback'); + alert('Authentication failed: Invalid response'); + window.location.href = '/login.html'; + return; + } + + // Verify state matches + const savedState = localStorage.getItem('oidc_state'); + if (state !== savedState) { + console.error('State mismatch - potential CSRF attack'); + alert('Authentication failed: Invalid state'); + window.location.href = '/login.html'; + return; + } + + // Clear stored state + localStorage.removeItem('oidc_state'); + + try { + // Extract provider from URL path or from saved data + const pathParts = window.location.pathname.split('/'); + const provider = localStorage.getItem('oidc_provider') || + (pathParts.length > 2 ? pathParts[2] : 'default'); + + // Process callback + const response = await fetch('/api/auth/oidc/callback', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + provider: provider, + code: code, + state: state, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to process callback: ${response.statusText}`); + } + + const authData = await response.json(); + + // Store auth data and redirect to dashboard + localStorage.setItem('auth_token', authData.access_token); + localStorage.setItem('refresh_token', authData.refresh_token); + localStorage.setItem('user', JSON.stringify(authData.user)); + + window.location.href = '/index.html'; + } catch (error) { + console.error('Error handling OIDC callback:', error); + alert('Failed to complete authentication. Please try again.'); + window.location.href = '/login.html'; + } + } +}; + +// Check if current page is callback page +if (window.location.pathname.includes('/oidc/callback')) { + document.addEventListener('DOMContentLoaded', () => { + oidcAuth.handleCallback(); + }); +} +``` + +### 9. Update Login Page + +Add OIDC login buttons to `static/login.html`: + +```html + + + + +``` + +## Configuration Example + +Here's how to configure OxiCloud to use OIDC with KeyCloak: + +```yaml +# docker-compose.yml +version: '3' +services: + oxicloud: + image: oxicloud:latest + environment: + OXICLOUD_ENABLE_OIDC: "true" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_NAME: "KeyCloak" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_CLIENT_ID: "oxicloud" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_CLIENT_SECRET: "your-client-secret" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_DISCOVERY_URL: "https://keycloak.example.com/realms/your-realm/.well-known/openid-configuration" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_REDIRECT_URI: "https://oxicloud.example.com/oidc/callback/keycloak" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_SCOPES: "openid profile email" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_USER_ID_ATTRIBUTE: "sub" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_DEFAULT_ROLE: "user" + OXICLOUD_OIDC_PROVIDER_KEYCLOAK_AUTO_CREATE_USERS: "true" + ports: + - "8085:8085" + volumes: + - ./storage:/app/storage +``` + +## Additional Considerations + +1. **Security**: OIDC connections should always use HTTPS. Ensure proper TLS configuration. + +2. **User mapping**: Consider how user attributes from OIDC map to your application (roles, groups, etc.). + +3. **Multiple providers**: The design supports multiple OIDC providers simultaneously. + +4. **Session management**: Implement proper session handling for OIDC users. + +5. **Access control**: Review how OIDC integration affects your application's permission model. + +6. **Testing**: Create separate test IdP configurations for development and testing. \ No newline at end of file diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs new file mode 100644 index 00000000..53eaa134 --- /dev/null +++ b/src/application/adapters/webdav_adapter.rs @@ -0,0 +1,684 @@ +/** + * WebDAV Adapter Module + * + * This module provides adapters for converting between OxiCloud's domain models + * and WebDAV protocol representations. It handles XML parsing and generation + * for all WebDAV operations (PROPFIND, PROPPATCH, etc.) according to RFC 4918. + * + * The adapter serves as a translation layer between the WebDAV protocol's XML-based + * communication format and OxiCloud's internal data models, ensuring proper + * serialization and deserialization of WebDAV requests and responses. + */ + +use std::io::{Read, Write}; +use quick_xml::{Reader, Writer, events::{Event, BytesStart, BytesEnd, BytesText}}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; +use thiserror::Error; + +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; + +/** + * Error types specific to WebDAV operations. + * These errors encapsulate the various failure modes during WebDAV processing. + */ +#[derive(Error, Debug)] +pub enum WebDavError { + /// Error during XML parsing or generation + #[error("XML error: {0}")] + XmlError(String), + + /// Error related to property handling + #[error("Property error: {0}")] + PropertyError(String), + + /// Error in the request format or content + #[error("Invalid request: {0}")] + InvalidRequest(String), + + /// I/O error during reading or writing + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// Other WebDAV related errors + #[error("WebDAV error: {0}")] + WebDavError(String), +} + +/// Type alias for WebDAV operation results +pub type Result = std::result::Result; + +/** + * Property namespace and name, used to identify WebDAV properties. + * WebDAV properties are identified by a combination of namespace and name. + */ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PropertyName { + /// XML namespace for the property (e.g., "DAV:") + pub namespace: String, + /// Local name of the property (e.g., "displayname") + pub name: String, +} + +/** + * Represents a WebDAV property with its name and value. + * WebDAV properties contain metadata about resources. + */ +#[derive(Debug, Clone)] +pub struct Property { + /// The qualified name of the property + pub name: PropertyName, + /// The property value, if any + pub value: Option, +} + +/** + * Represents a PROPFIND request as defined in RFC 4918. + * PROPFIND requests can ask for all properties, named properties, + * or property names only. + */ +#[derive(Debug, Clone)] +pub enum PropFindRequest { + /// Request all properties + AllProps, + /// Request specific properties by name + PropNames(Vec), + /// Request only property names without values + PropNameOnly, +} + +/** + * Adapter for WebDAV operations, providing XML serialization and deserialization. + * This struct contains methods for parsing WebDAV requests and generating + * appropriate responses according to the WebDAV specification. + */ +pub struct WebDavAdapter; + +impl WebDavAdapter { + // XML namespaces used in WebDAV + const DAV_NS: &'static str = "DAV:"; + + /** + * Parses a PROPFIND request body into a structured representation. + * + * Processes the XML body of a PROPFIND request to determine which + * properties are being requested (allprop, propname, or specific props). + * + * @param reader Source providing XML content to parse + * @return Result containing the parsed PropFindRequest or an error + */ + pub fn parse_propfind(reader: R) -> Result { + let mut xml_reader = Reader::from_reader(reader); + xml_reader.trim_text(true); + + let mut buf = Vec::new(); + let mut inside_propfind = false; + let mut prop_names = Vec::new(); + let mut result = None; + + loop { + match xml_reader.read_event(&mut buf) { + Ok(Event::Start(ref e)) => { + let name = e.name(); + let name_str = std::str::from_utf8(name).map_err(|_| { + WebDavError::XmlError("Invalid XML element name".to_string()) + })?; + + if name_str == "propfind" { + inside_propfind = true; + } else if inside_propfind { + match name_str { + "allprop" => { + result = Some(PropFindRequest::AllProps); + }, + "propname" => { + result = Some(PropFindRequest::PropNameOnly); + }, + "prop" => { + // Will collect property names in subsequent iterations + }, + _ if inside_propfind => { + // Handle property names within prop element + let namespace = Self::get_namespace_from_element(e)?; + prop_names.push(PropertyName { + namespace: namespace.unwrap_or_else(|| Self::DAV_NS.to_string()), + name: name_str.to_string(), + }); + }, + _ => {} + } + } + }, + Ok(Event::Empty(ref e)) => { + // Handle self-closing tags + let name = e.name(); + let name_str = std::str::from_utf8(name).map_err(|_| { + WebDavError::XmlError("Invalid XML element name".to_string()) + })?; + + if inside_propfind && name_str != "prop" { + let namespace = Self::get_namespace_from_element(e)?; + prop_names.push(PropertyName { + namespace: namespace.unwrap_or_else(|| Self::DAV_NS.to_string()), + name: name_str.to_string(), + }); + } + }, + Ok(Event::End(ref e)) => { + let name = e.name(); + let name_str = std::str::from_utf8(name).map_err(|_| { + WebDavError::XmlError("Invalid XML element name".to_string()) + })?; + + if name_str == "propfind" { + inside_propfind = false; + } + }, + Ok(Event::Eof) => break, + Err(e) => return Err(WebDavError::XmlError(format!("Error parsing XML: {}", e))), + _ => (), + } + + buf.clear(); + } + + if !prop_names.is_empty() { + return Ok(PropFindRequest::PropNames(prop_names)); + } + + result.ok_or_else(|| WebDavError::InvalidRequest("Invalid or missing propfind request".to_string())) + } + + /** + * Extracts the namespace from an XML element. + * + * @param element The XML element to extract namespace from + * @return Result containing the optional namespace or an error + */ + fn get_namespace_from_element(element: &BytesStart) -> Result> { + // Extract namespace from qualified name (e.g., "d:prop" -> "d") + let name = std::str::from_utf8(element.name()).map_err(|_| { + WebDavError::XmlError("Invalid XML element name".to_string()) + })?; + + if let Some(pos) = name.find(':') { + let prefix = &name[..pos]; + + // Find namespace declaration for this prefix + for attr in element.attributes() { + let attr = attr.map_err(|e| WebDavError::XmlError(format!("Invalid attribute: {}", e)))?; + let key = std::str::from_utf8(attr.key).map_err(|_| { + WebDavError::XmlError("Invalid attribute name".to_string()) + })?; + + if key == format!("xmlns:{}", prefix) { + let value = std::str::from_utf8(&attr.value).map_err(|_| { + WebDavError::XmlError("Invalid attribute value".to_string()) + })?; + return Ok(Some(value.to_string())); + } + } + } + + Ok(None) + } + + /** + * Generates a PROPFIND response for a file. + * + * Creates an XML response containing the requested properties + * for a single file resource. + * + * @param writer The output destination for the generated XML + * @param file The file DTO containing the resource data + * @param request The original PROPFIND request specifying which properties to include + * @param depth The requested depth (0, 1, or infinity) + * @param href The URL of the resource + * @return Result indicating success or containing an error + */ + pub fn generate_propfind_response_for_file( + writer: W, + file: &FileDto, + request: &PropFindRequest, + depth: &str, + href: &str, + ) -> Result<()> { + let mut xml_writer = Writer::new(writer); + + // Start multistatus response + let mut multistatus = BytesStart::owned(b"d:multistatus".to_vec(), "d:multistatus".len()); + multistatus.push_attribute(("xmlns:d", "DAV:")); + xml_writer.write_event(Event::Start(multistatus)).map_err(|e| { + WebDavError::XmlError(format!("Failed to write multistatus start: {}", e)) + })?; + + // Generate response for the file + Self::write_resource_properties( + &mut xml_writer, + href, + file.updated_at, + file.size as u64, + false, // is_collection + request, + )?; + + // End multistatus + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:multistatus"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write multistatus end: {}", e)) + })?; + + Ok(()) + } + + /** + * Generates a PROPFIND response for a directory and its contents. + * + * Creates an XML response containing the requested properties for a + * directory and its children (files and subdirectories) based on the depth. + * + * @param writer The output destination for the generated XML + * @param folder The folder DTO (or None for root) + * @param files List of file DTOs contained in the folder + * @param subfolders List of subfolder DTOs contained in the folder + * @param request The original PROPFIND request specifying which properties to include + * @param depth The requested depth (0, 1, or infinity) + * @param base_href The base URL of the resource + * @return Result indicating success or containing an error + */ + pub fn generate_propfind_response( + writer: W, + folder: Option<&FolderDto>, + files: &[FileDto], + subfolders: &[FolderDto], + request: &PropFindRequest, + depth: &str, + base_href: &str, + ) -> Result<()> { + let mut xml_writer = Writer::new(writer); + + // Start multistatus response + let mut multistatus = BytesStart::owned(b"d:multistatus".to_vec(), "d:multistatus".len()); + multistatus.push_attribute(("xmlns:d", "DAV:")); + xml_writer.write_event(Event::Start(multistatus)).map_err(|e| { + WebDavError::XmlError(format!("Failed to write multistatus start: {}", e)) + })?; + + // Add folder properties + if let Some(folder) = folder { + Self::write_resource_properties( + &mut xml_writer, + base_href, + folder.updated_at, + 0, // Size for directories is typically 0 + true, // is_collection + request, + )?; + } + + // If depth > 0, include children + if depth != "0" { + // Add files + for file in files { + let file_href = format!("{}{}", base_href, file.name); + Self::write_resource_properties( + &mut xml_writer, + &file_href, + file.updated_at, + file.size as u64, + false, // is_collection + request, + )?; + } + + // Add subfolders + for subfolder in subfolders { + let folder_href = format!("{}{}/", base_href, subfolder.name); + Self::write_resource_properties( + &mut xml_writer, + &folder_href, + subfolder.updated_at, + 0, // Size for directories is typically 0 + true, // is_collection + request, + )?; + } + } + + // End multistatus + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:multistatus"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write multistatus end: {}", e)) + })?; + + Ok(()) + } + + /** + * Writes the properties for a single resource in a PROPFIND response. + * + * Helper method to generate XML for a single resource's properties, + * used by both file and directory PROPFIND responses. + * + * @param writer The XML writer to output to + * @param href The URL of the resource + * @param last_modified Last modification timestamp of the resource + * @param size Size of the resource in bytes + * @param is_collection Whether the resource is a collection (directory) + * @param request The original PROPFIND request specifying which properties to include + * @return Result indicating success or containing an error + */ + fn write_resource_properties( + xml_writer: &mut Writer, + href: &str, + last_modified: DateTime, + size: u64, + is_collection: bool, + request: &PropFindRequest, + ) -> Result<()> { + // Start response element + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:response", "d:response".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write response start: {}", e)) + })?; + + // Write href + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:href", "d:href".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write href start: {}", e)) + })?; + xml_writer.write_event(Event::Text(BytesText::from_plain_str(href))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write href text: {}", e)) + })?; + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:href"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write href end: {}", e)) + })?; + + // Start propstat + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:propstat", "d:propstat".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write propstat start: {}", e)) + })?; + + // Start prop + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:prop", "d:prop".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write prop start: {}", e)) + })?; + + // Determine which properties to include based on the request + match request { + PropFindRequest::AllProps => { + // Include standard properties + Self::write_standard_properties(xml_writer, last_modified, size, is_collection)?; + }, + PropFindRequest::PropNames(props) => { + // Include only the requested properties + for prop_name in props { + if prop_name.namespace == Self::DAV_NS { + match prop_name.name.as_str() { + "resourcetype" => Self::write_resourcetype(xml_writer, is_collection)?, + "getcontentlength" => { + if !is_collection { + Self::write_getcontentlength(xml_writer, size)?; + } + }, + "getlastmodified" => Self::write_getlastmodified(xml_writer, last_modified)?, + "creationdate" => Self::write_creationdate(xml_writer, last_modified)?, + "displayname" => { + // Extract displayname from href + let display_name = href.split('/').last().unwrap_or(href); + Self::write_displayname(xml_writer, display_name)?; + }, + "getcontenttype" => { + if !is_collection { + // For files, try to determine MIME type + let content_type = if is_collection { + "httpd/unix-directory" + } else { + mime_guess::from_path(href) + .first_or_octet_stream() + .as_ref() + }; + Self::write_getcontenttype(xml_writer, content_type)?; + } + }, + // Add other standard properties as needed + _ => { + // Unknown property - return empty element + xml_writer.write_event(Event::Empty(BytesStart::borrowed( + format!("d:{}", prop_name.name).as_bytes(), + prop_name.name.len() + 2, + ))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write property: {}", e)) + })?; + } + } + } + } + }, + PropFindRequest::PropNameOnly => { + // Just include empty property elements + xml_writer.write_event(Event::Empty(BytesStart::borrowed(b"d:resourcetype", "d:resourcetype".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write resourcetype: {}", e)) + })?; + + if !is_collection { + xml_writer.write_event(Event::Empty(BytesStart::borrowed(b"d:getcontentlength", "d:getcontentlength".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getcontentlength: {}", e)) + })?; + } + + xml_writer.write_event(Event::Empty(BytesStart::borrowed(b"d:getlastmodified", "d:getlastmodified".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getlastmodified: {}", e)) + })?; + + xml_writer.write_event(Event::Empty(BytesStart::borrowed(b"d:creationdate", "d:creationdate".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write creationdate: {}", e)) + })?; + + xml_writer.write_event(Event::Empty(BytesStart::borrowed(b"d:displayname", "d:displayname".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write displayname: {}", e)) + })?; + + if !is_collection { + xml_writer.write_event(Event::Empty(BytesStart::borrowed(b"d:getcontenttype", "d:getcontenttype".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getcontenttype: {}", e)) + })?; + } + } + } + + // End prop + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:prop"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write prop end: {}", e)) + })?; + + // Write status + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:status", "d:status".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write status start: {}", e)) + })?; + xml_writer.write_event(Event::Text(BytesText::from_plain_str("HTTP/1.1 200 OK"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write status text: {}", e)) + })?; + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:status"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write status end: {}", e)) + })?; + + // End propstat + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:propstat"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write propstat end: {}", e)) + })?; + + // End response + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:response"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write response end: {}", e)) + })?; + + Ok(()) + } + + /** + * Writes all standard WebDAV properties for a resource. + * + * Helper method to write the core set of WebDAV properties that + * most clients expect. + * + * @param writer The XML writer to output to + * @param last_modified Last modification timestamp of the resource + * @param size Size of the resource in bytes + * @param is_collection Whether the resource is a collection (directory) + * @return Result indicating success or containing an error + */ + fn write_standard_properties( + xml_writer: &mut Writer, + last_modified: DateTime, + size: u64, + is_collection: bool, + ) -> Result<()> { + // Write resourcetype (collection or not) + Self::write_resourcetype(xml_writer, is_collection)?; + + // Write content length for files + if !is_collection { + Self::write_getcontentlength(xml_writer, size)?; + } + + // Write last modified date + Self::write_getlastmodified(xml_writer, last_modified)?; + + // Write creation date (using last modified as fallback) + Self::write_creationdate(xml_writer, last_modified)?; + + // Add other standard properties as needed + + Ok(()) + } + + // Helper methods for writing specific properties + + /** + * Writes the resourcetype property. + * Indicates whether the resource is a collection (directory) or regular resource. + */ + fn write_resourcetype(xml_writer: &mut Writer, is_collection: bool) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:resourcetype", "d:resourcetype".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write resourcetype start: {}", e)) + })?; + + if is_collection { + xml_writer.write_event(Event::Empty(BytesStart::borrowed(b"d:collection", "d:collection".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write collection: {}", e)) + })?; + } + + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:resourcetype"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write resourcetype end: {}", e)) + })?; + + Ok(()) + } + + /** + * Writes the getcontentlength property. + * Contains the size of the resource in bytes. + */ + fn write_getcontentlength(xml_writer: &mut Writer, size: u64) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:getcontentlength", "d:getcontentlength".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getcontentlength start: {}", e)) + })?; + + xml_writer.write_event(Event::Text(BytesText::from_plain_str(&size.to_string()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getcontentlength text: {}", e)) + })?; + + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:getcontentlength"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getcontentlength end: {}", e)) + })?; + + Ok(()) + } + + /** + * Writes the getlastmodified property. + * Contains the last modification date in RFC 822 format. + */ + fn write_getlastmodified(xml_writer: &mut Writer, last_modified: DateTime) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:getlastmodified", "d:getlastmodified".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getlastmodified start: {}", e)) + })?; + + // Format as RFC 822 date as required by WebDAV + let formatted_date = last_modified.to_rfc2822(); + xml_writer.write_event(Event::Text(BytesText::from_plain_str(&formatted_date))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getlastmodified text: {}", e)) + })?; + + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:getlastmodified"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getlastmodified end: {}", e)) + })?; + + Ok(()) + } + + /** + * Writes the creationdate property. + * Contains the creation date in ISO 8601 format. + */ + fn write_creationdate(xml_writer: &mut Writer, creation_date: DateTime) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:creationdate", "d:creationdate".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write creationdate start: {}", e)) + })?; + + // Format as ISO 8601 date as required by WebDAV + let formatted_date = creation_date.to_rfc3339(); + xml_writer.write_event(Event::Text(BytesText::from_plain_str(&formatted_date))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write creationdate text: {}", e)) + })?; + + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:creationdate"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write creationdate end: {}", e)) + })?; + + Ok(()) + } + + /** + * Writes the displayname property. + * Contains the human-readable name of the resource. + */ + fn write_displayname(xml_writer: &mut Writer, display_name: &str) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:displayname", "d:displayname".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write displayname start: {}", e)) + })?; + + xml_writer.write_event(Event::Text(BytesText::from_plain_str(display_name))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write displayname text: {}", e)) + })?; + + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:displayname"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write displayname end: {}", e)) + })?; + + Ok(()) + } + + /** + * Writes the getcontenttype property. + * Contains the MIME type of the resource. + */ + fn write_getcontenttype(xml_writer: &mut Writer, content_type: &str) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::borrowed(b"d:getcontenttype", "d:getcontenttype".len()))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getcontenttype start: {}", e)) + })?; + + xml_writer.write_event(Event::Text(BytesText::from_plain_str(content_type))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getcontenttype text: {}", e)) + })?; + + xml_writer.write_event(Event::End(BytesEnd::borrowed(b"d:getcontenttype"))).map_err(|e| { + WebDavError::XmlError(format!("Failed to write getcontenttype end: {}", e)) + })?; + + Ok(()) + } + + // Additional helper methods for other WebDAV operations + + // ... (PROPPATCH, LOCK, UNLOCK, etc. implementations would go here) +} \ No newline at end of file diff --git a/src/domain/entities/calendar.rs b/src/domain/entities/calendar.rs new file mode 100644 index 00000000..1fdad8af --- /dev/null +++ b/src/domain/entities/calendar.rs @@ -0,0 +1,340 @@ +/** + * Calendar Entity + * + * This module defines the Calendar entity, which represents a calendar in the CalDAV + * implementation. Calendars contain calendar events and are owned by users. + * + * Calendars have properties such as name, color, and description, and they serve as + * containers for calendar events. Each calendar belongs to a specific user and can + * have custom properties. + */ + +use uuid::Uuid; +use chrono::{DateTime, Utc}; +use thiserror::Error; + +use crate::common::errors::{Result, DomainError, ErrorKind}; + +/** + * Error types specific to calendar operations. + */ +#[derive(Error, Debug)] +pub enum CalendarError { + /// Error when calendar name is invalid + #[error("Invalid calendar name: {0}")] + InvalidName(String), + + /// Error when color code is invalid + #[error("Invalid color code: {0}")] + InvalidColor(String), + + /// Error when owner ID is invalid + #[error("Invalid owner ID: {0}")] + InvalidOwnerId(String), +} + +/** + * Calendar entity. + * + * Represents a calendar container that can hold multiple calendar events. + * Each calendar is owned by a user and has properties like name, color, and description. + */ +#[derive(Debug, Clone)] +pub struct Calendar { + /// Unique identifier for the calendar + id: Uuid, + + /// Display name of the calendar + name: String, + + /// ID of the user who owns this calendar + owner_id: String, + + /// Optional description of the calendar + description: Option, + + /// Optional color code for UI display (hex format #RRGGBB) + color: Option, + + /// Time when the calendar was created + created_at: DateTime, + + /// Time when the calendar was last modified + updated_at: DateTime, + + /// Optional list of custom properties (for extended CalDAV support) + custom_properties: std::collections::HashMap, +} + +impl Calendar { + /** + * Creates a new calendar with the given properties. + * + * @param name Display name of the calendar + * @param owner_id ID of the user who owns this calendar + * @param description Optional description of the calendar + * @param color Optional color code for UI display (#RRGGBB format) + * @return Result containing the new Calendar or a domain error + */ + pub fn new( + name: String, + owner_id: String, + description: Option, + color: Option, + ) -> Result { + // Validate inputs + if name.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Calendar name cannot be empty", + )); + } + + if owner_id.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Owner ID cannot be empty", + )); + } + + // Validate color format if provided (#RRGGBB) + if let Some(ref color_str) = color { + if !color_str.starts_with('#') || color_str.len() != 7 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Color must be in #RRGGBB format", + )); + } + + // Check if remaining characters are valid hex + if color_str[1..].chars().any(|c| !c.is_ascii_hexdigit()) { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Color must be in #RRGGBB format with valid hex digits", + )); + } + } + + let now = Utc::now(); + + Ok(Self { + id: Uuid::new_v4(), + name, + owner_id, + description, + color, + created_at: now, + updated_at: now, + custom_properties: std::collections::HashMap::new(), + }) + } + + /** + * Creates a calendar with specific ID and timestamps. + * Typically used when reconstructing from storage. + * + * @param id Unique identifier for the calendar + * @param name Display name of the calendar + * @param owner_id ID of the user who owns this calendar + * @param description Optional description of the calendar + * @param color Optional color code for UI display + * @param created_at Time when the calendar was created + * @param updated_at Time when the calendar was last modified + * @return Result containing the new Calendar or a domain error + */ + pub fn with_id( + id: Uuid, + name: String, + owner_id: String, + description: Option, + color: Option, + created_at: DateTime, + updated_at: DateTime, + ) -> Result { + // Basic validation + if name.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Calendar name cannot be empty", + )); + } + + if owner_id.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Owner ID cannot be empty", + )); + } + + Ok(Self { + id, + name, + owner_id, + description, + color, + created_at, + updated_at, + custom_properties: std::collections::HashMap::new(), + }) + } + + // Getters + + /// Returns the calendar's unique identifier + pub fn id(&self) -> &Uuid { + &self.id + } + + /// Returns the calendar's display name + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the ID of the user who owns this calendar + pub fn owner_id(&self) -> &str { + &self.owner_id + } + + /// Returns the calendar's description, if any + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Returns the calendar's color code, if any + pub fn color(&self) -> Option<&str> { + self.color.as_deref() + } + + /// Returns the time when the calendar was created + pub fn created_at(&self) -> &DateTime { + &self.created_at + } + + /// Returns the time when the calendar was last modified + pub fn updated_at(&self) -> &DateTime { + &self.updated_at + } + + /// Returns a custom property value by name, if it exists + pub fn custom_property(&self, name: &str) -> Option<&str> { + self.custom_properties.get(name).map(|s| s.as_str()) + } + + /// Returns all custom properties + pub fn custom_properties(&self) -> &std::collections::HashMap { + &self.custom_properties + } + + // Setters and Mutators + + /** + * Updates the calendar's name. + * + * @param name New display name for the calendar + * @return Result indicating success or containing a domain error + */ + pub fn update_name(&mut self, name: String) -> Result<()> { + if name.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Calendar name cannot be empty", + )); + } + + self.name = name; + self.updated_at = Utc::now(); + Ok(()) + } + + /** + * Updates the calendar's description. + * + * @param description New description for the calendar + */ + pub fn update_description(&mut self, description: Option) { + self.description = description; + self.updated_at = Utc::now(); + } + + /** + * Updates the calendar's color. + * + * @param color New color code for the calendar + * @return Result indicating success or containing a domain error + */ + pub fn update_color(&mut self, color: Option) -> Result<()> { + // Validate color format if provided (#RRGGBB) + if let Some(ref color_str) = color { + if !color_str.starts_with('#') || color_str.len() != 7 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Color must be in #RRGGBB format", + )); + } + + // Check if remaining characters are valid hex + if color_str[1..].chars().any(|c| !c.is_ascii_hexdigit()) { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Color must be in #RRGGBB format with valid hex digits", + )); + } + } + + self.color = color; + self.updated_at = Utc::now(); + Ok(()) + } + + /** + * Sets a custom property for extended CalDAV support. + * + * @param name Name of the property + * @param value Value of the property + */ + pub fn set_custom_property(&mut self, name: String, value: String) { + self.custom_properties.insert(name, value); + self.updated_at = Utc::now(); + } + + /** + * Removes a custom property. + * + * @param name Name of the property to remove + * @return true if the property was removed, false if it didn't exist + */ + pub fn remove_custom_property(&mut self, name: &str) -> bool { + let result = self.custom_properties.remove(name).is_some(); + if result { + self.updated_at = Utc::now(); + } + result + } + + /** + * Checks if this calendar belongs to the specified user. + * + * @param user_id ID of the user to check ownership against + * @return true if the calendar belongs to the user, false otherwise + */ + pub fn belongs_to(&self, user_id: &str) -> bool { + self.owner_id == user_id + } + + /** + * Updates the last modification time of the calendar to now. + * Called when calendar events are added, modified, or removed. + */ + pub fn touch(&mut self) { + self.updated_at = Utc::now(); + } +} \ No newline at end of file diff --git a/src/domain/entities/calendar_event.rs b/src/domain/entities/calendar_event.rs new file mode 100644 index 00000000..806600cb --- /dev/null +++ b/src/domain/entities/calendar_event.rs @@ -0,0 +1,810 @@ +/** + * Calendar Event Entity + * + * This module defines the CalendarEvent entity, which represents an event or + * appointment in a calendar, following the iCalendar (RFC 5545) specification. + * + * Calendar events have properties like summary, description, location, start/end times, + * and can include recurrence rules for repeating events. Each event belongs to a + * specific calendar and stores its complete iCalendar representation. + */ + +use uuid::Uuid; +use chrono::{DateTime, Utc, Duration}; +use thiserror::Error; + +use crate::common::errors::{Result, DomainError, ErrorKind}; + +/** + * Error types specific to calendar event operations. + */ +#[derive(Error, Debug)] +pub enum CalendarEventError { + /// Error when event summary/title is invalid + #[error("Invalid event summary: {0}")] + InvalidSummary(String), + + /// Error when event dates are invalid + #[error("Invalid event dates: {0}")] + InvalidDates(String), + + /// Error when recurrence rule is invalid + #[error("Invalid recurrence rule: {0}")] + InvalidRecurrence(String), + + /// Error when iCalendar data is invalid + #[error("Invalid iCalendar data: {0}")] + InvalidICalData(String), +} + +/** + * CalendarEvent entity. + * + * Represents a calendar event or appointment that can be synced via CalDAV. + * Follows the iCalendar format (RFC 5545) for compatibility with CalDAV clients. + */ +#[derive(Debug, Clone)] +pub struct CalendarEvent { + /// Unique identifier for the event + id: Uuid, + + /// ID of the calendar this event belongs to + calendar_id: Uuid, + + /// Short summary/title of the event + summary: String, + + /// Detailed description of the event (optional) + description: Option, + + /// Location of the event (optional) + location: Option, + + /// Start time of the event + start_time: DateTime, + + /// End time of the event + end_time: DateTime, + + /// Whether this is an all-day event + all_day: bool, + + /// Recurrence rule in iCalendar RRULE format (optional) + rrule: Option, + + /// Unique identifier in iCalendar format (used for CalDAV sync) + ical_uid: String, + + /// Complete iCalendar data (VEVENT component) + ical_data: String, + + /// Time when the event was created + created_at: DateTime, + + /// Time when the event was last modified + updated_at: DateTime, +} + +impl CalendarEvent { + /** + * Creates a new calendar event with the given properties. + * + * @param calendar_id ID of the calendar this event belongs to + * @param summary Short summary/title of the event + * @param description Detailed description of the event (optional) + * @param location Location of the event (optional) + * @param start_time Start time of the event + * @param end_time End time of the event + * @param all_day Whether this is an all-day event + * @param rrule Recurrence rule in iCalendar RRULE format (optional) + * @param ical_data Complete iCalendar data (VEVENT component) + * @return Result containing the new CalendarEvent or a domain error + */ + pub fn new( + calendar_id: Uuid, + summary: String, + description: Option, + location: Option, + start_time: DateTime, + end_time: DateTime, + all_day: bool, + rrule: Option, + ical_data: String, + ) -> Result { + // Validate inputs + if summary.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Event summary cannot be empty", + )); + } + + if end_time < start_time { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "End time cannot be before start time", + )); + } + + // Validate RRULE if provided (basic validation) + if let Some(ref rule) = rrule { + if !rule.starts_with("FREQ=") { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Recurrence rule must start with FREQ=", + )); + } + } + + // Validate iCalendar data (basic validation) + if !ical_data.contains("BEGIN:VEVENT") || !ical_data.contains("END:VEVENT") { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "iCalendar data must contain a VEVENT component", + )); + } + + let now = Utc::now(); + + Ok(Self { + id: Uuid::new_v4(), + calendar_id, + summary, + description, + location, + start_time, + end_time, + all_day, + rrule, + ical_uid: Uuid::new_v4().to_string(), + ical_data, + created_at: now, + updated_at: now, + }) + } + + /** + * Creates a calendar event with specific ID and timestamps. + * Typically used when reconstructing from storage. + * + * @param id Unique identifier for the event + * @param calendar_id ID of the calendar this event belongs to + * @param summary Short summary/title of the event + * @param description Detailed description of the event (optional) + * @param location Location of the event (optional) + * @param start_time Start time of the event + * @param end_time End time of the event + * @param all_day Whether this is an all-day event + * @param rrule Recurrence rule in iCalendar RRULE format (optional) + * @param ical_uid Unique identifier in iCalendar format + * @param ical_data Complete iCalendar data (VEVENT component) + * @param created_at Time when the event was created + * @param updated_at Time when the event was last modified + * @return Result containing the new CalendarEvent or a domain error + */ + pub fn with_id( + id: Uuid, + calendar_id: Uuid, + summary: String, + description: Option, + location: Option, + start_time: DateTime, + end_time: DateTime, + all_day: bool, + rrule: Option, + ical_uid: String, + ical_data: String, + created_at: DateTime, + updated_at: DateTime, + ) -> Result { + // Basic validation + if summary.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Event summary cannot be empty", + )); + } + + if end_time < start_time { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "End time cannot be before start time", + )); + } + + Ok(Self { + id, + calendar_id, + summary, + description, + location, + start_time, + end_time, + all_day, + rrule, + ical_uid, + ical_data, + created_at, + updated_at, + }) + } + + /** + * Creates a calendar event from an iCalendar VEVENT component. + * Parses the iCalendar data to extract event properties. + * + * @param calendar_id ID of the calendar this event belongs to + * @param ical_data Complete iCalendar data (VEVENT component) + * @return Result containing the new CalendarEvent or a domain error + */ + pub fn from_ical(calendar_id: Uuid, ical_data: String) -> Result { + // This implementation would require a proper iCalendar parser + // For brevity, we're using a simplified version here + + // Extract required fields from iCalendar data + let summary = Self::extract_ical_property(&ical_data, "SUMMARY") + .ok_or_else(|| DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Missing SUMMARY in iCalendar data", + ))?; + + let dtstart = Self::extract_ical_property(&ical_data, "DTSTART") + .ok_or_else(|| DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Missing DTSTART in iCalendar data", + ))?; + + let dtend = Self::extract_ical_property(&ical_data, "DTEND") + .ok_or_else(|| DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Missing DTEND in iCalendar data", + ))?; + + // Parse dates (simplified) + let start_time = Self::parse_ical_datetime(&dtstart) + .map_err(|e| DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + format!("Invalid DTSTART: {}", e), + ))?; + + let end_time = Self::parse_ical_datetime(&dtend) + .map_err(|e| DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + format!("Invalid DTEND: {}", e), + ))?; + + // Determine if all-day event (simplified check) + let all_day = dtstart.contains("VALUE=DATE") && !dtstart.contains("T"); + + // Extract optional fields + let description = Self::extract_ical_property(&ical_data, "DESCRIPTION"); + let location = Self::extract_ical_property(&ical_data, "LOCATION"); + let rrule = Self::extract_ical_property(&ical_data, "RRULE"); + + // Extract UID or generate a new one + let ical_uid = Self::extract_ical_property(&ical_data, "UID") + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + let now = Utc::now(); + + Ok(Self { + id: Uuid::new_v4(), + calendar_id, + summary, + description, + location, + start_time, + end_time, + all_day, + rrule, + ical_uid, + ical_data, + created_at: now, + updated_at: now, + }) + } + + // Getters + + /// Returns the event's unique identifier + pub fn id(&self) -> &Uuid { + &self.id + } + + /// Returns the ID of the calendar this event belongs to + pub fn calendar_id(&self) -> &Uuid { + &self.calendar_id + } + + /// Returns the event's summary/title + pub fn summary(&self) -> &str { + &self.summary + } + + /// Returns the event's description, if any + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Returns the event's location, if any + pub fn location(&self) -> Option<&str> { + self.location.as_deref() + } + + /// Returns the event's start time + pub fn start_time(&self) -> &DateTime { + &self.start_time + } + + /// Returns the event's end time + pub fn end_time(&self) -> &DateTime { + &self.end_time + } + + /// Returns whether this is an all-day event + pub fn all_day(&self) -> bool { + self.all_day + } + + /// Returns the event's recurrence rule, if any + pub fn rrule(&self) -> Option<&str> { + self.rrule.as_deref() + } + + /// Returns the event's iCalendar UID + pub fn ical_uid(&self) -> &str { + &self.ical_uid + } + + /// Returns the complete iCalendar data for the event + pub fn ical_data(&self) -> &str { + &self.ical_data + } + + /// Returns the time when the event was created + pub fn created_at(&self) -> &DateTime { + &self.created_at + } + + /// Returns the time when the event was last modified + pub fn updated_at(&self) -> &DateTime { + &self.updated_at + } + + /// Returns the duration of the event + pub fn duration(&self) -> Duration { + self.end_time - self.start_time + } + + // Setters and Mutators + + /** + * Updates the event's summary/title. + * + * @param summary New summary/title for the event + * @return Result indicating success or containing a domain error + */ + pub fn update_summary(&mut self, summary: String) -> Result<()> { + if summary.is_empty() { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Event summary cannot be empty", + )); + } + + self.summary = summary; + self.updated_at = Utc::now(); + + // Update iCalendar data + self.update_ical_property("SUMMARY", &self.summary); + + Ok(()) + } + + /** + * Updates the event's description. + * + * @param description New description for the event + */ + pub fn update_description(&mut self, description: Option) { + self.description = description.clone(); + self.updated_at = Utc::now(); + + // Update iCalendar data + match description { + Some(desc) => self.update_ical_property("DESCRIPTION", &desc), + None => self.remove_ical_property("DESCRIPTION"), + } + } + + /** + * Updates the event's location. + * + * @param location New location for the event + */ + pub fn update_location(&mut self, location: Option) { + self.location = location.clone(); + self.updated_at = Utc::now(); + + // Update iCalendar data + match location { + Some(loc) => self.update_ical_property("LOCATION", &loc), + None => self.remove_ical_property("LOCATION"), + } + } + + /** + * Updates the event's start and end times. + * + * @param start_time New start time for the event + * @param end_time New end time for the event + * @return Result indicating success or containing a domain error + */ + pub fn update_time_range(&mut self, start_time: DateTime, end_time: DateTime) -> Result<()> { + if end_time < start_time { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "End time cannot be before start time", + )); + } + + self.start_time = start_time; + self.end_time = end_time; + self.updated_at = Utc::now(); + + // Update iCalendar data + let start_str = if self.all_day { + format!("{}T000000Z", start_time.format("%Y%m%d")) + } else { + format!("{}", start_time.format("%Y%m%dT%H%M%SZ")) + }; + + let end_str = if self.all_day { + format!("{}T000000Z", end_time.format("%Y%m%d")) + } else { + format!("{}", end_time.format("%Y%m%dT%H%M%SZ")) + }; + + self.update_ical_property("DTSTART", &start_str); + self.update_ical_property("DTEND", &end_str); + + Ok(()) + } + + /** + * Updates whether this is an all-day event. + * + * @param all_day Whether this is an all-day event + */ + pub fn update_all_day(&mut self, all_day: bool) { + self.all_day = all_day; + self.updated_at = Utc::now(); + + // Update iCalendar data + let start_str = if all_day { + format!("VALUE=DATE:{}", self.start_time.format("%Y%m%d")) + } else { + format!("{}", self.start_time.format("%Y%m%dT%H%M%SZ")) + }; + + let end_str = if all_day { + format!("VALUE=DATE:{}", self.end_time.format("%Y%m%d")) + } else { + format!("{}", self.end_time.format("%Y%m%dT%H%M%SZ")) + }; + + self.update_ical_property("DTSTART", &start_str); + self.update_ical_property("DTEND", &end_str); + } + + /** + * Updates the event's recurrence rule. + * + * @param rrule New recurrence rule for the event + * @return Result indicating success or containing a domain error + */ + pub fn update_rrule(&mut self, rrule: Option) -> Result<()> { + // Validate RRULE if provided (basic validation) + if let Some(ref rule) = rrule { + if !rule.starts_with("FREQ=") { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "Recurrence rule must start with FREQ=", + )); + } + } + + self.rrule = rrule.clone(); + self.updated_at = Utc::now(); + + // Update iCalendar data + match rrule { + Some(rule) => self.update_ical_property("RRULE", &rule), + None => self.remove_ical_property("RRULE"), + } + + Ok(()) + } + + /** + * Updates the complete iCalendar data for the event. + * Also updates the event properties based on the new iCalendar data. + * + * @param ical_data New iCalendar data for the event + * @return Result indicating success or containing a domain error + */ + pub fn update_ical_data(&mut self, ical_data: String) -> Result<()> { + // Validate iCalendar data (basic validation) + if !ical_data.contains("BEGIN:VEVENT") || !ical_data.contains("END:VEVENT") { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "CalendarEvent", + "iCalendar data must contain a VEVENT component", + )); + } + + // Extract and update properties from iCalendar data + if let Some(summary) = Self::extract_ical_property(&ical_data, "SUMMARY") { + self.summary = summary; + } + + self.description = Self::extract_ical_property(&ical_data, "DESCRIPTION"); + self.location = Self::extract_ical_property(&ical_data, "LOCATION"); + + if let Some(dtstart) = Self::extract_ical_property(&ical_data, "DTSTART") { + if let Ok(start_time) = Self::parse_ical_datetime(&dtstart) { + self.start_time = start_time; + } + } + + if let Some(dtend) = Self::extract_ical_property(&ical_data, "DTEND") { + if let Ok(end_time) = Self::parse_ical_datetime(&dtend) { + self.end_time = end_time; + } + } + + // Update all-day status based on DTSTART + if let Some(dtstart) = Self::extract_ical_property(&ical_data, "DTSTART") { + self.all_day = dtstart.contains("VALUE=DATE") && !dtstart.contains("T"); + } + + self.rrule = Self::extract_ical_property(&ical_data, "RRULE"); + + if let Some(uid) = Self::extract_ical_property(&ical_data, "UID") { + self.ical_uid = uid; + } + + self.ical_data = ical_data; + self.updated_at = Utc::now(); + + Ok(()) + } + + /** + * Checks if this event belongs to the specified calendar. + * + * @param calendar_id ID of the calendar to check against + * @return true if the event belongs to the calendar, false otherwise + */ + pub fn belongs_to_calendar(&self, calendar_id: &Uuid) -> bool { + self.calendar_id == *calendar_id + } + + /** + * Checks if this event occurs within the specified time range. + * + * @param start Start of the time range to check + * @param end End of the time range to check + * @return true if the event occurs within the range, false otherwise + */ + pub fn occurs_in_range(&self, start: &DateTime, end: &DateTime) -> bool { + // Basic case: event directly overlaps with range + if (self.start_time <= *end && self.end_time >= *start) { + return true; + } + + // If event has recurrence, check if any recurrence occurs in range + // Note: A full implementation would need a proper recurrence rule parser + if let Some(rrule) = &self.rrule { + // Simplified check for demonstration + // A real implementation would need to generate recurrence instances + // and check if any fall within the range + + // For now, we'll just check if the recurrence hasn't ended + // or if it ended after the start of our range + if let Some(until_pos) = rrule.find("UNTIL=") { + let until_start = until_pos + 6; // "UNTIL=" is 6 chars + if let Some(until_end) = rrule[until_start..].find(';') { + let until_str = &rrule[until_start..until_start+until_end]; + if let Ok(until_date) = Self::parse_ical_datetime(&until_str) { + return until_date >= *start; + } + } else { + // UNTIL is the last part of the rule + let until_str = &rrule[until_start..]; + if let Ok(until_date) = Self::parse_ical_datetime(&until_str) { + return until_date >= *start; + } + } + } else { + // No UNTIL specified, so recurrence continues indefinitely + return true; + } + } + + false + } + + // Helper methods for iCalendar operations + + /** + * Extracts a property value from iCalendar data. + * + * @param ical_data The iCalendar data to search in + * @param property_name The name of the property to extract + * @return Option containing the property value if found + */ + fn extract_ical_property(ical_data: &str, property_name: &str) -> Option { + // Find the property in the iCalendar data + let search_str = format!("\n{}:", property_name); + let search_str_alt = format!("\r\n{}:", property_name); + + let pos = ical_data.find(&search_str) + .or_else(|| ical_data.find(&search_str_alt)); + + if let Some(pos) = pos { + // Find the start of the value + let value_start = pos + search_str.len(); + + // Find the end of the value (next line or end of string) + let value_end = ical_data[value_start..] + .find('\n') + .map(|p| value_start + p) + .unwrap_or_else(|| ical_data.len()); + + // Extract and return the value + let value = ical_data[value_start..value_end].trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + + None + } + + /** + * Parses an iCalendar datetime string into a DateTime object. + * + * @param datetime The iCalendar datetime string to parse + * @return Result containing the parsed DateTime or an error + */ + fn parse_ical_datetime(datetime: &str) -> std::result::Result, String> { + // Handle VALUE=DATE format + if datetime.contains("VALUE=DATE") { + let date_str = datetime.split(':').last().unwrap_or(""); + if date_str.len() != 8 { + return Err("Invalid date format".to_string()); + } + + let year = date_str[0..4].parse::() + .map_err(|_| "Invalid year".to_string())?; + let month = date_str[4..6].parse::() + .map_err(|_| "Invalid month".to_string())?; + let day = date_str[6..8].parse::() + .map_err(|_| "Invalid day".to_string())?; + + return match chrono::NaiveDate::from_ymd_opt(year, month, day) { + Some(date) => Ok(DateTime::::from_utc(date.and_hms_opt(0, 0, 0).unwrap(), Utc)), + None => Err("Invalid date components".to_string()), + }; + } + + // Handle standard UTC format (20230101T120000Z) + let datetime_str = datetime.split(':').last().unwrap_or(datetime); + if datetime_str.len() < 15 || !datetime_str.ends_with('Z') { + return Err("Invalid datetime format".to_string()); + } + + let year = datetime_str[0..4].parse::() + .map_err(|_| "Invalid year".to_string())?; + let month = datetime_str[4..6].parse::() + .map_err(|_| "Invalid month".to_string())?; + let day = datetime_str[6..8].parse::() + .map_err(|_| "Invalid day".to_string())?; + + let hour = datetime_str[9..11].parse::() + .map_err(|_| "Invalid hour".to_string())?; + let minute = datetime_str[11..13].parse::() + .map_err(|_| "Invalid minute".to_string())?; + let second = datetime_str[13..15].parse::() + .map_err(|_| "Invalid second".to_string())?; + + match chrono::NaiveDate::from_ymd_opt(year, month, day) { + Some(date) => match date.and_hms_opt(hour, minute, second) { + Some(datetime) => Ok(DateTime::::from_utc(datetime, Utc)), + None => Err("Invalid time components".to_string()), + }, + None => Err("Invalid date components".to_string()), + } + } + + /** + * Updates an iCalendar property in the event's iCalendar data. + * + * @param property_name The name of the property to update + * @param value The new value for the property + */ + fn update_ical_property(&mut self, property_name: &str, value: &str) { + let search_str = format!("\n{}:", property_name); + let search_str_alt = format!("\r\n{}:", property_name); + + // Check if property exists + let pos = self.ical_data.find(&search_str) + .or_else(|| self.ical_data.find(&search_str_alt)); + + if let Some(pos) = pos { + // Find the start of the value + let value_start = pos + search_str.len(); + + // Find the end of the value (next line or end of string) + let value_end = self.ical_data[value_start..] + .find('\n') + .map(|p| value_start + p) + .unwrap_or_else(|| self.ical_data.len()); + + // Replace the value + let before = &self.ical_data[..value_start]; + let after = &self.ical_data[value_end..]; + self.ical_data = format!("{}{}{}", before, value, after); + } else { + // Property doesn't exist, add it before END:VEVENT + let end_pos = self.ical_data.find("END:VEVENT") + .unwrap_or(self.ical_data.len()); + + let before = &self.ical_data[..end_pos]; + let after = &self.ical_data[end_pos..]; + self.ical_data = format!("{}{}:{}\n{}", before, property_name, value, after); + } + } + + /** + * Removes an iCalendar property from the event's iCalendar data. + * + * @param property_name The name of the property to remove + */ + fn remove_ical_property(&mut self, property_name: &str) { + let search_str = format!("\n{}:", property_name); + let search_str_alt = format!("\r\n{}:", property_name); + + // Check if property exists + let pos = self.ical_data.find(&search_str) + .or_else(|| self.ical_data.find(&search_str_alt)); + + if let Some(pos) = pos { + // Find the end of the value (next line or end of string) + let value_end = self.ical_data[pos + 1..] + .find('\n') + .map(|p| pos + 1 + p) + .unwrap_or_else(|| self.ical_data.len()); + + // Remove the property + let before = &self.ical_data[..pos]; + let after = &self.ical_data[value_end..]; + self.ical_data = format!("{}{}", before, after); + } + } +} \ No newline at end of file diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs new file mode 100644 index 00000000..552895ad --- /dev/null +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -0,0 +1,340 @@ +/** + * WebDAV Handler Module + * + * This module implements the WebDAV protocol (RFC 4918) endpoints for OxiCloud. + * It provides a complete WebDAV server implementation that allows clients to + * perform file operations over HTTP, including reading, writing, and manipulating + * files and directories. + * + * The WebDAV protocol extends HTTP to provide file system-like functionality, enabling: + * - File/folder listing (PROPFIND) + * - Creation of collections/directories (MKCOL) + * - Retrieving and updating resources (GET, PUT) + * - Moving and copying resources (MOVE, COPY) + * - Resource locking for concurrency control (LOCK, UNLOCK) + * + * This implementation leverages OxiCloud's existing file and folder services + * through the application's port interfaces. + */ + +use std::sync::Arc; +use axum::{ + Router, + routing::get, + extract::{Path, State, Request, Extension}, + http::StatusCode, + response::Response, +}; +use http::{Method, header}; + +use crate::common::di::AppState; +use crate::interfaces::middleware::auth::CurrentUser; +use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase}; +use crate::application::ports::folder_ports::FolderUseCase; +use crate::common::errors::AppError; +use crate::application::adapters::webdav_adapter::WebDavAdapter; + +/** + * Creates and returns the WebDAV router with all required endpoints. + * + * This function sets up all WebDAV method handlers following RFC 4918, + * mapping HTTP methods to appropriate WebDAV operations. + * + * @return Router configured with WebDAV endpoints + */ +pub fn webdav_routes() -> Router> { + Router::new() + // Standard HTTP methods used in WebDAV + .route("/webdav/*path", get(handle_get)) + .route("/webdav/*path", axum::routing::head(handle_head)) + .route("/webdav/*path", axum::routing::put(handle_put)) + .route("/webdav/*path", axum::routing::delete(handle_delete)) + + // WebDAV-specific methods + .route_with_tsr("/webdav/*path", axum::routing::on( + Method::OPTIONS, handle_options, + Method::PROPFIND, handle_propfind, + Method::PROPPATCH, handle_proppatch, + Method::MKCOL, handle_mkcol, + Method::COPY, handle_copy, + Method::MOVE, handle_move, + Method::LOCK, handle_lock, + Method::UNLOCK, handle_unlock, + )) +} + +/** + * Handles OPTIONS requests to advertise WebDAV capabilities. + * + * This handler responds with the DAV header indicating WebDAV compliance + * level and the methods supported by this WebDAV server. + * + * @param state The application state containing service dependencies + * @param path The requested resource path + * @return HTTP response with appropriate WebDAV headers + */ +async fn handle_options( + State(_state): State>, + Path(_path): Path, +) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::DAV, "1, 2") // Class 1 and 2 WebDAV support + .header(header::ALLOW, "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK") + .body(axum::body::Body::empty()) + .unwrap() +} + +/** + * Handles PROPFIND requests to retrieve resource properties. + * + * This handler processes WebDAV PROPFIND requests, which are used to retrieve + * properties for one or more resources. It supports different depths (0, 1, infinity) + * and can return all properties or a specified subset based on the request. + * + * @param state The application state containing service dependencies + * @param user The authenticated user making the request + * @param path The requested resource path + * @param request The full HTTP request containing headers and body + * @return XML response with requested properties + */ +async fn handle_propfind( + State(state): State>, + Extension(user): Extension, + Path(path): Path, + request: Request, +) -> Result { + // Extract depth header (0, 1, or infinity) + let depth = request + .headers() + .get(header::from_str("Depth").unwrap()) + .and_then(|v| v.to_str().ok()) + .unwrap_or("infinity"); + + // Read request body to determine which properties are requested + let body_bytes = hyper::body::to_bytes(request.into_body()).await + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?; + + // Use the adapter to parse the PROPFIND request + let prop_find_request = WebDavAdapter::parse_propfind(&body_bytes[..]) + .map_err(|e| AppError::bad_request(format!("Invalid PROPFIND request: {}", e)))?; + + // Determine if the path is a file or directory + let is_file = if path.ends_with('/') { + false + } else { + // Check if path exists as a file + let file_service = state.file_service.as_ref() + .ok_or_else(|| AppError::internal_error("File service not configured"))?; + + match file_service.file_retrieval_service.get_file_by_path(&path, &user.id).await { + Ok(_) => true, + Err(_) => false, + } + }; + + if is_file { + // Handle PROPFIND for a file + let file_service = state.file_service.as_ref() + .ok_or_else(|| AppError::internal_error("File service not configured"))?; + + let file = file_service.file_retrieval_service.get_file_by_path(&path, &user.id).await?; + + // Generate XML response + let mut xml_buffer = Vec::new(); + WebDavAdapter::generate_propfind_response_for_file( + &mut xml_buffer, + &file, + &prop_find_request, + depth, + &format!("/webdav/{}", path), + ).map_err(|e| AppError::internal_error(format!("Failed to generate XML response: {}", e)))?; + + // Return response with appropriate headers + Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(axum::body::Body::from(xml_buffer)) + .unwrap()) + } else { + // Handle PROPFIND for a directory + let folder_service = state.folder_service.as_ref() + .ok_or_else(|| AppError::internal_error("Folder service not configured"))?; + + let folder_path = if path.ends_with('/') { path.clone() } else { format!("{}/", path) }; + let folder = folder_service.get_folder_by_path(&folder_path, &user.id).await?; + + // Fetch children if depth > 0 + let (files, folders) = if depth == "0" { + (Vec::new(), Vec::new()) + } else { + let files = file_service.file_retrieval_service.get_files_in_folder( + Some(&folder.id.to_string()), + &user.id, + ).await?; + + let folders = folder_service.get_subfolders( + Some(&folder.id.to_string()), + &user.id, + ).await?; + + (files, folders) + }; + + // Generate XML response + let mut xml_buffer = Vec::new(); + WebDavAdapter::generate_propfind_response( + &mut xml_buffer, + Some(&folder), + &files, + &folders, + &prop_find_request, + depth, + &format!("/webdav/{}", folder_path), + ).map_err(|e| AppError::internal_error(format!("Failed to generate XML response: {}", e)))?; + + // Return response with appropriate headers + Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(axum::body::Body::from(xml_buffer)) + .unwrap()) + } +} + +/** + * Handles GET requests to retrieve file contents. + * + * This handler streams file contents to the client with appropriate + * content type and other metadata headers. + * + * @param state The application state containing service dependencies + * @param user The authenticated user making the request + * @param path The requested file path + * @return Streaming response with file contents + */ +async fn handle_get( + State(state): State>, + Extension(user): Extension, + Path(path): Path, +) -> Result { + // Ensure this is a file request (not a directory) + if path.ends_with('/') { + return Err(AppError::bad_request("Cannot GET a directory")); + } + + let file_service = state.file_service.as_ref() + .ok_or_else(|| AppError::internal_error("File service not configured"))?; + + // Get file metadata + let file = file_service.file_retrieval_service.get_file_by_path(&path, &user.id).await?; + + // Stream file content + let stream = file_service.file_retrieval_service.get_file_stream(&file.id, &user.id).await?; + + // Return streamed response with appropriate headers + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, file.mime_type) + .header(header::CONTENT_LENGTH, file.size.to_string()) + .header(header::ETAG, format!("\"{}\"", file.id)) + .header(header::LAST_MODIFIED, file.updated_at.to_rfc2822()) + .body(axum::body::Body::from_stream(stream)) + .unwrap()) +} + +// Implement remaining WebDAV method handlers... + +/** + * Handles HEAD requests to retrieve file metadata without content. + * Similar to GET but without returning the file body. + */ +async fn handle_head( + State(state): State>, + Extension(user): Extension, + Path(path): Path, +) -> Result { + // Implementation similar to handle_get but without body + // ... + todo!() +} + +/** + * Handles PUT requests to create or update files. + * Streams the request body to create or replace a file. + */ +async fn handle_put( + // ... +) -> Result { + todo!() +} + +/** + * Handles PROPPATCH requests to update resource properties. + * Processes property updates and removals. + */ +async fn handle_proppatch( + // ... +) -> Result { + todo!() +} + +/** + * Handles MKCOL requests to create directories. + * Creates a new collection (directory) at the specified path. + */ +async fn handle_mkcol( + // ... +) -> Result { + todo!() +} + +/** + * Handles DELETE requests to remove resources. + * Deletes the specified file or recursively deletes a directory. + */ +async fn handle_delete( + // ... +) -> Result { + todo!() +} + +/** + * Handles COPY requests to duplicate resources. + * Copies a file or recursively copies a directory. + */ +async fn handle_copy( + // ... +) -> Result { + todo!() +} + +/** + * Handles MOVE requests to relocate resources. + * Moves or renames a file or directory. + */ +async fn handle_move( + // ... +) -> Result { + todo!() +} + +/** + * Handles LOCK requests for concurrency control. + * Locks a resource for exclusive access by a client. + */ +async fn handle_lock( + // ... +) -> Result { + todo!() +} + +/** + * Handles UNLOCK requests to release locks. + * Releases a previously acquired lock on a resource. + */ +async fn handle_unlock( + // ... +) -> Result { + todo!() +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index d7377be9..b79182e9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -385,9 +385,32 @@ function setupEventListeners() { */ async function loadFiles() { try { - let url = '/api/folders'; - if (app.currentPath) { - // Use the correct endpoint for folder contents + // Always ensure a userHomeFolderId is set + if (!app.userHomeFolderId) { + // If we don't have a home folder ID yet, try to get the user's username + const USER_DATA_KEY = 'oxicloud_user'; + const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); + if (userData.username) { + // Find user's home folder + await findUserHomeFolder(userData.username); + } + } + + let url; + // ALWAYS use the userHomeFolderId (current folder or home folder) to avoid showing root + if (!app.currentPath || app.currentPath === '') { + // If at root, force user to their home folder + if (app.userHomeFolderId) { + url = `/api/folders/${app.userHomeFolderId}/contents`; + app.currentPath = app.userHomeFolderId; + ui.updateBreadcrumb(app.userHomeFolderName || 'Home'); + } else { + // Emergency fallback - this should rarely happen but prevents errors + url = '/api/folders'; + console.warn("Emergency fallback to root folder - this should not normally happen"); + } + } else { + // Normal case - viewing subfolder contents url = `/api/folders/${app.currentPath}/contents`; } @@ -440,7 +463,29 @@ async function loadFiles() { // Add folders (check if it's an array) const folderList = Array.isArray(folders) ? folders : []; - folderList.forEach(folder => { + + // Get user info for filtering + const USER_DATA_KEY = 'oxicloud_user'; + const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); + const username = userData.username || ''; + + // Filter folders before adding them to the view + const visibleFolders = folderList.filter(folder => { + // Skip system folders (starting with dot) when at root + if (!app.currentPath && folder.name.startsWith('.')) { + return false; + } + + // Skip other users' folders when at root + if (!app.currentPath && folder.name.startsWith('Mi Carpeta - ') && !folder.name.includes(username)) { + return false; + } + + return true; + }); + + // Add filtered folders to the view + visibleFolders.forEach(folder => { ui.addFolderToView(folder); }); @@ -852,9 +897,14 @@ function switchToFilesView() { filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; } - // Reset path and load files - app.currentPath = ''; - ui.updateBreadcrumb(''); + // Use user's home folder instead of root path + if (app.userHomeFolderId) { + app.currentPath = app.userHomeFolderId; + ui.updateBreadcrumb(app.userHomeFolderName || 'Home'); + } else { + // If no home folder is set, this will trigger finding it in loadFiles() + app.currentPath = ''; + } loadFiles(); } @@ -1262,17 +1312,26 @@ async function findUserHomeFolder(username) { console.log(`Found ${folderList.length} folders at root`); // Look for a folder with a name pattern that matches the user's home folder - // Typically named "Mi Carpeta - username" + // Only exact match "Mi Carpeta - username" const homeFolderPattern = `Mi Carpeta - ${username}`; - let homeFolder = folderList.find(folder => folder.name === homeFolderPattern); - // If exact match not found, try a more flexible match - if (!homeFolder) { - homeFolder = folderList.find(folder => - folder.name.toLowerCase().includes(username.toLowerCase()) || - folder.name.startsWith('Mi Carpeta -') - ); - } + // Filter first to remove system folders like .trash that shouldn't be visible + const visibleFolders = folderList.filter(folder => { + // Skip system folders (starting with dot) + if (folder.name.startsWith('.')) { + return false; + } + + // Skip other users' folders + if (folder.name.startsWith('Mi Carpeta - ') && !folder.name.includes(username)) { + return false; + } + + return true; + }); + + // Find the user's home folder from filtered list + let homeFolder = visibleFolders.find(folder => folder.name === homeFolderPattern); if (homeFolder) { console.log(`Found user's home folder: ${homeFolder.name} (${homeFolder.id})`); diff --git a/static/js/favorites.js b/static/js/favorites.js index 0478baf3..95e90fe6 100644 --- a/static/js/favorites.js +++ b/static/js/favorites.js @@ -27,18 +27,32 @@ const favorites = { */ async checkBackendAvailability() { try { + // Add error handling to prevent console errors by catching 500 errors + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout + const response = await fetch('/api/favorites', { method: 'GET', headers: { 'Authorization': `Bearer ${localStorage.getItem('oxicloud_token')}` - } + }, + signal: controller.signal + }).catch(err => { + console.warn('Network error checking favorites API:', err); + return { ok: false, status: 0 }; }); - this.backendApiAvailable = response.ok; - console.log(`Backend favorites API ${this.backendApiAvailable ? 'is' : 'is not'} available`); + clearTimeout(timeoutId); - // If backend API is available, sync local favorites with server - if (this.backendApiAvailable) { + // Check if the response indicates the API is properly implemented + this.backendApiAvailable = response.ok; + + if (!response.ok) { + console.log(`Backend favorites API returned status ${response.status} - using local storage fallback`); + this.backendApiAvailable = false; + } else { + console.log('Backend favorites API is available'); + // If backend API is available, sync local favorites with server this.syncWithServer(); } } catch (error) { diff --git a/static/js/ui.js b/static/js/ui.js index 49f58056..0a00a609 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -362,12 +362,17 @@ const ui = { return window.i18n.t(key); }; + // First determine if the current view is the user's home folder + const isUserHomeFolder = username && window.app.userHomeFolderName && + window.app.userHomeFolderName.includes(username) && + folderName === window.app.userHomeFolderName; + // Set appropriate text for home item - if (username && folderName && folderName.includes(username)) { + if (isUserHomeFolder) { // If the current folder is the user's home folder, label it as "Home" homeItem.textContent = getTranslatedText('breadcrumb.home', 'Home'); } else if (folderName && folderName.startsWith('Mi Carpeta')) { - // If the current folder is another user's home folder or a special folder, use its name + // If viewing a root folder but not the user's home folder, use its full name homeItem.textContent = folderName; } else { // Default - use "Home" label