diff --git a/models/db/setting.go b/models/db/setting.go new file mode 100644 index 0000000000000..2d3bb759cd121 --- /dev/null +++ b/models/db/setting.go @@ -0,0 +1,241 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package db + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/cache" + setting_module "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +type ResourceSetting struct { + ID int64 `xorm:"pk autoincr"` + GroupID int64 `xorm:"index unique(key_groupid)"` // to load all of some group's settings + SettingKey string `xorm:"varchar(255) index unique(key_groupid)"` // ensure key is always lowercase + SettingValue string `xorm:"text"` + Version int `xorm:"version"` // prevent to override + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` +} + +func (s *ResourceSetting) AsBool() bool { + if s == nil { + return false + } + + b, _ := strconv.ParseBool(s.SettingValue) + return b +} + +// GetSettings returns specific settings +func GetSettings(ctx context.Context, tableName string, groupID int64, keys []string) (map[string]*ResourceSetting, error) { + for i := range keys { + keys[i] = strings.ToLower(keys[i]) + } + settings := make([]*ResourceSetting, 0, len(keys)) + if err := GetEngine(ctx). + Table(tableName). + Where("group_id=?", groupID). + And(builder.In("setting_key", keys)). + Find(&settings); err != nil { + return nil, err + } + settingsMap := make(map[string]*ResourceSetting, len(settings)) + for _, s := range settings { + settingsMap[s.SettingKey] = s + } + return settingsMap, nil +} + +func ValidateSettingKey(key string) error { + if len(key) == 0 { + return fmt.Errorf("setting key must be set") + } + if strings.ToLower(key) != key { + return fmt.Errorf("setting key should be lowercase") + } + return nil +} + +// genSettingCacheKey returns the cache key for some configuration +func genSettingCacheKey(tableName string, groupID int64, key string) string { + return fmt.Sprintf("%s.setting.%d.%s", tableName, groupID, key) +} + +// GetSettingNoCache returns specific setting without using the cache +func GetSettingNoCache(ctx context.Context, tableName string, groupID int64, key string) (*ResourceSetting, error) { + v, err := GetSettings(ctx, tableName, groupID, []string{key}) + if err != nil { + return nil, err + } + if len(v) == 0 { + return nil, fmt.Errorf("%s[%d][%s]: %w", tableName, groupID, key, util.ErrNotExist) + } + return v[strings.ToLower(key)], nil +} + +// GetSetting returns the setting value via the key +func GetSetting(ctx context.Context, tableName string, groupID int64, key string) (string, error) { + if err := ValidateSettingKey(key); err != nil { + return "", err + } + return cache.GetString(genSettingCacheKey(tableName, groupID, key), func() (string, error) { + res, err := GetSettingNoCache(ctx, tableName, groupID, key) + if err != nil { + return "", err + } + return res.SettingValue, nil + }) +} + +// GetSettingBool return bool value of setting, +// none existing keys and errors are ignored and result in false +func GetSettingBool(ctx context.Context, tableName string, groupID int64, key string) bool { + s, _ := GetSetting(ctx, tableName, groupID, key) + v, _ := strconv.ParseBool(s) + return v +} + +type AllSettings map[string]*ResourceSetting + +func (settings AllSettings) Get(key string) ResourceSetting { + if v, ok := settings[strings.ToLower(key)]; ok { + return *v + } + return ResourceSetting{} +} + +func (settings AllSettings) AsBool(key string) bool { + b, _ := strconv.ParseBool(settings.Get(key).SettingValue) + return b +} + +func (settings AllSettings) GetVersion(key string) int { + return settings.Get(key).Version +} + +// GetAllSettings returns all settings from repo +func GetAllSettings(ctx context.Context, tableName string, groupID int64) (AllSettings, error) { + settings := make([]*ResourceSetting, 0, 5) + if err := GetEngine(ctx). + Table(tableName). + Where("group_id=?", groupID). + Find(&settings); err != nil { + return nil, err + } + settingsMap := make(map[string]*ResourceSetting, len(settings)) + for _, s := range settings { + settingsMap[s.SettingKey] = s + } + return settingsMap, nil +} + +// DeleteSetting deletes a specific setting for a repo +func DeleteSetting(ctx context.Context, tableName string, groupID int64, key string) error { + if err := ValidateSettingKey(key); err != nil { + return err + } + cache.Remove(genSettingCacheKey(tableName, groupID, key)) + _, err := GetEngine(ctx).Table(tableName).Delete(&ResourceSetting{GroupID: groupID, SettingKey: key}) + return err +} + +func SetSettingNoVersion(ctx context.Context, tableName string, groupID int64, key, value string) error { + s, err := GetSettingNoCache(ctx, tableName, groupID, key) + if errors.Is(err, util.ErrNotExist) { + return SetSetting(ctx, tableName, &ResourceSetting{ + GroupID: groupID, + SettingKey: key, + SettingValue: value, + }) + } + if err != nil { + return err + } + s.SettingValue = value + return SetSetting(ctx, tableName, s) +} + +// SetSetting updates a users' setting for a specific key +func SetSetting(ctx context.Context, tableName string, setting *ResourceSetting) error { + if err := upsertSettingValue(ctx, tableName, setting.GroupID, strings.ToLower(setting.SettingKey), setting.SettingValue, setting.Version); err != nil { + return err + } + + setting.Version++ + + cc := cache.GetCache() + if cc != nil { + return cc.Put(genSettingCacheKey(tableName, setting.GroupID, setting.SettingKey), setting.SettingValue, setting_module.CacheService.TTLSeconds()) + } + + return nil +} + +func upsertSettingValue(ctx context.Context, tableName string, groupID int64, key, value string, version int) error { + return WithTx(ctx, func(ctx context.Context) error { + e := GetEngine(ctx) + + // here we use a general method to do a safe upsert for different databases (and most transaction levels) + // 1. try to UPDATE the record and acquire the transaction write lock + // if UPDATE returns non-zero rows are changed, OK, the setting is saved correctly + // if UPDATE returns "0 rows changed", two possibilities: (a) record doesn't exist (b) value is not changed + // 2. do a SELECT to check if the row exists or not (we already have the transaction lock) + // 3. if the row doesn't exist, do an INSERT (we are still protected by the transaction lock, so it's safe) + // + // to optimize the SELECT in step 2, we can use an extra column like `revision=revision+1` + // to make sure the UPDATE always returns a non-zero value for existing (unchanged) records. + + res, err := e.Exec(fmt.Sprintf("UPDATE %s SET setting_value=?, version = version+1 WHERE group_id=? AND setting_key=? AND version=?", tableName), value, groupID, key, version) + if err != nil { + return err + } + rows, _ := res.RowsAffected() + if rows > 0 { + // the existing row is updated, so we can return + return nil + } + + // in case the value isn't changed, update would return 0 rows changed, so we need this check + has, err := e.Table(tableName).Exist(&ResourceSetting{GroupID: groupID, SettingKey: key}) + if err != nil { + return err + } + if has { + return nil + } + + // if no existing row, insert a new row + _, err = e.Table(tableName).Insert(&ResourceSetting{GroupID: groupID, SettingKey: key, SettingValue: value}) + return err + }) +} + +type ResourceSettingKey struct { + ID int64 + KeyName string `xorm:"unique"` +} + +// GetResourceSettingKeyID get key id by key name +func GetResourceSettingKeyID(ctx context.Context, keyname string) (int64, error) { + key := &ResourceSettingKey{KeyName: keyname} + has, err := GetEngine(ctx).Get(key) + if err != nil { + return 0, err + } else if !has { + if err := Insert(ctx, key); err != nil { + return 0, err + } + } + return key.ID, nil +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 6224e1c8d7c0f..a61d5b3d66ca8 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -475,6 +475,8 @@ var migrations = []Migration{ NewMigration("Fix incorrect project type", v1_20.FixIncorrectProjectType), // v248 -> v249 NewMigration("Add version column to action_runner table", v1_20.AddVersionToActionRunner), + // v249 -> v250 + NewMigration("Create key/value table for repo settings", v1_20.CreateRepoSettingsTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_20/v249.go b/models/migrations/v1_20/v249.go new file mode 100644 index 0000000000000..1d783ca7b2642 --- /dev/null +++ b/models/migrations/v1_20/v249.go @@ -0,0 +1,23 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_20 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func CreateRepoSettingsTable(x *xorm.Engine) error { + type RepoSetting struct { + ID int64 `xorm:"pk autoincr"` + GroupID int64 `xorm:"index unique(key_groupid)"` // to load all of some group's settings + SettingKey string `xorm:"varchar(255) index unique(key_groupid)"` // ensure key is always lowercase + SettingValue string `xorm:"text"` + Version int `xorm:"version"` // prevent to override + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` + } + return x.Sync(new(RepoSetting)) +} diff --git a/models/repo.go b/models/repo.go index 5a1e2e028e81c..480acfe5f0d39 100644 --- a/models/repo.go +++ b/models/repo.go @@ -160,6 +160,7 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error { &repo_model.Watch{RepoID: repoID}, &webhook.Webhook{RepoID: repoID}, &secret_model.Secret{RepoID: repoID}, + &repo_model.Setting{GroupID: repoID}, &actions_model.ActionTaskStep{RepoID: repoID}, &actions_model.ActionTask{RepoID: repoID}, &actions_model.ActionRunJob{RepoID: repoID}, diff --git a/models/repo/setting.go b/models/repo/setting.go new file mode 100644 index 0000000000000..6de7cecb64dc3 --- /dev/null +++ b/models/repo/setting.go @@ -0,0 +1,57 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "code.gitea.io/gitea/models/db" +) + +type Setting db.ResourceSetting + +const repoSettingTableName = "repo_setting" + +// TableName sets the table name for the settings struct +func (s *Setting) TableName() string { + return repoSettingTableName +} + +func init() { + db.RegisterModel(new(Setting)) +} + +func SetSetting(s *Setting) error { + return db.SetSetting(db.DefaultContext, repoSettingTableName, (*db.ResourceSetting)(s)) +} + +func GetSettings(repoID int64, keys []string) (map[string]*Setting, error) { + resourceSettings, err := db.GetSettings(db.DefaultContext, repoSettingTableName, repoID, keys) + if err != nil { + return nil, err + } + settings := make(map[string]*Setting, len(resourceSettings)) + for key, setting := range resourceSettings { + settings[key] = (*Setting)(setting) + } + return settings, nil +} + +func GetSetting(repoID int64, key string) (string, error) { + return db.GetSetting(db.DefaultContext, repoSettingTableName, repoID, key) +} + +func DeleteSetting(repoID int64, key string) error { + return db.DeleteSetting(db.DefaultContext, repoSettingTableName, repoID, key) +} + +func GetAllSettings(repoID int64) (map[string]*Setting, error) { + resourceSettings, err := db.GetAllSettings(db.DefaultContext, repoSettingTableName, repoID) + if err != nil { + return nil, err + } + settings := make(map[string]*Setting, len(resourceSettings)) + for key, setting := range resourceSettings { + settings[key] = (*Setting)(setting) + } + return settings, nil +} diff --git a/models/repo/setting_test.go b/models/repo/setting_test.go new file mode 100644 index 0000000000000..949b1651e9b64 --- /dev/null +++ b/models/repo/setting_test.go @@ -0,0 +1,60 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" +) + +func TestSettings(t *testing.T) { + keyName := "test_repo_setting" + assert.NoError(t, unittest.PrepareTestDatabase()) + + newSetting := &Setting{GroupID: 99, SettingKey: keyName, SettingValue: "Gitea Repo Setting Test"} + + // create setting + err := SetSetting(newSetting) + assert.NoError(t, err) + // test about saving unchanged values + err = SetSetting(newSetting) + assert.NoError(t, err) + + // get specific setting + settings, err := GetSettings(99, []string{keyName}) + assert.NoError(t, err) + assert.Len(t, settings, 1) + assert.EqualValues(t, newSetting.SettingValue, settings[keyName].SettingValue) + + settingValue, err := GetSetting(99, keyName) + assert.NoError(t, err) + assert.EqualValues(t, newSetting.SettingValue, settingValue) + + settingValue, err = GetSetting(99, "no_such") + assert.True(t, errors.Is(err, util.ErrNotExist)) + assert.EqualValues(t, "", settingValue) + + // updated setting + updatedSetting := &Setting{GroupID: 99, SettingKey: keyName, SettingValue: "Updated", Version: 2} // updated twice + err = SetSetting(updatedSetting) + assert.NoError(t, err) + + // get all settings + settings, err = GetAllSettings(99) + assert.NoError(t, err) + assert.Len(t, settings, 1) + assert.EqualValues(t, updatedSetting.SettingValue, settings[updatedSetting.SettingKey].SettingValue) + + // delete setting + err = DeleteSetting(99, keyName) + assert.NoError(t, err) + settings, err = GetAllSettings(99) + assert.NoError(t, err) + assert.Len(t, settings, 0) +} diff --git a/models/user/setting.go b/models/user/setting.go index aec79b756bf14..40bd74a8ca9ed 100644 --- a/models/user/setting.go +++ b/models/user/setting.go @@ -6,7 +6,6 @@ package user import ( "context" "fmt" - "strings" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/cache" @@ -107,19 +106,9 @@ func GetUserAllSettings(uid int64) (map[string]*Setting, error) { return settingsMap, nil } -func validateUserSettingKey(key string) error { - if len(key) == 0 { - return fmt.Errorf("setting key must be set") - } - if strings.ToLower(key) != key { - return fmt.Errorf("setting key should be lowercase") - } - return nil -} - // GetUserSetting gets a specific setting for a user func GetUserSetting(userID int64, key string, def ...string) (string, error) { - if err := validateUserSettingKey(key); err != nil { + if err := db.ValidateSettingKey(key); err != nil { return "", err } @@ -139,7 +128,7 @@ func GetUserSetting(userID int64, key string, def ...string) (string, error) { // DeleteUserSetting deletes a specific setting for a user func DeleteUserSetting(userID int64, key string) error { - if err := validateUserSettingKey(key); err != nil { + if err := db.ValidateSettingKey(key); err != nil { return err } @@ -151,7 +140,7 @@ func DeleteUserSetting(userID int64, key string) error { // SetUserSetting updates a users' setting for a specific key func SetUserSetting(userID int64, key, value string) error { - if err := validateUserSettingKey(key); err != nil { + if err := db.ValidateSettingKey(key); err != nil { return err }