This commit is contained in:
2026-08-16 09:02:39 +08:00
parent c554152f4f
commit f7f6db8695
105 changed files with 2535 additions and 948 deletions
+156 -74
View File
@@ -3,6 +3,7 @@ package data
import (
"context"
"encoding/json"
"strings"
"kra/internal/conf"
@@ -33,7 +34,16 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
if adminConfig != nil {
admin["routerPrefix"] = adminConfig.RouterPrefix
if adminConfig.System != nil {
admin["system"] = map[string]any{"useRedis": adminConfig.System.UseRedis, "useMultipoint": adminConfig.System.UseMultipoint, "useStrictAuth": adminConfig.System.UseStrictAuth, "disableAutoMigrate": adminConfig.System.DisableAutoMigrate, "useMongo": adminConfig.System.UseMongo}
admin["system"] = map[string]any{
"useRedis": adminConfig.System.UseRedis,
"useMultipoint": adminConfig.System.UseMultipoint,
"useStrictAuth": adminConfig.System.UseStrictAuth,
"disableAutoMigrate": adminConfig.System.DisableAutoMigrate,
"useMongo": adminConfig.System.UseMongo,
"addr": adminConfig.System.Addr,
"iplimitCount": adminConfig.System.IplimitCount,
"iplimitTime": adminConfig.System.IplimitTime,
}
}
if adminConfig.Jwt != nil {
admin["jwt"] = map[string]any{"signingKey": "******", "expiresTime": durationString(adminConfig.Jwt.ExpiresTime), "bufferTime": durationString(adminConfig.Jwt.BufferTime), "issuer": adminConfig.Jwt.Issuer}
@@ -45,7 +55,11 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
admin["local"] = map[string]any{"storePath": adminConfig.Local.StorePath, "pathPrefix": adminConfig.Local.PathPrefix}
}
if adminConfig.Media != nil {
admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize}
chunkDir := adminConfig.Media.ChunkDir
if chunkDir == "" {
chunkDir = "uploads/chunks"
}
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}
@@ -55,6 +69,13 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
maskStorageSecrets(storage)
admin["storage"] = storage
}
disks := make([]map[string]any, 0, len(adminConfig.DiskList))
for _, disk := range adminConfig.DiskList {
if disk != nil {
disks = append(disks, map[string]any{"mount_point": disk.MountPoint})
}
}
admin["disk_list"] = disks
if adminConfig.Zap != nil {
admin["zap"] = adminConfig.Zap
}
@@ -65,7 +86,12 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
admin["app"] = adminConfig.App
}
}
maskDataSecrets(dataConfig)
// Never mask secrets on the live runtime object. ConfigurationJSON is a
// read-only operation; mutating dataConfig here would replace the actual
// database/Redis/Mongo credentials with "******" and make the next reload
// fail. Work on a clone, just as we already do for adminConfig.
safeData := cloneDataConfig(dataConfig)
maskDataSecrets(safeData)
safeAdmin := cloneAdminConfig(adminConfig)
if safeAdmin != nil {
if safeAdmin.Jwt != nil {
@@ -77,8 +103,8 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
maskStorageSecrets(safeAdmin.Storage)
}
dataMap := map[string]any{}
if dataConfig != nil {
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(dataConfig)
if safeData != nil {
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(safeData)
if err != nil {
return nil, err
}
@@ -86,7 +112,7 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
return nil, err
}
}
config := gvaConfiguration(dataConfig, safeAdmin)
config := compatibleConfiguration(safeData, safeAdmin)
config["admin"], config["email"], config["data"] = admin, email, dataMap
return json.Marshal(map[string]any{"config": config})
}
@@ -98,7 +124,7 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json
}
nextData := cloneDataConfig(currentData)
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
if err := applyGVAConfiguration(raw, nextData, nextAdmin); err != nil {
if err := applyCompatibleConfiguration(raw, nextData, nextAdmin); err != nil {
return err
}
var value configurationEnvelope
@@ -107,18 +133,14 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json
}
options := protojson.UnmarshalOptions{DiscardUnknown: true}
if len(value.Data) > 0 && string(value.Data) != "null" {
patch := &conf.Data{}
if err := options.Unmarshal(value.Data, patch); err != nil {
if err := mergeConfigurationMessage(nextData, value.Data, options); err != nil {
return err
}
applyDataPatch(nextData, patch)
}
if len(value.Admin) > 0 && string(value.Admin) != "null" {
patch := &conf.AdminBackend{}
if err := options.Unmarshal(value.Admin, patch); err != nil {
if err := mergeConfigurationMessage(nextAdmin, value.Admin, options); err != nil {
return err
}
applyAdminPatch(nextAdmin, patch)
}
preserveDataSecrets(nextData, currentData)
preserveAdminSecrets(nextAdmin, currentAdmin)
@@ -216,6 +238,80 @@ func refreshDatabaseSource(database *conf.Data_Database) error {
return nil
}
// mergeConfigurationMessage applies a JSON object as a partial update while
// retaining fields that were not sent by the caller. protojson.Unmarshal is
// intentionally not used directly here because its reset semantics would
// clear unrelated configuration sections when the management page submits a
// partial object.
func mergeConfigurationMessage(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error {
currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target)
if err != nil {
return err
}
var current map[string]any
if err = json.Unmarshal(currentRaw, &current); err != nil {
return err
}
var incoming map[string]any
if err = json.Unmarshal(patch, &incoming); err != nil {
return err
}
incoming = normalizeJSONKeys(incoming).(map[string]any)
mergeJSONObjects(current, incoming)
merged, err := json.Marshal(current)
if err != nil {
return err
}
return options.Unmarshal(merged, target)
}
func normalizeJSONKeys(value any) any {
switch item := value.(type) {
case map[string]any:
result := make(map[string]any, len(item))
for key, nested := range item {
result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
}
return result
case []any:
result := make([]any, len(item))
for index, nested := range item {
result[index] = normalizeJSONKeys(nested)
}
return result
default:
return value
}
}
func snakeToLowerCamel(value string) string {
if !strings.Contains(value, "_") {
return value
}
parts := strings.Split(value, "_")
result := parts[0]
for _, part := range parts[1:] {
if part == "" {
continue
}
result += strings.ToUpper(part[:1]) + part[1:]
}
return result
}
func mergeJSONObjects(target, patch map[string]any) {
for key, value := range patch {
incoming, isObject := value.(map[string]any)
if isObject {
if existing, ok := target[key].(map[string]any); ok {
mergeJSONObjects(existing, incoming)
continue
}
}
target[key] = value
}
}
func durationString(value *durationpb.Duration) string {
if value == nil {
return "0s"
@@ -223,69 +319,17 @@ func durationString(value *durationpb.Duration) string {
return value.AsDuration().String()
}
func applyDataPatch(target, patch *conf.Data) {
if patch.Database != nil {
target.Database = patch.Database
}
if patch.Redis != nil {
target.Redis = patch.Redis
}
if patch.Mongo != nil {
target.Mongo = patch.Mongo
}
if patch.DatabaseList != nil {
target.DatabaseList = patch.DatabaseList
}
if patch.RedisList != nil {
target.RedisList = patch.RedisList
}
}
func applyAdminPatch(target, patch *conf.AdminBackend) {
target.RouterPrefix = patch.RouterPrefix
if patch.System != nil {
if target.System != nil {
patch.System.Addr = target.System.Addr
patch.System.IplimitCount = target.System.IplimitCount
patch.System.IplimitTime = target.System.IplimitTime
}
target.System = patch.System
}
if patch.Jwt != nil {
target.Jwt = patch.Jwt
}
if patch.Captcha != nil {
target.Captcha = patch.Captcha
}
if patch.Local != nil {
target.Local = patch.Local
}
if patch.Media != nil {
if patch.Media.ChunkDir == "" && target.Media != nil {
patch.Media.ChunkDir = target.Media.ChunkDir
}
target.Media = patch.Media
}
if patch.Storage != nil {
target.Storage = patch.Storage
}
if patch.Zap != nil {
target.Zap = patch.Zap
}
if patch.Cors != nil {
target.Cors = patch.Cors
}
if patch.App != nil {
target.App = patch.App
}
}
func maskDataSecrets(value *conf.Data) {
if value == nil {
return
}
if value.Database != nil {
value.Database.Password = "******"
// Source may contain an embedded password (for example a MySQL DSN).
// The management response must never expose it. Structured database
// fields are retained and refreshDatabaseSources rebuilds Source when
// the configuration is saved.
value.Database.Source = ""
}
if value.Redis != nil {
value.Redis.Password = "******"
@@ -296,6 +340,7 @@ func maskDataSecrets(value *conf.Data) {
for _, item := range value.DatabaseList {
if item != nil {
item.Password = "******"
item.Source = ""
}
}
for _, item := range value.RedisList {
@@ -312,20 +357,54 @@ func preserveDataSecrets(next, current *conf.Data) {
if next.Database != nil && current.Database != nil && maskedSecret(next.Database.Password) {
next.Database.Password = current.Database.Password
}
if next.Database != nil && current.Database != nil && next.Database.Source == "" {
next.Database.Source = current.Database.Source
}
if next.Redis != nil && current.Redis != nil && maskedSecret(next.Redis.Password) {
next.Redis.Password = current.Redis.Password
}
if next.Mongo != nil && current.Mongo != nil && maskedSecret(next.Mongo.Password) {
next.Mongo.Password = current.Mongo.Password
}
currentDatabases := make(map[string]*conf.Data_Database, len(current.DatabaseList))
for _, item := range current.DatabaseList {
if item != nil && item.AliasName != "" {
currentDatabases[item.AliasName] = item
}
}
for i, item := range next.DatabaseList {
if item != nil && i < len(current.DatabaseList) && current.DatabaseList[i] != nil && maskedSecret(item.Password) {
item.Password = current.DatabaseList[i].Password
var previous *conf.Data_Database
if item != nil && item.AliasName != "" {
previous = currentDatabases[item.AliasName]
}
if previous == nil && i < len(current.DatabaseList) {
previous = current.DatabaseList[i]
}
if item != nil && previous != nil {
if maskedSecret(item.Password) {
item.Password = previous.Password
}
if item.Source == "" {
item.Source = previous.Source
}
}
}
currentRedis := make(map[string]*conf.Data_Redis, len(current.RedisList))
for _, item := range current.RedisList {
if item != nil && item.Name != "" {
currentRedis[item.Name] = item
}
}
for i, item := range next.RedisList {
if item != nil && i < len(current.RedisList) && current.RedisList[i] != nil && maskedSecret(item.Password) {
item.Password = current.RedisList[i].Password
var previous *conf.Data_Redis
if item != nil && item.Name != "" {
previous = currentRedis[item.Name]
}
if previous == nil && i < len(current.RedisList) {
previous = current.RedisList[i]
}
if item != nil && previous != nil && maskedSecret(item.Password) {
item.Password = previous.Password
}
}
}
@@ -350,6 +429,9 @@ func objectStores(storage *conf.AdminBackend_Storage) []*conf.AdminBackend_Objec
}
func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
if storage == nil {
return
}
if storage.Qiniu != nil {
storage.Qiniu.SecretKey = "******"
}