Merge pull request #41 from DioCrafts/dev

Dev
This commit is contained in:
Dionisio Pozo
2025-04-04 22:27:26 +02:00
committed by GitHub
18 changed files with 5433 additions and 23 deletions
+54
View File
@@ -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
+56
View File
@@ -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"
+63
View File
@@ -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!"
+60
View File
@@ -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
+708
View File
@@ -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
+246
View File
@@ -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
+286
View File
@@ -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
+603
View File
@@ -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<Arc<AppState>> {
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<R: Read>(reader: R) -> Result<PropFindRequest, Error> {
// Implementación...
}
/// Genera respuesta XML para PROPFIND basada en archivos y carpetas
pub fn generate_propfind_response<W: Write>(
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<String>,
color: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
// 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<String>,
location: Option<String>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
all_day: bool,
rrule: Option<String>, // Regla de recurrencia
ical_data: String, // Datos iCalendar completos
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
```
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<Calendar>;
async fn get_calendar_by_id(&self, id: &Uuid) -> Result<Calendar>;
async fn get_calendars_by_owner(&self, owner_id: &str) -> Result<Vec<Calendar>>;
async fn update_calendar(&self, calendar: Calendar) -> Result<Calendar>;
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<CalendarEvent>;
async fn get_event_by_id(&self, id: &Uuid) -> Result<CalendarEvent>;
async fn get_events_by_calendar(&self, calendar_id: &Uuid) -> Result<Vec<CalendarEvent>>;
async fn get_events_in_timerange(
&self,
calendar_id: &Uuid,
start: &DateTime<Utc>,
end: &DateTime<Utc>
) -> Result<Vec<CalendarEvent>>;
async fn update_event(&self, event: CalendarEvent) -> Result<CalendarEvent>;
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<dyn CalendarRepository>,
event_repository: Arc<dyn CalendarEventRepository>,
}
impl CalDavService {
pub fn new(
calendar_repository: Arc<dyn CalendarRepository>,
event_repository: Arc<dyn CalendarEventRepository>,
) -> 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<Arc<AppState>> {
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<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
// 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<String>,
last_name: Option<String>,
email: Option<String>,
phone: Option<String>,
address: Option<String>,
organization: Option<String>,
vcard_data: String, // Datos vCard completos
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
```
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<AddressBook>;
async fn get_address_book_by_id(&self, id: &Uuid) -> Result<AddressBook>;
async fn get_address_books_by_owner(&self, owner_id: &str) -> Result<Vec<AddressBook>>;
async fn update_address_book(&self, address_book: AddressBook) -> Result<AddressBook>;
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<Contact>;
async fn get_contact_by_id(&self, id: &Uuid) -> Result<Contact>;
async fn get_contacts_by_address_book(&self, address_book_id: &Uuid) -> Result<Vec<Contact>>;
async fn search_contacts(&self, address_book_id: &Uuid, query: &str) -> Result<Vec<Contact>>;
async fn update_contact(&self, contact: Contact) -> Result<Contact>;
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<dyn AddressBookRepository>,
contact_repository: Arc<dyn ContactRepository>,
}
impl CardDavService {
pub fn new(
address_book_repository: Arc<dyn AddressBookRepository>,
contact_repository: Arc<dyn ContactRepository>,
) -> 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<Arc<AppState>> {
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
+162
View File
@@ -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.
+218
View File
@@ -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_<NOMBRE>_NAME="Nombre Visible"
OXICLOUD_OIDC_PROVIDER_<NOMBRE>_CLIENT_ID="su-client-id"
OXICLOUD_OIDC_PROVIDER_<NOMBRE>_CLIENT_SECRET="su-client-secret"
OXICLOUD_OIDC_PROVIDER_<NOMBRE>_DISCOVERY_URL="https://proveedor.example.com/.well-known/openid-configuration"
OXICLOUD_OIDC_PROVIDER_<NOMBRE>_REDIRECT_URI="https://su-oxicloud.example.com/oidc/callback/<nombre>"
OXICLOUD_OIDC_PROVIDER_<NOMBRE>_SCOPES="openid profile email"
OXICLOUD_OIDC_PROVIDER_<NOMBRE>_USER_ID_ATTRIBUTE="sub"
OXICLOUD_OIDC_PROVIDER_<NOMBRE>_DEFAULT_ROLE="user"
OXICLOUD_OIDC_PROVIDER_<NOMBRE>_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_<NOMBRE>_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
+702
View File
@@ -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<OidcProviderConfig>,
}
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<String>, // 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<OidcProvider>,
user_repository: Arc<dyn UserRepository>,
}
struct OidcProvider {
config: OidcProviderConfig,
client: DiscoveredClient,
}
impl OidcService {
pub async fn new(
configs: Vec<OidcProviderConfig>,
user_repository: Arc<dyn UserRepository>,
) -> Result<Self, DomainError> {
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<OidcProviderInfo> {
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<String, DomainError> {
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<Discovered, StandardClaims>), 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<String>, // 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<String>) {
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<OidcService>) -> Self {
self.oidc_service = Some(oidc_service);
self
}
// Get available OIDC providers
pub fn get_oidc_providers(&self) -> Result<Vec<OidcProviderDto>, 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<String, DomainError> {
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<AuthResponseDto, DomainError> {
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<Arc<AppState>> {
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<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
Json(dto): Json<OidcAuthUrlDto>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
Json(dto): Json<OidcCallbackDto>,
) -> Result<impl IntoResponse, AppError> {
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<OidcProviderInfo> 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<String>,
}
#[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
<!-- OIDC Login Section -->
<div class="oidc-login">
<h3>Login with SSO</h3>
<div id="oidc-providers">
<!-- OIDC provider buttons will be added here dynamically -->
</div>
</div>
<script>
// Load OIDC providers
async function loadOidcProviders() {
try {
const providers = await oidcAuth.getProviders();
const providersContainer = document.getElementById('oidc-providers');
if (providers.length === 0) {
providersContainer.innerHTML = '<p>No SSO providers configured.</p>';
return;
}
const buttons = providers.map(provider => {
return `<button
class="btn btn-oidc"
data-provider="${provider.name}"
onclick="startOidcAuth('${provider.name}')"
>
Login with ${provider.display_name}
</button>`;
}).join('');
providersContainer.innerHTML = buttons;
} catch (error) {
console.error('Failed to load OIDC providers:', error);
}
}
// Start OIDC authentication
function startOidcAuth(providerName) {
localStorage.setItem('oidc_provider', providerName);
oidcAuth.startAuth(providerName);
}
// Load providers when page loads
document.addEventListener('DOMContentLoaded', loadOidcProviders);
</script>
```
## 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.
+684
View File
@@ -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<T> = std::result::Result<T, WebDavError>;
/**
* 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<String>,
}
/**
* 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<PropertyName>),
/// 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<R: Read>(reader: R) -> Result<PropFindRequest> {
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<Option<String>> {
// 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<W: Write>(
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<W: Write>(
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<W: Write>(
xml_writer: &mut Writer<W>,
href: &str,
last_modified: DateTime<Utc>,
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<W: Write>(
xml_writer: &mut Writer<W>,
last_modified: DateTime<Utc>,
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<W: Write>(xml_writer: &mut Writer<W>, 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<W: Write>(xml_writer: &mut Writer<W>, 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<W: Write>(xml_writer: &mut Writer<W>, last_modified: DateTime<Utc>) -> 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<W: Write>(xml_writer: &mut Writer<W>, creation_date: DateTime<Utc>) -> 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<W: Write>(xml_writer: &mut Writer<W>, 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<W: Write>(xml_writer: &mut Writer<W>, 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)
}
+340
View File
@@ -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<String>,
/// Optional color code for UI display (hex format #RRGGBB)
color: Option<String>,
/// Time when the calendar was created
created_at: DateTime<Utc>,
/// Time when the calendar was last modified
updated_at: DateTime<Utc>,
/// Optional list of custom properties (for extended CalDAV support)
custom_properties: std::collections::HashMap<String, String>,
}
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<String>,
color: Option<String>,
) -> Result<Self> {
// 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<String>,
color: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
) -> Result<Self> {
// 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<Utc> {
&self.created_at
}
/// Returns the time when the calendar was last modified
pub fn updated_at(&self) -> &DateTime<Utc> {
&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<String, String> {
&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<String>) {
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<String>) -> 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();
}
}
+810
View File
@@ -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<String>,
/// Location of the event (optional)
location: Option<String>,
/// Start time of the event
start_time: DateTime<Utc>,
/// End time of the event
end_time: DateTime<Utc>,
/// Whether this is an all-day event
all_day: bool,
/// Recurrence rule in iCalendar RRULE format (optional)
rrule: Option<String>,
/// 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<Utc>,
/// Time when the event was last modified
updated_at: DateTime<Utc>,
}
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<String>,
location: Option<String>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
all_day: bool,
rrule: Option<String>,
ical_data: String,
) -> Result<Self> {
// 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<String>,
location: Option<String>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
all_day: bool,
rrule: Option<String>,
ical_uid: String,
ical_data: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
) -> Result<Self> {
// 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<Self> {
// 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<Utc> {
&self.start_time
}
/// Returns the event's end time
pub fn end_time(&self) -> &DateTime<Utc> {
&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<Utc> {
&self.created_at
}
/// Returns the time when the event was last modified
pub fn updated_at(&self) -> &DateTime<Utc> {
&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<String>) {
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<String>) {
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<Utc>, end_time: DateTime<Utc>) -> 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<String>) -> 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<Utc>, end: &DateTime<Utc>) -> 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<String> {
// 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<DateTime<Utc>, 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::<i32>()
.map_err(|_| "Invalid year".to_string())?;
let month = date_str[4..6].parse::<u32>()
.map_err(|_| "Invalid month".to_string())?;
let day = date_str[6..8].parse::<u32>()
.map_err(|_| "Invalid day".to_string())?;
return match chrono::NaiveDate::from_ymd_opt(year, month, day) {
Some(date) => Ok(DateTime::<Utc>::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::<i32>()
.map_err(|_| "Invalid year".to_string())?;
let month = datetime_str[4..6].parse::<u32>()
.map_err(|_| "Invalid month".to_string())?;
let day = datetime_str[6..8].parse::<u32>()
.map_err(|_| "Invalid day".to_string())?;
let hour = datetime_str[9..11].parse::<u32>()
.map_err(|_| "Invalid hour".to_string())?;
let minute = datetime_str[11..13].parse::<u32>()
.map_err(|_| "Invalid minute".to_string())?;
let second = datetime_str[13..15].parse::<u32>()
.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::<Utc>::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);
}
}
}
@@ -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<Arc<AppState>> {
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<Arc<AppState>>,
Path(_path): Path<String>,
) -> 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<Arc<AppState>>,
Extension(user): Extension<CurrentUser>,
Path(path): Path<String>,
request: Request<axum::body::Body>,
) -> Result<Response, AppError> {
// 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<Arc<AppState>>,
Extension(user): Extension<CurrentUser>,
Path(path): Path<String>,
) -> Result<Response, AppError> {
// 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<Arc<AppState>>,
Extension(user): Extension<CurrentUser>,
Path(path): Path<String>,
) -> Result<Response, AppError> {
// 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<Response, AppError> {
todo!()
}
/**
* Handles PROPPATCH requests to update resource properties.
* Processes property updates and removals.
*/
async fn handle_proppatch(
// ...
) -> Result<Response, AppError> {
todo!()
}
/**
* Handles MKCOL requests to create directories.
* Creates a new collection (directory) at the specified path.
*/
async fn handle_mkcol(
// ...
) -> Result<Response, AppError> {
todo!()
}
/**
* Handles DELETE requests to remove resources.
* Deletes the specified file or recursively deletes a directory.
*/
async fn handle_delete(
// ...
) -> Result<Response, AppError> {
todo!()
}
/**
* Handles COPY requests to duplicate resources.
* Copies a file or recursively copies a directory.
*/
async fn handle_copy(
// ...
) -> Result<Response, AppError> {
todo!()
}
/**
* Handles MOVE requests to relocate resources.
* Moves or renames a file or directory.
*/
async fn handle_move(
// ...
) -> Result<Response, AppError> {
todo!()
}
/**
* Handles LOCK requests for concurrency control.
* Locks a resource for exclusive access by a client.
*/
async fn handle_lock(
// ...
) -> Result<Response, AppError> {
todo!()
}
/**
* Handles UNLOCK requests to release locks.
* Releases a previously acquired lock on a resource.
*/
async fn handle_unlock(
// ...
) -> Result<Response, AppError> {
todo!()
}
+75 -16
View File
@@ -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})`);
+19 -5
View File
@@ -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) {
+7 -2
View File
@@ -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