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:
@@ -81,7 +81,7 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
|
|||||||
"data-markdown-generated-content", "data-attr-class",
|
"data-markdown-generated-content", "data-attr-class",
|
||||||
}
|
}
|
||||||
generalSafeElements := []string{
|
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",
|
"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",
|
"dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary",
|
||||||
"details", "caption", "figure", "figcaption",
|
"details", "caption", "figure", "figcaption",
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PictureStruct struct {
|
type PictureStruct struct {
|
||||||
DisableGravatar *config.Value[bool]
|
DisableGravatar *config.Option[bool]
|
||||||
EnableFederatedAvatar *config.Value[bool]
|
EnableFederatedAvatar *config.Option[bool]
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenWithEditorApp struct {
|
type OpenWithEditorApp struct {
|
||||||
@@ -23,6 +23,9 @@ type OpenWithEditorApp struct {
|
|||||||
|
|
||||||
type OpenWithEditorAppsType []OpenWithEditorApp
|
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 {
|
func (t OpenWithEditorAppsType) ToTextareaString() string {
|
||||||
var ret strings.Builder
|
var ret strings.Builder
|
||||||
for _, app := range t {
|
for _, app := range t {
|
||||||
@@ -31,7 +34,7 @@ func (t OpenWithEditorAppsType) ToTextareaString() string {
|
|||||||
return ret.String()
|
return ret.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultOpenWithEditorApps() OpenWithEditorAppsType {
|
func openWithEditorAppsDefaultValue() OpenWithEditorAppsType {
|
||||||
return OpenWithEditorAppsType{
|
return OpenWithEditorAppsType{
|
||||||
{
|
{
|
||||||
DisplayName: "VS Code",
|
DisplayName: "VS Code",
|
||||||
@@ -49,13 +52,14 @@ func DefaultOpenWithEditorApps() OpenWithEditorAppsType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RepositoryStruct struct {
|
type RepositoryStruct struct {
|
||||||
OpenWithEditorApps *config.Value[OpenWithEditorAppsType]
|
OpenWithEditorApps *config.Option[OpenWithEditorAppsType]
|
||||||
GitGuideRemoteName *config.Value[string]
|
GitGuideRemoteName *config.Option[string]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigStruct struct {
|
type ConfigStruct struct {
|
||||||
Picture *PictureStruct
|
Picture *PictureStruct
|
||||||
Repository *RepositoryStruct
|
Repository *RepositoryStruct
|
||||||
|
Instance *InstanceStruct
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -67,12 +71,16 @@ func initDefaultConfig() {
|
|||||||
config.SetCfgSecKeyGetter(&cfgSecKeyGetter{})
|
config.SetCfgSecKeyGetter(&cfgSecKeyGetter{})
|
||||||
defaultConfig = &ConfigStruct{
|
defaultConfig = &ConfigStruct{
|
||||||
Picture: &PictureStruct{
|
Picture: &PictureStruct{
|
||||||
DisableGravatar: config.ValueJSON[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}),
|
DisableGravatar: config.NewOption[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"}),
|
EnableFederatedAvatar: config.NewOption[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}),
|
||||||
},
|
},
|
||||||
Repository: &RepositoryStruct{
|
Repository: &RepositoryStruct{
|
||||||
OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"),
|
OpenWithEditorApps: config.NewOption[OpenWithEditorAppsType]("repository.open-with.editor-apps").WithEmptyAsDefault().WithDefaultFunc(openWithEditorAppsDefaultValue),
|
||||||
GitGuideRemoteName: config.ValueJSON[string]("repository.git-guide-remote-name").WithDefault("origin"),
|
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"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-40
@@ -5,6 +5,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/json"
|
"code.gitea.io/gitea/modules/json"
|
||||||
@@ -16,18 +17,31 @@ type CfgSecKey struct {
|
|||||||
Sec, Key string
|
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
|
mu sync.RWMutex
|
||||||
|
|
||||||
cfgSecKey CfgSecKey
|
cfgSecKey CfgSecKey
|
||||||
dynKey string
|
dynKey string
|
||||||
|
|
||||||
def, value T
|
value T
|
||||||
|
defSimple T
|
||||||
|
defFunc func() T
|
||||||
|
emptyAsDef bool
|
||||||
|
has bool
|
||||||
revision int
|
revision int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (value *Value[T]) parse(key, valStr string) (v T) {
|
func (opt *Option[T]) GetDefaultValue() any {
|
||||||
v = value.def
|
return opt.DefaultValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt *Option[T]) parse(key, valStr string) (v T) {
|
||||||
|
v = opt.DefaultValue()
|
||||||
if valStr != "" {
|
if valStr != "" {
|
||||||
if err := json.Unmarshal(util.UnsafeStringToBytes(valStr), &v); err != nil {
|
if err := json.Unmarshal(util.UnsafeStringToBytes(valStr), &v); err != nil {
|
||||||
log.Error("Unable to unmarshal json config for key %q, err: %v", key, err)
|
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
|
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()
|
dg := GetDynGetter()
|
||||||
if dg == nil {
|
if dg == nil {
|
||||||
// this is an edge case: the database is not initialized but the system setting is going to be used
|
// 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")
|
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
|
// if the revision in the database doesn't change, use the last value
|
||||||
value.mu.RLock()
|
opt.mu.RLock()
|
||||||
if rev == value.revision {
|
if rev == opt.revision {
|
||||||
v = value.value
|
v = opt.value
|
||||||
value.mu.RUnlock()
|
has = opt.has
|
||||||
return v
|
opt.mu.RUnlock()
|
||||||
|
return v, rev, has
|
||||||
}
|
}
|
||||||
value.mu.RUnlock()
|
opt.mu.RUnlock()
|
||||||
|
|
||||||
// try to parse the config and cache it
|
// try to parse the config and cache it
|
||||||
var valStr *string
|
var valStr *string
|
||||||
if dynVal, has := dg.GetValue(ctx, value.dynKey); has {
|
if dynVal, hasDbValue := dg.GetValue(ctx, opt.dynKey); hasDbValue {
|
||||||
valStr = &dynVal
|
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
|
valStr = &cfgVal
|
||||||
}
|
}
|
||||||
if valStr == nil {
|
if valStr == nil {
|
||||||
v = value.def
|
v = opt.DefaultValue()
|
||||||
|
has = false
|
||||||
} else {
|
} 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()
|
opt.mu.Lock()
|
||||||
value.value = v
|
opt.value = v
|
||||||
value.revision = rev
|
opt.revision = rev
|
||||||
value.mu.Unlock()
|
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
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (value *Value[T]) DynKey() string {
|
func GetConfigOption(dynKey string) OptionInterface {
|
||||||
return value.dynKey
|
return allConfigOptions[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}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"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 {
|
func GetRedirectToCookie(req *http.Request) string {
|
||||||
return GetSiteCookie(req, cookieRedirectTo)
|
return GetSiteCookie(req, cookieRedirectTo)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"add_all": "Add All",
|
"add_all": "Add All",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"remove_all": "Remove All",
|
"remove_all": "Remove All",
|
||||||
"remove_label_str": "Remove item \"%s\"",
|
"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_failed": "Failed to probe the cache: %v.",
|
||||||
"admin.config.cache_test_slow": "Cache test successful, but response is slow: %s.",
|
"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.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_config": "Session Configuration",
|
||||||
"admin.config.session_provider": "Session Provider",
|
"admin.config.session_provider": "Session Provider",
|
||||||
"admin.config.provider_config": "Provider Config",
|
"admin.config.provider_config": "Provider Config",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/httpcache"
|
"code.gitea.io/gitea/modules/httpcache"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/reqctx"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/templates"
|
"code.gitea.io/gitea/modules/templates"
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
"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)
|
w.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmplCtx := context.NewTemplateContext(req.Context(), req)
|
tmplCtx := context.NewTemplateContextForWeb(reqctx.FromContext(req.Context()), req, middleware.Locale(w, req))
|
||||||
tmplCtx["Locale"] = middleware.Locale(w, req)
|
|
||||||
|
|
||||||
w.WriteHeader(respCode)
|
w.WriteHeader(respCode)
|
||||||
|
|
||||||
outBuf := &bytes.Buffer{}
|
outBuf := &bytes.Buffer{}
|
||||||
|
|||||||
@@ -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 {
|
func NormalRoutes() *web.Router {
|
||||||
r := web.NewRouter()
|
r := web.NewRouter()
|
||||||
r.Use(common.ProtocolMiddlewares()...)
|
r.Use(common.ProtocolMiddlewares()...)
|
||||||
|
r.Use(common.MaintenanceModeHandler())
|
||||||
|
|
||||||
r.Mount("/", web_routers.Routes())
|
r.Mount("/", web_routers.Routes())
|
||||||
r.Mount("/api/v1", apiv1.Routes())
|
r.Mount("/api/v1", apiv1.Routes())
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
"code.gitea.io/gitea/routers/common"
|
"code.gitea.io/gitea/routers/common"
|
||||||
|
"code.gitea.io/gitea/routers/web/misc"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
|
|
||||||
"gitea.com/go-chi/binding"
|
"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.
|
// 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.Use(chi_middleware.RealIP)
|
||||||
|
|
||||||
|
r.Get("/dummy", misc.DummyOK)
|
||||||
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
|
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
|
||||||
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
|
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
|
||||||
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
|
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
|
||||||
|
|||||||
+24
-52
@@ -5,9 +5,9 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
system_model "code.gitea.io/gitea/models/system"
|
system_model "code.gitea.io/gitea/models/system"
|
||||||
@@ -145,7 +145,6 @@ func Config(ctx *context.Context) {
|
|||||||
ctx.Data["Service"] = setting.Service
|
ctx.Data["Service"] = setting.Service
|
||||||
ctx.Data["DbCfg"] = setting.Database
|
ctx.Data["DbCfg"] = setting.Database
|
||||||
ctx.Data["Webhook"] = setting.Webhook
|
ctx.Data["Webhook"] = setting.Webhook
|
||||||
|
|
||||||
ctx.Data["MailerEnabled"] = false
|
ctx.Data["MailerEnabled"] = false
|
||||||
if setting.MailService != nil {
|
if setting.MailService != nil {
|
||||||
ctx.Data["MailerEnabled"] = true
|
ctx.Data["MailerEnabled"] = true
|
||||||
@@ -191,52 +190,27 @@ func ConfigSettings(ctx *context.Context) {
|
|||||||
ctx.Data["Title"] = ctx.Tr("admin.config_settings")
|
ctx.Data["Title"] = ctx.Tr("admin.config_settings")
|
||||||
ctx.Data["PageIsAdminConfig"] = true
|
ctx.Data["PageIsAdminConfig"] = true
|
||||||
ctx.Data["PageIsAdminConfigSettings"] = true
|
ctx.Data["PageIsAdminConfigSettings"] = true
|
||||||
ctx.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString()
|
|
||||||
ctx.HTML(http.StatusOK, tplConfigSettings)
|
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) {
|
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()
|
_ = ctx.Req.ParseForm()
|
||||||
configKeys := ctx.Req.Form["key"]
|
configKeys := ctx.Req.Form["key"]
|
||||||
configValues := ctx.Req.Form["value"]
|
configValues := ctx.Req.Form["value"]
|
||||||
@@ -249,18 +223,16 @@ loop:
|
|||||||
}
|
}
|
||||||
value := configValues[i]
|
value := configValues[i]
|
||||||
|
|
||||||
marshaller, hasMarshaller := marshallers[key]
|
err := validateConfigKeyValue(key, value)
|
||||||
if !hasMarshaller {
|
|
||||||
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
|
|
||||||
break loop
|
|
||||||
}
|
|
||||||
|
|
||||||
marshaledValue, err := marshaller(value)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, util.ErrInvalidArgument) {
|
||||||
|
ctx.JSONError(err.Error())
|
||||||
|
} else {
|
||||||
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
|
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
|
||||||
|
}
|
||||||
break loop
|
break loop
|
||||||
}
|
}
|
||||||
configSettings[key] = string(marshaledValue)
|
configSettings[key] = value
|
||||||
}
|
}
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ func consumeAuthRedirectLink(ctx *context.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func redirectAfterAuth(ctx *context.Context) {
|
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))
|
ctx.RedirectToCurrentSite(consumeAuthRedirectLink(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ package misc
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
"code.gitea.io/gitea/modules/httpcache"
|
"code.gitea.io/gitea/modules/httpcache"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"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) {
|
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)
|
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)}
|
opts := &user_service.UpdateOptions{Theme: optional.Some(themeName)}
|
||||||
_ = user_service.UpdateUser(ctx, ctx.Doer, opts)
|
_ = user_service.UpdateUser(ctx, ctx.Doer, opts)
|
||||||
} else {
|
} 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) {
|
func prepareOpenWithEditorApps(ctx *context.Context) {
|
||||||
var tmplApps []map[string]any
|
var tmplApps []map[string]any
|
||||||
apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx)
|
apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx)
|
||||||
if len(apps) == 0 {
|
|
||||||
apps = setting.DefaultOpenWithEditorApps()
|
|
||||||
}
|
|
||||||
for _, app := range apps {
|
for _, app := range apps {
|
||||||
schema, _, _ := strings.Cut(app.OpenURL, ":")
|
schema, _, _ := strings.Cut(app.OpenURL, ":")
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -480,7 +480,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
}, optionsCorsHandler())
|
}, optionsCorsHandler())
|
||||||
|
|
||||||
m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup)
|
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.Get("/-/web-theme/list", misc.WebThemeList)
|
||||||
m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply)
|
m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply)
|
||||||
|
|
||||||
|
|||||||
@@ -100,12 +100,12 @@ func GetValidateContext(req *http.Request) (ctx *ValidateContext) {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTemplateContextForWeb(ctx *Context) TemplateContext {
|
func NewTemplateContextForWeb(ctx reqctx.RequestContext, req *http.Request, locale translation.Locale) TemplateContext {
|
||||||
tmplCtx := NewTemplateContext(ctx, ctx.Req)
|
tmplCtx := NewTemplateContext(ctx, req)
|
||||||
tmplCtx["Locale"] = ctx.Base.Locale
|
tmplCtx["Locale"] = locale
|
||||||
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
|
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
|
||||||
tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx)
|
tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx)
|
||||||
tmplCtx["RootData"] = ctx.Data
|
tmplCtx["RootData"] = ctx.GetData()
|
||||||
tmplCtx["Consts"] = map[string]any{
|
tmplCtx["Consts"] = map[string]any{
|
||||||
"RepoUnitTypeCode": unit.TypeCode,
|
"RepoUnitTypeCode": unit.TypeCode,
|
||||||
"RepoUnitTypeIssues": unit.TypeIssues,
|
"RepoUnitTypeIssues": unit.TypeIssues,
|
||||||
@@ -132,7 +132,7 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context {
|
|||||||
Repo: &Repository{},
|
Repo: &Repository{},
|
||||||
Org: &Organization{},
|
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.Flash = &middleware.Flash{DataStore: ctx, Values: url.Values{}}
|
||||||
ctx.SetContextValue(WebContextKey, ctx)
|
ctx.SetContextValue(WebContextKey, ctx)
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ package context
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/web/middleware"
|
||||||
"code.gitea.io/gitea/services/webtheme"
|
"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}
|
return TemplateContext{"_ctx": ctx, "_req": req}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c TemplateContext) req() *http.Request {
|
||||||
|
return c["_req"].(*http.Request)
|
||||||
|
}
|
||||||
|
|
||||||
func (c TemplateContext) parentContext() context.Context {
|
func (c TemplateContext) parentContext() context.Context {
|
||||||
return c["_ctx"].(context.Context)
|
return c["_ctx"].(context.Context)
|
||||||
}
|
}
|
||||||
@@ -38,7 +45,6 @@ func (c TemplateContext) Value(key any) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
|
func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
|
||||||
req := c["_req"].(*http.Request)
|
|
||||||
var themeName string
|
var themeName string
|
||||||
if webCtx := GetWebContext(c); webCtx != nil {
|
if webCtx := GetWebContext(c); webCtx != nil {
|
||||||
if webCtx.Doer != nil {
|
if webCtx.Doer != nil {
|
||||||
@@ -46,9 +52,20 @@ func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if themeName == "" {
|
if themeName == "" {
|
||||||
if cookieTheme, _ := req.Cookie("gitea_theme"); cookieTheme != nil {
|
themeName = middleware.GetSiteCookie(c.req(), middleware.CookieTheme)
|
||||||
themeName = cookieTheme.Value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return webtheme.GuaranteeGetThemeMetaInfo(themeName)
|
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/avatars" .}}
|
||||||
|
|
||||||
{{template "admin/config_settings/repository" .}}
|
{{template "admin/config_settings/repository" .}}
|
||||||
|
{{template "admin/config_settings/instance" .}}
|
||||||
|
|
||||||
{{template "admin/layout_footer" .}}
|
{{template "admin/layout_footer" .}}
|
||||||
|
|||||||
@@ -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"}}
|
{{ctx.Locale.Tr "repository"}}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="ui attached segment">
|
<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">
|
<div class="field">
|
||||||
<details>
|
<details>
|
||||||
<summary>{{ctx.Locale.Tr "admin.config.open_with_editor_app_help"}}</summary>
|
<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>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{{$cfg := .SystemConfig.Repository.OpenWithEditorApps}}
|
{{/* TODO: OPEN-WITH-EDITOR-APP-JSON: use a simple textarea */}}
|
||||||
<input type="hidden" name="key" value="{{$cfg.DynKey}}">
|
<textarea name="{{$cfg.DynKey}}">{{if $cfg.HasValue ctx}}{{($cfg.Value ctx).ToTextareaString}}{{end}}</textarea>
|
||||||
<textarea name="value">{{($cfg.Value ctx).ToTextareaString}}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{$cfg = .SystemConfig.Repository.GitGuideRemoteName}}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>{{ctx.Locale.Tr "admin.config.git_guide_remote_name"}}</label>
|
<label>{{ctx.Locale.Tr "admin.config.git_guide_remote_name"}}</label>
|
||||||
{{$cfg = .SystemConfig.Repository.GitGuideRemoteName}}
|
<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]*$">
|
||||||
<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]*$">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
|
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{template "base/head" .ctxData}}
|
{{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">
|
<div class="ui container fluid padded flex-container">
|
||||||
{{template "admin/navbar" .ctxData}}
|
{{template "admin/navbar" .ctxData}}
|
||||||
<div class="flex-container-main">
|
<div class="flex-container-main">
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
|
{{template "base/head_banner"}}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* ContainerClasses: additional classes for the container element
|
* ContainerClasses: additional classes for the container element
|
||||||
* MarkdownPreviewInRepo: the repo to preview markdown
|
* 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
|
* 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
|
* TextareaName: name attribute for the textarea
|
||||||
* TextareaContent: content for the textarea
|
* TextareaContent: content for the textarea
|
||||||
* TextareaMaxLength: maxlength attribute for the textarea
|
* TextareaMaxLength: maxlength attribute for the textarea
|
||||||
@@ -29,10 +29,12 @@
|
|||||||
data-preview-url="{{$previewUrl}}"
|
data-preview-url="{{$previewUrl}}"
|
||||||
data-preview-context="{{$previewContext}}"
|
data-preview-context="{{$previewContext}}"
|
||||||
>
|
>
|
||||||
|
{{if ne $previewMode "none"}}
|
||||||
<div class="ui top tabular menu">
|
<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="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>
|
<a class="item" data-tab-for="markdown-previewer">{{template "shared/misc/tabtitle" (ctx.Locale.Tr "preview")}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
<div class="ui tab active" data-tab-panel="markdown-writer">
|
<div class="ui tab active" data-tab-panel="markdown-writer">
|
||||||
<markdown-toolbar>
|
<markdown-toolbar>
|
||||||
<div class="markdown-toolbar-group">
|
<div class="markdown-toolbar-group">
|
||||||
@@ -87,9 +89,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="markdown-add-table-panel tippy-target">
|
<div class="markdown-add-table-panel tippy-target">
|
||||||
<div class="ui form tw-p-4 flex-text-block">
|
<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
|
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>
|
<button class="ui button primary" type="button">{{ctx.Locale.Tr "editor.buttons.table.add.insert"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"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/modules/test"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAdminConfig(t *testing.T) {
|
func TestAdminConfig(t *testing.T) {
|
||||||
@@ -20,4 +24,46 @@ func TestAdminConfig(t *testing.T) {
|
|||||||
req := NewRequest(t, "GET", "/-/admin/config")
|
req := NewRequest(t, "GET", "/-/admin/config")
|
||||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
gap: 1rem;
|
||||||
margin-bottom: 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 {
|
.ui.container.medium-width {
|
||||||
width: 800px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,14 +1,15 @@
|
|||||||
import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
||||||
import {POST} from '../../modules/fetch.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;
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
export function initAdminConfigs(): void {
|
function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
|
||||||
const elAdminConfig = document.querySelector<HTMLDivElement>('.page-content.admin.config');
|
|
||||||
if (!elAdminConfig) return;
|
|
||||||
|
|
||||||
for (const el of elAdminConfig.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-config-dyn-key]')) {
|
|
||||||
el.addEventListener('change', async () => {
|
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 {
|
try {
|
||||||
const resp = await POST(`${appSubUrl}/-/admin/config`, {
|
const resp = await POST(`${appSubUrl}/-/admin/config`, {
|
||||||
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key')!, value: String(el.checked)}),
|
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key')!, value: String(el.checked)}),
|
||||||
@@ -21,4 +22,189 @@ export function initAdminConfigs(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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) {
|
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
|
||||||
e.preventDefault();
|
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;
|
if (formEl.classList.contains('is-loading')) return;
|
||||||
|
|
||||||
formEl.classList.add('is-loading');
|
formEl.classList.add('is-loading');
|
||||||
@@ -80,8 +85,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitt
|
|||||||
|
|
||||||
const formMethod = formEl.getAttribute('method') || 'get';
|
const formMethod = formEl.getAttribute('method') || 'get';
|
||||||
const formActionUrl = formEl.getAttribute('action') || window.location.href;
|
const formActionUrl = formEl.getAttribute('action') || window.location.href;
|
||||||
const formData = new FormData(formEl);
|
const formData = opts.formData ?? new FormData(formEl);
|
||||||
const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')];
|
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
|
||||||
if (submitterName) {
|
if (submitterName) {
|
||||||
formData.append(submitterName, submitterValue || '');
|
formData.append(submitterName, submitterValue || '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,8 +266,8 @@ export class ComboMarkdownEditor {
|
|||||||
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
|
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
|
||||||
|
|
||||||
addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => {
|
addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => {
|
||||||
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]')!.value);
|
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-rows')!.value);
|
||||||
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]')!.value);
|
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-cols')!.value);
|
||||||
rows = Math.max(1, Math.min(100, rows));
|
rows = Math.max(1, Math.min(100, rows));
|
||||||
cols = Math.max(1, Math.min(100, cols));
|
cols = Math.max(1, Math.min(100, cols));
|
||||||
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
|
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
|
||||||
|
|||||||
@@ -197,5 +197,5 @@ export function initRepoEditor() {
|
|||||||
|
|
||||||
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
|
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
|
||||||
// the content is from the server, so it is safe to use innerHTML
|
// 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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user