0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-16 16:57:26 +02:00

Instance-wide (global) info banner and maintenance mode (#36571)

The banner allows site operators to communicate important announcements
(e.g., maintenance windows, policy updates, service notices) directly
within the UI.

The maintenance mode only allows admin to access the web UI.

* Fix #2345
* Fix #9618

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Nicolas 2026-02-26 16:16:11 +01:00 committed by GitHub
parent d0f92cb0a1
commit 26d83c932a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 870 additions and 158 deletions

View File

@ -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",

View File

@ -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"),
},
}
}

View File

@ -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]
}

View File

@ -0,0 +1,58 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"time"
"code.gitea.io/gitea/modules/setting/config"
)
// WebBannerType fields are directly used in templates,
// do remember to update the template if you change the fields
type WebBannerType struct {
DisplayEnabled bool
ContentMessage string
StartTimeUnix int64
EndTimeUnix int64
}
func (b WebBannerType) ShouldDisplay() bool {
if !b.DisplayEnabled || b.ContentMessage == "" {
return false
}
now := time.Now().Unix()
if b.StartTimeUnix > 0 && now < b.StartTimeUnix {
return false
}
if b.EndTimeUnix > 0 && now > b.EndTimeUnix {
return false
}
return true
}
type MaintenanceModeType struct {
AdminWebAccessOnly bool
StartTimeUnix int64
EndTimeUnix int64
}
func (m MaintenanceModeType) IsActive() bool {
if !m.AdminWebAccessOnly {
return false
}
now := time.Now().Unix()
if m.StartTimeUnix > 0 && now < m.StartTimeUnix {
return false
}
if m.EndTimeUnix > 0 && now > m.EndTimeUnix {
return false
}
return true
}
type InstanceStruct struct {
WebBanner *config.Option[WebBannerType]
MaintenanceMode *config.Option[MaintenanceModeType]
}

View File

@ -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)

View File

@ -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",

View File

@ -13,6 +13,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware"
@ -36,9 +37,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in
w.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions)
}
tmplCtx := context.NewTemplateContext(req.Context(), req)
tmplCtx["Locale"] = middleware.Locale(w, req)
tmplCtx := context.NewTemplateContextForWeb(reqctx.FromContext(req.Context()), req, middleware.Locale(w, req))
w.WriteHeader(respCode)
outBuf := &bytes.Buffer{}

View File

@ -0,0 +1,43 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"strings"
"code.gitea.io/gitea/modules/setting"
)
func isMaintenanceModeAllowedRequest(req *http.Request) bool {
if strings.HasPrefix(req.URL.Path, "/-/") {
// URLs like "/-/admin", "/-/fetch-redirect" and "/-/markup" are still accessible in maintenance mode
return true
}
if strings.HasPrefix(req.URL.Path, "/api/internal/") {
// internal APIs should be allowed
return true
}
if strings.HasPrefix(req.URL.Path, "/user/") {
// URLs like "/user/signin" and "/user/signup" are still accessible in maintenance mode
return true
}
if strings.HasPrefix(req.URL.Path, "/assets/") {
return true
}
return false
}
func MaintenanceModeHandler() func(h http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
maintenanceMode := setting.Config().Instance.MaintenanceMode.Value(req.Context())
if maintenanceMode.IsActive() && !isMaintenanceModeAllowedRequest(req) {
renderServiceUnavailable(resp, req)
return
}
next.ServeHTTP(resp, req)
})
}
}

View File

