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:
Nicolas
2026-02-26 16:16:11 +01:00
committed by GitHub
parent d0f92cb0a1
commit 26d83c932a
34 changed files with 870 additions and 158 deletions
+2 -3
View File
@@ -13,6 +13,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware"
@@ -36,9 +37,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in
w.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions)
}
tmplCtx := context.NewTemplateContext(req.Context(), req)
tmplCtx["Locale"] = middleware.Locale(w, req)
tmplCtx := context.NewTemplateContextForWeb(reqctx.FromContext(req.Context()), req, middleware.Locale(w, req))
w.WriteHeader(respCode)
outBuf := &bytes.Buffer{}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"strings"
"code.gitea.io/gitea/modules/setting"
)
func isMaintenanceModeAllowedRequest(req *http.Request) bool {
if strings.HasPrefix(req.URL.Path, "/-/") {
// URLs like "/-/admin", "/-/fetch-redirect" and "/-/markup" are still accessible in maintenance mode
return true
}
if strings.HasPrefix(req.URL.Path, "/api/internal/") {
// internal APIs should be allowed
return true
}
if strings.HasPrefix(req.URL.Path, "/user/") {
// URLs like "/user/signin" and "/user/signup" are still accessible in maintenance mode
return true
}
if strings.HasPrefix(req.URL.Path, "/assets/") {
return true
}
return false
}
func MaintenanceModeHandler() func(h http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
maintenanceMode := setting.Config().Instance.MaintenanceMode.Value(req.Context())
if maintenanceMode.IsActive() && !isMaintenanceModeAllowedRequest(req) {
renderServiceUnavailable(resp, req)
return
}
next.ServeHTTP(resp, req)
})
}
}
+1
View File
@@ -181,6 +181,7 @@ func InitWebInstalled(ctx context.Context) {
func NormalRoutes() *web.Router {
r := web.NewRouter()
r.Use(common.ProtocolMiddlewares()...)
r.Use(common.MaintenanceModeHandler())
r.Mount("/", web_routers.Routes())
r.Mount("/api/v1", apiv1.Routes())
+2
View File
@@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/routers/web/misc"
"code.gitea.io/gitea/services/context"
"gitea.com/go-chi/binding"
@@ -59,6 +60,7 @@ func Routes() *web.Router {
// Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers.
r.Use(chi_middleware.RealIP)
r.Get("/dummy", misc.DummyOK)
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
+25 -53
View File
@@ -5,9 +5,9 @@
package admin
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
system_model "code.gitea.io/gitea/models/system"
@@ -145,7 +145,6 @@ func Config(ctx *context.Context) {
ctx.Data["Service"] = setting.Service
ctx.Data["DbCfg"] = setting.Database
ctx.Data["Webhook"] = setting.Webhook
ctx.Data["MailerEnabled"] = false
if setting.MailService != nil {
ctx.Data["MailerEnabled"] = true
@@ -191,52 +190,27 @@ func ConfigSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.config_settings")
ctx.Data["PageIsAdminConfig"] = true
ctx.Data["PageIsAdminConfigSettings"] = true
ctx.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString()
ctx.HTML(http.StatusOK, tplConfigSettings)
}
func validateConfigKeyValue(dynKey, input string) error {
opt := config.GetConfigOption(dynKey)
if opt == nil {
return util.NewInvalidArgumentErrorf("unknown config key: %s", dynKey)
}
const limit = 64 * 1024
if len(input) > limit {
return util.NewInvalidArgumentErrorf("value length exceeds limit of %d", limit)
}
if !json.Valid([]byte(input)) {
return util.NewInvalidArgumentErrorf("invalid json value for key: %s", dynKey)
}
return nil
}
func ChangeConfig(ctx *context.Context) {
cfg := setting.Config()
marshalBool := func(v string) ([]byte, error) {
b, _ := strconv.ParseBool(v)
return json.Marshal(b)
}
marshalString := func(emptyDefault string) func(v string) ([]byte, error) {
return func(v string) ([]byte, error) {
return json.Marshal(util.IfZero(v, emptyDefault))
}
}
marshalOpenWithApps := func(value string) ([]byte, error) {
// TODO: move the block alongside OpenWithEditorAppsType.ToTextareaString
lines := strings.Split(value, "\n")
var openWithEditorApps setting.OpenWithEditorAppsType
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
displayName, openURL, ok := strings.Cut(line, "=")
displayName, openURL = strings.TrimSpace(displayName), strings.TrimSpace(openURL)
if !ok || displayName == "" || openURL == "" {
continue
}
openWithEditorApps = append(openWithEditorApps, setting.OpenWithEditorApp{
DisplayName: strings.TrimSpace(displayName),
OpenURL: strings.TrimSpace(openURL),
})
}
return json.Marshal(openWithEditorApps)
}
marshallers := map[string]func(string) ([]byte, error){
cfg.Picture.DisableGravatar.DynKey(): marshalBool,
cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool,
cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps,
cfg.Repository.GitGuideRemoteName.DynKey(): marshalString(cfg.Repository.GitGuideRemoteName.DefaultValue()),
}
_ = ctx.Req.ParseForm()
configKeys := ctx.Req.Form["key"]
configValues := ctx.Req.Form["value"]
@@ -249,18 +223,16 @@ loop:
}
value := configValues[i]
marshaller, hasMarshaller := marshallers[key]
if !hasMarshaller {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
break loop
}
marshaledValue, err := marshaller(value)
err := validateConfigKeyValue(key, value)
if err != nil {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
if errors.Is(err, util.ErrInvalidArgument) {
ctx.JSONError(err.Error())
} else {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
}
break loop
}
configSettings[key] = string(marshaledValue)
configSettings[key] = value
}
if ctx.Written() {
return
+5
View File
@@ -162,6 +162,11 @@ func consumeAuthRedirectLink(ctx *context.Context) string {
}
func redirectAfterAuth(ctx *context.Context) {
if setting.Config().Instance.MaintenanceMode.Value(ctx).IsActive() {
// in maintenance mode, redirect to admin dashboard, it is the only accessible page
ctx.Redirect(setting.AppSubURL + "/-/admin")
return
}
ctx.RedirectToCurrentSite(consumeAuthRedirectLink(ctx))
}
+9
View File
@@ -6,12 +6,15 @@ package misc
import (
"net/http"
"path"
"strconv"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context"
)
func SSHInfo(rw http.ResponseWriter, req *http.Request) {
@@ -47,3 +50,9 @@ func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request
http.Redirect(w, req, path.Join(setting.StaticURLPrefix, target), http.StatusMovedPermanently)
}
}
func WebBannerDismiss(ctx *context.Context) {
_, rev, _ := setting.Config().Instance.WebBanner.ValueRevision(ctx)
middleware.SetSiteCookie(ctx.Resp, middleware.CookieWebBannerDismissed, strconv.Itoa(rev), 48*3600)
ctx.JSONOK()
}
+1 -1
View File
@@ -37,6 +37,6 @@ func WebThemeApply(ctx *context.Context) {
opts := &user_service.UpdateOptions{Theme: optional.Some(themeName)}
_ = user_service.UpdateUser(ctx, ctx.Doer, opts)
} else {
middleware.SetSiteCookie(ctx.Resp, "gitea_theme", themeName, 0)
middleware.SetSiteCookie(ctx.Resp, middleware.CookieTheme, themeName, 0)
}
}
-3
View File
@@ -69,9 +69,6 @@ func prepareHomeSidebarRepoTopics(ctx *context.Context) {
func prepareOpenWithEditorApps(ctx *context.Context) {
var tmplApps []map[string]any
apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx)
if len(apps) == 0 {
apps = setting.DefaultOpenWithEditorApps()
}
for _, app := range apps {
schema, _, _ := strings.Cut(app.OpenURL, ":")
+1 -1
View File
@@ -480,7 +480,7 @@ func registerWebRoutes(m *web.Router) {
}, optionsCorsHandler())
m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Post("/-/web-banner/dismiss", misc.WebBannerDismiss)
m.Get("/-/web-theme/list", misc.WebThemeList)
m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply)