diff options
author | Anthony Wang | 2023-02-20 22:21:24 +0000 |
---|---|---|
committer | Anthony Wang | 2023-02-20 22:21:24 +0000 |
commit | dc20c2832871f6462990751ea802e14b02bf41b0 (patch) | |
tree | 71bfef0694e6c0f8284438c290521d7297f24eab /modules | |
parent | 07df0a6b1c97be4b03d23d5dfa047a108de36592 (diff) | |
parent | ef11d41639dd1e89676e395068ee453312560adb (diff) |
Merge remote-tracking branch 'origin/main' into forgejo-federation
Diffstat (limited to 'modules')
97 files changed, 2854 insertions, 1571 deletions
diff --git a/modules/auth/password/hash/argon2.go b/modules/auth/password/hash/argon2.go new file mode 100644 index 000000000..0cd6472fa --- /dev/null +++ b/modules/auth/password/hash/argon2.go @@ -0,0 +1,80 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "encoding/hex" + "strings" + + "code.gitea.io/gitea/modules/log" + + "golang.org/x/crypto/argon2" +) + +func init() { + MustRegister("argon2", NewArgon2Hasher) +} + +// Argon2Hasher implements PasswordHasher +// and uses the Argon2 key derivation function, hybrant variant +type Argon2Hasher struct { + time uint32 + memory uint32 + threads uint8 + keyLen uint32 +} + +// HashWithSaltBytes a provided password and salt +func (hasher *Argon2Hasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + return hex.EncodeToString(argon2.IDKey([]byte(password), salt, hasher.time, hasher.memory, hasher.threads, hasher.keyLen)) +} + +// NewArgon2Hasher is a factory method to create an Argon2Hasher +// The provided config should be either empty or of the form: +// "<time>$<memory>$<threads>$<keyLen>", where <x> is the string representation +// of an integer +func NewArgon2Hasher(config string) *Argon2Hasher { + // This default configuration uses the following parameters: + // time=2, memory=64*1024, threads=8, keyLen=50. + // It will make two passes through the memory, using 64MiB in total. + // This matches the original configuration for `argon2` prior to storing hash parameters + // in the database. + // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK + hasher := &Argon2Hasher{ + time: 2, + memory: 1 << 16, + threads: 8, + keyLen: 50, + } + + if config == "" { + return hasher + } + + vals := strings.SplitN(config, "$", 4) + if len(vals) != 4 { + log.Error("invalid argon2 hash spec %s", config) + return nil + } + + parsed, err := parseUIntParam(vals[0], "time", "argon2", config, nil) + hasher.time = uint32(parsed) + + parsed, err = parseUIntParam(vals[1], "memory", "argon2", config, err) + hasher.memory = uint32(parsed) + + parsed, err = parseUIntParam(vals[2], "threads", "argon2", config, err) + hasher.threads = uint8(parsed) + + parsed, err = parseUIntParam(vals[3], "keyLen", "argon2", config, err) + hasher.keyLen = uint32(parsed) + if err != nil { + return nil + } + + return hasher +} diff --git a/modules/auth/password/hash/bcrypt.go b/modules/auth/password/hash/bcrypt.go new file mode 100644 index 000000000..4607c169c --- /dev/null +++ b/modules/auth/password/hash/bcrypt.go @@ -0,0 +1,54 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "golang.org/x/crypto/bcrypt" +) + +func init() { + MustRegister("bcrypt", NewBcryptHasher) +} + +// BcryptHasher implements PasswordHasher +// and uses the bcrypt password hash function. +type BcryptHasher struct { + cost int +} + +// HashWithSaltBytes a provided password and salt +func (hasher *BcryptHasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), hasher.cost) + return string(hashedPassword) +} + +func (hasher *BcryptHasher) VerifyPassword(password, hashedPassword, salt string) bool { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil +} + +// NewBcryptHasher is a factory method to create an BcryptHasher +// The provided config should be either empty or the string representation of the "<cost>" +// as an integer +func NewBcryptHasher(config string) *BcryptHasher { + // This matches the original configuration for `bcrypt` prior to storing hash parameters + // in the database. + // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK + hasher := &BcryptHasher{ + cost: 10, // cost=10. i.e. 2^10 rounds of key expansion. + } + + if config == "" { + return hasher + } + var err error + hasher.cost, err = parseIntParam(config, "cost", "bcrypt", config, nil) + if err != nil { + return nil + } + + return hasher +} diff --git a/modules/auth/password/hash/common.go b/modules/auth/password/hash/common.go new file mode 100644 index 000000000..ac6faf35c --- /dev/null +++ b/modules/auth/password/hash/common.go @@ -0,0 +1,28 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "strconv" + + "code.gitea.io/gitea/modules/log" +) + +func parseIntParam(value, param, algorithmName, config string, previousErr error) (int, error) { + parsed, err := strconv.Atoi(value) + if err != nil { + log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config) + return 0, err + } + return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed +} + +func parseUIntParam(value, param, algorithmName, config string, previousErr error) (uint64, error) { + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config) + return 0, err + } + return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed +} diff --git a/modules/auth/password/hash/dummy.go b/modules/auth/password/hash/dummy.go new file mode 100644 index 000000000..22f2e2f64 --- /dev/null +++ b/modules/auth/password/hash/dummy.go @@ -0,0 +1,33 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "encoding/hex" +) + +// DummyHasher implements PasswordHasher and is a dummy hasher that simply +// puts the password in place with its salt +// This SHOULD NOT be used in production and is provided to make the integration +// tests faster only +type DummyHasher struct{} + +// HashWithSaltBytes a provided password and salt +func (hasher *DummyHasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + + if len(salt) == 10 { + return string(salt) + ":" + password + } + + return hex.EncodeToString(salt) + ":" + password +} + +// NewDummyHasher is a factory method to create a DummyHasher +// Any provided configuration is ignored +func NewDummyHasher(_ string) *DummyHasher { + return &DummyHasher{} +} diff --git a/modules/auth/password/hash/dummy_test.go b/modules/auth/password/hash/dummy_test.go new file mode 100644 index 000000000..f3b36df62 --- /dev/null +++ b/modules/auth/password/hash/dummy_test.go @@ -0,0 +1,25 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDummyHasher(t *testing.T) { + dummy := &PasswordHashAlgorithm{ + PasswordSaltHasher: NewDummyHasher(""), + Specification: "dummy", + } + + password, salt := "password", "ZogKvWdyEx" + + hash, err := dummy.Hash(password, salt) + assert.Nil(t, err) + assert.Equal(t, hash, salt+":"+password) + + assert.True(t, dummy.VerifyPassword(password, hash, salt)) +} diff --git a/modules/auth/password/hash/hash.go b/modules/auth/password/hash/hash.go new file mode 100644 index 000000000..459320e1b --- /dev/null +++ b/modules/auth/password/hash/hash.go @@ -0,0 +1,189 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "crypto/subtle" + "encoding/hex" + "fmt" + "strings" + "sync/atomic" + + "code.gitea.io/gitea/modules/log" +) + +// This package takes care of hashing passwords, verifying passwords, defining +// available password algorithms, defining recommended password algorithms and +// choosing the default password algorithm. + +// PasswordSaltHasher will hash a provided password with the provided saltBytes +type PasswordSaltHasher interface { + HashWithSaltBytes(password string, saltBytes []byte) string +} + +// PasswordHasher will hash a provided password with the salt +type PasswordHasher interface { + Hash(password, salt string) (string, error) +} + +// PasswordVerifier will ensure that a providedPassword matches the hashPassword when hashed with the salt +type PasswordVerifier interface { + VerifyPassword(providedPassword, hashedPassword, salt string) bool +} + +// PasswordHashAlgorithms are named PasswordSaltHashers with a default verifier and hash function +type PasswordHashAlgorithm struct { + PasswordSaltHasher + Specification string // The specification that is used to create the internal PasswordSaltHasher +} + +// Hash the provided password with the salt and return the hash +func (algorithm *PasswordHashAlgorithm) Hash(password, salt string) (string, error) { + var saltBytes []byte + + // There are two formats for the salt value: + // * The new format is a (32+)-byte hex-encoded string + // * The old format was a 10-byte binary format + // We have to tolerate both here. + if len(salt) == 10 { + saltBytes = []byte(salt) + } else { + var err error + saltBytes, err = hex.DecodeString(salt) + if err != nil { + return "", err + } + } + + return algorithm.HashWithSaltBytes(password, saltBytes), nil +} + +// Verify the provided password matches the hashPassword when hashed with the salt +func (algorithm *PasswordHashAlgorithm) VerifyPassword(providedPassword, hashedPassword, salt string) bool { + // Some PasswordSaltHashers have their own specialised compare function that takes into + // account the stored parameters within the hash. e.g. bcrypt + if verifier, ok := algorithm.PasswordSaltHasher.(PasswordVerifier); ok { + return verifier.VerifyPassword(providedPassword, hashedPassword, salt) + } + + // Compute the hash of the password. + providedPasswordHash, err := algorithm.Hash(providedPassword, salt) + if err != nil { + log.Error("passwordhash: %v.Hash(): %v", algorithm.Specification, err) + return false + } + + // Compare it against the hashed password in constant-time. + return subtle.ConstantTimeCompare([]byte(hashedPassword), []byte(providedPasswordHash)) == 1 +} + +var ( + lastNonDefaultAlgorithm atomic.Value + availableHasherFactories = map[string]func(string) PasswordSaltHasher{} +) + +// MustRegister registers a PasswordSaltHasher with the availableHasherFactories +// Caution: This is not thread safe. +func MustRegister[T PasswordSaltHasher](name string, newFn func(config string) T) { + if err := Register(name, newFn); err != nil { + panic(err) + } +} + +// Register registers a PasswordSaltHasher with the availableHasherFactories +// Caution: This is not thread safe. +func Register[T PasswordSaltHasher](name string, newFn func(config string) T) error { + if _, has := availableHasherFactories[name]; has { + return fmt.Errorf("duplicate registration of password salt hasher: %s", name) + } + + availableHasherFactories[name] = func(config string) PasswordSaltHasher { + n := newFn(config) + return n + } + return nil +} + +// In early versions of gitea the password hash algorithm field of a user could be +// empty. At that point the default was `pbkdf2` without configuration values +// +// Please note this is not the same as the DefaultAlgorithm which is used +// to determine what an empty PASSWORD_HASH_ALGO setting in the app.ini means. +// These are not the same even if they have the same apparent value and they mean different things. +// +// DO NOT COALESCE THESE VALUES +const defaultEmptyHashAlgorithmSpecification = "pbkdf2" + +// Parse will convert the provided algorithm specification in to a PasswordHashAlgorithm +// If the provided specification matches the DefaultHashAlgorithm Specification it will be +// used. +// In addition the last non-default hasher will be cached to help reduce the load from +// parsing specifications. +// +// NOTE: No de-aliasing is done in this function, thus any specification which does not +// contain a configuration will use the default values for that hasher. These are not +// necessarily the same values as those obtained by dealiasing. This allows for +// seamless backwards compatibility with the original configuration. +// +// To further labour this point, running `Parse("pbkdf2")` does not obtain the +// same algorithm as setting `PASSWORD_HASH_ALGO=pbkdf2` in app.ini, nor is it intended to. +// A user that has `password_hash_algo='pbkdf2'` in the db means get the original, unconfigured algorithm +// Users will be migrated automatically as they log-in to have the complete specification stored +// in their `password_hash_algo` fields by other code. +func Parse(algorithmSpec string) *PasswordHashAlgorithm { + if algorithmSpec == "" { + algorithmSpec = defaultEmptyHashAlgorithmSpecification + } + + if DefaultHashAlgorithm != nil && algorithmSpec == DefaultHashAlgorithm.Specification { + return DefaultHashAlgorithm + } + + ptr := lastNonDefaultAlgorithm.Load() + if ptr != nil { + hashAlgorithm, ok := ptr.(*PasswordHashAlgorithm) + if ok && hashAlgorithm.Specification == algorithmSpec { + return hashAlgorithm + } + } + + // Now convert the provided specification in to a hasherType +/- some configuration parameters + vals := strings.SplitN(algorithmSpec, "$", 2) + var hasherType string + var config string + + if len(vals) == 0 { + // This should not happen as algorithmSpec should not be empty + // due to it being assigned to defaultEmptyHashAlgorithmSpecification above + // but we should be absolutely cautious here + return nil + } + + hasherType = vals[0] + if len(vals) > 1 { + config = vals[1] + } + + newFn, has := availableHasherFactories[hasherType] + if !has { + // unknown hasher type + return nil + } + + ph := newFn(config) + if ph == nil { + // The provided configuration is likely invalid - it will have been logged already + // but we cannot hash safely + return nil + } + + hashAlgorithm := &PasswordHashAlgorithm{ + PasswordSaltHasher: ph, + Specification: algorithmSpec, + } + + lastNonDefaultAlgorithm.Store(hashAlgorithm) + + return hashAlgorithm +} diff --git a/modules/auth/password/hash/hash_test.go b/modules/auth/password/hash/hash_test.go new file mode 100644 index 000000000..7aa051733 --- /dev/null +++ b/modules/auth/password/hash/hash_test.go @@ -0,0 +1,190 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "encoding/hex" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type testSaltHasher string + +func (t testSaltHasher) HashWithSaltBytes(password string, salt []byte) string { + return password + "$" + string(salt) + "$" + string(t) +} + +func Test_registerHasher(t *testing.T) { + MustRegister("Test_registerHasher", func(config string) testSaltHasher { + return testSaltHasher(config) + }) + + assert.Panics(t, func() { + MustRegister("Test_registerHasher", func(config string) testSaltHasher { + return testSaltHasher(config) + }) + }) + + assert.Error(t, Register("Test_registerHasher", func(config string) testSaltHasher { + return testSaltHasher(config) + })) + + assert.Equal(t, "password$salt$", + Parse("Test_registerHasher").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt"))) + + assert.Equal(t, "password$salt$config", + Parse("Test_registerHasher$config").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt"))) + + delete(availableHasherFactories, "Test_registerHasher") +} + +func TestParse(t *testing.T) { + hashAlgorithmsToTest := []string{} + for plainHashAlgorithmNames := range availableHasherFactories { + hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames) + } + for _, aliased := range aliasAlgorithmNames { + if strings.Contains(aliased, "$") { + hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased) + } + } + for _, algorithmName := range hashAlgorithmsToTest { + t.Run(algorithmName, func(t *testing.T) { + algo := Parse(algorithmName) + assert.NotNil(t, algo, "Algorithm %s resulted in an empty algorithm", algorithmName) + }) + } +} + +func TestHashing(t *testing.T) { + hashAlgorithmsToTest := []string{} + for plainHashAlgorithmNames := range availableHasherFactories { + hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames) + } + for _, aliased := range aliasAlgorithmNames { + if strings.Contains(aliased, "$") { + hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased) + } + } + + runTests := func(password, salt string, shouldPass bool) { + for _, algorithmName := range hashAlgorithmsToTest { + t.Run(algorithmName, func(t *testing.T) { + output, err := Parse(algorithmName).Hash(password, salt) + if shouldPass { + assert.NoError(t, err) + assert.NotEmpty(t, output, "output for %s was empty", algorithmName) + } else { + assert.Error(t, err) + } + + assert.Equal(t, Parse(algorithmName).VerifyPassword(password, output, salt), shouldPass) + }) + } + } + + // Test with new salt format. + runTests(strings.Repeat("a", 16), hex.EncodeToString([]byte{0x01, 0x02, 0x03}), true) + + // Test with legacy salt format. + runTests(strings.Repeat("a", 16), strings.Repeat("b", 10), true) + + // Test with invalid salt. + runTests(strings.Repeat("a", 16), "a", false) +} + +// vectors were generated using the current codebase. +var vectors = []struct { + algorithms []string + password string + salt string + output string + shouldfail bool +}{ + { + algorithms: []string{"bcrypt", "bcrypt$10"}, + password: "abcdef", + salt: strings.Repeat("a", 10), + output: "$2a$10$fjtm8BsQ2crym01/piJroenO3oSVUBhSLKaGdTYJ4tG0ePVCrU0G2", + shouldfail: false, + }, + { + algorithms: []string{"scrypt", "scrypt$65536$16$2$50"}, + password: "abcdef", + salt: strings.Repeat("a", 10), + output: "3b571d0c07c62d42b7bad3dbf18fb0cd67d4d8cd4ad4c6928e1090e5b2a4a84437c6fd2627d897c0e7e65025ca62b67a0002", + shouldfail: false, + }, + { + algorithms: []string{"argon2", "argon2$2$65536$8$50"}, + password: "abcdef", + salt: strings.Repeat("a", 10), + output: "551f089f570f989975b6f7c6a8ff3cf89bc486dd7bbe87ed4d80ad4362f8ee599ec8dda78dac196301b98456402bcda775dc", + shouldfail: false, + }, + { + algorithms: []string{"pbkdf2", "pbkdf2$10000$50"}, + password: "abcdef", + salt: strings.Repeat("a", 10), + output: "ab48d5471b7e6ed42d10001db88c852ff7303c788e49da5c3c7b63d5adf96360303724b74b679223a3dea8a242d10abb1913", + shouldfail: false, + }, + { + algorithms: []string{"bcrypt", "bcrypt$10"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "$2a$10$qhgm32w9ZpqLygugWJsLjey8xRGcaq9iXAfmCeNBXxddgyoaOC3Gq", + shouldfail: false, + }, + { + algorithms: []string{"scrypt", "scrypt$65536$16$2$50"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "25fe5f66b43fa4eb7b6717905317cd2223cf841092dc8e0a1e8c75720ad4846cb5d9387303e14bc3c69faa3b1c51ef4b7de1", + shouldfail: false, + }, + { + algorithms: []string{"argon2", "argon2$2$65536$8$50"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "9c287db63a91d18bb1414b703216da4fc431387c1ae7c8acdb280222f11f0929831055dbfd5126a3b48566692e83ec750d2a", + shouldfail: false, + }, + { + algorithms: []string{"pbkdf2", "pbkdf2$10000$50"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "45d6cdc843d65cf0eda7b90ab41435762a282f7df013477a1c5b212ba81dbdca2edf1ecc4b5cb05956bb9e0c37ab29315d78", + shouldfail: false, + }, + { + algorithms: []string{"pbkdf2$320000$50"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "84e233114499e8721da80e85568e5b7b5900b3e49a30845fcda9d1e1756da4547d70f8740ac2b4a5d82f88cebcd27f21bfe2", + shouldfail: false, + }, + { + algorithms: []string{"pbkdf2", "pbkdf2$10000$50"}, + password: "abcdef", + salt: "", + output: "", + shouldfail: true, + }, +} + +// Ensure that the current code will correctly verify against the test vectors. +func TestVectors(t *testing.T) { + for i, vector := range vectors { + for _, algorithm := range vector.algorithms { + t.Run(strconv.Itoa(i)+": "+algorithm, func(t *testing.T) { + pa := Parse(algorithm) + assert.Equal(t, !vector.shouldfail, pa.VerifyPassword(vector.password, vector.output, vector.salt)) + }) + } + } +} diff --git a/modules/auth/password/hash/pbkdf2.go b/modules/auth/password/hash/pbkdf2.go new file mode 100644 index 000000000..27382fedb --- /dev/null +++ b/modules/auth/password/hash/pbkdf2.go @@ -0,0 +1,67 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "code.gitea.io/gitea/modules/log" + + "golang.org/x/crypto/pbkdf2" +) + +func init() { + MustRegister("pbkdf2", NewPBKDF2Hasher) +} + +// PBKDF2Hasher implements PasswordHasher +// and uses the PBKDF2 key derivation function. +type PBKDF2Hasher struct { + iter, keyLen int +} + +// HashWithSaltBytes a provided password and salt +func (hasher *PBKDF2Hasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + return hex.EncodeToString(pbkdf2.Key([]byte(password), salt, hasher.iter, hasher.keyLen, sha256.New)) +} + +// NewPBKDF2Hasher is a factory method to create an PBKDF2Hasher +// config should be either empty or of the form: +// "<iter>$<keyLen>", where <x> is the string representation +// of an integer +func NewPBKDF2Hasher(config string) *PBKDF2Hasher { + // This default configuration uses the following parameters: + // iter=10000, keyLen=50. + // This matches the original configuration for `pbkdf2` prior to storing parameters + // in the database. + // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK + hasher := &PBKDF2Hasher{ + iter: 10_000, + keyLen: 50, + } + + if config == "" { + return hasher + } + + vals := strings.SplitN(config, "$", 2) + if len(vals) != 2 { + log.Error("invalid pbkdf2 hash spec %s", config) + return nil + } + + var err error + hasher.iter, err = parseIntParam(vals[0], "iter", "pbkdf2", config, nil) + hasher.keyLen, err = parseIntParam(vals[1], "keyLen", "pbkdf2", config, err) + if err != nil { + return nil + } + + return hasher +} diff --git a/modules/auth/password/hash/scrypt.go b/modules/auth/password/hash/scrypt.go new file mode 100644 index 000000000..f3d38f751 --- /dev/null +++ b/modules/auth/password/hash/scrypt.go @@ -0,0 +1,67 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "encoding/hex" + "strings" + + "code.gitea.io/gitea/modules/log" + + "golang.org/x/crypto/scrypt" +) + +func init() { + MustRegister("scrypt", NewScryptHasher) +} + +// ScryptHasher implements PasswordHasher +// and uses the scrypt key derivation function. +type ScryptHasher struct { + n, r, p, keyLen int +} + +// HashWithSaltBytes a provided password and salt +func (hasher *ScryptHasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + hashedPassword, _ := scrypt.Key([]byte(password), salt, hasher.n, hasher.r, hasher.p, hasher.keyLen) + return hex.EncodeToString(hashedPassword) +} + +// NewScryptHasher is a factory method to create an ScryptHasher +// The provided config should be either empty or of the form: +// "<n>$<r>$<p>$<keyLen>", where <x> is the string representation +// of an integer +func NewScryptHasher(config string) *ScryptHasher { + // This matches the original configuration for `scrypt` prior to storing hash parameters + // in the database. + // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK + hasher := &ScryptHasher{ + n: 1 << 16, + r: 16, + p: 2, // 2 passes through memory - this default config will use 128MiB in total. + keyLen: 50, + } + + if config == "" { + return hasher + } + + vals := strings.SplitN(config, "$", 4) + if len(vals) != 4 { + log.Error("invalid scrypt hash spec %s", config) + return nil + } + var err error + hasher.n, err = parseIntParam(vals[0], "n", "scrypt", config, nil) + hasher.r, err = parseIntParam(vals[1], "r", "scrypt", config, err) + hasher.p, err = parseIntParam(vals[2], "p", "scrypt", config, err) + hasher.keyLen, err = parseIntParam(vals[3], "keyLen", "scrypt", config, err) + if err != nil { + return nil + } + return hasher +} diff --git a/modules/auth/password/hash/setting.go b/modules/auth/password/hash/setting.go new file mode 100644 index 000000000..701697430 --- /dev/null +++ b/modules/auth/password/hash/setting.go @@ -0,0 +1,61 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +// DefaultHashAlgorithmName represents the default value of PASSWORD_HASH_ALGO +// configured in app.ini. +// +// It is NOT the same and does NOT map to the defaultEmptyHashAlgorithmSpecification. +// +// It will be dealiased as per aliasAlgorithmNames whereas +// defaultEmptyHashAlgorithmSpecification does not undergo dealiasing. +const DefaultHashAlgorithmName = "pbkdf2" + +var DefaultHashAlgorithm *PasswordHashAlgorithm + +// aliasAlgorithNames provides a mapping between the value of PASSWORD_HASH_ALGO +// configured in the app.ini and the parameters used within the hashers internally. +// +// If it is necessary to change the default parameters for any hasher in future you +// should change these values and not those in argon2.go etc. +var aliasAlgorithmNames = map[string]string{ + "argon2": "argon2$2$65536$8$50", + "bcrypt": "bcrypt$10", + "scrypt": "scrypt$65536$16$2$50", + "pbkdf2": "pbkdf2_v2", // pbkdf2 should default to pbkdf2_v2 + "pbkdf2_v1": "pbkdf2$10000$50", + // The latest PBKDF2 password algorithm is used as the default since it doesn't + // use a lot of memory and is safer to use on less powerful devices. + "pbkdf2_v2": "pbkdf2$50000$50", + // The pbkdf2_hi password algorithm is offered as a stronger alternative to the + // slightly improved pbkdf2_v2 algorithm + "pbkdf2_hi": "pbkdf2$320000$50", +} + +var RecommendedHashAlgorithms = []string{ + "pbkdf2", + "argon2", + "bcrypt", + "scrypt", + "pbkdf2_hi", +} + +// SetDefaultPasswordHashAlgorithm will take a provided algorithmName and dealias it to +// a complete algorithm specification. +func SetDefaultPasswordHashAlgorithm(algorithmName string) (string, *PasswordHashAlgorithm) { + if algorithmName == "" { + algorithmName = DefaultHashAlgorithmName + } + alias, has := aliasAlgorithmNames[algorithmName] + for has { + algorithmName = alias + alias, has = aliasAlgorithmNames[algorithmName] + } + + // algorithmName should now be a full algorithm specification + // e.g. pbkdf2$50000$50 rather than pbdkf2 + DefaultHashAlgorithm = Parse(algorithmName) + + return algorithmName, DefaultHashAlgorithm +} diff --git a/modules/auth/password/hash/setting_test.go b/modules/auth/password/hash/setting_test.go new file mode 100644 index 000000000..d707207db --- /dev/null +++ b/modules/auth/password/hash/setting_test.go @@ -0,0 +1,38 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCheckSettingPasswordHashAlgorithm(t *testing.T) { + t.Run("pbkdf2 is pbkdf2_v2", func(t *testing.T) { + pbkdf2v2Config, pbkdf2v2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2_v2") + pbkdf2Config, pbkdf2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2") + + assert.Equal(t, pbkdf2v2Config, pbkdf2Config) + assert.Equal(t, pbkdf2v2Algo.Specification, pbkdf2Algo.Specification) + }) + + for a, b := range aliasAlgorithmNames { + t.Run(a+"="+b, func(t *testing.T) { + aConfig, aAlgo := SetDefaultPasswordHashAlgorithm(a) + bConfig, bAlgo := SetDefaultPasswordHashAlgorithm(b) + + assert.Equal(t, bConfig, aConfig) + assert.Equal(t, aAlgo.Specification, bAlgo.Specification) + }) + } + + t.Run("pbkdf2_v2 is the default when default password hash algorithm is empty", func(t *testing.T) { + emptyConfig, emptyAlgo := SetDefaultPasswordHashAlgorithm("") + pbkdf2v2Config, pbkdf2v2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2_v2") + + assert.Equal(t, pbkdf2v2Config, emptyConfig) + assert.Equal(t, pbkdf2v2Algo.Specification, emptyAlgo.Specification) + }) +} diff --git a/modules/password/password.go b/modules/auth/password/password.go index fe2a2a7bd..2172dc8b4 100644 --- a/modules/password/password.go +++ b/modules/auth/password/password.go @@ -11,8 +11,8 @@ import ( "strings" "sync" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" ) // complexity contains information about a particular kind of password complexity @@ -112,13 +112,13 @@ func Generate(n int) (string, error) { } // BuildComplexityError builds the error message when password complexity checks fail -func BuildComplexityError(ctx *context.Context) string { +func BuildComplexityError(locale translation.Locale) string { var buffer bytes.Buffer - buffer.WriteString(ctx.Tr("form.password_complexity")) + buffer.WriteString(locale.Tr("form.password_complexity")) buffer.WriteString("<ul>") for _, c := range requiredList { buffer.WriteString("<li>") - buffer.WriteString(ctx.Tr(c.TrNameOne)) + buffer.WriteString(locale.Tr(c.TrNameOne)) buffer.WriteString("</li>") } buffer.WriteString("</ul>") diff --git a/modules/password/password_test.go b/modules/auth/password/password_test.go index 6c35dc86b..6c35dc86b 100644 --- a/modules/password/password_test.go +++ b/modules/auth/password/password_test.go diff --git a/modules/password/pwn.go b/modules/auth/password/pwn.go index 91bad0d25..df425ac65 100644 --- a/modules/password/pwn.go +++ b/modules/auth/password/pwn.go @@ -6,7 +6,7 @@ package password import ( "context" - "code.gitea.io/gitea/modules/password/pwn" + "code.gitea.io/gitea/modules/auth/password/pwn" "code.gitea.io/gitea/modules/setting" ) diff --git a/modules/password/pwn/pwn.go b/modules/auth/password/pwn/pwn.go index b5a015fb9..b5a015fb9 100644 --- a/modules/password/pwn/pwn.go +++ b/modules/auth/password/pwn/pwn.go diff --git a/modules/password/pwn/pwn_test.go b/modules/auth/password/pwn/pwn_test.go index 148208b96..148208b96 100644 --- a/modules/password/pwn/pwn_test.go +++ b/modules/auth/password/pwn/pwn_test.go diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go index 937da872c..e732878f8 100644 --- a/modules/auth/webauthn/webauthn.go +++ b/modules/auth/webauthn/webauthn.go @@ -8,6 +8,7 @@ import ( "encoding/gob" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" @@ -62,7 +63,7 @@ func (u *User) WebAuthnDisplayName() string { // WebAuthnIcon implements the webauthn.User interface func (u *User) WebAuthnIcon() string { - return (*user_model.User)(u).AvatarLink() + return (*user_model.User)(u).AvatarLink(db.DefaultContext) } // WebAuthnCredentials implementns the webauthn.User interface diff --git a/modules/cache/context.go b/modules/cache/context.go new file mode 100644 index 000000000..f741a8744 --- /dev/null +++ b/modules/cache/context.go @@ -0,0 +1,92 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cache + +import ( + "context" + "sync" + + "code.gitea.io/gitea/modules/log" +) + +// cacheContext is a context that can be used to cache data in a request level context +// This is useful for caching data that is expensive to calculate and is likely to be +// used multiple times in a request. +type cacheContext struct { + ctx context.Context + data map[any]map[any]any + lock sync.RWMutex +} + +func (cc *cacheContext) Get(tp, key any) any { + cc.lock.RLock() + defer cc.lock.RUnlock() + if cc.data[tp] == nil { + return nil + } + return cc.data[tp][key] +} + +func (cc *cacheContext) Put(tp, key, value any) { + cc.lock.Lock() + defer cc.lock.Unlock() + if cc.data[tp] == nil { + cc.data[tp] = make(map[any]any) + } + cc.data[tp][key] = value +} + +func (cc *cacheContext) Delete(tp, key any) { + cc.lock.Lock() + defer cc.lock.Unlock() + if cc.data[tp] == nil { + return + } + delete(cc.data[tp], key) +} + +var cacheContextKey = struct{}{} + +func WithCacheContext(ctx context.Context) context.Context { + return context.WithValue(ctx, cacheContextKey, &cacheContext{ + ctx: ctx, + data: make(map[any]map[any]any), + }) +} + +func GetContextData(ctx context.Context, tp, key any) any { + if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok { + return c.Get(tp, key) + } + log.Warn("cannot get cache context when getting data: %v", ctx) + return nil +} + +func SetContextData(ctx context.Context, tp, key, value any) { + if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok { + c.Put(tp, key, value) + return + } + log.Warn("cannot get cache context when setting data: %v", ctx) +} + +func RemoveContextData(ctx context.Context, tp, key any) { + if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok { + c.Delete(tp, key) + } +} + +// GetWithContextCache returns the cache value of the given key in the given context. +func GetWithContextCache[T any](ctx context.Context, cacheGroupKey string, cacheTargetID any, f func() (T, error)) (T, error) { + v := GetContextData(ctx, cacheGroupKey, cacheTargetID) + if vv, ok := v.(T); ok { + return vv, nil + } + t, err := f() + if err != nil { + return t, err + } + SetContextData(ctx, cacheGroupKey, cacheTargetID, t) + return t, nil +} diff --git a/modules/cache/context_test.go b/modules/cache/context_test.go new file mode 100644 index 000000000..77e3ecad2 --- /dev/null +++ b/modules/cache/context_test.go @@ -0,0 +1,41 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cache + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWithCacheContext(t *testing.T) { + ctx := WithCacheContext(context.Background()) + + v := GetContextData(ctx, "empty_field", "my_config1") + assert.Nil(t, v) + + const field = "system_setting" + v = GetContextData(ctx, field, "my_config1") + assert.Nil(t, v) + SetContextData(ctx, field, "my_config1", 1) + v = GetContextData(ctx, field, "my_config1") + assert.NotNil(t, v) + assert.EqualValues(t, 1, v.(int)) + + RemoveContextData(ctx, field, "my_config1") + RemoveContextData(ctx, field, "my_config2") // remove an non-exist key + + v = GetContextData(ctx, field, "my_config1") + assert.Nil(t, v) + + vInt, err := GetWithContextCache(ctx, field, "my_config1", func() (int, error) { + return 1, nil + }) + assert.NoError(t, err) + assert.EqualValues(t, 1, vInt) + + v = GetContextData(ctx, field, "my_config1") + assert.EqualValues(t, 1, v) +} diff --git a/modules/context/access_log.go b/modules/context/access_log.go index 05c0f8621..84663ee8d 100644 --- a/modules/context/access_log.go +++ b/modules/context/access_log.go @@ -27,7 +27,7 @@ var signedUserNameStringPointerKey interface{} = "signedUserNameStringPointerKey // AccessLogger returns a middleware to log access logger func AccessLogger() func(http.Handler) http.Handler { logger := log.GetLogger("access") - logTemplate, _ := template.New("log").Parse(setting.AccessLogTemplate) + logTemplate, _ := template.New("log").Parse(setting.Log.AccessLogTemplate) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { start := time.Now() diff --git a/modules/context/repo.go b/modules/context/repo.go index 38c1d8454..e4ac65e96 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -273,8 +273,8 @@ func RetrieveBaseRepo(ctx *Context, repo *repo_model.Repository) { } ctx.ServerError("GetBaseRepo", err) return - } else if err = repo.BaseRepo.GetOwner(ctx); err != nil { - ctx.ServerError("BaseRepo.GetOwner", err) + } else if err = repo.BaseRepo.LoadOwner(ctx); err != nil { + ctx.ServerError("BaseRepo.LoadOwner", err) return } } @@ -290,8 +290,8 @@ func RetrieveTemplateRepo(ctx *Context, repo *repo_model.Repository) { } ctx.ServerError("GetTemplateRepo", err) return - } else if err = templateRepo.GetOwner(ctx); err != nil { - ctx.ServerError("TemplateRepo.GetOwner", err) + } else if err = templateRepo.LoadOwner(ctx); err != nil { + ctx.ServerError("TemplateRepo.LoadOwner", err) return } @@ -356,8 +356,8 @@ func RedirectToRepo(ctx *Context, redirectRepoID int64) { func repoAssignment(ctx *Context, repo *repo_model.Repository) { var err error - if err = repo.GetOwner(ctx); err != nil { - ctx.ServerError("GetOwner", err) + if err = repo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) return } @@ -743,9 +743,9 @@ func RepoAssignment(ctx *Context) (cancel context.CancelFunc) { if ctx.FormString("go-get") == "1" { ctx.Data["GoGetImport"] = ComposeGoGetImport(owner.Name, repo.Name) - prefix := repo.HTMLURL() + "/src/branch/" + util.PathEscapeSegments(ctx.Repo.BranchName) - ctx.Data["GoDocDirectory"] = prefix + "{/dir}" - ctx.Data["GoDocFile"] = prefix + "{/dir}/{file}#L{line}" + fullURLPrefix := repo.HTMLURL() + "/src/branch/" + util.PathEscapeSegments(ctx.Repo.BranchName) + ctx.Data["GoDocDirectory"] = fullURLPrefix + "{/dir}" + ctx.Data["GoDocFile"] = fullURLPrefix + "{/dir}/{file}#L{line}" } return cancel } diff --git a/modules/doctor/doctor.go b/modules/doctor/doctor.go index 2025edc58..b23805bc4 100644 --- a/modules/doctor/doctor.go +++ b/modules/doctor/doctor.go @@ -44,10 +44,10 @@ func (w *wrappedLevelLogger) Log(skip int, level log.Level, format string, v ... } func initDBDisableConsole(ctx context.Context, disableConsole bool) error { - setting.LoadFromExisting() - setting.InitDBConfig() - - setting.NewXORMLogService(disableConsole) + setting.InitProviderFromExistingFile() + setting.LoadCommonSettings() + setting.LoadDBSetting() + setting.InitSQLLog(disableConsole) if err := db.InitEngine(ctx); err != nil { return fmt.Errorf("db.InitEngine: %w", err) } @@ -71,7 +71,7 @@ func RunChecks(ctx context.Context, logger log.Logger, autofix bool, checks []*C for i, check := range checks { if !dbIsInit && !check.SkipDatabaseInitialization { // Only open database after the most basic configuration check - setting.EnableXORMLog = false + setting.Log.EnableXORMLog = false if err := initDBDisableConsole(ctx, true); err != nil { logger.Error("Error whilst initializing the database: %v", err) logger.Error("Check if you are using the right config file. You can use a --config directive to specify one.") diff --git a/modules/doctor/misc.go b/modules/doctor/misc.go index 73df51390..f20b5b26d 100644 --- a/modules/doctor/misc.go +++ b/modules/doctor/misc.go @@ -141,7 +141,7 @@ func checkDaemonExport(ctx context.Context, logger log.Logger, autofix bool) err if owner, has := cache.Get(repo.OwnerID); has { repo.Owner = owner.(*user_model.User) } else { - if err := repo.GetOwner(ctx); err != nil { + if err := repo.LoadOwner(ctx); err != nil { return err } cache.Add(repo.OwnerID, repo.Owner) diff --git a/modules/doctor/paths.go b/modules/doctor/paths.go index ad50078d3..7b1c6ce9a 100644 --- a/modules/doctor/paths.go +++ b/modules/doctor/paths.go @@ -67,7 +67,8 @@ func checkConfigurationFiles(ctx context.Context, logger log.Logger, autofix boo return err } - setting.LoadFromExisting() + setting.InitProviderFromExistingFile() + setting.LoadCommonSettings() configurationFiles := []configurationFile{ {"Configuration File Path", setting.CustomConf, false, true, false}, @@ -75,7 +76,7 @@ func checkConfigurationFiles(ctx context.Context, logger log.Logger, autofix boo {"Data Root Path", setting.AppDataPath, true, true, true}, {"Custom File Root Path", setting.CustomPath, true, false, false}, {"Work directory", setting.AppWorkPath, true, true, false}, - {"Log Root Path", setting.LogRootPath, true, true, true}, + {"Log Root Path", setting.Log.RootPath, true, true, true}, } if options.IsDynamic() { diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go index 84b6ea407..951312148 100644 --- a/modules/git/tree_entry.go +++ b/modules/git/tree_entry.go @@ -101,6 +101,16 @@ func (te *TreeEntry) FollowLinks() (*TreeEntry, error) { return entry, nil } +// returns the Tree pointed to by this TreeEntry, or nil if this is not a tree +func (te *TreeEntry) Tree() *Tree { + t, err := te.ptree.repo.getTree(te.ID) + if err != nil { + return nil + } + t.ptree = te.ptree + return t +} + // GetSubJumpablePathName return the full path of subdirectory jumpable ( contains only one directory ) func (te *TreeEntry) GetSubJumpablePathName() string { if te.IsSubModule() || !te.IsDir() { diff --git a/modules/gitgraph/graph_models.go b/modules/gitgraph/graph_models.go index 0e0fc1cd0..748f7f307 100644 --- a/modules/gitgraph/graph_models.go +++ b/modules/gitgraph/graph_models.go @@ -5,6 +5,7 @@ package gitgraph import ( "bytes" + "context" "fmt" "strings" @@ -88,9 +89,8 @@ func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error // LoadAndProcessCommits will load the git.Commits for each commit in the graph, // the associate the commit with the user author, and check the commit verification // before finally retrieving the latest status -func (graph *Graph) LoadAndProcessCommits(repository *repo_model.Repository, gitRepo *git.Repository) error { +func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error { var err error - var ok bool emails := map[string]*user_model.User{} @@ -108,12 +108,12 @@ func (graph *Graph) LoadAndProcessCommits(repository *repo_model.Repository, git if c.Commit.Author != nil { email := c.Commit.Author.Email if c.User, ok = emails[email]; !ok { - c.User, _ = user_model.GetUserByEmail(email) + c.User, _ = user_model.GetUserByEmail(ctx, email) emails[email] = c.User } } - c.Verification = asymkey_model.ParseCommitWithSignature(c.Commit) + c.Verification = asymkey_model.ParseCommitWithSignature(ctx, c.Commit) _ = asymkey_model.CalculateTrustStatus(c.Verification, repository.GetTrustModel(), func(user *user_model.User) (bool, error) { return repo_model.IsOwnerMemberCollaborator(repository, user.ID) diff --git a/modules/highlight/highlight.go b/modules/highlight/highlight.go index 05e472c08..a5c38940a 100644 --- a/modules/highlight/highlight.go +++ b/modules/highlight/highlight.go @@ -41,12 +41,8 @@ var ( // NewContext loads custom highlight map from local config func NewContext() { once.Do(func() { - if setting.Cfg != nil { - keys := setting.Cfg.Section("highlight.mapping").Keys() - for i := range keys { - highlightMapping[keys[i].Name()] = keys[i].Value() - } - } + highlightMapping = setting.GetHighlightMapping() + // The size 512 is simply a conservative rule of thumb c, err := lru.New2Q(512) if err != nil { diff --git a/modules/indexer/code/bleve.go b/modules/indexer/code/bleve.go index 6ee2639d3..e9085f410 100644 --- a/modules/indexer/code/bleve.go +++ b/modules/indexer/code/bleve.go @@ -27,6 +27,7 @@ import ( "github.com/blevesearch/bleve/v2" analyzer_custom "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" analyzer_keyword "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword" + "github.com/blevesearch/bleve/v2/analysis/token/camelcase" "github.com/blevesearch/bleve/v2/analysis/token/lowercase" "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" @@ -107,7 +108,7 @@ func (d *RepoIndexerData) Type() string { const ( repoIndexerAnalyzer = "repoIndexerAnalyzer" repoIndexerDocType = "repoIndexerDocType" - repoIndexerLatestVersion = 5 + repoIndexerLatestVersion = 6 ) // createBleveIndexer create a bleve repo indexer if one does not already exist @@ -138,7 +139,7 @@ func createBleveIndexer(path string, latestVersion int) (bleve.Index, error) { "type": analyzer_custom.Name, "char_filters": []string{}, "tokenizer": unicode.Name, - "token_filters": []string{unicodeNormalizeName, lowercase.Name}, + "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, }); err != nil { return nil, err } diff --git a/modules/indexer/issues/bleve.go b/modules/indexer/issues/bleve.go index 952bddfb2..e3ef9af5b 100644 --- a/modules/indexer/issues/bleve.go +++ b/modules/indexer/issues/bleve.go @@ -15,6 +15,7 @@ import ( "github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" + "github.com/blevesearch/bleve/v2/analysis/token/camelcase" "github.com/blevesearch/bleve/v2/analysis/token/lowercase" "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" @@ -27,7 +28,7 @@ import ( const ( issueIndexerAnalyzer = "issueIndexer" issueIndexerDocType = "issueIndexerDocType" - issueIndexerLatestVersion = 1 + issueIndexerLatestVersion = 2 ) // indexerID a bleve-compatible unique identifier for an integer id @@ -134,7 +135,7 @@ func createIssueIndexer(path string, latestVersion int) (bleve.Index, error) { "type": custom.Name, "char_filters": []string{}, "tokenizer": unicode.Name, - "token_filters": []string{unicodeNormalizeName, lowercase.Name}, + "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, }); err != nil { return nil, err } diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index ff0541d04..bf9d6d0d1 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -27,11 +27,11 @@ func TestMain(m *testing.M) { func TestBleveSearchIssues(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - setting.Cfg = ini.Empty() + setting.CfgProvider = ini.Empty() tmpIndexerDir := t.TempDir() - setting.Cfg.Section("queue.issue_indexer").Key("DATADIR").MustString(path.Join(tmpIndexerDir, "issues.queue")) + setting.CfgProvider.Section("queue.issue_indexer").Key("DATADIR").MustString(path.Join(tmpIndexerDir, "issues.queue")) oldIssuePath := setting.Indexer.IssuePath setting.Indexer.IssuePath = path.Join(tmpIndexerDir, "issues.queue") @@ -40,7 +40,7 @@ func TestBleveSearchIssues(t *testing.T) { }() setting.Indexer.IssueType = "bleve" - setting.NewQueueService() + setting.LoadQueueSettings() InitIssueIndexer(true) defer func() { indexer := holder.get() diff --git a/modules/indexer/stats/indexer_test.go b/modules/indexer/stats/indexer_test.go index bc6c4cd7f..50a5fade7 100644 --- a/modules/indexer/stats/indexer_test.go +++ b/modules/indexer/stats/indexer_test.go @@ -29,9 +29,9 @@ func TestMain(m *testing.M) { func TestRepoStatsIndex(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - setting.Cfg = ini.Empty() + setting.CfgProvider = ini.Empty() - setting.NewQueueService() + setting.LoadQueueSettings() err := Init() assert.NoError(t, err) diff --git a/modules/lfs/endpoint.go b/modules/lfs/endpoint.go index 3ae3cf077..2931defcd 100644 --- a/modules/lfs/endpoint.go +++ b/modules/lfs/endpoint.go @@ -4,7 +4,6 @@ package lfs import ( - "fmt" "net/url" "os" "path" @@ -12,6 +11,7 @@ import ( "strings" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" ) // DetermineEndpoint determines an endpoint from the clone url or uses the specified LFS url. @@ -95,7 +95,7 @@ func endpointFromLocalPath(path string) *url.URL { return nil } - path = fmt.Sprintf("file://%s%s", slash, filepath.ToSlash(path)) + path = "file://" + slash + util.PathEscapeSegments(filepath.ToSlash(path)) u, _ := url.Parse(path) diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index 32f26bffa..aea1d9267 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -28,7 +28,8 @@ var localMetas = map[string]string{ } func TestMain(m *testing.M) { - setting.LoadAllowEmpty() + setting.InitProviderAllowEmpty() + setting.LoadCommonSettings() if err := git.InitSimple(context.Background()); err != nil { log.Fatal("git init failed, err: %v", err) } diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index cc683dc5b..bb458a65c 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -33,7 +33,8 @@ var localMetas = map[string]string{ } func TestMain(m *testing.M) { - setting.LoadAllowEmpty() + setting.InitProviderAllowEmpty() + setting.LoadCommonSettings() if err := git.InitSimple(context.Background()); err != nil { log.Fatal("git init failed, err: %v", err) } diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index 5a31e961f..df2c9ebfc 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -317,41 +317,3 @@ func IsMarkupFile(name, markup string) bool { } return false } - -// IsReadmeFile reports whether name looks like a README file -// based on its name. -func IsReadmeFile(name string) bool { - name = strings.ToLower(name) - if len(name) < 6 { - return false - } else if len(name) == 6 { - return name == "readme" - } - return name[:7] == "readme." -} - -// IsReadmeFileExtension reports whether name looks like a README file -// based on its name. It will look through the provided extensions and check if the file matches -// one of the extensions and provide the index in the extension list. -// If the filename is `readme.` with an unmatched extension it will match with the index equaling -// the length of the provided extension list. -// Note that the '.' should be provided in ext, e.g ".md" -func IsReadmeFileExtension(name string, ext ...string) (int, bool) { - name = strings.ToLower(name) - if len(name) < 6 || name[:6] != "readme" { - return 0, false - } - - for i, extension := range ext { - extension = strings.ToLower(extension) - if name[6:] == extension { - return i, true - } - } - - if name[6] == '.' { - return len(ext), true - } - - return 0, false -} diff --git a/modules/markup/renderer_test.go b/modules/markup/renderer_test.go index 624558c3f..0791081f9 100644 --- a/modules/markup/renderer_test.go +++ b/modules/markup/renderer_test.go @@ -2,94 +2,3 @@ // SPDX-License-Identifier: MIT package markup_test - -import ( - "testing" - - . "code.gitea.io/gitea/modules/markup" - - _ "code.gitea.io/gitea/modules/markup/markdown" - - "github.com/stretchr/testify/assert" -) - -func TestMisc_IsReadmeFile(t *testing.T) { - trueTestCases := []string{ - "readme", - "README", - "readME.mdown", - "README.md", - "readme.i18n.md", - } - falseTestCases := []string{ - "test.md", - "wow.MARKDOWN", - "LOL.mDoWn", - "test", - "abcdefg", - "abcdefghijklmnopqrstuvwxyz", - "test.md.test", - "readmf", - } - - for _, testCase := range trueTestCases { - assert.True(t, IsReadmeFile(testCase)) - } - for _, testCase := range falseTestCases { - assert.False(t, IsReadmeFile(testCase)) - } - - type extensionTestcase struct { - name string - expected bool - idx int - } - - exts := []string{".md", ".txt", ""} - testCasesExtensions := []extensionTestcase{ - { - name: "readme", - expected: true, - idx: 2, - }, - { - name: "readme.md", - expected: true, - idx: 0, - }, - { - name: "README.md", - expected: true, - idx: 0, - }, - { - name: "ReAdMe.Md", - expected: true, - idx: 0, - }, - { - name: "readme.txt", - expected: true, - idx: 1, - }, - { - name: "readme.doc", - expected: true, - idx: 3, - }, - { - name: "readmee.md", - }, - { - name: "readme..", - expected: true, - idx: 3, - }, - } - - for _, testCase := range testCasesExtensions { - idx, ok := IsReadmeFileExtension(testCase.name, exts...) - assert.Equal(t, testCase.expected, ok) - assert.Equal(t, testCase.idx, idx) - } -} diff --git a/modules/migration/label.go b/modules/migration/label.go index 38f0eb10d..4927be3c0 100644 --- a/modules/migration/label.go +++ b/modules/migration/label.go @@ -9,4 +9,5 @@ type Label struct { Name string `json:"name"` Color string `json:"color"` Description string `json:"description"` + Exclusive bool `json:"exclusive"` } diff --git a/modules/repository/collaborator_test.go b/modules/repository/collaborator_test.go index 6cf239d0e..c77280681 100644 --- a/modules/repository/collaborator_test.go +++ b/modules/repository/collaborator_test.go @@ -23,7 +23,7 @@ func TestRepository_AddCollaborator(t *testing.T) { testSuccess := func(repoID, userID int64) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) - assert.NoError(t, repo.GetOwner(db.DefaultContext)) + assert.NoError(t, repo.LoadOwner(db.DefaultContext)) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) assert.NoError(t, AddCollaborator(db.DefaultContext, repo, user)) unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID}) diff --git a/modules/repository/commits.go b/modules/repository/commits.go index a47f9b2dc..96844d5b1 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -53,7 +53,7 @@ func (pc *PushCommits) toAPIPayloadCommit(ctx context.Context, repoPath, repoLin authorUsername := "" author, ok := pc.emailUsers[commit.AuthorEmail] if !ok { - author, err = user_model.GetUserByEmail(commit.AuthorEmail) + author, err = user_model.GetUserByEmail(ctx, commit.AuthorEmail) if err == nil { authorUsername = author.Name pc.emailUsers[commit.AuthorEmail] = author @@ -65,7 +65,7 @@ func (pc *PushCommits) toAPIPayloadCommit(ctx context.Context, repoPath, repoLin committerUsername := "" committer, ok := pc.emailUsers[commit.CommitterEmail] if !ok { - committer, err = user_model.GetUserByEmail(commit.CommitterEmail) + committer, err = user_model.GetUserByEmail(ctx, commit.CommitterEmail) if err == nil { // TODO: check errors other than email not found. committerUsername = committer.Name @@ -133,7 +133,7 @@ func (pc *PushCommits) ToAPIPayloadCommits(ctx context.Context, repoPath, repoLi // AvatarLink tries to match user in database with e-mail // in order to show custom avatar, and falls back to general avatar link. -func (pc *PushCommits) AvatarLink(email string) string { +func (pc *PushCommits) AvatarLink(ctx context.Context, email string) string { if pc.avatars == nil { pc.avatars = make(map[string]string) } @@ -147,9 +147,9 @@ func (pc *PushCommits) AvatarLink(email string) string { u, ok := pc.emailUsers[email] if !ok { var err error - u, err = user_model.GetUserByEmail(email) + u, err = user_model.GetUserByEmail(ctx, email) if err != nil { - pc.avatars[email] = avatars.GenerateEmailAvatarFastLink(email, size) + pc.avatars[email] = avatars.GenerateEmailAvatarFastLink(ctx, email, size) if !user_model.IsErrUserNotExist(err) { log.Error("GetUserByEmail: %v", err) return "" @@ -159,7 +159,7 @@ func (pc *PushCommits) AvatarLink(email string) string { } } if u != nil { - pc.avatars[email] = u.AvatarLinkWithSize(size) + pc.avatars[email] = u.AvatarLinkWithSize(ctx, size) } return pc.avatars[email] diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go index 2ae4bc73d..2bd8de38a 100644 --- a/modules/repository/commits_test.go +++ b/modules/repository/commits_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/models/unittest" @@ -102,7 +103,7 @@ func TestPushCommits_ToAPIPayloadCommits(t *testing.T) { } func enableGravatar(t *testing.T) { - err := system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, "false") + err := system_model.SetSettingNoVersion(db.DefaultContext, system_model.KeyPictureDisableGravatar, "false") assert.NoError(t, err) setting.GravatarSource = "https://secure.gravatar.com/avatar" err = system_model.Init() @@ -136,13 +137,13 @@ func TestPushCommits_AvatarLink(t *testing.T) { assert.Equal(t, "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s=84", - pushCommits.AvatarLink("user2@example.com")) + pushCommits.AvatarLink(db.DefaultContext, "user2@example.com")) assert.Equal(t, "https://secure.gravatar.com/avatar/"+ fmt.Sprintf("%x", md5.Sum([]byte("nonexistent@example.com")))+ "?d=identicon&s=84", - pushCommits.AvatarLink("nonexistent@example.com")) + pushCommits.AvatarLink(db.DefaultContext, "nonexistent@example.com")) } func TestCommitToPushCommit(t *testing.T) { diff --git a/modules/repository/create.go b/modules/repository/create.go index b9a72ad57..1704ea792 100644 --- a/modules/repository/create.go +++ b/modules/repository/create.go @@ -335,7 +335,7 @@ func UpdateRepoSize(ctx context.Context, repo *repo_model.Repository) error { // CheckDaemonExportOK creates/removes git-daemon-export-ok for git-daemon... func CheckDaemonExportOK(ctx context.Context, repo *repo_model.Repository) error { - if err := repo.GetOwner(ctx); err != nil { + if err := repo.LoadOwner(ctx); err != nil { return err } @@ -379,8 +379,8 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili } if visibilityChanged { - if err = repo.GetOwner(ctx); err != nil { - return fmt.Errorf("getOwner: %w", err) + if err = repo.LoadOwner(ctx); err != nil { + return fmt.Errorf("LoadOwner: %w", err) } if repo.Owner.IsOrganization() { // Organization repository need to recalculate access table when visibility is changed. diff --git a/modules/repository/delete.go b/modules/repository/delete.go index c7e05e666..01674db4a 100644 --- a/modules/repository/delete.go +++ b/modules/repository/delete.go @@ -16,7 +16,7 @@ func CanUserDelete(repo *repo_model.Repository, user *user_model.User) (bool, er return true, nil } - if err := repo.GetOwner(db.DefaultContext); err != nil { + if err := repo.LoadOwner(db.DefaultContext); err != nil { return false, err } diff --git a/modules/repository/repo.go b/modules/repository/repo.go index c03e46999..a1dba8fc6 100644 --- a/modules/repository/repo.go +++ b/modules/repository/repo.go @@ -318,7 +318,7 @@ func SyncReleasesWithTags(repo *repo_model.Repository, gitRepo *git.Repository) return nil } - if err := PushUpdateAddTag(repo, gitRepo, tagName, sha1, refname); err != nil { + if err := PushUpdateAddTag(db.DefaultContext, repo, gitRepo, tagName, sha1, refname); err != nil { return fmt.Errorf("unable to PushUpdateAddTag: %q to Repo[%d:%s/%s]: %w", tagName, repo.ID, repo.OwnerName, repo.Name, err) } @@ -328,7 +328,7 @@ func SyncReleasesWithTags(repo *repo_model.Repository, gitRepo *git.Repository) } // PushUpdateAddTag must be called for any push actions to add tag -func PushUpdateAddTag(repo *repo_model.Repository, gitRepo *git.Repository, tagName, sha1, refname string) error { +func PushUpdateAddTag(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, tagName, sha1, refname string) error { tag, err := gitRepo.GetTagWithID(sha1, tagName) if err != nil { return fmt.Errorf("unable to GetTag: %w", err) @@ -350,7 +350,7 @@ func PushUpdateAddTag(repo *repo_model.Repository, gitRepo *git.Repository, tagN createdAt := time.Unix(1, 0) if sig != nil { - author, err = user_model.GetUserByEmail(sig.Email) + author, err = user_model.GetUserByEmail(ctx, sig.Email) if err != nil && !user_model.IsErrUserNotExist(err) { return fmt.Errorf("unable to GetUserByEmail for %q: %w", sig.Email, err) } diff --git a/modules/setting/actions.go b/modules/setting/actions.go index 5c83a73ae..b11500dab 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -19,11 +19,11 @@ var ( } ) -func newActions() { - sec := Cfg.Section("actions") +func loadActionsFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("actions") if err := sec.MapTo(&Actions); err != nil { log.Fatal("Failed to map Actions settings: %v", err) } - Actions.Storage = getStorage("actions_log", "", nil) + Actions.Storage = getStorage(rootCfg, "actions_log", "", nil) } diff --git a/modules/setting/admin.go b/modules/setting/admin.go new file mode 100644 index 000000000..2d2dd26de --- /dev/null +++ b/modules/setting/admin.go @@ -0,0 +1,16 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +// Admin settings +var Admin struct { + DisableRegularOrgCreation bool + DefaultEmailNotification string +} + +func loadAdminFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "admin", &Admin) + sec := rootCfg.Section("admin") + Admin.DefaultEmailNotification = sec.Key("DEFAULT_EMAIL_NOTIFICATIONS").MustString("enabled") +} diff --git a/modules/setting/api.go b/modules/setting/api.go new file mode 100644 index 000000000..c36f05cfd --- /dev/null +++ b/modules/setting/api.go @@ -0,0 +1,40 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/url" + "path" + + "code.gitea.io/gitea/modules/log" +) + +// API settings +var API = struct { + EnableSwagger bool + SwaggerURL string + MaxResponseItems int + DefaultPagingNum int + DefaultGitTreesPerPage int + DefaultMaxBlobSize int64 +}{ + EnableSwagger: true, + SwaggerURL: "", + MaxResponseItems: 50, + DefaultPagingNum: 30, + DefaultGitTreesPerPage: 1000, + DefaultMaxBlobSize: 10485760, +} + +func loadAPIFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "api", &API) + + defaultAppURL := string(Protocol) + "://" + Domain + ":" + HTTPPort + u, err := url.Parse(rootCfg.Section("server").Key("ROOT_URL").MustString(defaultAppURL)) + if err != nil { + log.Fatal("Invalid ROOT_URL '%s': %s", AppURL, err) + } + u.Path = path.Join(u.Path, "api", "swagger") + API.SwaggerURL = u.String() +} diff --git a/modules/setting/attachment.go b/modules/setting/attachment.go index 68a2e8720..8b6eb9fd7 100644 --- a/modules/setting/attachment.go +++ b/modules/setting/attachment.go @@ -20,11 +20,11 @@ var Attachment = struct { Enabled: true, } -func newAttachmentService() { - sec := Cfg.Section("attachment") +func loadAttachmentFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - Attachment.Storage = getStorage("attachments", storageType, sec) + Attachment.Storage = getStorage(rootCfg, "attachments", storageType, sec) Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip") Attachment.MaxSize = sec.Key("MAX_SIZE").MustInt64(4) diff --git a/modules/setting/cache.go b/modules/setting/cache.go index 2da79adb3..783246077 100644 --- a/modules/setting/cache.go +++ b/modules/setting/cache.go @@ -49,8 +49,8 @@ var CacheService = struct { // MemcacheMaxTTL represents the maximum memcache TTL const MemcacheMaxTTL = 30 * 24 * time.Hour -func newCacheService() { - sec := Cfg.Section("cache") +func loadCacheFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("cache") if err := sec.MapTo(&CacheService); err != nil { log.Fatal("Failed to map Cache settings: %v", err) } @@ -79,7 +79,7 @@ func newCacheService() { Service.EnableCaptcha = false } - sec = Cfg.Section("cache.last_commit") + sec = rootCfg.Section("cache.last_commit") if !CacheService.Enabled { CacheService.LastCommit.Enabled = false } diff --git a/modules/setting/camo.go b/modules/setting/camo.go new file mode 100644 index 000000000..366e9a116 --- /dev/null +++ b/modules/setting/camo.go @@ -0,0 +1,22 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import "code.gitea.io/gitea/modules/log" + +var Camo = struct { + Enabled bool + ServerURL string `ini:"SERVER_URL"` + HMACKey string `ini:"HMAC_KEY"` + Allways bool +}{} + +func loadCamoFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "camo", &Camo) + if Camo.Enabled { + if Camo.ServerURL == "" || Camo.HMACKey == "" { + log.Fatal(`Camo settings require "SERVER_URL" and HMAC_KEY`) + } + } +} diff --git a/modules/setting/config_provider.go b/modules/setting/config_provider.go new file mode 100644 index 000000000..67a4e4ded --- /dev/null +++ b/modules/setting/config_provider.go @@ -0,0 +1,39 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/modules/log" + + ini "gopkg.in/ini.v1" +) + +// ConfigProvider represents a config provider +type ConfigProvider interface { + Section(section string) *ini.Section + NewSection(name string) (*ini.Section, error) + GetSection(name string) (*ini.Section, error) +} + +// a file is an implementation ConfigProvider and other implementations are possible, i.e. from docker, k8s, … +var _ ConfigProvider = &ini.File{} + +func mustMapSetting(rootCfg ConfigProvider, sectionName string, setting interface{}) { + if err := rootCfg.Section(sectionName).MapTo(setting); err != nil { + log.Fatal("Failed to map %s settings: %v", sectionName, err) + } +} + +func deprecatedSetting(rootCfg ConfigProvider, oldSection, oldKey, newSection, newKey string) { + if rootCfg.Section(oldSection).HasKey(oldKey) { + log.Error("Deprecated fallback `[%s]` `%s` present. Use `[%s]` `%s` instead. This fallback will be removed in v1.19.0", oldSection, oldKey, newSection, newKey) + } +} + +// 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) + } +} diff --git a/modules/setting/cors.go b/modules/setting/cors.go index ae0736e83..260848b5d 100644 --- a/modules/setting/cors.go +++ b/modules/setting/cors.go @@ -27,12 +27,8 @@ var CORSConfig = struct { XFrameOptions: "SAMEORIGIN", } -func newCORSService() { - sec := Cfg.Section("cors") - if err := sec.MapTo(&CORSConfig); err != nil { - log.Fatal("Failed to map cors settings: %v", err) - } - +func loadCorsFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "cors", &CORSConfig) if CORSConfig.Enabled { log.Info("CORS Service Enabled") } diff --git a/modules/setting/cron.go b/modules/setting/cron.go index a76de2797..45bae4dde 100644 --- a/modules/setting/cron.go +++ b/modules/setting/cron.go @@ -7,7 +7,11 @@ import "reflect" // GetCronSettings maps the cron subsection to the provided config func GetCronSettings(name string, config interface{}) (interface{}, error) { - if err := Cfg.Section("cron." + name).MapTo(config); err != nil { + return getCronSettings(CfgProvider, name, config) +} + +func getCronSettings(rootCfg ConfigProvider, name string, config interface{}) (interface{}, error) { + if err := rootCfg.Section("cron." + name).MapTo(config); err != nil { return config, err } @@ -18,7 +22,7 @@ func GetCronSettings(name string, config interface{}) (interface{}, error) { field := val.Field(i) tpField := typ.Field(i) if tpField.Type.Kind() == reflect.Struct && tpField.Anonymous { - if err := Cfg.Section("cron." + name).MapTo(field.Addr().Interface()); err != nil { + if err := rootCfg.Section("cron." + name).MapTo(field.Addr().Interface()); err != nil { return config, err } } diff --git a/modules/setting/cron_test.go b/modules/setting/cron_test.go index 29cdca8fb..be97e59bd 100644 --- a/modules/setting/cron_test.go +++ b/modules/setting/cron_test.go @@ -10,7 +10,7 @@ import ( ini "gopkg.in/ini.v1" ) -func Test_GetCronSettings(t *testing.T) { +func Test_getCronSettings(t *testing.T) { type BaseStruct struct { Base bool Second string @@ -27,7 +27,8 @@ Base = true Second = white rabbit Extend = true ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) extended := &Extended{ BaseStruct: BaseStruct{ @@ -35,8 +36,7 @@ Extend = true }, } - _, err := GetCronSettings("test", extended) - + _, err = getCronSettings(cfg, "test", extended) assert.NoError(t, err) assert.True(t, extended.Base) assert.EqualValues(t, extended.Second, "white rabbit") diff --git a/modules/setting/database.go b/modules/setting/database.go index 5480f9dff..49865a38a 100644 --- a/modules/setting/database.go +++ b/modules/setting/database.go @@ -56,9 +56,9 @@ var ( } ) -// InitDBConfig loads the database settings -func InitDBConfig() { - sec := Cfg.Section("database") +// LoadDBSetting loads the database settings +func LoadDBSetting() { + sec := CfgProvider.Section("database") Database.Type = sec.Key("DB_TYPE").String() defaultCharset := "utf8" Database.UseMySQL = false diff --git a/modules/setting/directory.go b/modules/setting/directory.go deleted file mode 100644 index a80df47ab..000000000 --- a/modules/setting/directory.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - "fmt" - "os" -) - -// PrepareAppDataPath creates app data directory if necessary -func PrepareAppDataPath() error { - // FIXME: There are too many calls to MkdirAll in old code. It is incorrect. - // For example, if someDir=/mnt/vol1/gitea-home/data, if the mount point /mnt/vol1 is not mounted when Gitea runs, - // then gitea will make new empty directories in /mnt/vol1, all are stored in the root filesystem. - // The correct behavior should be: creating parent directories is end users' duty. We only create sub-directories in existing parent directories. - // For quickstart, the parent directories should be created automatically for first startup (eg: a flag or a check of INSTALL_LOCK). - // Now we can take the first step to do correctly (using Mkdir) in other packages, and prepare the AppDataPath here, then make a refactor in future. - - st, err := os.Stat(AppDataPath) - - if os.IsNotExist(err) { - err = os.MkdirAll(AppDataPath, os.ModePerm) - if err != nil { - return fmt.Errorf("unable to create the APP_DATA_PATH directory: %q, Error: %w", AppDataPath, err) - } - return nil - } - - if err != nil { - return fmt.Errorf("unable to use APP_DATA_PATH %q. Error: %w", AppDataPath, err) - } - - if !st.IsDir() /* also works for symlink */ { - return fmt.Errorf("the APP_DATA_PATH %q is not a directory (or symlink to a directory) and can't be used", AppDataPath) - } - - return nil -} diff --git a/modules/setting/federation.go b/modules/setting/federation.go index 0ff0614b0..8ea4b986c 100644 --- a/modules/setting/federation.go +++ b/modules/setting/federation.go @@ -33,8 +33,8 @@ var ( // HttpsigAlgs is a constant slice of httpsig algorithm objects var HttpsigAlgs []httpsig.Algorithm -func newFederationService() { - if err := Cfg.Section("federation").MapTo(&Federation); err != nil { +func loadFederationFrom(rootCfg ConfigProvider) { + if err := rootCfg.Section("federation").MapTo(&Federation); err != nil { log.Fatal("Failed to map Federation settings: %v", err) } else if !httpsig.IsSupportedDigestAlgorithm(Federation.DigestAlgorithm) { log.Fatal("unsupported digest algorithm: %s", Federation.DigestAlgorithm) diff --git a/modules/setting/git.go b/modules/setting/git.go index a05f77a97..457b35936 100644 --- a/modules/setting/git.go +++ b/modules/setting/git.go @@ -67,9 +67,8 @@ var Git = struct { }, } -func newGit() { - sec := Cfg.Section("git") - +func loadGitFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("git") if err := sec.MapTo(&Git); err != nil { log.Fatal("Failed to map Git settings: %v", err) } diff --git a/modules/setting/highlight.go b/modules/setting/highlight.go new file mode 100644 index 000000000..6291b08a4 --- /dev/null +++ b/modules/setting/highlight.go @@ -0,0 +1,17 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +func GetHighlightMapping() map[string]string { + highlightMapping := map[string]string{} + if CfgProvider == nil { + return highlightMapping + } + + keys := CfgProvider.Section("highlight.mapping").Keys() + for _, key := range keys { + highlightMapping[key.Name()] = key.Value() + } + return highlightMapping +} diff --git a/modules/setting/i18n.go b/modules/setting/i18n.go index 0e67b18a3..c3076c0ab 100644 --- a/modules/setting/i18n.go +++ b/modules/setting/i18n.go @@ -47,3 +47,20 @@ func defaultI18nNames() (res []string) { } return res } + +var ( + // I18n settings + Langs []string + Names []string +) + +func loadI18nFrom(rootCfg ConfigProvider) { + Langs = rootCfg.Section("i18n").Key("LANGS").Strings(",") + if len(Langs) == 0 { + Langs = defaultI18nLangs() + } + Names = rootCfg.Section("i18n").Key("NAMES").Strings(",") + if len(Names) == 0 { + Names = defaultI18nNames() + } +} diff --git a/modules/setting/incoming_email.go b/modules/setting/incoming_email.go index b6a637bcc..75337a312 100644 --- a/modules/setting/incoming_email.go +++ b/modules/setting/incoming_email.go @@ -31,10 +31,8 @@ var IncomingEmail = struct { MaximumMessageSize: 10485760, } -func newIncomingEmail() { - if err := Cfg.Section("email.incoming").MapTo(&IncomingEmail); err != nil { - log.Fatal("Unable to map [email.incoming] section on to IncomingEmail. Error: %v", err) - } +func loadIncomingEmailFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "email.incoming", &IncomingEmail) if !IncomingEmail.Enabled { return diff --git a/modules/setting/indexer.go b/modules/setting/indexer.go index 1b1c8f7e7..528a9eb65 100644 --- a/modules/setting/indexer.go +++ b/modules/setting/indexer.go @@ -45,8 +45,8 @@ var Indexer = struct { ExcludeVendored: true, } -func newIndexerService() { - sec := Cfg.Section("indexer") +func loadIndexerFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("indexer") Indexer.IssueType = sec.Key("ISSUE_INDEXER_TYPE").MustString("bleve") Indexer.IssuePath = filepath.ToSlash(sec.Key("ISSUE_INDEXER_PATH").MustString(filepath.ToSlash(filepath.Join(AppDataPath, "indexers/issues.bleve")))) if !filepath.IsAbs(Indexer.IssuePath) { @@ -57,11 +57,11 @@ func newIndexerService() { // The following settings are deprecated and can be overridden by settings in [queue] or [queue.issue_indexer] // FIXME: DEPRECATED to be removed in v1.18.0 - deprecatedSetting("indexer", "ISSUE_INDEXER_QUEUE_TYPE", "queue.issue_indexer", "TYPE") - deprecatedSetting("indexer", "ISSUE_INDEXER_QUEUE_DIR", "queue.issue_indexer", "DATADIR") - deprecatedSetting("indexer", "ISSUE_INDEXER_QUEUE_CONN_STR", "queue.issue_indexer", "CONN_STR") - deprecatedSetting("indexer", "ISSUE_INDEXER_QUEUE_BATCH_NUMBER", "queue.issue_indexer", "BATCH_LENGTH") - deprecatedSetting("indexer", "UPDATE_BUFFER_LEN", "queue.issue_indexer", "LENGTH") + deprecatedSetting(rootCfg, "indexer", "ISSUE_INDEXER_QUEUE_TYPE", "queue.issue_indexer", "TYPE") + deprecatedSetting(rootCfg, "indexer", "ISSUE_INDEXER_QUEUE_DIR", "queue.issue_indexer", "DATADIR") + deprecatedSetting(rootCfg, "indexer", "ISSUE_INDEXER_QUEUE_CONN_STR", "queue.issue_indexer", "CONN_STR") + deprecatedSetting(rootCfg, "indexer", "ISSUE_INDEXER_QUEUE_BATCH_NUMBER", "queue.issue_indexer", "BATCH_LENGTH") + deprecatedSetting(rootCfg, "indexer", "UPDATE_BUFFER_LEN", "queue.issue_indexer", "LENGTH") Indexer.RepoIndexerEnabled = sec.Key("REPO_INDEXER_ENABLED").MustBool(false) Indexer.RepoType = sec.Key("REPO_INDEXER_TYPE").MustString("bleve") diff --git a/modules/setting/lfs.go b/modules/setting/lfs.go index 6f8e875c1..e6c9e42f2 100644 --- a/modules/setting/lfs.go +++ b/modules/setting/lfs.go @@ -25,22 +25,22 @@ var LFS = struct { Storage }{} -func newLFSService() { - sec := Cfg.Section("server") +func loadLFSFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("server") if err := sec.MapTo(&LFS); err != nil { log.Fatal("Failed to map LFS settings: %v", err) } - lfsSec := Cfg.Section("lfs") + lfsSec := rootCfg.Section("lfs") storageType := lfsSec.Key("STORAGE_TYPE").MustString("") // Specifically default PATH to LFS_CONTENT_PATH // FIXME: DEPRECATED to be removed in v1.18.0 - deprecatedSetting("server", "LFS_CONTENT_PATH", "lfs", "PATH") + deprecatedSetting(rootCfg, "server", "LFS_CONTENT_PATH", "lfs", "PATH") lfsSec.Key("PATH").MustString( sec.Key("LFS_CONTENT_PATH").String()) - LFS.Storage = getStorage("lfs", storageType, lfsSec) + LFS.Storage = getStorage(rootCfg, "lfs", storageType, lfsSec) // Rest of LFS service settings if LFS.LocksPagingNum == 0 { diff --git a/modules/setting/log.go b/modules/setting/log.go index 8a2d47eda..5448650aa 100644 --- a/modules/setting/log.go +++ b/modules/setting/log.go @@ -25,6 +25,21 @@ var ( logDescriptions = make(map[string]*LogDescription) ) +// Log settings +var Log struct { + Level log.Level + StacktraceLogLevel string + RootPath string + EnableSSHLog bool + EnableXORMLog bool + + DisableRouterLog bool + + EnableAccessLog bool + AccessLogTemplate string + BufferLength int64 +} + // GetLogDescriptions returns a race safe set of descriptions func GetLogDescriptions() map[string]*LogDescription { descriptionLock.RLock() @@ -94,9 +109,9 @@ type defaultLogOptions struct { func newDefaultLogOptions() defaultLogOptions { return defaultLogOptions{ - levelName: LogLevel.String(), + levelName: Log.Level.String(), flags: "stdflags", - filename: filepath.Join(LogRootPath, "gitea.log"), + filename: filepath.Join(Log.RootPath, "gitea.log"), bufferLength: 10000, disableConsole: false, } @@ -125,10 +140,33 @@ func getStacktraceLogLevel(section *ini.Section, key, defaultValue string) strin return log.FromString(value).String() } +func loadLogFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("log") + Log.Level = getLogLevel(sec, "LEVEL", log.INFO) + Log.StacktraceLogLevel = getStacktraceLogLevel(sec, "STACKTRACE_LEVEL", "None") + Log.RootPath = sec.Key("ROOT_PATH").MustString(path.Join(AppWorkPath, "log")) + forcePathSeparator(Log.RootPath) + Log.BufferLength = sec.Key("BUFFER_LEN").MustInt64(10000) + + Log.EnableSSHLog = sec.Key("ENABLE_SSH_LOG").MustBool(false) + Log.EnableAccessLog = sec.Key("ENABLE_ACCESS_LOG").MustBool(false) + Log.AccessLogTemplate = sec.Key("ACCESS_LOG_TEMPLATE").MustString( + `{{.Ctx.RemoteAddr}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}\" \"{{.Ctx.Req.UserAgent}}"`, + ) + // the `MustString` updates the default value, and `log.ACCESS` is used by `generateNamedLogger("access")` later + _ = rootCfg.Section("log").Key("ACCESS").MustString("file") + + sec.Key("ROUTER").MustString("console") + // Allow [log] DISABLE_ROUTER_LOG to override [server] DISABLE_ROUTER_LOG + Log.DisableRouterLog = sec.Key("DISABLE_ROUTER_LOG").MustBool(Log.DisableRouterLog) + + Log.EnableXORMLog = rootCfg.Section("log").Key("ENABLE_XORM_LOG").MustBool(true) +} + func generateLogConfig(sec *ini.Section, name string, defaults defaultLogOptions) (mode, jsonConfig, levelName string) { - level := getLogLevel(sec, "LEVEL", LogLevel) + level := getLogLevel(sec, "LEVEL", Log.Level) levelName = level.String() - stacktraceLevelName := getStacktraceLogLevel(sec, "STACKTRACE_LEVEL", StacktraceLogLevel) + stacktraceLevelName := getStacktraceLogLevel(sec, "STACKTRACE_LEVEL", Log.StacktraceLogLevel) stacktraceLevel := log.FromString(stacktraceLevelName) mode = name keys := sec.Keys() @@ -144,7 +182,7 @@ func generateLogConfig(sec *ini.Section, name string, defaults defaultLogOptions logPath = key.MustString(defaults.filename) forcePathSeparator(logPath) if !filepath.IsAbs(logPath) { - logPath = path.Join(LogRootPath, logPath) + logPath = path.Join(Log.RootPath, logPath) } case "FLAGS": flags = log.FlagsFromString(key.MustString(defaults.flags)) @@ -213,12 +251,12 @@ func generateLogConfig(sec *ini.Section, name string, defaults defaultLogOptions return mode, jsonConfig, levelName } -func generateNamedLogger(key string, options defaultLogOptions) *LogDescription { +func generateNamedLogger(rootCfg ConfigProvider, key string, options defaultLogOptions) *LogDescription { description := LogDescription{ Name: key, } - sections := strings.Split(Cfg.Section("log").Key(strings.ToUpper(key)).MustString(""), ",") + sections := strings.Split(rootCfg.Section("log").Key(strings.ToUpper(key)).MustString(""), ",") for i := 0; i < len(sections); i++ { sections[i] = strings.TrimSpace(sections[i]) @@ -228,9 +266,9 @@ func generateNamedLogger(key string, options defaultLogOptions) *LogDescription if len(name) == 0 || (name == "console" && options.disableConsole) { continue } - sec, err := Cfg.GetSection("log." + name + "." + key) + sec, err := rootCfg.GetSection("log." + name + "." + key) if err != nil { - sec, _ = Cfg.NewSection("log." + name + "." + key) + sec, _ = rootCfg.NewSection("log." + name + "." + key) } provider, config, levelName := generateLogConfig(sec, name, options) @@ -253,46 +291,17 @@ func generateNamedLogger(key string, options defaultLogOptions) *LogDescription return &description } -func newAccessLogService() { - EnableAccessLog = Cfg.Section("log").Key("ENABLE_ACCESS_LOG").MustBool(false) - AccessLogTemplate = Cfg.Section("log").Key("ACCESS_LOG_TEMPLATE").MustString( - `{{.Ctx.RemoteAddr}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}\" \"{{.Ctx.Req.UserAgent}}"`, - ) - // the `MustString` updates the default value, and `log.ACCESS` is used by `generateNamedLogger("access")` later - _ = Cfg.Section("log").Key("ACCESS").MustString("file") - if EnableAccessLog { - options := newDefaultLogOptions() - options.filename = filepath.Join(LogRootPath, "access.log") - options.flags = "" // For the router we don't want any prefixed flags - options.bufferLength = Cfg.Section("log").Key("BUFFER_LEN").MustInt64(10000) - generateNamedLogger("access", options) - } -} - -func newRouterLogService() { - Cfg.Section("log").Key("ROUTER").MustString("console") - // Allow [log] DISABLE_ROUTER_LOG to override [server] DISABLE_ROUTER_LOG - DisableRouterLog = Cfg.Section("log").Key("DISABLE_ROUTER_LOG").MustBool(DisableRouterLog) - - if !DisableRouterLog { - options := newDefaultLogOptions() - options.filename = filepath.Join(LogRootPath, "router.log") - options.flags = "date,time" // For the router we don't want any prefixed flags - options.bufferLength = Cfg.Section("log").Key("BUFFER_LEN").MustInt64(10000) - generateNamedLogger("router", options) - } -} - -func newLogService() { +// initLogFrom initializes logging with settings from configuration provider +func initLogFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("log") options := newDefaultLogOptions() - options.bufferLength = Cfg.Section("log").Key("BUFFER_LEN").MustInt64(10000) - EnableSSHLog = Cfg.Section("log").Key("ENABLE_SSH_LOG").MustBool(false) + options.bufferLength = Log.BufferLength description := LogDescription{ Name: log.DEFAULT, } - sections := strings.Split(Cfg.Section("log").Key("MODE").MustString("console"), ",") + sections := strings.Split(sec.Key("MODE").MustString("console"), ",") useConsole := false for _, name := range sections { @@ -304,11 +313,11 @@ func newLogService() { useConsole = true } - sec, err := Cfg.GetSection("log." + name + ".default") + sec, err := rootCfg.GetSection("log." + name + ".default") if err != nil { - sec, err = Cfg.GetSection("log." + name) + sec, err = rootCfg.GetSection("log." + name) if err != nil { - sec, _ = Cfg.NewSection("log." + name) + sec, _ = rootCfg.NewSection("log." + name) } } @@ -340,27 +349,45 @@ func newLogService() { // RestartLogsWithPIDSuffix restarts the logs with a PID suffix on files func RestartLogsWithPIDSuffix() { filenameSuffix = fmt.Sprintf(".%d", os.Getpid()) - NewLogServices(false) + InitLogs(false) +} + +// InitLogs creates all the log services +func InitLogs(disableConsole bool) { + initLogFrom(CfgProvider) + + if !Log.DisableRouterLog { + options := newDefaultLogOptions() + options.filename = filepath.Join(Log.RootPath, "router.log") + options.flags = "date,time" // For the router we don't want any prefixed flags + options.bufferLength = Log.BufferLength + generateNamedLogger(CfgProvider, "router", options) + } + + if Log.EnableAccessLog { + options := newDefaultLogOptions() + options.filename = filepath.Join(Log.RootPath, "access.log") + options.flags = "" // For the router we don't want any prefixed flags + options.bufferLength = Log.BufferLength + generateNamedLogger(CfgProvider, "access", options) + } + + initSQLLogFrom(CfgProvider, disableConsole) } -// NewLogServices creates all the log services -func NewLogServices(disableConsole bool) { - newLogService() - newRouterLogService() - newAccessLogService() - NewXORMLogService(disableConsole) +// InitSQLLog initializes xorm logger setting +func InitSQLLog(disableConsole bool) { + initSQLLogFrom(CfgProvider, disableConsole) } -// NewXORMLogService initializes xorm logger service -func NewXORMLogService(disableConsole bool) { - EnableXORMLog = Cfg.Section("log").Key("ENABLE_XORM_LOG").MustBool(true) - if EnableXORMLog { +func initSQLLogFrom(rootCfg ConfigProvider, disableConsole bool) { + if Log.EnableXORMLog { options := newDefaultLogOptions() - options.filename = filepath.Join(LogRootPath, "xorm.log") - options.bufferLength = Cfg.Section("log").Key("BUFFER_LEN").MustInt64(10000) + options.filename = filepath.Join(Log.RootPath, "xorm.log") + options.bufferLength = Log.BufferLength options.disableConsole = disableConsole - Cfg.Section("log").Key("XORM").MustString(",") - generateNamedLogger("xorm", options) + rootCfg.Section("log").Key("XORM").MustString(",") + generateNamedLogger(rootCfg, "xorm", options) } } diff --git a/modules/setting/mailer.go b/modules/setting/mailer.go index a5d311454..62a73cb2f 100644 --- a/modules/setting/mailer.go +++ b/modules/setting/mailer.go @@ -12,7 +12,6 @@ import ( "code.gitea.io/gitea/modules/log" shellquote "github.com/kballard/go-shellquote" - ini "gopkg.in/ini.v1" ) // Mailer represents mail service. @@ -50,7 +49,14 @@ type Mailer struct { // MailService the global mailer var MailService *Mailer -func parseMailerConfig(rootCfg *ini.File) { +func loadMailsFrom(rootCfg ConfigProvider) { + loadMailerFrom(rootCfg) + loadRegisterMailFrom(rootCfg) + loadNotifyMailFrom(rootCfg) + loadIncomingEmailFrom(rootCfg) +} + +func loadMailerFrom(rootCfg ConfigProvider) { sec := rootCfg.Section("mailer") // Check mailer setting. if !sec.Key("ENABLED").MustBool() { @@ -59,7 +65,7 @@ func parseMailerConfig(rootCfg *ini.File) { // Handle Deprecations and map on to new configuration // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "MAILER_TYPE", "mailer", "PROTOCOL") + deprecatedSetting(rootCfg, "mailer", "MAILER_TYPE", "mailer", "PROTOCOL") if sec.HasKey("MAILER_TYPE") && !sec.HasKey("PROTOCOL") { if sec.Key("MAILER_TYPE").String() == "sendmail" { sec.Key("PROTOCOL").MustString("sendmail") @@ -67,7 +73,7 @@ func parseMailerConfig(rootCfg *ini.File) { } // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "HOST", "mailer", "SMTP_ADDR") + deprecatedSetting(rootCfg, "mailer", "HOST", "mailer", "SMTP_ADDR") if sec.HasKey("HOST") && !sec.HasKey("SMTP_ADDR") { givenHost := sec.Key("HOST").String() addr, port, err := net.SplitHostPort(givenHost) @@ -84,7 +90,7 @@ func parseMailerConfig(rootCfg *ini.File) { } // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "IS_TLS_ENABLED", "mailer", "PROTOCOL") + deprecatedSetting(rootCfg, "mailer", "IS_TLS_ENABLED", "mailer", "PROTOCOL") if sec.HasKey("IS_TLS_ENABLED") && !sec.HasKey("PROTOCOL") { if sec.Key("IS_TLS_ENABLED").MustBool() { sec.Key("PROTOCOL").MustString("smtps") @@ -94,37 +100,37 @@ func parseMailerConfig(rootCfg *ini.File) { } // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "DISABLE_HELO", "mailer", "ENABLE_HELO") + deprecatedSetting(rootCfg, "mailer", "DISABLE_HELO", "mailer", "ENABLE_HELO") if sec.HasKey("DISABLE_HELO") && !sec.HasKey("ENABLE_HELO") { sec.Key("ENABLE_HELO").MustBool(!sec.Key("DISABLE_HELO").MustBool()) } // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "SKIP_VERIFY", "mailer", "FORCE_TRUST_SERVER_CERT") + deprecatedSetting(rootCfg, "mailer", "SKIP_VERIFY", "mailer", "FORCE_TRUST_SERVER_CERT") if sec.HasKey("SKIP_VERIFY") && !sec.HasKey("FORCE_TRUST_SERVER_CERT") { sec.Key("FORCE_TRUST_SERVER_CERT").MustBool(sec.Key("SKIP_VERIFY").MustBool()) } // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "USE_CERTIFICATE", "mailer", "USE_CLIENT_CERT") + deprecatedSetting(rootCfg, "mailer", "USE_CERTIFICATE", "mailer", "USE_CLIENT_CERT") if sec.HasKey("USE_CERTIFICATE") && !sec.HasKey("USE_CLIENT_CERT") { sec.Key("USE_CLIENT_CERT").MustBool(sec.Key("USE_CERTIFICATE").MustBool()) } // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "CERT_FILE", "mailer", "CLIENT_CERT_FILE") + deprecatedSetting(rootCfg, "mailer", "CERT_FILE", "mailer", "CLIENT_CERT_FILE") if sec.HasKey("CERT_FILE") && !sec.HasKey("CLIENT_CERT_FILE") { sec.Key("CERT_FILE").MustString(sec.Key("CERT_FILE").String()) } // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "KEY_FILE", "mailer", "CLIENT_KEY_FILE") + deprecatedSetting(rootCfg, "mailer", "KEY_FILE", "mailer", "CLIENT_KEY_FILE") if sec.HasKey("KEY_FILE") && !sec.HasKey("CLIENT_KEY_FILE") { sec.Key("KEY_FILE").MustString(sec.Key("KEY_FILE").String()) } // FIXME: DEPRECATED to be removed in v1.19.0 - deprecatedSetting("mailer", "ENABLE_HTML_ALTERNATIVE", "mailer", "SEND_AS_PLAIN_TEXT") + deprecatedSetting(rootCfg, "mailer", "ENABLE_HTML_ALTERNATIVE", "mailer", "SEND_AS_PLAIN_TEXT") if sec.HasKey("ENABLE_HTML_ALTERNATIVE") && !sec.HasKey("SEND_AS_PLAIN_TEXT") { sec.Key("SEND_AS_PLAIN_TEXT").MustBool(!sec.Key("ENABLE_HTML_ALTERNATIVE").MustBool(false)) } @@ -237,8 +243,8 @@ func parseMailerConfig(rootCfg *ini.File) { log.Info("Mail Service Enabled") } -func newRegisterMailService() { - if !Cfg.Section("service").Key("REGISTER_EMAIL_CONFIRM").MustBool() { +func loadRegisterMailFrom(rootCfg ConfigProvider) { + if !rootCfg.Section("service").Key("REGISTER_EMAIL_CONFIRM").MustBool() { return } else if MailService == nil { log.Warn("Register Mail Service: Mail Service is not enabled") @@ -248,8 +254,8 @@ func newRegisterMailService() { log.Info("Register Mail Service Enabled") } -func newNotifyMailService() { - if !Cfg.Section("service").Key("ENABLE_NOTIFY_MAIL").MustBool() { +func loadNotifyMailFrom(rootCfg ConfigProvider) { + if !rootCfg.Section("service").Key("ENABLE_NOTIFY_MAIL").MustBool() { return } else if MailService == nil { log.Warn("Notify Mail Service: Mail Service is not enabled") diff --git a/modules/setting/mailer_test.go b/modules/setting/mailer_test.go index 0fc9b0e73..4cfd6142b 100644 --- a/modules/setting/mailer_test.go +++ b/modules/setting/mailer_test.go @@ -10,7 +10,7 @@ import ( ini "gopkg.in/ini.v1" ) -func TestParseMailerConfig(t *testing.T) { +func Test_loadMailerFrom(t *testing.T) { iniFile := ini.Empty() kases := map[string]*Mailer{ "smtp.mydomain.com": { @@ -34,7 +34,7 @@ func TestParseMailerConfig(t *testing.T) { sec.NewKey("HOST", host) // Check mailer setting - parseMailerConfig(iniFile) + loadMailerFrom(iniFile) assert.EqualValues(t, kase.SMTPAddr, MailService.SMTPAddr) assert.EqualValues(t, kase.SMTPPort, MailService.SMTPPort) diff --git a/modules/setting/markup.go b/modules/setting/markup.go index c262234b6..b71a902be 100644 --- a/modules/setting/markup.go +++ b/modules/setting/markup.go @@ -25,6 +25,20 @@ const ( RenderContentModeIframe = "iframe" ) +// Markdown settings +var Markdown = struct { + EnableHardLineBreakInComments bool + EnableHardLineBreakInDocuments bool + CustomURLSchemes []string `ini:"CUSTOM_URL_SCHEMES"` + FileExtensions []string + EnableMath bool +}{ + EnableHardLineBreakInComments: true, + EnableHardLineBreakInDocuments: false, + FileExtensions: strings.Split(".md,.markdown,.mdown,.mkd", ","), + EnableMath: true, +} + // MarkupRenderer defines the external parser configured in ini type MarkupRenderer struct { Enabled bool @@ -46,12 +60,14 @@ type MarkupSanitizerRule struct { AllowDataURIImages bool } -func newMarkup() { - MermaidMaxSourceCharacters = Cfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(5000) +func loadMarkupFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "markdown", &Markdown) + + MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(5000) ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10) ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10) - for _, sec := range Cfg.Section("markup").ChildSections() { + for _, sec := range rootCfg.Section("markup").ChildSections() { name := strings.TrimPrefix(sec.Name(), "markup.") if name == "" { log.Warn("name is empty, markup " + sec.Name() + "ignored") diff --git a/modules/setting/metrics.go b/modules/setting/metrics.go new file mode 100644 index 000000000..daa0e3b70 --- /dev/null +++ b/modules/setting/metrics.go @@ -0,0 +1,21 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +// Metrics settings +var Metrics = struct { + Enabled bool + Token string + EnabledIssueByLabel bool + EnabledIssueByRepository bool +}{ + Enabled: false, + Token: "", + EnabledIssueByLabel: false, + EnabledIssueByRepository: false, +} + +func loadMetricsFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "metrics", &Metrics) +} diff --git a/modules/setting/migrations.go b/modules/setting/migrations.go index 2f6d08b6b..5a6079b6e 100644 --- a/modules/setting/migrations.go +++ b/modules/setting/migrations.go @@ -16,8 +16,8 @@ var Migrations = struct { RetryBackoff: 3, } -func newMigrationsService() { - sec := Cfg.Section("migrations") +func loadMigrationsFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("migrations") Migrations.MaxAttempts = sec.Key("MAX_ATTEMPTS").MustInt(Migrations.MaxAttempts) Migrations.RetryBackoff = sec.Key("RETRY_BACKOFF").MustInt(Migrations.RetryBackoff) diff --git a/modules/setting/mime_type_map.go b/modules/setting/mime_type_map.go index 6a0847bd7..55cb2c028 100644 --- a/modules/setting/mime_type_map.go +++ b/modules/setting/mime_type_map.go @@ -14,8 +14,8 @@ var MimeTypeMap = struct { Map: map[string]string{}, } -func newMimeTypeMap() { - sec := Cfg.Section("repository.mimetype_mapping") +func loadMimeTypeMapFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("repository.mimetype_mapping") keys := sec.Keys() m := make(map[string]string, len(keys)) for _, key := range keys { diff --git a/modules/setting/mirror.go b/modules/setting/mirror.go index 9ddce97da..875062f52 100644 --- a/modules/setting/mirror.go +++ b/modules/setting/mirror.go @@ -24,16 +24,16 @@ var Mirror = struct { DefaultInterval: 8 * time.Hour, } -func newMirror() { +func loadMirrorFrom(rootCfg ConfigProvider) { // Handle old configuration through `[repository]` `DISABLE_MIRRORS` // - please note this was badly named and only disabled the creation of new pull mirrors // FIXME: DEPRECATED to be removed in v1.18.0 - deprecatedSetting("repository", "DISABLE_MIRRORS", "mirror", "ENABLED") - if Cfg.Section("repository").Key("DISABLE_MIRRORS").MustBool(false) { + deprecatedSetting(rootCfg, "repository", "DISABLE_MIRRORS", "mirror", "ENABLED") + if rootCfg.Section("repository").Key("DISABLE_MIRRORS").MustBool(false) { Mirror.DisableNewPull = true } - if err := Cfg.Section("mirror").MapTo(&Mirror); err != nil { + if err := rootCfg.Section("mirror").MapTo(&Mirror); err != nil { log.Fatal("Failed to map Mirror settings: %v", err) } diff --git a/modules/setting/oauth2_client.go b/modules/setting/oauth2.go index 6492af82d..44f5568ef 100644 --- a/modules/setting/oauth2_client.go +++ b/modules/setting/oauth2.go @@ -4,6 +4,9 @@ package setting import ( + "math" + "path/filepath" + "code.gitea.io/gitea/modules/log" "gopkg.in/ini.v1" @@ -59,8 +62,8 @@ var OAuth2Client struct { AccountLinking OAuth2AccountLinkingType } -func newOAuth2Client() { - sec := Cfg.Section("oauth2_client") +func loadOAuth2ClientFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("oauth2_client") OAuth2Client.RegisterEmailConfirm = sec.Key("REGISTER_EMAIL_CONFIRM").MustBool(Service.RegisterEmailConfirm) OAuth2Client.OpenIDConnectScopes = parseScopes(sec, "OPENID_CONNECT_SCOPES") OAuth2Client.EnableAutoRegistration = sec.Key("ENABLE_AUTO_REGISTRATION").MustBool() @@ -87,3 +90,33 @@ func parseScopes(sec *ini.Section, name string) []string { } return scopes } + +var OAuth2 = struct { + Enable bool + AccessTokenExpirationTime int64 + RefreshTokenExpirationTime int64 + InvalidateRefreshTokens bool + JWTSigningAlgorithm string `ini:"JWT_SIGNING_ALGORITHM"` + JWTSecretBase64 string `ini:"JWT_SECRET"` + JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"` + MaxTokenLength int +}{ + Enable: true, + AccessTokenExpirationTime: 3600, + RefreshTokenExpirationTime: 730, + InvalidateRefreshTokens: false, + JWTSigningAlgorithm: "RS256", + JWTSigningPrivateKeyFile: "jwt/private.pem", + MaxTokenLength: math.MaxInt16, +} + +func loadOAuth2From(rootCfg ConfigProvider) { + if err := rootCfg.Section("oauth2").MapTo(&OAuth2); err != nil { + log.Fatal("Failed to OAuth2 settings: %v", err) + return + } + + if !filepath.IsAbs(OAuth2.JWTSigningPrivateKeyFile) { + OAuth2.JWTSigningPrivateKeyFile = filepath.Join(AppDataPath, OAuth2.JWTSigningPrivateKeyFile) + } +} diff --git a/modules/setting/other.go b/modules/setting/other.go new file mode 100644 index 000000000..4fba754a0 --- /dev/null +++ b/modules/setting/other.go @@ -0,0 +1,22 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +var ( + // Other settings + ShowFooterBranding bool + ShowFooterVersion bool + ShowFooterTemplateLoadTime bool + EnableFeed bool + EnableSitemap bool +) + +func loadOtherFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("other") + ShowFooterBranding = sec.Key("SHOW_FOOTER_BRANDING").MustBool(false) + ShowFooterVersion = sec.Key("SHOW_FOOTER_VERSION").MustBool(true) + ShowFooterTemplateLoadTime = sec.Key("SHOW_FOOTER_TEMPLATE_LOAD_TIME").MustBool(true) + EnableSitemap = sec.Key("ENABLE_SITEMAP").MustBool(true) + EnableFeed = sec.Key("ENABLE_FEED").MustBool(true) +} diff --git a/modules/setting/packages.go b/modules/setting/packages.go index 84da4eb53..13599e5a6 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -46,13 +46,13 @@ var ( } ) -func newPackages() { - sec := Cfg.Section("packages") +func loadPackagesFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("packages") if err := sec.MapTo(&Packages); err != nil { log.Fatal("Failed to map Packages settings: %v", err) } - Packages.Storage = getStorage("packages", "", nil) + Packages.Storage = getStorage(rootCfg, "packages", "", nil) appURL, _ := url.Parse(AppURL) Packages.RegistryHost = appURL.Host diff --git a/modules/setting/picture.go b/modules/setting/picture.go index a814af822..6d7c8b33c 100644 --- a/modules/setting/picture.go +++ b/modules/setting/picture.go @@ -32,16 +32,16 @@ var ( }{} ) -func newPictureService() { - sec := Cfg.Section("picture") +func loadPictureFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("picture") - avatarSec := Cfg.Section("avatar") + avatarSec := rootCfg.Section("avatar") storageType := sec.Key("AVATAR_STORAGE_TYPE").MustString("") // Specifically default PATH to AVATAR_UPLOAD_PATH avatarSec.Key("PATH").MustString( sec.Key("AVATAR_UPLOAD_PATH").String()) - Avatar.Storage = getStorage("avatars", storageType, avatarSec) + Avatar.Storage = getStorage(rootCfg, "avatars", storageType, avatarSec) Avatar.MaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096) Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072) @@ -60,11 +60,11 @@ func newPictureService() { } DisableGravatar = sec.Key("DISABLE_GRAVATAR").MustBool(GetDefaultDisableGravatar()) - deprecatedSettingDB("", "DISABLE_GRAVATAR") + deprecatedSettingDB(rootCfg, "", "DISABLE_GRAVATAR") EnableFederatedAvatar = sec.Key("ENABLE_FEDERATED_AVATAR").MustBool(GetDefaultEnableFederatedAvatar(DisableGravatar)) - deprecatedSettingDB("", "ENABLE_FEDERATED_AVATAR") + deprecatedSettingDB(rootCfg, "", "ENABLE_FEDERATED_AVATAR") - newRepoAvatarService() + loadRepoAvatarFrom(rootCfg) } func GetDefaultDisableGravatar() bool { @@ -82,16 +82,16 @@ func GetDefaultEnableFederatedAvatar(disableGravatar bool) bool { return v } -func newRepoAvatarService() { - sec := Cfg.Section("picture") +func loadRepoAvatarFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("picture") - repoAvatarSec := Cfg.Section("repo-avatar") + repoAvatarSec := rootCfg.Section("repo-avatar") storageType := sec.Key("REPOSITORY_AVATAR_STORAGE_TYPE").MustString("") // Specifically default PATH to AVATAR_UPLOAD_PATH repoAvatarSec.Key("PATH").MustString( sec.Key("REPOSITORY_AVATAR_UPLOAD_PATH").String()) - RepoAvatar.Storage = getStorage("repo-avatars", storageType, repoAvatarSec) + RepoAvatar.Storage = getStorage(rootCfg, "repo-avatars", storageType, repoAvatarSec) RepoAvatar.Fallback = sec.Key("REPOSITORY_AVATAR_FALLBACK").MustString("none") RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString("/assets/img/repo_default.png") diff --git a/modules/setting/project.go b/modules/setting/project.go index 53e09e8da..803e933b8 100644 --- a/modules/setting/project.go +++ b/modules/setting/project.go @@ -3,8 +3,6 @@ package setting -import "code.gitea.io/gitea/modules/log" - // Project settings var ( Project = struct { @@ -16,8 +14,6 @@ var ( } ) -func newProject() { - if err := Cfg.Section("project").MapTo(&Project); err != nil { - log.Fatal("Failed to map Project settings: %v", err) - } +func loadProjectFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "project", &Project) } diff --git a/modules/setting/proxy.go b/modules/setting/proxy.go index fed33395e..4ff420d09 100644 --- a/modules/setting/proxy.go +++ b/modules/setting/proxy.go @@ -21,8 +21,8 @@ var Proxy = struct { ProxyHosts: []string{}, } -func newProxyService() { - sec := Cfg.Section("proxy") +func loadProxyFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("proxy") Proxy.Enabled = sec.Key("PROXY_ENABLED").MustBool(false) Proxy.ProxyURL = sec.Key("PROXY_URL").MustString("") if Proxy.ProxyURL != "" { diff --git a/modules/setting/queue.go b/modules/setting/queue.go index a67d0d849..bd4bf48e3 100644 --- a/modules/setting/queue.go +++ b/modules/setting/queue.go @@ -39,8 +39,12 @@ var Queue = QueueSettings{} // GetQueueSettings returns the queue settings for the appropriately named queue func GetQueueSettings(name string) QueueSettings { + return getQueueSettings(CfgProvider, name) +} + +func getQueueSettings(rootCfg ConfigProvider, name string) QueueSettings { q := QueueSettings{} - sec := Cfg.Section("queue." + name) + sec := rootCfg.Section("queue." + name) q.Name = name // DataDir is not directly inheritable @@ -82,10 +86,14 @@ func GetQueueSettings(name string) QueueSettings { return q } -// NewQueueService sets up the default settings for Queues +// LoadQueueSettings sets up the default settings for Queues // This is exported for tests to be able to use the queue -func NewQueueService() { - sec := Cfg.Section("queue") +func LoadQueueSettings() { + loadQueueFrom(CfgProvider) +} + +func loadQueueFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("queue") Queue.DataDir = filepath.ToSlash(sec.Key("DATADIR").MustString("queues/")) if !filepath.IsAbs(Queue.DataDir) { Queue.DataDir = filepath.ToSlash(filepath.Join(AppDataPath, Queue.DataDir)) @@ -108,10 +116,10 @@ func NewQueueService() { // Now handle the old issue_indexer configuration // FIXME: DEPRECATED to be removed in v1.18.0 - section := Cfg.Section("queue.issue_indexer") + section := rootCfg.Section("queue.issue_indexer") directlySet := toDirectlySetKeysSet(section) if !directlySet.Contains("TYPE") && defaultType == "" { - switch typ := Cfg.Section("indexer").Key("ISSUE_INDEXER_QUEUE_TYPE").MustString(""); typ { + switch typ := rootCfg.Section("indexer").Key("ISSUE_INDEXER_QUEUE_TYPE").MustString(""); typ { case "levelqueue": _, _ = section.NewKey("TYPE", "level") case "channel": @@ -125,25 +133,25 @@ func NewQueueService() { } } if !directlySet.Contains("LENGTH") { - length := Cfg.Section("indexer").Key("UPDATE_BUFFER_LEN").MustInt(0) + length := rootCfg.Section("indexer").Key("UPDATE_BUFFER_LEN").MustInt(0) if length != 0 { _, _ = section.NewKey("LENGTH", strconv.Itoa(length)) } } if !directlySet.Contains("BATCH_LENGTH") { - fallback := Cfg.Section("indexer").Key("ISSUE_INDEXER_QUEUE_BATCH_NUMBER").MustInt(0) + fallback := rootCfg.Section("indexer").Key("ISSUE_INDEXER_QUEUE_BATCH_NUMBER").MustInt(0) if fallback != 0 { _, _ = section.NewKey("BATCH_LENGTH", strconv.Itoa(fallback)) } } if !directlySet.Contains("DATADIR") { - queueDir := filepath.ToSlash(Cfg.Section("indexer").Key("ISSUE_INDEXER_QUEUE_DIR").MustString("")) + queueDir := filepath.ToSlash(rootCfg.Section("indexer").Key("ISSUE_INDEXER_QUEUE_DIR").MustString("")) if queueDir != "" { _, _ = section.NewKey("DATADIR", queueDir) } } if !directlySet.Contains("CONN_STR") { - connStr := Cfg.Section("indexer").Key("ISSUE_INDEXER_QUEUE_CONN_STR").MustString("") + connStr := rootCfg.Section("indexer").Key("ISSUE_INDEXER_QUEUE_CONN_STR").MustString("") if connStr != "" { _, _ = section.NewKey("CONN_STR", connStr) } @@ -153,31 +161,31 @@ func NewQueueService() { // - will need to set default for [queue.*)] LENGTH appropriately though though // Handle the old mailer configuration - handleOldLengthConfiguration("mailer", "mailer", "SEND_BUFFER_LEN", 100) + handleOldLengthConfiguration(rootCfg, "mailer", "mailer", "SEND_BUFFER_LEN", 100) // Handle the old test pull requests configuration // Please note this will be a unique queue - handleOldLengthConfiguration("pr_patch_checker", "repository", "PULL_REQUEST_QUEUE_LENGTH", 1000) + handleOldLengthConfiguration(rootCfg, "pr_patch_checker", "repository", "PULL_REQUEST_QUEUE_LENGTH", 1000) // Handle the old mirror queue configuration // Please note this will be a unique queue - handleOldLengthConfiguration("mirror", "repository", "MIRROR_QUEUE_LENGTH", 1000) + handleOldLengthConfiguration(rootCfg, "mirror", "repository", "MIRROR_QUEUE_LENGTH", 1000) } // handleOldLengthConfiguration allows fallback to older configuration. `[queue.name]` `LENGTH` will override this configuration, but // if that is left unset then we should fallback to the older configuration. (Except where the new length woul be <=0) -func handleOldLengthConfiguration(queueName, oldSection, oldKey string, defaultValue int) { - if Cfg.Section(oldSection).HasKey(oldKey) { +func handleOldLengthConfiguration(rootCfg ConfigProvider, queueName, oldSection, oldKey string, defaultValue int) { + if rootCfg.Section(oldSection).HasKey(oldKey) { log.Error("Deprecated fallback for %s queue length `[%s]` `%s` present. Use `[queue.%s]` `LENGTH`. This will be removed in v1.18.0", queueName, queueName, oldSection, oldKey) } - value := Cfg.Section(oldSection).Key(oldKey).MustInt(defaultValue) + value := rootCfg.Section(oldSection).Key(oldKey).MustInt(defaultValue) // Don't override with 0 if value <= 0 { return } - section := Cfg.Section("queue." + queueName) + section := rootCfg.Section("queue." + queueName) directlySet := toDirectlySetKeysSet(section) if !directlySet.Contains("LENGTH") { _, _ = section.NewKey("LENGTH", strconv.Itoa(value)) diff --git a/modules/setting/repository.go b/modules/setting/repository.go index f53de17a4..4964704db 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -270,10 +270,10 @@ var ( }{} ) -func newRepository() { +func loadRepositoryFrom(rootCfg ConfigProvider) { var err error // Determine and create root git repository path. - sec := Cfg.Section("repository") + sec := rootCfg.Section("repository") Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool() Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool() Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1) @@ -295,19 +295,19 @@ func newRepository() { log.Warn("SCRIPT_TYPE %q is not on the current PATH. Are you sure that this is the correct SCRIPT_TYPE?", ScriptType) } - if err = Cfg.Section("repository").MapTo(&Repository); err != nil { + if err = sec.MapTo(&Repository); err != nil { log.Fatal("Failed to map Repository settings: %v", err) - } else if err = Cfg.Section("repository.editor").MapTo(&Repository.Editor); err != nil { + } else if err = rootCfg.Section("repository.editor").MapTo(&Repository.Editor); err != nil { log.Fatal("Failed to map Repository.Editor settings: %v", err) - } else if err = Cfg.Section("repository.upload").MapTo(&Repository.Upload); err != nil { + } else if err = rootCfg.Section("repository.upload").MapTo(&Repository.Upload); err != nil { log.Fatal("Failed to map Repository.Upload settings: %v", err) - } else if err = Cfg.Section("repository.local").MapTo(&Repository.Local); err != nil { + } else if err = rootCfg.Section("repository.local").MapTo(&Repository.Local); err != nil { log.Fatal("Failed to map Repository.Local settings: %v", err) - } else if err = Cfg.Section("repository.pull-request").MapTo(&Repository.PullRequest); err != nil { + } else if err = rootCfg.Section("repository.pull-request").MapTo(&Repository.PullRequest); err != nil { log.Fatal("Failed to map Repository.PullRequest settings: %v", err) } - if !Cfg.Section("packages").Key("ENABLED").MustBool(true) { + if !rootCfg.Section("packages").Key("ENABLED").MustBool(true) { Repository.DisabledRepoUnits = append(Repository.DisabledRepoUnits, "repo.packages") } @@ -354,5 +354,5 @@ func newRepository() { Repository.Upload.TempPath = path.Join(AppWorkPath, Repository.Upload.TempPath) } - RepoArchive.Storage = getStorage("repo-archive", "", nil) + RepoArchive.Storage = getStorage(rootCfg, "repo-archive", "", nil) } diff --git a/modules/setting/security.go b/modules/setting/security.go new file mode 100644 index 000000000..b9841cdb9 --- /dev/null +++ b/modules/setting/security.go @@ -0,0 +1,158 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/url" + "os" + "strings" + + "code.gitea.io/gitea/modules/auth/password/hash" + "code.gitea.io/gitea/modules/generate" + "code.gitea.io/gitea/modules/log" + + ini "gopkg.in/ini.v1" +) + +var ( + // Security settings + InstallLock bool + SecretKey string + InternalToken string // internal access token + LogInRememberDays int + CookieUserName string + CookieRememberName string + ReverseProxyAuthUser string + ReverseProxyAuthEmail string + ReverseProxyAuthFullName string + ReverseProxyLimit int + ReverseProxyTrustedProxies []string + MinPasswordLength int + ImportLocalPaths bool + DisableGitHooks bool + DisableWebhooks bool + OnlyAllowPushIfGiteaEnvironmentSet bool + PasswordComplexity []string + PasswordHashAlgo string + PasswordCheckPwn bool + SuccessfulTokensCacheSize int + CSRFCookieName = "_csrf" + CSRFCookieHTTPOnly = true +) + +// loadSecret load the secret from ini by uriKey or verbatimKey, only one of them could be set +// If the secret is loaded from uriKey (file), the file should be non-empty, to guarantee the behavior stable and clear. +func loadSecret(sec *ini.Section, uriKey, verbatimKey string) string { + // don't allow setting both URI and verbatim string + uri := sec.Key(uriKey).String() + verbatim := sec.Key(verbatimKey).String() + if uri != "" && verbatim != "" { + log.Fatal("Cannot specify both %s and %s", uriKey, verbatimKey) + } + + // if we have no URI, use verbatim + if uri == "" { + return verbatim + } + + tempURI, err := url.Parse(uri) + if err != nil { + log.Fatal("Failed to parse %s (%s): %v", uriKey, uri, err) + } + switch tempURI.Scheme { + case "file": + buf, err := os.ReadFile(tempURI.RequestURI()) + if err != nil { + log.Fatal("Failed to read %s (%s): %v", uriKey, tempURI.RequestURI(), err) + } + val := strings.TrimSpace(string(buf)) + if val == "" { + // The file shouldn't be empty, otherwise we can not know whether the user has ever set the KEY or KEY_URI + // For example: if INTERNAL_TOKEN_URI=file:///empty-file, + // Then if the token is re-generated during installation and saved to INTERNAL_TOKEN + // Then INTERNAL_TOKEN and INTERNAL_TOKEN_URI both exist, that's a fatal error (they shouldn't) + log.Fatal("Failed to read %s (%s): the file is empty", uriKey, tempURI.RequestURI()) + } + return val + + // only file URIs are allowed + default: + log.Fatal("Unsupported URI-Scheme %q (INTERNAL_TOKEN_URI = %q)", tempURI.Scheme, uri) + return "" + } +} + +// generateSaveInternalToken generates and saves the internal token to app.ini +func generateSaveInternalToken() { + token, err := generate.NewInternalToken() + if err != nil { + log.Fatal("Error generate internal token: %v", err) + } + + InternalToken = token + CreateOrAppendToCustomConf("security.INTERNAL_TOKEN", func(cfg *ini.File) { + cfg.Section("security").Key("INTERNAL_TOKEN").SetValue(token) + }) +} + +func loadSecurityFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("security") + InstallLock = sec.Key("INSTALL_LOCK").MustBool(false) + LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7) + CookieUserName = sec.Key("COOKIE_USERNAME").MustString("gitea_awesome") + SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY") + if SecretKey == "" { + // FIXME: https://github.com/go-gitea/gitea/issues/16832 + // Until it supports rotating an existing secret key, we shouldn't move users off of the widely used default value + SecretKey = "!#@FDEWREWR&*(" //nolint:gosec + } + + CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_incredible") + + ReverseProxyAuthUser = sec.Key("REVERSE_PROXY_AUTHENTICATION_USER").MustString("X-WEBAUTH-USER") + ReverseProxyAuthEmail = sec.Key("REVERSE_PROXY_AUTHENTICATION_EMAIL").MustString("X-WEBAUTH-EMAIL") + ReverseProxyAuthFullName = sec.Key("REVERSE_PROXY_AUTHENTICATION_FULL_NAME").MustString("X-WEBAUTH-FULLNAME") + + ReverseProxyLimit = sec.Key("REVERSE_PROXY_LIMIT").MustInt(1) + ReverseProxyTrustedProxies = sec.Key("REVERSE_PROXY_TRUSTED_PROXIES").Strings(",") + if len(ReverseProxyTrustedProxies) == 0 { + ReverseProxyTrustedProxies = []string{"127.0.0.0/8", "::1/128"} + } + + MinPasswordLength = sec.Key("MIN_PASSWORD_LENGTH").MustInt(6) + ImportLocalPaths = sec.Key("IMPORT_LOCAL_PATHS").MustBool(false) + DisableGitHooks = sec.Key("DISABLE_GIT_HOOKS").MustBool(true) + DisableWebhooks = sec.Key("DISABLE_WEBHOOKS").MustBool(false) + OnlyAllowPushIfGiteaEnvironmentSet = sec.Key("ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET").MustBool(true) + + // Ensure that the provided default hash algorithm is a valid hash algorithm + var algorithm *hash.PasswordHashAlgorithm + PasswordHashAlgo, algorithm = hash.SetDefaultPasswordHashAlgorithm(sec.Key("PASSWORD_HASH_ALGO").MustString("")) + if algorithm == nil { + log.Fatal("The provided password hash algorithm was invalid: %s", sec.Key("PASSWORD_HASH_ALGO").MustString("")) + } + + CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true) + PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false) + SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20) + + InternalToken = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN") + if InstallLock && InternalToken == "" { + // if Gitea has been installed but the InternalToken hasn't been generated (upgrade from an old release), we should generate + // some users do cluster deployment, they still depend on this auto-generating behavior. + generateSaveInternalToken() + } + + cfgdata := sec.Key("PASSWORD_COMPLEXITY").Strings(",") + if len(cfgdata) == 0 { + cfgdata = []string{"off"} + } + PasswordComplexity = make([]string, 0, len(cfgdata)) + for _, name := range cfgdata { + name := strings.ToLower(strings.Trim(name, `"`)) + if name != "" { + PasswordComplexity = append(PasswordComplexity, name) + } + } +} diff --git a/modules/setting/server.go b/modules/setting/server.go new file mode 100644 index 000000000..6b0f3752e --- /dev/null +++ b/modules/setting/server.go @@ -0,0 +1,356 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "encoding/base64" + "net" + "net/url" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// Scheme describes protocol types +type Scheme string + +// enumerates all the scheme types +const ( + HTTP Scheme = "http" + HTTPS Scheme = "https" + FCGI Scheme = "fcgi" + FCGIUnix Scheme = "fcgi+unix" + HTTPUnix Scheme = "http+unix" +) + +// LandingPage describes the default page +type LandingPage string + +// enumerates all the landing page types +const ( + LandingPageHome LandingPage = "/" + LandingPageExplore LandingPage = "/explore" + LandingPageOrganizations LandingPage = "/explore/organizations" + LandingPageLogin LandingPage = "/user/login" +) + +var ( + // AppName is the Application name, used in the page title. + // It maps to ini:"APP_NAME" + AppName string + // AppURL is the Application ROOT_URL. It always has a '/' suffix + // It maps to ini:"ROOT_URL" + AppURL string + // AppSubURL represents the sub-url mounting point for gitea. It is either "" or starts with '/' and ends without '/', such as '/{subpath}'. + // This value is empty if site does not have sub-url. + AppSubURL string + // AppDataPath is the default path for storing data. + // It maps to ini:"APP_DATA_PATH" in [server] and defaults to AppWorkPath + "/data" + AppDataPath string + // LocalURL is the url for locally running applications to contact Gitea. It always has a '/' suffix + // It maps to ini:"LOCAL_ROOT_URL" in [server] + LocalURL string + // AssetVersion holds a opaque value that is used for cache-busting assets + AssetVersion string + + // Server settings + Protocol Scheme + UseProxyProtocol bool // `ini:"USE_PROXY_PROTOCOL"` + ProxyProtocolTLSBridging bool //`ini:"PROXY_PROTOCOL_TLS_BRIDGING"` + ProxyProtocolHeaderTimeout time.Duration + ProxyProtocolAcceptUnknown bool + Domain string + HTTPAddr string + HTTPPort string + LocalUseProxyProtocol bool + RedirectOtherPort bool + RedirectorUseProxyProtocol bool + PortToRedirect string + OfflineMode bool + CertFile string + KeyFile string + StaticRootPath string + StaticCacheTime time.Duration + EnableGzip bool + LandingPageURL LandingPage + LandingPageCustom string + UnixSocketPermission uint32 + EnablePprof bool + PprofDataPath string + EnableAcme bool + AcmeTOS bool + AcmeLiveDirectory string + AcmeEmail string + AcmeURL string + AcmeCARoot string + SSLMinimumVersion string + SSLMaximumVersion string + SSLCurvePreferences []string + SSLCipherSuites []string + GracefulRestartable bool + GracefulHammerTime time.Duration + StartupTimeout time.Duration + PerWriteTimeout = 30 * time.Second + PerWritePerKbTimeout = 10 * time.Second + StaticURLPrefix string + AbsoluteAssetURL string + + HasRobotsTxt bool + ManifestData string +) + +// MakeManifestData generates web app manifest JSON +func MakeManifestData(appName, appURL, absoluteAssetURL string) []byte { + type manifestIcon struct { + Src string `json:"src"` + Type string `json:"type"` + Sizes string `json:"sizes"` + } + + type manifestJSON struct { + Name string `json:"name"` + ShortName string `json:"short_name"` + StartURL string `json:"start_url"` + Icons []manifestIcon `json:"icons"` + } + + bytes, err := json.Marshal(&manifestJSON{ + Name: appName, + ShortName: appName, + StartURL: appURL, + Icons: []manifestIcon{ + { + Src: absoluteAssetURL + "/assets/img/logo.png", + Type: "image/png", + Sizes: "512x512", + }, + { + Src: absoluteAssetURL + "/assets/img/logo.svg", + Type: "image/svg+xml", + Sizes: "512x512", + }, + }, + }) + if err != nil { + log.Error("unable to marshal manifest JSON. Error: %v", err) + return make([]byte, 0) + } + + return bytes +} + +// MakeAbsoluteAssetURL returns the absolute asset url prefix without a trailing slash +func MakeAbsoluteAssetURL(appURL, staticURLPrefix string) string { + parsedPrefix, err := url.Parse(strings.TrimSuffix(staticURLPrefix, "/")) + if err != nil { + log.Fatal("Unable to parse STATIC_URL_PREFIX: %v", err) + } + + if err == nil && parsedPrefix.Hostname() == "" { + if staticURLPrefix == "" { + return strings.TrimSuffix(appURL, "/") + } + + // StaticURLPrefix is just a path + return util.URLJoin(appURL, strings.TrimSuffix(staticURLPrefix, "/")) + } + + return strings.TrimSuffix(staticURLPrefix, "/") +} + +func loadServerFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("server") + AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea") + + Domain = sec.Key("DOMAIN").MustString("localhost") + HTTPAddr = sec.Key("HTTP_ADDR").MustString("0.0.0.0") + HTTPPort = sec.Key("HTTP_PORT").MustString("3000") + + Protocol = HTTP + protocolCfg := sec.Key("PROTOCOL").String() + switch protocolCfg { + case "https": + Protocol = HTTPS + // FIXME: DEPRECATED to be removed in v1.18.0 + if sec.HasKey("ENABLE_ACME") { + EnableAcme = sec.Key("ENABLE_ACME").MustBool(false) + } else { + deprecatedSetting(rootCfg, "server", "ENABLE_LETSENCRYPT", "server", "ENABLE_ACME") + EnableAcme = sec.Key("ENABLE_LETSENCRYPT").MustBool(false) + } + if EnableAcme { + AcmeURL = sec.Key("ACME_URL").MustString("") + AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("") + // FIXME: DEPRECATED to be removed in v1.18.0 + if sec.HasKey("ACME_ACCEPTTOS") { + AcmeTOS = sec.Key("ACME_ACCEPTTOS").MustBool(false) + } else { + deprecatedSetting(rootCfg, "server", "LETSENCRYPT_ACCEPTTOS", "server", "ACME_ACCEPTTOS") + AcmeTOS = sec.Key("LETSENCRYPT_ACCEPTTOS").MustBool(false) + } + if !AcmeTOS { + log.Fatal("ACME TOS is not accepted (ACME_ACCEPTTOS).") + } + // FIXME: DEPRECATED to be removed in v1.18.0 + if sec.HasKey("ACME_DIRECTORY") { + AcmeLiveDirectory = sec.Key("ACME_DIRECTORY").MustString("https") + } else { + deprecatedSetting(rootCfg, "server", "LETSENCRYPT_DIRECTORY", "server", "ACME_DIRECTORY") + AcmeLiveDirectory = sec.Key("LETSENCRYPT_DIRECTORY").MustString("https") + } + // FIXME: DEPRECATED to be removed in v1.18.0 + if sec.HasKey("ACME_EMAIL") { + AcmeEmail = sec.Key("ACME_EMAIL").MustString("") + } else { + deprecatedSetting(rootCfg, "server", "LETSENCRYPT_EMAIL", "server", "ACME_EMAIL") + AcmeEmail = sec.Key("LETSENCRYPT_EMAIL").MustString("") + } + } else { + CertFile = sec.Key("CERT_FILE").String() + KeyFile = sec.Key("KEY_FILE").String() + if len(CertFile) > 0 && !filepath.IsAbs(CertFile) { + CertFile = filepath.Join(CustomPath, CertFile) + } + if len(KeyFile) > 0 && !filepath.IsAbs(KeyFile) { + KeyFile = filepath.Join(CustomPath, KeyFile) + } + } + SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("") + SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("") + SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",") + SSLCipherSuites = sec.Key("SSL_CIPHER_SUITES").Strings(",") + case "fcgi": + Protocol = FCGI + case "fcgi+unix", "unix", "http+unix": + switch protocolCfg { + case "fcgi+unix": + Protocol = FCGIUnix + case "unix": + log.Warn("unix PROTOCOL value is deprecated, please use http+unix") + fallthrough + case "http+unix": + Protocol = HTTPUnix + } + UnixSocketPermissionRaw := sec.Key("UNIX_SOCKET_PERMISSION").MustString("666") + UnixSocketPermissionParsed, err := strconv.ParseUint(UnixSocketPermissionRaw, 8, 32) + if err != nil || UnixSocketPermissionParsed > 0o777 { + log.Fatal("Failed to parse unixSocketPermission: %s", UnixSocketPermissionRaw) + } + + UnixSocketPermission = uint32(UnixSocketPermissionParsed) + if !filepath.IsAbs(HTTPAddr) { + HTTPAddr = filepath.Join(AppWorkPath, HTTPAddr) + } + } + UseProxyProtocol = sec.Key("USE_PROXY_PROTOCOL").MustBool(false) + ProxyProtocolTLSBridging = sec.Key("PROXY_PROTOCOL_TLS_BRIDGING").MustBool(false) + ProxyProtocolHeaderTimeout = sec.Key("PROXY_PROTOCOL_HEADER_TIMEOUT").MustDuration(5 * time.Second) + ProxyProtocolAcceptUnknown = sec.Key("PROXY_PROTOCOL_ACCEPT_UNKNOWN").MustBool(false) + GracefulRestartable = sec.Key("ALLOW_GRACEFUL_RESTARTS").MustBool(true) + GracefulHammerTime = sec.Key("GRACEFUL_HAMMER_TIME").MustDuration(60 * time.Second) + StartupTimeout = sec.Key("STARTUP_TIMEOUT").MustDuration(0 * time.Second) + PerWriteTimeout = sec.Key("PER_WRITE_TIMEOUT").MustDuration(PerWriteTimeout) + PerWritePerKbTimeout = sec.Key("PER_WRITE_PER_KB_TIMEOUT").MustDuration(PerWritePerKbTimeout) + + defaultAppURL := string(Protocol) + "://" + Domain + ":" + HTTPPort + AppURL = sec.Key("ROOT_URL").MustString(defaultAppURL) + + // Check validity of AppURL + appURL, err := url.Parse(AppURL) + if err != nil { + log.Fatal("Invalid ROOT_URL '%s': %s", AppURL, err) + } + // Remove default ports from AppURL. + // (scheme-based URL normalization, RFC 3986 section 6.2.3) + if (appURL.Scheme == string(HTTP) && appURL.Port() == "80") || (appURL.Scheme == string(HTTPS) && appURL.Port() == "443") { + appURL.Host = appURL.Hostname() + } + // This should be TrimRight to ensure that there is only a single '/' at the end of AppURL. + AppURL = strings.TrimRight(appURL.String(), "/") + "/" + + // Suburl should start with '/' and end without '/', such as '/{subpath}'. + // This value is empty if site does not have sub-url. + AppSubURL = strings.TrimSuffix(appURL.Path, "/") + StaticURLPrefix = strings.TrimSuffix(sec.Key("STATIC_URL_PREFIX").MustString(AppSubURL), "/") + + // Check if Domain differs from AppURL domain than update it to AppURL's domain + urlHostname := appURL.Hostname() + if urlHostname != Domain && net.ParseIP(urlHostname) == nil && urlHostname != "" { + Domain = urlHostname + } + + AbsoluteAssetURL = MakeAbsoluteAssetURL(AppURL, StaticURLPrefix) + AssetVersion = strings.ReplaceAll(AppVer, "+", "~") // make sure the version string is clear (no real escaping is needed) + + manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL) + ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes) + + var defaultLocalURL string + switch Protocol { + case HTTPUnix: + defaultLocalURL = "http://unix/" + case FCGI: + defaultLocalURL = AppURL + case FCGIUnix: + defaultLocalURL = AppURL + default: + defaultLocalURL = string(Protocol) + "://" + if HTTPAddr == "0.0.0.0" { + defaultLocalURL += net.JoinHostPort("localhost", HTTPPort) + "/" + } else { + defaultLocalURL += net.JoinHostPort(HTTPAddr, HTTPPort) + "/" + } + } + LocalURL = sec.Key("LOCAL_ROOT_URL").MustString(defaultLocalURL) + LocalURL = strings.TrimRight(LocalURL, "/") + "/" + LocalUseProxyProtocol = sec.Key("LOCAL_USE_PROXY_PROTOCOL").MustBool(UseProxyProtocol) + RedirectOtherPort = sec.Key("REDIRECT_OTHER_PORT").MustBool(false) + PortToRedirect = sec.Key("PORT_TO_REDIRECT").MustString("80") + RedirectorUseProxyProtocol = sec.Key("REDIRECTOR_USE_PROXY_PROTOCOL").MustBool(UseProxyProtocol) + OfflineMode = sec.Key("OFFLINE_MODE").MustBool() + Log.DisableRouterLog = sec.Key("DISABLE_ROUTER_LOG").MustBool() + if len(StaticRootPath) == 0 { + StaticRootPath = AppWorkPath + } + StaticRootPath = sec.Key("STATIC_ROOT_PATH").MustString(StaticRootPath) + StaticCacheTime = sec.Key("STATIC_CACHE_TIME").MustDuration(6 * time.Hour) + AppDataPath = sec.Key("APP_DATA_PATH").MustString(path.Join(AppWorkPath, "data")) + if !filepath.IsAbs(AppDataPath) { + log.Info("The provided APP_DATA_PATH: %s is not absolute - it will be made absolute against the work path: %s", AppDataPath, AppWorkPath) + AppDataPath = filepath.ToSlash(filepath.Join(AppWorkPath, AppDataPath)) + } + + EnableGzip = sec.Key("ENABLE_GZIP").MustBool() + EnablePprof = sec.Key("ENABLE_PPROF").MustBool(false) + PprofDataPath = sec.Key("PPROF_DATA_PATH").MustString(path.Join(AppWorkPath, "data/tmp/pprof")) + if !filepath.IsAbs(PprofDataPath) { + PprofDataPath = filepath.Join(AppWorkPath, PprofDataPath) + } + + landingPage := sec.Key("LANDING_PAGE").MustString("home") + switch landingPage { + case "explore": + LandingPageURL = LandingPageExplore + case "organizations": + LandingPageURL = LandingPageOrganizations + case "login": + LandingPageURL = LandingPageLogin + case "": + case "home": + LandingPageURL = LandingPageHome + default: + LandingPageURL = LandingPage(landingPage) + } + + HasRobotsTxt, err = util.IsFile(path.Join(CustomPath, "robots.txt")) + if err != nil { + log.Error("Unable to check if %s is a file. Error: %v", path.Join(CustomPath, "robots.txt"), err) + } +} diff --git a/modules/setting/service.go b/modules/setting/service.go index 1d33ac6bc..d4a31ba5d 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -12,6 +12,15 @@ import ( "code.gitea.io/gitea/modules/structs" ) +// enumerates all the types of captchas +const ( + ImageCaptcha = "image" + ReCaptcha = "recaptcha" + HCaptcha = "hcaptcha" + MCaptcha = "mcaptcha" + CfTurnstile = "cfturnstile" +) + // Service settings var Service = struct { DefaultUserVisibility string @@ -105,8 +114,8 @@ func (a AllowedVisibility) ToVisibleTypeSlice() (result []structs.VisibleType) { return result } -func newService() { - sec := Cfg.Section("service") +func loadServiceFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("service") Service.ActiveCodeLives = sec.Key("ACTIVE_CODE_LIVE_MINUTES").MustInt(180) Service.ResetPwdCodeLives = sec.Key("RESET_PASSWD_CODE_LIVE_MINUTES").MustInt(180) Service.DisableRegistration = sec.Key("DISABLE_REGISTRATION").MustBool() @@ -184,11 +193,13 @@ func newService() { } Service.ValidSiteURLSchemes = schemes - if err := Cfg.Section("service.explore").MapTo(&Service.Explore); err != nil { - log.Fatal("Failed to map service.explore settings: %v", err) - } + mustMapSetting(rootCfg, "service.explore", &Service.Explore) + + loadOpenIDSetting(rootCfg) +} - sec = Cfg.Section("openid") +func loadOpenIDSetting(rootCfg ConfigProvider) { + sec := rootCfg.Section("openid") Service.EnableOpenIDSignIn = sec.Key("ENABLE_OPENID_SIGNIN").MustBool(!InstallLock) Service.EnableOpenIDSignUp = sec.Key("ENABLE_OPENID_SIGNUP").MustBool(!Service.DisableRegistration && Service.EnableOpenIDSignIn) pats := sec.Key("WHITELISTED_URIS").Strings(" ") diff --git a/modules/setting/session.go b/modules/setting/session.go index 082538c38..b8498335d 100644 --- a/modules/setting/session.go +++ b/modules/setting/session.go @@ -15,7 +15,8 @@ import ( // SessionConfig defines Session settings var SessionConfig = struct { - Provider string + OriginalProvider string + Provider string // Provider configuration, it's corresponding to provider. ProviderConfig string // Cookie name to save session ID. Default is "MacaronSession". @@ -39,8 +40,8 @@ var SessionConfig = struct { SameSite: http.SameSiteLaxMode, } -func newSessionService() { - sec := Cfg.Section("session") +func loadSessionFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("session") SessionConfig.Provider = sec.Key("PROVIDER").In("memory", []string{"memory", "file", "redis", "mysql", "postgres", "couchbase", "memcache", "db"}) SessionConfig.ProviderConfig = strings.Trim(sec.Key("PROVIDER_CONFIG").MustString(path.Join(AppDataPath, "sessions")), "\" ") @@ -67,6 +68,7 @@ func newSessionService() { log.Fatal("Can't shadow session config: %v", err) } SessionConfig.ProviderConfig = string(shadowConfig) + SessionConfig.OriginalProvider = SessionConfig.Provider SessionConfig.Provider = "VirtualSession" log.Info("Session Service Enabled") diff --git a/modules/setting/setting.go b/modules/setting/setting.go index a68a46f7a..87b1e2797 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -5,12 +5,8 @@ package setting import ( - "encoding/base64" "errors" "fmt" - "math" - "net" - "net/url" "os" "os/exec" "path" @@ -18,52 +14,16 @@ import ( "runtime" "strconv" "strings" - "text/template" "time" - "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/generate" - "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/auth/password/hash" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/user" "code.gitea.io/gitea/modules/util" - gossh "golang.org/x/crypto/ssh" ini "gopkg.in/ini.v1" ) -// Scheme describes protocol types -type Scheme string - -// enumerates all the scheme types -const ( - HTTP Scheme = "http" - HTTPS Scheme = "https" - FCGI Scheme = "fcgi" - FCGIUnix Scheme = "fcgi+unix" - HTTPUnix Scheme = "http+unix" -) - -// LandingPage describes the default page -type LandingPage string - -// enumerates all the landing page types -const ( - LandingPageHome LandingPage = "/" - LandingPageExplore LandingPage = "/explore" - LandingPageOrganizations LandingPage = "/explore/organizations" - LandingPageLogin LandingPage = "/user/login" -) - -// enumerates all the types of captchas -const ( - ImageCaptcha = "image" - ReCaptcha = "recaptcha" - HCaptcha = "hcaptcha" - MCaptcha = "mcaptcha" - CfTurnstile = "cfturnstile" -) - // settings var ( // AppVer is the version of the current build of Gitea. It is set in main.go from main.Version. @@ -72,15 +32,7 @@ var ( AppBuiltWith string // AppStartTime store time gitea has started AppStartTime time.Time - // AppName is the Application name, used in the page title. - // It maps to ini:"APP_NAME" - AppName string - // AppURL is the Application ROOT_URL. It always has a '/' suffix - // It maps to ini:"ROOT_URL" - AppURL string - // AppSubURL represents the sub-url mounting point for gitea. It is either "" or starts with '/' and ends without '/', such as '/{subpath}'. - // This value is empty if site does not have sub-url. - AppSubURL string + // AppPath represents the path to the gitea binary AppPath string // AppWorkPath is the "working directory" of Gitea. It maps to the environment variable GITEA_WORK_DIR. @@ -88,373 +40,17 @@ var ( // // AppWorkPath is used as the base path for several other paths. AppWorkPath string - // AppDataPath is the default path for storing data. - // It maps to ini:"APP_DATA_PATH" in [server] and defaults to AppWorkPath + "/data" - AppDataPath string - // LocalURL is the url for locally running applications to contact Gitea. It always has a '/' suffix - // It maps to ini:"LOCAL_ROOT_URL" in [server] - LocalURL string - // AssetVersion holds a opaque value that is used for cache-busting assets - AssetVersion string - - // Server settings - Protocol Scheme - UseProxyProtocol bool // `ini:"USE_PROXY_PROTOCOL"` - ProxyProtocolTLSBridging bool //`ini:"PROXY_PROTOCOL_TLS_BRIDGING"` - ProxyProtocolHeaderTimeout time.Duration - ProxyProtocolAcceptUnknown bool - Domain string - HTTPAddr string - HTTPPort string - LocalUseProxyProtocol bool - RedirectOtherPort bool - RedirectorUseProxyProtocol bool - PortToRedirect string - OfflineMode bool - CertFile string - KeyFile string - StaticRootPath string - StaticCacheTime time.Duration - EnableGzip bool - LandingPageURL LandingPage - LandingPageCustom string - UnixSocketPermission uint32 - EnablePprof bool - PprofDataPath string - EnableAcme bool - AcmeTOS bool - AcmeLiveDirectory string - AcmeEmail string - AcmeURL string - AcmeCARoot string - SSLMinimumVersion string - SSLMaximumVersion string - SSLCurvePreferences []string - SSLCipherSuites []string - GracefulRestartable bool - GracefulHammerTime time.Duration - StartupTimeout time.Duration - PerWriteTimeout = 30 * time.Second - PerWritePerKbTimeout = 10 * time.Second - StaticURLPrefix string - AbsoluteAssetURL string - - SSH = struct { - Disabled bool `ini:"DISABLE_SSH"` - StartBuiltinServer bool `ini:"START_SSH_SERVER"` - BuiltinServerUser string `ini:"BUILTIN_SSH_SERVER_USER"` - UseProxyProtocol bool `ini:"SSH_SERVER_USE_PROXY_PROTOCOL"` - Domain string `ini:"SSH_DOMAIN"` - Port int `ini:"SSH_PORT"` - User string `ini:"SSH_USER"` - ListenHost string `ini:"SSH_LISTEN_HOST"` - ListenPort int `ini:"SSH_LISTEN_PORT"` - RootPath string `ini:"SSH_ROOT_PATH"` - ServerCiphers []string `ini:"SSH_SERVER_CIPHERS"` - ServerKeyExchanges []string `ini:"SSH_SERVER_KEY_EXCHANGES"` - ServerMACs []string `ini:"SSH_SERVER_MACS"` - ServerHostKeys []string `ini:"SSH_SERVER_HOST_KEYS"` - KeyTestPath string `ini:"SSH_KEY_TEST_PATH"` - KeygenPath string `ini:"SSH_KEYGEN_PATH"` - AuthorizedKeysBackup bool `ini:"SSH_AUTHORIZED_KEYS_BACKUP"` - AuthorizedPrincipalsBackup bool `ini:"SSH_AUTHORIZED_PRINCIPALS_BACKUP"` - AuthorizedKeysCommandTemplate string `ini:"SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE"` - AuthorizedKeysCommandTemplateTemplate *template.Template `ini:"-"` - MinimumKeySizeCheck bool `ini:"-"` - MinimumKeySizes map[string]int `ini:"-"` - CreateAuthorizedKeysFile bool `ini:"SSH_CREATE_AUTHORIZED_KEYS_FILE"` - CreateAuthorizedPrincipalsFile bool `ini:"SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE"` - ExposeAnonymous bool `ini:"SSH_EXPOSE_ANONYMOUS"` - AuthorizedPrincipalsAllow []string `ini:"SSH_AUTHORIZED_PRINCIPALS_ALLOW"` - AuthorizedPrincipalsEnabled bool `ini:"-"` - TrustedUserCAKeys []string `ini:"SSH_TRUSTED_USER_CA_KEYS"` - TrustedUserCAKeysFile string `ini:"SSH_TRUSTED_USER_CA_KEYS_FILENAME"` - TrustedUserCAKeysParsed []gossh.PublicKey `ini:"-"` - PerWriteTimeout time.Duration `ini:"SSH_PER_WRITE_TIMEOUT"` - PerWritePerKbTimeout time.Duration `ini:"SSH_PER_WRITE_PER_KB_TIMEOUT"` - }{ - Disabled: false, - StartBuiltinServer: false, - Domain: "", - Port: 22, - ServerCiphers: []string{"chacha20-poly1305@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com"}, - ServerKeyExchanges: []string{"curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1"}, - ServerMACs: []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256", "hmac-sha1"}, - KeygenPath: "ssh-keygen", - MinimumKeySizeCheck: true, - MinimumKeySizes: map[string]int{"ed25519": 256, "ed25519-sk": 256, "ecdsa": 256, "ecdsa-sk": 256, "rsa": 2047}, - ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gogs.rsa"}, - AuthorizedKeysCommandTemplate: "{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}", - PerWriteTimeout: PerWriteTimeout, - PerWritePerKbTimeout: PerWritePerKbTimeout, - } - - // Security settings - InstallLock bool - SecretKey string - LogInRememberDays int - CookieUserName string - CookieRememberName string - ReverseProxyAuthUser string - ReverseProxyAuthEmail string - ReverseProxyAuthFullName string - ReverseProxyLimit int - ReverseProxyTrustedProxies []string - MinPasswordLength int - ImportLocalPaths bool - DisableGitHooks bool - DisableWebhooks bool - OnlyAllowPushIfGiteaEnvironmentSet bool - PasswordComplexity []string - PasswordHashAlgo string - PasswordCheckPwn bool - SuccessfulTokensCacheSize int - - Camo = struct { - Enabled bool - ServerURL string `ini:"SERVER_URL"` - HMACKey string `ini:"HMAC_KEY"` - Allways bool - }{} - - // UI settings - UI = struct { - ExplorePagingNum int - SitemapPagingNum int - IssuePagingNum int - RepoSearchPagingNum int - MembersPagingNum int - FeedMaxCommitNum int - FeedPagingNum int - PackagesPagingNum int - GraphMaxCommitNum int - CodeCommentLines int - ReactionMaxUserNum int - ThemeColorMetaTag string - MaxDisplayFileSize int64 - ShowUserEmail bool - DefaultShowFullName bool - DefaultTheme string - Themes []string - Reactions []string - ReactionsLookup container.Set[string] `ini:"-"` - CustomEmojis []string - CustomEmojisMap map[string]string `ini:"-"` - SearchRepoDescription bool - UseServiceWorker bool - - Notification struct { - MinTimeout time.Duration - TimeoutStep time.Duration - MaxTimeout time.Duration - EventSourceUpdateTime time.Duration - } `ini:"ui.notification"` - - SVG struct { - Enabled bool `ini:"ENABLE_RENDER"` - } `ini:"ui.svg"` - - CSV struct { - MaxFileSize int64 - } `ini:"ui.csv"` - - Admin struct { - UserPagingNum int - RepoPagingNum int - NoticePagingNum int - OrgPagingNum int - } `ini:"ui.admin"` - User struct { - RepoPagingNum int - } `ini:"ui.user"` - Meta struct { - Author string - Description string - Keywords string - } `ini:"ui.meta"` - }{ - ExplorePagingNum: 20, - SitemapPagingNum: 20, - IssuePagingNum: 20, - RepoSearchPagingNum: 20, - MembersPagingNum: 20, - FeedMaxCommitNum: 5, - FeedPagingNum: 20, - PackagesPagingNum: 20, - GraphMaxCommitNum: 100, - CodeCommentLines: 4, - ReactionMaxUserNum: 10, - ThemeColorMetaTag: `#6cc644`, - MaxDisplayFileSize: 8388608, - DefaultTheme: `auto`, - Themes: []string{`auto`, `gitea`, `arc-green`}, - Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, - CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`}, - CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"}, - Notification: struct { - MinTimeout time.Duration - TimeoutStep time.Duration - MaxTimeout time.Duration - EventSourceUpdateTime time.Duration - }{ - MinTimeout: 10 * time.Second, - TimeoutStep: 10 * time.Second, - MaxTimeout: 60 * time.Second, - EventSourceUpdateTime: 10 * time.Second, - }, - SVG: struct { - Enabled bool `ini:"ENABLE_RENDER"` - }{ - Enabled: true, - }, - CSV: struct { - MaxFileSize int64 - }{ - MaxFileSize: 524288, - }, - Admin: struct { - UserPagingNum int - RepoPagingNum int - NoticePagingNum int - OrgPagingNum int - }{ - UserPagingNum: 50, - RepoPagingNum: 50, - NoticePagingNum: 25, - OrgPagingNum: 50, - }, - User: struct { - RepoPagingNum int - }{ - RepoPagingNum: 15, - }, - Meta: struct { - Author string - Description string - Keywords string - }{ - Author: "Gitea - Git with a cup of tea", - Description: "Gitea (Git with a cup of tea) is a painless self-hosted Git service written in Go", - Keywords: "go,git,self-hosted,gitea", - }, - } - - // Markdown settings - Markdown = struct { - EnableHardLineBreakInComments bool - EnableHardLineBreakInDocuments bool - CustomURLSchemes []string `ini:"CUSTOM_URL_SCHEMES"` - FileExtensions []string - EnableMath bool - }{ - EnableHardLineBreakInComments: true, - EnableHardLineBreakInDocuments: false, - FileExtensions: strings.Split(".md,.markdown,.mdown,.mkd", ","), - EnableMath: true, - } - - // Admin settings - Admin struct { - DisableRegularOrgCreation bool - DefaultEmailNotification string - } - - // Log settings - LogLevel log.Level - StacktraceLogLevel string - LogRootPath string - EnableSSHLog bool - EnableXORMLog bool - - DisableRouterLog bool - - EnableAccessLog bool - AccessLogTemplate string - - // Time settings - TimeFormat string - // UILocation is the location on the UI, so that we can display the time on UI. - DefaultUILocation = time.Local - - CSRFCookieName = "_csrf" - CSRFCookieHTTPOnly = true - - ManifestData string - - // API settings - API = struct { - EnableSwagger bool - SwaggerURL string - MaxResponseItems int - DefaultPagingNum int - DefaultGitTreesPerPage int - DefaultMaxBlobSize int64 - }{ - EnableSwagger: true, - SwaggerURL: "", - MaxResponseItems: 50, - DefaultPagingNum: 30, - DefaultGitTreesPerPage: 1000, - DefaultMaxBlobSize: 10485760, - } - - OAuth2 = struct { - Enable bool - AccessTokenExpirationTime int64 - RefreshTokenExpirationTime int64 - InvalidateRefreshTokens bool - JWTSigningAlgorithm string `ini:"JWT_SIGNING_ALGORITHM"` - JWTSecretBase64 string `ini:"JWT_SECRET"` - JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"` - MaxTokenLength int - }{ - Enable: true, - AccessTokenExpirationTime: 3600, - RefreshTokenExpirationTime: 730, - InvalidateRefreshTokens: false, - JWTSigningAlgorithm: "RS256", - JWTSigningPrivateKeyFile: "jwt/private.pem", - MaxTokenLength: math.MaxInt16, - } - - // Metrics settings - Metrics = struct { - Enabled bool - Token string - EnabledIssueByLabel bool - EnabledIssueByRepository bool - }{ - Enabled: false, - Token: "", - EnabledIssueByLabel: false, - EnabledIssueByRepository: false, - } - - // I18n settings - Langs []string - Names []string - - // Highlight settings are loaded in modules/template/highlight.go - - // Other settings - ShowFooterBranding bool - ShowFooterVersion bool - ShowFooterTemplateLoadTime bool - EnableFeed bool // Global setting objects - Cfg *ini.File - CustomPath string // Custom directory path - CustomConf string - PIDFile = "/run/gitea.pid" - WritePIDFile bool - RunMode string - IsProd bool - RunUser string - IsWindows bool - HasRobotsTxt bool - EnableSitemap bool - InternalToken string // internal access token + CfgProvider ConfigProvider + CustomPath string // Custom directory path + CustomConf string + PIDFile = "/run/gitea.pid" + WritePIDFile bool + RunMode string + RunUser string + IsProd bool + IsWindows bool ) func getAppPath() (string, error) { @@ -591,465 +187,136 @@ func SetCustomPathAndConf(providedCustom, providedConf, providedWorkPath string) } } -// LoadFromExisting initializes setting options from an existing config file (app.ini) -func LoadFromExisting() { - loadFromConf(false, "") -} +// PrepareAppDataPath creates app data directory if necessary +func PrepareAppDataPath() error { + // FIXME: There are too many calls to MkdirAll in old code. It is incorrect. + // For example, if someDir=/mnt/vol1/gitea-home/data, if the mount point /mnt/vol1 is not mounted when Gitea runs, + // then gitea will make new empty directories in /mnt/vol1, all are stored in the root filesystem. + // The correct behavior should be: creating parent directories is end users' duty. We only create sub-directories in existing parent directories. + // For quickstart, the parent directories should be created automatically for first startup (eg: a flag or a check of INSTALL_LOCK). + // Now we can take the first step to do correctly (using Mkdir) in other packages, and prepare the AppDataPath here, then make a refactor in future. + + st, err := os.Stat(AppDataPath) + if os.IsNotExist(err) { + err = os.MkdirAll(AppDataPath, os.ModePerm) + if err != nil { + return fmt.Errorf("unable to create the APP_DATA_PATH directory: %q, Error: %w", AppDataPath, err) + } + return nil + } -// LoadAllowEmpty initializes setting options, it's also fine that if the config file (app.ini) doesn't exist -func LoadAllowEmpty() { - loadFromConf(true, "") -} + if err != nil { + return fmt.Errorf("unable to use APP_DATA_PATH %q. Error: %w", AppDataPath, err) + } -// LoadForTest initializes setting options for tests -func LoadForTest(extraConfigs ...string) { - loadFromConf(true, strings.Join(extraConfigs, "\n")) - if err := PrepareAppDataPath(); err != nil { - log.Fatal("Can not prepare APP_DATA_PATH: %v", err) + if !st.IsDir() /* also works for symlink */ { + return fmt.Errorf("the APP_DATA_PATH %q is not a directory (or symlink to a directory) and can't be used", AppDataPath) } + + return nil } -func deprecatedSetting(oldSection, oldKey, newSection, newKey string) { - if Cfg.Section(oldSection).HasKey(oldKey) { - log.Error("Deprecated fallback `[%s]` `%s` present. Use `[%s]` `%s` instead. This fallback will be removed in v1.19.0", oldSection, oldKey, newSection, newKey) - } +// InitProviderFromExistingFile initializes config provider from an existing config file (app.ini) +func InitProviderFromExistingFile() { + CfgProvider = newFileProviderFromConf(CustomConf, WritePIDFile, false, PIDFile, "") +} + +// InitProviderAllowEmpty initializes config provider from file, it's also fine that if the config file (app.ini) doesn't exist +func InitProviderAllowEmpty() { + CfgProvider = newFileProviderFromConf(CustomConf, WritePIDFile, true, PIDFile, "") } -// deprecatedSettingDB add a hint that the configuration has been moved to database but still kept in app.ini -func deprecatedSettingDB(oldSection, oldKey string) { - if Cfg.Section(oldSection).HasKey(oldKey) { - log.Error("Deprecated `[%s]` `%s` present which has been copied to database table sys_setting", oldSection, oldKey) +// InitProviderAndLoadCommonSettingsForTest initializes config provider and load common setttings for tests +func InitProviderAndLoadCommonSettingsForTest(extraConfigs ...string) { + CfgProvider = newFileProviderFromConf(CustomConf, WritePIDFile, true, PIDFile, strings.Join(extraConfigs, "\n")) + loadCommonSettingsFrom(CfgProvider) + if err := PrepareAppDataPath(); err != nil { + log.Fatal("Can not prepare APP_DATA_PATH: %v", err) } + // register the dummy hash algorithm function used in the test fixtures + _ = hash.Register("dummy", hash.NewDummyHasher) + + PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy") } -// loadFromConf initializes configuration context. +// newFileProviderFromConf initializes configuration context. // NOTE: do not print any log except error. -func loadFromConf(allowEmpty bool, extraConfig string) { - Cfg = ini.Empty() +func newFileProviderFromConf(customConf string, writePIDFile, allowEmpty bool, pidFile, extraConfig string) *ini.File { + cfg := ini.Empty() - if WritePIDFile && len(PIDFile) > 0 { - createPIDFile(PIDFile) + if writePIDFile && len(pidFile) > 0 { + createPIDFile(pidFile) } - isFile, err := util.IsFile(CustomConf) + isFile, err := util.IsFile(customConf) if err != nil { - log.Error("Unable to check if %s is a file. Error: %v", CustomConf, err) + log.Error("Unable to check if %s is a file. Error: %v", customConf, err) } if isFile { - if err := Cfg.Append(CustomConf); err != nil { - log.Fatal("Failed to load custom conf '%s': %v", CustomConf, err) + if err := cfg.Append(customConf); err != nil { + log.Fatal("Failed to load custom conf '%s': %v", customConf, err) } } else if !allowEmpty { log.Fatal("Unable to find configuration file: %q.\nEnsure you are running in the correct environment or set the correct configuration file with -c.", CustomConf) } // else: no config file, a config file might be created at CustomConf later (might not) if extraConfig != "" { - if err = Cfg.Append([]byte(extraConfig)); err != nil { + if err = cfg.Append([]byte(extraConfig)); err != nil { log.Fatal("Unable to append more config: %v", err) } } - Cfg.NameMapper = ini.SnackCase - - homeDir, err := util.HomeDir() - if err != nil { - log.Fatal("Failed to get home directory: %v", err) - } - homeDir = strings.ReplaceAll(homeDir, "\\", "/") - - LogLevel = getLogLevel(Cfg.Section("log"), "LEVEL", log.INFO) - StacktraceLogLevel = getStacktraceLogLevel(Cfg.Section("log"), "STACKTRACE_LEVEL", "None") - LogRootPath = Cfg.Section("log").Key("ROOT_PATH").MustString(path.Join(AppWorkPath, "log")) - forcePathSeparator(LogRootPath) - - sec := Cfg.Section("server") - AppName = Cfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea") - - Domain = sec.Key("DOMAIN").MustString("localhost") - HTTPAddr = sec.Key("HTTP_ADDR").MustString("0.0.0.0") - HTTPPort = sec.Key("HTTP_PORT").MustString("3000") - - Protocol = HTTP - protocolCfg := sec.Key("PROTOCOL").String() - switch protocolCfg { - case "https": - Protocol = HTTPS - // FIXME: DEPRECATED to be removed in v1.18.0 - if sec.HasKey("ENABLE_ACME") { - EnableAcme = sec.Key("ENABLE_ACME").MustBool(false) - } else { - deprecatedSetting("server", "ENABLE_LETSENCRYPT", "server", "ENABLE_ACME") - EnableAcme = sec.Key("ENABLE_LETSENCRYPT").MustBool(false) - } - if EnableAcme { - AcmeURL = sec.Key("ACME_URL").MustString("") - AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("") - // FIXME: DEPRECATED to be removed in v1.18.0 - if sec.HasKey("ACME_ACCEPTTOS") { - AcmeTOS = sec.Key("ACME_ACCEPTTOS").MustBool(false) - } else { - deprecatedSetting("server", "LETSENCRYPT_ACCEPTTOS", "server", "ACME_ACCEPTTOS") - AcmeTOS = sec.Key("LETSENCRYPT_ACCEPTTOS").MustBool(false) - } - if !AcmeTOS { - log.Fatal("ACME TOS is not accepted (ACME_ACCEPTTOS).") - } - // FIXME: DEPRECATED to be removed in v1.18.0 - if sec.HasKey("ACME_DIRECTORY") { - AcmeLiveDirectory = sec.Key("ACME_DIRECTORY").MustString("https") - } else { - deprecatedSetting("server", "LETSENCRYPT_DIRECTORY", "server", "ACME_DIRECTORY") - AcmeLiveDirectory = sec.Key("LETSENCRYPT_DIRECTORY").MustString("https") - } - // FIXME: DEPRECATED to be removed in v1.18.0 - if sec.HasKey("ACME_EMAIL") { - AcmeEmail = sec.Key("ACME_EMAIL").MustString("") - } else { - deprecatedSetting("server", "LETSENCRYPT_EMAIL", "server", "ACME_EMAIL") - AcmeEmail = sec.Key("LETSENCRYPT_EMAIL").MustString("") - } - } else { - CertFile = sec.Key("CERT_FILE").String() - KeyFile = sec.Key("KEY_FILE").String() - if len(CertFile) > 0 && !filepath.IsAbs(CertFile) { - CertFile = filepath.Join(CustomPath, CertFile) - } - if len(KeyFile) > 0 && !filepath.IsAbs(KeyFile) { - KeyFile = filepath.Join(CustomPath, KeyFile) - } - } - SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("") - SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("") - SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",") - SSLCipherSuites = sec.Key("SSL_CIPHER_SUITES").Strings(",") - case "fcgi": - Protocol = FCGI - case "fcgi+unix", "unix", "http+unix": - switch protocolCfg { - case "fcgi+unix": - Protocol = FCGIUnix - case "unix": - log.Warn("unix PROTOCOL value is deprecated, please use http+unix") - fallthrough - case "http+unix": - Protocol = HTTPUnix - } - UnixSocketPermissionRaw := sec.Key("UNIX_SOCKET_PERMISSION").MustString("666") - UnixSocketPermissionParsed, err := strconv.ParseUint(UnixSocketPermissionRaw, 8, 32) - if err != nil || UnixSocketPermissionParsed > 0o777 { - log.Fatal("Failed to parse unixSocketPermission: %s", UnixSocketPermissionRaw) - } - - UnixSocketPermission = uint32(UnixSocketPermissionParsed) - if !filepath.IsAbs(HTTPAddr) { - HTTPAddr = filepath.Join(AppWorkPath, HTTPAddr) - } - } - UseProxyProtocol = sec.Key("USE_PROXY_PROTOCOL").MustBool(false) - ProxyProtocolTLSBridging = sec.Key("PROXY_PROTOCOL_TLS_BRIDGING").MustBool(false) - ProxyProtocolHeaderTimeout = sec.Key("PROXY_PROTOCOL_HEADER_TIMEOUT").MustDuration(5 * time.Second) - ProxyProtocolAcceptUnknown = sec.Key("PROXY_PROTOCOL_ACCEPT_UNKNOWN").MustBool(false) - GracefulRestartable = sec.Key("ALLOW_GRACEFUL_RESTARTS").MustBool(true) - GracefulHammerTime = sec.Key("GRACEFUL_HAMMER_TIME").MustDuration(60 * time.Second) - StartupTimeout = sec.Key("STARTUP_TIMEOUT").MustDuration(0 * time.Second) - PerWriteTimeout = sec.Key("PER_WRITE_TIMEOUT").MustDuration(PerWriteTimeout) - PerWritePerKbTimeout = sec.Key("PER_WRITE_PER_KB_TIMEOUT").MustDuration(PerWritePerKbTimeout) - - defaultAppURL := string(Protocol) + "://" + Domain + ":" + HTTPPort - AppURL = sec.Key("ROOT_URL").MustString(defaultAppURL) - - // Check validity of AppURL - appURL, err := url.Parse(AppURL) - if err != nil { - log.Fatal("Invalid ROOT_URL '%s': %s", AppURL, err) - } - // Remove default ports from AppURL. - // (scheme-based URL normalization, RFC 3986 section 6.2.3) - if (appURL.Scheme == string(HTTP) && appURL.Port() == "80") || (appURL.Scheme == string(HTTPS) && appURL.Port() == "443") { - appURL.Host = appURL.Hostname() - } - // This should be TrimRight to ensure that there is only a single '/' at the end of AppURL. - AppURL = strings.TrimRight(appURL.String(), "/") + "/" - - // Suburl should start with '/' and end without '/', such as '/{subpath}'. - // This value is empty if site does not have sub-url. - AppSubURL = strings.TrimSuffix(appURL.Path, "/") - StaticURLPrefix = strings.TrimSuffix(sec.Key("STATIC_URL_PREFIX").MustString(AppSubURL), "/") - - // Check if Domain differs from AppURL domain than update it to AppURL's domain - urlHostname := appURL.Hostname() - if urlHostname != Domain && net.ParseIP(urlHostname) == nil && urlHostname != "" { - Domain = urlHostname - } - - AbsoluteAssetURL = MakeAbsoluteAssetURL(AppURL, StaticURLPrefix) - AssetVersion = strings.ReplaceAll(AppVer, "+", "~") // make sure the version string is clear (no real escaping is needed) - - manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL) - ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes) - - var defaultLocalURL string - switch Protocol { - case HTTPUnix: - defaultLocalURL = "http://unix/" - case FCGI: - defaultLocalURL = AppURL - case FCGIUnix: - defaultLocalURL = AppURL - default: - defaultLocalURL = string(Protocol) + "://" - if HTTPAddr == "0.0.0.0" { - defaultLocalURL += net.JoinHostPort("localhost", HTTPPort) + "/" - } else { - defaultLocalURL += net.JoinHostPort(HTTPAddr, HTTPPort) + "/" - } - } - LocalURL = sec.Key("LOCAL_ROOT_URL").MustString(defaultLocalURL) - LocalURL = strings.TrimRight(LocalURL, "/") + "/" - LocalUseProxyProtocol = sec.Key("LOCAL_USE_PROXY_PROTOCOL").MustBool(UseProxyProtocol) - RedirectOtherPort = sec.Key("REDIRECT_OTHER_PORT").MustBool(false) - PortToRedirect = sec.Key("PORT_TO_REDIRECT").MustString("80") - RedirectorUseProxyProtocol = sec.Key("REDIRECTOR_USE_PROXY_PROTOCOL").MustBool(UseProxyProtocol) - OfflineMode = sec.Key("OFFLINE_MODE").MustBool() - DisableRouterLog = sec.Key("DISABLE_ROUTER_LOG").MustBool() - if len(StaticRootPath) == 0 { - StaticRootPath = AppWorkPath - } - StaticRootPath = sec.Key("STATIC_ROOT_PATH").MustString(StaticRootPath) - StaticCacheTime = sec.Key("STATIC_CACHE_TIME").MustDuration(6 * time.Hour) - AppDataPath = sec.Key("APP_DATA_PATH").MustString(path.Join(AppWorkPath, "data")) - if !filepath.IsAbs(AppDataPath) { - log.Info("The provided APP_DATA_PATH: %s is not absolute - it will be made absolute against the work path: %s", AppDataPath, AppWorkPath) - AppDataPath = filepath.ToSlash(filepath.Join(AppWorkPath, AppDataPath)) - } - - EnableGzip = sec.Key("ENABLE_GZIP").MustBool() - EnablePprof = sec.Key("ENABLE_PPROF").MustBool(false) - PprofDataPath = sec.Key("PPROF_DATA_PATH").MustString(path.Join(AppWorkPath, "data/tmp/pprof")) - if !filepath.IsAbs(PprofDataPath) { - PprofDataPath = filepath.Join(AppWorkPath, PprofDataPath) - } - - landingPage := sec.Key("LANDING_PAGE").MustString("home") - switch landingPage { - case "explore": - LandingPageURL = LandingPageExplore - case "organizations": - LandingPageURL = LandingPageOrganizations - case "login": - LandingPageURL = LandingPageLogin - case "": - case "home": - LandingPageURL = LandingPageHome - default: - LandingPageURL = LandingPage(landingPage) - } - - if len(SSH.Domain) == 0 { - SSH.Domain = Domain - } - SSH.RootPath = path.Join(homeDir, ".ssh") - serverCiphers := sec.Key("SSH_SERVER_CIPHERS").Strings(",") - if len(serverCiphers) > 0 { - SSH.ServerCiphers = serverCiphers - } - serverKeyExchanges := sec.Key("SSH_SERVER_KEY_EXCHANGES").Strings(",") - if len(serverKeyExchanges) > 0 { - SSH.ServerKeyExchanges = serverKeyExchanges - } - serverMACs := sec.Key("SSH_SERVER_MACS").Strings(",") - if len(serverMACs) > 0 { - SSH.ServerMACs = serverMACs - } - SSH.KeyTestPath = os.TempDir() - if err = Cfg.Section("server").MapTo(&SSH); err != nil { - log.Fatal("Failed to map SSH settings: %v", err) - } - for i, key := range SSH.ServerHostKeys { - if !filepath.IsAbs(key) { - SSH.ServerHostKeys[i] = filepath.Join(AppDataPath, key) - } - } - - SSH.KeygenPath = sec.Key("SSH_KEYGEN_PATH").MustString("ssh-keygen") - SSH.Port = sec.Key("SSH_PORT").MustInt(22) - SSH.ListenPort = sec.Key("SSH_LISTEN_PORT").MustInt(SSH.Port) - SSH.UseProxyProtocol = sec.Key("SSH_SERVER_USE_PROXY_PROTOCOL").MustBool(false) - - // When disable SSH, start builtin server value is ignored. - if SSH.Disabled { - SSH.StartBuiltinServer = false - } - - SSH.TrustedUserCAKeysFile = sec.Key("SSH_TRUSTED_USER_CA_KEYS_FILENAME").MustString(filepath.Join(SSH.RootPath, "gitea-trusted-user-ca-keys.pem")) - - for _, caKey := range SSH.TrustedUserCAKeys { - pubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(caKey)) - if err != nil { - log.Fatal("Failed to parse TrustedUserCaKeys: %s %v", caKey, err) - } - - SSH.TrustedUserCAKeysParsed = append(SSH.TrustedUserCAKeysParsed, pubKey) - } - if len(SSH.TrustedUserCAKeys) > 0 { - // Set the default as email,username otherwise we can leave it empty - sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("username,email") - } else { - sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("off") - } - - SSH.AuthorizedPrincipalsAllow, SSH.AuthorizedPrincipalsEnabled = parseAuthorizedPrincipalsAllow(sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").Strings(",")) - - SSH.MinimumKeySizeCheck = sec.Key("MINIMUM_KEY_SIZE_CHECK").MustBool(SSH.MinimumKeySizeCheck) - minimumKeySizes := Cfg.Section("ssh.minimum_key_sizes").Keys() - for _, key := range minimumKeySizes { - if key.MustInt() != -1 { - SSH.MinimumKeySizes[strings.ToLower(key.Name())] = key.MustInt() - } else { - delete(SSH.MinimumKeySizes, strings.ToLower(key.Name())) - } - } - - SSH.AuthorizedKeysBackup = sec.Key("SSH_AUTHORIZED_KEYS_BACKUP").MustBool(true) - SSH.CreateAuthorizedKeysFile = sec.Key("SSH_CREATE_AUTHORIZED_KEYS_FILE").MustBool(true) - - SSH.AuthorizedPrincipalsBackup = false - SSH.CreateAuthorizedPrincipalsFile = false - if SSH.AuthorizedPrincipalsEnabled { - SSH.AuthorizedPrincipalsBackup = sec.Key("SSH_AUTHORIZED_PRINCIPALS_BACKUP").MustBool(true) - SSH.CreateAuthorizedPrincipalsFile = sec.Key("SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE").MustBool(true) - } - - SSH.ExposeAnonymous = sec.Key("SSH_EXPOSE_ANONYMOUS").MustBool(false) - SSH.AuthorizedKeysCommandTemplate = sec.Key("SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE").MustString(SSH.AuthorizedKeysCommandTemplate) - - SSH.AuthorizedKeysCommandTemplateTemplate = template.Must(template.New("").Parse(SSH.AuthorizedKeysCommandTemplate)) - - SSH.PerWriteTimeout = sec.Key("SSH_PER_WRITE_TIMEOUT").MustDuration(PerWriteTimeout) - SSH.PerWritePerKbTimeout = sec.Key("SSH_PER_WRITE_PER_KB_TIMEOUT").MustDuration(PerWritePerKbTimeout) - - if err = Cfg.Section("oauth2").MapTo(&OAuth2); err != nil { - log.Fatal("Failed to OAuth2 settings: %v", err) - return - } - - if !filepath.IsAbs(OAuth2.JWTSigningPrivateKeyFile) { - OAuth2.JWTSigningPrivateKeyFile = filepath.Join(AppDataPath, OAuth2.JWTSigningPrivateKeyFile) - } - - sec = Cfg.Section("admin") - Admin.DefaultEmailNotification = sec.Key("DEFAULT_EMAIL_NOTIFICATIONS").MustString("enabled") - - sec = Cfg.Section("security") - InstallLock = sec.Key("INSTALL_LOCK").MustBool(false) - LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7) - CookieUserName = sec.Key("COOKIE_USERNAME").MustString("gitea_awesome") - SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY") - if SecretKey == "" { - // FIXME: https://github.com/go-gitea/gitea/issues/16832 - // Until it supports rotating an existing secret key, we shouldn't move users off of the widely used default value - SecretKey = "!#@FDEWREWR&*(" //nolint:gosec - } - - CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_incredible") - - ReverseProxyAuthUser = sec.Key("REVERSE_PROXY_AUTHENTICATION_USER").MustString("X-WEBAUTH-USER") - ReverseProxyAuthEmail = sec.Key("REVERSE_PROXY_AUTHENTICATION_EMAIL").MustString("X-WEBAUTH-EMAIL") - ReverseProxyAuthFullName = sec.Key("REVERSE_PROXY_AUTHENTICATION_FULL_NAME").MustString("X-WEBAUTH-FULLNAME") - - ReverseProxyLimit = sec.Key("REVERSE_PROXY_LIMIT").MustInt(1) - ReverseProxyTrustedProxies = sec.Key("REVERSE_PROXY_TRUSTED_PROXIES").Strings(",") - if len(ReverseProxyTrustedProxies) == 0 { - ReverseProxyTrustedProxies = []string{"127.0.0.0/8", "::1/128"} - } - - MinPasswordLength = sec.Key("MIN_PASSWORD_LENGTH").MustInt(6) - ImportLocalPaths = sec.Key("IMPORT_LOCAL_PATHS").MustBool(false) - DisableGitHooks = sec.Key("DISABLE_GIT_HOOKS").MustBool(true) - DisableWebhooks = sec.Key("DISABLE_WEBHOOKS").MustBool(false) - OnlyAllowPushIfGiteaEnvironmentSet = sec.Key("ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET").MustBool(true) - PasswordHashAlgo = sec.Key("PASSWORD_HASH_ALGO").MustString("pbkdf2") - CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true) - PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false) - SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20) - - InternalToken = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN") - if InstallLock && InternalToken == "" { - // if Gitea has been installed but the InternalToken hasn't been generated (upgrade from an old release), we should generate - // some users do cluster deployment, they still depend on this auto-generating behavior. - generateSaveInternalToken() - } - - cfgdata := sec.Key("PASSWORD_COMPLEXITY").Strings(",") - if len(cfgdata) == 0 { - cfgdata = []string{"off"} - } - PasswordComplexity = make([]string, 0, len(cfgdata)) - for _, name := range cfgdata { - name := strings.ToLower(strings.Trim(name, `"`)) - if name != "" { - PasswordComplexity = append(PasswordComplexity, name) - } - } - - newAttachmentService() - newLFSService() + cfg.NameMapper = ini.SnackCase + return cfg +} - timeFormatKey := Cfg.Section("time").Key("FORMAT").MustString("") - if timeFormatKey != "" { - TimeFormat = map[string]string{ - "ANSIC": time.ANSIC, - "UnixDate": time.UnixDate, - "RubyDate": time.RubyDate, - "RFC822": time.RFC822, - "RFC822Z": time.RFC822Z, - "RFC850": time.RFC850, - "RFC1123": time.RFC1123, - "RFC1123Z": time.RFC1123Z, - "RFC3339": time.RFC3339, - "RFC3339Nano": time.RFC3339Nano, - "Kitchen": time.Kitchen, - "Stamp": time.Stamp, - "StampMilli": time.StampMilli, - "StampMicro": time.StampMicro, - "StampNano": time.StampNano, - }[timeFormatKey] - // When the TimeFormatKey does not exist in the previous map e.g.'2006-01-02 15:04:05' - if len(TimeFormat) == 0 { - TimeFormat = timeFormatKey - TestTimeFormat, _ := time.Parse(TimeFormat, TimeFormat) - if TestTimeFormat.Format(time.RFC3339) != "2006-01-02T15:04:05Z" { - log.Warn("Provided TimeFormat: %s does not create a fully specified date and time.", TimeFormat) - log.Warn("In order to display dates and times correctly please check your time format has 2006, 01, 02, 15, 04 and 05") - } - log.Trace("Custom TimeFormat: %s", TimeFormat) - } - } +// LoadCommonSettings loads common configurations from a configuration provider. +func LoadCommonSettings() { + loadCommonSettingsFrom(CfgProvider) +} - zone := Cfg.Section("time").Key("DEFAULT_UI_LOCATION").String() - if zone != "" { - DefaultUILocation, err = time.LoadLocation(zone) - if err != nil { - log.Fatal("Load time zone failed: %v", err) - } else { - log.Info("Default UI Location is %v", zone) - } - } - if DefaultUILocation == nil { - DefaultUILocation = time.Local - } +// loadCommonSettingsFrom loads common configurations from a configuration provider. +func loadCommonSettingsFrom(cfg ConfigProvider) { + // WARNNING: don't change the sequence except you know what you are doing. + loadRunModeFrom(cfg) + loadLogFrom(cfg) + loadServerFrom(cfg) + loadSSHFrom(cfg) + loadOAuth2From(cfg) + loadSecurityFrom(cfg) + loadAttachmentFrom(cfg) + loadLFSFrom(cfg) + loadTimeFrom(cfg) + loadRepositoryFrom(cfg) + loadPictureFrom(cfg) + loadPackagesFrom(cfg) + loadActionsFrom(cfg) + loadUIFrom(cfg) + loadAdminFrom(cfg) + loadAPIFrom(cfg) + loadMetricsFrom(cfg) + loadCamoFrom(cfg) + loadI18nFrom(cfg) + loadGitFrom(cfg) + loadMirrorFrom(cfg) + loadMarkupFrom(cfg) + loadOtherFrom(cfg) +} - RunUser = Cfg.Section("").Key("RUN_USER").MustString(user.CurrentUsername()) +func loadRunModeFrom(rootCfg ConfigProvider) { + rootSec := rootCfg.Section("") + RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername()) // The following is a purposefully undocumented option. Please do not run Gitea as root. It will only cause future headaches. // Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly. - unsafeAllowRunAsRoot := Cfg.Section("").Key("I_AM_BEING_UNSAFE_RUNNING_AS_ROOT").MustBool(false) + unsafeAllowRunAsRoot := rootSec.Key("I_AM_BEING_UNSAFE_RUNNING_AS_ROOT").MustBool(false) RunMode = os.Getenv("GITEA_RUN_MODE") if RunMode == "" { - RunMode = Cfg.Section("").Key("RUN_MODE").MustString("prod") + RunMode = rootSec.Key("RUN_MODE").MustString("prod") } IsProd = strings.EqualFold(RunMode, "prod") // Does not check run user when the install lock is off. - if InstallLock { + installLock := rootCfg.Section("security").Key("INSTALL_LOCK").MustBool(false) + if installLock { currentUser, match := IsRunUserMatchCurrentUser(RunUser) if !match { log.Fatal("Expect user '%s' but current user is: %s", RunUser, currentUser) @@ -1064,227 +331,6 @@ func loadFromConf(allowEmpty bool, extraConfig string) { } log.Critical("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.") } - - SSH.BuiltinServerUser = Cfg.Section("server").Key("BUILTIN_SSH_SERVER_USER").MustString(RunUser) - SSH.User = Cfg.Section("server").Key("SSH_USER").MustString(SSH.BuiltinServerUser) - - newRepository() - - newPictureService() - - newPackages() - - newActions() - - if err = Cfg.Section("ui").MapTo(&UI); err != nil { - log.Fatal("Failed to map UI settings: %v", err) - } else if err = Cfg.Section("markdown").MapTo(&Markdown); err != nil { - log.Fatal("Failed to map Markdown settings: %v", err) - } else if err = Cfg.Section("admin").MapTo(&Admin); err != nil { - log.Fatal("Fail to map Admin settings: %v", err) - } else if err = Cfg.Section("api").MapTo(&API); err != nil { - log.Fatal("Failed to map API settings: %v", err) - } else if err = Cfg.Section("metrics").MapTo(&Metrics); err != nil { - log.Fatal("Failed to map Metrics settings: %v", err) - } else if err = Cfg.Section("camo").MapTo(&Camo); err != nil { - log.Fatal("Failed to map Camo settings: %v", err) - } - - if Camo.Enabled { - if Camo.ServerURL == "" || Camo.HMACKey == "" { - log.Fatal(`Camo settings require "SERVER_URL" and HMAC_KEY`) - } - } - - u := *appURL - u.Path = path.Join(u.Path, "api", "swagger") - API.SwaggerURL = u.String() - - newGit() - - newMirror() - - Langs = Cfg.Section("i18n").Key("LANGS").Strings(",") - if len(Langs) == 0 { - Langs = defaultI18nLangs() - } - Names = Cfg.Section("i18n").Key("NAMES").Strings(",") - if len(Names) == 0 { - Names = defaultI18nNames() - } - - ShowFooterBranding = Cfg.Section("other").Key("SHOW_FOOTER_BRANDING").MustBool(false) - ShowFooterVersion = Cfg.Section("other").Key("SHOW_FOOTER_VERSION").MustBool(true) - ShowFooterTemplateLoadTime = Cfg.Section("other").Key("SHOW_FOOTER_TEMPLATE_LOAD_TIME").MustBool(true) - EnableSitemap = Cfg.Section("other").Key("ENABLE_SITEMAP").MustBool(true) - EnableFeed = Cfg.Section("other").Key("ENABLE_FEED").MustBool(true) - - UI.ShowUserEmail = Cfg.Section("ui").Key("SHOW_USER_EMAIL").MustBool(true) - UI.DefaultShowFullName = Cfg.Section("ui").Key("DEFAULT_SHOW_FULL_NAME").MustBool(false) - UI.SearchRepoDescription = Cfg.Section("ui").Key("SEARCH_REPO_DESCRIPTION").MustBool(true) - UI.UseServiceWorker = Cfg.Section("ui").Key("USE_SERVICE_WORKER").MustBool(false) - - HasRobotsTxt, err = util.IsFile(path.Join(CustomPath, "robots.txt")) - if err != nil { - log.Error("Unable to check if %s is a file. Error: %v", path.Join(CustomPath, "robots.txt"), err) - } - - newMarkup() - - UI.ReactionsLookup = make(container.Set[string]) - for _, reaction := range UI.Reactions { - UI.ReactionsLookup.Add(reaction) - } - UI.CustomEmojisMap = make(map[string]string) - for _, emoji := range UI.CustomEmojis { - UI.CustomEmojisMap[emoji] = ":" + emoji + ":" - } -} - -func parseAuthorizedPrincipalsAllow(values []string) ([]string, bool) { - anything := false - email := false - username := false - for _, value := range values { - v := strings.ToLower(strings.TrimSpace(value)) - switch v { - case "off": - return []string{"off"}, false - case "email": - email = true - case "username": - username = true - case "anything": - anything = true - } - } - if anything { - return []string{"anything"}, true - } - - authorizedPrincipalsAllow := []string{} - if username { - authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "username") - } - if email { - authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "email") - } - - return authorizedPrincipalsAllow, true -} - -// loadSecret load the secret from ini by uriKey or verbatimKey, only one of them could be set -// If the secret is loaded from uriKey (file), the file should be non-empty, to guarantee the behavior stable and clear. -func loadSecret(sec *ini.Section, uriKey, verbatimKey string) string { - // don't allow setting both URI and verbatim string - uri := sec.Key(uriKey).String() - verbatim := sec.Key(verbatimKey).String() - if uri != "" && verbatim != "" { - log.Fatal("Cannot specify both %s and %s", uriKey, verbatimKey) - } - - // if we have no URI, use verbatim - if uri == "" { - return verbatim - } - - tempURI, err := url.Parse(uri) - if err != nil { - log.Fatal("Failed to parse %s (%s): %v", uriKey, uri, err) - } - switch tempURI.Scheme { - case "file": - buf, err := os.ReadFile(tempURI.RequestURI()) - if err != nil { - log.Fatal("Failed to read %s (%s): %v", uriKey, tempURI.RequestURI(), err) - } - val := strings.TrimSpace(string(buf)) - if val == "" { - // The file shouldn't be empty, otherwise we can not know whether the user has ever set the KEY or KEY_URI - // For example: if INTERNAL_TOKEN_URI=file:///empty-file, - // Then if the token is re-generated during installation and saved to INTERNAL_TOKEN - // Then INTERNAL_TOKEN and INTERNAL_TOKEN_URI both exist, that's a fatal error (they shouldn't) - log.Fatal("Failed to read %s (%s): the file is empty", uriKey, tempURI.RequestURI()) - } - return val - - // only file URIs are allowed - default: - log.Fatal("Unsupported URI-Scheme %q (INTERNAL_TOKEN_URI = %q)", tempURI.Scheme, uri) - return "" - } -} - -// generateSaveInternalToken generates and saves the internal token to app.ini -func generateSaveInternalToken() { - token, err := generate.NewInternalToken() - if err != nil { - log.Fatal("Error generate internal token: %v", err) - } - - InternalToken = token - CreateOrAppendToCustomConf("security.INTERNAL_TOKEN", func(cfg *ini.File) { - cfg.Section("security").Key("INTERNAL_TOKEN").SetValue(token) - }) -} - -// MakeAbsoluteAssetURL returns the absolute asset url prefix without a trailing slash -func MakeAbsoluteAssetURL(appURL, staticURLPrefix string) string { - parsedPrefix, err := url.Parse(strings.TrimSuffix(staticURLPrefix, "/")) - if err != nil { - log.Fatal("Unable to parse STATIC_URL_PREFIX: %v", err) - } - - if err == nil && parsedPrefix.Hostname() == "" { - if staticURLPrefix == "" { - return strings.TrimSuffix(appURL, "/") - } - - // StaticURLPrefix is just a path - return util.URLJoin(appURL, strings.TrimSuffix(staticURLPrefix, "/")) - } - - return strings.TrimSuffix(staticURLPrefix, "/") -} - -// MakeManifestData generates web app manifest JSON -func MakeManifestData(appName, appURL, absoluteAssetURL string) []byte { - type manifestIcon struct { - Src string `json:"src"` - Type string `json:"type"` - Sizes string `json:"sizes"` - } - - type manifestJSON struct { - Name string `json:"name"` - ShortName string `json:"short_name"` - StartURL string `json:"start_url"` - Icons []manifestIcon `json:"icons"` - } - - bytes, err := json.Marshal(&manifestJSON{ - Name: appName, - ShortName: appName, - StartURL: appURL, - Icons: []manifestIcon{ - { - Src: absoluteAssetURL + "/assets/img/logo.png", - Type: "image/png", - Sizes: "512x512", - }, - { - Src: absoluteAssetURL + "/assets/img/logo.svg", - Type: "image/svg+xml", - Sizes: "512x512", - }, - }, - }) - if err != nil { - log.Error("unable to marshal manifest JSON. Error: %v", err) - return make([]byte, 0) - } - - return bytes } // CreateOrAppendToCustomConf creates or updates the custom config. @@ -1332,32 +378,30 @@ func CreateOrAppendToCustomConf(purpose string, callback func(cfg *ini.File)) { } } -// NewServices initializes the services -func NewServices() { - InitDBConfig() - newService() - newOAuth2Client() - NewLogServices(false) - newCacheService() - newSessionService() - newCORSService() - parseMailerConfig(Cfg) - newIncomingEmail() - newRegisterMailService() - newNotifyMailService() - newProxyService() - newWebhookService() - newMigrationsService() - newIndexerService() - newTaskService() - NewQueueService() - newProject() - newMimeTypeMap() - newFederationService() +// LoadSettings initializes the settings for normal start up +func LoadSettings() { + LoadDBSetting() + loadServiceFrom(CfgProvider) + loadOAuth2ClientFrom(CfgProvider) + InitLogs(false) + loadCacheFrom(CfgProvider) + loadSessionFrom(CfgProvider) + loadCorsFrom(CfgProvider) + loadMailsFrom(CfgProvider) + loadProxyFrom(CfgProvider) + loadWebhookFrom(CfgProvider) + loadMigrationsFrom(CfgProvider) + loadIndexerFrom(CfgProvider) + loadTaskFrom(CfgProvider) + LoadQueueSettings() + loadProjectFrom(CfgProvider) + loadMimeTypeMapFrom(CfgProvider) + loadFederationFrom(CfgProvider) } -// NewServicesForInstall initializes the services for install -func NewServicesForInstall() { - newService() - parseMailerConfig(Cfg) +// LoadSettingsForInstall initializes the settings for install +func LoadSettingsForInstall() { + LoadDBSetting() + loadServiceFrom(CfgProvider) + loadMailerFrom(CfgProvider) } diff --git a/modules/setting/ssh.go b/modules/setting/ssh.go new file mode 100644 index 000000000..e8796f98d --- /dev/null +++ b/modules/setting/ssh.go @@ -0,0 +1,197 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "os" + "path" + "path/filepath" + "strings" + "text/template" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + + gossh "golang.org/x/crypto/ssh" +) + +var SSH = struct { + Disabled bool `ini:"DISABLE_SSH"` + StartBuiltinServer bool `ini:"START_SSH_SERVER"` + BuiltinServerUser string `ini:"BUILTIN_SSH_SERVER_USER"` + UseProxyProtocol bool `ini:"SSH_SERVER_USE_PROXY_PROTOCOL"` + Domain string `ini:"SSH_DOMAIN"` + Port int `ini:"SSH_PORT"` + User string `ini:"SSH_USER"` + ListenHost string `ini:"SSH_LISTEN_HOST"` + ListenPort int `ini:"SSH_LISTEN_PORT"` + RootPath string `ini:"SSH_ROOT_PATH"` + ServerCiphers []string `ini:"SSH_SERVER_CIPHERS"` + ServerKeyExchanges []string `ini:"SSH_SERVER_KEY_EXCHANGES"` + ServerMACs []string `ini:"SSH_SERVER_MACS"` + ServerHostKeys []string `ini:"SSH_SERVER_HOST_KEYS"` + KeyTestPath string `ini:"SSH_KEY_TEST_PATH"` + KeygenPath string `ini:"SSH_KEYGEN_PATH"` + AuthorizedKeysBackup bool `ini:"SSH_AUTHORIZED_KEYS_BACKUP"` + AuthorizedPrincipalsBackup bool `ini:"SSH_AUTHORIZED_PRINCIPALS_BACKUP"` + AuthorizedKeysCommandTemplate string `ini:"SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE"` + AuthorizedKeysCommandTemplateTemplate *template.Template `ini:"-"` + MinimumKeySizeCheck bool `ini:"-"` + MinimumKeySizes map[string]int `ini:"-"` + CreateAuthorizedKeysFile bool `ini:"SSH_CREATE_AUTHORIZED_KEYS_FILE"` + CreateAuthorizedPrincipalsFile bool `ini:"SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE"` + ExposeAnonymous bool `ini:"SSH_EXPOSE_ANONYMOUS"` + AuthorizedPrincipalsAllow []string `ini:"SSH_AUTHORIZED_PRINCIPALS_ALLOW"` + AuthorizedPrincipalsEnabled bool `ini:"-"` + TrustedUserCAKeys []string `ini:"SSH_TRUSTED_USER_CA_KEYS"` + TrustedUserCAKeysFile string `ini:"SSH_TRUSTED_USER_CA_KEYS_FILENAME"` + TrustedUserCAKeysParsed []gossh.PublicKey `ini:"-"` + PerWriteTimeout time.Duration `ini:"SSH_PER_WRITE_TIMEOUT"` + PerWritePerKbTimeout time.Duration `ini:"SSH_PER_WRITE_PER_KB_TIMEOUT"` +}{ + Disabled: false, + StartBuiltinServer: false, + Domain: "", + Port: 22, + ServerCiphers: []string{"chacha20-poly1305@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com"}, + ServerKeyExchanges: []string{"curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1"}, + ServerMACs: []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256", "hmac-sha1"}, + KeygenPath: "ssh-keygen", + MinimumKeySizeCheck: true, + MinimumKeySizes: map[string]int{"ed25519": 256, "ed25519-sk": 256, "ecdsa": 256, "ecdsa-sk": 256, "rsa": 2047}, + ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gogs.rsa"}, + AuthorizedKeysCommandTemplate: "{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}", + PerWriteTimeout: PerWriteTimeout, + PerWritePerKbTimeout: PerWritePerKbTimeout, +} + +func parseAuthorizedPrincipalsAllow(values []string) ([]string, bool) { + anything := false + email := false + username := false + for _, value := range values { + v := strings.ToLower(strings.TrimSpace(value)) + switch v { + case "off": + return []string{"off"}, false + case "email": + email = true + case "username": + username = true + case "anything": + anything = true + } + } + if anything { + return []string{"anything"}, true + } + + authorizedPrincipalsAllow := []string{} + if username { + authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "username") + } + if email { + authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "email") + } + + return authorizedPrincipalsAllow, true +} + +func loadSSHFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("server") + if len(SSH.Domain) == 0 { + SSH.Domain = Domain + } + + homeDir, err := util.HomeDir() + if err != nil { + log.Fatal("Failed to get home directory: %v", err) + } + homeDir = strings.ReplaceAll(homeDir, "\\", "/") + + SSH.RootPath = path.Join(homeDir, ".ssh") + serverCiphers := sec.Key("SSH_SERVER_CIPHERS").Strings(",") + if len(serverCiphers) > 0 { + SSH.ServerCiphers = serverCiphers + } + serverKeyExchanges := sec.Key("SSH_SERVER_KEY_EXCHANGES").Strings(",") + if len(serverKeyExchanges) > 0 { + SSH.ServerKeyExchanges = serverKeyExchanges + } + serverMACs := sec.Key("SSH_SERVER_MACS").Strings(",") + if len(serverMACs) > 0 { + SSH.ServerMACs = serverMACs + } + SSH.KeyTestPath = os.TempDir() + if err = sec.MapTo(&SSH); err != nil { + log.Fatal("Failed to map SSH settings: %v", err) + } + for i, key := range SSH.ServerHostKeys { + if !filepath.IsAbs(key) { + SSH.ServerHostKeys[i] = filepath.Join(AppDataPath, key) + } + } + + SSH.KeygenPath = sec.Key("SSH_KEYGEN_PATH").MustString("ssh-keygen") + SSH.Port = sec.Key("SSH_PORT").MustInt(22) + SSH.ListenPort = sec.Key("SSH_LISTEN_PORT").MustInt(SSH.Port) + SSH.UseProxyProtocol = sec.Key("SSH_SERVER_USE_PROXY_PROTOCOL").MustBool(false) + + // When disable SSH, start builtin server value is ignored. + if SSH.Disabled { + SSH.StartBuiltinServer = false + } + + SSH.TrustedUserCAKeysFile = sec.Key("SSH_TRUSTED_USER_CA_KEYS_FILENAME").MustString(filepath.Join(SSH.RootPath, "gitea-trusted-user-ca-keys.pem")) + + for _, caKey := range SSH.TrustedUserCAKeys { + pubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(caKey)) + if err != nil { + log.Fatal("Failed to parse TrustedUserCaKeys: %s %v", caKey, err) + } + + SSH.TrustedUserCAKeysParsed = append(SSH.TrustedUserCAKeysParsed, pubKey) + } + if len(SSH.TrustedUserCAKeys) > 0 { + // Set the default as email,username otherwise we can leave it empty + sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("username,email") + } else { + sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("off") + } + + SSH.AuthorizedPrincipalsAllow, SSH.AuthorizedPrincipalsEnabled = parseAuthorizedPrincipalsAllow(sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").Strings(",")) + + SSH.MinimumKeySizeCheck = sec.Key("MINIMUM_KEY_SIZE_CHECK").MustBool(SSH.MinimumKeySizeCheck) + minimumKeySizes := rootCfg.Section("ssh.minimum_key_sizes").Keys() + for _, key := range minimumKeySizes { + if key.MustInt() != -1 { + SSH.MinimumKeySizes[strings.ToLower(key.Name())] = key.MustInt() + } else { + delete(SSH.MinimumKeySizes, strings.ToLower(key.Name())) + } + } + + SSH.AuthorizedKeysBackup = sec.Key("SSH_AUTHORIZED_KEYS_BACKUP").MustBool(true) + SSH.CreateAuthorizedKeysFile = sec.Key("SSH_CREATE_AUTHORIZED_KEYS_FILE").MustBool(true) + + SSH.AuthorizedPrincipalsBackup = false + SSH.CreateAuthorizedPrincipalsFile = false + if SSH.AuthorizedPrincipalsEnabled { + SSH.AuthorizedPrincipalsBackup = sec.Key("SSH_AUTHORIZED_PRINCIPALS_BACKUP").MustBool(true) + SSH.CreateAuthorizedPrincipalsFile = sec.Key("SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE").MustBool(true) + } + + SSH.ExposeAnonymous = sec.Key("SSH_EXPOSE_ANONYMOUS").MustBool(false) + SSH.AuthorizedKeysCommandTemplate = sec.Key("SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE").MustString(SSH.AuthorizedKeysCommandTemplate) + + SSH.AuthorizedKeysCommandTemplateTemplate = template.Must(template.New("").Parse(SSH.AuthorizedKeysCommandTemplate)) + + SSH.PerWriteTimeout = sec.Key("SSH_PER_WRITE_TIMEOUT").MustDuration(PerWriteTimeout) + SSH.PerWritePerKbTimeout = sec.Key("SSH_PER_WRITE_PER_KB_TIMEOUT").MustDuration(PerWritePerKbTimeout) + + // ensure parseRunModeSetting has been executed before this + SSH.BuiltinServerUser = rootCfg.Section("server").Key("BUILTIN_SSH_SERVER_USER").MustString(RunUser) + SSH.User = rootCfg.Section("server").Key("SSH_USER").MustString(SSH.BuiltinServerUser) +} diff --git a/modules/setting/storage.go b/modules/setting/storage.go index 32f74aa07..9197c5f8b 100644 --- a/modules/setting/storage.go +++ b/modules/setting/storage.go @@ -30,9 +30,9 @@ func (s *Storage) MapTo(v interface{}) error { return nil } -func getStorage(name, typ string, targetSec *ini.Section) Storage { +func getStorage(rootCfg ConfigProvider, name, typ string, targetSec *ini.Section) Storage { const sectionName = "storage" - sec := Cfg.Section(sectionName) + sec := rootCfg.Section(sectionName) // Global Defaults sec.Key("MINIO_ENDPOINT").MustString("localhost:9000") @@ -43,7 +43,7 @@ func getStorage(name, typ string, targetSec *ini.Section) Storage { sec.Key("MINIO_USE_SSL").MustBool(false) if targetSec == nil { - targetSec, _ = Cfg.NewSection(name) + targetSec, _ = rootCfg.NewSection(name) } var storage Storage @@ -51,12 +51,12 @@ func getStorage(name, typ string, targetSec *ini.Section) Storage { storage.Type = typ overrides := make([]*ini.Section, 0, 3) - nameSec, err := Cfg.GetSection(sectionName + "." + name) + nameSec, err := rootCfg.GetSection(sectionName + "." + name) if err == nil { overrides = append(overrides, nameSec) } - typeSec, err := Cfg.GetSection(sectionName + "." + typ) + typeSec, err := rootCfg.GetSection(sectionName + "." + typ) if err == nil { overrides = append(overrides, typeSec) nextType := typeSec.Key("STORAGE_TYPE").String() diff --git a/modules/setting/storage_test.go b/modules/setting/storage_test.go index 256bbb7a5..7737d233b 100644 --- a/modules/setting/storage_test.go +++ b/modules/setting/storage_test.go @@ -20,11 +20,12 @@ MINIO_BUCKET = gitea-attachment STORAGE_TYPE = minio MINIO_ENDPOINT = my_minio:9000 ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "minio", storage.Type) assert.EqualValues(t, "my_minio:9000", storage.Section.Key("MINIO_ENDPOINT").String()) @@ -42,11 +43,12 @@ MINIO_BUCKET = gitea-attachment [storage.minio] MINIO_BUCKET = gitea ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "minio", storage.Type) assert.EqualValues(t, "gitea-attachment", storage.Section.Key("MINIO_BUCKET").String()) @@ -63,11 +65,12 @@ MINIO_BUCKET = gitea-minio [storage] MINIO_BUCKET = gitea ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "minio", storage.Type) assert.EqualValues(t, "gitea-minio", storage.Section.Key("MINIO_BUCKET").String()) @@ -85,22 +88,24 @@ MINIO_BUCKET = gitea [storage] STORAGE_TYPE = local ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "minio", storage.Type) assert.EqualValues(t, "gitea-attachment", storage.Section.Key("MINIO_BUCKET").String()) } func Test_getStorageGetDefaults(t *testing.T) { - Cfg, _ = ini.Load([]byte("")) + cfg, err := ini.Load([]byte("")) + assert.NoError(t, err) - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "gitea", storage.Section.Key("MINIO_BUCKET").String()) } @@ -116,26 +121,27 @@ MINIO_BUCKET = gitea-attachment [storage] MINIO_BUCKET = gitea-storage ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) { - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "gitea-attachment", storage.Section.Key("MINIO_BUCKET").String()) } { - sec := Cfg.Section("lfs") + sec := cfg.Section("lfs") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("lfs", storageType, sec) + storage := getStorage(cfg, "lfs", storageType, sec) assert.EqualValues(t, "gitea-lfs", storage.Section.Key("MINIO_BUCKET").String()) } { - sec := Cfg.Section("avatar") + sec := cfg.Section("avatar") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("avatars", storageType, sec) + storage := getStorage(cfg, "avatars", storageType, sec) assert.EqualValues(t, "gitea-storage", storage.Section.Key("MINIO_BUCKET").String()) } @@ -149,19 +155,20 @@ STORAGE_TYPE = lfs [storage.lfs] MINIO_BUCKET = gitea-storage ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) { - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "gitea-storage", storage.Section.Key("MINIO_BUCKET").String()) } { - sec := Cfg.Section("lfs") + sec := cfg.Section("lfs") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("lfs", storageType, sec) + storage := getStorage(cfg, "lfs", storageType, sec) assert.EqualValues(t, "gitea-storage", storage.Section.Key("MINIO_BUCKET").String()) } @@ -172,11 +179,12 @@ func Test_getStorageInheritStorageType(t *testing.T) { [storage] STORAGE_TYPE = minio ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "minio", storage.Type) } @@ -186,11 +194,12 @@ func Test_getStorageInheritNameSectionType(t *testing.T) { [storage.attachments] STORAGE_TYPE = minio ` - Cfg, _ = ini.Load([]byte(iniStr)) + cfg, err := ini.Load([]byte(iniStr)) + assert.NoError(t, err) - sec := Cfg.Section("attachment") + sec := cfg.Section("attachment") storageType := sec.Key("STORAGE_TYPE").MustString("") - storage := getStorage("attachments", storageType, sec) + storage := getStorage(cfg, "attachments", storageType, sec) assert.EqualValues(t, "minio", storage.Type) } diff --git a/modules/setting/task.go b/modules/setting/task.go index cfb0f5466..81732deeb 100644 --- a/modules/setting/task.go +++ b/modules/setting/task.go @@ -5,13 +5,13 @@ package setting // FIXME: DEPRECATED to be removed in v1.18.0 // - will need to set default for [queue.task] LENGTH to 1000 though -func newTaskService() { - taskSec := Cfg.Section("task") - queueTaskSec := Cfg.Section("queue.task") +func loadTaskFrom(rootCfg ConfigProvider) { + taskSec := rootCfg.Section("task") + queueTaskSec := rootCfg.Section("queue.task") - deprecatedSetting("task", "QUEUE_TYPE", "queue.task", "TYPE") - deprecatedSetting("task", "QUEUE_CONN_STR", "queue.task", "CONN_STR") - deprecatedSetting("task", "QUEUE_LENGTH", "queue.task", "LENGTH") + deprecatedSetting(rootCfg, "task", "QUEUE_TYPE", "queue.task", "TYPE") + deprecatedSetting(rootCfg, "task", "QUEUE_CONN_STR", "queue.task", "CONN_STR") + deprecatedSetting(rootCfg, "task", "QUEUE_LENGTH", "queue.task", "LENGTH") switch taskSec.Key("QUEUE_TYPE").MustString("channel") { case "channel": diff --git a/modules/setting/time.go b/modules/setting/time.go new file mode 100644 index 000000000..5fd0fdb92 --- /dev/null +++ b/modules/setting/time.go @@ -0,0 +1,64 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "time" + + "code.gitea.io/gitea/modules/log" +) + +var ( + // Time settings + TimeFormat string + // UILocation is the location on the UI, so that we can display the time on UI. + DefaultUILocation = time.Local +) + +func loadTimeFrom(rootCfg ConfigProvider) { + timeFormatKey := rootCfg.Section("time").Key("FORMAT").MustString("") + if timeFormatKey != "" { + TimeFormat = map[string]string{ + "ANSIC": time.ANSIC, + "UnixDate": time.UnixDate, + "RubyDate": time.RubyDate, + "RFC822": time.RFC822, + "RFC822Z": time.RFC822Z, + "RFC850": time.RFC850, + "RFC1123": time.RFC1123, + "RFC1123Z": time.RFC1123Z, + "RFC3339": time.RFC3339, + "RFC3339Nano": time.RFC3339Nano, + "Kitchen": time.Kitchen, + "Stamp": time.Stamp, + "StampMilli": time.StampMilli, + "StampMicro": time.StampMicro, + "StampNano": time.StampNano, + }[timeFormatKey] + // When the TimeFormatKey does not exist in the previous map e.g.'2006-01-02 15:04:05' + if len(TimeFormat) == 0 { + TimeFormat = timeFormatKey + TestTimeFormat, _ := time.Parse(TimeFormat, TimeFormat) + if TestTimeFormat.Format(time.RFC3339) != "2006-01-02T15:04:05Z" { + log.Warn("Provided TimeFormat: %s does not create a fully specified date and time.", TimeFormat) + log.Warn("In order to display dates and times correctly please check your time format has 2006, 01, 02, 15, 04 and 05") + } + log.Trace("Custom TimeFormat: %s", TimeFormat) + } + } + + zone := rootCfg.Section("time").Key("DEFAULT_UI_LOCATION").String() + if zone != "" { + var err error + DefaultUILocation, err = time.LoadLocation(zone) + if err != nil { + log.Fatal("Load time zone failed: %v", err) + } else { + log.Info("Default UI Location is %v", zone) + } + } + if DefaultUILocation == nil { + DefaultUILocation = time.Local + } +} diff --git a/modules/setting/ui.go b/modules/setting/ui.go new file mode 100644 index 000000000..2df3c35c7 --- /dev/null +++ b/modules/setting/ui.go @@ -0,0 +1,152 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "time" + + "code.gitea.io/gitea/modules/container" +) + +// UI settings +var UI = struct { + ExplorePagingNum int + SitemapPagingNum int + IssuePagingNum int + RepoSearchPagingNum int + MembersPagingNum int + FeedMaxCommitNum int + FeedPagingNum int + PackagesPagingNum int + GraphMaxCommitNum int + CodeCommentLines int + ReactionMaxUserNum int + ThemeColorMetaTag string + MaxDisplayFileSize int64 + ShowUserEmail bool + DefaultShowFullName bool + DefaultTheme string + Themes []string + Reactions []string + ReactionsLookup container.Set[string] `ini:"-"` + CustomEmojis []string + CustomEmojisMap map[string]string `ini:"-"` + SearchRepoDescription bool + UseServiceWorker bool + OnlyShowRelevantRepos bool + + Notification struct { + MinTimeout time.Duration + TimeoutStep time.Duration + MaxTimeout time.Duration + EventSourceUpdateTime time.Duration + } `ini:"ui.notification"` + + SVG struct { + Enabled bool `ini:"ENABLE_RENDER"` + } `ini:"ui.svg"` + + CSV struct { + MaxFileSize int64 + } `ini:"ui.csv"` + + Admin struct { + UserPagingNum int + RepoPagingNum int + NoticePagingNum int + OrgPagingNum int + } `ini:"ui.admin"` + User struct { + RepoPagingNum int + } `ini:"ui.user"` + Meta struct { + Author string + Description string + Keywords string + } `ini:"ui.meta"` +}{ + ExplorePagingNum: 20, + SitemapPagingNum: 20, + IssuePagingNum: 20, + RepoSearchPagingNum: 20, + MembersPagingNum: 20, + FeedMaxCommitNum: 5, + FeedPagingNum: 20, + PackagesPagingNum: 20, + GraphMaxCommitNum: 100, + CodeCommentLines: 4, + ReactionMaxUserNum: 10, + ThemeColorMetaTag: `#6cc644`, + MaxDisplayFileSize: 8388608, + DefaultTheme: `auto`, + Themes: []string{`auto`, `gitea`, `arc-green`}, + Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, + CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`}, + CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"}, + Notification: struct { + MinTimeout time.Duration + TimeoutStep time.Duration + MaxTimeout time.Duration + EventSourceUpdateTime time.Duration + }{ + MinTimeout: 10 * time.Second, + TimeoutStep: 10 * time.Second, + MaxTimeout: 60 * time.Second, + EventSourceUpdateTime: 10 * time.Second, + }, + SVG: struct { + Enabled bool `ini:"ENABLE_RENDER"` + }{ + Enabled: true, + }, + CSV: struct { + MaxFileSize int64 + }{ + MaxFileSize: 524288, + }, + Admin: struct { + UserPagingNum int + RepoPagingNum int + NoticePagingNum int + OrgPagingNum int + }{ + UserPagingNum: 50, + RepoPagingNum: 50, + NoticePagingNum: 25, + OrgPagingNum: 50, + }, + User: struct { + RepoPagingNum int + }{ + RepoPagingNum: 15, + }, + Meta: struct { + Author string + Description string + Keywords string + }{ + Author: "Gitea - Git with a cup of tea", + Description: "Gitea (Git with a cup of tea) is a painless self-hosted Git service written in Go", + Keywords: "go,git,self-hosted,gitea", + }, +} + +func loadUIFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "ui", &UI) + sec := rootCfg.Section("ui") + UI.ShowUserEmail = sec.Key("SHOW_USER_EMAIL").MustBool(true) + UI.DefaultShowFullName = sec.Key("DEFAULT_SHOW_FULL_NAME").MustBool(false) + UI.SearchRepoDescription = sec.Key("SEARCH_REPO_DESCRIPTION").MustBool(true) + UI.UseServiceWorker = sec.Key("USE_SERVICE_WORKER").MustBool(false) + UI.OnlyShowRelevantRepos = sec.Key("ONLY_SHOW_RELEVANT_REPOS").MustBool(false) + + UI.ReactionsLookup = make(container.Set[string]) + for _, reaction := range UI.Reactions { + UI.ReactionsLookup.Add(reaction) + } + UI.CustomEmojisMap = make(map[string]string) + for _, emoji := range UI.CustomEmojis { + UI.CustomEmojisMap[emoji] = ":" + emoji + ":" + } +} diff --git a/modules/setting/webhook.go b/modules/setting/webhook.go index 51e36c341..c01261dbb 100644 --- a/modules/setting/webhook.go +++ b/modules/setting/webhook.go @@ -29,8 +29,8 @@ var Webhook = struct { ProxyHosts: []string{}, } -func newWebhookService() { - sec := Cfg.Section("webhook") +func loadWebhookFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("webhook") Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000) Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5) Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool() diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index 0739653ee..4d679c81d 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -4,6 +4,8 @@ package structs +import "time" + // CreateUserOption create user options type CreateUserOption struct { SourceID int64 `json:"source_id"` @@ -20,6 +22,11 @@ type CreateUserOption struct { SendNotify bool `json:"send_notify"` Restricted *bool `json:"restricted"` Visibility string `json:"visibility" binding:"In(,public,limited,private)"` + + // For explicitly setting the user creation timestamp. Useful when users are + // migrated from other systems. When omitted, the user's creation timestamp + // will be set to "now". + Created *time.Time `json:"created_at"` } // EditUserOption edit user options diff --git a/modules/structs/issue_label.go b/modules/structs/issue_label.go index 5c622797f..5bb6cc3b8 100644 --- a/modules/structs/issue_label.go +++ b/modules/structs/issue_label.go @@ -9,6 +9,8 @@ package structs type Label struct { ID int64 `json:"id"` Name string `json:"name"` + // example: false + Exclusive bool `json:"exclusive"` // example: 00aabb Color string `json:"color"` Description string `json:"description"` @@ -19,6 +21,8 @@ type Label struct { type CreateLabelOption struct { // required:true Name string `json:"name" binding:"Required"` + // example: false + Exclusive bool `json:"exclusive"` // required:true // example: #00aabb Color string `json:"color" binding:"Required"` @@ -27,7 +31,10 @@ type CreateLabelOption struct { // EditLabelOption options for editing a label type EditLabelOption struct { - Name *string `json:"name"` + Name *string `json:"name"` + // example: false + Exclusive *bool `json:"exclusive"` + // example: #00aabb Color *string `json:"color"` Description *string `json:"description"` } diff --git a/modules/structs/repo.go b/modules/structs/repo.go index ee4bec4df..b5a26a815 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -96,6 +96,7 @@ type Repository struct { AllowRebaseUpdate bool `json:"allow_rebase_update"` DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"` DefaultMergeStyle string `json:"default_merge_style"` + DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit"` AvatarURL string `json:"avatar_url"` Internal bool `json:"internal"` MirrorInterval string `json:"mirror_interval"` @@ -187,6 +188,8 @@ type EditRepoOption struct { DefaultDeleteBranchAfterMerge *bool `json:"default_delete_branch_after_merge,omitempty"` // set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", or "squash". DefaultMergeStyle *string `json:"default_merge_style,omitempty"` + // set to `true` to allow edits from maintainers by default + DefaultAllowMaintainerEdit *bool `json:"default_allow_maintainer_edit,omitempty"` // set to `true` to archive this repository. Archived *bool `json:"archived,omitempty"` // set to a string like `8h30m0s` to set the mirror interval time diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 7afc3aa59..4ffd0a5de 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -7,10 +7,12 @@ package templates import ( "bytes" "context" + "encoding/hex" "errors" "fmt" "html" "html/template" + "math" "mime" "net/url" "path/filepath" @@ -25,7 +27,6 @@ import ( activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/avatars" - "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" @@ -90,8 +91,8 @@ func NewFuncMap() []template.FuncMap { "AssetVersion": func() string { return setting.AssetVersion }, - "DisableGravatar": func() bool { - return system_model.GetSettingBool(system_model.KeyPictureDisableGravatar) + "DisableGravatar": func(ctx context.Context) bool { + return system_model.GetSettingBool(ctx, system_model.KeyPictureDisableGravatar) }, "DefaultShowFullName": func() bool { return setting.UI.DefaultShowFullName @@ -383,6 +384,9 @@ func NewFuncMap() []template.FuncMap { // the table is NOT sorted with this header return "" }, + "RenderLabel": func(label *issues_model.Label) template.HTML { + return template.HTML(RenderLabel(label)) + }, "RenderLabels": func(labels []*issues_model.Label, repoLink string) template.HTML { htmlCode := `<span class="labels-list">` for _, label := range labels { @@ -390,8 +394,8 @@ func NewFuncMap() []template.FuncMap { if label == nil { continue } - htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d' class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</a> ", - repoLink, label.ID, label.ForegroundColor(), label.Color, html.EscapeString(label.Description), RenderEmoji(label.Name)) + htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ", + repoLink, label.ID, RenderLabel(label)) } htmlCode += "</span>" return template.HTML(htmlCode) @@ -613,22 +617,22 @@ func AvatarHTML(src string, size int, class, name string) template.HTML { } // Avatar renders user avatars. args: user, size (int), class (string) -func Avatar(item interface{}, others ...interface{}) template.HTML { +func Avatar(ctx context.Context, item interface{}, others ...interface{}) template.HTML { size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) switch t := item.(type) { case *user_model.User: - src := t.AvatarLinkWithSize(size * setting.Avatar.RenderedSizeFactor) + src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) if src != "" { return AvatarHTML(src, size, class, t.DisplayName()) } case *repo_model.Collaborator: - src := t.AvatarLinkWithSize(size * setting.Avatar.RenderedSizeFactor) + src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) if src != "" { return AvatarHTML(src, size, class, t.DisplayName()) } case *organization.Organization: - src := t.AsUser().AvatarLinkWithSize(size * setting.Avatar.RenderedSizeFactor) + src := t.AsUser().AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) if src != "" { return AvatarHTML(src, size, class, t.AsUser().DisplayName()) } @@ -638,9 +642,9 @@ func Avatar(item interface{}, others ...interface{}) template.HTML { } // AvatarByAction renders user avatars from action. args: action, size (int), class (string) -func AvatarByAction(action *activities_model.Action, others ...interface{}) template.HTML { - action.LoadActUser(db.DefaultContext) - return Avatar(action.ActUser, others...) +func AvatarByAction(ctx context.Context, action *activities_model.Action, others ...interface{}) template.HTML { + action.LoadActUser(ctx) + return Avatar(ctx, action.ActUser, others...) } // RepoAvatar renders repo avatars. args: repo, size(int), class (string) @@ -655,9 +659,9 @@ func RepoAvatar(repo *repo_model.Repository, others ...interface{}) template.HTM } // AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string) -func AvatarByEmail(email, name string, others ...interface{}) template.HTML { +func AvatarByEmail(ctx context.Context, email, name string, others ...interface{}) template.HTML { size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) - src := avatars.GenerateEmailAvatarFastLink(email, size*setting.Avatar.RenderedSizeFactor) + src := avatars.GenerateEmailAvatarFastLink(ctx, email, size*setting.Avatar.RenderedSizeFactor) if src != "" { return AvatarHTML(src, size, class, name) @@ -802,6 +806,67 @@ func RenderIssueTitle(ctx context.Context, text, urlPrefix string, metas map[str return template.HTML(renderedText) } +// RenderLabel renders a label +func RenderLabel(label *issues_model.Label) string { + labelScope := label.ExclusiveScope() + + textColor := "#111" + if label.UseLightTextColor() { + textColor = "#eee" + } + + description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) + + if labelScope == "" { + // Regular label + return fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</div>", + textColor, label.Color, description, RenderEmoji(label.Name)) + } + + // Scoped label + scopeText := RenderEmoji(labelScope) + itemText := RenderEmoji(label.Name[len(labelScope)+1:]) + + itemColor := label.Color + scopeColor := label.Color + if r, g, b, err := label.ColorRGB(); err == nil { + // Make scope and item background colors slightly darker and lighter respectively. + // More contrast needed with higher luminance, empirically tweaked. + luminance := (0.299*r + 0.587*g + 0.114*b) / 255 + contrast := 0.01 + luminance*0.06 + // Ensure we add the same amount of contrast also near 0 and 1. + darken := contrast + math.Max(luminance+contrast-1.0, 0.0) + lighten := contrast + math.Max(contrast-luminance, 0.0) + // Compute factor to keep RGB values proportional. + darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) + lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) + + scopeBytes := []byte{ + uint8(math.Min(math.Round(r*darkenFactor), 255)), + uint8(math.Min(math.Round(g*darkenFactor), 255)), + uint8(math.Min(math.Round(b*darkenFactor), 255)), + } + itemBytes := []byte{ + uint8(math.Min(math.Round(r*lightenFactor), 255)), + uint8(math.Min(math.Round(g*lightenFactor), 255)), + uint8(math.Min(math.Round(b*lightenFactor), 255)), + } + + itemColor = "#" + hex.EncodeToString(itemBytes) + scopeColor = "#" + hex.EncodeToString(scopeBytes) + } + + return fmt.Sprintf("<span class='ui label scope-parent' title='%s'>"+ + "<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+ + "<div class='ui label scope-middle' style='background: linear-gradient(-80deg, %s 48%%, %s 52%% 0%%);'> </div>"+ + "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important''>%s</div>"+ + "</span>", + description, + textColor, scopeColor, scopeText, + itemColor, scopeColor, + textColor, itemColor, itemText) +} + // RenderEmoji renders html text with emoji post processors func RenderEmoji(text string) template.HTML { renderedText, err := markup.RenderEmoji(template.HTMLEscapeString(text)) diff --git a/modules/test/context_tests.go b/modules/test/context_tests.go index d50f8efc7..6c434c201 100644 --- a/modules/test/context_tests.go +++ b/modules/test/context_tests.go @@ -86,7 +86,7 @@ func LoadUser(t *testing.T, ctx *context.Context, userID int64) { // LoadGitRepo load a git repo into a test context. Requires that ctx.Repo has // already been populated. func LoadGitRepo(t *testing.T, ctx *context.Context) { - assert.NoError(t, ctx.Repo.Repository.GetOwner(ctx)) + assert.NoError(t, ctx.Repo.Repository.LoadOwner(ctx)) var err error ctx.Repo.GitRepo, err = git.OpenRepository(ctx, ctx.Repo.Repository.RepoPath()) assert.NoError(t, err) diff --git a/modules/util/path.go b/modules/util/path.go index e060b527f..74acb7a85 100644 --- a/modules/util/path.go +++ b/modules/util/path.go @@ -11,6 +11,7 @@ import ( "path/filepath" "regexp" "runtime" + "strings" ) // EnsureAbsolutePath ensure that a path is absolute, making it @@ -201,3 +202,41 @@ func CommonSkip(name string) bool { return false } + +// IsReadmeFileName reports whether name looks like a README file +// based on its name. +func IsReadmeFileName(name string) bool { + name = strings.ToLower(name) + if len(name) < 6 { + return false + } else if len(name) == 6 { + return name == "readme" + } + return name[:7] == "readme." +} + +// IsReadmeFileExtension reports whether name looks like a README file +// based on its name. It will look through the provided extensions and check if the file matches +// one of the extensions and provide the index in the extension list. +// If the filename is `readme.` with an unmatched extension it will match with the index equaling +// the length of the provided extension list. +// Note that the '.' should be provided in ext, e.g ".md" +func IsReadmeFileExtension(name string, ext ...string) (int, bool) { + name = strings.ToLower(name) + if len(name) < 6 || name[:6] != "readme" { + return 0, false + } + + for i, extension := range ext { + extension = strings.ToLower(extension) + if name[6:] == extension { + return i, true + } + } + + if name[6] == '.' { + return len(ext), true + } + + return 0, false +} diff --git a/modules/util/path_test.go b/modules/util/path_test.go index 5794df1eb..93f4f67cf 100644 --- a/modules/util/path_test.go +++ b/modules/util/path_test.go @@ -55,3 +55,84 @@ func TestFileURLToPath(t *testing.T) { } } } + +func TestMisc_IsReadmeFileName(t *testing.T) { + trueTestCases := []string{ + "readme", + "README", + "readME.mdown", + "README.md", + "readme.i18n.md", + } + falseTestCases := []string{ + "test.md", + "wow.MARKDOWN", + "LOL.mDoWn", + "test", + "abcdefg", + "abcdefghijklmnopqrstuvwxyz", + "test.md.test", + "readmf", + } + + for _, testCase := range trueTestCases { + assert.True(t, IsReadmeFileName(testCase)) + } + for _, testCase := range falseTestCases { + assert.False(t, IsReadmeFileName(testCase)) + } + + type extensionTestcase struct { + name string + expected bool + idx int + } + + exts := []string{".md", ".txt", ""} + testCasesExtensions := []extensionTestcase{ + { + name: "readme", + expected: true, + idx: 2, + }, + { + name: "readme.md", + expected: true, + idx: 0, + }, + { + name: "README.md", + expected: true, + idx: 0, + }, + { + name: "ReAdMe.Md", + expected: true, + idx: 0, + }, + { + name: "readme.txt", + expected: true, + idx: 1, + }, + { + name: "readme.doc", + expected: true, + idx: 3, + }, + { + name: "readmee.md", + }, + { + name: "readme..", + expected: true, + idx: 3, + }, + } + + for _, testCase := range testCasesExtensions { + idx, ok := IsReadmeFileExtension(testCase.name, exts...) + assert.Equal(t, testCase.expected, ok) + assert.Equal(t, testCase.idx, idx) + } +} |