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)}
`; }