diff --git a/modules/setting/session.go b/modules/setting/session.go index 19a05ce2c2a5f..cb9b6024ba5b7 100644 --- a/modules/setting/session.go +++ b/modules/setting/session.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" ) // SessionConfig defines Session settings @@ -49,10 +50,8 @@ func loadSessionFrom(rootCfg ConfigProvider) { checkOverlappedPath("[session].PROVIDER_CONFIG", SessionConfig.ProviderConfig) } SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea") - SessionConfig.CookiePath = AppSubURL - if SessionConfig.CookiePath == "" { - SessionConfig.CookiePath = "/" - } + // HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath + SessionConfig.CookiePath = util.IfZero(AppSubURL, "/") SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(strings.HasPrefix(strings.ToLower(AppURL), "https://")) SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400) SessionConfig.Maxlifetime = sec.Key("SESSION_LIFE_TIME").MustInt64(86400) diff --git a/modules/svg/svg.go b/modules/svg/svg.go index fded9d0873744..333b5764c2774 100644 --- a/modules/svg/svg.go +++ b/modules/svg/svg.go @@ -58,6 +58,9 @@ func MockIcon(icon string) func() { // RenderHTML renders icons - arguments icon name (string), size (int), class (string) func RenderHTML(icon string, others ...any) template.HTML { + if icon == "" { + return "" + } size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...) if svgStr, ok := svgIcons[icon]; ok { // the code is somewhat hacky, but it just works, because the SVG contents are all normalized diff --git a/modules/templates/helper.go b/modules/templates/helper.go index e454bce4bd3c2..a7aa321811d55 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -12,7 +12,6 @@ import ( "strings" "time" - user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/markup" @@ -21,7 +20,6 @@ import ( "code.gitea.io/gitea/modules/templates/eval" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/gitdiff" - "code.gitea.io/gitea/services/webtheme" ) // NewFuncMap returns functions for injecting to templates @@ -130,7 +128,6 @@ func NewFuncMap() template.FuncMap { "DisableWebhooks": func() bool { return setting.DisableWebhooks }, - "UserThemeName": userThemeName, "NotificationSettings": func() map[string]any { return map[string]any{ "MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond), @@ -217,16 +214,6 @@ func evalTokens(tokens ...any) (any, error) { return n.Value, err } -func userThemeName(user *user_model.User) string { - if user == nil || user.Theme == "" { - return setting.UI.DefaultTheme - } - if webtheme.IsThemeAvailable(user.Theme) { - return user.Theme - } - return setting.UI.DefaultTheme -} - func isQueryParamEmpty(v any) bool { return v == nil || v == false || v == 0 || v == int64(0) || v == "" } diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 1056c4264334b..132ca4d9160ae 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -23,8 +23,10 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/webtheme" ) type RenderUtils struct { @@ -259,3 +261,18 @@ func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink strin htmlCode += "" return template.HTML(htmlCode) } + +func (ut *RenderUtils) RenderThemeItem(info *webtheme.ThemeMetaInfo, iconSize int) template.HTML { + svgName := "octicon-paintbrush" + switch info.ColorScheme { + case "dark": + svgName = "octicon-moon" + case "light": + svgName = "octicon-sun" + case "auto": + svgName = "gitea-eclipse" + } + icon := svg.RenderHTML(svgName, iconSize) + extraIcon := svg.RenderHTML(info.GetExtraIconName(), iconSize) + return htmlutil.HTMLFormat(`
%s %s %s
`, info.GetDescription(), icon, info.DisplayName, extraIcon) +} diff --git a/modules/web/middleware/cookie.go b/modules/web/middleware/cookie.go index f2d25f5b1cda7..ad9aee6478f98 100644 --- a/modules/web/middleware/cookie.go +++ b/modules/web/middleware/cookie.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) // SetRedirectToCookie convenience function to set the RedirectTo cookie consistently @@ -39,11 +40,13 @@ func SetSiteCookie(resp http.ResponseWriter, name, value string, maxAge int) { // These are more specific than cookies without a trailing /, so // we need to delete these if they exist. deleteLegacySiteCookie(resp, name) + + // HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath cookie := &http.Cookie{ Name: name, Value: url.QueryEscape(value), MaxAge: maxAge, - Path: setting.SessionConfig.CookiePath, + Path: util.IfZero(setting.SessionConfig.CookiePath, "/"), Domain: setting.SessionConfig.Domain, Secure: setting.SessionConfig.Secure, HttpOnly: true, diff --git a/public/assets/img/svg/gitea-colorblind-redgreen.svg b/public/assets/img/svg/gitea-colorblind-redgreen.svg new file mode 100644 index 0000000000000..5933afa850f53 --- /dev/null +++ b/public/assets/img/svg/gitea-colorblind-redgreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-eclipse.svg b/public/assets/img/svg/gitea-eclipse.svg new file mode 100644 index 0000000000000..eb90ad8f6b34a --- /dev/null +++ b/public/assets/img/svg/gitea-eclipse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/common/errpage.go b/routers/common/errpage.go index 9ca309931bbb4..4caef92d14e95 100644 --- a/routers/common/errpage.go +++ b/routers/common/errpage.go @@ -35,7 +35,7 @@ func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) { httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{NoTransform: true}) w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) - tmplCtx := context.TemplateContext{} + tmplCtx := context.NewTemplateContext(req.Context(), req) tmplCtx["Locale"] = middleware.Locale(w, req) ctxData := middleware.GetContextData(req.Context()) diff --git a/routers/common/qos.go b/routers/common/qos.go index e50fbe4f6990f..0670ea0b4c863 100644 --- a/routers/common/qos.go +++ b/routers/common/qos.go @@ -133,7 +133,7 @@ func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) { return } - tmplCtx := giteacontext.TemplateContext{} + tmplCtx := giteacontext.NewTemplateContext(req.Context(), req) tmplCtx["Locale"] = middleware.Locale(w, req) ctxData := middleware.GetContextData(req.Context()) err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx) diff --git a/routers/install/routes.go b/routers/install/routes.go index e4f833e751573..d47c1f61eeb53 100644 --- a/routers/install/routes.go +++ b/routers/install/routes.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/web/healthcheck" + "code.gitea.io/gitea/routers/web/misc" "code.gitea.io/gitea/services/forms" ) @@ -32,7 +33,11 @@ func Routes() *web.Router { r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall) r.Get("/post-install", InstallDone) + + r.Get("/-/web-theme/list", misc.WebThemeList) + r.Post("/-/web-theme/apply", misc.WebThemeApply) r.Get("/api/healthz", healthcheck.Check) + r.NotFound(installNotFound) base.Mount("", r) diff --git a/routers/web/misc/webtheme.go b/routers/web/misc/webtheme.go new file mode 100644 index 0000000000000..076bdf8fda2f0 --- /dev/null +++ b/routers/web/misc/webtheme.go @@ -0,0 +1,42 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" + "code.gitea.io/gitea/services/webtheme" +) + +func WebThemeList(ctx *context.Context) { + curWebTheme := ctx.TemplateContext.CurrentWebTheme() + renderUtils := templates.NewRenderUtils(ctx) + allThemes := webtheme.GetAvailableThemes() + + var results []map[string]any + for _, theme := range allThemes { + results = append(results, map[string]any{ + "name": renderUtils.RenderThemeItem(theme, 14), + "value": theme.InternalName, + "class": "item js-aria-clickable" + util.Iif(theme.InternalName == curWebTheme.InternalName, " selected", ""), + }) + } + ctx.JSON(http.StatusOK, map[string]any{"results": results}) +} + +func WebThemeApply(ctx *context.Context) { + themeName := ctx.FormString("theme") + if ctx.Doer != nil { + opts := &user_service.UpdateOptions{Theme: optional.Some(themeName)} + _ = user_service.UpdateUser(ctx, ctx.Doer, opts) + } else { + middleware.SetSiteCookie(ctx.Resp, "gitea_theme", themeName, 0) + } +} diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 98995cd69c435..27b0c83a38373 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -369,7 +369,7 @@ func UpdateUIThemePost(ctx *context.Context) { return } - if !webtheme.IsThemeAvailable(form.Theme) { + if webtheme.GetThemeMetaInfo(form.Theme) == nil { ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") return diff --git a/routers/web/web.go b/routers/web/web.go index b5d283607a722..8b55e4469eeb7 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -490,6 +490,9 @@ func registerWebRoutes(m *web.Router) { m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup) + m.Get("/-/web-theme/list", misc.WebThemeList) + m.Post("/-/web-theme/apply", optSignInIgnoreCsrf, misc.WebThemeApply) + m.Group("/explore", func() { m.Get("", func(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/explore/repos") diff --git a/services/context/context.go b/services/context/context.go index 4e83dee807097..26b5bd3775b7a 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -103,7 +103,7 @@ func GetValidateContext(req *http.Request) (ctx *ValidateContext) { } func NewTemplateContextForWeb(ctx *Context) TemplateContext { - tmplCtx := NewTemplateContext(ctx) + tmplCtx := NewTemplateContext(ctx, ctx.Req) tmplCtx["Locale"] = ctx.Base.Locale tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx) tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx) diff --git a/services/context/context_template.go b/services/context/context_template.go index 7878d409caf49..c1045136ee9ad 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -5,13 +5,16 @@ package context import ( "context" + "net/http" "time" + + "code.gitea.io/gitea/services/webtheme" ) var _ context.Context = TemplateContext(nil) -func NewTemplateContext(ctx context.Context) TemplateContext { - return TemplateContext{"_ctx": ctx} +func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext { + return TemplateContext{"_ctx": ctx, "_req": req} } func (c TemplateContext) parentContext() context.Context { @@ -33,3 +36,19 @@ func (c TemplateContext) Err() error { func (c TemplateContext) Value(key any) any { return c.parentContext().Value(key) } + +func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo { + req := c["_req"].(*http.Request) + var themeName string + if webCtx := GetWebContext(c); webCtx != nil { + if webCtx.Doer != nil { + themeName = webCtx.Doer.Theme + } + } + if themeName == "" { + if cookieTheme, _ := req.Cookie("gitea_theme"); cookieTheme != nil { + themeName = cookieTheme.Value + } + } + return webtheme.GuaranteeGetThemeMetaInfo(themeName) +} diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 4e89d6dbac13a..72f01a76c713b 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -17,9 +17,9 @@ import ( ) var ( - availableThemes []*ThemeMetaInfo - availableThemeInternalNames container.Set[string] - themeOnce sync.Once + availableThemes []*ThemeMetaInfo + availableThemeMap map[string]*ThemeMetaInfo + themeOnce sync.Once ) const ( @@ -28,9 +28,25 @@ const ( ) type ThemeMetaInfo struct { - FileName string - InternalName string - DisplayName string + FileName string + InternalName string + DisplayName string + ColorblindType string + ColorScheme string +} + +func (info *ThemeMetaInfo) GetDescription() string { + if info.ColorblindType == "red-green" { + return "Red-green colorblind friendly" + } + return "" +} + +func (info *ThemeMetaInfo) GetExtraIconName() string { + if info.ColorblindType == "red-green" { + return "gitea-colorblind-redgreen" + } + return "" } func parseThemeMetaInfoToMap(cssContent string) map[string]string { @@ -54,7 +70,7 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string { |('(\\'|[^'])*') |([^'";]+) ) -\s*; +\s*;? \s* ) ` @@ -102,17 +118,19 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { return themeInfo } themeInfo.DisplayName = m["--theme-display-name"] + themeInfo.ColorblindType = m["--theme-colorblind-type"] + themeInfo.ColorScheme = m["--theme-color-scheme"] return themeInfo } func initThemes() { availableThemes = nil defer func() { - availableThemeInternalNames = container.Set[string]{} + availableThemeMap = map[string]*ThemeMetaInfo{} for _, theme := range availableThemes { - availableThemeInternalNames.Add(theme.InternalName) + availableThemeMap[theme.InternalName] = theme } - if !availableThemeInternalNames.Contains(setting.UI.DefaultTheme) { + if availableThemeMap[setting.UI.DefaultTheme] == nil { setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme) } }() @@ -147,6 +165,9 @@ func initThemes() { if availableThemes[i].InternalName == setting.UI.DefaultTheme { return true } + if availableThemes[i].ColorblindType != availableThemes[j].ColorblindType { + return availableThemes[i].ColorblindType < availableThemes[j].ColorblindType + } return availableThemes[i].DisplayName < availableThemes[j].DisplayName }) if len(availableThemes) == 0 { @@ -160,7 +181,21 @@ func GetAvailableThemes() []*ThemeMetaInfo { return availableThemes } -func IsThemeAvailable(internalName string) bool { +func GetThemeMetaInfo(internalName string) *ThemeMetaInfo { themeOnce.Do(initThemes) - return availableThemeInternalNames.Contains(internalName) + return availableThemeMap[internalName] +} + +// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo, +// to simplify the caller's logic, especially for templates. +// There are already enough warnings messages if the default theme is not available. +func GuaranteeGetThemeMetaInfo(internalName string) *ThemeMetaInfo { + info := GetThemeMetaInfo(internalName) + if info == nil { + info = GetThemeMetaInfo(setting.UI.DefaultTheme) + } + if info == nil { + info = &ThemeMetaInfo{DisplayName: "unavailable", InternalName: "unavailable", FileName: "unavailable"} + } + return info } diff --git a/services/webtheme/webtheme_test.go b/services/webtheme/webtheme_test.go index 587953ab0c82f..d6c014fabf62a 100644 --- a/services/webtheme/webtheme_test.go +++ b/services/webtheme/webtheme_test.go @@ -34,4 +34,10 @@ gitea-theme-meta-info { --k2: real; }`) assert.Equal(t, map[string]string{"--k2": "real"}, m) + + // compressed CSS, no trailing semicolon + m = parseThemeMetaInfoToMap(`gitea-theme-meta-info{--k1:"v1"}`) + assert.Equal(t, map[string]string{"--k1": "v1"}, m) + m = parseThemeMetaInfoToMap(`gitea-theme-meta-info{--k1:"v1";--k2:"v2"}`) + assert.Equal(t, map[string]string{"--k1": "v1", "--k2": "v2"}, m) } diff --git a/templates/base/footer_content.tmpl b/templates/base/footer_content.tmpl index 60eb2fe1f8295..df437badf688a 100644 --- a/templates/base/footer_content.tmpl +++ b/templates/base/footer_content.tmpl @@ -17,6 +17,10 @@ {{end}}