diff options
Diffstat (limited to '')
-rw-r--r-- | modules/setting/config.go | 98 | ||||
-rw-r--r-- | modules/setting/config/getter.go | 49 | ||||
-rw-r--r-- | modules/setting/config/value.go | 94 | ||||
-rw-r--r-- | modules/setting/config_env.go | 170 | ||||
-rw-r--r-- | modules/setting/config_env_test.go | 151 | ||||
-rw-r--r-- | modules/setting/config_provider.go | 360 | ||||
-rw-r--r-- | modules/setting/config_provider_test.go | 157 |
7 files changed, 1079 insertions, 0 deletions
diff --git a/modules/setting/config.go b/modules/setting/config.go new file mode 100644 index 0000000..0355857 --- /dev/null +++ b/modules/setting/config.go @@ -0,0 +1,98 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "sync" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting/config" +) + +type PictureStruct struct { + DisableGravatar *config.Value[bool] + EnableFederatedAvatar *config.Value[bool] +} + +type OpenWithEditorApp struct { + DisplayName string + OpenURL string +} + +type OpenWithEditorAppsType []OpenWithEditorApp + +func (t OpenWithEditorAppsType) ToTextareaString() string { + ret := "" + for _, app := range t { + ret += app.DisplayName + " = " + app.OpenURL + "\n" + } + return ret +} + +func DefaultOpenWithEditorApps() OpenWithEditorAppsType { + return OpenWithEditorAppsType{ + { + DisplayName: "VS Code", + OpenURL: "vscode://vscode.git/clone?url={url}", + }, + { + DisplayName: "VSCodium", + OpenURL: "vscodium://vscode.git/clone?url={url}", + }, + { + DisplayName: "Intellij IDEA", + OpenURL: "jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo={url}", + }, + } +} + +type RepositoryStruct struct { + OpenWithEditorApps *config.Value[OpenWithEditorAppsType] +} + +type ConfigStruct struct { + Picture *PictureStruct + Repository *RepositoryStruct +} + +var ( + defaultConfig *ConfigStruct + defaultConfigOnce sync.Once +) + +func initDefaultConfig() { + config.SetCfgSecKeyGetter(&cfgSecKeyGetter{}) + defaultConfig = &ConfigStruct{ + Picture: &PictureStruct{ + DisableGravatar: config.ValueJSON[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}), + EnableFederatedAvatar: config.ValueJSON[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}), + }, + Repository: &RepositoryStruct{ + OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"), + }, + } +} + +func Config() *ConfigStruct { + defaultConfigOnce.Do(initDefaultConfig) + return defaultConfig +} + +type cfgSecKeyGetter struct{} + +func (c cfgSecKeyGetter) GetValue(sec, key string) (v string, has bool) { + if key == "" { + return "", false + } + cfgSec, err := CfgProvider.GetSection(sec) + if err != nil { + log.Error("Unable to get config section: %q", sec) + return "", false + } + cfgKey := ConfigSectionKey(cfgSec, key) + if cfgKey == nil { + return "", false + } + return cfgKey.Value(), true +} diff --git a/modules/setting/config/getter.go b/modules/setting/config/getter.go new file mode 100644 index 0000000..99f9a47 --- /dev/null +++ b/modules/setting/config/getter.go @@ -0,0 +1,49 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "sync" +) + +var getterMu sync.RWMutex + +type CfgSecKeyGetter interface { + GetValue(sec, key string) (v string, has bool) +} + +var cfgSecKeyGetterInternal CfgSecKeyGetter + +func SetCfgSecKeyGetter(p CfgSecKeyGetter) { + getterMu.Lock() + cfgSecKeyGetterInternal = p + getterMu.Unlock() +} + +func GetCfgSecKeyGetter() CfgSecKeyGetter { + getterMu.RLock() + defer getterMu.RUnlock() + return cfgSecKeyGetterInternal +} + +type DynKeyGetter interface { + GetValue(ctx context.Context, key string) (v string, has bool) + GetRevision(ctx context.Context) int + InvalidateCache() +} + +var dynKeyGetterInternal DynKeyGetter + +func SetDynGetter(p DynKeyGetter) { + getterMu.Lock() + dynKeyGetterInternal = p + getterMu.Unlock() +} + +func GetDynGetter() DynKeyGetter { + getterMu.RLock() + defer getterMu.RUnlock() + return dynKeyGetterInternal +} diff --git a/modules/setting/config/value.go b/modules/setting/config/value.go new file mode 100644 index 0000000..f0ec120 --- /dev/null +++ b/modules/setting/config/value.go @@ -0,0 +1,94 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "sync" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +type CfgSecKey struct { + Sec, Key string +} + +type Value[T any] struct { + mu sync.RWMutex + + cfgSecKey CfgSecKey + dynKey string + + def, value T + revision int +} + +func (value *Value[T]) parse(key, valStr string) (v T) { + v = value.def + if valStr != "" { + if err := json.Unmarshal(util.UnsafeStringToBytes(valStr), &v); err != nil { + log.Error("Unable to unmarshal json config for key %q, err: %v", key, err) + } + } + return v +} + +func (value *Value[T]) Value(ctx context.Context) (v T) { + dg := GetDynGetter() + if dg == nil { + // this is an edge case: the database is not initialized but the system setting is going to be used + // it should panic to avoid inconsistent config values (from config / system setting) and fix the code + panic("no config dyn value getter") + } + + rev := dg.GetRevision(ctx) + + // if the revision in database doesn't change, use the last value + value.mu.RLock() + if rev == value.revision { + v = value.value + value.mu.RUnlock() + return v + } + value.mu.RUnlock() + + // try to parse the config and cache it + var valStr *string + if dynVal, has := dg.GetValue(ctx, value.dynKey); has { + valStr = &dynVal + } else if cfgVal, has := GetCfgSecKeyGetter().GetValue(value.cfgSecKey.Sec, value.cfgSecKey.Key); has { + valStr = &cfgVal + } + if valStr == nil { + v = value.def + } else { + v = value.parse(value.dynKey, *valStr) + } + + value.mu.Lock() + value.value = v + value.revision = rev + value.mu.Unlock() + return v +} + +func (value *Value[T]) DynKey() string { + return value.dynKey +} + +func (value *Value[T]) WithDefault(def T) *Value[T] { + value.def = def + return value +} + +func (value *Value[T]) WithFileConfig(cfgSecKey CfgSecKey) *Value[T] { + value.cfgSecKey = cfgSecKey + return value +} + +func ValueJSON[T any](dynKey string) *Value[T] { + return &Value[T]{dynKey: dynKey} +} diff --git a/modules/setting/config_env.go b/modules/setting/config_env.go new file mode 100644 index 0000000..fa0100d --- /dev/null +++ b/modules/setting/config_env.go @@ -0,0 +1,170 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "bytes" + "os" + "regexp" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/log" +) + +const ( + EnvConfigKeyPrefixGitea = "^(FORGEJO|GITEA)__" + EnvConfigKeySuffixFile = "__FILE" +) + +const escapeRegexpString = "_0[xX](([0-9a-fA-F][0-9a-fA-F])+)_" + +var escapeRegex = regexp.MustCompile(escapeRegexpString) + +func CollectEnvConfigKeys() (keys []string) { + for _, env := range os.Environ() { + if strings.HasPrefix(env, EnvConfigKeyPrefixGitea) { + k, _, _ := strings.Cut(env, "=") + keys = append(keys, k) + } + } + return keys +} + +func ClearEnvConfigKeys() { + for _, k := range CollectEnvConfigKeys() { + _ = os.Unsetenv(k) + } +} + +// decodeEnvSectionKey will decode a portable string encoded Section__Key pair +// Portable strings are considered to be of the form [A-Z0-9_]* +// We will encode a disallowed value as the UTF8 byte string preceded by _0X and +// followed by _. E.g. _0X2C_ for a '-' and _0X2E_ for '.' +// Section and Key are separated by a plain '__'. +// The entire section can be encoded as a UTF8 byte string +func decodeEnvSectionKey(encoded string) (ok bool, section, key string) { + inKey := false + last := 0 + escapeStringIndices := escapeRegex.FindAllStringIndex(encoded, -1) + for _, unescapeIdx := range escapeStringIndices { + preceding := encoded[last:unescapeIdx[0]] + if !inKey { + if splitter := strings.Index(preceding, "__"); splitter > -1 { + section += preceding[:splitter] + inKey = true + key += preceding[splitter+2:] + } else { + section += preceding + } + } else { + key += preceding + } + toDecode := encoded[unescapeIdx[0]+3 : unescapeIdx[1]-1] + decodedBytes := make([]byte, len(toDecode)/2) + for i := 0; i < len(toDecode)/2; i++ { + // Can ignore error here as we know these should be hexadecimal from the regexp + byteInt, _ := strconv.ParseInt(toDecode[2*i:2*i+2], 16, 0) + decodedBytes[i] = byte(byteInt) + } + if inKey { + key += string(decodedBytes) + } else { + section += string(decodedBytes) + } + last = unescapeIdx[1] + } + remaining := encoded[last:] + if !inKey { + if splitter := strings.Index(remaining, "__"); splitter > -1 { + section += remaining[:splitter] + key += remaining[splitter+2:] + } else { + section += remaining + } + } else { + key += remaining + } + section = strings.ToLower(section) + ok = key != "" + if !ok { + section = "" + key = "" + } + return ok, section, key +} + +// decodeEnvironmentKey decode the environment key to section and key +// The environment key is in the form of GITEA__SECTION__KEY or GITEA__SECTION__KEY__FILE +func decodeEnvironmentKey(prefixRegexp *regexp.Regexp, suffixFile, envKey string) (ok bool, section, key string, useFileValue bool) { //nolint:unparam + if strings.HasSuffix(envKey, suffixFile) { + useFileValue = true + envKey = envKey[:len(envKey)-len(suffixFile)] + } + loc := prefixRegexp.FindStringIndex(envKey) + if loc == nil { + return false, "", "", false + } + ok, section, key = decodeEnvSectionKey(envKey[loc[1]:]) + return ok, section, key, useFileValue +} + +func EnvironmentToConfig(cfg ConfigProvider, envs []string) (changed bool) { + prefixRegexp := regexp.MustCompile(EnvConfigKeyPrefixGitea) + for _, kv := range envs { + idx := strings.IndexByte(kv, '=') + if idx < 0 { + continue + } + + // parse the environment variable to config section name and key name + envKey := kv[:idx] + envValue := kv[idx+1:] + ok, sectionName, keyName, useFileValue := decodeEnvironmentKey(prefixRegexp, EnvConfigKeySuffixFile, envKey) + if !ok { + continue + } + + // use environment value as config value, or read the file content as value if the key indicates a file + keyValue := envValue + if useFileValue { + fileContent, err := os.ReadFile(envValue) + if err != nil { + log.Error("Error reading file for %s : %v", envKey, envValue, err) + continue + } + if bytes.HasSuffix(fileContent, []byte("\r\n")) { + fileContent = fileContent[:len(fileContent)-2] + } else if bytes.HasSuffix(fileContent, []byte("\n")) { + fileContent = fileContent[:len(fileContent)-1] + } + keyValue = string(fileContent) + } + + // try to set the config value if necessary + section, err := cfg.GetSection(sectionName) + if err != nil { + section, err = cfg.NewSection(sectionName) + if err != nil { + log.Error("Error creating section: %s : %v", sectionName, err) + continue + } + } + key := ConfigSectionKey(section, keyName) + if key == nil { + changed = true + key, err = section.NewKey(keyName, keyValue) + if err != nil { + log.Error("Error creating key: %s in section: %s with value: %s : %v", keyName, sectionName, keyValue, err) + continue + } + } + oldValue := key.Value() + if !changed && oldValue != keyValue { + changed = true + } + key.SetValue(keyValue) + } + return changed +} diff --git a/modules/setting/config_env_test.go b/modules/setting/config_env_test.go new file mode 100644 index 0000000..bec3e58 --- /dev/null +++ b/modules/setting/config_env_test.go @@ -0,0 +1,151 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "os" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeEnvSectionKey(t *testing.T) { + ok, section, key := decodeEnvSectionKey("SEC__KEY") + assert.True(t, ok) + assert.Equal(t, "sec", section) + assert.Equal(t, "KEY", key) + + ok, section, key = decodeEnvSectionKey("sec__key") + assert.True(t, ok) + assert.Equal(t, "sec", section) + assert.Equal(t, "key", key) + + ok, section, key = decodeEnvSectionKey("LOG_0x2E_CONSOLE__STDERR") + assert.True(t, ok) + assert.Equal(t, "log.console", section) + assert.Equal(t, "STDERR", key) + + ok, section, key = decodeEnvSectionKey("SEC") + assert.False(t, ok) + assert.Equal(t, "", section) + assert.Equal(t, "", key) +} + +func TestDecodeEnvironmentKey(t *testing.T) { + prefix := regexp.MustCompile(EnvConfigKeyPrefixGitea) + suffix := "__FILE" + + ok, section, key, file := decodeEnvironmentKey(prefix, suffix, "SEC__KEY") + assert.False(t, ok) + assert.Equal(t, "", section) + assert.Equal(t, "", key) + assert.False(t, file) + + ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA__SEC") + assert.False(t, ok) + assert.Equal(t, "", section) + assert.Equal(t, "", key) + assert.False(t, file) + + ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA____KEY") + assert.True(t, ok) + assert.Equal(t, "", section) + assert.Equal(t, "KEY", key) + assert.False(t, file) + + ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA__SEC__KEY") + assert.True(t, ok) + assert.Equal(t, "sec", section) + assert.Equal(t, "KEY", key) + assert.False(t, file) + + ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "FORGEJO__SEC__KEY") + assert.True(t, ok) + assert.Equal(t, "sec", section) + assert.Equal(t, "KEY", key) + assert.False(t, file) + + // with "__FILE" suffix, it doesn't support to write "[sec].FILE" to config (no such key FILE is used in Gitea) + // but it could be fixed in the future by adding a new suffix like "__VALUE" (no such key VALUE is used in Gitea either) + ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA__SEC__FILE") + assert.False(t, ok) + assert.Equal(t, "", section) + assert.Equal(t, "", key) + assert.True(t, file) + + ok, section, key, file = decodeEnvironmentKey(prefix, suffix, "GITEA__SEC__KEY__FILE") + assert.True(t, ok) + assert.Equal(t, "sec", section) + assert.Equal(t, "KEY", key) + assert.True(t, file) +} + +func TestEnvironmentToConfig(t *testing.T) { + cfg, _ := NewConfigProviderFromData("") + + changed := EnvironmentToConfig(cfg, nil) + assert.False(t, changed) + + cfg, err := NewConfigProviderFromData(` +[sec] +key = old +`) + require.NoError(t, err) + + changed = EnvironmentToConfig(cfg, []string{"GITEA__sec__key=new"}) + assert.True(t, changed) + assert.Equal(t, "new", cfg.Section("sec").Key("key").String()) + + changed = EnvironmentToConfig(cfg, []string{"GITEA__sec__key=new"}) + assert.False(t, changed) + + tmpFile := t.TempDir() + "/the-file" + _ = os.WriteFile(tmpFile, []byte("value-from-file"), 0o644) + changed = EnvironmentToConfig(cfg, []string{"GITEA__sec__key__FILE=" + tmpFile}) + assert.True(t, changed) + assert.Equal(t, "value-from-file", cfg.Section("sec").Key("key").String()) + + cfg, _ = NewConfigProviderFromData("") + _ = os.WriteFile(tmpFile, []byte("value-from-file\n"), 0o644) + EnvironmentToConfig(cfg, []string{"GITEA__sec__key__FILE=" + tmpFile}) + assert.Equal(t, "value-from-file", cfg.Section("sec").Key("key").String()) + + cfg, _ = NewConfigProviderFromData("") + _ = os.WriteFile(tmpFile, []byte("value-from-file\r\n"), 0o644) + EnvironmentToConfig(cfg, []string{"GITEA__sec__key__FILE=" + tmpFile}) + assert.Equal(t, "value-from-file", cfg.Section("sec").Key("key").String()) + + cfg, _ = NewConfigProviderFromData("") + _ = os.WriteFile(tmpFile, []byte("value-from-file\n\n"), 0o644) + EnvironmentToConfig(cfg, []string{"GITEA__sec__key__FILE=" + tmpFile}) + assert.Equal(t, "value-from-file\n", cfg.Section("sec").Key("key").String()) +} + +func TestEnvironmentToConfigSubSecKey(t *testing.T) { + // the INI package has a quirk: by default, the keys are inherited. + // when maintaining the keys, the newly added sub key should not be affected by the parent key. + cfg, err := NewConfigProviderFromData(` +[sec] +key = some +`) + require.NoError(t, err) + + changed := EnvironmentToConfig(cfg, []string{"GITEA__sec_0X2E_sub__key=some"}) + assert.True(t, changed) + + tmpFile := t.TempDir() + "/test-sub-sec-key.ini" + defer os.Remove(tmpFile) + err = cfg.SaveTo(tmpFile) + require.NoError(t, err) + bs, err := os.ReadFile(tmpFile) + require.NoError(t, err) + assert.Equal(t, `[sec] +key = some + +[sec.sub] +key = some +`, string(bs)) +} diff --git a/modules/setting/config_provider.go b/modules/setting/config_provider.go new file mode 100644 index 0000000..12cf36a --- /dev/null +++ b/modules/setting/config_provider.go @@ -0,0 +1,360 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + + "gopkg.in/ini.v1" //nolint:depguard +) + +type ConfigKey interface { + Name() string + Value() string + SetValue(v string) + + In(defaultVal string, candidates []string) string + String() string + Strings(delim string) []string + + MustString(defaultVal string) string + MustBool(defaultVal ...bool) bool + MustInt(defaultVal ...int) int + MustInt64(defaultVal ...int64) int64 + MustDuration(defaultVal ...time.Duration) time.Duration +} + +type ConfigSection interface { + Name() string + MapTo(any) error + HasKey(key string) bool + NewKey(name, value string) (ConfigKey, error) + Key(key string) ConfigKey + Keys() []ConfigKey + ChildSections() []ConfigSection +} + +// ConfigProvider represents a config provider +type ConfigProvider interface { + Section(section string) ConfigSection + Sections() []ConfigSection + NewSection(name string) (ConfigSection, error) + GetSection(name string) (ConfigSection, error) + Save() error + SaveTo(filename string) error + + GetFile() string + DisableSaving() + PrepareSaving() (ConfigProvider, error) + IsLoadedFromEmpty() bool +} + +type iniConfigProvider struct { + file string + ini *ini.File + + disableSaving bool // disable the "Save" method because the config options could be polluted + loadedFromEmpty bool // whether the file has not existed previously +} + +type iniConfigSection struct { + sec *ini.Section +} + +var ( + _ ConfigProvider = (*iniConfigProvider)(nil) + _ ConfigSection = (*iniConfigSection)(nil) + _ ConfigKey = (*ini.Key)(nil) +) + +// ConfigSectionKey only searches the keys in the given section, but it is O(n). +// ini package has a special behavior: with "[sec] a=1" and an empty "[sec.sub]", +// then in "[sec.sub]", Key()/HasKey() can always see "a=1" because it always tries parent sections. +// It returns nil if the key doesn't exist. +func ConfigSectionKey(sec ConfigSection, key string) ConfigKey { + if sec == nil { + return nil + } + for _, k := range sec.Keys() { + if k.Name() == key { + return k + } + } + return nil +} + +func ConfigSectionKeyString(sec ConfigSection, key string, def ...string) string { + k := ConfigSectionKey(sec, key) + if k != nil && k.String() != "" { + return k.String() + } + if len(def) > 0 { + return def[0] + } + return "" +} + +func ConfigSectionKeyBool(sec ConfigSection, key string, def ...bool) bool { + k := ConfigSectionKey(sec, key) + if k != nil && k.String() != "" { + b, _ := strconv.ParseBool(k.String()) + return b + } + if len(def) > 0 { + return def[0] + } + return false +} + +// ConfigInheritedKey works like ini.Section.Key(), but it always returns a new key instance, it is O(n) because NewKey is O(n) +// and the returned key is safe to be used with "MustXxx", it doesn't change the parent's values. +// Otherwise, ini.Section.Key().MustXxx would pollute the parent section's keys. +// It never returns nil. +func ConfigInheritedKey(sec ConfigSection, key string) ConfigKey { + k := sec.Key(key) + if k != nil && k.String() != "" { + newKey, _ := sec.NewKey(k.Name(), k.String()) + return newKey + } + newKey, _ := sec.NewKey(key, "") + return newKey +} + +func ConfigInheritedKeyString(sec ConfigSection, key string, def ...string) string { + k := sec.Key(key) + if k != nil && k.String() != "" { + return k.String() + } + if len(def) > 0 { + return def[0] + } + return "" +} + +func (s *iniConfigSection) Name() string { + return s.sec.Name() +} + +func (s *iniConfigSection) MapTo(v any) error { + return s.sec.MapTo(v) +} + +func (s *iniConfigSection) HasKey(key string) bool { + return s.sec.HasKey(key) +} + +func (s *iniConfigSection) NewKey(name, value string) (ConfigKey, error) { + return s.sec.NewKey(name, value) +} + +func (s *iniConfigSection) Key(key string) ConfigKey { + return s.sec.Key(key) +} + +func (s *iniConfigSection) Keys() (keys []ConfigKey) { + for _, k := range s.sec.Keys() { + keys = append(keys, k) + } + return keys +} + +func (s *iniConfigSection) ChildSections() (sections []ConfigSection) { + for _, s := range s.sec.ChildSections() { + sections = append(sections, &iniConfigSection{s}) + } + return sections +} + +func configProviderLoadOptions() ini.LoadOptions { + return ini.LoadOptions{ + KeyValueDelimiterOnWrite: " = ", + IgnoreContinuation: true, + } +} + +// NewConfigProviderFromData this function is mainly for testing purpose +func NewConfigProviderFromData(configContent string) (ConfigProvider, error) { + cfg, err := ini.LoadSources(configProviderLoadOptions(), strings.NewReader(configContent)) + if err != nil { + return nil, err + } + cfg.NameMapper = ini.SnackCase + return &iniConfigProvider{ + ini: cfg, + loadedFromEmpty: true, + }, nil +} + +// NewConfigProviderFromFile load configuration from file. +// NOTE: do not print any log except error. +func NewConfigProviderFromFile(file string) (ConfigProvider, error) { + cfg := ini.Empty(configProviderLoadOptions()) + loadedFromEmpty := true + + if file != "" { + isFile, err := util.IsFile(file) + if err != nil { + return nil, fmt.Errorf("unable to check if %q is a file. Error: %v", file, err) + } + if isFile { + if err = cfg.Append(file); err != nil { + return nil, fmt.Errorf("failed to load config file %q: %v", file, err) + } + loadedFromEmpty = false + } + } + + cfg.NameMapper = ini.SnackCase + return &iniConfigProvider{ + file: file, + ini: cfg, + loadedFromEmpty: loadedFromEmpty, + }, nil +} + +func (p *iniConfigProvider) Section(section string) ConfigSection { + return &iniConfigSection{sec: p.ini.Section(section)} +} + +func (p *iniConfigProvider) Sections() (sections []ConfigSection) { + for _, s := range p.ini.Sections() { + sections = append(sections, &iniConfigSection{s}) + } + return sections +} + +func (p *iniConfigProvider) NewSection(name string) (ConfigSection, error) { + sec, err := p.ini.NewSection(name) + if err != nil { + return nil, err + } + return &iniConfigSection{sec: sec}, nil +} + +func (p *iniConfigProvider) GetSection(name string) (ConfigSection, error) { + sec, err := p.ini.GetSection(name) + if err != nil { + return nil, err + } + return &iniConfigSection{sec: sec}, nil +} + +var errDisableSaving = errors.New("this config can't be saved, developers should prepare a new config to save") + +func (p *iniConfigProvider) GetFile() string { + return p.file +} + +// Save saves the content into file +func (p *iniConfigProvider) Save() error { + if p.disableSaving { + return errDisableSaving + } + filename := p.file + if filename == "" { + return fmt.Errorf("config file path must not be empty") + } + if p.loadedFromEmpty { + if err := os.MkdirAll(filepath.Dir(filename), os.ModePerm); err != nil { + return fmt.Errorf("failed to create %q: %v", filename, err) + } + } + if err := p.ini.SaveTo(filename); err != nil { + return fmt.Errorf("failed to save %q: %v", filename, err) + } + + // Change permissions to be more restrictive + fi, err := os.Stat(filename) + if err != nil { + return fmt.Errorf("failed to determine current conf file permissions: %v", err) + } + + if fi.Mode().Perm() > 0o600 { + if err = os.Chmod(filename, 0o600); err != nil { + log.Warn("Failed changing conf file permissions to -rw-------. Consider changing them manually.") + } + } + return nil +} + +func (p *iniConfigProvider) SaveTo(filename string) error { + if p.disableSaving { + return errDisableSaving + } + return p.ini.SaveTo(filename) +} + +// DisableSaving disables the saving function, use PrepareSaving to get clear config options. +func (p *iniConfigProvider) DisableSaving() { + p.disableSaving = true +} + +// PrepareSaving loads the ini from file again to get clear config options. +// Otherwise, the "MustXxx" calls would have polluted the current config provider, +// it makes the "Save" outputs a lot of garbage options +// After the INI package gets refactored, no "MustXxx" pollution, this workaround can be dropped. +func (p *iniConfigProvider) PrepareSaving() (ConfigProvider, error) { + if p.file == "" { + return nil, errors.New("no config file to save") + } + return NewConfigProviderFromFile(p.file) +} + +func (p *iniConfigProvider) IsLoadedFromEmpty() bool { + return p.loadedFromEmpty +} + +func mustMapSetting(rootCfg ConfigProvider, sectionName string, setting any) { + if err := rootCfg.Section(sectionName).MapTo(setting); err != nil { + log.Fatal("Failed to map %s settings: %v", sectionName, err) + } +} + +// DeprecatedWarnings contains the warning message for various deprecations, including: setting option, file/folder, etc +var DeprecatedWarnings []string + +func deprecatedSetting(rootCfg ConfigProvider, oldSection, oldKey, newSection, newKey, version string) { + if rootCfg.Section(oldSection).HasKey(oldKey) { + msg := fmt.Sprintf("Deprecated config option `[%s]` `%s` present. Use `[%s]` `%s` instead. This fallback will be/has been removed in %s", oldSection, oldKey, newSection, newKey, version) + log.Error("%v", msg) + DeprecatedWarnings = append(DeprecatedWarnings, msg) + } +} + +// deprecatedSettingDB add a hint that the configuration has been moved to database but still kept in app.ini +func deprecatedSettingDB(rootCfg ConfigProvider, oldSection, oldKey string) { + if rootCfg.Section(oldSection).HasKey(oldKey) { + log.Error("Deprecated `[%s]` `%s` present which has been copied to database table sys_setting", oldSection, oldKey) + } +} + +// NewConfigProviderForLocale loads locale configuration from source and others. "string" if for a local file path, "[]byte" is for INI content +func NewConfigProviderForLocale(source any, others ...any) (ConfigProvider, error) { + iniFile, err := ini.LoadSources(ini.LoadOptions{ + IgnoreInlineComment: true, + UnescapeValueCommentSymbols: true, + IgnoreContinuation: true, + }, source, others...) + if err != nil { + return nil, fmt.Errorf("unable to load locale ini: %w", err) + } + iniFile.BlockMode = false + return &iniConfigProvider{ + ini: iniFile, + loadedFromEmpty: true, + }, nil +} + +func init() { + ini.PrettyFormat = false +} diff --git a/modules/setting/config_provider_test.go b/modules/setting/config_provider_test.go new file mode 100644 index 0000000..702be80 --- /dev/null +++ b/modules/setting/config_provider_test.go @@ -0,0 +1,157 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigProviderBehaviors(t *testing.T) { + t.Run("BuggyKeyOverwritten", func(t *testing.T) { + cfg, _ := NewConfigProviderFromData(` +[foo] +key = +`) + sec := cfg.Section("foo") + secSub := cfg.Section("foo.bar") + secSub.Key("key").MustString("1") // try to read a key from subsection + assert.Equal(t, "1", sec.Key("key").String()) // TODO: BUGGY! the key in [foo] is overwritten + }) + + t.Run("SubsectionSeeParentKeys", func(t *testing.T) { + cfg, _ := NewConfigProviderFromData(` +[foo] +key = 123 +`) + secSub := cfg.Section("foo.bar.xxx") + assert.Equal(t, "123", secSub.Key("key").String()) + }) + t.Run("TrailingSlash", func(t *testing.T) { + cfg, _ := NewConfigProviderFromData(` +[foo] +key = E:\ +xxx = yyy +`) + sec := cfg.Section("foo") + assert.Equal(t, "E:\\", sec.Key("key").String()) + assert.Equal(t, "yyy", sec.Key("xxx").String()) + }) +} + +func TestConfigProviderHelper(t *testing.T) { + cfg, _ := NewConfigProviderFromData(` +[foo] +empty = +key = 123 +`) + + sec := cfg.Section("foo") + secSub := cfg.Section("foo.bar") + + // test empty key + assert.Equal(t, "def", ConfigSectionKeyString(sec, "empty", "def")) + assert.Equal(t, "xyz", ConfigSectionKeyString(secSub, "empty", "xyz")) + + // test non-inherited key, only see the keys in current section + assert.NotNil(t, ConfigSectionKey(sec, "key")) + assert.Nil(t, ConfigSectionKey(secSub, "key")) + + // test default behavior + assert.Equal(t, "123", ConfigSectionKeyString(sec, "key")) + assert.Equal(t, "", ConfigSectionKeyString(secSub, "key")) + assert.Equal(t, "def", ConfigSectionKeyString(secSub, "key", "def")) + + assert.Equal(t, "123", ConfigInheritedKeyString(secSub, "key")) + + // Workaround for ini package's BuggyKeyOverwritten behavior + assert.Equal(t, "", ConfigSectionKeyString(sec, "empty")) + assert.Equal(t, "", ConfigSectionKeyString(secSub, "empty")) + assert.Equal(t, "def", ConfigInheritedKey(secSub, "empty").MustString("def")) + assert.Equal(t, "def", ConfigInheritedKey(secSub, "empty").MustString("xyz")) + assert.Equal(t, "", ConfigSectionKeyString(sec, "empty")) + assert.Equal(t, "def", ConfigSectionKeyString(secSub, "empty")) +} + +func TestNewConfigProviderFromFile(t *testing.T) { + cfg, err := NewConfigProviderFromFile("no-such.ini") + require.NoError(t, err) + assert.True(t, cfg.IsLoadedFromEmpty()) + + // load non-existing file and save + testFile := t.TempDir() + "/test.ini" + testFile1 := t.TempDir() + "/test1.ini" + cfg, err = NewConfigProviderFromFile(testFile) + require.NoError(t, err) + + sec, _ := cfg.NewSection("foo") + _, _ = sec.NewKey("k1", "a") + require.NoError(t, cfg.Save()) + _, _ = sec.NewKey("k2", "b") + require.NoError(t, cfg.SaveTo(testFile1)) + + bs, err := os.ReadFile(testFile) + require.NoError(t, err) + assert.Equal(t, "[foo]\nk1 = a\n", string(bs)) + + bs, err = os.ReadFile(testFile1) + require.NoError(t, err) + assert.Equal(t, "[foo]\nk1 = a\nk2 = b\n", string(bs)) + + // load existing file and save + cfg, err = NewConfigProviderFromFile(testFile) + require.NoError(t, err) + assert.Equal(t, "a", cfg.Section("foo").Key("k1").String()) + sec, _ = cfg.NewSection("bar") + _, _ = sec.NewKey("k1", "b") + require.NoError(t, cfg.Save()) + bs, err = os.ReadFile(testFile) + require.NoError(t, err) + assert.Equal(t, "[foo]\nk1 = a\n\n[bar]\nk1 = b\n", string(bs)) +} + +func TestNewConfigProviderForLocale(t *testing.T) { + // load locale from file + localeFile := t.TempDir() + "/locale.ini" + _ = os.WriteFile(localeFile, []byte(`k1=a`), 0o644) + cfg, err := NewConfigProviderForLocale(localeFile) + require.NoError(t, err) + assert.Equal(t, "a", cfg.Section("").Key("k1").String()) + + // load locale from bytes + cfg, err = NewConfigProviderForLocale([]byte("k1=foo\nk2=bar")) + require.NoError(t, err) + assert.Equal(t, "foo", cfg.Section("").Key("k1").String()) + cfg, err = NewConfigProviderForLocale([]byte("k1=foo\nk2=bar"), []byte("k2=xxx")) + require.NoError(t, err) + assert.Equal(t, "foo", cfg.Section("").Key("k1").String()) + assert.Equal(t, "xxx", cfg.Section("").Key("k2").String()) +} + +func TestDisableSaving(t *testing.T) { + testFile := t.TempDir() + "/test.ini" + _ = os.WriteFile(testFile, []byte("k1=a\nk2=b"), 0o644) + cfg, err := NewConfigProviderFromFile(testFile) + require.NoError(t, err) + + cfg.DisableSaving() + err = cfg.Save() + require.ErrorIs(t, err, errDisableSaving) + + saveCfg, err := cfg.PrepareSaving() + require.NoError(t, err) + + saveCfg.Section("").Key("k1").MustString("x") + saveCfg.Section("").Key("k2").SetValue("y") + saveCfg.Section("").Key("k3").SetValue("z") + err = saveCfg.Save() + require.NoError(t, err) + + bs, err := os.ReadFile(testFile) + require.NoError(t, err) + assert.Equal(t, "k1 = a\nk2 = y\nk3 = z\n", string(bs)) +} |