@ -181,6 +181,7 @@ func InitWebInstalled(ctx context.Context) {
func NormalRoutes() *web.Router {
r := web.NewRouter()
r.Use(common.ProtocolMiddlewares()...)
r.Use(common.MaintenanceModeHandler())
r.Mount("/", web_routers.Routes())
r.Mount("/api/v1", apiv1.Routes())

View File

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/routers/web/misc"
"code.gitea.io/gitea/services/context"
"gitea.com/go-chi/binding"
@ -59,6 +60,7 @@ func Routes() *web.Router {
// Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers.
r.Use(chi_middleware.RealIP)
r.Get("/dummy", misc.DummyOK)
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)

View File

@ -5,9 +5,9 @@
package admin
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
system_model "code.gitea.io/gitea/models/system"
@ -145,7 +145,6 @@ func Config(ctx *context.Context) {
ctx.Data["Service"] = setting.Service
ctx.Data["DbCfg"] = setting.Database
ctx.Data["Webhook"] = setting.Webhook
ctx.Data["MailerEnabled"] = false
if setting.MailService != nil {
ctx.Data["MailerEnabled"] = true
@ -191,52 +190,27 @@ func ConfigSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.config_settings")
ctx.Data["PageIsAdminConfig"] = true
ctx.Data["PageIsAdminConfigSettings"] = true
ctx.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString()
ctx.HTML(http.StatusOK, tplConfigSettings)
}
func validateConfigKeyValue(dynKey, input string) error {
opt := config.GetConfigOption(dynKey)
if opt == nil {
return util.NewInvalidArgumentErrorf("unknown config key: %s", dynKey)
}
const limit = 64 * 1024
if len(input) > limit {
return util.NewInvalidArgumentErrorf("value length exceeds limit of %d", limit)
}
if !json.Valid([]byte(input)) {
return util.NewInvalidArgumentErrorf("invalid json value for key: %s", dynKey)
}
return nil
}
func ChangeConfig(ctx *context.Context) {
cfg := setting.Config()
marshalBool := func(v string) ([]byte, error) {
b, _ := strconv.ParseBool(v)
return json.Marshal(b)
}
marshalString := func(emptyDefault string) func(v string) ([]byte, error) {
return func(v string) ([]byte, error) {
return json.Marshal(util.IfZero(v, emptyDefault))
}
}
marshalOpenWithApps := func(value string) ([]byte, error) {
// TODO: move the block alongside OpenWithEditorAppsType.ToTextareaString
lines := strings.Split(value, "\n")
var openWithEditorApps setting.OpenWithEditorAppsType
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
displayName, openURL, ok := strings.Cut(line, "=")
displayName, openURL = strings.TrimSpace(displayName), strings.TrimSpace(openURL)
if !ok || displayName == "" || openURL == "" {
continue
}
openWithEditorApps = append(openWithEditorApps, setting.OpenWithEditorApp{
DisplayName: strings.TrimSpace(displayName),
OpenURL: strings.TrimSpace(openURL),
})
}
return json.Marshal(openWithEditorApps)
}
marshallers := map[string]func(string) ([]byte, error){
cfg.Picture.DisableGravatar.DynKey(): marshalBool,
cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool,
cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps,
cfg.Repository.GitGuideRemoteName.DynKey(): marshalString(cfg.Repository.GitGuideRemoteName.DefaultValue()),
}
_ = ctx.Req.ParseForm()
configKeys := ctx.Req.Form["key"]
configValues := ctx.Req.Form["value"]
@ -249,18 +223,16 @@ loop:
}
value := configValues[i]
marshaller, hasMarshaller := marshallers[key]
if !hasMarshaller {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
break loop
}
marshaledValue, err := marshaller(value)
err := validateConfigKeyValue(key, value)
if err != nil {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
if errors.Is(err, util.ErrInvalidArgument) {
ctx.JSONError(err.Error())
} else {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
}
break loop
}
configSettings[key] = string(marshaledValue)
configSettings[key] = value
}
if ctx.Written() {
return

View File

@ -162,6 +162,11 @@ func consumeAuthRedirectLink(ctx *context.Context) string {
}
func redirectAfterAuth(ctx *context.Context) {
if setting.Config().Instance.MaintenanceMode.Value(ctx).IsActive() {
// in maintenance mode, redirect to admin dashboard, it is the only accessible page
ctx.Redirect(setting.AppSubURL + "/-/admin")
return
}
ctx.RedirectToCurrentSite(consumeAuthRedirectLink(ctx))
}

View File

@ -6,12 +6,15 @@ package misc
import (
"net/http"
"path"
"strconv"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context"
)
func SSHInfo(rw http.ResponseWriter, req *http.Request) {
@ -47,3 +50,9 @@ func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request
http.Redirect(w, req, path.Join(setting.StaticURLPrefix, target), http.StatusMovedPermanently)
}
}
func WebBannerDismiss(ctx *context.Context) {
_, rev, _ := setting.Config().Instance.WebBanner.ValueRevision(ctx)
middleware.SetSiteCookie(ctx.Resp, middleware.CookieWebBannerDismissed, strconv.Itoa(rev), 48*3600)
ctx.JSONOK()
}

View File

@ -37,6 +37,6 @@ func WebThemeApply(ctx *context.Context) {
opts := &user_service.UpdateOptions{Theme: optional.Some(themeName)}
_ = user_service.UpdateUser(ctx, ctx.Doer, opts)
} else {
middleware.SetSiteCookie(ctx.Resp, "gitea_theme", themeName, 0)
middleware.SetSiteCookie(ctx.Resp, middleware.CookieTheme, themeName, 0)
}
}

