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:
parent
d0f92cb0a1
commit
26d83c932a
@ -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",
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
58
modules/setting/config_option_instance.go
Normal file
58
modules/setting/config_option_instance.go
Normal 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]
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/httpcache"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
@ -36,9 +37,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in
|
||||
w.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions)
|
||||
}
|
||||
|
||||
tmplCtx := context.NewTemplateContext(req.Context(), req)
|
||||
tmplCtx["Locale"] = middleware.Locale(w, req)
|
||||
|
||||
tmplCtx := context.NewTemplateContextForWeb(reqctx.FromContext(req.Context()), req, middleware.Locale(w, req))
|
||||
w.WriteHeader(respCode)
|
||||
|
||||
outBuf := &bytes.Buffer{}
|
||||
|
||||
43
routers/common/maintenancemode.go
Normal file
43
routers/common/maintenancemode.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, ":")
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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" .}}
|
||||
|
||||
63
templates/admin/config_settings/instance.tmpl
Normal file
63
templates/admin/config_settings/instance.tmpl
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
11
templates/base/head_banner.tmpl
Normal file
11
templates/base/head_banner.tmpl
Normal 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}}
|
||||
@ -176,3 +176,4 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{template "base/head_banner"}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
126
tests/integration/config_instance_test.go
Normal file
126
tests/integration/config_instance_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
41
web_src/js/features/admin/config.test.ts
Normal file
41
web_src/js/features/admin/config.test.ts
Normal 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"}',
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 || '');
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user