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