Skip to content

Commit 26d83c9

Browse files
bircniwxiaoguang
andauthored
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>
1 parent d0f92cb commit 26d83c9

34 files changed

Lines changed: 858 additions & 146 deletions

modules/markup/sanitizer_default.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
8181
"data-markdown-generated-content", "data-attr-class",
8282
}
8383
generalSafeElements := []string{
84-
"h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt",
84+
"h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "center", "i", "strong", "em", "a", "pre", "code", "img", "tt",
8585
"div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label",
8686
"dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary",
8787
"details", "caption", "figure", "figcaption",

modules/setting/config.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import (
1212
)
1313

1414
type PictureStruct struct {
15-
DisableGravatar *config.Value[bool]
16-
EnableFederatedAvatar *config.Value[bool]
15+
DisableGravatar *config.Option[bool]
16+
EnableFederatedAvatar *config.Option[bool]
1717
}
1818

1919
type OpenWithEditorApp struct {
@@ -23,6 +23,9 @@ type OpenWithEditorApp struct {
2323

2424
type OpenWithEditorAppsType []OpenWithEditorApp
2525

26+
// ToTextareaString is only used in templates, for help prompt only
27+
// TODO: OPEN-WITH-EDITOR-APP-JSON: Because there is no "rich UI", a plain text editor is used to manage the list of apps
28+
// Maybe we can use some better formats like Yaml in the future, then a simple textarea can manage the config clearly
2629
func (t OpenWithEditorAppsType) ToTextareaString() string {
2730
var ret strings.Builder
2831
for _, app := range t {
@@ -31,7 +34,7 @@ func (t OpenWithEditorAppsType) ToTextareaString() string {
3134
return ret.String()
3235
}
3336

34-
func DefaultOpenWithEditorApps() OpenWithEditorAppsType {
37+
func openWithEditorAppsDefaultValue() OpenWithEditorAppsType {
3538
return OpenWithEditorAppsType{
3639
{
3740
DisplayName: "VS Code",
@@ -49,13 +52,14 @@ func DefaultOpenWithEditorApps() OpenWithEditorAppsType {
4952
}
5053

5154
type RepositoryStruct struct {
52-
OpenWithEditorApps *config.Value[OpenWithEditorAppsType]
53-
GitGuideRemoteName *config.Value[string]
55+
OpenWithEditorApps *config.Option[OpenWithEditorAppsType]
56+
GitGuideRemoteName *config.Option[string]
5457
}
5558

5659
type ConfigStruct struct {
5760
Picture *PictureStruct
5861
Repository *RepositoryStruct
62+
Instance *InstanceStruct
5963
}
6064

6165
var (
@@ -67,12 +71,16 @@ func initDefaultConfig() {
6771
config.SetCfgSecKeyGetter(&cfgSecKeyGetter{})
6872
defaultConfig = &ConfigStruct{
6973
Picture: &PictureStruct{
70-
DisableGravatar: config.ValueJSON[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}),
71-
EnableFederatedAvatar: config.ValueJSON[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}),
74+
DisableGravatar: config.NewOption[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}),
75+
EnableFederatedAvatar: config.NewOption[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}),
7276
},
7377
Repository: &RepositoryStruct{
74-
OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"),
75-
GitGuideRemoteName: config.ValueJSON[string]("repository.git-guide-remote-name").WithDefault("origin"),
78+
OpenWithEditorApps: config.NewOption[OpenWithEditorAppsType]("repository.open-with.editor-apps").WithEmptyAsDefault().WithDefaultFunc(openWithEditorAppsDefaultValue),
79+
GitGuideRemoteName: config.NewOption[string]("repository.git-guide-remote-name").WithEmptyAsDefault().WithDefaultSimple("origin"),
80+
},
81+
Instance: &InstanceStruct{
82+
WebBanner: config.NewOption[WebBannerType]("instance.web_banner"),
83+
MaintenanceMode: config.NewOption[MaintenanceModeType]("instance.maintenance_mode"),
7684
},
7785
}
7886
}

modules/setting/config/value.go

Lines changed: 116 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package config
55

66
import (
77
"context"
8+
"reflect"
89
"sync"
910

1011
"code.gitea.io/gitea/modules/json"
@@ -16,18 +17,31 @@ type CfgSecKey struct {
1617
Sec, Key string
1718
}
1819

19-
type Value[T any] struct {
20+
// OptionInterface is used to overcome Golang's generic interface limitation
21+
type OptionInterface interface {
22+
GetDefaultValue() any
23+
}
24+
25+
type Option[T any] struct {
2026
mu sync.RWMutex
2127

2228
cfgSecKey CfgSecKey
2329
dynKey string
2430

25-
def, value T
31+
value T
32+
defSimple T
33+
defFunc func() T
34+
emptyAsDef bool
35+
has bool
2636
revision int
2737
}
2838

29-
func (value *Value[T]) parse(key, valStr string) (v T) {
30-
v = value.def
39+
func (opt *Option[T]) GetDefaultValue() any {
40+
return opt.DefaultValue()
41+
}
42+
43+
func (opt *Option[T]) parse(key, valStr string) (v T) {
44+
v = opt.DefaultValue()
3145
if valStr != "" {
3246
if err := json.Unmarshal(util.UnsafeStringToBytes(valStr), &v); err != nil {
3347
log.Error("Unable to unmarshal json config for key %q, err: %v", key, err)
@@ -36,63 +50,132 @@ func (value *Value[T]) parse(key, valStr string) (v T) {
3650
return v
3751
}
3852

39-
func (value *Value[T]) Value(ctx context.Context) (v T) {
53+
func (opt *Option[T]) HasValue(ctx context.Context) bool {
54+
_, _, has := opt.ValueRevision(ctx)
55+
return has
56+
}
57+
58+
func (opt *Option[T]) Value(ctx context.Context) (v T) {
59+
v, _, _ = opt.ValueRevision(ctx)
60+
return v
61+
}
62+
63+
func isZeroOrEmpty(v any) bool {
64+
if v == nil {
65+
return true // interface itself is nil
66+
}
67+
r := reflect.ValueOf(v)
68+
if r.IsZero() {
69+
return true
70+
}
71+
72+
if r.Kind() == reflect.Slice || r.Kind() == reflect.Map {
73+
if r.IsNil() {
74+
return true
75+
}
76+
return r.Len() == 0
77+
}
78+
return false
79+
}
80+
81+
func (opt *Option[T]) ValueRevision(ctx context.Context) (v T, rev int, has bool) {
4082
dg := GetDynGetter()
4183
if dg == nil {
4284
// this is an edge case: the database is not initialized but the system setting is going to be used
4385
// it should panic to avoid inconsistent config values (from config / system setting) and fix the code
4486
panic("no config dyn value getter")
4587
}
4688

47-
rev := dg.GetRevision(ctx)
89+
rev = dg.GetRevision(ctx)
4890

4991
// if the revision in the database doesn't change, use the last value
50-
value.mu.RLock()
51-
if rev == value.revision {
52-
v = value.value
53-
value.mu.RUnlock()
54-
return v
92+
opt.mu.RLock()
93+
if rev == opt.revision {
94+
v = opt.value
95+
has = opt.has
96+
opt.mu.RUnlock()
97+
return v, rev, has
5598
}
56-
value.mu.RUnlock()
99+
opt.mu.RUnlock()
57100

58101
// try to parse the config and cache it
59102
var valStr *string
60-
if dynVal, has := dg.GetValue(ctx, value.dynKey); has {
103+
if dynVal, hasDbValue := dg.GetValue(ctx, opt.dynKey); hasDbValue {
61104
valStr = &dynVal
62-
} else if cfgVal, has := GetCfgSecKeyGetter().GetValue(value.cfgSecKey.Sec, value.cfgSecKey.Key); has {
105+
} else if cfgVal, has := GetCfgSecKeyGetter().GetValue(opt.cfgSecKey.Sec, opt.cfgSecKey.Key); has {
63106
valStr = &cfgVal
64107
}
65108
if valStr == nil {
66-
v = value.def
109+
v = opt.DefaultValue()
110+
has = false
67111
} else {
68-
v = value.parse(value.dynKey, *valStr)
112+
v = opt.parse(opt.dynKey, *valStr)
113+
if opt.emptyAsDef && isZeroOrEmpty(v) {
114+
v = opt.DefaultValue()
115+
} else {
116+
has = true
117+
}
69118
}
70119

71-
value.mu.Lock()
72-
value.value = v
73-
value.revision = rev
74-
value.mu.Unlock()
75-
return v
120+
opt.mu.Lock()
121+
opt.value = v
122+
opt.revision = rev
123+
opt.has = has
124+
opt.mu.Unlock()
125+
return v, rev, has
126+
}
127+
128+
func (opt *Option[T]) DynKey() string {
129+
return opt.dynKey
130+
}
131+
132+
// WithDefaultFunc sets the default value with a function
133+
// The "def" value might be changed during runtime (e.g.: Unmarshal with default), so it shouldn't use the same pointer or slice
134+
func (opt *Option[T]) WithDefaultFunc(f func() T) *Option[T] {
135+
opt.defFunc = f
136+
return opt
76137
}
77138

78-
func (value *Value[T]) DynKey() string {
79-
return value.dynKey
139+
func (opt *Option[T]) WithDefaultSimple(def T) *Option[T] {
140+
v := any(def)
141+
switch v.(type) {
142+
case string, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
143+
default:
144+
// TODO: use reflect to support convertable basic types like `type State string`
145+
r := reflect.ValueOf(v)
146+
if r.Kind() != reflect.Struct {
147+
panic("invalid type for default value, use WithDefaultFunc instead")
148+
}
149+
}
150+
opt.defSimple = def
151+
return opt
80152
}
81153

82-
func (value *Value[T]) WithDefault(def T) *Value[T] {
83-
value.def = def
84-
return value
154+
func (opt *Option[T]) WithEmptyAsDefault() *Option[T] {
155+
opt.emptyAsDef = true
156+
return opt
85157
}
86158

87-
func (value *Value[T]) DefaultValue() T {
88-
return value.def
159+
func (opt *Option[T]) DefaultValue() T {
160+
if opt.defFunc != nil {
161+
return opt.defFunc()
162+
}
163+
return opt.defSimple
89164
}
90165

91-
func (value *Value[T]) WithFileConfig(cfgSecKey CfgSecKey) *Value[T] {
92-
value.cfgSecKey = cfgSecKey
93-
return value
166+
func (opt *Option[T]) WithFileConfig(cfgSecKey CfgSecKey) *Option[T] {
167+
opt.cfgSecKey = cfgSecKey
168+
return opt
169+
}
170+
171+
var allConfigOptions = map[string]OptionInterface{}
172+
173+
func NewOption[T any](dynKey string) *Option[T] {
174+
v := &Option[T]{dynKey: dynKey}
175+
allConfigOptions[dynKey] = v
176+
return v
94177
}
95178

96-
func ValueJSON[T any](dynKey string) *Value[T] {
97-
return &Value[T]{dynKey: dynKey}
179+
func GetConfigOption(dynKey string) OptionInterface {
180+
return allConfigOptions[dynKey]
98181
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package setting
5+
6+
import (
7+
"time"
8+
9+
"code.gitea.io/gitea/modules/setting/config"
10+
)
11+
12+
// WebBannerType fields are directly used in templates,
13+
// do remember to update the template if you change the fields
14+
type WebBannerType struct {
15+
DisplayEnabled bool
16+
ContentMessage string
17+
StartTimeUnix int64
18+
EndTimeUnix int64
19+
}
20+
21+
func (b WebBannerType) ShouldDisplay() bool {
22+
if !b.DisplayEnabled || b.ContentMessage == "" {
23+
return false
24+
}
25+
now := time.Now().Unix()
26+
if b.StartTimeUnix > 0 && now < b.StartTimeUnix {
27+
return false
28+
}
29+
if b.EndTimeUnix > 0 && now > b.EndTimeUnix {
30+
return false
31+
}
32+
return true
33+
}
34+
35+
type MaintenanceModeType struct {
36+
AdminWebAccessOnly bool
37+
StartTimeUnix int64
38+
EndTimeUnix int64
39+
}
40+
41+
func (m MaintenanceModeType) IsActive() bool {
42+
if !m.AdminWebAccessOnly {
43+
return false
44+
}
45+
now := time.Now().Unix()
46+
if m.StartTimeUnix > 0 && now < m.StartTimeUnix {
47+
return false
48+
}
49+
if m.EndTimeUnix > 0 && now > m.EndTimeUnix {
50+
return false
51+
}
52+
return true
53+
}
54+
55+
type InstanceStruct struct {
56+
WebBanner *config.Option[WebBannerType]
57+
MaintenanceMode *config.Option[MaintenanceModeType]
58+
}

modules/web/middleware/cookie.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import (
1414
"code.gitea.io/gitea/modules/util"
1515
)
1616

17-
const cookieRedirectTo = "redirect_to"
17+
const (
18+
CookieWebBannerDismissed = "gitea_disbnr"
19+
CookieTheme = "gitea_theme"
20+
cookieRedirectTo = "redirect_to"
21+
)
1822

1923
func GetRedirectToCookie(req *http.Request) string {
2024
return GetSiteCookie(req, cookieRedirectTo)

options/locale/locale_en-US.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"save": "Save",
8585
"add": "Add",
8686
"add_all": "Add All",
87+
"dismiss": "Dismiss",
8788
"remove": "Remove",
8889
"remove_all": "Remove All",
8990
"remove_label_str": "Remove item \"%s\"",
@@ -3278,6 +3279,13 @@
32783279
"admin.config.cache_test_failed": "Failed to probe the cache: %v.",
32793280
"admin.config.cache_test_slow": "Cache test successful, but response is slow: %s.",
32803281
"admin.config.cache_test_succeeded": "Cache test successful, got a response in %s.",
3282+
"admin.config.common.start_time": "Start time",
3283+
"admin.config.common.end_time": "End time",
3284+
"admin.config.common.skip_time_check": "Leave time empty (clear the field) to skip time check",
3285+
"admin.config.instance_maintenance": "Instance Maintenance",
3286+
"admin.config.instance_maintenance_mode.admin_web_access_only": "Only allow admin to access the web UI",
3287+
"admin.config.instance_web_banner.enabled": "Show banner",
3288+
"admin.config.instance_web_banner.message_placeholder": "Banner message (supports markdown)",
32813289
"admin.config.session_config": "Session Configuration",
32823290
"admin.config.session_provider": "Session Provider",
32833291
"admin.config.provider_config": "Provider Config",

routers/common/errpage.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
user_model "code.gitea.io/gitea/models/user"
1414
"code.gitea.io/gitea/modules/httpcache"
1515
"code.gitea.io/gitea/modules/log"
16+
"code.gitea.io/gitea/modules/reqctx"
1617
"code.gitea.io/gitea/modules/setting"
1718
"code.gitea.io/gitea/modules/templates"
1819
"code.gitea.io/gitea/modules/web/middleware"
@@ -36,9 +37,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in
3637
w.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions)
3738
}
3839

39-
tmplCtx := context.NewTemplateContext(req.Context(), req)
40-
tmplCtx["Locale"] = middleware.Locale(w, req)
41-
40+
tmplCtx := context.NewTemplateContextForWeb(reqctx.FromContext(req.Context()), req, middleware.Locale(w, req))
4241
w.WriteHeader(respCode)
4342

4443
outBuf := &bytes.Buffer{}

0 commit comments

Comments
 (0)