mirror of
https://gitea.com/gitea/gitea-mirror.git
synced 2026-03-20 11:50:27 +00:00
Instance-wide (global) info banner and maintenance mode (#36571)
The banner allows site operators to communicate important announcements (e.g., maintenance windows, policy updates, service notices) directly within the UI. The maintenance mode only allows admin to access the web UI. * Fix #2345 * Fix #9618 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -12,8 +12,8 @@ import (
|
||||
)
|
||||
|
||||
type PictureStruct struct {
|
||||
DisableGravatar *config.Value[bool]
|
||||
EnableFederatedAvatar *config.Value[bool]
|
||||
DisableGravatar *config.Option[bool]
|
||||
EnableFederatedAvatar *config.Option[bool]
|
||||
}
|
||||
|
||||
type OpenWithEditorApp struct {
|
||||
@@ -23,6 +23,9 @@ type OpenWithEditorApp struct {
|
||||
|
||||
type OpenWithEditorAppsType []OpenWithEditorApp
|
||||
|
||||
// ToTextareaString is only used in templates, for help prompt only
|
||||
// TODO: OPEN-WITH-EDITOR-APP-JSON: Because there is no "rich UI", a plain text editor is used to manage the list of apps
|
||||
// Maybe we can use some better formats like Yaml in the future, then a simple textarea can manage the config clearly
|
||||
func (t OpenWithEditorAppsType) ToTextareaString() string {
|
||||
var ret strings.Builder
|
||||
for _, app := range t {
|
||||
@@ -31,7 +34,7 @@ func (t OpenWithEditorAppsType) ToTextareaString() string {
|
||||
return ret.String()
|
||||
}
|
||||
|
||||
func DefaultOpenWithEditorApps() OpenWithEditorAppsType {
|
||||
func openWithEditorAppsDefaultValue() OpenWithEditorAppsType {
|
||||
return OpenWithEditorAppsType{
|
||||
{
|
||||
DisplayName: "VS Code",
|
||||
@@ -49,13 +52,14 @@ func DefaultOpenWithEditorApps() OpenWithEditorAppsType {
|
||||
}
|
||||
|
||||
type RepositoryStruct struct {
|
||||
OpenWithEditorApps *config.Value[OpenWithEditorAppsType]
|
||||
GitGuideRemoteName *config.Value[string]
|
||||
OpenWithEditorApps *config.Option[OpenWithEditorAppsType]
|
||||
GitGuideRemoteName *config.Option[string]
|
||||
}
|
||||
|
||||
type ConfigStruct struct {
|
||||
Picture *PictureStruct
|
||||
Repository *RepositoryStruct
|
||||
Instance *InstanceStruct
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -67,12 +71,16 @@ func initDefaultConfig() {
|
||||
config.SetCfgSecKeyGetter(&cfgSecKeyGetter{})
|
||||
defaultConfig = &ConfigStruct{
|
||||
Picture: &PictureStruct{
|
||||
DisableGravatar: config.ValueJSON[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}),
|
||||
EnableFederatedAvatar: config.ValueJSON[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}),
|
||||
DisableGravatar: config.NewOption[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}),
|
||||
EnableFederatedAvatar: config.NewOption[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}),
|
||||
},
|
||||
Repository: &RepositoryStruct{
|
||||
OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"),
|
||||
GitGuideRemoteName: config.ValueJSON[string]("repository.git-guide-remote-name").WithDefault("origin"),
|
||||
OpenWithEditorApps: config.NewOption[OpenWithEditorAppsType]("repository.open-with.editor-apps").WithEmptyAsDefault().WithDefaultFunc(openWithEditorAppsDefaultValue),
|
||||
GitGuideRemoteName: config.NewOption[string]("repository.git-guide-remote-name").WithEmptyAsDefault().WithDefaultSimple("origin"),
|
||||
},
|
||||
Instance: &InstanceStruct{
|
||||
WebBanner: config.NewOption[WebBannerType]("instance.web_banner"),
|
||||
MaintenanceMode: config.NewOption[MaintenanceModeType]("instance.maintenance_mode"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
@@ -16,18 +17,31 @@ type CfgSecKey struct {
|
||||
Sec, Key string
|
||||
}
|
||||
|
||||
type Value[T any] struct {
|
||||
// OptionInterface is used to overcome Golang's generic interface limitation
|
||||
type OptionInterface interface {
|
||||
GetDefaultValue() any
|
||||
}
|
||||
|
||||
type Option[T any] struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
cfgSecKey CfgSecKey
|
||||
dynKey string
|
||||
|
||||
def, value T
|
||||
value T
|
||||
defSimple T
|
||||
defFunc func() T
|
||||
emptyAsDef bool
|
||||
has bool
|
||||
revision int
|
||||
}
|
||||
|
||||
func (value *Value[T]) parse(key, valStr string) (v T) {
|
||||
v = value.def
|
||||
func (opt *Option[T]) GetDefaultValue() any {
|
||||
return opt.DefaultValue()
|
||||
}
|
||||
|
||||
func (opt *Option[T]) parse(key, valStr string) (v T) {
|
||||
v = opt.DefaultValue()
|
||||
if valStr != "" {
|
||||
if err := json.Unmarshal(util.UnsafeStringToBytes(valStr), &v); err != nil {
|
||||
log.Error("Unable to unmarshal json config for key %q, err: %v", key, err)
|
||||
@@ -36,7 +50,35 @@ func (value *Value[T]) parse(key, valStr string) (v T) {
|
||||
return v
|
||||
}
|
||||
|
||||
func (value *Value[T]) Value(ctx context.Context) (v T) {
|
||||
func (opt *Option[T]) HasValue(ctx context.Context) bool {
|
||||
_, _, has := opt.ValueRevision(ctx)
|
||||
return has
|
||||
}
|
||||
|
||||
func (opt *Option[T]) Value(ctx context.Context) (v T) {
|
||||
v, _, _ = opt.ValueRevision(ctx)
|
||||
return v
|
||||
}
|
||||
|
||||
func isZeroOrEmpty(v any) bool {
|
||||
if v == nil {
|
||||
return true // interface itself is nil
|
||||
}
|
||||
r := reflect.ValueOf(v)
|
||||
if r.IsZero() {
|
||||
return true
|
||||
}
|
||||
|
||||
if r.Kind() == reflect.Slice || r.Kind() == reflect.Map {
|
||||
if r.IsNil() {
|
||||
return true
|
||||
}
|
||||
return r.Len() == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (opt *Option[T]) ValueRevision(ctx context.Context) (v T, rev int, has bool) {
|
||||
dg := GetDynGetter()
|
||||
if dg == nil {
|
||||
// this is an edge case: the database is not initialized but the system setting is going to be used
|
||||
@@ -44,55 +86,96 @@ func (value *Value[T]) Value(ctx context.Context) (v T) {
|
||||
panic("no config dyn value getter")
|
||||
}
|
||||
|
||||
rev := dg.GetRevision(ctx)
|
||||
rev = dg.GetRevision(ctx)
|
||||
|
||||
// if the revision in the database doesn't change, use the last value
|
||||
value.mu.RLock()
|
||||
if rev == value.revision {
|
||||
v = value.value
|
||||
value.mu.RUnlock()
|
||||
return v
|
||||
opt.mu.RLock()
|
||||
if rev == opt.revision {
|
||||
v = opt.value
|
||||
has = opt.has
|
||||
opt.mu.RUnlock()
|
||||
return v, rev, has
|
||||
}
|
||||
value.mu.RUnlock()
|
||||
opt.mu.RUnlock()
|
||||
|
||||
// try to parse the config and cache it
|
||||
var valStr *string
|
||||
if dynVal, has := dg.GetValue(ctx, value.dynKey); has {
|
||||
if dynVal, hasDbValue := dg.GetValue(ctx, opt.dynKey); hasDbValue {
|
||||
valStr = &dynVal
|
||||
} else if cfgVal, has := GetCfgSecKeyGetter().GetValue(value.cfgSecKey.Sec, value.cfgSecKey.Key); has {
|
||||
} else if cfgVal, has := GetCfgSecKeyGetter().GetValue(opt.cfgSecKey.Sec, opt.cfgSecKey.Key); has {
|
||||
valStr = &cfgVal
|
||||
}
|
||||
if valStr == nil {
|
||||
v = value.def
|
||||
v = opt.DefaultValue()
|
||||
has = false
|
||||
} else {
|
||||
v = value.parse(value.dynKey, *valStr)
|
||||
v = opt.parse(opt.dynKey, *valStr)
|
||||
if opt.emptyAsDef && isZeroOrEmpty(v) {
|
||||
v = opt.DefaultValue()
|
||||
} else {
|
||||
has = true
|
||||
}
|
||||
}
|
||||
|
||||
value.mu.Lock()
|
||||
value.value = v
|
||||
value.revision = rev
|
||||
value.mu.Unlock()
|
||||
opt.mu.Lock()
|
||||
opt.value = v
|
||||
opt.revision = rev
|
||||
opt.has = has
|
||||
opt.mu.Unlock()
|
||||
return v, rev, has
|
||||
}
|
||||
|
||||
func (opt *Option[T]) DynKey() string {
|
||||
return opt.dynKey
|
||||
}
|
||||
|
||||
// WithDefaultFunc sets the default value with a function
|
||||
// The "def" value might be changed during runtime (e.g.: Unmarshal with default), so it shouldn't use the same pointer or slice
|
||||
func (opt *Option[T]) WithDefaultFunc(f func() T) *Option[T] {
|
||||
opt.defFunc = f
|
||||
return opt
|
||||
}
|
||||
|
||||
func (opt *Option[T]) WithDefaultSimple(def T) *Option[T] {
|
||||
v := any(def)
|
||||
switch v.(type) {
|
||||
case string, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
default:
|
||||
// TODO: use reflect to support convertable basic types like `type State string`
|
||||
r := reflect.ValueOf(v)
|
||||
if r.Kind() != reflect.Struct {
|
||||
panic("invalid type for default value, use WithDefaultFunc instead")
|
||||
}
|
||||
}
|
||||
opt.defSimple = def
|
||||
return opt
|
||||
}
|
||||
|
||||
func (opt *Option[T]) WithEmptyAsDefault() *Option[T] {
|
||||
opt.emptyAsDef = true
|
||||
return opt
|
||||
}
|
||||
|
||||
func (opt *Option[T]) DefaultValue() T {
|
||||
if opt.defFunc != nil {
|
||||
return opt.defFunc()
|
||||
}
|
||||
return opt.defSimple
|
||||
}
|
||||
|
||||
func (opt *Option[T]) WithFileConfig(cfgSecKey CfgSecKey) *Option[T] {
|
||||
opt.cfgSecKey = cfgSecKey
|
||||
return opt
|
||||
}
|
||||
|
||||
var allConfigOptions = map[string]OptionInterface{}
|
||||
|
||||
func NewOption[T any](dynKey string) *Option[T] {
|
||||
v := &Option[T]{dynKey: dynKey}
|
||||
allConfigOptions[dynKey] = v
|
||||
return v
|
||||
}
|
||||
|
||||
func (value *Value[T]) DynKey() string {
|
||||
return value.dynKey
|
||||
}
|
||||
|
||||
func (value *Value[T]) WithDefault(def T) *Value[T] {
|
||||
value.def = def
|
||||
return value
|
||||
}
|
||||
|
||||
func (value *Value[T]) DefaultValue() T {
|
||||
return value.def
|
||||
}
|
||||
|
||||
func (value *Value[T]) WithFileConfig(cfgSecKey CfgSecKey) *Value[T] {
|
||||
value.cfgSecKey = cfgSecKey
|
||||
return value
|
||||
}
|
||||
|
||||
func ValueJSON[T any](dynKey string) *Value[T] {
|
||||
return &Value[T]{dynKey: dynKey}
|
||||
func GetConfigOption(dynKey string) OptionInterface {
|
||||
return allConfigOptions[dynKey]
|
||||
}
|
||||
|
||||
58
modules/setting/config_option_instance.go
Normal file
58
modules/setting/config_option_instance.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting/config"
|
||||
)
|
||||
|
||||
// WebBannerType fields are directly used in templates,
|
||||
// do remember to update the template if you change the fields
|
||||
type WebBannerType struct {
|
||||
DisplayEnabled bool
|
||||
ContentMessage string
|
||||
StartTimeUnix int64
|
||||
EndTimeUnix int64
|
||||
}
|
||||
|
||||
func (b WebBannerType) ShouldDisplay() bool {
|
||||
if !b.DisplayEnabled || b.ContentMessage == "" {
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if b.StartTimeUnix > 0 && now < b.StartTimeUnix {
|
||||
return false
|
||||
}
|
||||
if b.EndTimeUnix > 0 && now > b.EndTimeUnix {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type MaintenanceModeType struct {
|
||||
AdminWebAccessOnly bool
|
||||
StartTimeUnix int64
|
||||
EndTimeUnix int64
|
||||
}
|
||||
|
||||
func (m MaintenanceModeType) IsActive() bool {
|
||||
if !m.AdminWebAccessOnly {
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if m.StartTimeUnix > 0 && now < m.StartTimeUnix {
|
||||
return false
|
||||
}
|
||||
if m.EndTimeUnix > 0 && now > m.EndTimeUnix {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type InstanceStruct struct {
|
||||
WebBanner *config.Option[WebBannerType]
|
||||
MaintenanceMode *config.Option[MaintenanceModeType]
|
||||
}
|
||||
Reference in New Issue
Block a user