diff --git a/.golangci.yml b/.golangci.yml index 342fe97837bf4..22de387facb29 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -86,6 +86,7 @@ linters-settings: - io/ioutil: "use os or io instead" - golang.org/x/exp: "it's experimental and unreliable." - code.gitea.io/gitea/modules/git/internal: "do not use the internal package, use AddXxx function instead" + - gopkg.in/ini.v1: "do not use the ini package, use gitea's config system instead" issues: max-issues-per-linter: 0 diff --git a/build/backport-locales.go b/build/backport-locales.go index 054b623d698e8..0346215348596 100644 --- a/build/backport-locales.go +++ b/build/backport-locales.go @@ -12,7 +12,7 @@ import ( "path/filepath" "strings" - "gopkg.in/ini.v1" + "code.gitea.io/gitea/modules/setting" ) func main() { @@ -22,14 +22,13 @@ func main() { os.Exit(1) } - ini.PrettyFormat = false mustNoErr := func(err error) { if err != nil { panic(err) } } - collectInis := func(ref string) map[string]*ini.File { - inis := map[string]*ini.File{} + collectInis := func(ref string) map[string]setting.ConfigProvider { + inis := map[string]setting.ConfigProvider{} err := filepath.WalkDir("options/locale", func(path string, d os.DirEntry, err error) error { if err != nil { return err @@ -37,10 +36,7 @@ func main() { if d.IsDir() || !strings.HasSuffix(d.Name(), ".ini") { return nil } - cfg, err := ini.LoadSources(ini.LoadOptions{ - IgnoreInlineComment: true, - UnescapeValueCommentSymbols: true, - }, path) + cfg, err := setting.NewConfigProviderForLocale(path) mustNoErr(err) inis[path] = cfg fmt.Printf("collecting: %s @ %s\n", path, ref) diff --git a/contrib/environment-to-ini/environment-to-ini.go b/contrib/environment-to-ini/environment-to-ini.go index ae8535d89128e..3405d7d429b49 100644 --- a/contrib/environment-to-ini/environment-to-ini.go +++ b/contrib/environment-to-ini/environment-to-ini.go @@ -9,10 +9,8 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "github.com/urfave/cli" - "gopkg.in/ini.v1" ) // EnvironmentPrefix environment variables prefixed with this represent ini values to write @@ -97,19 +95,10 @@ func runEnvironmentToIni(c *cli.Context) error { providedWorkPath := c.String("work-path") setting.SetCustomPathAndConf(providedCustom, providedConf, providedWorkPath) - cfg := ini.Empty() - confFileExists, err := util.IsFile(setting.CustomConf) + cfg, err := setting.NewConfigProviderFromFile(&setting.Options{CustomConf: setting.CustomConf, AllowEmpty: true}) if err != nil { - log.Fatal("Unable to check if %s is a file. Error: %v", setting.CustomConf, err) + log.Fatal("Failed to load custom conf '%s': %v", setting.CustomConf, err) } - if confFileExists { - if err := cfg.Append(setting.CustomConf); err != nil { - log.Fatal("Failed to load custom conf '%s': %v", setting.CustomConf, err) - } - } else { - log.Warn("Custom config '%s' not found, ignore this if you're running first time", setting.CustomConf) - } - cfg.NameMapper = ini.SnackCase prefixGitea := c.String("prefix") + "__" suffixFile := "__FILE" diff --git a/docs/content/doc/development/oauth2-provider.en-us.md b/docs/content/doc/development/oauth2-provider.en-us.md index cf045ac2fe677..5f9960a477751 100644 --- a/docs/content/doc/development/oauth2-provider.en-us.md +++ b/docs/content/doc/development/oauth2-provider.en-us.md @@ -1,5 +1,5 @@ --- -date: "2019-04-19:44:00+01:00" +date: "2023-06-01T08:40:00+08:00" title: "OAuth2 provider" slug: "oauth2-provider" weight: 41 @@ -40,7 +40,7 @@ At the moment Gitea only supports the [**Authorization Code Grant**](https://too - [Proof Key for Code Exchange (PKCE)](https://tools.ietf.org/html/rfc7636) - [OpenID Connect (OIDC)](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) -To use the Authorization Code Grant as a third party application it is required to register a new application via the "Settings" (`/user/settings/applications`) section of the settings. +To use the Authorization Code Grant as a third party application it is required to register a new application via the "Settings" (`/user/settings/applications`) section of the settings. To test or debug you can use the web-tool https://oauthdebugger.com/. ## Scopes @@ -87,17 +87,19 @@ Gitea supports both confidential and public client types, [as defined by RFC 674 For public clients, a redirect URI of a loopback IP address such as `http://127.0.0.1/` allows any port. Avoid using `localhost`, [as recommended by RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252#section-8.3). -## Example +## Examples + +### Confidential client **Note:** This example does not use PKCE. -1. Redirect to user to the authorization endpoint in order to get their consent for accessing the resources: +1. Redirect the user to the authorization endpoint in order to get their consent for accessing the resources: ```curl https://[YOUR-GITEA-URL]/login/oauth/authorize?client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE ``` - The `CLIENT_ID` can be obtained by registering an application in the settings. The `STATE` is a random string that will be send back to your application after the user authorizes. The `state` parameter is optional but should be used to prevent CSRF attacks. + The `CLIENT_ID` can be obtained by registering an application in the settings. The `STATE` is a random string that will be sent back to your application after the user authorizes. The `state` parameter is optional, but should be used to prevent CSRF attacks.  @@ -107,7 +109,7 @@ For public clients, a redirect URI of a loopback IP address such as `http://127. https://[REDIRECT_URI]?code=RETURNED_CODE&state=STATE ``` -2. Using the provided `code` from the redirect, you can request a new application and refresh token. The access token endpoints accepts POST requests with `application/json` and `application/x-www-form-urlencoded` body, for example: +2. Using the provided `code` from the redirect, you can request a new application and refresh token. The access token endpoint accepts POST requests with `application/json` and `application/x-www-form-urlencoded` body, for example: ```curl POST https://[YOUR-GITEA-URL]/login/oauth/access_token @@ -134,7 +136,69 @@ For public clients, a redirect URI of a loopback IP address such as `http://127. } ``` - The `CLIENT_SECRET` is the unique secret code generated for this application. Please note that the secret will only be visible after you created/registered the application with Gitea and cannot be recovered. If you lose the secret you must regenerate the secret via the application's settings. + The `CLIENT_SECRET` is the unique secret code generated for this application. Please note that the secret will only be visible after you created/registered the application with Gitea and cannot be recovered. If you lose the secret, you must regenerate the secret via the application's settings. + + The `REDIRECT_URI` in the `access_token` request must match the `REDIRECT_URI` in the `authorize` request. + +3. Use the `access_token` to make [API requests](https://docs.gitea.io/en-us/api-usage#oauth2) to access the user's resources. + +### Public client (PKCE) + +PKCE (Proof Key for Code Exchange) is an extension to the OAuth flow which allows for a secure credential exchange without the requirement to provide a client secret. + +**Note**: Please ensure you have registered your OAuth application as a public client. + +To achieve this, you have to provide a `code_verifier` for every authorization request. A `code_verifier` has to be a random string with a minimum length of 43 characters and a maximum length of 128 characters. It can contain alphanumeric characters as well as the characters `-`, `.`, `_` and `~`. + +Using this `code_verifier` string, a new one called `code_challenge` is created by using one of two methods: + +- If you have the required functionality on your client, set `code_challenge` to be a URL-safe base64-encoded string of the SHA256 hash of `code_verifier`. In that case, your `code_challenge_method` becomes `S256`. +- If you are unable to do so, you can provide your `code_verifier` as a plain string to `code_challenge`. Then you have to set your `code_challenge_method` as `plain`. + +After you have generated this values, you can continue with your request. + +1. Redirect the user to the authorization endpoint in order to get their consent for accessing the resources: + + ```curl + https://[YOUR-GITEA-URL]/login/oauth/authorize?client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&response_type=code&code_challenge_method=CODE_CHALLENGE_METHOD&code_challenge=CODE_CHALLENGE&state=STATE + ``` + + The `CLIENT_ID` can be obtained by registering an application in the settings. The `STATE` is a random string that will be sent back to your application after the user authorizes. The `state` parameter is optional, but should be used to prevent CSRF attacks. + +  + + The user will now be asked to authorize your application. If they authorize it, the user will be redirected to the `REDIRECT_URL`, for example: + + ```curl + https://[REDIRECT_URI]?code=RETURNED_CODE&state=STATE + ``` + +2. Using the provided `code` from the redirect, you can request a new application and refresh token. The access token endpoint accepts POST requests with `application/json` and `application/x-www-form-urlencoded` body, for example: + + ```curl + POST https://[YOUR-GITEA-URL]/login/oauth/access_token + ``` + + ```json + { + "client_id": "YOUR_CLIENT_ID", + "code": "RETURNED_CODE", + "grant_type": "authorization_code", + "redirect_uri": "REDIRECT_URI", + "code_verifier": "CODE_VERIFIER", + } + ``` + + Response: + + ```json + { + "access_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJnbnQiOjIsInR0IjowLCJleHAiOjE1NTUxNzk5MTIsImlhdCI6MTU1NTE3NjMxMn0.0-iFsAwBtxuckA0sNZ6QpBQmywVPz129u75vOM7wPJecw5wqGyBkmstfJHAjEOqrAf_V5Z-1QYeCh_Cz4RiKug", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJnbnQiOjIsInR0IjoxLCJjbnQiOjEsImV4cCI6MTU1NzgwNDMxMiwiaWF0IjoxNTU1MTc2MzEyfQ.S_HZQBy4q9r5SEzNGNIoFClT43HPNDbUdHH-GYNYYdkRfft6XptJBkUQscZsGxOW975Yk6RbgtGvq1nkEcklOw" + } + ``` The `REDIRECT_URI` in the `access_token` request must match the `REDIRECT_URI` in the `authorize` request. diff --git a/docs/content/doc/usage/labels.en-us.md b/docs/content/doc/usage/labels.en-us.md index bf60951d8c679..8467f7e037243 100644 --- a/docs/content/doc/usage/labels.en-us.md +++ b/docs/content/doc/usage/labels.en-us.md @@ -27,7 +27,7 @@ For organizations, you can define organization-wide labels that are shared with Labels have a mandatory name, a mandatory color, an optional description, and must either be exclusive or not (see `Scoped Labels` below). -When you create a repository, you can ensure certain labels exist by using the `Issue Labels` option. This option lists a number of available label sets that are [configured globally on your instance](../customizing-gitea/#labels). Its contained labels will all be created as well while creating the repository. +When you create a repository, you can ensure certain labels exist by using the `Issue Labels` option. This option lists a number of available label sets that are [configured globally on your instance](../administration/customizing-gitea/#labels). Its contained labels will all be created as well while creating the repository. ## Scoped Labels diff --git a/docs/content/doc/usage/labels.zh-cn.md b/docs/content/doc/usage/labels.zh-cn.md index 10fef72e75024..07dd2bf854371 100644 --- a/docs/content/doc/usage/labels.zh-cn.md +++ b/docs/content/doc/usage/labels.zh-cn.md @@ -27,7 +27,7 @@ menu: 标签具有必填的名称和颜色,可选的描述,以及必须是独占的或非独占的(见下面的“作用域标签”)。 -当您创建一个仓库时,可以通过使用 `工单标签(Issue Labels)` 选项来选择标签集。该选项列出了一些在您的实例上 [全局配置的可用标签集](../customizing-gitea/#labels)。在创建仓库时,这些标签也将被创建。 +当您创建一个仓库时,可以通过使用 `工单标签(Issue Labels)` 选项来选择标签集。该选项列出了一些在您的实例上 [全局配置的可用标签集](../administration/customizing-gitea/#labels)。在创建仓库时,这些标签也将被创建。 ## 作用域标签 diff --git a/models/activities/statistic.go b/models/activities/statistic.go index 138f4d8fe9e92..9d379cd0c4f1a 100644 --- a/models/activities/statistic.go +++ b/models/activities/statistic.go @@ -21,7 +21,7 @@ import ( type Statistic struct { Counter struct { User, Org, PublicKey, - Repo, Watch, Star, Action, Access, + Repo, Watch, Star, Access, Issue, IssueClosed, IssueOpen, Comment, Oauth, Follow, Mirror, Release, AuthSource, Webhook, @@ -55,7 +55,6 @@ func GetStatistic() (stats Statistic) { stats.Counter.Repo, _ = repo_model.CountRepositories(db.DefaultContext, repo_model.CountRepositoryOptions{}) stats.Counter.Watch, _ = e.Count(new(repo_model.Watch)) stats.Counter.Star, _ = e.Count(new(repo_model.Star)) - stats.Counter.Action, _ = db.EstimateCount(db.DefaultContext, new(Action)) stats.Counter.Access, _ = e.Count(new(access_model.Access)) type IssueCount struct { @@ -83,7 +82,7 @@ func GetStatistic() (stats Statistic) { Find(&stats.Counter.IssueByRepository) } - issueCounts := []IssueCount{} + var issueCounts []IssueCount _ = e.Select("COUNT(*) AS count, is_closed").Table("issue").GroupBy("is_closed").Find(&issueCounts) for _, c := range issueCounts { diff --git a/models/db/context.go b/models/db/context.go index 670f6272aa9a9..59be1e138914a 100644 --- a/models/db/context.go +++ b/models/db/context.go @@ -9,7 +9,6 @@ import ( "xorm.io/builder" "xorm.io/xorm" - "xorm.io/xorm/schemas" ) // DefaultContext is the default context to run xorm queries in @@ -241,30 +240,6 @@ func TableName(bean interface{}) string { return x.TableName(bean) } -// EstimateCount returns an estimate of total number of rows in table -func EstimateCount(ctx context.Context, bean interface{}) (int64, error) { - e := GetEngine(ctx) - e.Context(ctx) - - var rows int64 - var err error - tablename := TableName(bean) - switch x.Dialect().URI().DBType { - case schemas.MYSQL: - _, err = e.Context(ctx).SQL("SELECT table_rows FROM information_schema.tables WHERE tables.table_name = ? AND tables.table_schema = ?;", tablename, x.Dialect().URI().DBName).Get(&rows) - case schemas.POSTGRES: - // the table can live in multiple schemas of a postgres database - // See https://wiki.postgresql.org/wiki/Count_estimate - tablename = x.TableName(bean, true) - _, err = e.Context(ctx).SQL("SELECT reltuples::bigint AS estimate FROM pg_class WHERE oid = ?::regclass;", tablename).Get(&rows) - case schemas.MSSQL: - _, err = e.Context(ctx).SQL("sp_spaceused ?;", tablename).Get(&rows) - default: - return e.Context(ctx).Count(tablename) - } - return rows, err -} - // InTransaction returns true if the engine is in a transaction otherwise return false func InTransaction(ctx context.Context) bool { _, ok := inTransaction(ctx) diff --git a/models/issues/pull_test.go b/models/issues/pull_test.go index dd13df99b14fd..1eb106047cea4 100644 --- a/models/issues/pull_test.go +++ b/models/issues/pull_test.go @@ -324,5 +324,4 @@ func TestParseCodeOwnersLine(t *testing.T) { tokens := issues_model.TokenizeCodeOwnersLine(g.Line) assert.Equal(t, g.Tokens, tokens, "Codeowners tokenizer failed") } - } diff --git a/modules/metrics/collector.go b/modules/metrics/collector.go index 94699c161cd56..33678256c3cb3 100755 --- a/modules/metrics/collector.go +++ b/modules/metrics/collector.go @@ -18,7 +18,6 @@ const namespace = "gitea_" // exposes gitea metrics for prometheus type Collector struct { Accesses *prometheus.Desc - Actions *prometheus.Desc Attachments *prometheus.Desc BuildInfo *prometheus.Desc Comments *prometheus.Desc @@ -56,11 +55,6 @@ func NewCollector() Collector { "Number of Accesses", nil, nil, ), - Actions: prometheus.NewDesc( - namespace+"actions", - "Number of Actions", - nil, nil, - ), Attachments: prometheus.NewDesc( namespace+"attachments", "Number of Attachments", @@ -207,7 +201,6 @@ func NewCollector() Collector { // Describe returns all possible prometheus.Desc func (c Collector) Describe(ch chan<- *prometheus.Desc) { ch <- c.Accesses - ch <- c.Actions ch <- c.Attachments ch <- c.BuildInfo ch <- c.Comments @@ -246,11 +239,6 @@ func (c Collector) Collect(ch chan<- prometheus.Metric) { prometheus.GaugeValue, float64(stats.Counter.Access), ) - ch <- prometheus.MustNewConstMetric( - c.Actions, - prometheus.GaugeValue, - float64(stats.Counter.Action), - ) ch <- prometheus.MustNewConstMetric( c.Attachments, prometheus.GaugeValue, diff --git a/modules/repository/repo.go b/modules/repository/repo.go index 62e1f31b9e5e6..3991e9ad995be 100644 --- a/modules/repository/repo.go +++ b/modules/repository/repo.go @@ -27,7 +27,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" - "gopkg.in/ini.v1" + "gopkg.in/ini.v1" //nolint:depguard ) /* @@ -241,7 +241,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, // cleanUpMigrateGitConfig removes mirror info which prevents "push --all". // This also removes possible user credentials. func cleanUpMigrateGitConfig(configPath string) error { - cfg, err := ini.Load(configPath) + cfg, err := ini.Load(configPath) // FIXME: the ini package doesn't really work with git config files if err != nil { return fmt.Errorf("open config file: %w", err) } diff --git a/modules/setting/config_env.go b/modules/setting/config_env.go index dca9f2bb47bb5..63488037059ab 100644 --- a/modules/setting/config_env.go +++ b/modules/setting/config_env.go @@ -10,8 +10,6 @@ import ( "strings" "code.gitea.io/gitea/modules/log" - - "gopkg.in/ini.v1" ) const escapeRegexpString = "_0[xX](([0-9a-fA-F][0-9a-fA-F])+)_" @@ -89,7 +87,7 @@ func decodeEnvironmentKey(prefixGitea, suffixFile, envKey string) (ok bool, sect return ok, section, key, useFileValue } -func EnvironmentToConfig(cfg *ini.File, prefixGitea, suffixFile string, envs []string) (changed bool) { +func EnvironmentToConfig(cfg ConfigProvider, prefixGitea, suffixFile string, envs []string) (changed bool) { for _, kv := range envs { idx := strings.IndexByte(kv, '=') if idx < 0 { diff --git a/modules/setting/config_env_test.go b/modules/setting/config_env_test.go index d49464ecf785e..d574554bcc049 100644 --- a/modules/setting/config_env_test.go +++ b/modules/setting/config_env_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "gopkg.in/ini.v1" ) func TestDecodeEnvSectionKey(t *testing.T) { @@ -71,15 +70,15 @@ func TestDecodeEnvironmentKey(t *testing.T) { } func TestEnvironmentToConfig(t *testing.T) { - cfg := ini.Empty() + cfg, _ := NewConfigProviderFromData("") changed := EnvironmentToConfig(cfg, "GITEA__", "__FILE", nil) assert.False(t, changed) - cfg, err := ini.Load([]byte(` + cfg, err := NewConfigProviderFromData(` [sec] key = old -`)) +`) assert.NoError(t, err) changed = EnvironmentToConfig(cfg, "GITEA__", "__FILE", []string{"GITEA__sec__key=new"}) diff --git a/modules/setting/config_provider.go b/modules/setting/config_provider.go index 37f5754ffdb0c..8b317d94e32c7 100644 --- a/modules/setting/config_provider.go +++ b/modules/setting/config_provider.go @@ -8,36 +8,71 @@ import ( "os" "path/filepath" "strings" + "time" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" - ini "gopkg.in/ini.v1" + "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(interface{}) error + MapTo(any) error HasKey(key string) bool - NewKey(name, value string) (*ini.Key, error) - Key(key string) *ini.Key - Keys() []*ini.Key - ChildSections() []*ini.Section + 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 +} + +type iniConfigProvider struct { + opts *Options + ini *ini.File + newFile 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) *ini.Key { +func ConfigSectionKey(sec ConfigSection, key string) ConfigKey { if sec == nil { return nil } @@ -64,7 +99,7 @@ func ConfigSectionKeyString(sec ConfigSection, key string, def ...string) string // 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) *ini.Key { +func ConfigInheritedKey(sec ConfigSection, key string) ConfigKey { k := sec.Key(key) if k != nil && k.String() != "" { newKey, _ := sec.NewKey(k.Name(), k.String()) @@ -85,41 +120,64 @@ func ConfigInheritedKeyString(sec ConfigSection, key string, def ...string) stri return "" } -type iniFileConfigProvider struct { - opts *Options - *ini.File - newFile bool // whether the file has not existed previously +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 } -// NewConfigProviderFromData this function is only for testing +func (s *iniConfigSection) ChildSections() (sections []ConfigSection) { + for _, s := range s.sec.ChildSections() { + sections = append(sections, &iniConfigSection{s}) + } + return sections +} + +// NewConfigProviderFromData this function is mainly for testing purpose func NewConfigProviderFromData(configContent string) (ConfigProvider, error) { - var cfg *ini.File - var err error - if configContent == "" { - cfg = ini.Empty() - } else { - cfg, err = ini.Load(strings.NewReader(configContent)) - if err != nil { - return nil, err - } + cfg, err := ini.Load(strings.NewReader(configContent)) + if err != nil { + return nil, err } cfg.NameMapper = ini.SnackCase - return &iniFileConfigProvider{ - File: cfg, + return &iniConfigProvider{ + ini: cfg, newFile: true, }, nil } type Options struct { - CustomConf string // the ini file path - AllowEmpty bool // whether not finding configuration files is allowed (only true for the tests) - ExtraConfig string - DisableLoadCommonSettings bool + CustomConf string // the ini file path + AllowEmpty bool // whether not finding configuration files is allowed + ExtraConfig string + + DisableLoadCommonSettings bool // only used by "Init()", not used by "NewConfigProvider()" } -// newConfigProviderFromFile load configuration from file. +// NewConfigProviderFromFile load configuration from file. // NOTE: do not print any log except error. -func newConfigProviderFromFile(opts *Options) (*iniFileConfigProvider, error) { +func NewConfigProviderFromFile(opts *Options) (ConfigProvider, error) { cfg := ini.Empty() newFile := true @@ -147,61 +205,77 @@ func newConfigProviderFromFile(opts *Options) (*iniFileConfigProvider, error) { } cfg.NameMapper = ini.SnackCase - return &iniFileConfigProvider{ + return &iniConfigProvider{ opts: opts, - File: cfg, + ini: cfg, newFile: newFile, }, nil } -func (p *iniFileConfigProvider) Section(section string) ConfigSection { - return p.File.Section(section) +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 *iniFileConfigProvider) NewSection(name string) (ConfigSection, error) { - return p.File.NewSection(name) +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 *iniFileConfigProvider) GetSection(name string) (ConfigSection, error) { - return p.File.GetSection(name) +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 } -// Save save the content into file -func (p *iniFileConfigProvider) Save() error { - if p.opts.CustomConf == "" { +// Save saves the content into file +func (p *iniConfigProvider) Save() error { + filename := p.opts.CustomConf + if filename == "" { if !p.opts.AllowEmpty { return fmt.Errorf("custom config path must not be empty") } return nil } - if p.newFile { - if err := os.MkdirAll(filepath.Dir(CustomConf), os.ModePerm); err != nil { - return fmt.Errorf("failed to create '%s': %v", CustomConf, err) + if err := os.MkdirAll(filepath.Dir(filename), os.ModePerm); err != nil { + return fmt.Errorf("failed to create '%s': %v", filename, err) } } - if err := p.SaveTo(p.opts.CustomConf); err != nil { - return fmt.Errorf("failed to save '%s': %v", p.opts.CustomConf, err) + if err := p.ini.SaveTo(filename); err != nil { + return fmt.Errorf("failed to save '%s': %v", filename, err) } // Change permissions to be more restrictive - fi, err := os.Stat(CustomConf) + 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(CustomConf, 0o600); err != nil { + if err = os.Chmod(filename, 0o600); err != nil { log.Warn("Failed changing conf file permissions to -rw-------. Consider changing them manually.") } } return nil } -// a file is an implementation ConfigProvider and other implementations are possible, i.e. from docker, k8s, … -var _ ConfigProvider = &iniFileConfigProvider{} +func (p *iniConfigProvider) SaveTo(filename string) error { + return p.ini.SaveTo(filename) +} -func mustMapSetting(rootCfg ConfigProvider, sectionName string, setting interface{}) { +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) } @@ -219,3 +293,23 @@ func deprecatedSettingDB(rootCfg ConfigProvider, oldSection, oldKey string) { 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, + }, source, others...) + if err != nil { + return nil, fmt.Errorf("unable to load locale ini: %w", err) + } + iniFile.BlockMode = false + return &iniConfigProvider{ + ini: iniFile, + newFile: true, + }, nil +} + +func init() { + ini.PrettyFormat = false +} diff --git a/modules/setting/config_provider_test.go b/modules/setting/config_provider_test.go index 76f7048d59c9e..17650edea404c 100644 --- a/modules/setting/config_provider_test.go +++ b/modules/setting/config_provider_test.go @@ -4,6 +4,7 @@ package setting import ( + "os" "testing" "github.com/stretchr/testify/assert" @@ -64,3 +65,57 @@ key = 123 assert.Equal(t, "", ConfigSectionKeyString(sec, "empty")) assert.Equal(t, "def", ConfigSectionKeyString(secSub, "empty")) } + +func TestNewConfigProviderFromFile(t *testing.T) { + _, err := NewConfigProviderFromFile(&Options{CustomConf: "no-such.ini", AllowEmpty: false}) + assert.ErrorContains(t, err, "unable to find configuration file") + + // load non-existing file and save + testFile := t.TempDir() + "/test.ini" + testFile1 := t.TempDir() + "/test1.ini" + cfg, err := NewConfigProviderFromFile(&Options{CustomConf: testFile, AllowEmpty: true}) + assert.NoError(t, err) + + sec, _ := cfg.NewSection("foo") + _, _ = sec.NewKey("k1", "a") + assert.NoError(t, cfg.Save()) + _, _ = sec.NewKey("k2", "b") + assert.NoError(t, cfg.SaveTo(testFile1)) + + bs, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, "[foo]\nk1=a\n", string(bs)) + + bs, err = os.ReadFile(testFile1) + assert.NoError(t, err) + assert.Equal(t, "[foo]\nk1=a\nk2=b\n", string(bs)) + + // load existing file and save + cfg, err = NewConfigProviderFromFile(&Options{CustomConf: testFile, AllowEmpty: true}) + assert.NoError(t, err) + assert.Equal(t, "a", cfg.Section("foo").Key("k1").String()) + sec, _ = cfg.NewSection("bar") + _, _ = sec.NewKey("k1", "b") + assert.NoError(t, cfg.Save()) + bs, err = os.ReadFile(testFile) + assert.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) + assert.NoError(t, err) + assert.Equal(t, "a", cfg.Section("").Key("k1").String()) + + // load locale from bytes + cfg, err = NewConfigProviderForLocale([]byte("k1=foo\nk2=bar")) + assert.NoError(t, err) + assert.Equal(t, "foo", cfg.Section("").Key("k1").String()) + cfg, err = NewConfigProviderForLocale([]byte("k1=foo\nk2=bar"), []byte("k2=xxx")) + assert.NoError(t, err) + assert.Equal(t, "foo", cfg.Section("").Key("k1").String()) + assert.Equal(t, "xxx", cfg.Section("").Key("k2").String()) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 71cd9a12a99fd..1967d9e79aa12 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -201,7 +201,7 @@ func Init(opts *Options) { opts.CustomConf = CustomConf } var err error - CfgProvider, err = newConfigProviderFromFile(opts) + CfgProvider, err = NewConfigProviderFromFile(opts) if err != nil { log.Fatal("Init[%v]: %v", opts, err) } diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go index 0664bcfd1a227..aa784e866fce4 100644 --- a/modules/translation/i18n/localestore.go +++ b/modules/translation/i18n/localestore.go @@ -7,8 +7,7 @@ import ( "fmt" "code.gitea.io/gitea/modules/log" - - "gopkg.in/ini.v1" + "code.gitea.io/gitea/modules/setting" ) // This file implements the static LocaleStore that will not watch for changes @@ -47,14 +46,10 @@ func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, more l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string)} store.localeMap[l.langName] = l - iniFile, err := ini.LoadSources(ini.LoadOptions{ - IgnoreInlineComment: true, - UnescapeValueCommentSymbols: true, - }, source, moreSource) + iniFile, err := setting.NewConfigProviderForLocale(source, moreSource) if err != nil { return fmt.Errorf("unable to load ini: %w", err) } - iniFile.BlockMode = false for _, section := range iniFile.Sections() { for _, key := range section.Keys() { diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index 4e7a3a32bd08f..7780a412ba4e3 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -1182,7 +1182,7 @@ issues.add_label=hat das Label %s %s hinzugefügt issues.add_labels=hat die Labels %s %s hinzugefügt issues.remove_label=hat das Label %s %s entfernt issues.remove_labels=hat die Labels %s %s entfernt -issues.add_remove_labels=hat %s hinzugefügt, und %s %s enternt +issues.add_remove_labels=hat %s hinzugefügt, und %s %s entfernt issues.add_milestone_at=`hat diesen Issue %[2]s zum %[1]s Meilenstein hinzugefügt` issues.add_project_at=`hat dieses zum %s projekt %s hinzugefügt` issues.change_milestone_at=`hat den Meilenstein %[3]s von %[1]s zu %[2]s geändert` diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index bf6e4b75247f3..517d829c84bf7 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2619,7 +2619,6 @@ dashboard.new_version_hint = Gitea %s is now available, you are running %s. Chec dashboard.statistic = Summary dashboard.operations = Maintenance Operations dashboard.system_status = System Status -dashboard.statistic_info = The Gitea database holds %d users, %d organizations, %d public keys, %d repositories, %d watches, %d stars, ~%d actions, %d accesses, %d issues, %d comments, %d social accounts, %d follows, %d mirrors, %d releases, %d authentication sources, %d webhooks, %d milestones, %d labels, %d hook tasks, %d teams, %d update tasks, %d attachments. dashboard.operation_name = Operation Name dashboard.operation_switch = Switch dashboard.operation_run = Run @@ -3060,6 +3059,8 @@ config.xorm_log_sql = Log SQL config.get_setting_failed = Get setting %s failed config.set_setting_failed = Set setting %s failed +monitor.stats = Stats + monitor.cron = Cron Tasks monitor.name = Name monitor.schedule = Schedule diff --git a/routers/install/install.go b/routers/install/install.go index 4635cd7cb60fa..51ad6ec378794 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -35,7 +35,6 @@ import ( "code.gitea.io/gitea/services/forms" "gitea.com/go-chi/session" - "gopkg.in/ini.v1" ) const ( @@ -371,17 +370,11 @@ func SubmitInstall(ctx *context.Context) { } // Save settings. - cfg := ini.Empty() - isFile, err := util.IsFile(setting.CustomConf) + cfg, err := setting.NewConfigProviderFromFile(&setting.Options{CustomConf: setting.CustomConf, AllowEmpty: true}) if err != nil { - log.Error("Unable to check if %s is a file. Error: %v", setting.CustomConf, err) - } - if isFile { - // Keeps custom settings if there is already something. - if err = cfg.Append(setting.CustomConf); err != nil { - log.Error("Failed to load custom conf '%s': %v", setting.CustomConf, err) - } + log.Error("Failed to load custom conf '%s': %v", setting.CustomConf, err) } + cfg.Section("database").Key("DB_TYPE").SetValue(setting.Database.Type.String()) cfg.Section("database").Key("HOST").SetValue(setting.Database.Host) cfg.Section("database").Key("NAME").SetValue(setting.Database.Name) diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index 1ada4deefc3f8..797ba8798d06c 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -8,11 +8,13 @@ import ( "fmt" "net/http" "runtime" + "sort" "time" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/updatechecker" "code.gitea.io/gitea/modules/web" @@ -26,6 +28,7 @@ const ( tplQueue base.TplName = "admin/queue" tplStacktrace base.TplName = "admin/stacktrace" tplQueueManage base.TplName = "admin/queue_manage" + tplStats base.TplName = "admin/stats" ) var sysStatus struct { @@ -111,7 +114,6 @@ func updateSystemStatus() { func Dashboard(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.dashboard") ctx.Data["PageIsAdminDashboard"] = true - ctx.Data["Stats"] = activities_model.GetStatistic() ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate() ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion() // FIXME: update periodically @@ -126,7 +128,6 @@ func DashboardPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.AdminDashboardForm) ctx.Data["Title"] = ctx.Tr("admin.dashboard") ctx.Data["PageIsAdminDashboard"] = true - ctx.Data["Stats"] = activities_model.GetStatistic() updateSystemStatus() ctx.Data["SysStatus"] = sysStatus @@ -153,3 +154,30 @@ func CronTasks(ctx *context.Context) { ctx.Data["Entries"] = cron.ListTasks() ctx.HTML(http.StatusOK, tplCron) } + +func MonitorStats(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.stats") + ctx.Data["PageIsAdminMonitorStats"] = true + bs, err := json.Marshal(activities_model.GetStatistic().Counter) + if err != nil { + ctx.ServerError("MonitorStats", err) + return + } + statsCounter := map[string]any{} + err = json.Unmarshal(bs, &statsCounter) + if err != nil { + ctx.ServerError("MonitorStats", err) + return + } + statsKeys := make([]string, 0, len(statsCounter)) + for k := range statsCounter { + if statsCounter[k] == nil { + continue + } + statsKeys = append(statsKeys, k) + } + sort.Strings(statsKeys) + ctx.Data["StatsKeys"] = statsKeys + ctx.Data["StatsCounter"] = statsCounter + ctx.HTML(http.StatusOK, tplStats) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 92a06e7c14a03..80f149d8061fc 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -695,7 +695,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server } // "The authorization server MUST ... require client authentication for confidential clients" // https://datatracker.ietf.org/doc/html/rfc6749#section-6 - if !app.ValidateClientSecret([]byte(form.ClientSecret)) { + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { errorDescription := "invalid client secret" if form.ClientSecret == "" { errorDescription = "invalid empty client secret" @@ -753,7 +753,7 @@ func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, s }) return } - if !app.ValidateClientSecret([]byte(form.ClientSecret)) { + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { errorDescription := "invalid client secret" if form.ClientSecret == "" { errorDescription = "invalid empty client secret" diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 5dca3eeaa92d4..1d54f25884e39 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -16,8 +16,6 @@ import ( "strings" "time" - "github.com/nektos/act/pkg/model" - activities_model "code.gitea.io/gitea/models/activities" admin_model "code.gitea.io/gitea/models/admin" asymkey_model "code.gitea.io/gitea/models/asymkey" @@ -44,6 +42,8 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" issue_service "code.gitea.io/gitea/services/issue" + + "github.com/nektos/act/pkg/model" ) const ( @@ -369,7 +369,6 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st ctx.Data["FileWarning"] = strings.Join(warnings, "\n") } } - } isDisplayingSource := ctx.FormString("display") == "source" diff --git a/routers/web/web.go b/routers/web/web.go index da6064257bd20..f5037a848ea59 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -538,8 +538,8 @@ func registerRoutes(m *web.Route) { // ***** START: Admin ***** m.Group("/admin", func() { - m.Get("", adminReq, admin.Dashboard) - m.Post("", adminReq, web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) + m.Get("", admin.Dashboard) + m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) m.Group("/config", func() { m.Get("", admin.Config) @@ -548,6 +548,7 @@ func registerRoutes(m *web.Route) { }) m.Group("/monitor", func() { + m.Get("/stats", admin.MonitorStats) m.Get("/cron", admin.CronTasks) m.Get("/stacktrace", admin.Stacktrace) m.Post("/stacktrace/cancel/{pid}", admin.StacktraceCancel) diff --git a/services/issue/issue.go b/services/issue/issue.go index cb99fc04d89cf..61890c75def09 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -58,7 +58,9 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode } if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issues_model.HasWorkInProgressPrefix(title) { - issues_model.PullRequestCodeOwnersReview(ctx, issue, issue.PullRequest) + if err = issues_model.PullRequestCodeOwnersReview(ctx, issue, issue.PullRequest); err != nil { + return + } } notification.NotifyIssueChangeTitle(ctx, doer, issue, oldTitle) diff --git a/services/migrations/gitlab.go b/services/migrations/gitlab.go index 015c38cd3b0cb..76180a5159a2b 100644 --- a/services/migrations/gitlab.go +++ b/services/migrations/gitlab.go @@ -413,7 +413,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er milestone = issue.Milestone.Title } - var reactions []*base.Reaction + var reactions []*gitlab.AwardEmoji awardPage := 1 for { awards, _, err := g.client.AwardEmoji.ListIssueAwardEmoji(g.repoID, issue.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx)) @@ -421,9 +421,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er return nil, false, fmt.Errorf("error while listing issue awards: %w", err) } - for i := range awards { - reactions = append(reactions, g.awardToReaction(awards[i])) - } + reactions = append(reactions, awards...) if len(awards) < perPage { break @@ -442,7 +440,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er State: issue.State, Created: *issue.CreatedAt, Labels: labels, - Reactions: reactions, + Reactions: g.awardsToReactions(reactions), Closed: issue.ClosedAt, IsLocked: issue.DiscussionLocked, Updated: *issue.UpdatedAt, @@ -577,7 +575,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque milestone = pr.Milestone.Title } - var reactions []*base.Reaction + var reactions []*gitlab.AwardEmoji awardPage := 1 for { awards, _, err := g.client.AwardEmoji.ListMergeRequestAwardEmoji(g.repoID, pr.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx)) @@ -585,9 +583,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque return nil, false, fmt.Errorf("error while listing merge requests awards: %w", err) } - for i := range awards { - reactions = append(reactions, g.awardToReaction(awards[i])) - } + reactions = append(reactions, awards...) if len(awards) < perPage { break @@ -614,7 +610,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque MergeCommitSHA: pr.MergeCommitSHA, MergedTime: mergeTime, IsLocked: locked, - Reactions: reactions, + Reactions: g.awardsToReactions(reactions), Head: base.PullRequestBranch{ Ref: pr.SourceBranch, SHA: pr.SHA, @@ -675,10 +671,19 @@ func (g *GitlabDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Revie return reviews, nil } -func (g *GitlabDownloader) awardToReaction(award *gitlab.AwardEmoji) *base.Reaction { - return &base.Reaction{ - UserID: int64(award.User.ID), - UserName: award.User.Username, - Content: award.Name, +func (g *GitlabDownloader) awardsToReactions(awards []*gitlab.AwardEmoji) []*base.Reaction { + result := make([]*base.Reaction, 0, len(awards)) + uniqCheck := make(map[string]struct{}) + for _, award := range awards { + uid := fmt.Sprintf("%s%d", award.Name, award.User.ID) + if _, ok := uniqCheck[uid]; !ok { + result = append(result, &base.Reaction{ + UserID: int64(award.User.ID), + UserName: award.User.Username, + Content: award.Name, + }) + uniqCheck[uid] = struct{}{} + } } + return result } diff --git a/services/migrations/gitlab_test.go b/services/migrations/gitlab_test.go index 1d8c5989bb534..731486eff21e3 100644 --- a/services/migrations/gitlab_test.go +++ b/services/migrations/gitlab_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "code.gitea.io/gitea/modules/json" base "code.gitea.io/gitea/modules/migration" "github.com/stretchr/testify/assert" @@ -469,3 +470,49 @@ func TestGitlabGetReviews(t *testing.T) { assertReviewsEqual(t, []*base.Review{&review}, rvs) } } + +func TestAwardsToReactions(t *testing.T) { + downloader := &GitlabDownloader{} + // yes gitlab can have duplicated reactions (https://gitlab.com/jaywink/socialhome/-/issues/24) + testResponse := ` +[ + { + "name": "thumbsup", + "user": { + "id": 1241334, + "username": "lafriks" + } + }, + { + "name": "thumbsup", + "user": { + "id": 1241334, + "username": "lafriks" + } + }, + { + "name": "thumbsup", + "user": { + "id": 4575606, + "username": "real6543" + } + } +] +` + var awards []*gitlab.AwardEmoji + assert.NoError(t, json.Unmarshal([]byte(testResponse), &awards)) + + reactions := downloader.awardsToReactions(awards) + assert.EqualValues(t, []*base.Reaction{ + { + UserName: "lafriks", + UserID: 1241334, + Content: "thumbsup", + }, + { + UserName: "real6543", + UserID: 4575606, + Content: "thumbsup", + }, + }, reactions) +} diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index b81e7b5ff371c..af9d4c4bc5024 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -133,7 +133,7 @@
{{(.locale.Tr "admin.dashboard.new_version_hint" .RemoteVersion AppVer) | Str2html}}
- {{.locale.Tr "admin.dashboard.statistic_info" .Stats.Counter.User .Stats.Counter.Org .Stats.Counter.PublicKey .Stats.Counter.Repo .Stats.Counter.Watch .Stats.Counter.Star .Stats.Counter.Action .Stats.Counter.Access .Stats.Counter.Issue .Stats.Counter.Comment .Stats.Counter.Oauth .Stats.Counter.Follow .Stats.Counter.Mirror .Stats.Counter.Release .Stats.Counter.AuthSource .Stats.Counter.Webhook .Stats.Counter.Milestone .Stats.Counter.Label .Stats.Counter.HookTask .Stats.Counter.Team .Stats.Counter.UpdateTask .Stats.Counter.Attachment | Str2html}} -
-