Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions modules/assetfs/layered.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"

"code.gitea.io/gitea/modules/container"
Expand Down Expand Up @@ -61,6 +63,8 @@ type LayeredFS struct {
layers []*Layer
}

var _ fs.ReadDirFS = (*LayeredFS)(nil)

// Layered returns a new LayeredFS with the given layers. The first layer is the top layer.
func Layered(layers ...*Layer) *LayeredFS {
return &LayeredFS{layers: layers}
Expand All @@ -83,6 +87,27 @@ func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) {
return bs, err
}

func (l *LayeredFS) ReadDir(name string) (files []fs.DirEntry, _ error) {
filesMap := map[string]fs.DirEntry{}
for _, layer := range l.layers {
entries, err := readDirOptional(layer, name)
if err != nil {
return nil, err
}
for _, entry := range entries {
entryName := entry.Name()
if _, exist := filesMap[entryName]; !exist && shouldInclude(entry) {
filesMap[entryName] = entry
}
}
}
for _, file := range filesMap {
files = append(files, file)
}
slices.SortFunc(files, func(a, b fs.DirEntry) int { return strings.Compare(a.Name(), b.Name()) })
return files, nil
}

// ReadLayeredFile reads the named file, and returns the layer name.
func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) {
name := util.PathJoinRel(elems...)
Expand Down
67 changes: 50 additions & 17 deletions modules/public/vitedev.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"sync/atomic"
"time"

"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/routing"
Expand All @@ -22,24 +23,29 @@ const viteDevPortFile = "public/assets/.vite/dev-port"

var viteDevProxy atomic.Pointer[httputil.ReverseProxy]

func getViteDevServerBaseURL() string {
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
portContent, _ := os.ReadFile(portFile)
port := strings.TrimSpace(string(portContent))
if port == "" {
return ""
}
return "http://localhost:" + port
}

func getViteDevProxy() *httputil.ReverseProxy {
if proxy := viteDevProxy.Load(); proxy != nil {
return proxy
}

portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
data, err := os.ReadFile(portFile)
if err != nil {
return nil
}
port := strings.TrimSpace(string(data))
if port == "" {
viteDevServerBaseURL := getViteDevServerBaseURL()
if viteDevServerBaseURL == "" {
return nil
}

target, err := url.Parse("http://localhost:" + port)
target, err := url.Parse(viteDevServerBaseURL)
if err != nil {
log.Error("Failed to parse Vite dev server URL: %v", err)
log.Error("Failed to parse Vite dev server base URL %s, err: %v", viteDevServerBaseURL, err)
return nil
}

Expand All @@ -60,7 +66,7 @@ func getViteDevProxy() *httputil.ReverseProxy {
ModifyResponse: func(resp *http.Response) error {
// add a header to indicate the Vite dev server port,
// make developers know that this request is proxied to Vite dev server and which port it is
resp.Header.Add("X-Gitea-Vite-Port", port)
resp.Header.Add("X-Gitea-Vite-Dev-Server", viteDevServerBaseURL)
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
Expand Down Expand Up @@ -92,19 +98,46 @@ func ViteDevMiddleware(next http.Handler) http.Handler {
})
}

// isViteDevMode returns true if the Vite dev server port file exists.
// In production mode, the result is cached after the first check.
func isViteDevMode() bool {
var viteDevModeCheck atomic.Pointer[struct {
isDev bool
time time.Time
}]

// IsViteDevMode returns true if the Vite dev server port file exists and the server is alive
func IsViteDevMode() bool {
if setting.IsProd {
return false
}
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
_, err := os.Stat(portFile)
return err == nil

now := time.Now()
lastCheck := viteDevModeCheck.Load()
if lastCheck != nil && time.Now().Sub(lastCheck.time) < time.Second {
return lastCheck.isDev
}

viteDevServerBaseURL := getViteDevServerBaseURL()
if viteDevServerBaseURL == "" {
return false
}

req := httplib.NewRequest(viteDevServerBaseURL+"/web_src/js/__vite_dev_server_check", "GET")
resp, _ := req.Response()
if resp != nil {
_ = resp.Body.Close()
}
isDev := resp != nil && resp.StatusCode == http.StatusOK
viteDevModeCheck.Store(&struct {
isDev bool
time time.Time
}{
isDev: isDev,
time: now,
})
return isDev
}

func viteDevSourceURL(name string) string {
if !isViteDevMode() {
if !IsViteDevMode() {
return ""
}
if strings.HasPrefix(name, "css/theme-") {
Expand Down
2 changes: 2 additions & 0 deletions modules/web/middleware/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"time"

"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting"
)
Expand Down Expand Up @@ -36,5 +37,6 @@ func CommonTemplateContextData() reqctx.ContextData {
"PageStartTime": time.Now(),

"RunModeIsProd": setting.IsProd,
"ViteModeIsDev": public.IsViteDevMode(),
}
}
5 changes: 5 additions & 0 deletions routers/common/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/modules/gtprof"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/routing"
Expand Down Expand Up @@ -40,6 +41,10 @@ func ProtocolMiddlewares() (handlers []any) {
handlers = append(handlers, context.AccessLogger())
}

if !setting.IsProd {
handlers = append(handlers, public.ViteDevMiddleware)
}

return handlers
}

Expand Down
4 changes: 0 additions & 4 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,6 @@ func Routes() *web.Router {
// GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
routes.BeforeRouting(chi_middleware.GetHead)

if !setting.IsProd {
routes.BeforeRouting(public.ViteDevMiddleware)
}

routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
Expand Down
104 changes: 60 additions & 44 deletions services/webtheme/webtheme.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
package webtheme

import (
"io/fs"
"os"
"path"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"

"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
Expand All @@ -16,15 +20,15 @@ import (
"code.gitea.io/gitea/modules/util"
)

type themeCollection struct {
type themeCollectionStruct struct {
lastCheckTime time.Time
usingViteDevMode bool

themeList []*ThemeMetaInfo
themeMap map[string]*ThemeMetaInfo
}

var (
themeMu sync.RWMutex
availableThemes *themeCollection
)
var themeCollection atomic.Pointer[themeCollectionStruct]

const (
fileNamePrefix = "theme-"
Expand Down Expand Up @@ -140,23 +144,42 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
return themeInfo
}

func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
cssFiles, err := public.AssetFS().ListFiles("assets/css")
func collectThemeFiles(dirFS fs.ReadDirFS, fsPath string) (themes []*ThemeMetaInfo, _ error) {
files, err := dirFS.ReadDir(fsPath)
if err != nil {
log.Error("Failed to list themes: %v", err)
return nil, nil
return nil, err
}

var foundThemes []*ThemeMetaInfo
for _, fileName := range cssFiles {
if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) {
content, err := public.AssetFS().ReadFile("/assets/css/" + fileName)
if err != nil {
log.Error("Failed to read theme file %q: %v", fileName, err)
continue
}
foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
for _, file := range files {
fileName := file.Name()
if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) {
continue
}
content, err := fs.ReadFile(dirFS, path.Join(fsPath, file.Name()))
if err != nil {
log.Error("Failed to read theme file %q: %v", fileName, err)
continue
}
themes = append(themes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
}
return themes, nil
}

func loadThemesFromAssets(isViteDevMode bool) (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
var themeDir fs.ReadDirFS
var themePath string

if isViteDevMode {
// In vite dev mode, Vite serves themes directly from source files.
themeDir, themePath = os.DirFS(setting.StaticRootPath).(fs.ReadDirFS), "web_src/css/themes"
} else {
// Without vite dev server, use built assets from AssetFS.
themeDir, themePath = public.AssetFS(), "assets/css"
}

foundThemes, err := collectThemeFiles(themeDir, themePath)
if err != nil {
log.Error("Failed to load theme files: %v", err)
return themeList, themeMap
}

themeList = foundThemes
Expand Down Expand Up @@ -187,20 +210,21 @@ func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*Th
return themeList, themeMap
}

func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
themeMu.RLock()
if availableThemes != nil {
themeList, themeMap = availableThemes.themeList, availableThemes.themeMap
func getAvailableThemes() *themeCollectionStruct {
themes := themeCollection.Load()

now := time.Now()
if themes != nil && now.Sub(themes.lastCheckTime) < time.Second {
return themes
}
themeMu.RUnlock()
if len(themeList) != 0 {
return themeList, themeMap

isViteDevMode := public.IsViteDevMode()
useLoadedThemes := themes != nil && (setting.IsProd || themes.usingViteDevMode == isViteDevMode)
if useLoadedThemes && len(themes.themeList) > 0 {
return themes
}

themeMu.Lock()
defer themeMu.Unlock()
// no need to double-check "availableThemes.themeList" since the loading isn't really slow, to keep code simple
themeList, themeMap = loadThemesFromAssets()
themeList, themeMap := loadThemesFromAssets(isViteDevMode)
hasAvailableThemes := len(themeList) > 0
if !hasAvailableThemes {
defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)
Expand All @@ -215,27 +239,19 @@ func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*Them
if themeMap[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)
}
availableThemes = &themeCollection{themeList, themeMap}
return themeList, themeMap
}

// In dev mode, only store the loaded themes if the list is not empty, in case the frontend is still being built.
// TBH, there still could be a data-race that the themes are only partially built then the list is incomplete for first time loading.
// Such edge case can be handled by checking whether the loaded themes are the same in a period or there is a flag file, but it is an over-kill, so, no.
if hasAvailableThemes {
availableThemes = &themeCollection{themeList, themeMap}
}
return themeList, themeMap
themes = &themeCollectionStruct{now, isViteDevMode, themeList, themeMap}
themeCollection.Store(themes)
return themes
}

func GetAvailableThemes() []*ThemeMetaInfo {
themes, _ := getAvailableThemes()
return themes
return getAvailableThemes().themeList
}

func GetThemeMetaInfo(internalName string) *ThemeMetaInfo {
_, themeMap := getAvailableThemes()
return themeMap[internalName]
return getAvailableThemes().themeMap[internalName]
}

// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo,
Expand Down
9 changes: 7 additions & 2 deletions templates/base/footer_content.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@
<a target="_blank" href="https://about.gitea.com">{{ctx.Locale.Tr "powered_by" "Gitea"}}</a>
{{end}}
{{if (or .ShowFooterVersion .PageIsAdmin)}}
<span>
{{ctx.Locale.Tr "version"}}:
{{if .IsAdmin}}
<a href="{{AppSubUrl}}/-/admin/config">{{AppVer}}</a>
{{else}}
{{AppVer}}
{{end}}
</span>
{{end}}
{{if and .TemplateLoadTimes ShowFooterTemplateLoadTime}}
{{ctx.Locale.Tr "page"}}: <strong>{{LoadTimes .PageStartTime}}</strong>
{{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: <strong>{{call .TemplateLoadTimes}}</strong>
<span>
{{ctx.Locale.Tr "page"}}: <strong>{{LoadTimes .PageStartTime}}</strong>
{{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: <strong>{{call .TemplateLoadTimes}}</strong>
</span>
{{end}}
{{if $.ViteModeIsDev}}<span class="ui basic label primary">ViteDevMode</span>{{end}}
</div>
<div class="right-links" role="group" aria-label="{{ctx.Locale.Tr "aria.footer.links"}}">
<div class="ui dropdown custom" id="footer-theme-selector">
Expand Down
Loading