This commit is contained in:
@@ -4,27 +4,31 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// https://vite.dev/guide/backend-integration
|
||||
type manifestEntry struct {
|
||||
File string `json:"file"`
|
||||
Name string `json:"name"`
|
||||
IsEntry bool `json:"isEntry"`
|
||||
CSS []string `json:"css"`
|
||||
Imports []string `json:"imports"`
|
||||
}
|
||||
|
||||
type manifestDataStruct struct {
|
||||
paths map[string]string // unhashed path -> hashed path
|
||||
names map[string]string // hashed path -> entry name
|
||||
entries map[string]*manifestEntry // source path -> entry
|
||||
names map[string]string // content-hashed output file -> entry name
|
||||
modTime int64
|
||||
checkTime time.Time
|
||||
}
|
||||
@@ -36,35 +40,16 @@ var (
|
||||
|
||||
const manifestPath = "assets/.vite/manifest.json"
|
||||
|
||||
func parseManifest(data []byte) (map[string]string, map[string]string) {
|
||||
var manifest map[string]manifestEntry
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
func parseManifest(data []byte) (entries map[string]*manifestEntry, names map[string]string) {
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
log.Error("Failed to parse frontend manifest: %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
paths := make(map[string]string)
|
||||
names := make(map[string]string)
|
||||
for _, entry := range manifest {
|
||||
if !entry.IsEntry || entry.Name == "" {
|
||||
continue
|
||||
}
|
||||
// Build unhashed key from file path: "js/index.js", "css/theme-gitea-dark.css"
|
||||
dir := path.Dir(entry.File)
|
||||
ext := path.Ext(entry.File)
|
||||
key := dir + "/" + entry.Name + ext
|
||||
paths[key] = entry.File
|
||||
names = make(map[string]string, len(entries))
|
||||
for _, entry := range entries {
|
||||
names[entry.File] = entry.Name
|
||||
// Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css"
|
||||
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: the logic is wrong, Vite manifest doesn't work this way
|
||||
// It just happens to be correct for the current modules dependencies
|
||||
for _, css := range entry.CSS {
|
||||
cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css)
|
||||
paths[cssKey] = css
|
||||
names[css] = entry.Name
|
||||
}
|
||||
}
|
||||
return paths, names
|
||||
return entries, names
|
||||
}
|
||||
|
||||
func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
|
||||
@@ -102,9 +87,9 @@ func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
|
||||
}
|
||||
|
||||
func storeManifestFromBytes(manifestContent []byte, modTime int64, checkTime time.Time) *manifestDataStruct {
|
||||
paths, names := parseManifest(manifestContent)
|
||||
entries, names := parseManifest(manifestContent)
|
||||
data := &manifestDataStruct{
|
||||
paths: paths,
|
||||
entries: entries,
|
||||
names: names,
|
||||
modTime: modTime,
|
||||
checkTime: checkTime,
|
||||
@@ -127,33 +112,66 @@ func getManifestData() *manifestDataStruct {
|
||||
return data
|
||||
}
|
||||
|
||||
// AssetURI returns the URI for a frontend asset.
|
||||
// It may return a relative path or a full URL depending on the StaticURLPrefix setting.
|
||||
// In Vite dev mode, known entry points are mapped to their source paths
|
||||
// so the reverse proxy serves them from the Vite dev server.
|
||||
// In production, it resolves the content-hashed path from the manifest.
|
||||
func AssetURI(originPath string) string {
|
||||
// devAssetURL returns a source file's Vite dev server URL, panicking in dev/testing if it's absent.
|
||||
func devAssetURL(src string) string {
|
||||
if url := viteDevSourceURL(src); url != "" {
|
||||
return url
|
||||
}
|
||||
setting.PanicInDevOrTesting("Failed to locate source file for asset: %s", src)
|
||||
return ""
|
||||
}
|
||||
|
||||
// AssetURI resolves a frontend asset by its source path (the Vite manifest key, e.g.
|
||||
// "web_src/js/index.ts"). Dev mode serves the source file; production resolves the hashed output.
|
||||
func AssetURI(srcPath string) string {
|
||||
if IsViteDevMode() {
|
||||
if src := viteDevSourceURL(originPath); src != "" {
|
||||
return src
|
||||
}
|
||||
// it should be caused by incorrect vite config
|
||||
setting.PanicInDevOrTesting("Failed to locate local path for managed asset URI: %s", originPath)
|
||||
return devAssetURL(srcPath)
|
||||
}
|
||||
if entry := getManifestData().entries[srcPath]; entry != nil {
|
||||
return setting.StaticURLPrefix + "/assets/" + entry.File
|
||||
}
|
||||
// The only expected manifest miss is a user's custom theme CSS, served as a static asset
|
||||
// under "/assets/css/". Anything else is a misconfigured or missing entry.
|
||||
if path.Ext(srcPath) == ".css" {
|
||||
return setting.StaticURLPrefix + "/assets/css/" + path.Base(srcPath)
|
||||
}
|
||||
log.Error("asset not found in frontend manifest: %s", srcPath)
|
||||
return setting.StaticURLPrefix + "/assets/" + path.Base(srcPath)
|
||||
}
|
||||
|
||||
// Try to resolve an unhashed asset path (origin path) to its content-hashed path from the frontend manifest.
|
||||
// Example: "js/index.js" -> "js/index.C6Z2MRVQ.js"
|
||||
data := getManifestData()
|
||||
assetPath := data.paths[originPath]
|
||||
if assetPath == "" {
|
||||
// it should be caused by either: "incorrect vite config" or "user's custom theme"
|
||||
assetPath = originPath
|
||||
if !setting.IsProd {
|
||||
log.Warn("Failed to find managed asset URI for origin path: %s", originPath)
|
||||
// AssetCSSLinks renders the <link> tags for a JS entry's stylesheets: the entry's CSS plus the CSS
|
||||
// of every statically-imported chunk. Dev links devStylesheetSrc and lets the JS module inject the rest.
|
||||
func AssetCSSLinks(jsEntrySrc, devStylesheetSrc string) template.HTML {
|
||||
var b strings.Builder
|
||||
for _, href := range entryStyleURLs(jsEntrySrc, devStylesheetSrc) {
|
||||
b.WriteString(`<link rel="stylesheet" href="` + html.EscapeString(href) + `">`)
|
||||
}
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
func entryStyleURLs(jsEntrySrc, devStylesheetSrc string) []string {
|
||||
if IsViteDevMode() {
|
||||
return []string{devAssetURL(devStylesheetSrc)}
|
||||
}
|
||||
entries := getManifestData().entries
|
||||
var urls []string
|
||||
seen := make(map[string]bool)
|
||||
var walk func(key string)
|
||||
walk = func(key string) {
|
||||
entry := entries[key]
|
||||
if entry == nil || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
for _, css := range entry.CSS {
|
||||
urls = append(urls, setting.StaticURLPrefix+"/assets/"+css)
|
||||
}
|
||||
for _, imp := range entry.Imports {
|
||||
walk(imp)
|
||||
}
|
||||
}
|
||||
|
||||
return setting.StaticURLPrefix + "/assets/" + assetPath
|
||||
walk(jsEntrySrc)
|
||||
return urls
|
||||
}
|
||||
|
||||
// AssetNameFromHashedPath returns the asset entry name for a given hashed asset path.
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -22,59 +23,54 @@ func TestViteManifest(t *testing.T) {
|
||||
"name": "index",
|
||||
"src": "web_src/js/index.ts",
|
||||
"isEntry": true,
|
||||
"css": ["css/index.B3zrQPqD.css"]
|
||||
"imports": ["_shared.AaAaAaAa.js"],
|
||||
"css": ["css/index.B3zrQPqD.css", "css/index-extra.CcCcCcCc.css"]
|
||||
},
|
||||
"_shared.AaAaAaAa.js": {
|
||||
"file": "js/shared.AaAaAaAa.js",
|
||||
"name": "shared",
|
||||
"css": ["css/shared.BbBbBbBb.css"]
|
||||
},
|
||||
"web_src/css/themes/theme-gitea-dark.css": {
|
||||
"file": "css/theme-gitea-dark.CyAaQnn5.css",
|
||||
"name": "theme-gitea-dark",
|
||||
"src": "web_src/css/themes/theme-gitea-dark.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"web_src/js/features/eventsource.sharedworker.ts": {
|
||||
"file": "js/eventsource.sharedworker.Dug1twio.js",
|
||||
"name": "eventsource.sharedworker",
|
||||
"src": "web_src/js/features/eventsource.sharedworker.ts",
|
||||
"isEntry": true
|
||||
},
|
||||
"_chunk.js": {
|
||||
"file": "js/chunk.abc123.js",
|
||||
"name": "chunk"
|
||||
}
|
||||
}`
|
||||
|
||||
t.Run("EmptyManifest", func(t *testing.T) {
|
||||
storeManifestFromBytes([]byte(``), 0, time.Now())
|
||||
assert.Equal(t, "/assets/js/index.js", AssetURI("js/index.js"))
|
||||
assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("css/theme-gitea-dark.css"))
|
||||
assert.Equal(t, "", AssetNameFromHashedPath("css/no-such-file.css"))
|
||||
// not in manifest -> custom theme fallback
|
||||
assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("web_src/css/themes/theme-gitea-dark.css"))
|
||||
assert.Empty(t, entryStyleURLs("web_src/js/index.ts", "web_src/css/index.css"))
|
||||
assert.Empty(t, AssetNameFromHashedPath("css/no-such-file.css"))
|
||||
})
|
||||
|
||||
t.Run("ParseManifest", func(t *testing.T) {
|
||||
storeManifestFromBytes([]byte(testManifest), 0, time.Now())
|
||||
paths, names := manifestData.Load().paths, manifestData.Load().names
|
||||
|
||||
// JS entries
|
||||
assert.Equal(t, "js/index.C6Z2MRVQ.js", paths["js/index.js"])
|
||||
assert.Equal(t, "js/eventsource.sharedworker.Dug1twio.js", paths["js/eventsource.sharedworker.js"])
|
||||
// assets are addressed by their source path (the manifest key)
|
||||
assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("web_src/js/index.ts"))
|
||||
assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("web_src/css/themes/theme-gitea-dark.css"))
|
||||
|
||||
// Associated CSS from JS entries
|
||||
assert.Equal(t, "css/index.B3zrQPqD.css", paths["css/index.css"])
|
||||
// custom theme not in the manifest falls back to the static asset location
|
||||
assert.Equal(t, "/assets/css/theme-custom.css", AssetURI("web_src/css/themes/theme-custom.css"))
|
||||
|
||||
// CSS-only entries
|
||||
assert.Equal(t, "css/theme-gitea-dark.CyAaQnn5.css", paths["css/theme-gitea-dark.css"])
|
||||
// a JS entry's stylesheets: all of the entry's own CSS plus the CSS of statically-imported chunks
|
||||
assert.Equal(t, []string{
|
||||
"/assets/css/index.B3zrQPqD.css",
|
||||
"/assets/css/index-extra.CcCcCcCc.css",
|
||||
"/assets/css/shared.BbBbBbBb.css",
|
||||
}, entryStyleURLs("web_src/js/index.ts", "web_src/css/index.css"))
|
||||
assert.Equal(t, template.HTML(
|
||||
`<link rel="stylesheet" href="/assets/css/index.B3zrQPqD.css">`+
|
||||
`<link rel="stylesheet" href="/assets/css/index-extra.CcCcCcCc.css">`+
|
||||
`<link rel="stylesheet" href="/assets/css/shared.BbBbBbBb.css">`,
|
||||
), AssetCSSLinks("web_src/js/index.ts", "web_src/css/index.css"))
|
||||
|
||||
// Non-entry chunks should not be included
|
||||
assert.Empty(t, paths["js/chunk.js"])
|
||||
|
||||
// Names: hashed path -> entry name
|
||||
assert.Equal(t, "index", names["js/index.C6Z2MRVQ.js"])
|
||||
assert.Equal(t, "index", names["css/index.B3zrQPqD.css"])
|
||||
assert.Equal(t, "theme-gitea-dark", names["css/theme-gitea-dark.CyAaQnn5.css"])
|
||||
assert.Equal(t, "eventsource.sharedworker", names["js/eventsource.sharedworker.Dug1twio.js"])
|
||||
|
||||
// Test Asset related functions
|
||||
assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("js/index.js"))
|
||||
assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("css/theme-gitea-dark.css"))
|
||||
// hashed output file -> entry name
|
||||
assert.Equal(t, "theme-gitea-dark", AssetNameFromHashedPath("css/theme-gitea-dark.CyAaQnn5.css"))
|
||||
assert.Empty(t, AssetNameFromHashedPath("css/no-such-file.css"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/httpcache"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/assetfs"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/httpcache"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
_ "embed"
|
||||
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"gitea.dev/modules/assetfs"
|
||||
)
|
||||
|
||||
//go:embed bindata.dat
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/assetfs"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/assetfs"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func BuiltinAssets() *assetfs.Layer {
|
||||
|
||||
@@ -6,7 +6,7 @@ package public
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"gitea.dev/modules/container"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -13,11 +13,11 @@ 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/util"
|
||||
"code.gitea.io/gitea/modules/web/routing"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web/routing"
|
||||
)
|
||||
|
||||
const viteDevPortFile = "public/assets/.vite/dev-port"
|
||||
@@ -87,6 +87,7 @@ func getViteDevProxy() *httputil.ReverseProxy {
|
||||
// the Vite dev server port from the port file written by the viteDevServerPortPlugin.
|
||||
// It is needed because there are container-based development, only Gitea web server's port is exposed.
|
||||
func ViteDevMiddleware(next http.Handler) http.Handler {
|
||||
markLongPolling := routing.MarkLongPolling()
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
if !isViteDevRequest(req) {
|
||||
next.ServeHTTP(resp, req)
|
||||
@@ -97,8 +98,7 @@ func ViteDevMiddleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(resp, req)
|
||||
return
|
||||
}
|
||||
routing.MarkLongPolling(resp, req)
|
||||
proxy.ServeHTTP(resp, req)
|
||||
markLongPolling(proxy).ServeHTTP(resp, req)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,31 +140,13 @@ func IsViteDevMode() bool {
|
||||
return isDev
|
||||
}
|
||||
|
||||
func detectWebSrcPath(webSrcPath string) string {
|
||||
localPath := util.FilePathJoinAbs(setting.StaticRootPath, "web_src", webSrcPath)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return setting.AppSubURL + "/web_src/" + webSrcPath
|
||||
// viteDevSourceURL returns the dev server URL for a source file, or "" if it doesn't exist.
|
||||
func viteDevSourceURL(srcPath string) string {
|
||||
localPath := util.FilePathJoinAbs(setting.StaticRootPath, srcPath)
|
||||
if _, err := os.Stat(localPath); err != nil {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func viteDevSourceURL(name string) string {
|
||||
if strings.HasPrefix(name, "css/theme-") {
|
||||
// Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/
|
||||
themeFilePath := "css/themes/" + strings.TrimPrefix(name, "css/")
|
||||
if srcPath := detectWebSrcPath(themeFilePath); srcPath != "" {
|
||||
return srcPath
|
||||
}
|
||||
}
|
||||
// try to map ".js" files to ".ts" files
|
||||
pathPrefix, ok := strings.CutSuffix(name, ".js")
|
||||
if ok {
|
||||
if srcPath := detectWebSrcPath(pathPrefix + ".ts"); srcPath != "" {
|
||||
return srcPath
|
||||
}
|
||||
}
|
||||
// for all others that the names match
|
||||
return detectWebSrcPath(name)
|
||||
return setting.AppSubURL + "/" + srcPath
|
||||
}
|
||||
|
||||
// isViteDevRequest returns true if the request should be proxied to the Vite dev server.
|
||||
@@ -192,12 +174,13 @@ func isViteDevRequest(req *http.Request) bool {
|
||||
|
||||
// Vite uses a path relative to project root and adds "?import" to non-JS/CSS asset imports:
|
||||
// - {WebSite}/public/assets/... (e.g. SVG icons from "{RepoRoot}/public/assets/img/svg/")
|
||||
// - {WebSite}/assets/emoji.json: it is an exception for the frontend assets, it is imported by JS code, but:
|
||||
// - {WebSite}/assets/<file>.json: exception for frontend-imported repo-root assets:
|
||||
// - KEEP IN MIND: all static frontend assets are served from "{AssetFS}/assets" to "{WebSite}/assets" by Gitea Web Server
|
||||
// - "{AssetFS}" is a layered filesystem from "{RepoRoot}/public" or embedded assets, and user's custom files in "{CustomPath}/public"
|
||||
// - "{RepoRoot}/assets/emoji.json" just happens to have the dir name "assets", it is not related to frontend assets
|
||||
// - "{RepoRoot}/assets/*.json" just happens to live under the dir name "assets"; it is not related to frontend assets
|
||||
// - BAD DESIGN: indeed it is a "conflicted and polluted name" sample
|
||||
if path == "/assets/emoji.json" {
|
||||
switch path {
|
||||
case "/assets/emoji.json", "/assets/codemirror-languages.json":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user