docs: update documentation to reflect 100% blob storage model

Rewrite documentation to match the new architecture where all
file metadata lives in PostgreSQL and content is stored as
content-addressed blobs via DedupService.

Updated files:
- internal-architecture.md: complete rewrite — new DB schema,
  blob repos (FolderDb, FileBlobRead/Write, TrashDb), updated
  DI container, service groups, architecture diagram, data flows
- file-system-safety.md: repurposed as storage-safety.md —
  covers PostgreSQL ACID guarantees + DedupService atomic writes
- caching-architecture.md: updated repo references to blob repos,
  removed write-behind cache section, updated upload/download flows
- trash-feature-summary.md: rewritten for soft-delete model
  (is_trashed flag, trash_items VIEW, TrashDbRepository)
- share-integration.md: clarified ShareFsRepository scope,
  updated DI snippet, added blob storage context note
- deduplication.md: updated DI snippet (dedup injected into repos)
- deployment.md: updated feature matrix (file storage requires DB)
- important-delta-sync-implementation.md: updated DI references

Removed legacy references: IdMappingPort, StorageMediator,
WriteBehindCache, FsFileRepository, FsFolderRepository,
TrashFsRepository, folder_ids.json, file_ids.json.
This commit is contained in:
Dionisio
2026-02-14 18:27:30 +01:00
parent 5d2bc36d74
commit e071841ec2
8 changed files with 1033 additions and 1115 deletions
+274 -370
View File
@@ -1,370 +1,274 @@
# 04 - Caching Architecture # 04 - Caching Architecture
OxiCloud uses a multi-layer caching system spanning HTTP-level caching down to kernel-level memory mapping. Covers both uploads and downloads. OxiCloud uses a multi-layer caching system spanning HTTP-level caching down to kernel-level memory mapping. Covers both uploads and downloads.
## Cache Layers Summary ## Cache Layers Summary
``` ```
┌─────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────┐
│ Layer 0: HTTP Cache Middleware (ETag + 304) │ All endpoints │ Layer 0: HTTP Cache Middleware (ETag + 304) │ All endpoints
├─────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────┤
│ Layer 1: File Content Cache (LRU, <10MB files) │ Downloads │ Layer 1: File Content Cache (LRU, <10MB files) │ Downloads
├─────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────┤
│ Layer 2: MMAP (memmap2, 10-100MB files) │ Downloads │ Layer 2: MMAP (memmap2, 10-100MB blobs) │ Downloads
├─────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────┤
│ Layer 3: Streaming (FramedRead, ≥100MB files) │ Downloads │ Layer 3: Streaming (FramedRead, ≥100MB blobs) │ Downloads
├─────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────┤
│ Layer 4: File Metadata Cache (adaptive TTL) │ All file ops │ Layer 4: Buffer Pool (reusable I/O buffers) │ Compression
├─────────────────────────────────────────────────────┤ └─────────────────────────────────────────────────────┘
│ Layer 5: Write-Behind Cache (<256KB uploads) │ Uploads ```
├─────────────────────────────────────────────────────┤
│ Layer 6: Buffer Pool (reusable I/O buffers) │ Compression > **Note:** File metadata (name, size, MIME type, folder) is served from PostgreSQL — no separate filesystem metadata cache is needed.
└─────────────────────────────────────────────────────┘
``` ---
--- ## Layer 0: HTTP Cache Middleware
## Layer 0: HTTP Cache Middleware **File**: `src/interfaces/middleware/cache.rs`
**File**: `src/interfaces/middleware/cache.rs` Generic HTTP caching layer applied to API endpoints.
Generic HTTP caching layer applied to API endpoints. | Parameter | Value |
|---|---|
| Parameter | Value | | Max entries | 1,000 |
|---|---| | Default max-age | 60 seconds |
| Max entries | 1,000 | | Eviction | LRU (oldest 10% when full) |
| Default max-age | 60 seconds | | Cleanup | Background task every 5 minutes |
| Eviction | LRU (oldest 10% when full) |
| Cleanup | Background task every 5 minutes | Features:
- ETag-based conditional requests (`If-None-Match` → `304 Not Modified`)
Features: - `Cache-Control` header injection
- ETag-based conditional requests (`If-None-Match` → `304 Not Modified`) - Implements Tower `Layer` + `Service` traits for Axum integration
- `Cache-Control` header injection - Per-request key: method + URI
- Implements Tower `Layer` + `Service` traits for Axum integration
- Per-request key: method + URI ---
--- ## Layer 1: File Content Cache (Download Tier 1)
## Layer 1: File Content Cache (Download Tier 1) **File**: `src/infrastructure/services/file_content_cache.rs`
**File**: `src/infrastructure/services/file_content_cache.rs` In-memory LRU cache for small files, served directly from RAM.
In-memory LRU cache for small files, served directly from RAM. | Parameter | Value |
|---|---|
| Parameter | Value | | Max file size | 10 MB per file |
|---|---| | Max total cache size | 512 MB |
| Max file size | 10 MB per file | | Max entries | 10,000 |
| Max total cache size | 512 MB | | Structure | `lru::LruCache<String, CacheEntry>` |
| Max entries | 10,000 | | Latency | ~0.1ms |
| Structure | `lru::LruCache<String, CacheEntry>` |
| Latency | ~0.1ms | **CacheEntry**: `{ data: Bytes, etag: String, content_type: String, size: usize }`
**CacheEntry**: `{ data: Bytes, etag: String, content_type: String, size: usize }` Methods:
- `should_cache(size)` — checks if file fits in cache
Methods: - `get(file_id)` → `Option<(Bytes, String, String)>` — returns (data, etag, content_type)
- `should_cache(size)` -- checks if file fits in cache - `put(file_id, content, etag, content_type)` — inserts with LRU eviction
- `get(file_id)` → `Option<(Bytes, String, String)>` -- returns (data, etag, content_type) - `invalidate(file_id)`, `clear()`
- `put(file_id, content, etag, content_type)` -- inserts with LRU eviction - `stats()` → `CacheStats { current_size_bytes, max_size_bytes, hits, misses, hit_rate_percent }`
- `invalidate(file_id)`, `clear()`
- `stats()` → `CacheStats { current_size_bytes, max_size_bytes, hits, misses, hit_rate_percent }` Port: implements **ContentCachePort** trait.
Port: implements **ContentCachePort** trait. ---
--- ## Layer 2: MMAP (Download Tier 2)
## Layer 2: MMAP (Download Tier 2) **File**: `src/infrastructure/repositories/pg/file_blob_read_repository.rs`
**File**: `src/infrastructure/repositories/file_fs_read_repository.rs` Memory-mapped I/O for medium blobs using `memmap2`.
Memory-mapped I/O for medium files using `memmap2`. | Parameter | Value |
|---|---|
| Parameter | Value | | File range | 10 MB - 100 MB |
|---|---| | Implementation | `memmap2::Mmap` via `spawn_blocking` |
| File range | 10 MB - 100 MB | | Latency | ~1-5ms |
| Implementation | `memmap2::Mmap` via `spawn_blocking` |
| Latency | ~1-5ms | The blob file (`.blobs/{prefix}/{hash}.blob`) is memory-mapped and its contents copied to `Bytes`. Benefits from kernel page cache for frequently accessed blobs.
Current implementation copies mmap'd data to `Bytes` (`Bytes::copy_from_slice(&mmap[..])`). Not true zero-copy, but still benefits from kernel page cache. ---
--- ## Layer 3: Streaming (Download Tier 3)
## Layer 3: Streaming (Download Tier 3) **File**: `src/infrastructure/repositories/pg/file_blob_read_repository.rs`
**File**: `src/infrastructure/repositories/file_fs_read_repository.rs` Chunked streaming for large blobs using tokio-util codecs.
Chunked streaming for large files using tokio-util codecs. | Parameter | Value |
|---|---|
| Parameter | Value | | File range | ≥100 MB |
|---|---| | Chunk size | 1 MB (configurable via **ResourceConfig.chunk_size_bytes**) |
| File range | ≥100 MB | | Implementation | `FramedRead` + `BytesCodec` |
| Chunk size | 1 MB (configurable via **ResourceConfig.chunk_size_bytes**) | | RAM usage | Near zero (one chunk at a time) |
| Implementation | `FramedRead` + `BytesCodec` |
| RAM usage | Near zero (one chunk at a time) | ---
--- ## Layer 4: Buffer Pool
## Layer 4: File Metadata Cache **File**: `src/infrastructure/services/buffer_pool.rs`
**File**: `src/infrastructure/services/file_metadata_cache.rs` Reusable byte buffer pool to reduce allocation pressure during compression operations.
Caches filesystem metadata (existence, size, MIME type, timestamps) to avoid repeated `stat()` calls. | Parameter | Value |
|---|---|
| Parameter | Value | | Buffer size | 64 KB |
|---|---| | Max buffers | 100 |
| Default file TTL | 60 seconds | | Buffer TTL | 60 seconds |
| Default directory TTL | 120 seconds | | Concurrency control | `tokio::sync::Semaphore` |
| Max entries | 10,000 |
| Adaptive TTL multiplier | 5x for popular entries (≥10 accesses) | Features:
| LRU eviction | Frees 10% capacity when full | - `get_buffer()` — borrows a buffer (blocks if pool exhausted)
| Cleanup | Background task runs periodically | - **BorrowedBuffer** auto-returns to pool on `Drop` via `tokio::spawn`
- Expired buffers are cleaned periodically via `start_cleaner()`
**CachedMetadata:** - Tracks stats: gets, hits, misses, returns, evictions, waits
```rust
pub struct FileMetadata { ---
pub path: PathBuf,
pub exists: bool, ## Configuration
pub entry_type: CacheEntryType, // File | Directory | Unknown
pub size: Option<u64>, All cache-related config in `src/common/config.rs`:
pub mime_type: Option<String>,
pub created_at: Option<u64>, ```rust
pub modified_at: Option<u64>, pub struct ResourceConfig {
pub last_access: Instant, pub large_file_threshold_mb: u64, // 100 MB (mmap→streaming boundary)
pub expires_at: Instant, pub chunk_size_bytes: usize, // 1 MB (streaming chunk size)
pub access_count: usize, pub max_in_memory_file_size_mb: u64, // 50 MB
} }
``` ```
**Adaptive TTL**: entries accessed ≥10 times get 5x the configured TTL, keeping frequently accessed file metadata in cache longer. ## Download Flow
Port: implements **MetadataCachePort** trait. ```
Request → ETag check (304?) → Range request (206?)
--- → file size < 10MB? → Tier 1: LRU cache (RAM)
→ file size < 100MB? → Tier 2: MMAP (kernel page cache on blob file)
## Layer 5: Write-Behind Cache → file size ≥ 100MB → Tier 3: Streaming (chunked from blob file)
```
**File**: `src/infrastructure/services/write_behind_cache.rs`
In all tiers, metadata (file name, size, MIME type) comes from a PostgreSQL `SELECT` on `storage.files`. Content is read from the DedupService blob at `.blobs/{prefix}/{hash}.blob`.
Buffers small uploads in RAM and confirms immediately. Flushes to disk asynchronously.
---
| Parameter | Value |
|---|---| ## Range Requests (HTTP 206 Partial Content)
| Max file size | 1 MB per file |
| Max total cache | 100 MB | **Files**: `src/interfaces/api/handlers/file_handler.rs`, `src/infrastructure/repositories/pg/file_blob_read_repository.rs`
| Max pending duration | 30 seconds |
| Flush interval | 100 ms | **Crate**: `http-range-header = "0.4"` for parsing.
| Write strategy | Atomic (temp file + rename) |
### Request Processing Flow
Architecture:
- `put_pending(file_id, content, target_path)` stores bytes in `HashMap<String, PendingWrite>` ```
- Background `flush_worker` processes **FlushCommands** via `mpsc` channel Range header present?
- Periodic checker force-flushes entries older than 30 seconds ├─ parse_range_header(range_str)
- `get_pending(file_id)` serves reads while data is still in RAM (before flush) │ ├─ Parse OK → ranges.validate(file_size)
│ │ ├─ Valid → take first range → get_file_range_stream(start, end+1)
Port: implements **WriteBehindCachePort** trait. │ │ │ ├─ Stream OK → 206 Partial Content
│ │ │ └─ Stream Err → fall through to normal download (200)
Statistics: │ │ └─ Invalid → 416 Range Not Satisfiable
```rust │ └─ Parse Err → fall through to normal download (200)
pub struct WriteBehindStatsDto { └─ No Range header → normal 3-tier download
pub pending_count: usize, ```
pub pending_bytes: usize,
pub total_writes: u64, ### Response Headers (206)
pub total_bytes_written: u64,
pub cache_hits: u64, | Header | Value |
pub avg_flush_time_us: u64, |---|---|
} | `Content-Type` | File MIME type |
``` | `Content-Range` | `bytes {start}-{end}/{total_size}` |
| `Content-Length` | Range length (end - start + 1) |
--- | `Accept-Ranges` | `bytes` |
| `ETag` | `"{file_id}-{modified_at}"` |
## Layer 6: Buffer Pool | `Cache-Control` | `private, max-age=3600, must-revalidate` |
**File**: `src/infrastructure/services/buffer_pool.rs` ### 416 Range Not Satisfiable
Reusable byte buffer pool to reduce allocation pressure during compression operations. Returned when `ranges.validate(file_size)` fails:
```
| Parameter | Value | HTTP/1.1 416 Range Not Satisfiable
|---|---| Content-Range: bytes */12345
| Buffer size | 64 KB | ```
| Max buffers | 100 |
| Buffer TTL | 60 seconds | ### Blob File Seek Implementation
| Concurrency control | `tokio::sync::Semaphore` |
`get_file_range_stream()` at the repository level:
Features:
- `get_buffer()` -- borrows a buffer (blocks if pool exhausted) 1. Resolves blob path from `blob_hash` via DedupService
- **BorrowedBuffer** auto-returns to pool on `Drop` via `tokio::spawn` 2. Opens the blob file with `TokioFile::open()`
- Expired buffers are cleaned periodically via `start_cleaner()` 3. Seeks to `start` via `fh.seek(SeekFrom::Start(start))`
- Tracks stats: gets, hits, misses, returns, evictions, waits 4. Limits read to `range_length` via `fh.take(range_length)`
5. Wraps in `FramedRead` + `BytesCodec`
---
Adaptive chunk size:
## Configuration
| Range size | Chunk size |
All cache-related config in `src/common/config.rs`: |---|---|
| ≤ 1 MB | 8 KB |
```rust | > 1 MB | 1 MB (from **ResourceConfig.chunk_size_bytes**) |
pub struct CacheConfig {
pub file_ttl_ms: u64, // default: 60,000 (1 min) ### Tier Interaction
pub directory_ttl_ms: u64, // default: 120,000 (2 min)
pub max_entries: usize, // default: 10,000 Range requests **bypass all download tiers** (LRU, MMAP). They always use direct blob file seek + streaming. On stream creation error, the handler falls through to the normal `get_file_optimized()` 3-tier path.
}
### Limitations
pub struct ResourceConfig {
pub large_file_threshold_mb: u64, // 100 MB (mmap→streaming boundary) - **Multipart ranges not supported**: only the first range in a multi-range request is served.
pub chunk_size_bytes: usize, // 1 MB (streaming chunk size) - **`If-Range` not handled**: no conditional range support.
pub max_in_memory_file_size_mb: u64, // 50 MB - **`If-Modified-Since` not handled**: only `If-None-Match` (ETag) is checked.
}
``` ---
## Download Flow ## Upload Flow
``` ```
Request → ETag check (304?) → Range request (206?) Request → file size < 1MB? → Buffered write (sync to blob store)
→ file size < 10MB? → Tier 1: LRU cache (RAM) → file size ≥ 1MB → Streaming write (chunk-by-chunk to blob store)
→ file size < 100MB? → Tier 2: MMAP (kernel page cache) ```
→ file size ≥ 100MB → Tier 3: Streaming (chunked)
``` ### Upload Strategy Selection
--- **File**: `src/application/services/file_upload_service.rs`
## Range Requests (HTTP 206 Partial Content) ```rust
pub enum UploadStrategy {
**Files**: `src/interfaces/api/handlers/file_handler.rs`, `src/infrastructure/repositories/file_fs_read_repository.rs` Buffered, // < 1 MB — collect bytes, write to blob store
Streaming, // ≥ 1 MB — chunk-by-chunk write via save_file_from_stream
**Crate**: `http-range-header = "0.4"` for parsing. }
```
### Request Processing Flow
| Constant | Value |
``` |---|---|
Range header present? | `STREAMING_UPLOAD_THRESHOLD` | 1 MB |
├─ parse_range_header(range_str)
│ ├─ Parse OK → ranges.validate(file_size) ### Handler-Level Buffering
│ │ ├─ Valid → take first range → get_file_range_stream(start, end+1)
│ │ │ ├─ Stream OK → 206 Partial Content Upload handlers buffer the multipart body in RAM as `Vec<Bytes>` before calling the service layer:
│ │ │ └─ Stream Err → fall through to normal download (200)
│ │ └─ Invalid → 416 Range Not Satisfiable ```rust
│ └─ Parse Err → fall through to normal download (200) let mut chunks: Vec<Bytes> = Vec::new();
└─ No Range header → normal 3-tier download while let Some(chunk) = field.chunk().await {
``` chunks.push(chunk);
}
### Response Headers (206) upload_service.smart_upload(..., chunks, total_size).await
```
| Header | Value |
|---|---| ### Buffered Path (< 1 MB)
| `Content-Type` | File MIME type |
| `Content-Range` | `bytes {start}-{end}/{total_size}` | Uses `save_file()` — all bytes are passed to `FileBlobWriteRepository`, which calls `DedupService.store_bytes()` to compute hash and store the blob, then INSERTs metadata into `storage.files`.
| `Content-Length` | Range length (end - start + 1) |
| `Accept-Ranges` | `bytes` | ### Streaming Path (≥ 1 MB)
| `ETag` | `"{file_id}-{modified_at}"` |
| `Cache-Control` | `private, max-age=3600, must-revalidate` | `smart_upload()` converts the in-memory `Vec<Bytes>` into a `futures::stream::iter()` and passes it to `save_file_from_stream()`:
### 416 Range Not Satisfiable ```rust
let chunk_stream = stream::iter(chunks.into_iter().map(|c| Ok(c)));
Returned when `ranges.validate(file_size)` fails: self.file_write.save_file_from_stream(name, folder_id, content_type, chunk_stream).await
``` ```
HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */12345 `FileBlobWriteRepository.save_file_from_stream()` collects the stream, stores via DedupService, and INSERTs metadata.
```
### Dedup Integration
### File Seek Implementation
Deduplication is handled at the **repository layer** (not the service layer) for all upload strategies. `FileBlobWriteRepository` always calls `DedupService.store_bytes()` which:
`get_file_range_stream()` at the repository level:
1. Computes SHA-256 hash of content
```rust 2. Checks if blob already exists (dedup hit → increment ref count, skip write)
async fn get_file_range_stream( 3. If new → atomic write to `.blobs/{prefix}/{hash}.blob`
&self, id: &str, start: u64, end: Option<u64>, 4. Returns the hash for storage in `storage.files.blob_hash`
) -> Result<Box<dyn Stream<...> + Send>, DomainError>
```
1. Opens the file with `TokioFile::open()`
2. Seeks to `start` via `fh.seek(SeekFrom::Start(start))`
3. Limits read to `range_length` via `fh.take(range_length)`
4. Wraps in `FramedRead` + `BytesCodec`
Adaptive chunk size:
| Range size | Chunk size |
|---|---|
| ≤ 1 MB | 8 KB |
| > 1 MB | 1 MB (from **ResourceConfig.chunk_size_bytes**) |
### Tier Interaction
Range requests **bypass all download tiers** (LRU, MMAP, write-behind). They always use direct file seek + streaming. On stream creation error, the handler falls through to the normal `get_file_optimized()` 3-tier path.
### Limitations
- **Multipart ranges not supported**: only the first range in a multi-range request is served. Additional ranges are ignored.
- **`If-Range` not handled**: no conditional range support.
- **`If-Modified-Since` not handled**: only `If-None-Match` (ETag) is checked.
---
## Upload Flow
```
Request → file size < 256KB? → Write-behind cache (instant 201, async flush)
→ file size < 1MB? → Buffered write (sync)
→ file size ≥ 1MB → Streaming write (chunk-by-chunk to temp + rename)
```
### Upload Strategy Selection
**File**: `src/application/services/file_upload_service.rs`
```rust
pub enum UploadStrategy {
WriteBehind, // < 256 KB — instant response, async disk write
Buffered, // 256 KB – 1 MB — sync write to final path
Streaming, // ≥ 1 MB — chunk-by-chunk write to temp file + rename
}
```
| Constant | Value |
|---|---|
| `WRITE_BEHIND_THRESHOLD` | 256 KB |
| `STREAMING_UPLOAD_THRESHOLD` | 1 MB |
### Handler-Level Buffering
Both upload handlers (`upload_file` and `upload_file_with_cache`) buffer the **entire multipart body in RAM** as `Vec<Bytes>` before calling the service layer:
```rust
let mut chunks: Vec<Bytes> = Vec::new();
while let Some(chunk) = field.chunk().await {
chunks.push(chunk);
}
// All bytes are now in RAM
upload_service.smart_upload(..., chunks, total_size).await
```
The "streaming" in `UploadStrategy::Streaming` refers to the **service→repository** path, not the HTTP-body→disk path. By the time `save_file_from_stream()` is called, data is already in memory.
### Streaming Path (≥ 1 MB): Service → Repository
`smart_upload()` converts the in-memory `Vec<Bytes>` into a `futures::stream::iter()` and passes it to `save_file_from_stream()`:
```rust
let chunk_stream = stream::iter(chunks.into_iter().map(|c| Ok(c)));
self.file_write.save_file_from_stream(name, folder_id, content_type, chunk_stream).await
```
**`save_file_from_stream()` implementation** (`file_fs_write_repository.rs`):
1. Resolves target path + generates unique name if collision
2. Creates temp file: `{target_path}.tmp.upload`
3. Iterates stream, writing each chunk with `fh.write_all(&chunk)`
4. Calls `fh.flush()` + `fh.sync_all()` for durability
5. Atomic rename: `fs::rename(temp_path, final_path)`
6. Post-write: ID mapping, cache invalidation, metadata update
### Buffered Path (256 KB - 1 MB)
Uses `save_file()` -- writes all bytes directly to the **final path** (no temp file). For larger content, writes in chunks of **ResourceConfig.chunk_size_bytes** (1 MB).
### Write-Behind Path (< 256 KB)
See **Layer 5** above. Instant `201`, background flush within 30 seconds.
### Dedup Pre-Check
Runs for **all upload strategies** before writing. Re-combines all chunks into a single `Vec<u8>` for hash computation, which means data is temporarily duplicated in RAM during dedup processing.
+6 -3
View File
@@ -285,9 +285,12 @@ pub struct CoreServices {
// ... // ...
} }
// Injected into application services: // Injected into blob repositories (which handle dedup internally):
FileUploadService::new_full(... core.dedup_service.clone()) FileBlobReadRepository::new(pool, core.dedup_service.clone(), folder_repo)
FileManagementService::new_full(... core.dedup_service.clone()) FileBlobWriteRepository::new(pool, core.dedup_service.clone(), folder_repo)
// Also injected into FileManagementService for ref cleanup on delete:
FileManagementService::new_full(write, read, trash, core.dedup_service.clone())
``` ```
## Persistence ## Persistence
+4 -4
View File
@@ -174,17 +174,17 @@ Hardcoded defaults in `src/common/config.rs`:
| Feature | Requires DB | Requires Auth | Feature Flag | | Feature | Requires DB | Requires Auth | Feature Flag |
|---|---|---|---| |---|---|---|---|
| File storage | No | No | Always on | | File storage | Yes | No | Always on |
| Authentication | Yes | -- | `OXICLOUD_ENABLE_AUTH` | | Authentication | Yes | -- | `OXICLOUD_ENABLE_AUTH` |
| OIDC / SSO | Yes | Yes | `OXICLOUD_OIDC_ENABLED` | | OIDC / SSO | Yes | Yes | `OXICLOUD_OIDC_ENABLED` |
| File sharing | Yes | Yes | `OXICLOUD_ENABLE_FILE_SHARING` | | File sharing | Yes | Yes | `OXICLOUD_ENABLE_FILE_SHARING` |
| Trash | No | No | `OXICLOUD_ENABLE_TRASH` | | Trash | Yes | No | `OXICLOUD_ENABLE_TRASH` |
| Search | No | No | `OXICLOUD_ENABLE_SEARCH` | | Search | Yes | No | `OXICLOUD_ENABLE_SEARCH` |
| Favorites | Yes | Yes | Always on (when DB available) | | Favorites | Yes | Yes | Always on (when DB available) |
| Recent items | Yes | Yes | Always on (when DB available) | | Recent items | Yes | Yes | Always on (when DB available) |
| Storage quotas | Yes | Yes | `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` | | Storage quotas | Yes | Yes | `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` |
| Admin panel | Yes | Yes | Always on (when auth enabled) | | Admin panel | Yes | Yes | Always on (when auth enabled) |
| WebDAV | No | Optional | Always on | | WebDAV | Yes | Optional | Always on |
| CalDAV | Yes | Yes | Always on (when DB available) | | CalDAV | Yes | Yes | Always on (when DB available) |
| CardDAV | Yes | Yes | Always on (when DB available) | | CardDAV | Yes | Yes | Always on (when DB available) |
| Deduplication | No | No | Always on | | Deduplication | No | No | Always on |
+125 -156
View File
@@ -1,156 +1,125 @@
# 03 - File System Safety # 03 - Storage Safety
OxiCloud ensures data integrity and durability during file operations through atomic writes, fsync, and directory synchronization. The goal: writes either complete fully or not at all, data reaches persistent storage, and the system recovers from crashes or power loss. OxiCloud ensures data integrity and durability through a combination of PostgreSQL transactional guarantees and atomic blob writes. The goal: writes either complete fully or not at all, data reaches persistent storage, and the system recovers from crashes or power loss.
--- ---
## The Problem: Buffered I/O ## Storage Model
Standard filesystem operations use buffered I/O by default: OxiCloud uses a **100% blob storage model**:
```rust - **Metadata** (file names, folder hierarchy, sizes, MIME types, trash status) lives in **PostgreSQL** — protected by ACID transactions.
// This operation may not immediately persist to disk - **File content** is stored as content-addressed blobs via **DedupService** at `.blobs/{prefix}/{hash}.blob` — protected by atomic writes and fsync.
fs::write(path, content)
``` ---
When an application writes data, the OS typically: ## PostgreSQL Safety (Metadata)
1. Accepts the write into memory buffers All file and folder metadata operations use PostgreSQL transactions:
2. Acknowledges completion to the application
3. Schedules the actual disk write for later - **Single-row operations** (INSERT, UPDATE, DELETE) are inherently atomic.
- **Multi-step operations** (e.g., move file: UPDATE folder_id + UPDATE path) use explicit transactions via `sqlx`.
A crash during that window means data loss -- the data exists only in memory buffers that haven't been flushed. - **Foreign key constraints** prevent orphaned records (e.g., files referencing non-existent folders).
- **Unique constraints** prevent duplicate names within the same parent folder.
--- - **Soft-delete** for trash (`is_trashed = TRUE`) preserves data until explicit permanent deletion.
## OxiCloud's Approach The `storage.trash_items` VIEW provides a unified read interface over trashed files and folders without duplicating data.
All safety mechanisms live in the **FileSystemUtils** service. ---
### Atomic Write Pattern ## Blob Storage Safety (Content)
Files are written using write-then-rename: ### DedupService Atomic Writes
```rust **File:** `src/infrastructure/services/dedup_service.rs`
/// Writes data to a file with fsync to ensure durability
/// Uses a safe atomic write pattern: write to temp file, fsync, rename When storing file content, DedupService uses the following pattern:
pub async fn atomic_write<P: AsRef<Path>>(path: P, contents: &[u8]) -> Result<(), IoError>
``` 1. **Hash computation** — SHA-256 hash of content determines the blob path
2. **Deduplication check** — if a blob with the same hash exists, only increment the reference counter (no write needed)
Steps: 3. **Atomic write** — if new content:
1. Write to a temporary file in the same directory - Write to a temporary file (`.blob.tmp`)
2. Call `fsync` to ensure data is on disk - Call `fsync` to ensure data reaches persistent storage
3. Atomically rename the temp file to the target file - Atomically rename temp file to final path (`.blobs/{prefix}/{hash}.blob`)
4. Sync the parent directory to ensure the rename is persisted 4. **Reference counting** — track how many files reference each blob
### Directory Synchronization This ensures that a blob either fully exists or doesn't — no partial writes.
```rust ### FileSystemUtils
/// Creates directories with fsync
pub async fn create_dir_with_sync<P: AsRef<Path>>(path: P) -> Result<(), IoError> **File:** `src/infrastructure/services/file_system_utils.rs`
```
Low-level utilities used internally by DedupService and other infrastructure services:
Directories are created, their entries persisted to disk, and parent directories synchronized too.
```rust
### Rename and Delete Operations /// Atomic write: temp file → fsync → rename
pub async fn atomic_write<P: AsRef<Path>>(path: P, contents: &[u8]) -> Result<(), IoError>
```rust
/// Renames a file or directory with proper syncing /// Directory creation with fsync
pub async fn rename_with_sync<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<(), IoError> pub async fn create_dir_with_sync<P: AsRef<Path>>(path: P) -> Result<(), IoError>
/// Removes a file with directory syncing /// Rename with directory sync
pub async fn remove_file_with_sync<P: AsRef<Path>>(path: P) -> Result<(), IoError> pub async fn rename_with_sync<P, Q>(from: P, to: Q) -> Result<(), IoError>
```
/// Delete with directory sync
Both complete the operation itself, then update and sync the parent directory entry. pub async fn remove_file_with_sync<P: AsRef<Path>>(path: P) -> Result<(), IoError>
```
---
### fsync Guarantees
## Implementation Details
- `sync_all()` on written files ensures data and metadata reach the physical storage device
### fsync on Files - Directory entries are synced after create/rename/delete operations
- Prevents data loss during crashes or power failures between OS buffer flush and disk write
```rust
// Write file content ---
file.write_all(contents).await?;
## Transaction Flow: File Upload
// Ensure data is synced to disk
file.flush().await?; ```
file.sync_all().await?; 1. DedupService.store_bytes(content)
``` → Compute SHA-256 hash
→ Check if blob exists (dedup hit → increment ref, return hash)
`sync_all()` instructs the OS to flush data and metadata to the physical storage device. → Write to .blobs/{prefix}/{hash}.blob.tmp
→ fsync + rename → .blobs/{prefix}/{hash}.blob
### fsync on Directories
2. FileBlobWriteRepository.save_file()
```rust → BEGIN TRANSACTION
// Sync a directory to ensure its contents (entries) are durable → INSERT INTO storage.files (name, folder_id, blob_hash, size, ...)
async fn sync_directory<P: AsRef<Path>>(path: P) -> Result<(), IoError> { → COMMIT
let dir_file = OpenOptions::new().read(true).open(path).await?; ```
dir_file.sync_all().await
} If step 1 fails, no metadata is written. If step 2 fails, the blob exists but is unreferenced (cleaned up by garbage collection). Data is never in an inconsistent state.
```
## Transaction Flow: File Deletion
Required after any operation that modifies directory entries (create, rename, delete).
```
--- 1. FileBlobWriteRepository.delete_file_permanently()
→ BEGIN TRANSACTION
## Usage in the Codebase → DELETE FROM storage.files WHERE id = $1 (captures blob_hash first)
→ COMMIT
### File Write Repository
2. DedupService.decrement_ref(blob_hash)
```rust → Decrement reference counter
// Write the file to disk using atomic write with fsync → If counter reaches 0, delete the blob file
tokio::time::timeout( ```
self.config.timeouts.file_write_timeout(),
FileSystemUtils::atomic_write(&abs_path, &content) If step 2 fails, an unreferenced blob may remain on disk (occupies space but is not a correctness issue). Future garbage collection can clean these up.
).await
``` ---
### File Move Operations ## Benefits
```rust 1. **ACID transactions** — metadata operations are atomic, consistent, isolated, and durable
// Move the file physically with fsync 2. **Content-addressable storage** — identical content is stored once, referenced by hash
time::timeout( 3. **Crash resilience** — atomic blob writes + PostgreSQL WAL ensure recovery
self.config.timeouts.file_timeout(), 4. **No partial writes** — temp file + rename pattern guarantees all-or-nothing
FileSystemUtils::rename_with_sync(&old_abs_path, &new_abs_path) 5. **Referential integrity** — foreign keys prevent orphaned metadata
).await
``` ---
### Directory Creation ## Performance Considerations
```rust - PostgreSQL connection pooling (`sqlx::PgPool`) amortizes connection overhead
// Ensure the parent directory exists with proper syncing - Dedup hash computation is CPU-bound but avoids unnecessary disk writes for duplicate content
self.ensure_parent_directory(&abs_path).await?; - Blob fsync adds latency vs. buffered writes, but ensures durability for critical user data
- Content cache (in-memory LRU) serves repeat reads without disk or DB access
// Implementation uses FileSystemUtils
async fn ensure_parent_directory(&self, abs_path: &PathBuf) -> FileRepositoryResult<()> {
if let Some(parent) = abs_path.parent() {
time::timeout(
self.config.timeouts.dir_timeout(),
FileSystemUtils::create_dir_with_sync(parent)
).await
}
}
```
---
## Benefits
1. **Data durability** -- critical data is synced to persistent storage
2. **Crash resilience** -- recovery from unexpected failures without data loss
3. **Consistency** -- file operations maintain a consistent filesystem state
4. **Atomic operations** -- file writes appear as all-or-nothing
---
## Performance Considerations
Syncing to disk costs more than buffered writes. OxiCloud mitigates this by:
1. Applying these measures only to critical operations
2. Using timeouts to prevent indefinite blocking
3. Implementing parallel processing for large files
The tradeoff favors safety for critical data while maintaining good performance for most operations.
+2 -2
View File
@@ -462,7 +462,7 @@ src/
│ └── delta_sync_handler.rs # NUEVO: Endpoints API │ └── delta_sync_handler.rs # NUEVO: Endpoints API
│ │
└── common/ └── common/
└── di.rs # Añadir: delta_sync_service a CoreServices └── di.rs # Añadir: delta_sync_service a AppState
``` ```
### Main service (delta_sync_service.rs) ### Main service (delta_sync_service.rs)
@@ -1133,7 +1133,7 @@ thiserror = "1.0" # Para errores tipados (probablemente ya existe)
- [ ] Implement **generate_delta()** - [ ] Implement **generate_delta()**
- [ ] Implement **apply_delta()** - [ ] Implement **apply_delta()**
- [ ] Create handler and API endpoints - [ ] Create handler and API endpoints
- [ ] Integrate into DI (**CoreServices**) - [ ] Integrate into DI (**AppState**)
- [ ] Add routes in `routes.rs` - [ ] Add routes in `routes.rs`
- [ ] Integrate with upload (automatic indexing) - [ ] Integrate with upload (automatic indexing)
- [ ] Integrate with delete (signature cleanup) - [ ] Integrate with delete (signature cleanup)
+474 -477
View File
@@ -1,477 +1,474 @@
# 01 - Internal Architecture # 01 - Internal Architecture
OxiCloud follows a **hexagonal (ports & adapters) architecture** organized in four layers: OxiCloud follows a **hexagonal (ports & adapters) architecture** organized in four layers:
``` ```
Domain → Application → Infrastructure → Interfaces Domain → Application → Infrastructure → Interfaces
``` ```
All cross-layer dependencies point inward via trait-based ports. The DI container (**AppServiceFactory**) wires concrete implementations at startup. All cross-layer dependencies point inward via trait-based ports. The DI container (**AppServiceFactory**) wires concrete implementations at startup.
--- ---
## Dependency Injection Container ## Storage Model: 100% Blob Storage
### AppServiceFactory OxiCloud uses a **100% blob storage model** where:
**File:** `src/common/di.rs` - **File metadata** (name, folder, size, user, timestamps, trash status) is stored in **PostgreSQL** (`storage.files` table).
- **File content** is stored as content-addressed blobs via **DedupService** at `.blobs/{prefix}/{hash}.blob`.
```rust - **Folder structure** is purely virtual — represented as rows in `storage.folders` (no filesystem directories per user).
pub struct AppServiceFactory { - **Trash** is a soft-delete flag (`is_trashed`, `trashed_at`) on files and folders, exposed via `storage.trash_items` VIEW.
storage_path: PathBuf,
locales_path: PathBuf, There are no filesystem-based ID mappings, no `folder_ids.json`/`file_ids.json`, and no storage mediator.
config: AppConfig,
} ---
```
## Dependency Injection Container
Initialization order in `build_app_state()`:
### AppServiceFactory
1. **Core services** -- path, caches, ID mapping, thumbnail, write-behind, chunked upload, transcode, dedup, compression
2. **Repository services** -- folder repo (stub mediator first), then **FileSystemStorageMediator** (real), file repos, metadata cache, buffer pool **File:** `src/common/di.rs`
3. **Trash service** (if **enable_trash** enabled)
4. **Application services** -- folder, file upload/retrieval/management, search, i18n ```rust
5. **Share service** (if **enable_file_sharing** enabled) pub struct AppServiceFactory {
6. **DB-dependent services** -- favorites, recent, storage usage, auth (via **auth_factory**) storage_path: PathBuf,
7. **Preload** translations + metadata cache locales_path: PathBuf,
8. **ZIP service** (needs file retrieval + folder service, wired last) config: AppConfig,
9. **Assemble AppState** + admin settings + CalDAV/CardDAV }
```
### AppState (Global State)
Initialization order in `build_app_state()`:
```rust
pub struct AppState { 1. **Core services** — path, content cache, thumbnail, chunked upload, transcode, dedup, compression
pub core: CoreServices, 2. **Repository services** — `FolderDbRepository`, `FileBlobReadRepository`, `FileBlobWriteRepository`, `TrashDbRepository` (all PgPool-backed)
pub repositories: RepositoryServices, 3. **Trash service** (if **enable_trash** enabled)
pub applications: ApplicationServices, 4. **Application services** — folder, file upload/retrieval/management, search, i18n
pub db_pool: Option<Arc<PgPool>>, 5. **Share service** (if **enable_file_sharing** enabled)
pub auth_service: Option<AuthServices>, 6. **DB-dependent services** — favorites, recent, storage usage, auth (via **auth_factory**)
pub admin_settings_service: Option<Arc<AdminSettingsService>>, 7. **Preload** translations
pub trash_service: Option<Arc<dyn TrashUseCase>>, 8. **ZIP service** (needs file retrieval + folder service, wired last)
pub share_service: Option<Arc<dyn ShareUseCase>>, 9. **Assemble AppState** + admin settings + CalDAV/CardDAV
pub favorites_service: Option<Arc<dyn FavoritesUseCase>>,
pub recent_service: Option<Arc<dyn RecentItemsUseCase>>, ### AppState (Global State)
pub storage_usage_service: Option<Arc<dyn StorageUsagePort>>,
pub calendar_service: Option<Arc<dyn StorageUseCase>>, ```rust
pub contact_service: Option<Arc<dyn StorageUseCase>>, pub struct AppState {
pub calendar_use_case: Option<Arc<dyn CalendarUseCase>>, pub core: CoreServices,
pub addressbook_use_case: Option<Arc<dyn AddressBookUseCase>>, pub repositories: RepositoryServices,
pub contact_use_case: Option<Arc<dyn ContactUseCase>>, pub applications: ApplicationServices,
} pub db_pool: Option<Arc<PgPool>>,
``` pub auth_service: Option<AuthServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
Builder pattern: `new()` → `with_database()` → `with_auth_services()` → `with_trash_service()` → ... → `for_routing()`. The `Default` impl uses stubs from `crate::common::stubs`. pub trash_service: Option<Arc<dyn TrashUseCase>>,
pub share_service: Option<Arc<dyn ShareUseCase>>,
### Service Groups pub favorites_service: Option<Arc<dyn FavoritesUseCase>>,
pub recent_service: Option<Arc<dyn RecentItemsUseCase>>,
```rust pub storage_usage_service: Option<Arc<dyn StorageUsagePort>>,
pub struct CoreServices { pub calendar_service: Option<Arc<dyn StorageUseCase>>,
pub path_service: Arc<PathService>, pub contact_service: Option<Arc<dyn StorageUseCase>>,
pub file_content_cache: Arc<dyn ContentCachePort>, pub calendar_use_case: Option<Arc<dyn CalendarUseCase>>,
pub id_mapping_service: Arc<dyn IdMappingPort>, // folder IDs pub addressbook_use_case: Option<Arc<dyn AddressBookUseCase>>,
pub file_id_mapping_service: Arc<IdMappingService>, // file IDs (concrete) pub contact_use_case: Option<Arc<dyn ContactUseCase>>,
pub id_mapping_optimizer: Arc<IdMappingOptimizer>, }
pub thumbnail_service: Arc<dyn ThumbnailPort>, ```
pub write_behind_cache: Arc<dyn WriteBehindCachePort>,
pub chunked_upload_service: Arc<dyn ChunkedUploadPort>, Builder pattern: `new()` → `with_database()` → `with_auth_services()` → `with_trash_service()` → ... → `for_routing()`. The `Default` impl uses stubs from `crate::common::stubs`.
pub image_transcode_service: Arc<dyn ImageTranscodePort>,
pub dedup_service: Arc<dyn DedupPort>, ### Service Groups
pub compression_service: Arc<dyn CompressionPort>,
pub zip_service: Arc<dyn ZipPort>, ```rust
pub config: AppConfig, pub struct CoreServices {
} pub path_service: Arc<PathService>,
pub file_content_cache: Arc<dyn ContentCachePort>,
pub struct RepositoryServices { pub thumbnail_service: Arc<dyn ThumbnailPort>,
pub folder_repository: Arc<dyn FolderStoragePort>, pub chunked_upload_service: Arc<dyn ChunkedUploadPort>,
pub file_read_repository: Arc<dyn FileReadPort>, pub image_transcode_service: Arc<dyn ImageTranscodePort>,
pub file_write_repository: Arc<dyn FileWritePort>, pub dedup_service: Arc<dyn DedupPort>,
pub i18n_repository: Arc<dyn I18nService>, pub compression_service: Arc<dyn CompressionPort>,
pub storage_mediator: Arc<dyn StorageMediator>, pub zip_service: Arc<dyn ZipPort>,
pub metadata_cache: Arc<FileMetadataCache>, pub config: AppConfig,
pub trash_repository: Option<Arc<dyn TrashRepository>>, }
}
pub struct RepositoryServices {
pub struct ApplicationServices { pub folder_repository: Arc<dyn FolderStoragePort>,
pub folder_service_concrete: Arc<FolderService>, pub folder_repo_concrete: Arc<FolderDbRepository>,
pub folder_service: Arc<dyn FolderUseCase>, pub file_read_repository: Arc<dyn FileReadPort>,
pub file_upload_service: Arc<dyn FileUploadUseCase>, pub file_write_repository: Arc<dyn FileWritePort>,
pub file_retrieval_service: Arc<dyn FileRetrievalUseCase>, pub i18n_repository: Arc<dyn I18nService>,
pub file_management_service: Arc<dyn FileManagementUseCase>, pub trash_repository: Option<Arc<dyn TrashRepository>>,
pub file_use_case_factory: Arc<dyn FileUseCaseFactory>, }
pub i18n_service: Arc<I18nApplicationService>,
pub trash_service: Option<Arc<dyn TrashUseCase>>, pub struct ApplicationServices {
pub search_service: Option<Arc<dyn SearchUseCase>>, pub folder_service_concrete: Arc<FolderService>,
pub share_service: Option<Arc<dyn ShareUseCase>>, pub folder_service: Arc<dyn FolderUseCase>,
pub favorites_service: Option<Arc<dyn FavoritesUseCase>>, pub file_upload_service: Arc<dyn FileUploadUseCase>,
pub recent_service: Option<Arc<dyn RecentItemsUseCase>>, pub file_retrieval_service: Arc<dyn FileRetrievalUseCase>,
} pub file_management_service: Arc<dyn FileManagementUseCase>,
pub file_use_case_factory: Arc<dyn FileUseCaseFactory>,
pub struct AuthServices { pub i18n_service: Arc<I18nApplicationService>,
pub token_service: Arc<dyn TokenServicePort>, pub trash_service: Option<Arc<dyn TrashUseCase>>,
pub auth_application_service: Arc<AuthApplicationService>, pub search_service: Option<Arc<dyn SearchUseCase>>,
} pub share_service: Option<Arc<dyn ShareUseCase>>,
``` pub favorites_service: Option<Arc<dyn FavoritesUseCase>>,
pub recent_service: Option<Arc<dyn RecentItemsUseCase>>,
--- }
## ID Mapping System pub struct AuthServices {
pub token_service: Arc<dyn TokenServicePort>,
Maps bidirectionally between **filesystem StoragePaths** and **UUID identifiers**. Two separate instances exist: one for folders (`folder_ids.json`), one for files (`file_ids.json`). pub auth_application_service: Arc<AuthApplicationService>,
}
### StoragePath (Domain Value Object) ```
**File:** `src/domain/services/path_service.rs` ---
```rust ## Database Schema (Storage)
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StoragePath { All file and folder metadata lives in the `storage` PostgreSQL schema:
segments: Vec<String>, // e.g., ["Mi Carpeta - admin", "file.txt"]
} ```sql
``` CREATE SCHEMA IF NOT EXISTS storage;
| Method | Description | CREATE TABLE storage.folders (
|---|---| id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
| `root()` | Empty path (storage root) | name TEXT NOT NULL,
| `from_string(path)` | Parse from `/`-delimited string | parent_id UUID REFERENCES storage.folders(id) ON DELETE CASCADE,
| `join(segment)` | Append a segment | user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id),
| `file_name()` | Last segment | is_trashed BOOLEAN NOT NULL DEFAULT FALSE,
| `parent()` | All segments except last | trashed_at TIMESTAMPTZ,
| `to_string()` | Join segments with `/` | original_parent_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
### IdMappingPort (Application Port) updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
**File:** `src/application/ports/outbound.rs`
CREATE TABLE storage.files (
```rust id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
#[async_trait] name TEXT NOT NULL,
pub trait IdMappingPort: Send + Sync + 'static { folder_id UUID NOT NULL REFERENCES storage.folders(id) ON DELETE CASCADE,
async fn get_or_create_id(&self, path: &StoragePath) -> Result<String, DomainError>; user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id),
async fn get_path_by_id(&self, id: &str) -> Result<StoragePath, DomainError>; blob_hash TEXT NOT NULL,
async fn update_path(&self, id: &str, new_path: &StoragePath) -> Result<(), DomainError>; size BIGINT NOT NULL DEFAULT 0,
async fn remove_id(&self, id: &str) -> Result<(), DomainError>; mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
async fn save_changes(&self) -> Result<(), DomainError>; is_trashed BOOLEAN NOT NULL DEFAULT FALSE,
// Default impls for PathBuf variants: trashed_at TIMESTAMPTZ,
async fn get_file_path(&self, file_id: &str) -> Result<PathBuf, DomainError>; original_folder_id UUID,
async fn update_file_path(&self, file_id: &str, new_path: &PathBuf) -> Result<(), DomainError>; created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
} updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
``` );
### IdMappingService (Base Implementation) CREATE OR REPLACE VIEW storage.trash_items AS
SELECT id, name, 'file' AS item_type, folder_id AS parent_id,
**File:** `src/infrastructure/services/id_mapping_service.rs` user_id, size, mime_type, trashed_at, created_at
FROM storage.files WHERE is_trashed = TRUE
```rust UNION ALL
pub struct IdMappingService { SELECT id, name, 'folder' AS item_type, parent_id,
map_path: PathBuf, // e.g., storage/file_ids.json user_id, 0 AS size, NULL AS mime_type, trashed_at, created_at
id_map: RwLock<IdMap>, FROM storage.folders WHERE is_trashed = TRUE;
save_mutex: Mutex<()>, ```
timeouts: TimeoutConfig,
pending_save: RwLock<bool>, ---
}
## Repository Layer (Infrastructure)
struct IdMap {
path_to_id: HashMap<String, String>, All repositories use **PgPool** for metadata and **DedupService** for blob content.
id_to_path: HashMap<String, String>,
version: u32, ### FolderDbRepository
}
``` **File:** `src/infrastructure/repositories/pg/folder_db_repository.rs`
Operations: ```rust
- `get_or_create_id(path)` -- Read-lock first (cache hit). Write-lock on miss, generates `Uuid::new_v4()`. pub struct FolderDbRepository {
- `save_pending_changes()` -- Atomic write: serialize → write `.tmp` file → rename over original (with retry). pool: Option<Arc<PgPool>>,
- `new(map_path)` -- Loads from JSON, rebuilds inverse map if inconsistent. }
```
Persistence format (`storage/file_ids.json`):
```json Implements `FolderRepository`. Uses recursive CTEs for path building, unique constraints for name dedup within parent, and soft-delete flags for trash operations.
{
"path_to_id": { "/Mi Carpeta - admin/doc.pdf": "a1b2c3d4-..." }, Key methods: `create_folder`, `get_folder`, `get_folder_by_path`, `list_folders`, `rename_folder`, `move_folder`, `delete_folder`, `move_to_trash`, `restore_from_trash`, `create_home_folder`, `get_folder_user_id`.
"id_to_path": { "a1b2c3d4-...": "/Mi Carpeta - admin/doc.pdf" },
"version": 42 `new_stub()` creates a pool-less instance for `AppState::default()`.
}
``` ### FileBlobReadRepository
### IdMappingOptimizer (Cache Layer) **File:** `src/infrastructure/repositories/pg/file_blob_read_repository.rs`
**File:** `src/infrastructure/services/id_mapping_optimizer.rs` ```rust
pub struct FileBlobReadRepository {
Wraps **IdMappingService** with an in-memory TTL cache: pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
| Parameter | Value | folder_repo: Arc<FolderDbRepository>,
|---|---| }
| Max cache entries | 10,000 | ```
| TTL | 300 s (5 min) |
| Cleanup interval | 150 s (2.5 min) | Implements `FileReadPort`. Reads metadata from `storage.files` and content from blob store via `dedup.read_blob()` / `read_blob_bytes()`.
| Batch threshold | ≥ 20 queued items |
| Max concurrent batches | 2 (semaphore) | Key methods: `get_file`, `list_files`, `get_file_content`, `get_file_stream`, `get_file_range_stream`, `get_file_mmap`, `get_file_path`, `get_parent_folder_id`.
```rust ### FileBlobWriteRepository
pub struct IdMappingOptimizer {
base_service: Arc<IdMappingService>, **File:** `src/infrastructure/repositories/pg/file_blob_write_repository.rs`
path_to_id_cache: RwLock<HashMap<String, (String, Instant)>>,
id_to_path_cache: RwLock<HashMap<String, (String, Instant)>>, ```rust
stats: RwLock<OptimizerStats>, pub struct FileBlobWriteRepository {
batch_limiter: Semaphore, pool: Arc<PgPool>,
pending_batch: Mutex<BatchQueue>, dedup: Arc<dyn DedupPort>,
} folder_repo: Arc<FolderDbRepository>,
``` }
```
Lookup flow: check cache → if miss, queue request → trigger batch if ≥ 20 pending → fallback to **base_service** → update cache.
Implements `FileWritePort`. Stores content via `dedup.store_bytes()` (returns hash), then INSERTs metadata into `storage.files`.
On `update_path` / `remove_id`, cache entries are invalidated first, then delegated.
Key methods: `save_file`, `save_file_from_stream`, `move_file`, `rename_file`, `delete_file`, `update_file_content`, `move_to_trash`, `restore_from_trash`, `delete_file_permanently`.
Used only for folder ID mapping. File ID mapping uses the base **IdMappingService** directly.
### TrashDbRepository
---
**File:** `src/infrastructure/repositories/pg/trash_db_repository.rs`
## Path Service
```rust
**File:** `src/infrastructure/services/path_service.rs` pub struct TrashDbRepository {
pool: Arc<PgPool>,
```rust retention_days: u32,
pub struct PathService { }
root_path: PathBuf, // e.g., ./storage ```
}
``` Implements `TrashRepository`. Reads from `storage.trash_items` VIEW. `clear_trash` DELETEs rows where `is_trashed = TRUE`. `get_expired_items` checks `trashed_at` against the configured retention period.
### Path Resolution ---
| Method | Description | ## Path Service
|---|---|
| `resolve_path(storage_path)` | Appends **StoragePath** segments to **root_path** → absolute `PathBuf` | **File:** `src/infrastructure/services/path_service.rs`
| `to_storage_path(physical_path)` | Strips **root_path** prefix → **StoragePath** (returns `None` if outside root) |
| `create_file_path(folder, name)` | Combines folder path + filename | ```rust
| `is_direct_child(parent, child)` | Check parent-child relationship | pub struct PathService {
| `is_in_root(path)` | Verify path is within storage root | root_path: PathBuf, // e.g., ./storage
}
### Path Validation ```
`validate_path(path)` rejects: Used for resolving storage root paths (blob storage directory, thumbnail paths, etc.). Not used for per-user folder resolution — that is handled by `FolderDbRepository` via PostgreSQL.
- Empty path segments
- Segments containing dangerous characters: `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|` ### StoragePath (Domain Value Object)
- Segments starting with `.` (exception: `.well-known` for WebDAV/CalDAV/CardDAV)
**File:** `src/domain/services/path_service.rs`
### Trait Implementations
```rust
- **StoragePort** -- `resolve_path()`, `ensure_directory()` (validates first, then `fs::create_dir_all`), `file_exists()`, `directory_exists()` #[derive(Debug, Clone, PartialEq, Eq, Default)]
- **StorageMediator** -- Simplified stub variant. Folder lookup methods return `NotFound`. pub struct StoragePath {
segments: Vec<String>,
--- }
```
## Storage Mediator
| Method | Description |
Bridges folder IDs to filesystem paths by combining folder repository, path service, and ID mapping. |---|---|
| `root()` | Empty path (storage root) |
### StorageMediator Trait | `from_string(path)` | Parse from `/`-delimited string |
| `join(segment)` | Append a segment |
**File:** `src/application/services/storage_mediator.rs` | `file_name()` | Last segment |
| `parent()` | All segments except last |
```rust | `to_string()` | Join segments with `/` |
#[async_trait]
pub trait StorageMediator: Send + Sync + 'static { ### Trait Implementations
async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult<PathBuf>;
async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult<StoragePath>; - **StoragePort** — `resolve_path()`, `ensure_directory()`, `file_exists()`, `directory_exists()`
async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult<Folder>;
async fn file_exists_at_path(&self, path: &Path) -> StorageMediatorResult<bool>; ---
async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult<bool>;
async fn folder_exists_at_path(&self, path: &Path) -> StorageMediatorResult<bool>; ## Session Management
async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult<bool>;
fn resolve_path(&self, relative_path: &Path) -> PathBuf; ### Session Entity
fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf;
async fn ensure_directory(&self, path: &Path) -> StorageMediatorResult<()>; **File:** `src/domain/entities/session.rs`
async fn ensure_storage_directory(&self, storage_path: &StoragePath) -> StorageMediatorResult<()>;
} ```rust
``` pub struct Session {
id: String, // UUID v4
### FileSystemStorageMediator user_id: String,
refresh_token: String,
```rust expires_at: DateTime<Utc>,
pub struct FileSystemStorageMediator { ip_address: Option<String>,
pub folder_storage_port: Arc<dyn FolderStoragePort>, user_agent: Option<String>,
pub path_service: Arc<dyn StoragePort>, created_at: DateTime<Utc>,
pub id_mapping: Arc<dyn IdMappingPort>, revoked: bool,
} }
``` ```
Folder ID → filesystem path resolution: Constructors:
- `Session::new(user_id, refresh_token, ip_address, user_agent, expires_in_days)` — generates UUID, panics if **user_id** or **refresh_token** empty
``` - `Session::from_raw(...)` — for DB reconstruction
folder_id ──► FolderStoragePort.get_folder(id)
──► Folder entity ### SessionRepository (Domain Port)
──► IdMappingPort.get_path_by_id(folder.id())
──► StoragePath **File:** `src/domain/repositories/session_repository.rs`
──► StoragePort.resolve_path(storage_path)
──► PathBuf (absolute) ```rust
``` #[async_trait]
pub trait SessionRepository: Send + Sync + 'static {
A **StubStorageMediator** also exists (returns `/tmp` paths) for DI bootstrap before the real mediator is available. async fn create_session(&self, session: Session) -> SessionRepositoryResult<Session>;
async fn get_session_by_id(&self, id: &str) -> SessionRepositoryResult<Session>;
--- async fn get_session_by_refresh_token(&self, token: &str) -> SessionRepositoryResult<Session>;
async fn get_sessions_by_user_id(&self, user_id: &str) -> SessionRepositoryResult<Vec<Session>>;
## Session Management async fn revoke_session(&self, session_id: &str) -> SessionRepositoryResult<()>;
async fn revoke_all_user_sessions(&self, user_id: &str) -> SessionRepositoryResult<u64>;
### Session Entity async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64>;
}
**File:** `src/domain/entities/session.rs` ```
```rust ### SessionStoragePort (Application Port)
pub struct Session {
id: String, // UUID v4 **File:** `src/application/ports/auth_ports.rs`
user_id: String,
refresh_token: String, ```rust
expires_at: DateTime<Utc>, #[async_trait]
ip_address: Option<String>, pub trait SessionStoragePort: Send + Sync + 'static {
user_agent: Option<String>, async fn create_session(&self, session: Session) -> Result<Session, DomainError>;
created_at: DateTime<Utc>, async fn get_session_by_refresh_token(&self, token: &str) -> Result<Session, DomainError>;
revoked: bool, async fn revoke_session(&self, session_id: &str) -> Result<(), DomainError>;
} async fn revoke_all_user_sessions(&self, user_id: &str) -> Result<u64, DomainError>;
``` }
```
Constructors:
- `Session::new(user_id, refresh_token, ip_address, user_agent, expires_in_days)` -- generates UUID, panics if **user_id** or **refresh_token** empty ### SessionPgRepository (Infrastructure)
- `Session::from_raw(...)` -- for DB reconstruction
**File:** `src/infrastructure/repositories/pg/session_pg_repository.rs`
### SessionRepository (Domain Port)
```rust
**File:** `src/domain/repositories/session_repository.rs` pub struct SessionPgRepository {
pool: Arc<PgPool>,
```rust }
#[async_trait] ```
pub trait SessionRepository: Send + Sync + 'static {
async fn create_session(&self, session: Session) -> SessionRepositoryResult<Session>; Implements both **SessionRepository** and **SessionStoragePort**. Uses `with_transaction()` helper for write operations. `create_session` also updates `auth.users.last_login_at` within the same transaction.
async fn get_session_by_id(&self, id: &str) -> SessionRepositoryResult<Session>;
async fn get_session_by_refresh_token(&self, token: &str) -> SessionRepositoryResult<Session>; ### Database Schema
async fn get_sessions_by_user_id(&self, user_id: &str) -> SessionRepositoryResult<Vec<Session>>;
async fn revoke_session(&self, session_id: &str) -> SessionRepositoryResult<()>; ```sql
async fn revoke_all_user_sessions(&self, user_id: &str) -> SessionRepositoryResult<u64>; CREATE TABLE IF NOT EXISTS auth.sessions (
async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64>; id VARCHAR(36) PRIMARY KEY,
} user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
``` refresh_token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
### SessionStoragePort (Application Port) ip_address TEXT,
user_agent TEXT,
**File:** `src/application/ports/auth_ports.rs` created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked BOOLEAN NOT NULL DEFAULT FALSE
```rust );
#[async_trait]
pub trait SessionStoragePort: Send + Sync + 'static { CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id);
async fn create_session(&self, session: Session) -> Result<Session, DomainError>; CREATE INDEX idx_sessions_refresh_token ON auth.sessions(refresh_token);
async fn get_session_by_refresh_token(&self, token: &str) -> Result<Session, DomainError>; CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at);
async fn revoke_session(&self, session_id: &str) -> Result<(), DomainError>; CREATE INDEX idx_sessions_active ON auth.sessions(user_id, revoked)
async fn revoke_all_user_sessions(&self, user_id: &str) -> Result<u64, DomainError>; WHERE NOT revoked AND is_session_active(expires_at);
} ```
```
### Auth Service
### SessionPgRepository (Infrastructure)
**File:** `src/application/services/auth_application_service.rs`
**File:** `src/infrastructure/repositories/pg/session_pg_repository.rs`
**AuthApplicationService** orchestrates authentication using:
```rust - **UserStoragePort** — user CRUD
pub struct SessionPgRepository { - **SessionStoragePort** — session lifecycle
pool: Arc<PgPool>, - **PasswordHasherPort** — Argon2id hashing
} - **TokenServicePort** — JWT generation/validation
``` - `RwLock<OidcState>` — hot-reloadable OIDC configuration
- `Mutex<HashMap<String, PendingOidcFlow>>` — in-flight OIDC login states
Implements both **SessionRepository** and **SessionStoragePort**. Uses `with_transaction()` helper for write operations. `create_session` also updates `auth.users.last_login_at` within the same transaction.
Wired by `auth_factory.rs`: **UserPgRepository** + **SessionPgRepository** + **Argon2PasswordHasher** + **JwtTokenService** → **AuthApplicationService**.
### Database Schema
---
```sql
CREATE TABLE IF NOT EXISTS auth.sessions ( ## File Use Case Factory
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, **File:** `src/application/services/file_use_case_factory.rs`
refresh_token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL, ```rust
ip_address TEXT, pub trait FileUseCaseFactory: Send + Sync + 'static {
user_agent TEXT, fn create_file_upload_use_case(&self) -> Arc<dyn FileUploadUseCase>;
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, fn create_file_retrieval_use_case(&self) -> Arc<dyn FileRetrievalUseCase>;
revoked BOOLEAN NOT NULL DEFAULT FALSE fn create_file_management_use_case(&self) -> Arc<dyn FileManagementUseCase>;
); }
```
-- Indexes
CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id); **AppFileUseCaseFactory** creates lightweight service instances with only **FileReadPort** / **FileWritePort**.
CREATE INDEX idx_sessions_refresh_token ON auth.sessions(refresh_token);
CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at); ### File Operation Port Hierarchy
CREATE INDEX idx_sessions_active ON auth.sessions(user_id, revoked)
WHERE NOT revoked AND is_session_active(expires_at); | Port | Key Methods |
``` |---|---|
| **FileUploadUseCase** | `upload_file()`, `smart_upload()` (returns **UploadStrategy**: `Buffered` <1MB, `Streaming` ≥1MB), `create_file()`, `update_file()` |
### Auth Service | **FileRetrievalUseCase** | `get_file()`, `get_file_content()`, `get_file_stream()`, `get_file_optimized()` (content-cache → WebP transcode → mmap → streaming), `get_file_range_stream()` |
| **FileManagementUseCase** | `move_file()`, `rename_file()`, `delete_file()`, `delete_with_cleanup()` (trash-first with dedup reference cleanup) |
**File:** `src/application/services/auth_application_service.rs`
---
**AuthApplicationService** orchestrates authentication using:
- **UserStoragePort** -- user CRUD ## Architecture Diagram
- **SessionStoragePort** -- session lifecycle
- **PasswordHasherPort** -- Argon2id hashing ```
- **TokenServicePort** -- JWT generation/validation ┌─────────────────────────────────────────────────────────────┐
- `RwLock<OidcState>` -- hot-reloadable OIDC configuration │ Interfaces Layer │
- `Mutex<HashMap<String, PendingOidcFlow>>` -- in-flight OIDC login states │ Axum Router → API Routes + Middleware (Auth, Compress) │
└─────────────────────┬───────────────────────────────────────┘
Wired by `auth_factory.rs`: **UserPgRepository** + **SessionPgRepository** + **Argon2PasswordHasher** + **JwtTokenService** → **AuthApplicationService**. │ Arc<AppState>
┌─────────────────────▼───────────────────────────────────────┐
--- │ Application Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
## File Use Case Factory │ │ FileUpload │ │ FolderService│ │ AuthApplication │ │
│ │ FileRetrieval│ │ SearchService│ │ AdminSettings │ │
**File:** `src/application/services/file_use_case_factory.rs` │ │ FileMgmt │ │ I18nService │ │ TrashService │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬──────────┘ │
```rust │ │ Ports (traits) │ │ │
pub trait FileUseCaseFactory: Send + Sync + 'static { └─────────┼────────────────┼─────────────────────┼────────────┘
fn create_file_upload_use_case(&self) -> Arc<dyn FileUploadUseCase>; │ │ │
fn create_file_retrieval_use_case(&self) -> Arc<dyn FileRetrievalUseCase>; ┌─────────▼────────────────▼─────────────────────▼────────────┐
fn create_file_management_use_case(&self) -> Arc<dyn FileManagementUseCase>; │ Infrastructure Layer │
} │ ┌────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
``` │ │ FileBlobRead │ │ PathService │ │ SessionPg │ │
│ │ FileBlobWrite │ │ DedupService │ │ UserPg │ │
**AppFileUseCaseFactory** creates lightweight service instances with only **FileReadPort** / **FileWritePort**. The main DI-wired services use `*_full()` constructors that inject write-behind cache, dedup, content cache, and transcode ports for full optimization. │ │ FolderDb │ │ Thumbnail │ │ JwtTokenService │ │
│ │ TrashDb │ │ Transcode │ │ Argon2Hasher │ │
### File Operation Port Hierarchy │ └────────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
| Port | Key Methods | │ │ ContentCache │ │ Compression │ │ ChunkedUpload │ │
|---|---| │ │ BufferPool │ │ ZipService │ │ ShareFsRepo │ │
| **FileUploadUseCase** | `upload_file()`, `smart_upload()` (returns **UploadStrategy**: `WriteBehind` <256KB, `Buffered` 256KB-1MB, `Streaming` ≥1MB), `create_file()`, `update_file()` | │ └────────────────┘ └──────────────┘ └──────────────────┘ │
| **FileRetrievalUseCase** | `get_file()`, `get_file_content()`, `get_file_stream()`, `get_file_optimized()` (write-behind → content-cache → WebP transcode → mmap → streaming), `get_file_range_stream()` | └─────────────────────────────────────────────────────────────┘
| **FileManagementUseCase** | `move_file()`, `rename_file()`, `delete_file()`, `delete_with_cleanup()` (trash-first with dedup reference cleanup) | │
┌─────────────────────▼───────────────────────────────────────┐
--- │ Domain Layer │
│ Entities: File, Folder, Session, User, Calendar, Contact │
## Architecture Diagram │ Value Objects: StoragePath │
│ Repository Traits: FolderRepository, TrashRepository, ... │
``` │ Domain Errors │
┌─────────────────────────────────────────────────────────────┐ └─────────────────────────────────────────────────────────────┘
│ Interfaces Layer │ ```
│ Axum Router → API Routes + Middleware (Auth, Compress) │
└─────────────────────┬───────────────────────────────────────┘ ### Data Flow: File Upload
│ Arc<AppState>
┌─────────────────────▼───────────────────────────────────────┐ ```
│ Application Layer │ HTTP Request (multipart)
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ → FileUploadService.smart_upload()
│ │ FileUpload │ │ FolderService│ │ AuthApplication │ │ → FileBlobWriteRepository.save_file() / save_file_from_stream()
│ │ FileRetrieval│ │ SearchService│ │ AdminSettings │ │ → DedupService.store_bytes() → .blobs/{prefix}/{hash}.blob
│ │ FileMgmt │ │ I18nService │ │ TrashService │ │ → INSERT INTO storage.files (name, folder_id, blob_hash, size, ...)
│ └──────┬───────┘ └──────┬───────┘ └──────────┬──────────┘ │ → 201 Created (FileDto)
│ │ Ports (traits) │ │ │ ```
└─────────┼────────────────┼─────────────────────┼────────────┘
│ │ │ ### Data Flow: File Download
┌─────────▼────────────────▼─────────────────────▼────────────┐
│ Infrastructure Layer │ ```
│ ┌────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ HTTP Request (GET /api/files/{id}/download)
│ │ FileFsRead/ │ │ IdMapping │ │ SessionPg │ │ → FileRetrievalService.get_file_optimized()
│ │ FileFsWrite │ │ + Optimizer │ │ UserPg │ │ → ContentCache hit? → serve from RAM
│ │ FolderFs │ │ PathService │ │ JwtTokenService │ │ → FileBlobReadRepository.get_file_content() / get_file_stream()
│ │ TrashFs │ │ StorageMed. │ │ Argon2Hasher │ │ → SELECT blob_hash FROM storage.files WHERE id = $1
│ └────────────────┘ └──────────────┘ └──────────────────┘ │ → DedupService.read_blob(hash) → bytes from .blobs/
│ ┌────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ → Optional WebP transcode → response
│ │ ContentCache │ │ Thumbnail │ │ WriteBehind │ │ ```
│ │ MetadataCache │ │ Transcode │ │ BufferPool │ │
│ │ BufferPool │ │ Dedup │ │ Compression │ │ ### Data Flow: Folder Operations
│ └────────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘ ```
│ HTTP Request (POST /api/folders)
┌─────────────────────▼───────────────────────────────────────┐ → FolderService.create_folder()
│ Domain Layer │ → FolderDbRepository.create_folder()
│ Entities: File, Folder, Session, User, Calendar, Contact │ → INSERT INTO storage.folders (name, parent_id, user_id, ...)
│ Value Objects: StoragePath │ → 201 Created (FolderDto)
│ Repository Traits: SessionRepository, ... │ ```
│ Domain Errors │
└─────────────────────────────────────────────────────────────┘
```
+31 -24
View File
@@ -134,7 +134,7 @@ Handles: shared element validation, permission management, unique link/token gen
## Infrastructure ## Infrastructure
**ShareFsRepository** (`src/infrastructure/repositories/share_fs_repository.rs`) persists share links to the filesystem: **ShareFsRepository** (`src/infrastructure/repositories/share_fs_repository.rs`) persists share link metadata to a local JSON file:
```rust ```rust
pub struct ShareFsRepository { pub struct ShareFsRepository {
@@ -157,7 +157,9 @@ struct ShareRecord {
} }
``` ```
Stores shared links in a JSON file. Supports queries and updates, search by ID/token/user, and pagination. Stores share link records in a JSON file. Supports queries and updates, search by ID/token/user, and pagination.
> **Note:** This repository stores *share link metadata* only (tokens, permissions, expiration). The actual file/folder content is accessed via `FileReadPort` / `FolderStoragePort` which use the blob storage model (PostgreSQL metadata + DedupService blobs).
## API Handlers and Routes ## API Handlers and Routes
@@ -256,26 +258,30 @@ The service is instantiated via **AppServiceFactory** in `src/common/di.rs` and
```rust ```rust
// In AppServiceFactory::create_share_service() // In AppServiceFactory::create_share_service()
let share_service: Option<Arc<dyn ShareUseCase>> = if config.features.enable_file_sharing { pub fn create_share_service(&self, repos: &RepositoryServices)
let share_repository = Arc::new(ShareFsRepository::new(Arc::new(config.clone()))); -> Option<Arc<dyn ShareUseCase>>
let share_service = Arc::new(ShareService::new( {
Arc::new(config.clone()), if !self.config.features.enable_file_sharing {
share_repository, return None;
file_read_repository.clone(), }
folder_repository.clone(),
password_hasher.clone(),
));
Some(share_service)
} else {
None
};
// Add to AppState let share_repository = Arc::new(ShareFsRepository::new(
let app_state = AppState { Arc::new(self.config.clone())
// ... ));
share_service: share_service.clone(),
// ... let password_hasher: Arc<dyn PasswordHasherPort> =
}; Arc::new(Argon2PasswordHasher::new());
let service = Arc::new(ShareService::new(
Arc::new(self.config.clone()),
share_repository,
repos.file_read_repository.clone(), // FileBlobReadRepository
repos.folder_repository.clone(), // FolderDbRepository
password_hasher,
));
Some(service)
}
``` ```
## Workflows ## Workflows
@@ -353,8 +359,9 @@ HTTP status code mapping:
## Technical Notes ## Technical Notes
- **Performance**: JSON file-based storage works for moderate volumes. For higher load, migrate to a database. - **Share metadata** is stored in a local JSON file via `ShareFsRepository`. This is separate from the 100% blob storage model used for file content.
- **Scalability**: the design supports horizontal scaling via distributed or cloud-based repositories. - **File/folder lookups** during share access go through `FileReadPort` / `FolderStoragePort`, which read metadata from PostgreSQL and content from the DedupService blob store.
- **Scalability**: for higher load, share metadata could be migrated to PostgreSQL using the same hexagonal architecture (implement `ShareRepository` with PgPool).
- **Maintenance**: clear separation of concerns makes testing and maintenance straightforward. - **Maintenance**: clear separation of concerns makes testing and maintenance straightforward.
The sharing feature is enabled by default in the current configuration. The sharing feature is enabled via `OXICLOUD_ENABLE_FILE_SHARING` configuration flag.
+117 -79
View File
@@ -1,79 +1,117 @@
# 14 - Trash Feature # 14 - Trash Feature
Soft-delete for files and folders. Items go to a per-user trash bin instead of being permanently removed. Configurable retention period with automatic cleanup. Soft-delete for files and folders. Items go to a per-user trash bin instead of being permanently removed. Configurable retention period with automatic cleanup.
## Architecture ## Architecture
Follows the hexagonal architecture: Follows the hexagonal architecture:
1. **Domain Layer** (`/src/domain/`): 1. **Domain Layer** (`/src/domain/`):
- Entities: **TrashedItem** representing files and folders in the trash - Entities: **TrashedItem** representing files and folders in the trash
- Repository interfaces: **TrashRepository** defining trash management operations - Repository interfaces: **TrashRepository** defining trash management operations
2. **Application Layer** (`/src/application/`): 2. **Application Layer** (`/src/application/`):
- DTOs: **TrashedItemDto** for data transfer between layers - DTOs: **TrashedItemDto** for data transfer between layers
- Ports: **TrashUseCase** defining available operations - Ports: **TrashUseCase** defining available operations
- Services: **TrashService** implementing the trash use cases - Services: **TrashService** implementing the trash use cases
3. **Infrastructure Layer** (`/src/infrastructure/`): 3. **Infrastructure Layer** (`/src/infrastructure/`):
- Repositories: **TrashFsRepository** for filesystem-based trash storage - Repositories: **TrashDbRepository** (PostgreSQL) — reads from `storage.trash_items` VIEW, manages soft-delete flags
- Trash-related methods in existing repositories: `FileWriteRepository::move_to_trash()`, `FolderRepository::move_to_trash()`, etc. - Trash-related methods in file/folder repositories: `FileBlobWriteRepository::move_to_trash()`, `FolderDbRepository::move_to_trash()`, etc.
- Services: **TrashCleanupService** for automatic cleanup of expired items - Services: **TrashCleanupService** for automatic cleanup of expired items
4. **Interface Layer** (`/src/interfaces/`): 4. **Interface Layer** (`/src/interfaces/`):
- API handlers: `trash_handler.rs` with HTTP endpoints for trash operations - API handlers: `trash_handler.rs` with HTTP endpoints for trash operations
- Routes: updated `routes.rs` to include trash endpoints - Routes: updated `routes.rs` to include trash endpoints
## Key Features ## Storage Model
1. **Soft Deletion** -- files and folders move to trash, not immediately deleted Trash uses a **soft-delete** model in PostgreSQL:
2. **Per-User Trash** -- each user has an isolated trash bin
3. **Retention Policy** -- items auto-delete after a configurable period - Files and folders have `is_trashed` (BOOLEAN) and `trashed_at` (TIMESTAMPTZ) columns
4. **Restoration** -- items can be restored to their original location - When an item is trashed, `is_trashed` is set to `TRUE` and `trashed_at` records the timestamp
5. **Permanent Deletion** -- items can be permanently deleted before retention expires - `original_parent_id` / `original_folder_id` stores the original location for restore
6. **Empty Trash** -- wipe everything in the trash at once - The `storage.trash_items` VIEW provides a unified list of all trashed items (files + folders)
- No physical file movement occurs — blob content stays at `.blobs/{prefix}/{hash}.blob`
## API Endpoints - Permanent deletion removes the DB row and decrements the blob reference counter
- `GET /api/trash` or `GET /api/trash/` -- list all items in the user's trash ```sql
- `DELETE /api/trash/files/:id` -- move a file to trash CREATE OR REPLACE VIEW storage.trash_items AS
- `DELETE /api/trash/folders/:id` -- move a folder to trash SELECT id, name, 'file' AS item_type, folder_id AS parent_id,
- `POST /api/trash/:id/restore` -- restore an item to its original location user_id, size, mime_type, trashed_at, created_at
- `DELETE /api/trash/:id` -- permanently delete an item from trash FROM storage.files WHERE is_trashed = TRUE
- `DELETE /api/trash/empty` -- empty the entire trash bin UNION ALL
SELECT id, name, 'folder' AS item_type, parent_id,
## Testing user_id, 0 AS size, NULL AS mime_type, trashed_at, created_at
FROM storage.folders WHERE is_trashed = TRUE;
1. **Unit Tests** -- testing **TrashService**: ```
- Move files and folders to trash
- Restore items from trash ## Key Features
- Permanent deletion
- Empty trash operation 1. **Soft Deletion** — files and folders are flagged as trashed, not immediately deleted
2. **Per-User Trash** — each user has an isolated trash bin (filtered by `user_id`)
2. **Integration Tests** -- Python script hitting the API endpoints: 3. **Retention Policy** — items auto-delete after a configurable period
- End-to-end testing of all trash operations 4. **Restoration** — items can be restored to their original location via `original_parent_id`/`original_folder_id`
- Verification of move, list, restore, and delete behavior 5. **Permanent Deletion** — items can be permanently deleted before retention expires (removes DB row + decrements blob ref)
6. **Empty Trash** — wipe everything in the trash at once
3. **Shell Script** -- for manual testing and demonstration
## API Endpoints
## Configuration
- `GET /api/trash` or `GET /api/trash/` — list all items in the user's trash
- **OXICLOUD_ENABLE_TRASH**: enable/disable the trash feature via **FeaturesConfig** (default: true) - `DELETE /api/trash/files/:id` — move a file to trash
- **OXICLOUD_TRASH_RETENTION_DAYS**: days to keep items before automatic deletion (default: 30, via **StorageConfig**) - `DELETE /api/trash/folders/:id` — move a folder to trash
- `POST /api/trash/:id/restore` — restore an item to its original location
## Implementation Details - `DELETE /api/trash/:id` — permanently delete an item from trash
- `DELETE /api/trash/empty` — empty the entire trash bin
1. **Physical File Storage** -- when items are trashed, they physically move to a `.trash` directory
2. **Metadata Storage** -- trashed item info stored in a separate database table or file ## Implementation Details
3. **User Isolation** -- trash items are isolated by user ID
4. **Automatic Cleanup** -- a background job runs periodically to clean up expired items ### TrashDbRepository
5. **Transaction Safety** -- operations are atomic with proper error handling
**File:** `src/infrastructure/repositories/pg/trash_db_repository.rs`
## Future Enhancements
```rust
1. **Trash Quotas** -- limit trash storage per user pub struct TrashDbRepository {
2. **Batch Operations** -- trash, restore, or delete multiple items at once pool: Arc<PgPool>,
3. **Storage Optimization** -- deduplication for trashed items retention_days: u32,
4. **Version Control** -- track file versions when moving to trash }
5. **Scheduled Cleanup** -- let users configure custom retention periods ```
6. **Trash Monitoring** -- metrics and alerts for trash usage and cleanup
Key methods:
- `get_trash_items(user_id)` — SELECT from `storage.trash_items` WHERE `user_id = $1`
- `clear_trash(user_id)` — DELETE from `storage.files` and `storage.folders` WHERE `is_trashed = TRUE AND user_id = $1`
- `get_expired_items()` — finds items where `trashed_at + retention_days < NOW()`
### TrashService
**File:** `src/application/services/trash_service.rs`
Constructor: `TrashService::new(trash_repo, file_read, file_write, folder_repo, retention_days)`
Orchestrates trash operations by delegating to the appropriate repository:
- Moving a file to trash → `FileBlobWriteRepository::move_to_trash()`
- Moving a folder to trash → `FolderDbRepository::move_to_trash()`
- Permanent deletion → removes DB row + calls `DedupService::decrement_ref()` to clean blob if unreferenced
### TrashCleanupService
**File:** `src/infrastructure/services/trash_cleanup_service.rs`
Background job that runs every 24 hours to permanently delete items past the retention period.
## Testing
1. **Unit Tests** — testing **TrashService**:
- Move files and folders to trash
- Restore items from trash
- Permanent deletion
- Empty trash operation
2. **Integration Tests** — Python script hitting the API endpoints:
- End-to-end testing of all trash operations
- Verification of move, list, restore, and delete behavior
## Configuration
- **OXICLOUD_ENABLE_TRASH**: enable/disable the trash feature via **FeaturesConfig** (default: true)
- **OXICLOUD_TRASH_RETENTION_DAYS**: days to keep items before automatic deletion (default: 30, via **StorageConfig**)