@@ -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
|
||||
@@ -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"
|
||||
@@ -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!"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user