From 26d83c932a8cc6f6f984a76d6b57945f99664cb1 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 26 Feb 2026 16:16:11 +0100 Subject: [PATCH] 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 --- modules/markup/sanitizer_default.go | 2 +- modules/setting/config.go | 26 ++- modules/setting/config/value.go | 163 +++++++++---- modules/setting/config_option_instance.go | 58 +++++ modules/web/middleware/cookie.go | 6 +- options/locale/locale_en-US.json | 8 + routers/common/errpage.go | 5 +- routers/common/maintenancemode.go | 43 ++++ routers/init.go | 1 + routers/private/internal.go | 2 + routers/web/admin/config.go | 78 ++----- routers/web/auth/auth.go | 5 + routers/web/misc/misc.go | 9 + routers/web/misc/webtheme.go | 2 +- routers/web/repo/view_home.go | 3 - routers/web/web.go | 2 +- services/context/context.go | 10 +- services/context/context_template.go | 25 +- .../config_settings/config_settings.tmpl | 8 +- templates/admin/config_settings/instance.tmpl | 63 ++++++ .../admin/config_settings/repository.tmpl | 15 +- templates/admin/layout_head.tmpl | 2 +- templates/base/head_banner.tmpl | 11 + templates/base/head_navbar.tmpl | 1 + templates/shared/combomarkdowneditor.tmpl | 8 +- tests/integration/admin_config_test.go | 46 ++++ tests/integration/config_instance_test.go | 126 +++++++++++ web_src/css/admin.css | 8 + web_src/css/modules/container.css | 18 ++ web_src/js/features/admin/config.test.ts | 41 ++++ web_src/js/features/admin/config.ts | 214 ++++++++++++++++-- web_src/js/features/common-fetch-action.ts | 13 +- .../js/features/comp/ComboMarkdownEditor.ts | 4 +- web_src/js/features/repo-editor.ts | 2 +- 34 files changed, 870 insertions(+), 158 deletions(-) create mode 100644 modules/setting/config_option_instance.go create mode 100644 routers/common/maintenancemode.go create mode 100644 templates/admin/config_settings/instance.tmpl create mode 100644 templates/base/head_banner.tmpl create mode 100644 tests/integration/config_instance_test.go create mode 100644 web_src/js/features/admin/config.test.ts diff --git a/modules/markup/sanitizer_default.go b/modules/markup/sanitizer_default.go index 7fdf66c4bc..77ba8bf4f4 100644 --- a/modules/markup/sanitizer_default.go +++ b/modules/markup/sanitizer_default.go @@ -81,7 +81,7 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy { "data-markdown-generated-content", "data-attr-class", } generalSafeElements := []string{ - "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", + "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "center", "i", "strong", "em", "a", "pre", "code", "img", "tt", "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", "details", "caption", "figure", "figcaption", diff --git a/modules/setting/config.go b/modules/setting/config.go index fb99325a95..bde8e4ac2a 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -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"), }, } } diff --git a/modules/setting/config/value.go b/modules/setting/config/value.go index 301c60f5e8..bd91add97a 100644 --- a/modules/setting/config/value.go +++ b/modules/setting/config/value.go @@ -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] } diff --git a/modules/setting/config_option_instance.go b/modules/setting/config_option_instance.go new file mode 100644 index 0000000000..6d97055a75 --- /dev/null +++ b/modules/setting/config_option_instance.go @@ -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] +} diff --git a/modules/web/middleware/cookie.go b/modules/web/middleware/cookie.go index f98aceba10..336c276fe8 100644 --- a/modules/web/middleware/cookie.go +++ b/modules/web/middleware/cookie.go @@ -14,7 +14,11 @@ import ( "code.gitea.io/gitea/modules/util" ) -const cookieRedirectTo = "redirect_to" +const ( + CookieWebBannerDismissed = "gitea_disbnr" + CookieTheme = "gitea_theme" + cookieRedirectTo = "redirect_to" +) func GetRedirectToCookie(req *http.Request) string { return GetSiteCookie(req, cookieRedirectTo) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 97e2ebe0d1..bcd28f2deb 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -84,6 +84,7 @@ "save": "Save", "add": "Add", "add_all": "Add All", + "dismiss": "Dismiss", "remove": "Remove", "remove_all": "Remove All", "remove_label_str": "Remove item \"%s\"", @@ -3278,6 +3279,13 @@ "admin.config.cache_test_failed": "Failed to probe the cache: %v.", "admin.config.cache_test_slow": "Cache test successful, but response is slow: %s.", "admin.config.cache_test_succeeded": "Cache test successful, got a response in %s.", + "admin.config.common.start_time": "Start time", + "admin.config.common.end_time": "End time", + "admin.config.common.skip_time_check": "Leave time empty (clear the field) to skip time check", + "admin.config.instance_maintenance": "Instance Maintenance", + "admin.config.instance_maintenance_mode.admin_web_access_only": "Only allow admin to access the web UI", + "admin.config.instance_web_banner.enabled": "Show banner", + "admin.config.instance_web_banner.message_placeholder": "Banner message (supports markdown)", "admin.config.session_config": "Session Configuration", "admin.config.session_provider": "Session Provider", "admin.config.provider_config": "Provider Config", diff --git a/routers/common/errpage.go b/routers/common/errpage.go index b14ab8bcf8..2406cf443f 100644 --- a/routers/common/errpage.go +++ b/routers/common/errpage.go @@ -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{} diff --git a/routers/common/maintenancemode.go b/routers/common/maintenancemode.go new file mode 100644 index 0000000000..b5827ac94f --- /dev/null +++ b/routers/common/maintenancemode.go @@ -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) + }) + } +} diff --git a/routers/init.go b/routers/init.go index 82a5378263..8874236a60 100644 --- a/routers/init.go +++ b/routers/init.go @@ -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()) diff --git a/routers/private/internal.go b/routers/private/internal.go index 55a11aa3dd..2d5436468b 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -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) diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 774b31ab98..79e969fd5e 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -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 diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index bc0939d92a..9529525a27 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -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)) } diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index 59b97c1717..3d2f624263 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -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() +} diff --git a/routers/web/misc/webtheme.go b/routers/web/misc/webtheme.go index 076bdf8fda..76ddf4b567 100644 --- a/routers/web/misc/webtheme.go +++ b/routers/web/misc/webtheme.go @@ -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) } } diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index 00d30bedef..d1a969cf2d 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -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, ":") diff --git a/routers/web/web.go b/routers/web/web.go index b1b31a7ec9..ce037afe1b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/context/context.go b/services/context/context.go index ccd0057f59..97b9890f43 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -100,12 +100,12 @@ func GetValidateContext(req *http.Request) (ctx *ValidateContext) { return ctx } -func NewTemplateContextForWeb(ctx *Context) TemplateContext { - tmplCtx := NewTemplateContext(ctx, ctx.Req) - tmplCtx["Locale"] = ctx.Base.Locale +func NewTemplateContextForWeb(ctx reqctx.RequestContext, req *http.Request, locale translation.Locale) TemplateContext { + tmplCtx := NewTemplateContext(ctx, req) + tmplCtx["Locale"] = locale tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx) tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx) - tmplCtx["RootData"] = ctx.Data + tmplCtx["RootData"] = ctx.GetData() tmplCtx["Consts"] = map[string]any{ "RepoUnitTypeCode": unit.TypeCode, "RepoUnitTypeIssues": unit.TypeIssues, @@ -132,7 +132,7 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context { Repo: &Repository{}, Org: &Organization{}, } - ctx.TemplateContext = NewTemplateContextForWeb(ctx) + ctx.TemplateContext = NewTemplateContextForWeb(ctx, ctx.Base.Req, ctx.Base.Locale) ctx.Flash = &middleware.Flash{DataStore: ctx, Values: url.Values{}} ctx.SetContextValue(WebContextKey, ctx) return ctx diff --git a/services/context/context_template.go b/services/context/context_template.go index c1045136ee..52c7461187 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -6,8 +6,11 @@ package context import ( "context" "net/http" + "strconv" "time" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/webtheme" ) @@ -17,6 +20,10 @@ func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext return TemplateContext{"_ctx": ctx, "_req": req} } +func (c TemplateContext) req() *http.Request { + return c["_req"].(*http.Request) +} + func (c TemplateContext) parentContext() context.Context { return c["_ctx"].(context.Context) } @@ -38,7 +45,6 @@ func (c TemplateContext) Value(key any) any { } func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo { - req := c["_req"].(*http.Request) var themeName string if webCtx := GetWebContext(c); webCtx != nil { if webCtx.Doer != nil { @@ -46,9 +52,20 @@ func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo { } } if themeName == "" { - if cookieTheme, _ := req.Cookie("gitea_theme"); cookieTheme != nil { - themeName = cookieTheme.Value - } + themeName = middleware.GetSiteCookie(c.req(), middleware.CookieTheme) } return webtheme.GuaranteeGetThemeMetaInfo(themeName) } + +func (c TemplateContext) CurrentWebBanner() *setting.WebBannerType { + // Using revision as a simple approach to determine if the banner has been changed after the user dismissed it. + // There could be some false-positives because revision can be changed even if the banner isn't. + // While it should be still good enough (no admin would keep changing the settings) and doesn't really harm end users (just a few more times to see the banner) + // So it doesn't need to make it more complicated by allocating unique IDs or using hashes. + dismissedBannerRevision, _ := strconv.Atoi(middleware.GetSiteCookie(c.req(), middleware.CookieWebBannerDismissed)) + banner, revision, _ := setting.Config().Instance.WebBanner.ValueRevision(c) + if banner.ShouldDisplay() && dismissedBannerRevision != revision { + return &banner + } + return nil +} diff --git a/templates/admin/config_settings/config_settings.tmpl b/templates/admin/config_settings/config_settings.tmpl index 1ef764a58b..6d1db4f89f 100644 --- a/templates/admin/config_settings/config_settings.tmpl +++ b/templates/admin/config_settings/config_settings.tmpl @@ -1,7 +1,7 @@ -{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}} +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config" "dataGlobalInit" "initAdminConfigSettings")}} -{{template "admin/config_settings/avatars" .}} - -{{template "admin/config_settings/repository" .}} + {{template "admin/config_settings/avatars" .}} + {{template "admin/config_settings/repository" .}} + {{template "admin/config_settings/instance" .}} {{template "admin/layout_footer" .}} diff --git a/templates/admin/config_settings/instance.tmpl b/templates/admin/config_settings/instance.tmpl new file mode 100644 index 0000000000..da28fffddb --- /dev/null +++ b/templates/admin/config_settings/instance.tmpl @@ -0,0 +1,63 @@ +

