初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package public
|
||||
|
||||
import (
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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"`
|
||||
CSS []string `json:"css"`
|
||||
Imports []string `json:"imports"`
|
||||
}
|
||||
|
||||
type manifestDataStruct struct {
|
||||
entries map[string]*manifestEntry // source path -> entry
|
||||
names map[string]string // content-hashed output file -> entry name
|
||||
modTime int64
|
||||
checkTime time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
manifestData atomic.Pointer[manifestDataStruct]
|
||||
manifestFS = sync.OnceValue(AssetFS)
|
||||
)
|
||||
|
||||
const manifestPath = "assets/.vite/manifest.json"
|
||||
|
||||
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
|
||||
}
|
||||
names = make(map[string]string, len(entries))
|
||||
for _, entry := range entries {
|
||||
names[entry.File] = entry.Name
|
||||
}
|
||||
return entries, names
|
||||
}
|
||||
|
||||
func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
|
||||
now := time.Now()
|
||||
data := existingData
|
||||
if data != nil && now.Sub(data.checkTime) < time.Second {
|
||||
// a single request triggers multiple calls to getHashedPath
|
||||
// do not check the manifest file too frequently
|
||||
return data
|
||||
}
|
||||
|
||||
f, err := manifestFS().Open(manifestPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to open frontend manifest: %v", err)
|
||||
return data
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
log.Error("Failed to stat frontend manifest: %v", err)
|
||||
return data
|
||||
}
|
||||
|
||||
needReload := data == nil || fi.ModTime().UnixNano() != data.modTime
|
||||
if !needReload {
|
||||
return data
|
||||
}
|
||||
manifestContent, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
log.Error("Failed to read frontend manifest: %v", err)
|
||||
return data
|
||||
}
|
||||
return storeManifestFromBytes(manifestContent, fi.ModTime().UnixNano(), now)
|
||||
}
|
||||
|
||||
func storeManifestFromBytes(manifestContent []byte, modTime int64, checkTime time.Time) *manifestDataStruct {
|
||||
entries, names := parseManifest(manifestContent)
|
||||
data := &manifestDataStruct{
|
||||
entries: entries,
|
||||
names: names,
|
||||
modTime: modTime,
|
||||
checkTime: checkTime,
|
||||
}
|
||||
manifestData.Store(data)
|
||||
return data
|
||||
}
|
||||
|
||||
func getManifestData() *manifestDataStruct {
|
||||
data := manifestData.Load()
|
||||
|
||||
// In production the manifest is immutable (embedded in the binary).
|
||||
// In dev mode, check if it changed on disk (for watch-frontend).
|
||||
if data == nil || !setting.IsProd {
|
||||
data = reloadManifest(data)
|
||||
}
|
||||
if data == nil {
|
||||
data = &manifestDataStruct{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
walk(jsEntrySrc)
|
||||
return urls
|
||||
}
|
||||
|
||||
// AssetNameFromHashedPath returns the asset entry name for a given hashed asset path.
|
||||
// Example: returns "theme-gitea-dark" for "css/theme-gitea-dark.CyAaQnn5.css".
|
||||
// Returns empty string if the path is not found in the manifest.
|
||||
func AssetNameFromHashedPath(hashedPath string) string {
|
||||
return getManifestData().names[hashedPath]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package public
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestViteManifest(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.IsProd, true)()
|
||||
|
||||
const testManifest = `{
|
||||
"web_src/js/index.ts": {
|
||||
"file": "js/index.C6Z2MRVQ.js",
|
||||
"name": "index",
|
||||
"src": "web_src/js/index.ts",
|
||||
"isEntry": true,
|
||||
"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
|
||||
}
|
||||
}`
|
||||
|
||||
t.Run("EmptyManifest", func(t *testing.T) {
|
||||
storeManifestFromBytes([]byte(``), 0, time.Now())
|
||||
// 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())
|
||||
|
||||
// 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"))
|
||||
|
||||
// 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"))
|
||||
|
||||
// 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"))
|
||||
|
||||
// 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"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package public
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`,
|
||||
// see the comment of DetectWellKnownMimeType
|
||||
var wellKnownMimeTypesLower = sync.OnceValue(func() map[string]string {
|
||||
return map[string]string{
|
||||
".avif": "image/avif",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".wasm": "application/wasm",
|
||||
".webp": "image/webp",
|
||||
".xml": "text/xml; charset=utf-8",
|
||||
|
||||
// well, there are some types missing from the builtin list
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
}
|
||||
})
|
||||
|
||||
// DetectWellKnownMimeType will return the mime-type for a well-known file ext name
|
||||
// The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension
|
||||
// mime.TypeByExtension would use OS's mime-type config to overwrite the well-known types (see its document).
|
||||
// If the user's OS has incorrect mime-type config, it would make Gitea can not respond a correct Content-Type to browsers.
|
||||
// For example, if Gitea returns `text/plain` for a `.js` file, the browser couldn't run the JS due to security reasons.
|
||||
// DetectWellKnownMimeType makes the Content-Type for well-known files stable.
|
||||
func DetectWellKnownMimeType(ext string) string {
|
||||
ext = strings.ToLower(ext)
|
||||
if s, ok := wellKnownMimeTypesLower()[ext]; ok {
|
||||
return s
|
||||
}
|
||||
return mime.TypeByExtension(ext)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package public
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func CustomAssets() *assetfs.Layer {
|
||||
return assetfs.Local("custom", setting.CustomPath, "public")
|
||||
}
|
||||
|
||||
func AssetFS() *assetfs.LayeredFS {
|
||||
return assetfs.Layered(CustomAssets(), BuiltinAssets())
|
||||
}
|
||||
|
||||
func AssetsCors() func(next http.Handler) http.Handler {
|
||||
// static assets need to be served for external renders (sandboxed)
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"HEAD", "GET"},
|
||||
MaxAge: 3600 * 24,
|
||||
})
|
||||
}
|
||||
|
||||
// FileHandlerFunc implements the static handler for serving files in "public" assets
|
||||
func FileHandlerFunc() http.HandlerFunc {
|
||||
assetFS := AssetFS()
|
||||
return func(resp http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != "GET" && req.Method != "HEAD" {
|
||||
resp.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
handleRequest(resp, req, http.FS(assetFS), req.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// parseAcceptEncoding parse Accept-Encoding: deflate, gzip;q=1.0, *;q=0.5 as compress methods
|
||||
func parseAcceptEncoding(val string) container.Set[string] {
|
||||
parts := strings.Split(val, ";")
|
||||
types := make(container.Set[string])
|
||||
for v := range strings.SplitSeq(parts[0], ",") {
|
||||
types.Add(strings.TrimSpace(v))
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// setWellKnownContentType will set the Content-Type if the file is a well-known type.
|
||||
// See the comments of DetectWellKnownMimeType
|
||||
func setWellKnownContentType(w http.ResponseWriter, file string) {
|
||||
mimeType := DetectWellKnownMimeType(path.Ext(file))
|
||||
if mimeType != "" {
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRequest(w http.ResponseWriter, req *http.Request, fs http.FileSystem, file string) {
|
||||
// actually, fs (http.FileSystem) is designed to be a safe interface, relative paths won't bypass its parent directory, it's also fine to do a clean here
|
||||
f, err := fs.Open(util.PathJoinRelX(file))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Error("[Static] Open %q failed: %v", file, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Error("[Static] %q exists, but fails to open: %v", file, err)
|
||||
return
|
||||
}
|
||||
|
||||
// need to serve index file? (no at the moment)
|
||||
if fi.IsDir() {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
servePublicAsset(w, req, fi, fi.ModTime(), f)
|
||||
}
|
||||
|
||||
// servePublicAsset serve http content
|
||||
func servePublicAsset(w http.ResponseWriter, req *http.Request, fi os.FileInfo, modtime time.Time, content io.ReadSeeker) {
|
||||
setWellKnownContentType(w, fi.Name())
|
||||
httpcache.SetCacheControlInHeader(w.Header(), httpcache.CacheControlForPublicStatic())
|
||||
encodings := parseAcceptEncoding(req.Header.Get("Accept-Encoding"))
|
||||
fiEmbedded, _ := fi.(assetfs.EmbeddedFileInfo)
|
||||
if encodings.Contains("gzip") && fiEmbedded != nil {
|
||||
// try to provide gzip content directly from bindata
|
||||
if gzipBytes, ok := fiEmbedded.GetGzipContent(); ok {
|
||||
rdGzip := bytes.NewReader(gzipBytes)
|
||||
// all gzipped static files (from bindata) are managed by Gitea, so we can make sure every file has the correct ext name
|
||||
// then we can get the correct Content-Type, we do not need to do http.DetectContentType on the decompressed data
|
||||
if w.Header().Get("Content-Type") == "" {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
http.ServeContent(w, req, fi.Name(), modtime, rdGzip)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.ServeContent(w, req, fi.Name(), modtime, content)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build bindata
|
||||
|
||||
package public
|
||||
|
||||
//go:generate go run ../../build/generate-bindata.go ../../public bindata.dat
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"gitea.dev/modules/assetfs"
|
||||
)
|
||||
|
||||
//go:embed bindata.dat
|
||||
var bindata []byte
|
||||
|
||||
var BuiltinAssets = sync.OnceValue(func() *assetfs.Layer {
|
||||
return assetfs.Bindata("builtin(bindata)", assetfs.NewEmbeddedFS(bindata))
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !bindata
|
||||
|
||||
package public
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/assetfs"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func BuiltinAssets() *assetfs.Layer {
|
||||
return assetfs.Local("builtin(static)", setting.StaticRootPath, "public")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package public
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseAcceptEncoding(t *testing.T) {
|
||||
kases := []struct {
|
||||
Header string
|
||||
Expected container.Set[string]
|
||||
}{
|
||||
{
|
||||
Header: "deflate, gzip;q=1.0, *;q=0.5",
|
||||
Expected: container.SetOf("deflate", "gzip"),
|
||||
},
|
||||
{
|
||||
Header: " gzip, deflate, br",
|
||||
Expected: container.SetOf("deflate", "gzip", "br"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.Header, func(t *testing.T) {
|
||||
assert.EqualValues(t, kase.Expected, parseAcceptEncoding(kase.Header))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package public
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
viteDevServerBaseURL := getViteDevServerBaseURL()
|
||||
if viteDevServerBaseURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
target, err := url.Parse(viteDevServerBaseURL)
|
||||
if err != nil {
|
||||
log.Error("Failed to parse Vite dev server base URL %s, err: %v", viteDevServerBaseURL, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// there is a strange error log (from Golang's HTTP package)
|
||||
// 2026/03/28 19:50:13 modules/log/misc.go:72:(*loggerToWriter).Write() [I] Unsolicited response received on idle HTTP channel starting with "HTTP/1.1 400 Bad Request\r\n\r\n"; err=<nil>
|
||||
// maybe it is caused by that the Vite dev server doesn't support keep-alive connections? or different keep-alive timeouts?
|
||||
transport := &http.Transport{
|
||||
IdleConnTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
log.Info("Proxying Vite dev server requests to %s", target)
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Transport: transport,
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(target)
|
||||
r.Out.Host = target.Host
|
||||
},
|
||||
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-Dev-Server", viteDevServerBaseURL)
|
||||
return nil
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if r.Context().Err() != nil {
|
||||
return // request cancelled (e.g. client disconnected), silently ignore
|
||||
}
|
||||
log.Error("Error proxying to Vite dev server: %v", err)
|
||||
http.Error(w, "Error proxying to Vite dev server: "+err.Error(), http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
viteDevProxy.Store(proxy)
|
||||
return proxy
|
||||
}
|
||||
|
||||
// ViteDevMiddleware proxies matching requests to the Vite dev server.
|
||||
// It is registered as middleware in non-production mode and lazily discovers
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
proxy := getViteDevProxy()
|
||||
if proxy == nil {
|
||||
next.ServeHTTP(resp, req)
|
||||
return
|
||||
}
|
||||
markLongPolling(proxy).ServeHTTP(resp, req)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 setting.AppSubURL + "/" + srcPath
|
||||
}
|
||||
|
||||
// isViteDevRequest returns true if the request should be proxied to the Vite dev server.
|
||||
// Ref: Vite source packages/vite/src/node/constants.ts and packages/vite/src/shared/constants.ts
|
||||
func isViteDevRequest(req *http.Request) bool {
|
||||
if req.Header.Get("Upgrade") == "websocket" {
|
||||
wsProtocol := req.Header.Get("Sec-WebSocket-Protocol")
|
||||
return wsProtocol == "vite-hmr" || wsProtocol == "vite-ping"
|
||||
}
|
||||
path := req.URL.Path
|
||||
|
||||
// vite internal requests
|
||||
if strings.HasPrefix(path, "/@vite/") /* HMR client */ ||
|
||||
strings.HasPrefix(path, "/@fs/") /* out-of-root file access, see vite.config.ts: fs.allow */ ||
|
||||
strings.HasPrefix(path, "/@id/") /* virtual modules */ {
|
||||
return true
|
||||
}
|
||||
|
||||
// local source requests (VITE-DEV-SERVER-SECURITY: don't serve sensitive files outside the allowed paths)
|
||||
if strings.HasPrefix(path, "/node_modules/") ||
|
||||
strings.HasPrefix(path, "/public/assets/") ||
|
||||
strings.HasPrefix(path, "/web_src/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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/<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/*.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
|
||||
switch path {
|
||||
case "/assets/emoji.json", "/assets/codemirror-languages.json":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user