package data import ( "encoding/json" "strconv" "strings" "time" "kra/internal/conf" "google.golang.org/protobuf/types/known/durationpb" ) func compatibleDatabase(value *conf.Data_Database) map[string]any { if value == nil { return map[string]any{} } path := value.Host if value.Driver == "sqlite" { path = value.Path } return map[string]any{ "prefix": value.Prefix, "port": value.Port, "config": value.Config, "db-name": value.Name, "username": value.User, "password": value.Password, "path": path, "engine": value.Engine, "log-mode": value.LogMode, "max-idle-conns": value.MaxIdleConns, "max-open-conns": value.MaxOpenConns, "conn-max-lifetime": value.ConnMaxLifetime, "singular": value.Singular, } } func compatibleRedis(value *conf.Data_Redis) map[string]any { if value == nil { return map[string]any{} } return map[string]any{"name": value.Name, "addr": value.Addr, "password": value.Password, "db": value.Db, "useCluster": value.UseCluster, "clusterAddrs": value.ClusterAddrs} } func compatibleObjectStore(value *conf.AdminBackend_ObjectStore) map[string]any { if value == nil { return map[string]any{} } return map[string]any{ "endpoint": value.Endpoint, "region": value.Region, "bucket": value.Bucket, "access-key": value.AccessKey, "secret-key": value.SecretKey, "base-url": value.BaseUrl, "path-prefix": value.PathPrefix, "use-ssl": value.UseSsl, "s3-force-path-style": value.ForcePathStyle, "account-id": value.AccountId, } } func compatibleObjectStores(storage *conf.AdminBackend_Storage) map[string]map[string]any { if storage == nil { storage = &conf.AdminBackend_Storage{} } aliyun, huawei := storage.AliyunOss, storage.HuaweiObs tencent, aws := storage.TencentCos, storage.AwsS3 cloudflare, minio := storage.CloudflareR2, storage.Minio if aliyun == nil { aliyun = &conf.AdminBackend_ObjectStore{} } if huawei == nil { huawei = &conf.AdminBackend_ObjectStore{} } if tencent == nil { tencent = &conf.AdminBackend_ObjectStore{} } if aws == nil { aws = &conf.AdminBackend_ObjectStore{} } if cloudflare == nil { cloudflare = &conf.AdminBackend_ObjectStore{} } if minio == nil { minio = &conf.AdminBackend_ObjectStore{} } return map[string]map[string]any{ "aliyun-oss": {"endpoint": aliyun.Endpoint, "access-key-id": aliyun.AccessKey, "access-key-secret": aliyun.SecretKey, "bucket-name": aliyun.Bucket, "bucket-url": aliyun.BaseUrl, "base-path": aliyun.PathPrefix}, "hua-wei-obs": {"path": huawei.PathPrefix, "bucket": huawei.Bucket, "endpoint": huawei.Endpoint, "access-key": huawei.AccessKey, "secret-key": huawei.SecretKey}, "tencent-cos": {"bucket": tencent.Bucket, "region": tencent.Region, "secret-id": tencent.AccessKey, "secret-key": tencent.SecretKey, "base-url": tencent.BaseUrl, "path-prefix": tencent.PathPrefix}, "aws-s3": {"bucket": aws.Bucket, "region": aws.Region, "endpoint": aws.Endpoint, "secret-id": aws.AccessKey, "secret-key": aws.SecretKey, "base-url": aws.BaseUrl, "path-prefix": aws.PathPrefix, "s3-force-path-style": aws.ForcePathStyle, "disable-ssl": !aws.UseSsl}, "cloudflare-r2": {"bucket": cloudflare.Bucket, "base-url": cloudflare.BaseUrl, "path": cloudflare.PathPrefix, "account-id": cloudflare.AccountId, "access-key-id": cloudflare.AccessKey, "secret-access-key": cloudflare.SecretKey}, "minio": {"endpoint": minio.Endpoint, "access-key-id": minio.AccessKey, "access-key-secret": minio.SecretKey, "bucket-name": minio.Bucket, "use-ssl": minio.UseSsl, "base-path": minio.PathPrefix, "bucket-url": minio.BaseUrl}, } } func compatibleConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any { result := map[string]any{} if data == nil { data = &conf.Data{} } if admin == nil { admin = &conf.AdminBackend{} } jwt := admin.Jwt if jwt == nil { jwt = &conf.AdminBackend_JWT{} } result["jwt"] = map[string]any{"signing-key": jwt.SigningKey, "expires-time": durationString(jwt.ExpiresTime), "buffer-time": durationString(jwt.BufferTime), "issuer": jwt.Issuer} zap := admin.Zap if zap == nil { zap = &conf.AdminBackend_Zap{} } result["zap"] = map[string]any{ "level": zap.Level, "prefix": zap.Prefix, "format": zap.Format, "director": zap.Director, "encode-level": zap.EncodeLevel, "stacktrace-key": zap.StacktraceKey, "show-line": zap.ShowLine, "log-in-console": zap.LogInConsole, "retention-day": zap.RetentionDay, "access-req-body": zap.AccessReqBody, "access-resp-data": zap.AccessRespData, "access-req-headers": zap.AccessReqHeaders, "access-log-max-bytes": zap.AccessLogMaxBytes, "file-only-modules": zap.FileOnlyModules, } result["redis"] = compatibleRedis(data.Redis) redisList := make([]map[string]any, 0, len(data.RedisList)) for _, item := range data.RedisList { redisList = append(redisList, compatibleRedis(item)) } result["redis-list"] = redisList mongo := data.Mongo if mongo == nil { mongo = &conf.Data_Mongo{} } hosts := make([]map[string]any, 0, len(mongo.Hosts)) for _, host := range mongo.Hosts { if host != nil { hosts = append(hosts, map[string]any{"host": host.Host, "port": host.Port}) } } result["mongo"] = map[string]any{"coll": mongo.Coll, "options": mongo.Options, "database": mongo.Database, "username": mongo.Username, "password": mongo.Password, "auth-source": mongo.AuthSource, "min-pool-size": mongo.MinPoolSize, "max-pool-size": mongo.MaxPoolSize, "socket-timeout-ms": mongo.SocketTimeoutMs, "connect-timeout-ms": mongo.ConnectTimeoutMs, "is-zap": mongo.IsZap, "hosts": hosts} email := admin.Email if email == nil { email = &conf.AdminBackend_Email{} } result["email"] = map[string]any{"to": email.To, "from": email.From, "host": email.Host, "secret": email.Secret, "nickname": email.Nickname, "port": email.Port, "is-ssl": email.IsSsl, "is-loginauth": email.IsLoginAuth} system := admin.System if system == nil { system = &conf.AdminBackend_System{} } storageType := "local" if admin.Storage != nil && admin.Storage.Type != "" { storageType = admin.Storage.Type } driver := "" if data.Database != nil { driver = data.Database.Driver } result["system"] = map[string]any{"db-type": driver, "oss-type": storageType, "router-prefix": admin.RouterPrefix, "addr": system.Addr, "iplimit-count": system.IplimitCount, "iplimit-time": system.IplimitTime, "use-multipoint": system.UseMultipoint, "use-redis": system.UseRedis, "use-mongo": system.UseMongo, "use-strict-auth": system.UseStrictAuth, "disable-auto-migrate": system.DisableAutoMigrate} for _, name := range []string{"mysql", "mssql", "pgsql", "oracle", "sqlite"} { result[name] = map[string]any{} } if driver != "" { result[driver] = compatibleDatabase(data.Database) } dbList := make([]map[string]any, 0, len(data.DatabaseList)) for _, item := range data.DatabaseList { row := compatibleDatabase(item) if item != nil { row["type"], row["alias-name"], row["disable"] = item.Driver, item.AliasName, item.Disable } dbList = append(dbList, row) } result["db-list"] = dbList local := admin.Local if local == nil { local = &conf.AdminBackend_Local{} } result["local"] = map[string]any{"path": local.PathPrefix, "store-path": local.StorePath} media := admin.Media if media == nil { media = &conf.AdminBackend_Media{} } chunkDir := media.ChunkDir if chunkDir == "" { chunkDir = "uploads/chunks" } result["media"] = map[string]any{"session-ttl": media.SessionTtl, "max-file-size": media.MaxFileSize, "chunk-dir": chunkDir} if admin.Storage == nil { admin.Storage = &conf.AdminBackend_Storage{} } qiniu := admin.Storage.Qiniu if qiniu == nil { qiniu = &conf.AdminBackend_Qiniu{} } result["qiniu"] = map[string]any{"zone": qiniu.Zone, "bucket": qiniu.Bucket, "img-path": qiniu.BaseUrl, "access-key": qiniu.AccessKey, "secret-key": qiniu.SecretKey, "use-https": qiniu.UseHttps, "use-cdn-domains": qiniu.UseCdnDomains} for key, value := range compatibleObjectStores(admin.Storage) { result[key] = value } disks := make([]map[string]any, 0, len(admin.DiskList)) for _, disk := range admin.DiskList { if disk != nil { disks = append(disks, map[string]any{"mount-point": disk.MountPoint}) } } result["disk-list"] = disks cors := admin.Cors if cors == nil { cors = &conf.AdminBackend_CORS{} } whitelist := make([]map[string]any, 0, len(cors.Whitelist)) for _, rule := range cors.Whitelist { if rule != nil { whitelist = append(whitelist, map[string]any{"allow-origin": rule.AllowOrigin, "allow-methods": rule.AllowMethods, "allow-headers": rule.AllowHeaders, "expose-headers": rule.ExposeHeaders, "allow-credentials": rule.AllowCredentials}) } } result["cors"] = map[string]any{"mode": cors.Mode, "whitelist": whitelist} result["app"] = map[string]any{"node": admin.GetApp().GetNode(), "app-id": admin.GetApp().GetAppId(), "env": admin.GetApp().GetEnv()} return result } func objectMap(value any) map[string]any { result, _ := value.(map[string]any) return result } func stringValue(values map[string]any, key string) string { value, _ := values[key].(string) return value } func boolValue(values map[string]any, key string, fallback bool) bool { value, ok := values[key].(bool) if !ok { return fallback } return value } func int64Value(values map[string]any, key string, fallback int64) int64 { value, ok := values[key].(float64) if !ok { return fallback } return int64(value) } func stringSliceValue(values map[string]any, key string, fallback []string) []string { items, ok := values[key].([]any) if !ok { return fallback } result := make([]string, 0, len(items)) for _, item := range items { if value, ok := item.(string); ok { result = append(result, value) } } return result } func applyCompatibleRedis(target *conf.Data_Redis, values map[string]any) { if target == nil || values == nil { return } target.Name, target.Addr = stringValue(values, "name"), stringValue(values, "addr") if password := stringValue(values, "password"); password != "" && password != "******" { target.Password = password } target.Db = int32(int64Value(values, "db", int64(target.Db))) target.UseCluster = boolValue(values, "useCluster", target.UseCluster) target.ClusterAddrs = stringSliceValue(values, "clusterAddrs", target.ClusterAddrs) } func durationValue(raw string, fallback *durationpb.Duration) *durationpb.Duration { if raw == "" { return fallback } value, err := parseCompatibleDuration(raw) if err != nil { return fallback } return durationpb.New(value) } func parseCompatibleDuration(raw string) (time.Duration, error) { raw = strings.TrimSpace(raw) if value, err := time.ParseDuration(raw); err == nil { return value, nil } if index := strings.Index(raw, "d"); index >= 0 { days, _ := strconv.Atoi(raw[:index]) value := time.Duration(days) * 24 * time.Hour remainder, err := time.ParseDuration(raw[index+1:]) if err != nil { return value, nil } return value + remainder, nil } nanoseconds, err := strconv.ParseInt(raw, 10, 64) return time.Duration(nanoseconds), err } func applyCompatibleDatabase(target *conf.Data_Database, values map[string]any, driver string) { if target == nil || values == nil { return } target.Driver = driver target.Prefix, target.Port, target.Config = stringValue(values, "prefix"), stringValue(values, "port"), stringValue(values, "config") target.Name, target.User = stringValue(values, "db-name"), stringValue(values, "username") if password := stringValue(values, "password"); password != "" && password != "******" { target.Password = password } if driver == "sqlite" { target.Path = stringValue(values, "path") } else { target.Host = stringValue(values, "path") } target.Engine, target.LogMode = stringValue(values, "engine"), stringValue(values, "log-mode") target.MaxIdleConns = int32(int64Value(values, "max-idle-conns", int64(target.MaxIdleConns))) target.MaxOpenConns = int32(int64Value(values, "max-open-conns", int64(target.MaxOpenConns))) target.ConnMaxLifetime = int32(int64Value(values, "conn-max-lifetime", int64(target.ConnMaxLifetime))) target.Singular = boolValue(values, "singular", target.Singular) } func applyCompatibleObjectStore(target *conf.AdminBackend_ObjectStore, values map[string]any) { if target == nil || values == nil { return } target.Endpoint, target.Region, target.Bucket = stringValue(values, "endpoint"), stringValue(values, "region"), stringValue(values, "bucket") if target.Bucket == "" { target.Bucket = stringValue(values, "bucket-name") } target.AccessKey = stringValue(values, "access-key") if target.AccessKey == "" { target.AccessKey = stringValue(values, "access-key-id") } if target.AccessKey == "" { target.AccessKey = stringValue(values, "secret-id") } secret := stringValue(values, "secret-key") if secret == "" { secret = stringValue(values, "access-key-secret") } if secret == "" { secret = stringValue(values, "secret-access-key") } if secret != "" && secret != "******" { target.SecretKey = secret } target.BaseUrl = stringValue(values, "base-url") if target.BaseUrl == "" { target.BaseUrl = stringValue(values, "bucket-url") } target.PathPrefix = stringValue(values, "path-prefix") if target.PathPrefix == "" { target.PathPrefix = stringValue(values, "base-path") } if target.PathPrefix == "" { target.PathPrefix = stringValue(values, "path") } target.UseSsl = boolValue(values, "use-ssl", !boolValue(values, "disable-ssl", !target.UseSsl)) target.ForcePathStyle = boolValue(values, "s3-force-path-style", target.ForcePathStyle) target.AccountId = stringValue(values, "account-id") } func applyCompatibleConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.AdminBackend) error { var values map[string]any if err := json.Unmarshal(raw, &values); err != nil { return err } if _, compatibleShape := values["system"]; !compatibleShape { return nil } if data.Database == nil { data.Database = &conf.Data_Database{} } if admin.System == nil { admin.System = &conf.AdminBackend_System{} } if admin.Storage == nil { admin.Storage = &conf.AdminBackend_Storage{} } system := objectMap(values["system"]) driver := stringValue(system, "db-type") if driver == "" { driver = data.Database.Driver } applyCompatibleDatabase(data.Database, objectMap(values[driver]), driver) admin.Storage.Type = stringValue(system, "oss-type") admin.RouterPrefix = stringValue(system, "router-prefix") admin.System.UseMultipoint = boolValue(system, "use-multipoint", admin.System.UseMultipoint) admin.System.UseRedis = boolValue(system, "use-redis", admin.System.UseRedis) admin.System.UseMongo = boolValue(system, "use-mongo", admin.System.UseMongo) admin.System.UseStrictAuth = boolValue(system, "use-strict-auth", admin.System.UseStrictAuth) admin.System.DisableAutoMigrate = boolValue(system, "disable-auto-migrate", admin.System.DisableAutoMigrate) admin.System.Addr = int32(int64Value(system, "addr", int64(admin.System.Addr))) admin.System.IplimitCount = int32(int64Value(system, "iplimit-count", int64(admin.System.IplimitCount))) admin.System.IplimitTime = int32(int64Value(system, "iplimit-time", int64(admin.System.IplimitTime))) if admin.Jwt == nil { admin.Jwt = &conf.AdminBackend_JWT{} } jwt := objectMap(values["jwt"]) if secret := stringValue(jwt, "signing-key"); secret != "" && secret != "******" { admin.Jwt.SigningKey = secret } admin.Jwt.ExpiresTime = durationValue(stringValue(jwt, "expires-time"), admin.Jwt.ExpiresTime) admin.Jwt.BufferTime = durationValue(stringValue(jwt, "buffer-time"), admin.Jwt.BufferTime) admin.Jwt.Issuer = stringValue(jwt, "issuer") if admin.Zap == nil { admin.Zap = &conf.AdminBackend_Zap{} } zap := objectMap(values["zap"]) admin.Zap.Level, admin.Zap.Prefix, admin.Zap.Format, admin.Zap.Director = stringValue(zap, "level"), stringValue(zap, "prefix"), stringValue(zap, "format"), stringValue(zap, "director") admin.Zap.EncodeLevel, admin.Zap.StacktraceKey = stringValue(zap, "encode-level"), stringValue(zap, "stacktrace-key") admin.Zap.ShowLine, admin.Zap.LogInConsole = boolValue(zap, "show-line", admin.Zap.ShowLine), boolValue(zap, "log-in-console", admin.Zap.LogInConsole) admin.Zap.RetentionDay = int32(int64Value(zap, "retention-day", int64(admin.Zap.RetentionDay))) admin.Zap.AccessReqBody = boolValue(zap, "access-req-body", admin.Zap.AccessReqBody) admin.Zap.AccessRespData = boolValue(zap, "access-resp-data", admin.Zap.AccessRespData) admin.Zap.AccessReqHeaders = boolValue(zap, "access-req-headers", admin.Zap.AccessReqHeaders) admin.Zap.AccessLogMaxBytes = int32(int64Value(zap, "access-log-max-bytes", int64(admin.Zap.AccessLogMaxBytes))) if list, ok := zap["file-only-modules"].([]any); ok { admin.Zap.FileOnlyModules = admin.Zap.FileOnlyModules[:0] for _, item := range list { if value, ok := item.(string); ok { admin.Zap.FileOnlyModules = append(admin.Zap.FileOnlyModules, value) } } } if data.Redis == nil { data.Redis = &conf.Data_Redis{} } applyCompatibleRedis(data.Redis, objectMap(values["redis"])) if items, ok := values["redis-list"].([]any); ok { current := make(map[string]*conf.Data_Redis, len(data.RedisList)) for _, item := range data.RedisList { if item != nil { current[item.Name] = item } } data.RedisList = make([]*conf.Data_Redis, 0, len(items)) for _, item := range items { values := objectMap(item) target := current[stringValue(values, "name")] if target == nil { target = &conf.Data_Redis{} } applyCompatibleRedis(target, values) data.RedisList = append(data.RedisList, target) } } if mongo := objectMap(values["mongo"]); mongo != nil { if data.Mongo == nil { data.Mongo = &conf.Data_Mongo{} } data.Mongo.Coll, data.Mongo.Options, data.Mongo.Database = stringValue(mongo, "coll"), stringValue(mongo, "options"), stringValue(mongo, "database") data.Mongo.Username, data.Mongo.AuthSource = stringValue(mongo, "username"), stringValue(mongo, "auth-source") if password := stringValue(mongo, "password"); password != "" && password != "******" { data.Mongo.Password = password } data.Mongo.MinPoolSize = uint64(int64Value(mongo, "min-pool-size", int64(data.Mongo.MinPoolSize))) data.Mongo.MaxPoolSize = uint64(int64Value(mongo, "max-pool-size", int64(data.Mongo.MaxPoolSize))) data.Mongo.SocketTimeoutMs = int64Value(mongo, "socket-timeout-ms", data.Mongo.SocketTimeoutMs) data.Mongo.ConnectTimeoutMs = int64Value(mongo, "connect-timeout-ms", data.Mongo.ConnectTimeoutMs) data.Mongo.IsZap = boolValue(mongo, "is-zap", data.Mongo.IsZap) if hosts, ok := mongo["hosts"].([]any); ok { data.Mongo.Hosts = make([]*conf.Data_MongoHost, 0, len(hosts)) for _, item := range hosts { host := objectMap(item) data.Mongo.Hosts = append(data.Mongo.Hosts, &conf.Data_MongoHost{Host: stringValue(host, "host"), Port: stringValue(host, "port")}) } } } if items, ok := values["db-list"].([]any); ok { current := make(map[string]*conf.Data_Database, len(data.DatabaseList)) for _, item := range data.DatabaseList { if item != nil { current[item.AliasName] = item } } data.DatabaseList = make([]*conf.Data_Database, 0, len(items)) for _, item := range items { row := objectMap(item) alias := stringValue(row, "alias-name") target := current[alias] if target == nil { target = &conf.Data_Database{} } applyCompatibleDatabase(target, row, stringValue(row, "type")) target.AliasName, target.Disable = alias, boolValue(row, "disable", target.Disable) data.DatabaseList = append(data.DatabaseList, target) } } if admin.Email == nil { admin.Email = &conf.AdminBackend_Email{} } email := objectMap(values["email"]) admin.Email.To, admin.Email.From, admin.Email.Host = stringValue(email, "to"), stringValue(email, "from"), stringValue(email, "host") admin.Email.Nickname = stringValue(email, "nickname") admin.Email.Port = int32(int64Value(email, "port", int64(admin.Email.Port))) admin.Email.IsSsl, admin.Email.IsLoginAuth = boolValue(email, "is-ssl", admin.Email.IsSsl), boolValue(email, "is-loginauth", admin.Email.IsLoginAuth) if secret := stringValue(email, "secret"); secret != "" && secret != "******" { admin.Email.Secret = secret } if admin.Local == nil { admin.Local = &conf.AdminBackend_Local{} } local := objectMap(values["local"]) admin.Local.PathPrefix, admin.Local.StorePath = stringValue(local, "path"), stringValue(local, "store-path") if admin.Media == nil { admin.Media = &conf.AdminBackend_Media{} } media := objectMap(values["media"]) admin.Media.SessionTtl = int32(int64Value(media, "session-ttl", int64(admin.Media.SessionTtl))) admin.Media.MaxFileSize = int64Value(media, "max-file-size", admin.Media.MaxFileSize) admin.Media.ChunkDir = stringValue(media, "chunk-dir") if admin.Storage.Qiniu == nil { admin.Storage.Qiniu = &conf.AdminBackend_Qiniu{} } qiniu := objectMap(values["qiniu"]) admin.Storage.Qiniu.Zone, admin.Storage.Qiniu.Bucket, admin.Storage.Qiniu.BaseUrl = stringValue(qiniu, "zone"), stringValue(qiniu, "bucket"), stringValue(qiniu, "img-path") admin.Storage.Qiniu.AccessKey = stringValue(qiniu, "access-key") if secret := stringValue(qiniu, "secret-key"); secret != "" && secret != "******" { admin.Storage.Qiniu.SecretKey = secret } admin.Storage.Qiniu.UseHttps, admin.Storage.Qiniu.UseCdnDomains = boolValue(qiniu, "use-https", admin.Storage.Qiniu.UseHttps), boolValue(qiniu, "use-cdn-domains", admin.Storage.Qiniu.UseCdnDomains) stores := []struct { key string target **conf.AdminBackend_ObjectStore }{{"aliyun-oss", &admin.Storage.AliyunOss}, {"hua-wei-obs", &admin.Storage.HuaweiObs}, {"tencent-cos", &admin.Storage.TencentCos}, {"aws-s3", &admin.Storage.AwsS3}, {"cloudflare-r2", &admin.Storage.CloudflareR2}, {"minio", &admin.Storage.Minio}} for _, store := range stores { if *store.target == nil { *store.target = &conf.AdminBackend_ObjectStore{} } applyCompatibleObjectStore(*store.target, objectMap(values[store.key])) } if disks, ok := values["disk-list"].([]any); ok { admin.DiskList = make([]*conf.AdminBackend_Disk, 0, len(disks)) for _, item := range disks { disk := objectMap(item) admin.DiskList = append(admin.DiskList, &conf.AdminBackend_Disk{MountPoint: stringValue(disk, "mount-point")}) } } if cors := objectMap(values["cors"]); cors != nil { if admin.Cors == nil { admin.Cors = &conf.AdminBackend_CORS{} } admin.Cors.Mode = stringValue(cors, "mode") if rules, ok := cors["whitelist"].([]any); ok { admin.Cors.Whitelist = make([]*conf.AdminBackend_CORSRule, 0, len(rules)) for _, item := range rules { rule := objectMap(item) admin.Cors.Whitelist = append(admin.Cors.Whitelist, &conf.AdminBackend_CORSRule{AllowOrigin: stringValue(rule, "allow-origin"), AllowMethods: stringValue(rule, "allow-methods"), AllowHeaders: stringValue(rule, "allow-headers"), ExposeHeaders: stringValue(rule, "expose-headers"), AllowCredentials: boolValue(rule, "allow-credentials", false)}) } } } if app := objectMap(values["app"]); app != nil { if admin.App == nil { admin.App = &conf.AdminBackend_App{} } admin.App.Node, admin.App.AppId, admin.App.Env = stringValue(app, "node"), stringValue(app, "app-id"), stringValue(app, "env") } return nil }