View File

@ -69,9 +69,6 @@ func prepareHomeSidebarRepoTopics(ctx *context.Context) {
func prepareOpenWithEditorApps(ctx *context.Context) {
var tmplApps []map[string]any
apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx)
if len(apps) == 0 {
apps = setting.DefaultOpenWithEditorApps()
}
for _, app := range apps {
schema, _, _ := strings.Cut(app.OpenURL, ":")

View File

@ -480,7 +480,7 @@ func registerWebRoutes(m *web.Router) {
}, optionsCorsHandler())
m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Post("/-/web-banner/dismiss", misc.WebBannerDismiss)
m.Get("/-/web-theme/list", misc.WebThemeList)
m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply)

View File

@ -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

View File

@ -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
}

View File

@ -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" .}}

View File

@ -0,0 +1,63 @@
<h4 class="ui top attached header">{{ctx.Locale.Tr "admin.config.instance_maintenance"}}</h4>
<div class="ui attached segment">
<form class="ui form ignore-dirty system-config-form" method="post" action="{{AppSubUrl}}/-/admin/config">
{{$cfgOpt := $.SystemConfig.Instance.MaintenanceMode}}
{{$cfgKey := $cfgOpt.DynKey}}
{{$maintenanceMode := $cfgOpt.Value ctx}}
<input type="hidden" data-config-dyn-key="{{$cfgKey}}" data-config-value-json="{{JsonUtils.EncodeToString $maintenanceMode}}">
<div class="field">
<div class="ui checkbox tw-mb-2">
<input type="checkbox" name="{{$cfgKey}}.AdminWebAccessOnly" value="true" {{if $maintenanceMode.AdminWebAccessOnly}}checked{{end}} data-config-value-type="boolean">
<label>{{ctx.Locale.Tr "admin.config.instance_maintenance_mode.admin_web_access_only"}}</label>
</div>
</div>
<div class="field">
<div class="fields tw-mb-1">
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.common.start_time"}}</label>
<input type="datetime-local" name="{{$cfgKey}}.StartTimeUnix" data-config-value-type="timestamp">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.common.end_time"}}</label>
<input type="datetime-local" name="{{$cfgKey}}.EndTimeUnix" data-config-value-type="timestamp">
</div>
</div>
<div class="help">{{ctx.Locale.Tr "admin.config.common.skip_time_check"}}</div>
</div>
<div class="divider"></div>
{{$cfgOpt = $.SystemConfig.Instance.WebBanner}}
{{$cfgKey = $cfgOpt.DynKey}}
{{$banner := $cfgOpt.Value ctx}}
<input type="hidden" data-config-dyn-key="{{$cfgKey}}" data-config-value-json="{{JsonUtils.EncodeToString $banner}}">
<div class="field">
<div class="ui checkbox tw-mb-2">
<input type="checkbox" name="{{$cfgKey}}.DisplayEnabled" value="true" {{if $banner.DisplayEnabled}}checked{{end}} data-config-value-type="boolean">
<label>{{ctx.Locale.Tr "admin.config.instance_web_banner.enabled"}}</label>
</div>
{{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")
)}}
</div>
<div class="field">
<div class="fields tw-mb-1">
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.common.start_time"}}</label>
<input type="datetime-local" name="{{$cfgKey}}.StartTimeUnix" data-config-value-type="timestamp">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.common.end_time"}}</label>
<input type="datetime-local" name="{{$cfgKey}}.EndTimeUnix" data-config-value-type="timestamp">
</div>
</div>
<div class="help">{{ctx.Locale.Tr "admin.config.common.skip_time_check"}}</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
</div>
</form>
</div>

