mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-12 20:48:40 +00:00
Fixes #12564. Fixes #8951. This introduces a new setting, `ADD_MEMBERS_BY_INVITATIONS`, which is turned off by default. When turned on, adding a user to a team issues an invitation instead of adding them directly to the team. A prerequisite for this work was to be able to link invitations to existing users (so far, they were only associated to an email address, since those invitations were meant to be issued to users who didn't have an account yet). --- I plan to work on the following improvements, which I propose to do in separate PRs given that this one is already a bit big: * generate an in-app notification for the invited user * advertise the invitation to the invited user from the org page as well (#12120) * show the list of invited users in the list of organization members (not just on the team page) and various other improvements to invitations (#12570, #12716). Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12845 Reviewed-by: Gusted <gusted@noreply.codeberg.org>
349 lines
16 KiB
Go
349 lines
16 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// Copyright 2025 The Forgejo Authors. All rights reserved
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package setting
|
|
|
|
import (
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"forgejo.org/modules/log"
|
|
"forgejo.org/modules/structs"
|
|
|
|
"github.com/gobwas/glob"
|
|
)
|
|
|
|
// enumerates all the types of captchas
|
|
const (
|
|
ImageCaptcha = "image"
|
|
ReCaptcha = "recaptcha"
|
|
HCaptcha = "hcaptcha"
|
|
MCaptcha = "mcaptcha"
|
|
CfTurnstile = "cfturnstile"
|
|
)
|
|
|
|
// Service settings
|
|
var Service = struct {
|
|
DefaultUserVisibility string
|
|
DefaultUserVisibilityMode structs.VisibleType
|
|
AllowedUserVisibilityModes []string
|
|
AllowedUserVisibilityModesSlice AllowedVisibility `ini:"-"`
|
|
DefaultOrgVisibility string
|
|
DefaultOrgVisibilityMode structs.VisibleType
|
|
ActiveCodeLives int
|
|
ResetPwdCodeLives int
|
|
RegisterEmailConfirm bool
|
|
RegisterManualConfirm bool
|
|
EmailDomainAllowList []glob.Glob
|
|
EmailDomainBlockList []glob.Glob
|
|
EmailDomainBlockDisposable bool
|
|
DisableRegistration bool
|
|
AllowOnlyInternalRegistration bool
|
|
AllowOnlyExternalRegistration bool
|
|
ShowRegistrationButton bool
|
|
EnableInternalSignIn bool
|
|
ShowMilestonesDashboardPage bool
|
|
RequireSignInView bool
|
|
EnableNotifyMail bool
|
|
EnableBasicAuth bool
|
|
EnableReverseProxyAuth bool
|
|
EnableReverseProxyAuthAPI bool
|
|
EnableReverseProxyAutoRegister bool
|
|
EnableReverseProxyEmail bool
|
|
EnableReverseProxyFullName bool
|
|
EnableCaptcha bool
|
|
RequireCaptchaForLogin bool
|
|
RequireExternalRegistrationCaptcha bool
|
|
RequireExternalRegistrationPassword bool
|
|
CaptchaType string
|
|
RecaptchaSecret string
|
|
RecaptchaSitekey string
|
|
RecaptchaURL string
|
|
CfTurnstileSecret string
|
|
CfTurnstileSitekey string
|
|
HcaptchaSecret string
|
|
HcaptchaSitekey string
|
|
McaptchaSecret string
|
|
McaptchaSitekey string
|
|
McaptchaURL string
|
|
DefaultKeepEmailPrivate bool
|
|
DefaultAllowCreateOrganization bool
|
|
DefaultUserIsRestricted bool
|
|
AllowDotsInUsernames bool
|
|
EnableTimetracking bool
|
|
DefaultEnableTimetracking bool
|
|
DefaultEnableDependencies bool
|
|
AllowCrossRepositoryDependencies bool
|
|
DefaultAllowOnlyContributorsToTrackTime bool
|
|
NoReplyAddress string
|
|
UserLocationMapURL string
|
|
EnableUserHeatmap bool
|
|
AddMembersByInvitations bool
|
|
AutoWatchNewRepos bool
|
|
AutoWatchOnChanges bool
|
|
DefaultOrgMemberVisible bool
|
|
UserDeleteWithCommentsMaxTime time.Duration
|
|
ValidSiteURLSchemes []string
|
|
UsernameCooldownPeriod int64
|
|
MaxUserRedirects int64
|
|
|
|
// OpenID settings
|
|
EnableOpenIDSignIn bool
|
|
EnableOpenIDSignUp bool
|
|
OpenIDWhitelist []*regexp.Regexp
|
|
OpenIDBlacklist []*regexp.Regexp
|
|
|
|
// Explore page settings
|
|
Explore struct {
|
|
RequireSigninView bool `ini:"REQUIRE_SIGNIN_VIEW"`
|
|
DisableUsersPage bool `ini:"DISABLE_USERS_PAGE"`
|
|
DisableOrganizationsPage bool `ini:"DISABLE_ORGANIZATIONS_PAGE"`
|
|
DisableCodePage bool `ini:"DISABLE_CODE_PAGE"`
|
|
} `ini:"service.explore"`
|
|
}{
|
|
AllowedUserVisibilityModesSlice: []bool{true, true, true},
|
|
}
|
|
|
|
// AllowedVisibility store in a 3 item bool array what is allowed
|
|
type AllowedVisibility []bool
|
|
|
|
// IsAllowedVisibility check if a AllowedVisibility allow a specific VisibleType
|
|
func (a AllowedVisibility) IsAllowedVisibility(t structs.VisibleType) bool {
|
|
if int(t) >= len(a) {
|
|
return false
|
|
}
|
|
return a[t]
|
|
}
|
|
|
|
// ToVisibleTypeSlice convert a AllowedVisibility into a VisibleType slice
|
|
func (a AllowedVisibility) ToVisibleTypeSlice() (result []structs.VisibleType) {
|
|
for i, v := range a {
|
|
if v {
|
|
result = append(result, structs.VisibleType(i))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func CompileEmailGlobList(sec ConfigSection, keys ...string) (globs []glob.Glob) {
|
|
for _, key := range keys {
|
|
list := sec.Key(key).Strings(",")
|
|
for _, s := range list {
|
|
if g, err := glob.Compile(s); err == nil {
|
|
globs = append(globs, g)
|
|
} else {
|
|
log.Error("Skip invalid email allow/block list expression %q: %v", s, err)
|
|
}
|
|
}
|
|
}
|
|
return globs
|
|
}
|
|
|
|
// LoadServiceSetting loads the service settings
|
|
func LoadServiceSetting() {
|
|
loadServiceFrom(CfgProvider)
|
|
}
|
|
|
|
func appURLAsGlob(fqdn string) (glob.Glob, error) {
|
|
localFqdn, err := url.ParseRequestURI(fqdn)
|
|
if err != nil {
|
|
log.Error("Error in EmailDomainAllowList: %v", err)
|
|
return nil, err
|
|
}
|
|
appFqdn, err := glob.Compile(localFqdn.Hostname(), ',')
|
|
if err != nil {
|
|
log.Error("Error in EmailDomainAllowList: %v", err)
|
|
return nil, err
|
|
}
|
|
return appFqdn, nil
|
|
}
|
|
|
|
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()
|
|
Service.AllowOnlyInternalRegistration = sec.Key("ALLOW_ONLY_INTERNAL_REGISTRATION").MustBool()
|
|
Service.AllowOnlyExternalRegistration = sec.Key("ALLOW_ONLY_EXTERNAL_REGISTRATION").MustBool()
|
|
if Service.AllowOnlyExternalRegistration && Service.AllowOnlyInternalRegistration {
|
|
log.Warn("ALLOW_ONLY_INTERNAL_REGISTRATION and ALLOW_ONLY_EXTERNAL_REGISTRATION are true - disabling registration")
|
|
Service.DisableRegistration = true
|
|
}
|
|
if !sec.Key("REGISTER_EMAIL_CONFIRM").MustBool() {
|
|
Service.RegisterManualConfirm = sec.Key("REGISTER_MANUAL_CONFIRM").MustBool(false)
|
|
} else {
|
|
Service.RegisterManualConfirm = false
|
|
}
|
|
if sec.HasKey("EMAIL_DOMAIN_WHITELIST") {
|
|
deprecatedSetting(rootCfg, "service", "EMAIL_DOMAIN_WHITELIST", "service", "EMAIL_DOMAIN_ALLOWLIST", "1.21")
|
|
}
|
|
emailDomainAllowList := CompileEmailGlobList(sec, "EMAIL_DOMAIN_WHITELIST", "EMAIL_DOMAIN_ALLOWLIST")
|
|
|
|
if len(emailDomainAllowList) > 0 && Federation.Enabled {
|
|
appURL, err := appURLAsGlob(AppURL)
|
|
if err == nil {
|
|
emailDomainAllowList = append(emailDomainAllowList, appURL)
|
|
}
|
|
}
|
|
Service.EmailDomainAllowList = emailDomainAllowList
|
|
Service.EmailDomainBlockList = CompileEmailGlobList(sec, "EMAIL_DOMAIN_BLOCKLIST")
|
|
Service.EmailDomainBlockDisposable = sec.Key("EMAIL_DOMAIN_BLOCK_DISPOSABLE").MustBool(false)
|
|
if Service.EmailDomainBlockDisposable {
|
|
toAdd := make([]glob.Glob, 0, len(DisposableEmailDomains()))
|
|
for _, domain := range DisposableEmailDomains() {
|
|
domain = strings.ToLower(domain)
|
|
// Only add domains that aren't blocked yet.
|
|
if !slices.ContainsFunc(Service.EmailDomainBlockList, func(g glob.Glob) bool { return g.Match(domain) }) {
|
|
if g, err := glob.Compile(domain); err != nil {
|
|
log.Error("Error in disposable domain %s: %v", domain, err)
|
|
} else {
|
|
toAdd = append(toAdd, g)
|
|
}
|
|
}
|
|
}
|
|
Service.EmailDomainBlockList = append(Service.EmailDomainBlockList, toAdd...)
|
|
}
|
|
Service.ShowRegistrationButton = sec.Key("SHOW_REGISTRATION_BUTTON").MustBool(!Service.DisableRegistration && !Service.AllowOnlyExternalRegistration)
|
|
Service.EnableInternalSignIn = sec.Key("ENABLE_INTERNAL_SIGNIN").MustBool(true)
|
|
Service.ShowMilestonesDashboardPage = sec.Key("SHOW_MILESTONES_DASHBOARD_PAGE").MustBool(true)
|
|
Service.RequireSignInView = sec.Key("REQUIRE_SIGNIN_VIEW").MustBool()
|
|
Service.EnableBasicAuth = sec.Key("ENABLE_BASIC_AUTHENTICATION").MustBool(true)
|
|
Service.EnableReverseProxyAuth = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION").MustBool()
|
|
Service.EnableReverseProxyAuthAPI = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION_API").MustBool()
|
|
Service.EnableReverseProxyAutoRegister = sec.Key("ENABLE_REVERSE_PROXY_AUTO_REGISTRATION").MustBool()
|
|
Service.EnableReverseProxyEmail = sec.Key("ENABLE_REVERSE_PROXY_EMAIL").MustBool()
|
|
Service.EnableReverseProxyFullName = sec.Key("ENABLE_REVERSE_PROXY_FULL_NAME").MustBool()
|
|
Service.EnableCaptcha = sec.Key("ENABLE_CAPTCHA").MustBool(false)
|
|
Service.RequireCaptchaForLogin = sec.Key("REQUIRE_CAPTCHA_FOR_LOGIN").MustBool(false)
|
|
Service.RequireExternalRegistrationCaptcha = sec.Key("REQUIRE_EXTERNAL_REGISTRATION_CAPTCHA").MustBool(Service.EnableCaptcha)
|
|
Service.RequireExternalRegistrationPassword = sec.Key("REQUIRE_EXTERNAL_REGISTRATION_PASSWORD").MustBool()
|
|
Service.CaptchaType = sec.Key("CAPTCHA_TYPE").MustString(ImageCaptcha)
|
|
Service.RecaptchaSecret = sec.Key("RECAPTCHA_SECRET").MustString("")
|
|
Service.RecaptchaSitekey = sec.Key("RECAPTCHA_SITEKEY").MustString("")
|
|
Service.RecaptchaURL = sec.Key("RECAPTCHA_URL").MustString("https://www.google.com/recaptcha/")
|
|
Service.CfTurnstileSecret = sec.Key("CF_TURNSTILE_SECRET").MustString("")
|
|
Service.CfTurnstileSitekey = sec.Key("CF_TURNSTILE_SITEKEY").MustString("")
|
|
Service.HcaptchaSecret = sec.Key("HCAPTCHA_SECRET").MustString("")
|
|
Service.HcaptchaSitekey = sec.Key("HCAPTCHA_SITEKEY").MustString("")
|
|
Service.McaptchaURL = sec.Key("MCAPTCHA_URL").MustString("https://demo.mcaptcha.org/")
|
|
Service.McaptchaSecret = sec.Key("MCAPTCHA_SECRET").MustString("")
|
|
Service.McaptchaSitekey = sec.Key("MCAPTCHA_SITEKEY").MustString("")
|
|
Service.DefaultKeepEmailPrivate = sec.Key("DEFAULT_KEEP_EMAIL_PRIVATE").MustBool()
|
|
Service.DefaultAllowCreateOrganization = sec.Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").MustBool(true)
|
|
Service.DefaultUserIsRestricted = sec.Key("DEFAULT_USER_IS_RESTRICTED").MustBool(false)
|
|
Service.AllowDotsInUsernames = sec.Key("ALLOW_DOTS_IN_USERNAMES").MustBool(true)
|
|
Service.EnableTimetracking = sec.Key("ENABLE_TIMETRACKING").MustBool(true)
|
|
if Service.EnableTimetracking {
|
|
Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true)
|
|
}
|
|
Service.DefaultEnableDependencies = sec.Key("DEFAULT_ENABLE_DEPENDENCIES").MustBool(true)
|
|
Service.AllowCrossRepositoryDependencies = sec.Key("ALLOW_CROSS_REPOSITORY_DEPENDENCIES").MustBool(true)
|
|
Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true)
|
|
Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply." + Domain)
|
|
Service.UserLocationMapURL = sec.Key("USER_LOCATION_MAP_URL").MustString("https://www.openstreetmap.org/search?query=")
|
|
Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true)
|
|
Service.AddMembersByInvitations = sec.Key("ADD_MEMBERS_BY_INVITATIONS").MustBool(false)
|
|
Service.AutoWatchNewRepos = sec.Key("AUTO_WATCH_NEW_REPOS").MustBool(true)
|
|
Service.AutoWatchOnChanges = sec.Key("AUTO_WATCH_ON_CHANGES").MustBool(false)
|
|
modes := sec.Key("ALLOWED_USER_VISIBILITY_MODES").Strings(",")
|
|
if len(modes) != 0 {
|
|
Service.AllowedUserVisibilityModes = []string{}
|
|
Service.AllowedUserVisibilityModesSlice = []bool{false, false, false}
|
|
for _, sMode := range modes {
|
|
if tp, ok := structs.VisibilityModes[sMode]; ok { // remove unsupported modes
|
|
Service.AllowedUserVisibilityModes = append(Service.AllowedUserVisibilityModes, sMode)
|
|
Service.AllowedUserVisibilityModesSlice[tp] = true
|
|
} else {
|
|
log.Warn("ALLOWED_USER_VISIBILITY_MODES %s is unsupported", sMode)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(Service.AllowedUserVisibilityModes) == 0 {
|
|
Service.AllowedUserVisibilityModes = []string{"public", "limited", "private"}
|
|
Service.AllowedUserVisibilityModesSlice = []bool{true, true, true}
|
|
}
|
|
|
|
Service.DefaultUserVisibility = sec.Key("DEFAULT_USER_VISIBILITY").String()
|
|
if Service.DefaultUserVisibility == "" {
|
|
Service.DefaultUserVisibility = Service.AllowedUserVisibilityModes[0]
|
|
} else if !Service.AllowedUserVisibilityModesSlice[structs.VisibilityModes[Service.DefaultUserVisibility]] {
|
|
log.Warn("DEFAULT_USER_VISIBILITY %s is wrong or not in ALLOWED_USER_VISIBILITY_MODES, using first allowed", Service.DefaultUserVisibility)
|
|
Service.DefaultUserVisibility = Service.AllowedUserVisibilityModes[0]
|
|
}
|
|
Service.DefaultUserVisibilityMode = structs.VisibilityModes[Service.DefaultUserVisibility]
|
|
Service.DefaultOrgVisibility = sec.Key("DEFAULT_ORG_VISIBILITY").In("public", structs.ExtractKeysFromMapString(structs.VisibilityModes))
|
|
Service.DefaultOrgVisibilityMode = structs.VisibilityModes[Service.DefaultOrgVisibility]
|
|
Service.DefaultOrgMemberVisible = sec.Key("DEFAULT_ORG_MEMBER_VISIBLE").MustBool()
|
|
Service.UserDeleteWithCommentsMaxTime = sec.Key("USER_DELETE_WITH_COMMENTS_MAX_TIME").MustDuration(0)
|
|
sec.Key("VALID_SITE_URL_SCHEMES").MustString("http,https")
|
|
Service.ValidSiteURLSchemes = sec.Key("VALID_SITE_URL_SCHEMES").Strings(",")
|
|
schemes := make([]string, 0, len(Service.ValidSiteURLSchemes))
|
|
for _, scheme := range Service.ValidSiteURLSchemes {
|
|
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
|
if scheme != "" {
|
|
schemes = append(schemes, scheme)
|
|
}
|
|
}
|
|
Service.ValidSiteURLSchemes = schemes
|
|
|
|
// A pattern from ValidSiteURLSchemes must be valid for use in HTML <input pattern=""> validation
|
|
_ = regexp.MustCompile(`^(?:` + ValidSiteURLPattern() + `)$`)
|
|
|
|
Service.UsernameCooldownPeriod = sec.Key("USERNAME_COOLDOWN_PERIOD").MustInt64(0)
|
|
|
|
// Only set a default if USERNAME_COOLDOWN_PERIOD's feature is active.
|
|
maxUserRedirectsDefault := int64(0)
|
|
if Service.UsernameCooldownPeriod > 0 {
|
|
maxUserRedirectsDefault = 5
|
|
}
|
|
Service.MaxUserRedirects = sec.Key("MAX_USER_REDIRECTS").MustInt64(maxUserRedirectsDefault)
|
|
|
|
mustMapSetting(rootCfg, "service.explore", &Service.Explore)
|
|
|
|
loadOpenIDSetting(rootCfg)
|
|
}
|
|
|
|
// Returns a regex pattern string based on the current value of
|
|
// `Service.ValidSiteURLSchemes`.
|
|
//
|
|
// This pattern string is meant to be used as the value of an <input> element's
|
|
// `pattern` attribute. As such, this function assumes that the pattern will be
|
|
// implicitly wrapped with `^(?:` and `)$`, such that the match is required
|
|
// against the entire input value, i.e., `^(?:<pattern>)$`.
|
|
//
|
|
// See [MDN] for more details.
|
|
//
|
|
// [MDN]: https://developer.mozilla.org/docs/Web/HTML/Reference/Elements/input#pattern
|
|
func ValidSiteURLPattern() string {
|
|
// While technically possible, returning a compiled Regexp from this seems heavy-handed.
|
|
// We could store a compiled Regexp instead of generating on the fly, but that complicates testing!
|
|
// Much easier to simply edit `Service.ValidSiteURLSchemes for a given test and proceed.
|
|
// We run the compiler against the initial config value anyway, so this string always works in production.
|
|
schemes := strings.Join(Service.ValidSiteURLSchemes, "|")
|
|
return `(` + schemes + `)://.+`
|
|
}
|
|
|
|
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(" ")
|
|
if len(pats) != 0 {
|
|
Service.OpenIDWhitelist = make([]*regexp.Regexp, len(pats))
|
|
for i, p := range pats {
|
|
Service.OpenIDWhitelist[i] = regexp.MustCompilePOSIX(p)
|
|
}
|
|
}
|
|
pats = sec.Key("BLACKLISTED_URIS").Strings(" ")
|
|
if len(pats) != 0 {
|
|
Service.OpenIDBlacklist = make([]*regexp.Regexp, len(pats))
|
|
for i, p := range pats {
|
|
Service.OpenIDBlacklist[i] = regexp.MustCompilePOSIX(p)
|
|
}
|
|
}
|
|
}
|