优化
This commit is contained in:
@@ -62,7 +62,11 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
|
||||
admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize, "chunkDir": chunkDir}
|
||||
}
|
||||
if adminConfig.Email != nil {
|
||||
email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": "******", "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth}
|
||||
secret := ""
|
||||
if adminConfig.Email.Secret != "" {
|
||||
secret = "******"
|
||||
}
|
||||
email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": secret, "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth}
|
||||
}
|
||||
if adminConfig.Storage != nil {
|
||||
storage := proto.Clone(adminConfig.Storage).(*conf.AdminBackend_Storage)
|
||||
@@ -97,7 +101,7 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
|
||||
if safeAdmin.Jwt != nil {
|
||||
safeAdmin.Jwt.SigningKey = "******"
|
||||
}
|
||||
if safeAdmin.Email != nil {
|
||||
if safeAdmin.Email != nil && safeAdmin.Email.Secret != "" {
|
||||
safeAdmin.Email.Secret = "******"
|
||||
}
|
||||
maskStorageSecrets(safeAdmin.Storage)
|
||||
@@ -432,11 +436,11 @@ func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
|
||||
if storage == nil {
|
||||
return
|
||||
}
|
||||
if storage.Qiniu != nil {
|
||||
if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" {
|
||||
storage.Qiniu.SecretKey = "******"
|
||||
}
|
||||
for _, item := range objectStores(storage) {
|
||||
if item != nil {
|
||||
if item != nil && item.SecretKey != "" {
|
||||
item.SecretKey = "******"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,30 @@ func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node {
|
||||
return node
|
||||
}
|
||||
|
||||
func deleteYAMLMapping(node *yaml.Node, keys ...string) {
|
||||
if len(keys) == 0 || node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = node.Content[0]
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return
|
||||
}
|
||||
key := keys[0]
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value != key {
|
||||
continue
|
||||
}
|
||||
if len(keys) == 1 {
|
||||
node.Content = append(node.Content[:i], node.Content[i+2:]...)
|
||||
return
|
||||
}
|
||||
deleteYAMLMapping(node.Content[i+1], keys[1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func setServerHTTPPort(document *yaml.Node, port int32) error {
|
||||
if port <= 0 {
|
||||
return nil
|
||||
@@ -154,7 +178,10 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminValue, err := protoMap(adminConfig)
|
||||
fileAdmin := cloneAdminConfig(adminConfig)
|
||||
fileAdmin.Storage = nil
|
||||
fileAdmin.Email = nil
|
||||
adminValue, err := protoMap(fileAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,6 +191,8 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
||||
if err = setYAMLMapping(&document, "admin", adminValue); err != nil {
|
||||
return err
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
if adminConfig.System != nil {
|
||||
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
|
||||
return err
|
||||
@@ -221,6 +250,31 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st
|
||||
return err
|
||||
}
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
func (d *Data) removeIntegrationConfigFromFile() error {
|
||||
d.configMu.Lock()
|
||||
defer d.configMu.Unlock()
|
||||
configPath := d.runtime.ConfigPath()
|
||||
if configPath == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var document yaml.Node
|
||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil {
|
||||
return nil
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
@@ -300,6 +354,27 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
return fmt.Errorf("reload database migrations: %w", err)
|
||||
}
|
||||
}
|
||||
legacyStorage := next.Admin.Storage
|
||||
legacyEmail := next.Admin.Email
|
||||
currentAdmin := d.runtime.Admin()
|
||||
if legacyStorage == nil {
|
||||
if currentAdmin != nil {
|
||||
legacyStorage = currentAdmin.Storage
|
||||
}
|
||||
}
|
||||
if legacyEmail == nil && currentAdmin != nil {
|
||||
legacyEmail = currentAdmin.Email
|
||||
}
|
||||
storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload storage configuration: %w", err)
|
||||
}
|
||||
next.Admin.Storage = storageConfig
|
||||
emailConfig, err := resolveEmailIntegrationConfig(candidateDB.WithContext(ctx), legacyEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload email configuration: %w", err)
|
||||
}
|
||||
next.Admin.Email = emailConfig
|
||||
candidateStorage, err := buildFileStorage(next.Admin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload storage: %w", err)
|
||||
|
||||
@@ -55,6 +55,10 @@ func (d *Data) watchConfig() func() {
|
||||
logger.Error("reload changed config: data and admin configuration are required", "mod", "system")
|
||||
return
|
||||
}
|
||||
if current := d.runtime.Admin(); current != nil {
|
||||
next.Admin.Storage = current.Storage
|
||||
next.Admin.Email = current.Email
|
||||
}
|
||||
next.Admin.ConfigPath = absolute
|
||||
d.runtime.Replace(next.Data, next.Admin)
|
||||
logger.Info("config file changed", "mod", "system", "path", absolute)
|
||||
|
||||
+22
-1
@@ -133,12 +133,33 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
|
||||
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
|
||||
}
|
||||
admin := runtime.Admin()
|
||||
disableAutoMigrate := admin != nil && admin.System != nil && admin.System.DisableAutoMigrate
|
||||
if admin == nil {
|
||||
admin = &conf.AdminBackend{}
|
||||
}
|
||||
disableAutoMigrate := admin.System != nil && admin.System.DisableAutoMigrate
|
||||
if !usingFallback && !disableAutoMigrate {
|
||||
if err = migrateAll(db); err != nil {
|
||||
return nil, nil, fmt.Errorf("migrate tables: %w", err)
|
||||
}
|
||||
}
|
||||
if !usingFallback {
|
||||
storageConfig, storageErr := resolveStorageIntegrationConfig(db, admin.Storage)
|
||||
if storageErr != nil {
|
||||
return nil, nil, fmt.Errorf("load storage integration configuration: %w", storageErr)
|
||||
}
|
||||
emailConfig, emailErr := resolveEmailIntegrationConfig(db, admin.Email)
|
||||
if emailErr != nil {
|
||||
return nil, nil, fmt.Errorf("load email integration configuration: %w", emailErr)
|
||||
}
|
||||
admin.Storage = storageConfig
|
||||
admin.Email = emailConfig
|
||||
runtime.Replace(c, admin)
|
||||
if db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
if removeErr := d.removeIntegrationConfigFromFile(); removeErr != nil {
|
||||
appLogger.Warn("remove legacy integration configuration from file", "mod", "integration", "error", removeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
|
||||
d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger))
|
||||
useMongo := admin != nil && admin.System != nil && admin.System.UseMongo
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
integrationKindStorage = "storage"
|
||||
integrationKindEmail = "email"
|
||||
integrationKindPayment = "payment"
|
||||
)
|
||||
|
||||
// integrationConfigPO stores credentials and provider-specific options for
|
||||
// external services. Payment integrations use the same table with kind
|
||||
// "payment", keeping secrets out of the bootstrap configuration file.
|
||||
type integrationConfigPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Kind string `gorm:"size:32;not null;uniqueIndex:idx_integration_kind_provider"`
|
||||
Provider string `gorm:"size:64;not null;uniqueIndex:idx_integration_kind_provider"`
|
||||
Enabled bool `gorm:"not null;default:false;index"`
|
||||
Config string `gorm:"type:text;not null"`
|
||||
}
|
||||
|
||||
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
|
||||
|
||||
var storageProviderNames = []string{
|
||||
"local",
|
||||
"qiniu",
|
||||
"aliyun-oss",
|
||||
"huawei-obs",
|
||||
"tencent-cos",
|
||||
"aws-s3",
|
||||
"cloudflare-r2",
|
||||
"minio",
|
||||
}
|
||||
|
||||
func normalizeStorageType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return "local"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func storageProviderMessage(storage *conf.AdminBackend_Storage, provider string) proto.Message {
|
||||
if storage == nil {
|
||||
storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
switch provider {
|
||||
case "qiniu":
|
||||
if storage.Qiniu == nil {
|
||||
storage.Qiniu = &conf.AdminBackend_Qiniu{}
|
||||
}
|
||||
return storage.Qiniu
|
||||
case "aliyun-oss":
|
||||
return ensureObjectStore(&storage.AliyunOss)
|
||||
case "huawei-obs":
|
||||
return ensureObjectStore(&storage.HuaweiObs)
|
||||
case "tencent-cos":
|
||||
return ensureObjectStore(&storage.TencentCos)
|
||||
case "aws-s3":
|
||||
return ensureObjectStore(&storage.AwsS3)
|
||||
case "cloudflare-r2":
|
||||
return ensureObjectStore(&storage.CloudflareR2)
|
||||
case "minio":
|
||||
return ensureObjectStore(&storage.Minio)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ensureObjectStore(value **conf.AdminBackend_ObjectStore) proto.Message {
|
||||
if *value == nil {
|
||||
*value = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func marshalStorageProvider(storage *conf.AdminBackend_Storage, provider string) (string, error) {
|
||||
message := storageProviderMessage(storage, provider)
|
||||
if message == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func unmarshalStorageProvider(storage *conf.AdminBackend_Storage, provider, value string) error {
|
||||
if provider == "local" || strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
message := storageProviderMessage(storage, provider)
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
if !json.Valid([]byte(value)) {
|
||||
return fmt.Errorf("invalid %s integration configuration", provider)
|
||||
}
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(value), message); err != nil {
|
||||
return fmt.Errorf("decode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storage) error {
|
||||
if storage == nil {
|
||||
storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
active := normalizeStorageType(storage.Type)
|
||||
known := false
|
||||
for _, provider := range storageProviderNames {
|
||||
if provider == active {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return fmt.Errorf("unsupported storage type %q", active)
|
||||
}
|
||||
|
||||
return db.Session(&gorm.Session{NewDB: true}).Transaction(func(tx *gorm.DB) error {
|
||||
for _, provider := range storageProviderNames {
|
||||
value, err := marshalStorageProvider(storage, provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
var current integrationConfigPO
|
||||
err = tx.Where("kind = ? AND provider = ?", integrationKindStorage, provider).First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
current = integrationConfigPO{Kind: integrationKindStorage, Provider: provider}
|
||||
current.Enabled, current.Config = provider == active, value
|
||||
if err = tx.Create(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if err = tx.Model(¤t).Updates(map[string]any{"enabled": provider == active, "config": value}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool, error) {
|
||||
var rows []integrationConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind = ?", integrationKindStorage).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
storage := &conf.AdminBackend_Storage{Type: "local"}
|
||||
for _, row := range rows {
|
||||
if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if row.Enabled {
|
||||
storage.Type = row.Provider
|
||||
}
|
||||
}
|
||||
return storage, true, nil
|
||||
}
|
||||
|
||||
// resolveStorageIntegrationConfig upgrades a legacy YAML configuration only
|
||||
// when the database has no storage rows yet. From then on the database is the
|
||||
// sole source of truth.
|
||||
func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Storage) (*conf.AdminBackend_Storage, error) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return &conf.AdminBackend_Storage{Type: "local"}, nil
|
||||
}
|
||||
return proto.Clone(legacy).(*conf.AdminBackend_Storage), nil
|
||||
}
|
||||
storage, found, err := loadStorageIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return storage, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = &conf.AdminBackend_Storage{Type: "local"}
|
||||
}
|
||||
if err = saveStorageIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage, _, err = loadStorageIntegrationConfig(clean)
|
||||
return storage, err
|
||||
}
|
||||
|
||||
func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *conf.AdminBackend_Storage) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
db := d.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
return errors.New("integration configuration table does not exist")
|
||||
}
|
||||
return saveStorageIntegrationConfig(db, storage)
|
||||
}
|
||||
|
||||
func defaultEmailIntegrationConfig() *conf.AdminBackend_Email {
|
||||
return &conf.AdminBackend_Email{Port: 465, IsSsl: true}
|
||||
}
|
||||
|
||||
func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) error {
|
||||
if email == nil {
|
||||
email = defaultEmailIntegrationConfig()
|
||||
}
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode smtp integration configuration: %w", err)
|
||||
}
|
||||
enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
var current integrationConfigPO
|
||||
err = clean.Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return clean.Create(&integrationConfigPO{
|
||||
Kind: integrationKindEmail, Provider: "smtp", Enabled: enabled, Config: string(raw),
|
||||
}).Error
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
return clean.Model(¤t).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error
|
||||
}
|
||||
}
|
||||
|
||||
func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, error) {
|
||||
var row integrationConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !json.Valid([]byte(row.Config)) {
|
||||
return nil, false, errors.New("invalid smtp integration configuration")
|
||||
}
|
||||
email := defaultEmailIntegrationConfig()
|
||||
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), email); err != nil {
|
||||
return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err)
|
||||
}
|
||||
return email, true, nil
|
||||
}
|
||||
|
||||
func resolveEmailIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Email) (*conf.AdminBackend_Email, error) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return defaultEmailIntegrationConfig(), nil
|
||||
}
|
||||
return proto.Clone(legacy).(*conf.AdminBackend_Email), nil
|
||||
}
|
||||
email, found, err := loadEmailIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return email, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = defaultEmailIntegrationConfig()
|
||||
}
|
||||
if err = saveEmailIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email, _, err = loadEmailIntegrationConfig(clean)
|
||||
return email, err
|
||||
}
|
||||
|
||||
func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *conf.AdminBackend_Email) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
db := d.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
return errors.New("integration configuration table does not exist")
|
||||
}
|
||||
return saveEmailIntegrationConfig(db, email)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err = db.AutoMigrate(&integrationConfigPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMigrateAllCreatesIntegrationConfigTable(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err = migrateAll(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
t.Fatal("migrateAll did not create sys_integration_configs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
storage := &conf.AdminBackend_Storage{
|
||||
Type: "aliyun-oss",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{
|
||||
Zone: "ZoneHuadong", Bucket: "qiniu-bucket", AccessKey: "qiniu-key", SecretKey: "qiniu-secret",
|
||||
},
|
||||
AliyunOss: &conf.AdminBackend_ObjectStore{
|
||||
Endpoint: "oss-cn-hangzhou.aliyuncs.com", Region: "cn-hangzhou", Bucket: "assets",
|
||||
AccessKey: "aliyun-key", SecretKey: "aliyun-secret", BaseUrl: "https://cdn.example.com", PathPrefix: "uploads",
|
||||
},
|
||||
Minio: &conf.AdminBackend_ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true},
|
||||
}
|
||||
if err := saveStorageIntegrationConfig(db, storage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
email := &conf.AdminBackend_Email{
|
||||
To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com",
|
||||
Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSsl: true,
|
||||
}
|
||||
if err := saveEmailIntegrationConfig(db, email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: "wechat-pay", Config: `{"merchant_id":"123"}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, found, err := loadStorageIntegrationConfig(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("storage integration configuration was not found")
|
||||
}
|
||||
if loaded.Type != "aliyun-oss" {
|
||||
t.Fatalf("storage type = %q, want aliyun-oss", loaded.Type)
|
||||
}
|
||||
if loaded.AliyunOss == nil || loaded.AliyunOss.SecretKey != "aliyun-secret" || loaded.AliyunOss.PathPrefix != "uploads" {
|
||||
t.Fatalf("aliyun configuration = %#v", loaded.AliyunOss)
|
||||
}
|
||||
if loaded.Qiniu == nil || loaded.Qiniu.SecretKey != "qiniu-secret" {
|
||||
t.Fatalf("qiniu configuration = %#v", loaded.Qiniu)
|
||||
}
|
||||
|
||||
loadedEmail, found, err := loadEmailIntegrationConfig(db)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("email configuration found=%v, err=%v", found, err)
|
||||
}
|
||||
if loadedEmail.Host != "smtp.example.com" || loadedEmail.Secret != "smtp-secret" || loadedEmail.Port != 465 {
|
||||
t.Fatalf("email configuration = %#v", loadedEmail)
|
||||
}
|
||||
|
||||
var storageCount, emailCount, paymentCount int64
|
||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindStorage).Count(&storageCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&paymentCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindEmail).Count(&emailCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storageCount != int64(len(storageProviderNames)) {
|
||||
t.Fatalf("storage row count = %d, want %d", storageCount, len(storageProviderNames))
|
||||
}
|
||||
if paymentCount != 1 {
|
||||
t.Fatalf("payment row count = %d, want 1", paymentCount)
|
||||
}
|
||||
if emailCount != 1 {
|
||||
t.Fatalf("email row count = %d, want 1", emailCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &conf.AdminBackend_Storage{
|
||||
Type: "qiniu",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"},
|
||||
}
|
||||
loaded, err := resolveStorageIntegrationConfig(db, legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Type != "qiniu" || loaded.Qiniu.GetBucket() != "legacy" {
|
||||
t.Fatalf("migrated storage = %#v", loaded)
|
||||
}
|
||||
|
||||
other := &conf.AdminBackend_Storage{
|
||||
Type: "minio",
|
||||
Minio: &conf.AdminBackend_ObjectStore{Bucket: "must-not-replace-database"},
|
||||
}
|
||||
loaded, err = resolveStorageIntegrationConfig(db, other)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Type != "qiniu" || loaded.Qiniu.GetSecretKey() != "legacy-secret" {
|
||||
t.Fatalf("database configuration was replaced by legacy config: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) {
|
||||
storage := &conf.AdminBackend_Storage{
|
||||
Qiniu: &conf.AdminBackend_Qiniu{},
|
||||
AliyunOss: &conf.AdminBackend_ObjectStore{SecretKey: "configured-secret"},
|
||||
Minio: &conf.AdminBackend_ObjectStore{},
|
||||
}
|
||||
maskStorageSecrets(storage)
|
||||
if storage.Qiniu.SecretKey != "" || storage.Minio.SecretKey != "" {
|
||||
t.Fatalf("empty provider secrets were masked: %#v", storage)
|
||||
}
|
||||
if storage.AliyunOss.SecretKey != "******" {
|
||||
t.Fatalf("configured secret was not masked: %q", storage.AliyunOss.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &conf.AdminBackend_Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSsl: true}
|
||||
loaded, err := resolveEmailIntegrationConfig(db, legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret {
|
||||
t.Fatalf("migrated email = %#v", loaded)
|
||||
}
|
||||
loaded, err = resolveEmailIntegrationConfig(db, &conf.AdminBackend_Email{Host: "must-not-replace.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret {
|
||||
t.Fatalf("database email was replaced by legacy config: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n extension_key: retained\n")
|
||||
if err := os.WriteFile(path, input, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &Data{}
|
||||
admin := &conf.AdminBackend{
|
||||
ConfigPath: path,
|
||||
RouterPrefix: "/api",
|
||||
Storage: &conf.AdminBackend_Storage{
|
||||
Type: "qiniu",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"},
|
||||
},
|
||||
Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
|
||||
}
|
||||
if err := d.persistConfigValues(&conf.Data{}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document map[string]any
|
||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminValue, ok := document["admin"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("admin config = %#v", document["admin"])
|
||||
}
|
||||
if _, exists := adminValue["storage"]; exists {
|
||||
t.Fatalf("storage remained in YAML: %s", raw)
|
||||
}
|
||||
if _, exists := adminValue["email"]; exists {
|
||||
t.Fatalf("email remained in YAML: %s", raw)
|
||||
}
|
||||
if adminValue["extension_key"] != "retained" {
|
||||
t.Fatalf("extension key was not retained: %#v", adminValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("data: {}\nadmin: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldRoot := filepath.Join(t.TempDir(), "old")
|
||||
newRoot := filepath.Join(t.TempDir(), "new")
|
||||
currentAdmin := &conf.AdminBackend{
|
||||
ConfigPath: configPath,
|
||||
Local: &conf.AdminBackend_Local{StorePath: oldRoot, PathPrefix: "old-files"},
|
||||
Storage: &conf.AdminBackend_Storage{Type: "local"},
|
||||
}
|
||||
currentStorage, err := buildFileStorage(currentAdmin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadableDB := &reloadableDB{}
|
||||
reloadableDB.current.Store(db)
|
||||
d := &Data{
|
||||
runtime: conf.NewRuntime(&conf.Data{}, currentAdmin),
|
||||
gormDB: reloadableDB,
|
||||
storage: &reloadableStorage{current: currentStorage},
|
||||
}
|
||||
d.databaseReady.Store(true)
|
||||
|
||||
nextAdmin := cloneAdminConfig(currentAdmin)
|
||||
nextAdmin.Local = &conf.AdminBackend_Local{StorePath: newRoot, PathPrefix: "new-files"}
|
||||
nextAdmin.Email = &conf.AdminBackend_Email{
|
||||
To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com",
|
||||
Secret: "runtime-secret", Port: 465, IsSsl: true,
|
||||
}
|
||||
dataRaw, err := protojson.Marshal(&conf.Data{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminRaw, err := protojson.Marshal(nextAdmin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &initializationRepo{data: d}
|
||||
if err = repo.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stored, err := d.storage.Put(context.Background(), "active.txt", strings.NewReader("active"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.URL != "/new-files/active.txt" {
|
||||
t.Fatalf("active storage URL = %q, want /new-files/active.txt", stored.URL)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(newRoot, "active.txt")); err != nil {
|
||||
t.Fatalf("active storage did not write to the new root: %v", err)
|
||||
}
|
||||
loaded, found, err := loadStorageIntegrationConfig(db)
|
||||
if err != nil || !found || loaded.Type != "local" {
|
||||
t.Fatalf("database storage config = %#v, found=%v, err=%v", loaded, found, err)
|
||||
}
|
||||
loadedEmail, found, err := loadEmailIntegrationConfig(db)
|
||||
if err != nil || !found || loadedEmail.Secret != "runtime-secret" {
|
||||
t.Fatalf("database email config = %#v, found=%v, err=%v", loadedEmail, found, err)
|
||||
}
|
||||
if runtimeEmail := d.runtime.Admin().Email; runtimeEmail == nil || runtimeEmail.Host != "smtp.example.com" {
|
||||
t.Fatalf("runtime email config = %#v", runtimeEmail)
|
||||
}
|
||||
}
|
||||
@@ -18,22 +18,41 @@ import (
|
||||
)
|
||||
|
||||
func (r *initializationRepo) PersistConfig(context.Context) error { return r.data.persistConfig() }
|
||||
func (r *initializationRepo) PersistAdminConfig(_ context.Context, raw []byte) error {
|
||||
func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
next := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil {
|
||||
return err
|
||||
}
|
||||
if next.Storage == nil {
|
||||
next.Storage = currentAdmin.Storage
|
||||
}
|
||||
if next.Email == nil {
|
||||
next.Email = currentAdmin.Email
|
||||
}
|
||||
next.ConfigPath = currentAdmin.ConfigPath
|
||||
candidateStorage, err := buildFileStorage(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistStorageIntegrationConfig(ctx, next.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistConfigValues(currentData, next); err != nil {
|
||||
return err
|
||||
}
|
||||
// Writing through the management API updates the same in-memory values
|
||||
// immediately; the file watcher remains the fallback for external edits.
|
||||
r.data.runtime.Replace(currentData, next)
|
||||
if r.data.storage != nil {
|
||||
r.data.storage.replace(candidateStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *initializationRepo) PersistRuntimeConfig(_ context.Context, dataRaw, adminRaw []byte) error {
|
||||
func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error {
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
nextData := proto.Clone(currentData).(*conf.Data)
|
||||
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
@@ -44,11 +63,30 @@ func (r *initializationRepo) PersistRuntimeConfig(_ context.Context, dataRaw, ad
|
||||
if err := options.Unmarshal(adminRaw, nextAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
if nextAdmin.Storage == nil {
|
||||
nextAdmin.Storage = currentAdmin.Storage
|
||||
}
|
||||
if nextAdmin.Email == nil {
|
||||
nextAdmin.Email = currentAdmin.Email
|
||||
}
|
||||
nextAdmin.ConfigPath = currentAdmin.ConfigPath
|
||||
candidateStorage, err := buildFileStorage(nextAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
r.data.runtime.Replace(nextData, nextAdmin)
|
||||
if r.data.storage != nil {
|
||||
r.data.storage.replace(candidateStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *initializationRepo) ReloadConfig(ctx context.Context) error {
|
||||
@@ -236,6 +274,23 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
currentAdmin := r.data.runtime.Admin()
|
||||
var legacyStorage *conf.AdminBackend_Storage
|
||||
if currentAdmin != nil {
|
||||
legacyStorage = currentAdmin.Storage
|
||||
}
|
||||
storageConfig, err := resolveStorageIntegrationConfig(candidate.WithContext(ctx), legacyStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize storage integration configuration: %w", err)
|
||||
}
|
||||
var legacyEmail *conf.AdminBackend_Email
|
||||
if currentAdmin != nil {
|
||||
legacyEmail = currentAdmin.Email
|
||||
}
|
||||
emailConfig, err := resolveEmailIntegrationConfig(candidate.WithContext(ctx), legacyEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize email integration configuration: %w", err)
|
||||
}
|
||||
signingKey := uuid.NewString()
|
||||
if err := r.data.persistDatabaseConfig(config, signingKey); err != nil {
|
||||
return fmt.Errorf("persist database configuration: %w", err)
|
||||
@@ -249,6 +304,8 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||
currentAdmin.Jwt = &conf.AdminBackend_JWT{}
|
||||
}
|
||||
currentAdmin.Jwt.SigningKey = signingKey
|
||||
currentAdmin.Storage = storageConfig
|
||||
currentAdmin.Email = emailConfig
|
||||
r.data.runtime.Replace(currentData, currentAdmin)
|
||||
activated = true
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user