# External Storage Backends — Implementation Plan > **Purpose**: This prompt provides Claude Code with full architectural context to implement pluggable blob storage backends (S3, Backblaze B2, MinIO, etc.) for OxiCloud, across 4 phases. All changes MUST respect the existing hexagonal architecture, BLAKE3 dedup system, and coding conventions defined in `CLAUDE.md`. --- ## Current Architecture Summary ### How blobs are stored today - **Content-addressable**: Files hashed with BLAKE3 → stored at `.blobs/{2-char-prefix}/{hash}.blob` - **Dedup index**: PostgreSQL `storage.blobs` table (hash PK, ref_count, size, content_type) - **Write-first strategy**: Blob written to disk BEFORE PostgreSQL upsert (PG connection never held during disk I/O) - **Streaming reads**: 256 KB chunks via `tokio::fs::File` + `ReaderStream` - **Range support**: `AsyncSeekExt::seek()` + `file.take()` for HTTP Range requests ### Key files | File | Role | |------|------| | `src/application/ports/dedup_ports.rs` | `DedupPort` trait — 12 methods, the hexagonal port | | `src/infrastructure/services/dedup_service.rs` | `DedupService` struct — sole implementation of `DedupPort` | | `src/common/di.rs` | `AppServiceFactory` — DI composition root, builds `DedupService` | | `src/common/config.rs` | `StorageConfig`, `AppConfig` — env var loading | | `src/interfaces/api/handlers/admin_handler.rs` | Admin API handlers (OIDC pattern to follow) | | `src/application/services/admin_settings_service.rs` | `AdminSettingsService` — runtime settings with env override | | `src/domain/repositories/settings_repository.rs` | `SettingsRepository` trait | | `src/infrastructure/repositories/pg/settings_pg_repository.rs` | PostgreSQL settings impl | | `static/admin.html` | Admin panel HTML (3 tabs: Dashboard, Users, OIDC) | | `static/js/views/admin/admin.js` | Admin panel JS logic | ### DedupService filesystem operations (candidates for extraction) These are the EXACT `tokio::fs` calls inside `DedupService` that must be delegated to the new `BlobStorageBackend` trait: ``` initialize() → fs::create_dir_all (blob_root, temp_root, 256 prefix dirs) store_from_file() → fs::metadata, fs::try_exists, fs::rename, fs::copy, fs::remove_file read_blob_stream() → File::open + ReaderStream read_blob_range_stream() → File::open + seek + take + ReaderStream blob_size() → fs::metadata remove_reference() → fs::remove_file (after PG commit) verify_integrity() → spawn_blocking with path checks blob_path() → PathBuf computation (sync) ``` ### DedupService PostgreSQL operations (stay in DedupService, untouched) ``` store_from_file() → INSERT … ON CONFLICT … RETURNING ref_count blob_exists() → SELECT EXISTS from storage.blobs get_blob_metadata() → SELECT from storage.blobs add_reference() → UPDATE ref_count + 1 remove_reference() → BEGIN TX → SELECT FOR UPDATE → DELETE if ref_count=0 → COMMIT get_stats() → SELECT COUNT, SUM from storage.blobs verify_integrity() → Streaming cursor SELECT from storage.blobs ``` --- ## Phase 1 — Foundation (Backend Trait + Local + S3) ### Task 1.1: Create `BlobStorageBackend` trait **File**: `src/application/ports/blob_storage_ports.rs` (NEW) Create a minimal trait that abstracts ONLY raw byte I/O operations: ```rust use async_trait::async_trait; use bytes::Bytes; use futures::Stream; use std::path::Path; use std::pin::Pin; use crate::domain::errors::DomainError; /// Health check result for storage backend connectivity #[derive(Debug, Clone, serde::Serialize)] pub struct StorageHealthStatus { pub connected: bool, pub backend_type: String, pub message: String, /// Optional: available space in bytes (if backend reports it) pub available_bytes: Option, } /// Minimal trait for blob byte I/O — decoupled from dedup logic. /// /// Implementations: `LocalBlobBackend`, `S3BlobBackend`, etc. /// DedupService owns an `Arc` and delegates /// all filesystem/object-store operations through this trait. #[async_trait] pub trait BlobStorageBackend: Send + Sync + 'static { /// Initialize the backend (create directories, verify bucket access, etc.) async fn initialize(&self) -> Result<(), DomainError>; /// Store a blob from a local temporary file. /// The backend MUST handle the case where the blob already exists (idempotent). /// Returns the number of bytes stored. async fn put_blob(&self, hash: &str, source_path: &Path) -> Result; /// Stream the full blob content. async fn get_blob_stream( &self, hash: &str, ) -> Result> + Send>>, DomainError>; /// Stream a byte range of the blob (for HTTP Range requests). async fn get_blob_range_stream( &self, hash: &str, start: u64, end: Option, ) -> Result> + Send>>, DomainError>; /// Delete a blob by hash. Must be idempotent (no error if already deleted). async fn delete_blob(&self, hash: &str) -> Result<(), DomainError>; /// Check if a blob exists in the backend. async fn blob_exists(&self, hash: &str) -> Result; /// Get blob size in bytes without downloading content. async fn blob_size(&self, hash: &str) -> Result; /// Verify connectivity and permissions. Used by admin "Test Connection" button. async fn health_check(&self) -> Result; /// Return the backend type name (for display in admin panel). fn backend_type(&self) -> &'static str; } ``` Register in `src/application/ports/mod.rs` and `src/application/mod.rs`. ### Task 1.2: Create `LocalBlobBackend` **File**: `src/infrastructure/services/local_blob_backend.rs` (NEW) Extract ALL `tokio::fs` operations from `DedupService` into this struct. This is a **pure refactor** — zero behavior change. ```rust pub struct LocalBlobBackend { blob_root: PathBuf, temp_root: PathBuf, } ``` Methods to implement from the trait, mapping from current DedupService code: | Trait method | Source in DedupService | Key logic | |---|---|---| | `initialize()` | `DedupService::initialize()` lines 115-154 | Create `.blobs/`, `.dedup_temp/`, 256 prefix dirs | | `put_blob()` | `DedupService::store_from_file()` lines 196-253 | `fs::try_exists` → `fs::rename` (EXDEV fallback `fs::copy`) → cleanup source | | `get_blob_stream()` | `DedupService::read_blob_stream()` lines 306-323 | `File::open` → `ReaderStream::with_capacity(256KB)` | | `get_blob_range_stream()` | `DedupService::read_blob_range_stream()` lines 325-353 | `File::open` → `seek` → `take` → `ReaderStream` | | `delete_blob()` | `DedupService::remove_reference()` line ~469 | `fs::remove_file` | | `blob_exists()` | New (was inline `fs::try_exists`) | `fs::try_exists(blob_path)` | | `blob_size()` | `DedupService::blob_size()` lines 355-369 | `fs::metadata().len()` | | `health_check()` | New | Check blob_root is writable, return disk available via `statvfs` | | `backend_type()` | New | Return `"local"` | Also add a public helper: ```rust pub fn blob_path(&self, hash: &str) -> PathBuf { let prefix = &hash[..2]; self.blob_root.join(prefix).join(format!("{hash}.blob")) } ``` Register in `src/infrastructure/services/mod.rs`. ### Task 1.3: Refactor `DedupService` to use `BlobStorageBackend` **File**: `src/infrastructure/services/dedup_service.rs` (MODIFY) Changes: 1. Add field: `backend: Arc` 2. Remove fields: `blob_root: PathBuf`, `temp_root: PathBuf` (moved to `LocalBlobBackend`) 3. Update constructor to accept `Arc` instead of `storage_root: &Path` 4. Replace all direct `tokio::fs` calls with `self.backend.*` calls 5. Keep `blob_path()` in DedupPort as a delegation: `self.backend.blob_path()` — BUT since `blob_path()` returns `PathBuf` and is used by thumbnails/caching services, consider adding it to the backend trait OR keeping a separate method. For S3 backends, this should return a virtual path or the method should be deprecated in favor of streaming. **Critical**: `hash_file()` stays in `DedupService` (BLAKE3 hashing is NOT a backend concern — it always runs on local temp files before upload). **Critical**: The write-first strategy is preserved: ``` 1. hash_file() on local temp file 2. self.backend.put_blob(hash, temp_path) ← backend moves/uploads 3. INSERT INTO storage.blobs … ON CONFLICT ← PostgreSQL upsert ``` **Critical**: `remove_reference()` flow preserved: ``` 1. BEGIN TX → SELECT FOR UPDATE → check ref_count 2. If ref_count == 1 → DELETE FROM storage.blobs → COMMIT 3. self.backend.delete_blob(hash) ← after PG commit ``` ### Task 1.4: Create `S3BlobBackend` **File**: `src/infrastructure/services/s3_blob_backend.rs` (NEW) **Dependency to add to `Cargo.toml`**: ```toml aws-sdk-s3 = "1" aws-config = { version = "1", features = ["behavior-version-latest"] } aws-smithy-types = "1" # For ByteStream ``` > Note: `aws-sdk-s3` is the official AWS SDK for Rust. It's compatible with ALL S3-compatible services (Backblaze B2, MinIO, Cloudflare R2, DigitalOcean Spaces, Wasabi) via custom endpoint configuration. ```rust pub struct S3BlobBackend { client: aws_sdk_s3::Client, bucket: String, } ``` **S3 key scheme**: Same as local — `{2-char-prefix}/{hash}.blob` (e.g., `a3/a3c5f2e8d1…blob`) Method mapping: | Trait method | S3 operation | |---|---| | `initialize()` | `head_bucket()` to verify bucket exists + permissions | | `put_blob()` | `put_object()` with `Body::from_path(source_path)`. Check existence with `head_object()` first for idempotency | | `get_blob_stream()` | `get_object()` → `.body.into_async_read()` → `ReaderStream` | | `get_blob_range_stream()` | `get_object().range(format!("bytes={start}-{end}"))` → stream | | `delete_blob()` | `delete_object()` (already idempotent in S3) | | `blob_exists()` | `head_object()` — 200 = true, 404 = false | | `blob_size()` | `head_object()` → `.content_length()` | | `health_check()` | `head_bucket()` + `list_objects_v2(max_keys=1)` | | `backend_type()` | Return `"s3"` | **S3 Client construction**: Must support custom endpoints for non-AWS providers: ```rust impl S3BlobBackend { pub async fn new(config: &S3StorageConfig) -> Result { let mut s3_config_builder = aws_sdk_s3::config::Builder::new() .region(aws_sdk_s3::config::Region::new(config.region.clone())) .credentials_provider( aws_sdk_s3::config::Credentials::new( &config.access_key, &config.secret_key, None, None, "oxicloud", ) ) .behavior_version_latest(); if let Some(endpoint) = &config.endpoint_url { s3_config_builder = s3_config_builder .endpoint_url(endpoint) .force_path_style(config.force_path_style); } let client = aws_sdk_s3::Client::from_conf(s3_config_builder.build()); Ok(Self { client, bucket: config.bucket.clone() }) } } ``` ### Task 1.5: Add storage backend configuration **File**: `src/common/config.rs` (MODIFY) Add to existing `StorageConfig`: ```rust #[derive(Debug, Clone)] pub enum StorageBackendType { Local, S3, } #[derive(Debug, Clone)] pub struct S3StorageConfig { pub endpoint_url: Option, // OXICLOUD_S3_ENDPOINT_URL pub bucket: String, // OXICLOUD_S3_BUCKET pub region: String, // OXICLOUD_S3_REGION (default: "us-east-1") pub access_key: String, // OXICLOUD_S3_ACCESS_KEY pub secret_key: String, // OXICLOUD_S3_SECRET_KEY pub force_path_style: bool, // OXICLOUD_S3_FORCE_PATH_STYLE (default: false) } // Add to existing StorageConfig: pub struct StorageConfig { pub root_dir: String, // existing pub chunk_size: usize, // existing pub parallel_threshold: usize, // existing pub trash_retention_days: u32, // existing pub max_upload_size: usize, // existing pub backend: StorageBackendType, // NEW — OXICLOUD_STORAGE_BACKEND (default: "local") pub s3: Option, // NEW — populated when backend=s3 } ``` **Env var loading** in `AppConfig::from_env()`: ``` OXICLOUD_STORAGE_BACKEND → "local" | "s3" (default: "local") OXICLOUD_S3_ENDPOINT_URL → Optional custom endpoint OXICLOUD_S3_BUCKET → Required when backend=s3 OXICLOUD_S3_REGION → Default "us-east-1" OXICLOUD_S3_ACCESS_KEY → Required when backend=s3 OXICLOUD_S3_SECRET_KEY → Required when backend=s3 OXICLOUD_S3_FORCE_PATH_STYLE → Default false ``` ### Task 1.6: Wire backend selection in DI **File**: `src/common/di.rs` (MODIFY) In `create_core_services()`, replace the current DedupService construction: ```rust // Build storage backend based on config let blob_backend: Arc = match self.config.storage.backend { StorageBackendType::Local => { Arc::new(LocalBlobBackend::new(&self.storage_path)) } StorageBackendType::S3 => { let s3_config = self.config.storage.s3.as_ref() .expect("S3 config required when backend=s3"); Arc::new(S3BlobBackend::new(s3_config).await?) } }; blob_backend.initialize().await?; let dedup_service = Arc::new(DedupService::new( blob_backend.clone(), db_pool.clone(), maintenance_pool.clone(), )); ``` ### Task 1.7: Handle `blob_path()` deprecation path The `DedupPort::blob_path()` method returns a `PathBuf` and is used by: - Thumbnail generation service (needs local file access) - File content caching (moka cache) For S3 backends, `blob_path()` has no meaning. Solutions: 1. **For thumbnails**: Change thumbnail service to accept a `Stream` instead of a `PathBuf`, OR download to a temp file first 2. **For caching**: Cache already works with streams 3. Keep `blob_path()` on `DedupPort` but make it return `Option` (None for remote backends) — consumers must handle the None case Search for all callers of `blob_path()` and update them. ### Task 1.8: Tests - Unit test `LocalBlobBackend` in isolation (mock filesystem with temp dirs) - Unit test `S3BlobBackend` with a mock S3 (use `aws-smithy-runtime` test utilities or `mockall`) - Integration test: `DedupService` with `LocalBlobBackend` must pass ALL existing tests unchanged (this proves the refactor is correct) - Add `#[cfg(test)]` inline tests in each new file following existing project convention ### Task 1.9: Pre-commit validation ```bash cargo fmt --all cargo clippy -- -D warnings cargo test --workspace ``` ALL existing ~208 tests MUST pass. Zero regressions. --- ## Phase 2 — Admin Panel Configuration ### Task 2.1: Storage settings service **File**: `src/application/services/storage_settings_service.rs` (NEW) Follow the EXACT same pattern as `AdminSettingsService` for OIDC. Create `StorageSettingsService`: ```rust pub struct StorageSettingsService { settings_repo: Arc, env_storage_config: StorageConfig, // from AppConfig at startup } ``` **Methods** (follow OIDC pattern): | Method | Purpose | |---|---| | `get_storage_settings()` | Load from DB (category: `"storage"`), mask secrets, mark env overrides | | `save_storage_settings(dto, user_id)` | Upsert each field to `admin_settings`, mark secrets with `is_secret: true` | | `test_storage_connection(dto)` | Build temporary backend from DTO config, call `health_check()`, return result | | `load_effective_storage_config()` | Merge: DB settings + env var overrides + defaults | | `get_env_overrides()` | Return list of `OXICLOUD_S3_*` / `OXICLOUD_STORAGE_*` env vars that are set | **DB keys** (category: `"storage"`): ``` storage.backend → "local" | "s3" storage.s3.endpoint_url → string (optional) storage.s3.bucket → string storage.s3.region → string storage.s3.access_key → string (is_secret: true) storage.s3.secret_key → string (is_secret: true) storage.s3.force_path_style → "true" | "false" ``` ### Task 2.2: Storage admin API endpoints **File**: `src/interfaces/api/handlers/admin_handler.rs` (MODIFY) Add to `admin_routes()`: ```rust .route("/settings/storage", get(get_storage_settings)) .route("/settings/storage", put(save_storage_settings)) .route("/settings/storage/test", post(test_storage_connection)) ``` **Handler implementations** (follow OIDC handlers exactly): | Handler | Method | Body | Response | |---|---|---|---| | `get_storage_settings` | GET | — | `StorageSettingsDto` (secrets masked, env_overrides listed) | | `save_storage_settings` | PUT | `SaveStorageSettingsDto` | `{ "message": "Storage settings saved" }` | | `test_storage_connection` | POST | `TestStorageConnectionDto` | `StorageTestResultDto { connected, message, available_bytes }` | **DTOs** (add to `src/application/dtos/`): ```rust #[derive(Serialize)] pub struct StorageSettingsDto { pub backend: String, // "local" | "s3" pub s3_endpoint_url: Option, pub s3_bucket: Option, pub s3_region: Option, pub s3_access_key_set: bool, // masked — never send actual key pub s3_secret_key_set: bool, // masked — never send actual secret pub s3_force_path_style: bool, pub env_overrides: Vec, // which fields are locked by env vars // Current stats pub current_backend: String, pub total_blobs: u64, pub total_bytes_stored: u64, pub dedup_ratio: f64, } #[derive(Deserialize)] pub struct SaveStorageSettingsDto { pub backend: String, pub s3_endpoint_url: Option, pub s3_bucket: Option, pub s3_region: Option, pub s3_access_key: Option, // only sent if changed pub s3_secret_key: Option, // only sent if changed pub s3_force_path_style: Option, } #[derive(Deserialize)] pub struct TestStorageConnectionDto { pub backend: String, pub s3_endpoint_url: Option, pub s3_bucket: Option, pub s3_region: Option, pub s3_access_key: Option, pub s3_secret_key: Option, pub s3_force_path_style: Option, } #[derive(Serialize)] pub struct StorageTestResultDto { pub connected: bool, pub message: String, pub backend_type: String, pub available_bytes: Option, } ``` ### Task 2.3: Wire `StorageSettingsService` into DI **File**: `src/common/di.rs` (MODIFY) Add `StorageSettingsService` construction alongside `AdminSettingsService`. Add to `AppState`. ### Task 2.4: Admin Panel — Storage tab (HTML) **File**: `static/admin.html` (MODIFY) Add a 4th tab button after the OIDC tab: ```html ``` Add `tab-storage` content div with: 1. **Backend selector** — Radio buttons: Local Filesystem / S3-Compatible 2. **S3 configuration form** (shown/hidden based on selector): - Provider preset dropdown (Amazon S3, Backblaze B2, Cloudflare R2, MinIO, DigitalOcean Spaces, Wasabi, Custom) - Endpoint URL field - Bucket field - Region field - Access Key ID field - Secret Key field (password type) - Force Path Style checkbox - ENV badges on fields overridden by env vars (same as OIDC) 3. **Test Connection button** → calls `POST /api/admin/settings/storage/test` 4. **Save button** → calls `PUT /api/admin/settings/storage` 5. **Current Status section**: - Active backend type - Total blobs / total size / dedup ratio (from `DedupStatsDto`) 6. **Migration section** (Phase 3 — can be placeholder with "Coming soon") ### Task 2.5: Admin Panel — Storage tab (JS) **File**: `static/js/views/admin/admin.js` (MODIFY) Follow OIDC tab patterns: ```javascript // Provider presets const STORAGE_PRESETS = { 'aws': { endpoint: '', region: 'us-east-1', pathStyle: false }, 'backblaze': { endpoint: 's3.{region}.backblazeb2.com', region: 'us-west-004', pathStyle: false }, 'cloudflare-r2': { endpoint: '{accountId}.r2.cloudflarestorage.com', region: 'auto', pathStyle: true }, 'minio': { endpoint: 'http://localhost:9000', region: 'us-east-1', pathStyle: true }, 'digitalocean': { endpoint: '{region}.digitaloceanspaces.com', region: 'nyc3', pathStyle: false }, 'wasabi': { endpoint: 's3.{region}.wasabisys.com', region: 'us-east-1', pathStyle: false }, 'custom': { endpoint: '', region: '', pathStyle: false }, }; ``` Functions: - `loadStorage()` — `GET /api/admin/settings/storage` → populate form - `saveStorage()` — Collect form → `PUT /api/admin/settings/storage` - `testStorageConnection()` — Collect form → `POST /api/admin/settings/storage/test` → show result - `onPresetChange(preset)` — Auto-fill endpoint/region/pathStyle from preset - `toggleS3Form(visible)` — Show/hide S3 fields when backend radio changes ### Task 2.6: i18n keys **Files**: `static/locales/*.json` (MODIFY at minimum `en.json` and `es.json`) Add translation keys for all new UI labels: ``` admin.tab_storage admin.storage_backend admin.storage_local admin.storage_s3 admin.storage_provider_preset admin.storage_endpoint_url admin.storage_bucket admin.storage_region admin.storage_access_key admin.storage_secret_key admin.storage_path_style admin.storage_test_connection admin.storage_test_success admin.storage_test_failure admin.storage_save admin.storage_saved admin.storage_current_status admin.storage_total_blobs admin.storage_total_size admin.storage_dedup_ratio admin.storage_migration admin.storage_migration_coming_soon ``` ### Task 2.7: CSS for storage tab **File**: `static/css/admin.css` (MODIFY) Add styles for: - `.storage-backend-selector` — Radio button group - `.storage-provider-presets` — Dropdown styling - `.storage-form` — Form fields (reuse existing OIDC form patterns) - `.storage-status-grid` — Stats display - BEM methodology, CSS custom properties for colors, no raw hex/rgb --- ## Phase 3 — Migration Between Backends ### Task 3.1: `MigrationBlobBackend` wrapper **File**: `src/infrastructure/services/migration_blob_backend.rs` (NEW) A `BlobStorageBackend` decorator that enables zero-downtime migration between backends: ```rust pub struct MigrationBlobBackend { source: Arc, // old backend (read fallback) target: Arc, // new backend (primary for writes) state: Arc>, } pub struct MigrationState { pub status: MigrationStatus, pub total_blobs: u64, pub migrated_blobs: u64, pub migrated_bytes: u64, pub failed_blobs: Vec, // hashes that failed pub started_at: Option>, pub completed_at: Option>, } pub enum MigrationStatus { Idle, // no migration in progress Running, // background job active Paused, // manually paused Completed, // all blobs migrated Failed, // unrecoverable error } ``` **Behavior**: | Operation | During Migration | |---|---| | `put_blob()` | Write to **target** only | | `get_blob_stream()` | Try **target** first → fallback to **source** (+ schedule lazy copy) | | `get_blob_range_stream()` | Same fallback strategy | | `delete_blob()` | Delete from **both** (best-effort on source) | | `blob_exists()` | Check **target** first, then **source** | | `blob_size()` | Check **target** first, then **source** | ### Task 3.2: Background migration job **File**: `src/infrastructure/services/migration_job.rs` (NEW) ```rust pub async fn run_migration( source: Arc, target: Arc, pool: Arc, state: Arc>, concurrency: usize, // default: 4 parallel transfers bandwidth_limit: Option, // bytes/sec, None = unlimited ) -> Result<(), DomainError> ``` **Algorithm**: 1. Query `SELECT hash, size FROM storage.blobs ORDER BY hash` with streaming cursor 2. For each blob hash: a. Check if already exists in target (`target.blob_exists(hash)`) b. If not: stream from source → write to temp file → `target.put_blob(hash, temp)` c. Update `MigrationState` counters d. Respect bandwidth limit via `tokio::time::sleep` throttling 3. Use `futures::stream::buffer_unordered(concurrency)` for parallel transfers 4. On error: log, add to `failed_blobs`, continue (don't abort entire migration) ### Task 3.3: Migration API endpoints **File**: `src/interfaces/api/handlers/admin_handler.rs` (MODIFY) Add to `admin_routes()`: ```rust .route("/storage/migration", get(get_migration_status)) .route("/storage/migration/start", post(start_migration)) .route("/storage/migration/pause", post(pause_migration)) .route("/storage/migration/resume", post(resume_migration)) .route("/storage/migration/complete", post(complete_migration)) ``` | Endpoint | Purpose | |---|---| | `GET /migration` | Return `MigrationState` (status, progress, ETA) | | `POST /migration/start` | Begin background migration from current → configured backend | | `POST /migration/pause` | Pause the background job | | `POST /migration/resume` | Resume paused migration | | `POST /migration/complete` | Finalize: switch primary backend, optionally clean up source | ### Task 3.4: Migration UI in admin panel In the Storage tab's Migration section: 1. **Start Migration button** (when backend config differs from active) 2. **Progress bar**: `{migrated} / {total} blobs ({percent}%) — {bytes_migrated} transferred` 3. **Estimated time remaining** (based on throughput) 4. **Pause / Resume button** 5. **Complete Migration button** (enabled only when 100% migrated) 6. **Failed blobs list** (expandable, with retry button) 7. **Status badge**: Idle / Running / Paused / Completed / Failed ### Task 3.5: Integrity verification post-migration After migration completes (before `complete_migration`): 1. Run `verify_integrity()` against the target backend 2. Compare blob count in PG vs target backend 3. Sample-verify N random blobs (download + BLAKE3 hash check) 4. Show verification results in admin UI before allowing finalization --- ## Phase 4 — Enterprise Extras ### Task 4.1: `CachedBlobBackend` — LRU local disk cache **File**: `src/infrastructure/services/cached_blob_backend.rs` (NEW) A `BlobStorageBackend` decorator for remote backends (S3, Azure) that caches hot blobs on local SSD: ```rust pub struct CachedBlobBackend { inner: Arc, // S3 backend cache_dir: PathBuf, // local cache directory max_cache_bytes: u64, // configurable limit index: Arc>>, current_size: Arc, } struct CacheEntry { size: u64, last_accessed: Instant, } ``` **Behavior**: - **Reads**: Check local cache first → cache hit returns local file stream → cache miss downloads from inner backend, writes to cache, returns stream - **Writes**: `put_blob()` writes to inner backend AND local cache simultaneously - **Eviction**: LRU eviction when `current_size` exceeds `max_cache_bytes` - **Startup**: Scan cache directory to rebuild index **Configuration** (admin panel): ``` storage.cache.enabled → "true" | "false" storage.cache.max_size_bytes → u64 (default: 50 GB) storage.cache.path → PathBuf (default: "{storage_root}/.cache") ``` **Env vars**: ``` OXICLOUD_STORAGE_CACHE_ENABLED=true OXICLOUD_STORAGE_CACHE_MAX_SIZE=53687091200 # 50 GB OXICLOUD_STORAGE_CACHE_PATH=/fast-ssd/oxicloud-cache ``` ### Task 4.2: Client-side encryption (AES-256-GCM) **File**: `src/infrastructure/services/encrypted_blob_backend.rs` (NEW) Another `BlobStorageBackend` decorator that encrypts blobs before sending to the inner backend: ```rust pub struct EncryptedBlobBackend { inner: Arc, encryption_key: [u8; 32], // AES-256 key } ``` **Dependency**: Add `aes-gcm = "0.10"` to Cargo.toml. **Behavior**: - `put_blob()`: Read source → encrypt with AES-256-GCM (random 96-bit nonce prepended) → write encrypted to temp → `inner.put_blob(hash, encrypted_temp)` - `get_blob_stream()`: `inner.get_blob_stream()` → decrypt stream → return plaintext stream - **CRITICAL**: BLAKE3 hash is computed on the PLAINTEXT (before encryption), so dedup still works across encrypted backends - **Nonce storage**: Prepend 12-byte nonce to each encrypted blob (total overhead: 28 bytes per blob — 12 nonce + 16 GCM tag) **Configuration** (admin panel): ``` storage.encryption.enabled → "true" | "false" storage.encryption.key → base64-encoded 32-byte key (is_secret: true) ``` **Key generation**: Provide an admin API endpoint `POST /api/admin/settings/storage/generate-key` that generates a cryptographically secure key and returns it once (user must save it). **WARNING in admin UI**: "If you lose the encryption key, all data in the storage backend is IRRECOVERABLY LOST. Back up this key securely." ### Task 4.3: Azure Blob Storage backend **File**: `src/infrastructure/services/azure_blob_backend.rs` (NEW) **Dependency**: `azure_storage_blobs = "0.21"`, `azure_storage = "0.21"` Same `BlobStorageBackend` trait implementation targeting Azure Blob Storage: - Container = bucket equivalent - Blob key scheme: `{prefix}/{hash}.blob` (same as S3/local) - Authentication: Account Name + Account Key OR SAS token **Configuration**: ``` OXICLOUD_AZURE_ACCOUNT_NAME OXICLOUD_AZURE_ACCOUNT_KEY OXICLOUD_AZURE_CONTAINER OXICLOUD_AZURE_SAS_TOKEN # alternative auth ``` ### Task 4.4: Bandwidth throttling & retry policies Apply to all remote backends (S3, Azure): **Throttling**: - Configurable upload/download bandwidth limit per-backend - Implemented via `tokio::time::sleep` between chunks - Admin configurable: `storage.s3.max_upload_bandwidth`, `storage.s3.max_download_bandwidth` **Retry policy** (exponential backoff): ```rust pub struct RetryPolicy { pub max_retries: u32, // default: 3 pub initial_backoff_ms: u64, // default: 100 pub max_backoff_ms: u64, // default: 10_000 pub backoff_multiplier: f64, // default: 2.0 } ``` Wrap remote backend calls with retry logic for transient errors (network timeouts, 500s, 503s). --- ## Architecture Invariants (MUST be preserved) 1. **BLAKE3 hashing**: Always performed locally on temp files, never delegated to backend 2. **PostgreSQL dedup index**: `storage.blobs` table remains the source of truth for ref_count, metadata 3. **Write-first strategy**: Blob stored in backend BEFORE PostgreSQL upsert 4. **Remove-after-commit**: Blob deleted from backend AFTER PostgreSQL transaction commits 5. **Streaming reads**: All blob reads return `Pin>`, never load full blob into memory 6. **Zero framework deps in domain**: `BlobStorageBackend` trait lives in `application/ports/`, not infrastructure 7. **DI via AppState**: All backends are `Arc`-wrapped and assembled in `common/di.rs` 8. **Env var precedence**: `OXICLOUD_*` env vars always override DB-stored admin settings 9. **Admin guard**: All storage admin endpoints require JWT with role `"admin"` 10. **Existing tests**: All ~208 tests must pass after Phase 1 refactor ## Backend Decorator Composition The backends compose as decorators. In `di.rs`, the assembly looks like: ```rust // Phase 1: Base backend let base_backend: Arc = match config { Local => Arc::new(LocalBlobBackend::new(...)), S3 => Arc::new(S3BlobBackend::new(...).await?), Azure => Arc::new(AzureBlobBackend::new(...).await?), }; // Phase 4: Optional encryption layer let backend = if encryption_enabled { Arc::new(EncryptedBlobBackend::new(base_backend, key)) } else { base_backend }; // Phase 4: Optional cache layer (only for remote backends) let backend = if cache_enabled && !matches!(config, Local) { Arc::new(CachedBlobBackend::new(backend, cache_config)) } else { backend }; // Phase 3: Optional migration wrapper let backend = if migration_in_progress { Arc::new(MigrationBlobBackend::new(old_backend, backend, state)) } else { backend }; // Finally: DedupService uses the composed backend let dedup_service = Arc::new(DedupService::new(backend, pool, maintenance_pool)); ``` This decorator pattern means each feature (encryption, caching, migration) is: - Independently testable - Independently toggleable - Zero overhead when disabled - Composable in any order