From dbabe8b8d7da387e4d2b0b7ca5ac6dcb9edb89a8 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 13 Jun 2026 15:54:09 +0800 Subject: [PATCH] feat(system_config): validate storage configuration connectivity on update - Add a live test connectivity check in UpdateSystemConfig before saving the storage configuration. - Merge masked placeholder secrets from current configuration prior to testing and database storage. - Extract validation and test logic to validateAndMergeStorageConfig helper to satisfy cyclomatic complexity. - Add TestUpdateStorageConfigValidation covering successful updates and failed checks. --- .../common/admin/storage-config-tab.tsx | 159 +++++++++++------- internal/apps/admin/system_config/routers.go | 48 ++++++ .../apps/admin/system_config/routers_test.go | 59 +++++++ 3 files changed, 202 insertions(+), 64 deletions(-) diff --git a/frontend/components/common/admin/storage-config-tab.tsx b/frontend/components/common/admin/storage-config-tab.tsx index 4a63755b..b12f912b 100644 --- a/frontend/components/common/admin/storage-config-tab.tsx +++ b/frontend/components/common/admin/storage-config-tab.tsx @@ -247,66 +247,78 @@ export function StorageConfigTab() { )} - - - - - 文件存储 - - - 默认使用本地存储。切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。 - + + +
+
+ +
+
+ 文件存储 + + 默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。 + +
+
- - - - 存储类型 - - + + +
+ + 存储类型 + + - {config.driver === "local" && ( - setConfig({...config, local: {root}})} - /> - )} + {config.driver === "local" && ( +
+ setConfig({...config, local: {root}})} + /> +
+ )} - {(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && ( - updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)} - /> - )} + {(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && ( + updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)} + /> + )} - {config.driver === "webdav" && ( - <> - setConfig({...config, webdav: {...config.webdav, endpoint}})} /> - setConfig({...config, webdav: {...config.webdav, username}})} /> - setConfig({...config, webdav: {...config.webdav, password}})} /> - setConfig({...config, webdav: {...config.webdav, base_path}})} /> - - )} + {config.driver === "webdav" && ( + <> +
+ setConfig({...config, webdav: {...config.webdav, endpoint}})} /> +
+ setConfig({...config, webdav: {...config.webdav, username}})} /> + setConfig({...config, webdav: {...config.webdav, password}})} /> +
+ setConfig({...config, webdav: {...config.webdav, base_path}})} /> +
+ + )} +
- + {config.driver !== query.data?.config.driver && ( ⚠️ 您已切换存储类型。请先点击“保存配置”保存各存储端的配置凭据,然后点击“开始迁移”手动执行文件迁移。 @@ -317,6 +329,7 @@ export function StorageConfigTab() { variant="outline" disabled={isFormDisabled} onClick={() => saveMutation.mutate(config)} + className="border-dashed" > {saveMutation.isPending ? : } 保存配置 @@ -348,8 +361,16 @@ function ObjectFields({ }) { return ( <> - {driver === "r2" && onChange({account_id})} />} - {driver !== "s3" && onChange({endpoint})} />} + {driver === "r2" && ( +
+ onChange({account_id})} /> +
+ )} + {driver !== "s3" && ( +
+ onChange({endpoint})} /> +
+ )} onChange({region})} /> onChange({bucket})} /> onChange({access_key_id})} /> @@ -357,11 +378,15 @@ function ObjectFields({ onChange({key_prefix})} /> onChange({cdn_url})} /> {(driver === "s3" || driver === "minio") && ( - - Path Style - onChange({path_style})} /> - MinIO 等自托管 S3 通常需要开启。 - +
+ +
+ Path Style + MinIO 等自托管 S3 通常需要开启。 +
+ onChange({path_style})} /> +
+
)} ) @@ -381,9 +406,15 @@ function TextField({ type?: React.HTMLInputTypeAttribute }) { return ( - - {label} - onChange(event.target.value)} /> + + {label} + onChange(event.target.value)} + className="border-dashed bg-card text-xs" + /> ) } diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index 7f6770b1..79927d57 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -5,8 +5,10 @@ package system_config import ( + "context" "encoding/json" "errors" + "fmt" "net/http" "time" @@ -199,6 +201,13 @@ func UpdateSystemConfig(c *gin.Context) { if err := json.Unmarshal([]byte(config.Value), ¤tCfg); err == nil { originalDriver = currentCfg.Driver } + + validatedVal, err := validateAndMergeStorageConfig(c.Request.Context(), req.Value, config.Value) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + req.Value = validatedVal } if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error { @@ -338,3 +347,42 @@ func maskSensitiveConfig(key, value string) string { } return value } + +// validateAndMergeStorageConfig parses, merges unmasked secrets, validates parameter values, +// and tests connectivity of the new storage configuration. +func validateAndMergeStorageConfig(ctx context.Context, value string, currentConfig string) (string, error) { + var currentCfg storage.Config + if err := json.Unmarshal([]byte(currentConfig), ¤tCfg); err != nil { + return "", fmt.Errorf("解析当前存储配置失败: %w", err) + } + + var newCfg storage.Config + if err := json.Unmarshal([]byte(value), &newCfg); err != nil { + return "", fmt.Errorf("解析目标存储配置失败: %w", err) + } + + // 合并被掩码屏蔽的敏感信息,获取完整的真实配置 + targetCfg := storage.MergeMaskedSecrets(newCfg, currentCfg) + + // 校验配置参数是否合法 + if err := storage.ValidateConfig(targetCfg); err != nil { + return "", fmt.Errorf("验证存储配置参数失败: %w", err) + } + + // 进行连通性测试验证,如果测试失败则拒绝保存 + testBackend, err := storage.NewBackend(ctx, targetCfg, targetCfg.Driver) + if err != nil { + return "", fmt.Errorf("初始化测试存储实例失败: %w", err) + } + if err := testBackend.Test(ctx); err != nil { + return "", fmt.Errorf("存储连通性测试失败: %w", err) + } + + // 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符 + unmaskedVal, err := json.Marshal(targetCfg) + if err != nil { + return "", fmt.Errorf("序列化存储配置失败: %w", err) + } + + return string(unmaskedVal), nil +} diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index 268aa907..a2a3e3f9 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -18,6 +18,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/storage" "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/Rain-kl/Wavelet/internal/util" "github.com/gin-gonic/gin" @@ -362,3 +363,61 @@ func TestTestSMTP(t *testing.T) { t.Errorf("expected test success, got failed: %s. Log: %s", testResp.Error, testResp.Log) } } + +func TestUpdateStorageConfigValidation(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} + router := setupTestRouter(adminUser) + + t.Run("update storage config successfully", func(t *testing.T) { + tempDir := t.TempDir() + cfg := storage.DefaultConfig() + cfg.Local.Root = tempDir + + cfgBytes, _ := json.Marshal(cfg) + payload := UpdateSystemConfigRequest{ + Value: string(cfgBytes), + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/storage_config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Verify database + var dbCfg model.SystemConfig + dbConn.Where("key = ?", "storage_config").First(&dbCfg) + var savedCfg storage.Config + _ = json.Unmarshal([]byte(dbCfg.Value), &savedCfg) + if savedCfg.Local.Root != tempDir { + t.Errorf("expected local root to be updated to %s, got %s", tempDir, savedCfg.Local.Root) + } + }) + + t.Run("update storage config failed connectivity check", func(t *testing.T) { + cfg := storage.DefaultConfig() + cfg.Driver = storage.DriverS3 + cfg.S3.Bucket = "non-existent-bucket" + cfg.S3.Endpoint = "http://127.0.0.1:9999" // Will fail connectivity check + + cfgBytes, _ := json.Marshal(cfg) + payload := UpdateSystemConfigRequest{ + Value: string(cfgBytes), + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/storage_config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String()) + } + }) +}