diff --git a/modules/setting/config.go b/modules/setting/config.go index fb99325a95..59f046e88d 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -4,6 +4,7 @@ package setting import ( + "context" "strings" "sync" @@ -53,9 +54,72 @@ type RepositoryStruct struct { GitGuideRemoteName *config.Value[string] } +const ( + InstanceNoticeLevelInfo = "info" + InstanceNoticeLevelSuccess = "success" + InstanceNoticeLevelWarning = "warning" + InstanceNoticeLevelDanger = "danger" +) + +type InstanceNotice struct { + Enabled bool + Message string + Level string + ShowIcon bool + + StartTime int64 + EndTime int64 +} + +func DefaultInstanceNotice() InstanceNotice { + return InstanceNotice{ + Level: InstanceNoticeLevelInfo, + ShowIcon: true, + } +} + +func IsValidInstanceNoticeLevel(level string) bool { + switch level { + case InstanceNoticeLevelInfo, InstanceNoticeLevelSuccess, InstanceNoticeLevelWarning, InstanceNoticeLevelDanger: + return true + default: + return false + } +} + +func (n *InstanceNotice) Normalize() { + if !IsValidInstanceNoticeLevel(n.Level) { + n.Level = InstanceNoticeLevelInfo + } +} + +func (n *InstanceNotice) IsActive(now int64) bool { + if !n.Enabled || n.Message == "" { + return false + } + if n.StartTime > 0 && now < n.StartTime { + return false + } + if n.EndTime > 0 && now > n.EndTime { + return false + } + return true +} + +func GetInstanceNotice(ctx context.Context) InstanceNotice { + notice := Config().InstanceNotice.Banner.Value(ctx) + notice.Normalize() + return notice +} + +type InstanceNoticeStruct struct { + Banner *config.Value[InstanceNotice] +} + type ConfigStruct struct { - Picture *PictureStruct - Repository *RepositoryStruct + Picture *PictureStruct + Repository *RepositoryStruct + InstanceNotice *InstanceNoticeStruct } var ( @@ -74,6 +138,9 @@ func initDefaultConfig() { OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"), GitGuideRemoteName: config.ValueJSON[string]("repository.git-guide-remote-name").WithDefault("origin"), }, + InstanceNotice: &InstanceNoticeStruct{ + Banner: config.ValueJSON[InstanceNotice]("instance.notice").WithDefault(DefaultInstanceNotice()), + }, } } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 93ac046612..01be25b2fa 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3277,6 +3277,29 @@ "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.instance_notice": "Instance Banner", + "admin.config.instance_notice.settings": "Settings", + "admin.config.instance_notice.enabled": "Enable banner", + "admin.config.instance_notice.message": "Message", + "admin.config.instance_notice.message_placeholder": "This message supports Markdown.", + "admin.config.instance_notice.message_required": "Message is required when the banner is enabled.", + "admin.config.instance_notice.level": "Level", + "admin.config.instance_notice.level.info": "Info", + "admin.config.instance_notice.level.success": "Success", + "admin.config.instance_notice.level.warning": "Warning", + "admin.config.instance_notice.level.danger": "Danger", + "admin.config.instance_notice.show_icon": "Show icon", + "admin.config.instance_notice.start_time": "Start time", + "admin.config.instance_notice.end_time": "End time", + "admin.config.instance_notice.invalid_time": "Invalid date-time value. Use the picker format.", + "admin.config.instance_notice.invalid_time_range": "End time must be after start time.", + "admin.config.instance_notice.markdown_hint": "This banner is informational only and does not block access.", + "admin.config.instance_notice.preview": "Preview", + "admin.config.instance_notice.save": "Save banner", + "admin.config.instance_notice.save_success": "Instance banner settings updated.", + "admin.config.instance_notice.delete": "Delete banner", + "admin.config.instance_notice.delete_success": "Instance banner deleted.", + "admin.config.instance_notice.edit_hint": "Edit this banner", "admin.config.session_config": "Session Configuration", "admin.config.session_provider": "Session Provider", "admin.config.provider_config": "Provider Config", diff --git a/routers/common/pagetmpl.go b/routers/common/pagetmpl.go index c48596d48b..b5a12a5334 100644 --- a/routers/common/pagetmpl.go +++ b/routers/common/pagetmpl.go @@ -12,6 +12,8 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/services/context" ) @@ -69,14 +71,50 @@ type pageGlobalDataType struct { IsSigned bool IsSiteAdmin bool + InstanceNoticeBanner *InstanceNoticeBannerTmplInfo + GetNotificationUnreadCount func() int64 GetActiveStopwatch func() *StopwatchTmplInfo } +type InstanceNoticeBannerTmplInfo struct { + Message string + Level string + ShowIcon bool + IconName string +} + +func instanceNoticeIconName(level string) string { + switch level { + case setting.InstanceNoticeLevelSuccess: + return "octicon-check-circle" + case setting.InstanceNoticeLevelWarning: + return "octicon-alert" + case setting.InstanceNoticeLevelDanger: + return "octicon-stop" + default: + return "octicon-info" + } +} + +func getInstanceNoticeBanner(ctx *context.Context) *InstanceNoticeBannerTmplInfo { + notice := setting.GetInstanceNotice(ctx) + if !notice.IsActive(int64(timeutil.TimeStampNow())) { + return nil + } + return &InstanceNoticeBannerTmplInfo{ + Message: notice.Message, + Level: notice.Level, + ShowIcon: notice.ShowIcon, + IconName: instanceNoticeIconName(notice.Level), + } +} + func PageGlobalData(ctx *context.Context) { var data pageGlobalDataType data.IsSigned = ctx.Doer != nil data.IsSiteAdmin = ctx.Doer != nil && ctx.Doer.IsAdmin + data.InstanceNoticeBanner = getInstanceNoticeBanner(ctx) data.GetNotificationUnreadCount = sync.OnceValue(func() int64 { return notificationUnreadCount(ctx) }) data.GetActiveStopwatch = sync.OnceValue(func() *StopwatchTmplInfo { return getActiveStopwatch(ctx) }) ctx.Data["PageGlobalData"] = data diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 774b31ab98..cc9e04a670 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -9,6 +9,7 @@ import ( "net/url" "strconv" "strings" + "time" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/modules/cache" @@ -145,6 +146,14 @@ func Config(ctx *context.Context) { ctx.Data["Service"] = setting.Service ctx.Data["DbCfg"] = setting.Database ctx.Data["Webhook"] = setting.Webhook + instanceNotice := setting.GetInstanceNotice(ctx) + ctx.Data["InstanceNotice"] = instanceNotice + if instanceNotice.StartTime > 0 { + ctx.Data["InstanceNoticeStartTime"] = time.Unix(instanceNotice.StartTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04") + } + if instanceNotice.EndTime > 0 { + ctx.Data["InstanceNoticeEndTime"] = time.Unix(instanceNotice.EndTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04") + } ctx.Data["MailerEnabled"] = false if setting.MailService != nil { @@ -187,6 +196,92 @@ func Config(ctx *context.Context) { ctx.HTML(http.StatusOK, tplConfig) } +func parseDatetimeLocalValue(raw string) (int64, error) { + if raw == "" { + return 0, nil + } + tm, err := time.ParseInLocation("2006-01-02T15:04", raw, setting.DefaultUILocation) + if err != nil { + return 0, err + } + return tm.Unix(), nil +} + +func SetInstanceNotice(ctx *context.Context) { + saveInstanceNotice := func(instanceNotice setting.InstanceNotice) { + instanceNotice.Normalize() + marshaled, err := json.Marshal(instanceNotice) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + if err := system_model.SetSettings(ctx, map[string]string{ + setting.Config().InstanceNotice.Banner.DynKey(): string(marshaled), + }); err != nil { + ctx.ServerError("SetSettings", err) + return + } + config.GetDynGetter().InvalidateCache() + } + + if ctx.FormString("action") == "delete" { + saveInstanceNotice(setting.DefaultInstanceNotice()) + if ctx.Written() { + return + } + ctx.Flash.Success(ctx.Tr("admin.config.instance_notice.delete_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + + enabled := ctx.FormBool("enabled") + message := strings.TrimSpace(ctx.FormString("message")) + level := strings.TrimSpace(ctx.FormString("level")) + showIcon := ctx.FormBool("show_icon") + startTime, err := parseDatetimeLocalValue(strings.TrimSpace(ctx.FormString("start_time"))) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + endTime, err := parseDatetimeLocalValue(strings.TrimSpace(ctx.FormString("end_time"))) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + if !setting.IsValidInstanceNoticeLevel(level) { + level = setting.InstanceNoticeLevelInfo + } + if enabled && message == "" { + ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.message_required")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + if startTime > 0 && endTime > 0 && endTime < startTime { + ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time_range")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + + instanceNotice := setting.InstanceNotice{ + Enabled: enabled, + Message: message, + Level: level, + ShowIcon: showIcon, + StartTime: startTime, + EndTime: endTime, + } + + saveInstanceNotice(instanceNotice) + if ctx.Written() { + return + } + + ctx.Flash.Success(ctx.Tr("admin.config.instance_notice.save_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") +} + func ConfigSettings(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.config_settings") ctx.Data["PageIsAdminConfig"] = true diff --git a/routers/web/web.go b/routers/web/web.go index 9e6354e138..4e991ac51b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -717,6 +717,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/config", func() { m.Get("", admin.Config) m.Post("", admin.ChangeConfig) + m.Post("/instance_notice", admin.SetInstanceNotice) m.Post("/test_mail", admin.SendTestMail) m.Post("/test_cache", admin.TestCache) m.Get("/settings", admin.ConfigSettings) diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index 728746713c..940b7ec65f 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -265,6 +265,79 @@ +
Rendered message
', + } as Response); + + initAdminConfigs(); + + const messageInput = document.querySelectorSecond render
', + } as Response); + + initAdminConfigs(); + + const messageInput = document.querySelectorFirst render
', + } as Response); + + for (let i = 0; i < 10 && vi.mocked(POST).mock.calls.length < 2; i++) { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + + expect(POST).toHaveBeenCalledTimes(2); + const secondData = vi.mocked(POST).mock.calls[1][1]?.data as FormData; + expect(secondData.get('text')).toBe('Latest value'); + + const previewContent = document.querySelector('#instance-notice-preview-content')!; + for (let i = 0; i < 10 && !previewContent.innerHTML.includes('Second render'); i++) { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + expect(previewContent.innerHTML).toContain('Second render'); + }); +}); diff --git a/web_src/js/features/admin/config.ts b/web_src/js/features/admin/config.ts index 76f7c1db50..7f00cd611c 100644 --- a/web_src/js/features/admin/config.ts +++ b/web_src/js/features/admin/config.ts @@ -1,8 +1,78 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts'; import {POST} from '../../modules/fetch.ts'; +import {html, htmlRaw} from '../../utils/html.ts'; const {appSubUrl} = window.config; +function initInstanceNoticePreview(elAdminConfig: HTMLDivElement): void { + const form = elAdminConfig.querySelector