View File

@ -2,24 +2,23 @@
{{ctx.Locale.Tr "repository"}}
</h4>
<div class="ui attached segment">
<form class="ui form form-fetch-action" method="post" action="{{AppSubUrl}}/-/admin/config">
<form class="ui form system-config-form" method="post" action="{{AppSubUrl}}/-/admin/config">
{{$cfg := .SystemConfig.Repository.OpenWithEditorApps}}
<div class="field">
<details>
<summary>{{ctx.Locale.Tr "admin.config.open_with_editor_app_help"}}</summary>
<pre class="tw-px-4">{{.DefaultOpenWithEditorAppsString}}</pre>
<pre class="tw-px-4">{{$cfg.DefaultValue.ToTextareaString}}</pre>
</details>
</div>
<div class="field">
{{$cfg := .SystemConfig.Repository.OpenWithEditorApps}}
<input type="hidden" name="key" value="{{$cfg.DynKey}}">
<textarea name="value">{{($cfg.Value ctx).ToTextareaString}}</textarea>
{{/* TODO: OPEN-WITH-EDITOR-APP-JSON: use a simple textarea */}}
<textarea name="{{$cfg.DynKey}}">{{if $cfg.HasValue ctx}}{{($cfg.Value ctx).ToTextareaString}}{{end}}</textarea>
</div>
{{$cfg = .SystemConfig.Repository.GitGuideRemoteName}}
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.git_guide_remote_name"}}</label>
{{$cfg = .SystemConfig.Repository.GitGuideRemoteName}}
<input type="hidden" name="key" value="{{$cfg.DynKey}}">
<input name="value" value="{{$cfg.Value ctx}}" placeholder="{{$cfg.DefaultValue}}" maxlength="100" dir="auto" required pattern="^[A-Za-z0-9][\-_A-Za-z0-9]*$">
<input name="{{$cfg.DynKey}}" value="{{$cfg.Value ctx}}" placeholder="{{$cfg.DefaultValue}}" maxlength="100" dir="auto" required pattern="^[A-Za-z0-9][\-_A-Za-z0-9]*$">
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>

View File

@ -1,5 +1,5 @@
{{template "base/head" .ctxData}}
<div role="main" aria-label="{{.ctxData.Title}}" class="page-content {{.pageClass}}">
<div role="main" aria-label="{{.ctxData.Title}}" class="page-content {{.pageClass}}" {{if .dataGlobalInit}}data-global-init="{{.dataGlobalInit}}"{{end}}>
<div class="ui container fluid padded flex-container">
{{template "admin/navbar" .ctxData}}
<div class="flex-container-main">

View File

@ -0,0 +1,11 @@
{{$banner := ctx.CurrentWebBanner}}
{{if $banner}}
<div class="ui info message web-banner-container">
<div class="render-content markup web-banner-content">
{{ctx.RenderUtils.MarkdownToHtml $banner.ContentMessage}}
</div>
<button type="button" class="btn dismiss-banner link-action" aria-label="{{ctx.Locale.Tr "dismiss"}}" data-url="{{AppSubUrl}}/-/web-banner/dismiss">
{{svg "octicon-x"}}
</button>
</div>
{{end}}

