summaryrefslogtreecommitdiffstats
path: root/modules/setting/config
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /modules/setting/config
parentInitial commit. (diff)
downloadforgejo-debian.tar.xz
forgejo-debian.zip
Adding upstream version 9.0.0.HEADupstream/9.0.0upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--modules/setting/config.go98
-rw-r--r--modules/setting/config/getter.go49
-rw-r--r--modules/setting/config/value.go94
-rw-r--r--modules/setting/config_env.go170
-rw-r--r--modules/setting/config_env_test.go151
-rw-r--r--modules/setting/config_provider.go360
-rw-r--r--modules/setting/config_provider_test.go157
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))
+}