feat: pluggable storage backends (S3, Azure, local) with admin UI
Implement 4-phase external storage backends architecture: Phase 1 - Foundation: - BlobStorageBackend trait (application/ports/blob_storage_ports.rs) - LocalBlobBackend: extracted all tokio::fs ops from DedupService - S3BlobBackend: AWS SDK with custom endpoint support (MinIO, R2, B2) - DedupService refactored to use Arc<dyn BlobStorageBackend> Phase 2 - Admin Panel: - StorageSettingsService with DB persistence + env override - Storage tab in admin panel (backend selector, S3 form, provider presets) - GET/PUT/POST endpoints for storage settings + connection test - i18n keys (en/es) and BEM CSS Phase 3 - Migration: - MigrationBlobBackend decorator (dual-read: target-first + source fallback) - Background migration job with parallel transfers + progress tracking - Migration UI (progress bar, ETA, pause/resume/verify/complete) - 6 admin API endpoints for migration lifecycle Phase 4 - Enterprise Extras: - CachedBlobBackend: LRU disk cache for remote backends - EncryptedBlobBackend: AES-256-GCM at-rest encryption - AzureBlobBackend: Azure Blob Storage support - RetryBlobBackend: exponential backoff for transient errors - Decorator composition in DI: retry → encryption → cache All 223 tests passing, clippy clean, fmt verified.
This commit is contained in:
@@ -128,3 +128,97 @@ pub struct DashboardStatsDto {
|
||||
pub users_over_quota: i64,
|
||||
pub registration_enabled: bool,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage Settings DTOs (Admin Panel)
|
||||
// ============================================================================
|
||||
|
||||
/// Current storage settings returned to admin UI (secrets masked)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct StorageSettingsDto {
|
||||
/// Active backend type: "local" or "s3"
|
||||
pub backend: String,
|
||||
pub s3_endpoint_url: Option<String>,
|
||||
pub s3_bucket: Option<String>,
|
||||
pub s3_region: Option<String>,
|
||||
/// True if an access key is configured (never reveals the actual value)
|
||||
pub s3_access_key_set: bool,
|
||||
/// True if a secret key is configured (never reveals the actual value)
|
||||
pub s3_secret_key_set: bool,
|
||||
pub s3_force_path_style: bool,
|
||||
/// Field names overridden by environment variables (read-only in UI)
|
||||
pub env_overrides: Vec<String>,
|
||||
// ── Current stats ──
|
||||
pub current_backend: String,
|
||||
pub total_blobs: u64,
|
||||
pub total_bytes_stored: u64,
|
||||
pub dedup_ratio: f64,
|
||||
}
|
||||
|
||||
/// Request body for saving storage settings from the admin panel
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SaveStorageSettingsDto {
|
||||
pub backend: String,
|
||||
pub s3_endpoint_url: Option<String>,
|
||||
pub s3_bucket: Option<String>,
|
||||
pub s3_region: Option<String>,
|
||||
/// Only update if provided and non-empty (None = keep existing)
|
||||
pub s3_access_key: Option<String>,
|
||||
/// Only update if provided and non-empty (None = keep existing)
|
||||
pub s3_secret_key: Option<String>,
|
||||
pub s3_force_path_style: Option<bool>,
|
||||
}
|
||||
|
||||
/// Request body for testing a storage connection
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TestStorageConnectionDto {
|
||||
pub backend: String,
|
||||
pub s3_endpoint_url: Option<String>,
|
||||
pub s3_bucket: Option<String>,
|
||||
pub s3_region: Option<String>,
|
||||
pub s3_access_key: Option<String>,
|
||||
pub s3_secret_key: Option<String>,
|
||||
pub s3_force_path_style: Option<bool>,
|
||||
}
|
||||
|
||||
/// Result of a storage connection test
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct StorageTestResultDto {
|
||||
pub connected: bool,
|
||||
pub message: String,
|
||||
pub backend_type: String,
|
||||
pub available_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration DTOs (Admin Panel — Storage Migration)
|
||||
// ============================================================================
|
||||
|
||||
/// Migration progress returned by `GET /api/admin/storage/migration`.
|
||||
/// Re-exports the `MigrationState` shape for the admin UI.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MigrationStateDto {
|
||||
pub status: String,
|
||||
pub total_blobs: u64,
|
||||
pub migrated_blobs: u64,
|
||||
pub migrated_bytes: u64,
|
||||
pub failed_blobs: Vec<String>,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
/// Estimated throughput in bytes/sec (for UI ETA calculation).
|
||||
pub throughput_bytes_per_sec: Option<f64>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/admin/storage/migration/start`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct StartMigrationDto {
|
||||
/// How many blobs to copy in parallel (default: 4).
|
||||
pub concurrency: Option<usize>,
|
||||
}
|
||||
|
||||
/// Request body (empty) for `POST /api/admin/storage/migration/verify`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VerifyMigrationDto {
|
||||
/// Number of random blobs to sample-check (default: 100).
|
||||
pub sample_size: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Blob Storage Backend Port — abstracts raw byte I/O for content-addressable storage.
|
||||
//!
|
||||
//! This trait decouples `DedupService` from any specific storage medium.
|
||||
//! Implementations include:
|
||||
//! - `LocalBlobBackend` — local filesystem (default)
|
||||
//! - `S3BlobBackend` — any S3-compatible service (AWS, Backblaze B2, MinIO, R2…)
|
||||
//!
|
||||
//! `DedupService` owns an `Arc<dyn BlobStorageBackend>` and delegates all
|
||||
//! byte-level I/O through this trait, keeping BLAKE3 hashing, ref-counting
|
||||
//! and PostgreSQL index logic in `DedupService` itself.
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::domain::errors::DomainError;
|
||||
|
||||
/// Boxed future alias used by [`BlobStorageBackend`] to keep the trait dyn-compatible.
|
||||
type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||
|
||||
/// Pinned boxed byte stream — the return type for blob reads.
|
||||
pub type BlobStream = Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
|
||||
|
||||
/// Health-check result returned by [`BlobStorageBackend::health_check`].
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StorageHealthStatus {
|
||||
/// Whether the backend is reachable and functional.
|
||||
pub connected: bool,
|
||||
/// Human-readable backend identifier (e.g. `"local"`, `"s3"`).
|
||||
pub backend_type: String,
|
||||
/// Descriptive status message.
|
||||
pub message: String,
|
||||
/// Available space in bytes, if the backend can report it.
|
||||
pub available_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// Minimal trait for blob byte I/O — decoupled from dedup logic.
|
||||
///
|
||||
/// Every method operates on a *hash key* that uniquely identifies a blob.
|
||||
/// The backend is responsible for mapping the hash to its own addressing
|
||||
/// scheme (filesystem path, S3 key, etc.).
|
||||
///
|
||||
/// Returns boxed futures so the trait is dyn-compatible (`Arc<dyn BlobStorageBackend>`).
|
||||
pub trait BlobStorageBackend: Send + Sync + 'static {
|
||||
/// Perform any one-time setup (create directories, verify bucket, etc.).
|
||||
fn initialize(&self) -> BoxFut<'_, Result<(), DomainError>>;
|
||||
|
||||
/// Store a blob from a local temporary file.
|
||||
///
|
||||
/// Must be **idempotent**: if the blob already exists the call succeeds
|
||||
/// without overwriting. Returns the number of bytes stored.
|
||||
fn put_blob(&self, hash: &str, source_path: &Path) -> BoxFut<'_, Result<u64, DomainError>>;
|
||||
|
||||
/// Stream the full blob content in chunks.
|
||||
fn get_blob_stream(&self, hash: &str) -> BoxFut<'_, Result<BlobStream, DomainError>>;
|
||||
|
||||
/// Stream a byte range of the blob (for HTTP Range requests / video seek).
|
||||
fn get_blob_range_stream(
|
||||
&self,
|
||||
hash: &str,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> BoxFut<'_, Result<BlobStream, DomainError>>;
|
||||
|
||||
/// Delete a blob by hash. Must be **idempotent** (no error if already gone).
|
||||
fn delete_blob(&self, hash: &str) -> BoxFut<'_, Result<(), DomainError>>;
|
||||
|
||||
/// Check if a blob exists in the backend.
|
||||
fn blob_exists(&self, hash: &str) -> BoxFut<'_, Result<bool, DomainError>>;
|
||||
|
||||
/// Get blob size in bytes without downloading content.
|
||||
fn blob_size(&self, hash: &str) -> BoxFut<'_, Result<u64, DomainError>>;
|
||||
|
||||
/// Verify connectivity and permissions (used by the admin "Test Connection" button).
|
||||
fn health_check(&self) -> BoxFut<'_, Result<StorageHealthStatus, DomainError>>;
|
||||
|
||||
/// Return the backend type name for display (e.g. `"local"`, `"s3"`).
|
||||
fn backend_type(&self) -> &'static str;
|
||||
|
||||
/// Return the local filesystem path for a blob, if available.
|
||||
///
|
||||
/// Only meaningful for local-filesystem backends. Remote backends
|
||||
/// return `None`; callers that need a local file must stream + spool.
|
||||
fn local_blob_path(&self, hash: &str) -> Option<PathBuf>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod auth_ports;
|
||||
pub mod blob_storage_ports;
|
||||
pub mod cache_ports;
|
||||
pub mod calendar_ports;
|
||||
pub mod carddav_ports;
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod nextcloud_login_flow_service;
|
||||
pub mod recent_service;
|
||||
pub mod search_service;
|
||||
pub mod share_service;
|
||||
pub mod storage_settings_service;
|
||||
pub mod storage_usage_service;
|
||||
pub mod trash_service;
|
||||
pub mod wopi_lock_service;
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::settings_dto::{
|
||||
SaveStorageSettingsDto, StorageSettingsDto, StorageTestResultDto, TestStorageConnectionDto,
|
||||
};
|
||||
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use crate::common::config::{S3StorageConfig, StorageConfig};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::repositories::settings_repository::SettingsRepository;
|
||||
use crate::infrastructure::repositories::pg::SettingsPgRepository;
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use crate::infrastructure::services::s3_blob_backend::S3BlobBackend;
|
||||
|
||||
/// Storage settings service — manages storage backend configuration via the admin panel.
|
||||
///
|
||||
/// Configuration priority: **env vars > DB settings > defaults**.
|
||||
pub struct StorageSettingsService {
|
||||
settings_repo: Arc<SettingsPgRepository>,
|
||||
env_storage_config: StorageConfig,
|
||||
dedup_service: Arc<DedupService>,
|
||||
}
|
||||
|
||||
impl StorageSettingsService {
|
||||
pub fn new(
|
||||
settings_repo: Arc<SettingsPgRepository>,
|
||||
env_storage_config: StorageConfig,
|
||||
dedup_service: Arc<DedupService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings_repo,
|
||||
env_storage_config,
|
||||
dedup_service,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect which storage fields are overridden by environment variables.
|
||||
fn get_env_overrides(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let vars = [
|
||||
("OXICLOUD_STORAGE_BACKEND", "backend"),
|
||||
("OXICLOUD_S3_ENDPOINT_URL", "s3_endpoint_url"),
|
||||
("OXICLOUD_S3_BUCKET", "s3_bucket"),
|
||||
("OXICLOUD_S3_REGION", "s3_region"),
|
||||
("OXICLOUD_S3_ACCESS_KEY", "s3_access_key"),
|
||||
("OXICLOUD_S3_SECRET_KEY", "s3_secret_key"),
|
||||
("OXICLOUD_S3_FORCE_PATH_STYLE", "s3_force_path_style"),
|
||||
];
|
||||
for (env_key, field_name) in &vars {
|
||||
if std::env::var(env_key).is_ok() {
|
||||
out.push(field_name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides on top of a config.
|
||||
fn apply_env_overrides(&self, config: &mut StorageConfig) {
|
||||
let e = &self.env_storage_config;
|
||||
if std::env::var("OXICLOUD_STORAGE_BACKEND").is_ok() {
|
||||
config.backend = e.backend.clone();
|
||||
}
|
||||
// S3 env overrides — only apply if S3 config exists in env
|
||||
if let Some(env_s3) = &e.s3 {
|
||||
let s3 = config.s3.get_or_insert_with(|| S3StorageConfig {
|
||||
endpoint_url: None,
|
||||
bucket: String::new(),
|
||||
region: "us-east-1".to_string(),
|
||||
access_key: String::new(),
|
||||
secret_key: String::new(),
|
||||
force_path_style: false,
|
||||
});
|
||||
if std::env::var("OXICLOUD_S3_ENDPOINT_URL").is_ok() {
|
||||
s3.endpoint_url = env_s3.endpoint_url.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_S3_BUCKET").is_ok() {
|
||||
s3.bucket = env_s3.bucket.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_S3_REGION").is_ok() {
|
||||
s3.region = env_s3.region.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_S3_ACCESS_KEY").is_ok() {
|
||||
s3.access_key = env_s3.access_key.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_S3_SECRET_KEY").is_ok() {
|
||||
s3.secret_key = env_s3.secret_key.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_S3_FORCE_PATH_STYLE").is_ok() {
|
||||
s3.force_path_style = env_s3.force_path_style;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load effective storage config: DB settings + env var overrides + defaults.
|
||||
pub async fn load_effective_storage_config(&self) -> Result<StorageConfig, DomainError> {
|
||||
let db: HashMap<String, String> = self.settings_repo.get_by_category("storage").await?;
|
||||
let d = StorageConfig::default();
|
||||
|
||||
let backend = db
|
||||
.get("storage.backend")
|
||||
.map(|v| match v.as_str() {
|
||||
"s3" => crate::common::config::StorageBackendType::S3,
|
||||
"azure" => crate::common::config::StorageBackendType::Azure,
|
||||
_ => crate::common::config::StorageBackendType::Local,
|
||||
})
|
||||
.unwrap_or(d.backend);
|
||||
|
||||
let s3 = {
|
||||
let bucket = db.get("storage.s3.bucket").cloned().unwrap_or_default();
|
||||
if bucket.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(S3StorageConfig {
|
||||
endpoint_url: db
|
||||
.get("storage.s3.endpoint_url")
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty()),
|
||||
bucket,
|
||||
region: db
|
||||
.get("storage.s3.region")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "us-east-1".to_string()),
|
||||
access_key: db.get("storage.s3.access_key").cloned().unwrap_or_default(),
|
||||
secret_key: db.get("storage.s3.secret_key").cloned().unwrap_or_default(),
|
||||
force_path_style: db
|
||||
.get("storage.s3.force_path_style")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let mut config = StorageConfig {
|
||||
backend,
|
||||
s3,
|
||||
..self.env_storage_config.clone()
|
||||
};
|
||||
|
||||
self.apply_env_overrides(&mut config);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get storage settings for display in admin UI (secrets masked).
|
||||
pub async fn get_storage_settings(&self) -> Result<StorageSettingsDto, DomainError> {
|
||||
let db: HashMap<String, String> = self.settings_repo.get_by_category("storage").await?;
|
||||
|
||||
let has_access_key = db
|
||||
.get("storage.s3.access_key")
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false)
|
||||
|| std::env::var("OXICLOUD_S3_ACCESS_KEY")
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_secret_key = db
|
||||
.get("storage.s3.secret_key")
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false)
|
||||
|| std::env::var("OXICLOUD_S3_SECRET_KEY")
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
let effective = self.load_effective_storage_config().await?;
|
||||
let stats = self.dedup_service.get_stats().await;
|
||||
let current_backend = self.dedup_service.backend().backend_type().to_string();
|
||||
|
||||
let backend_str = match effective.backend {
|
||||
crate::common::config::StorageBackendType::Local => "local",
|
||||
crate::common::config::StorageBackendType::S3 => "s3",
|
||||
crate::common::config::StorageBackendType::Azure => "azure",
|
||||
};
|
||||
|
||||
Ok(StorageSettingsDto {
|
||||
backend: backend_str.to_string(),
|
||||
s3_endpoint_url: effective.s3.as_ref().and_then(|s| s.endpoint_url.clone()),
|
||||
s3_bucket: effective.s3.as_ref().map(|s| s.bucket.clone()),
|
||||
s3_region: effective.s3.as_ref().map(|s| s.region.clone()),
|
||||
s3_access_key_set: has_access_key,
|
||||
s3_secret_key_set: has_secret_key,
|
||||
s3_force_path_style: effective.s3.as_ref().is_some_and(|s| s.force_path_style),
|
||||
env_overrides: self.get_env_overrides(),
|
||||
current_backend,
|
||||
total_blobs: stats.total_blobs,
|
||||
total_bytes_stored: stats.total_bytes_stored,
|
||||
dedup_ratio: stats.dedup_ratio,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save storage settings to DB.
|
||||
pub async fn save_storage_settings(
|
||||
&self,
|
||||
dto: SaveStorageSettingsDto,
|
||||
updated_by: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let cat = "storage";
|
||||
let by = Some(updated_by);
|
||||
|
||||
self.settings_repo
|
||||
.set("storage.backend", &dto.backend, cat, false, by)
|
||||
.await?;
|
||||
|
||||
if let Some(ref v) = dto.s3_endpoint_url {
|
||||
self.settings_repo
|
||||
.set("storage.s3.endpoint_url", v, cat, false, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(ref v) = dto.s3_bucket {
|
||||
self.settings_repo
|
||||
.set("storage.s3.bucket", v, cat, false, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(ref v) = dto.s3_region {
|
||||
self.settings_repo
|
||||
.set("storage.s3.region", v, cat, false, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(ref v) = dto.s3_access_key
|
||||
&& !v.is_empty()
|
||||
{
|
||||
self.settings_repo
|
||||
.set("storage.s3.access_key", v, cat, true, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(ref v) = dto.s3_secret_key
|
||||
&& !v.is_empty()
|
||||
{
|
||||
self.settings_repo
|
||||
.set("storage.s3.secret_key", v, cat, true, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(v) = dto.s3_force_path_style {
|
||||
self.settings_repo
|
||||
.set(
|
||||
"storage.s3.force_path_style",
|
||||
&v.to_string(),
|
||||
cat,
|
||||
false,
|
||||
by,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tracing::info!("Storage settings saved by admin (backend={})", dto.backend);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test a storage connection by building a temporary backend and calling health_check().
|
||||
pub async fn test_storage_connection(
|
||||
&self,
|
||||
dto: TestStorageConnectionDto,
|
||||
) -> Result<StorageTestResultDto, DomainError> {
|
||||
match dto.backend.as_str() {
|
||||
"local" => {
|
||||
// Test local backend health via the current dedup service backend
|
||||
let status = self.dedup_service.backend().health_check().await?;
|
||||
Ok(StorageTestResultDto {
|
||||
connected: status.connected,
|
||||
message: status.message,
|
||||
backend_type: "local".to_string(),
|
||||
available_bytes: status.available_bytes,
|
||||
})
|
||||
}
|
||||
"s3" => {
|
||||
let bucket = dto.s3_bucket.as_deref().unwrap_or_default();
|
||||
if bucket.is_empty() {
|
||||
return Ok(StorageTestResultDto {
|
||||
connected: false,
|
||||
message: "S3 bucket name is required".to_string(),
|
||||
backend_type: "s3".to_string(),
|
||||
available_bytes: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Build a temporary S3 backend from the DTO values,
|
||||
// falling back to existing DB/env config for missing fields.
|
||||
let effective = self.load_effective_storage_config().await.ok();
|
||||
let existing_s3 = effective.as_ref().and_then(|c| c.s3.as_ref());
|
||||
|
||||
let config = S3StorageConfig {
|
||||
endpoint_url: dto
|
||||
.s3_endpoint_url
|
||||
.clone()
|
||||
.or_else(|| existing_s3.and_then(|s| s.endpoint_url.clone())),
|
||||
bucket: bucket.to_string(),
|
||||
region: dto.s3_region.clone().unwrap_or_else(|| {
|
||||
existing_s3
|
||||
.map(|s| s.region.clone())
|
||||
.unwrap_or_else(|| "us-east-1".to_string())
|
||||
}),
|
||||
access_key: dto
|
||||
.s3_access_key
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
existing_s3
|
||||
.map(|s| s.access_key.clone())
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
secret_key: dto
|
||||
.s3_secret_key
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
existing_s3
|
||||
.map(|s| s.secret_key.clone())
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
force_path_style: dto
|
||||
.s3_force_path_style
|
||||
.unwrap_or_else(|| existing_s3.is_some_and(|s| s.force_path_style)),
|
||||
};
|
||||
|
||||
let backend = S3BlobBackend::new(&config);
|
||||
match backend.health_check().await {
|
||||
Ok(status) => Ok(StorageTestResultDto {
|
||||
connected: status.connected,
|
||||
message: status.message,
|
||||
backend_type: "s3".to_string(),
|
||||
available_bytes: status.available_bytes,
|
||||
}),
|
||||
Err(e) => Ok(StorageTestResultDto {
|
||||
connected: false,
|
||||
message: format!("Connection failed: {}", e),
|
||||
backend_type: "s3".to_string(),
|
||||
available_bytes: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
other => Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Storage",
|
||||
format!("Unknown backend type: {}", other),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user