View File

@ -176,3 +176,4 @@
</div>
{{end}}
</nav>
{{template "base/head_banner"}}

View File

@ -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"}}
<div class="ui top tabular menu">
<a class="active item" data-tab-for="markdown-writer">{{template "shared/misc/tabtitle" (ctx.Locale.Tr "write")}}</a>
<a class="item" data-tab-for="markdown-previewer">{{template "shared/misc/tabtitle" (ctx.Locale.Tr "preview")}}</a>
</div>
{{end}}
<div class="ui tab active" data-tab-panel="markdown-writer">
<markdown-toolbar>
<div class="markdown-toolbar-group">
@ -87,9 +89,9 @@
</div>
<div class="markdown-add-table-panel tippy-target">
<div class="ui form tw-p-4 flex-text-block">
<input type="number" name="rows" min="1" value="3" size="3" class="tw-w-24" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.rows"}}">
<input type="number" min="1" value="3" size="3" class="add-table-rows tw-w-24" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.rows"}}">
x
<input type="number" name="cols" min="1" value="3" size="3" class="tw-w-24" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.cols"}}">
<input type="number" min="1" value="3" size="3" class="add-table-cols tw-w-24" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.cols"}}">
<button class="ui button primary" type="button">{{ctx.Locale.Tr "editor.buttons.table.add.insert"}}</button>
</div>
</div>

View File

@ -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)
})
}

View File

@ -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 <strong>upgrade</strong> 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 <strong>upgrade</strong> 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 <strong>upgrade</strong> 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)
})
})
}

View File

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

View File

@ -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;
}

View File

@ -0,0 +1,41 @@
import {ConfigFormValueMapper} from './config.ts';
test('ConfigFormValueMapper', () => {
document.body.innerHTML = `
<form>
<input id="checkbox-unrelated" type="checkbox" value="v-unrelated" checked>
<!-- top-level key -->
<input name="k1" type="checkbox" value="v-key-only" data-config-dyn-key="k1" data-config-value-json="true" data-config-value-type="boolean">
<input type="hidden" data-config-dyn-key="k2" data-config-value-json='"k2-val"'>
<input name="k2">
<textarea name="repository.open-with.editor-apps"> a = b\n</textarea>
<!-- sub key -->
<input type="hidden" data-config-dyn-key="struct" data-config-value-json='{"SubBoolean": true, "SubTimestamp": 123456789, "OtherKey": "other-value"}'>
<input name="struct.SubBoolean" type="checkbox" data-config-value-type="boolean">
<input name="struct.SubTimestamp" type="datetime-local" data-config-value-type="timestamp">
<textarea name="struct.NewKey">new-value</textarea>
</form>
`;
const form = document.querySelector('form')!;
const mapper = new ConfigFormValueMapper(form);
mapper.fillFromSystemConfig();
const formData = mapper.collectToFormData();
const result: Record<string, string> = {};
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"}',
});
});

View File

@ -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<HTMLDivElement>('.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<string, any> = 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<HTMLInputElement>('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<string, any> = {};
presetValueTypes: Record<string, ConfigValueType> = {};
constructor(form: HTMLFormElement) {
this.form = form;
for (const el of queryElems<HTMLInputElement>(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<string, any> = 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<HTMLInputElement>(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<GeneralFormFieldElement | null>, dynKey: string, cfgVal: Record<string, any>) {
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<GeneralFormFieldElement>(`[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<GeneralFormFieldElement | null> = [];
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);
});
}

View File

@ -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 || '');
}

View File

@ -266,8 +266,8 @@ export class ComboMarkdownEditor {
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => {
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]')!.value);
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]')!.value);
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-rows')!.value);
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('.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`);

View File

@ -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`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`;
previewPanel.innerHTML = html`<div class="render-content render-preview markup">${htmlRaw(htmlContent)}</div>`;
}