{{ctx.Locale.Tr "admin.config.instance_maintenance"}}

+
+
+ {{$cfgOpt := $.SystemConfig.Instance.MaintenanceMode}} + {{$cfgKey := $cfgOpt.DynKey}} + {{$maintenanceMode := $cfgOpt.Value ctx}} + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+
{{ctx.Locale.Tr "admin.config.common.skip_time_check"}}
+
+ +
+ + {{$cfgOpt = $.SystemConfig.Instance.WebBanner}} + {{$cfgKey = $cfgOpt.DynKey}} + {{$banner := $cfgOpt.Value ctx}} + +
+
+ + +
+ {{template "shared/combomarkdowneditor" (dict + "ContainerClasses" "web-banner-content-editor" + "TextareaName" (print $cfgKey ".ContentMessage") + "TextareaContent" $banner.ContentMessage + "TextareaPlaceholder" (ctx.Locale.Tr "admin.config.instance_web_banner.message_placeholder") + )}} +
+
+
+
+ + +
+
+ + +
+
+
{{ctx.Locale.Tr "admin.config.common.skip_time_check"}}
+
+
+ +
+
+
diff --git a/templates/admin/config_settings/repository.tmpl b/templates/admin/config_settings/repository.tmpl index 9a37707835..2d5845ba4e 100644 --- a/templates/admin/config_settings/repository.tmpl +++ b/templates/admin/config_settings/repository.tmpl @@ -2,24 +2,23 @@ {{ctx.Locale.Tr "repository"}}
-
+ + {{$cfg := .SystemConfig.Repository.OpenWithEditorApps}}
{{ctx.Locale.Tr "admin.config.open_with_editor_app_help"}} -
{{.DefaultOpenWithEditorAppsString}}
+
{{$cfg.DefaultValue.ToTextareaString}}
- {{$cfg := .SystemConfig.Repository.OpenWithEditorApps}} - - + {{/* TODO: OPEN-WITH-EDITOR-APP-JSON: use a simple textarea */}} +
+ {{$cfg = .SystemConfig.Repository.GitGuideRemoteName}}
- {{$cfg = .SystemConfig.Repository.GitGuideRemoteName}} - - +
diff --git a/templates/admin/layout_head.tmpl b/templates/admin/layout_head.tmpl index 7cc6624d50..397516da5d 100644 --- a/templates/admin/layout_head.tmpl +++ b/templates/admin/layout_head.tmpl @@ -1,5 +1,5 @@ {{template "base/head" .ctxData}} -
+
{{template "admin/navbar" .ctxData}}
diff --git a/templates/base/head_banner.tmpl b/templates/base/head_banner.tmpl new file mode 100644 index 0000000000..d237161622 --- /dev/null +++ b/templates/base/head_banner.tmpl @@ -0,0 +1,11 @@ +{{$banner := ctx.CurrentWebBanner}} +{{if $banner}} +
+
+ {{ctx.RenderUtils.MarkdownToHtml $banner.ContentMessage}} +
+ +
+{{end}} diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index cda1f377b4..28fcee023f 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -176,3 +176,4 @@
{{end}} +{{template "base/head_banner"}} diff --git a/templates/shared/combomarkdowneditor.tmpl b/templates/shared/combomarkdowneditor.tmpl index 1c48ebbb95..3c0759f9b2 100644 --- a/templates/shared/combomarkdowneditor.tmpl +++ b/templates/shared/combomarkdowneditor.tmpl @@ -4,7 +4,7 @@ * ContainerClasses: additional classes for the container element * MarkdownPreviewInRepo: the repo to preview markdown * MarkdownPreviewContext: preview context (the related url path when rendering) for the preview tab, eg: repo link or user home link -* MarkdownPreviewMode: content mode for the editor, eg: wiki, comment or default +* MarkdownPreviewMode: content mode for the editor, eg: wiki, comment or default, can be disabled by "none" * TextareaName: name attribute for the textarea * TextareaContent: content for the textarea * TextareaMaxLength: maxlength attribute for the textarea @@ -29,10 +29,12 @@ data-preview-url="{{$previewUrl}}" data-preview-context="{{$previewContext}}" > + {{if ne $previewMode "none"}} + {{end}}
@@ -87,9 +89,9 @@
- + x - +
diff --git a/tests/integration/admin_config_test.go b/tests/integration/admin_config_test.go index eec7e75fd9..5f882e8a55 100644 --- a/tests/integration/admin_config_test.go +++ b/tests/integration/admin_config_test.go @@ -7,10 +7,14 @@ import ( "net/http" "testing" + "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAdminConfig(t *testing.T) { @@ -20,4 +24,46 @@ func TestAdminConfig(t *testing.T) { req := NewRequest(t, "GET", "/-/admin/config") resp := session.MakeRequest(t, req, http.StatusOK) assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) + + t.Run("OpenEditorWithApps", func(t *testing.T) { + cfg := setting.Config().Repository.OpenWithEditorApps + editorApps := cfg.Value(t.Context()) + assert.Len(t, editorApps, 3) + assert.False(t, cfg.HasValue(t.Context())) + + require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[]"})) + config.GetDynGetter().InvalidateCache() + + editorApps = cfg.Value(t.Context()) + assert.Len(t, editorApps, 3) + assert.False(t, cfg.HasValue(t.Context())) + + require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[{}]"})) + config.GetDynGetter().InvalidateCache() + + editorApps = cfg.Value(t.Context()) + assert.Len(t, editorApps, 1) + assert.True(t, cfg.HasValue(t.Context())) + }) + + t.Run("InstanceWebBanner", func(t *testing.T) { + banner, rev1, has := setting.Config().Instance.WebBanner.ValueRevision(t.Context()) + assert.False(t, has) + assert.Equal(t, setting.WebBannerType{}, banner) + + req = NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{ + "key": "instance.web_banner", + "value": `{"DisplayEnabled":true,"ContentMessage":"test-msg","StartTimeUnix":123,"EndTimeUnix":456}`, + }) + session.MakeRequest(t, req, http.StatusOK) + banner, rev2, has := setting.Config().Instance.WebBanner.ValueRevision(t.Context()) + assert.NotEqual(t, rev1, rev2) + assert.True(t, has) + assert.Equal(t, setting.WebBannerType{ + DisplayEnabled: true, + ContentMessage: "test-msg", + StartTimeUnix: 123, + EndTimeUnix: 456, + }, banner) + }) } diff --git a/tests/integration/config_instance_test.go b/tests/integration/config_instance_test.go new file mode 100644 index 0000000000..c9aa7ab745 --- /dev/null +++ b/tests/integration/config_instance_test.go @@ -0,0 +1,126 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + "time" + + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockSystemConfig[T any](t *testing.T, opt *config.Option[T], v T) func() { + jsonBuf, _ := json.Marshal(v) + old := opt.Value(t.Context()) + require.NoError(t, system_model.SetSettings(t.Context(), map[string]string{opt.DynKey(): string(jsonBuf)})) + config.GetDynGetter().InvalidateCache() + return func() { + jsonBuf, _ := json.Marshal(old) + require.NoError(t, system_model.SetSettings(t.Context(), map[string]string{opt.DynKey(): string(jsonBuf)})) + config.GetDynGetter().InvalidateCache() + } +} + +func TestInstance(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("WebBanner", func(t *testing.T) { + t.Run("Visibility", func(t *testing.T) { + defer mockSystemConfig(t, setting.Config().Instance.WebBanner, setting.WebBannerType{ + DisplayEnabled: true, + ContentMessage: "Planned **upgrade** in progress.", + })() + + t.Run("AnonymousUserSeesBanner", func(t *testing.T) { + resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK) + assert.Contains(t, resp.Body.String(), "Planned upgrade in progress.") + }) + + t.Run("NormalUserSeesBanner", func(t *testing.T) { + sess := loginUser(t, "user2") + resp := sess.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK) + assert.Contains(t, resp.Body.String(), "Planned upgrade in progress.") + }) + + t.Run("AdminSeesBannerWithoutEditHint", func(t *testing.T) { + sess := loginUser(t, "user1") + resp := sess.MakeRequest(t, NewRequest(t, "GET", "/-/admin"), http.StatusOK) + assert.Contains(t, resp.Body.String(), "Planned upgrade in progress.") + assert.NotContains(t, resp.Body.String(), "Edit this banner") + }) + + t.Run("APIRequestUnchanged", func(t *testing.T) { + MakeRequest(t, NewRequest(t, "GET", "/api/v1/version"), http.StatusOK) + }) + }) + + t.Run("TimeWindow", func(t *testing.T) { + now := time.Now().Unix() + defer mockSystemConfig(t, setting.Config().Instance.WebBanner, setting.WebBannerType{ + DisplayEnabled: true, + ContentMessage: "Future banner", + StartTimeUnix: now + 3600, + EndTimeUnix: now + 7200, + })() + + resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK) + assert.NotContains(t, resp.Body.String(), "Future banner") + + defer mockSystemConfig(t, setting.Config().Instance.WebBanner, setting.WebBannerType{ + DisplayEnabled: true, + ContentMessage: "Expired banner", + StartTimeUnix: now - 7200, + EndTimeUnix: now - 3600, + })() + + resp = MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK) + assert.NotContains(t, resp.Body.String(), "Expired banner") + }) + }) + + t.Run("MaintenanceMode", func(t *testing.T) { + defer mockSystemConfig(t, setting.Config().Instance.WebBanner, setting.WebBannerType{ + DisplayEnabled: true, + ContentMessage: "MaintenanceModeBanner", + })() + defer mockSystemConfig(t, setting.Config().Instance.MaintenanceMode, setting.MaintenanceModeType{AdminWebAccessOnly: true})() + + t.Run("AnonymousUser", func(t *testing.T) { + req := NewRequest(t, "GET", "/") + req.Header.Add("Accept", "text/html") + resp := MakeRequest(t, req, http.StatusServiceUnavailable) + assert.Contains(t, resp.Body.String(), "MaintenanceModeBanner") + assert.Contains(t, resp.Body.String(), `href="/user/login"`) // it must contain the login link + + MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusOK) + MakeRequest(t, NewRequest(t, "GET", "/-/admin"), http.StatusSeeOther) + MakeRequest(t, NewRequest(t, "GET", "/api/internal/dummy"), http.StatusForbidden) + }) + + t.Run("AdminLogin", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{"user_name": "user1", "password": userPassword}) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/-/admin", resp.Header().Get("Location")) + + sess := loginUser(t, "user1") + req = NewRequest(t, "GET", "/") + req.Header.Add("Accept", "text/html") + resp = sess.MakeRequest(t, req, http.StatusServiceUnavailable) + assert.Contains(t, resp.Body.String(), "MaintenanceModeBanner") + + resp = sess.MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusSeeOther) + assert.Equal(t, "/-/admin", resp.Header().Get("Location")) + + sess.MakeRequest(t, NewRequest(t, "GET", "/-/admin"), http.StatusOK) + }) + }) +} diff --git a/web_src/css/admin.css b/web_src/css/admin.css index cda38c6ddd..d84aa7e811 100644 --- a/web_src/css/admin.css +++ b/web_src/css/admin.css @@ -49,3 +49,11 @@ gap: 1rem; margin-bottom: 1rem; } + +.web-banner-content-editor .render-content.render-preview { + /* use the styles from ".ui.message" */ + padding: 1em 1.5em; + border: 1px solid var(--color-info-border); + background: var(--color-info-bg); + color: var(--color-info-text); +} diff --git a/web_src/css/modules/container.css b/web_src/css/modules/container.css index 236cb986fd..1b2a1d64b7 100644 --- a/web_src/css/modules/container.css +++ b/web_src/css/modules/container.css @@ -14,3 +14,21 @@ .ui.container.medium-width { width: 800px; } + +.ui.message.web-banner-container { + position: relative; + margin: 0; + border-radius: 0; +} + +.ui.message.web-banner-container > .web-banner-content { + width: 1280px; + max-width: calc(100% - calc(2 * var(--page-margin-x))); + margin: auto; +} + +.ui.message.web-banner-container > button.dismiss-banner { + position: absolute; + right: 20px; + top: 15px; +} diff --git a/web_src/js/features/admin/config.test.ts b/web_src/js/features/admin/config.test.ts new file mode 100644 index 0000000000..e44ccb2a94 --- /dev/null +++ b/web_src/js/features/admin/config.test.ts @@ -0,0 +1,41 @@ +import {ConfigFormValueMapper} from './config.ts'; + +test('ConfigFormValueMapper', () => { + document.body.innerHTML = ` + + + + + + + + + + + + + + + +`; + + const form = document.querySelector('form')!; + const mapper = new ConfigFormValueMapper(form); + mapper.fillFromSystemConfig(); + const formData = mapper.collectToFormData(); + const result: Record = {}; + const keys = [], values = []; + for (const [key, value] of formData.entries()) { + if (key === 'key') keys.push(value as string); + if (key === 'value') values.push(value as string); + } + for (let i = 0; i < keys.length; i++) { + result[keys[i]] = values[i]; + } + expect(result).toEqual({ + 'k1': 'true', + 'k2': '"k2-val"', + 'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend + 'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}', + }); +}); diff --git a/web_src/js/features/admin/config.ts b/web_src/js/features/admin/config.ts index 76f7c1db50..047c1a46a4 100644 --- a/web_src/js/features/admin/config.ts +++ b/web_src/js/features/admin/config.ts @@ -1,24 +1,210 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts'; import {POST} from '../../modules/fetch.ts'; +import {registerGlobalInitFunc} from '../../modules/observer.ts'; +import {queryElems} from '../../utils/dom.ts'; +import {submitFormFetchAction} from '../common-fetch-action.ts'; const {appSubUrl} = window.config; -export function initAdminConfigs(): void { - const elAdminConfig = document.querySelector('.page-content.admin.config'); - if (!elAdminConfig) return; +function initSystemConfigAutoCheckbox(el: HTMLInputElement) { + el.addEventListener('change', async () => { + // if the checkbox is inside a form, we assume it's handled by the form submit and do not send an individual request + if (el.closest('form')) return; + try { + const resp = await POST(`${appSubUrl}/-/admin/config`, { + data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key')!, value: String(el.checked)}), + }); + const json: Record = await resp.json(); + if (json.errorMessage) throw new Error(json.errorMessage); + } catch (ex) { + showTemporaryTooltip(el, ex.toString()); + el.checked = !el.checked; + } + }); +} - for (const el of elAdminConfig.querySelectorAll('input[type="checkbox"][data-config-dyn-key]')) { - el.addEventListener('change', async () => { +type GeneralFormFieldElement = HTMLInputElement; + +function unsupportedElement(el: Element): never { + // HINT: for future developers: if you need to handle a config that cannot be directly mapped to a form element, you should either: + // * Add a "hidden" input to store the value (not configurable) + // * Design a new "component" to handle the config + throw new Error(`Unsupported config form value mapping for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add more and design carefully`); +} + +function requireExplicitValueType(el: Element): never { + throw new Error(`Unsupported config form value type for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add explicit value type with "data-config-value-type" attribute`); +} + +// try to extract the subKey for the config value from the element name +// * return '' if the element name exactly matches the config key, which means the value is directly stored in the element +// * return null if the config key not match +function extractElemConfigSubKey(el: GeneralFormFieldElement, dynKey: string): string | null { + if (el.name === dynKey) return ''; + if (el.name.startsWith(`${dynKey}.`)) return el.name.slice(dynKey.length + 1); // +1 for the dot + return null; +} + +// Due to the different design between HTML form elements and the JSON struct of the config values, we need to explicitly define some types. +// * checkbox can be used for boolean value, it can also be used for multiple values (array) +type ConfigValueType = 'boolean' | 'string' | 'number' | 'timestamp'; // TODO: support more types like array, not used at the moment. + +function toDatetimeLocalValue(unixSeconds: number) { + const d = new Date(unixSeconds * 1000); + return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16); +} + +export class ConfigFormValueMapper { + form: HTMLFormElement; + presetJsonValues: Record = {}; + presetValueTypes: Record = {}; + + constructor(form: HTMLFormElement) { + this.form = form; + for (const el of queryElems(form, '[data-config-value-json]')) { + const dynKey = el.getAttribute('data-config-dyn-key')!; + const jsonStr = el.getAttribute('data-config-value-json'); try { - const resp = await POST(`${appSubUrl}/-/admin/config`, { - data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key')!, value: String(el.checked)}), - }); - const json: Record = await resp.json(); - if (json.errorMessage) throw new Error(json.errorMessage); - } catch (ex) { - showTemporaryTooltip(el, ex.toString()); - el.checked = !el.checked; + this.presetJsonValues[dynKey] = JSON.parse(jsonStr || '{}'); // empty string also is valid, default to an empty object + } catch (error) { + this.presetJsonValues[dynKey] = {}; // in case the value in database is corrupted, don't break the whole form + console.error(`Error parsing JSON for config ${dynKey}:`, error); } - }); + } + for (const el of queryElems(form, '[data-config-value-type]')) { + const valKey = el.getAttribute('data-config-dyn-key') || el.name; + this.presetValueTypes[valKey] = el.getAttribute('data-config-value-type')! as ConfigValueType; + } + } + + // try to assign the config value to the form element, return true if assigned successfully, + // otherwise return false (e.g. the element is not related to the config key) + assignConfigValueToFormElement(el: GeneralFormFieldElement, dynKey: string, cfgVal: any) { + const subKey = extractElemConfigSubKey(el, dynKey); + if (subKey === null) return false; // if not match, skip + + const val = subKey ? cfgVal![subKey] : cfgVal; + if (val === null) return true; // if name matches, but no value to assign, also succeed because the form element does exist + const valType = this.presetValueTypes[el.name]; + if (el.matches('[type="checkbox"]')) { + if (valType !== 'boolean') requireExplicitValueType(el); + el.checked = Boolean(val ?? el.checked); + } else if (el.matches('[type="datetime-local"]')) { + if (valType !== 'timestamp') requireExplicitValueType(el); + if (val) el.value = toDatetimeLocalValue(val); + } else if (el.matches('textarea')) { + el.value = String(val ?? el.value); + } else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') { + el.value = String(val ?? el.value); + } else { + unsupportedElement(el); + } + return true; + } + + collectConfigValueFromElement(el: GeneralFormFieldElement, _oldVal: any = null) { + let val: any; + const valType = this.presetValueTypes[el.name]; + if (el.matches('[type="checkbox"]')) { + if (valType !== 'boolean') requireExplicitValueType(el); + val = el.checked; + // oldVal: for future use when we support array value with checkbox + } else if (el.matches('[type="datetime-local"]')) { + if (valType !== 'timestamp') requireExplicitValueType(el); + val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null. + } else if (el.matches('textarea')) { + val = el.value; + } else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') { + val = el.value; + } else { + unsupportedElement(el); + } + return val; + } + + collectConfigSubValues(namedElems: Array, dynKey: string, cfgVal: Record) { + for (let idx = 0; idx < namedElems.length; idx++) { + const el = namedElems[idx]; + if (!el) continue; + const subKey = extractElemConfigSubKey(el, dynKey); + if (!subKey) continue; // if not match, skip + cfgVal[subKey] = this.collectConfigValueFromElement(el, cfgVal[subKey]); + namedElems[idx] = null; + } + } + + fillFromSystemConfig() { + for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) { + const elems = this.form.querySelectorAll(`[name^="${CSS.escape(dynKey)}"]`); + let assigned = false; + for (const el of elems) { + if (this.assignConfigValueToFormElement(el, dynKey, cfgVal)) { + assigned = true; + } + } + if (!assigned) throw new Error(`Could not find form element for config ${dynKey}, please check the form design and json struct`); + } + } + + // TODO: OPEN-WITH-EDITOR-APP-JSON: need to use the same logic as backend + marshalConfigValueOpenWithEditorApps(cfgVal: string): string { + const apps: Array<{DisplayName: string, OpenURL: string}> = []; + const lines = cfgVal.split('\n'); + for (const line of lines) { + let [displayName, openUrl] = line.split('=', 2); + displayName = displayName.trim(); + openUrl = openUrl?.trim() ?? ''; + if (!displayName || !openUrl) continue; + apps.push({DisplayName: displayName, OpenURL: openUrl}); + } + return JSON.stringify(apps); + } + + marshalConfigValue(dynKey: string, cfgVal: any): string { + if (dynKey === 'repository.open-with.editor-apps') return this.marshalConfigValueOpenWithEditorApps(cfgVal); + return JSON.stringify(cfgVal); + } + + collectToFormData(): FormData { + const namedElems: Array = []; + queryElems(this.form, '[name]', (el) => namedElems.push(el as GeneralFormFieldElement)); + + // first, process the config options with sub values, for example: + // merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar" + const formData = new FormData(); + for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) { + this.collectConfigSubValues(namedElems, dynKey, cfgVal); + formData.append('key', dynKey); + formData.append('value', this.marshalConfigValue(dynKey, cfgVal)); + } + + // now, the namedElems should only contain the config options without sub values, + // directly store the value in formData with key as the element name, for example: + for (const el of namedElems) { + if (!el) continue; + const dynKey = el.name; + const newVal = this.collectConfigValueFromElement(el); + formData.append('key', dynKey); + formData.append('value', this.marshalConfigValue(dynKey, newVal)); + } + return formData; } } + +function initSystemConfigForm(form: HTMLFormElement) { + const formMapper = new ConfigFormValueMapper(form); + formMapper.fillFromSystemConfig(); + form.addEventListener('submit', async (e) => { + if (!form.reportValidity()) return; + e.preventDefault(); + const formData = formMapper.collectToFormData(); + await submitFormFetchAction(form, {formData}); + }); +} + +export function initAdminConfigs(): void { + registerGlobalInitFunc('initAdminConfigSettings', (el) => { + queryElems(el, 'input[type="checkbox"][data-config-dyn-key]', initSystemConfigAutoCheckbox); + queryElems(el, 'form.system-config-form', initSystemConfigForm); + }); +} diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts index 0714de95c8..0d72fb32c9 100644 --- a/web_src/js/features/common-fetch-action.ts +++ b/web_src/js/features/common-fetch-action.ts @@ -67,10 +67,15 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) { e.preventDefault(); - await submitFormFetchAction(formEl, submitEventSubmitter(e)); + await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)}); } -export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitter?: HTMLElement) { +type SubmitFormFetchActionOpts = { + formSubmitter?: HTMLElement; + formData?: FormData; +}; + +export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) { if (formEl.classList.contains('is-loading')) return; formEl.classList.add('is-loading'); @@ -80,8 +85,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitt const formMethod = formEl.getAttribute('method') || 'get'; const formActionUrl = formEl.getAttribute('action') || window.location.href; - const formData = new FormData(formEl); - const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')]; + const formData = opts.formData ?? new FormData(formEl); + const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')]; if (submitterName) { formData.append(submitterName, submitterValue || ''); } diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index 42104947df..5b470ea03d 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -266,8 +266,8 @@ export class ComboMarkdownEditor { addTableButton.addEventListener('click', () => addTablePanelTippy.show()); addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => { - let rows = parseInt(addTablePanel.querySelector('[name=rows]')!.value); - let cols = parseInt(addTablePanel.querySelector('[name=cols]')!.value); + let rows = parseInt(addTablePanel.querySelector('.add-table-rows')!.value); + let cols = parseInt(addTablePanel.querySelector('.add-table-cols')!.value); rows = Math.max(1, Math.min(100, rows)); cols = Math.max(1, Math.min(100, cols)); replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`); diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts index b100cd7c91..4957e83d00 100644 --- a/web_src/js/features/repo-editor.ts +++ b/web_src/js/features/repo-editor.ts @@ -197,5 +197,5 @@ export function initRepoEditor() { export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) { // the content is from the server, so it is safe to use innerHTML - previewPanel.innerHTML = html`
${htmlRaw(htmlContent)}
`; + previewPanel.innerHTML = html`
${htmlRaw(htmlContent)}
`; }