From e6752e04db84adca70e2a24d714359d9a64f551c Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 12:24:33 +0800 Subject: [PATCH] feat: load fingerprinted asset manifest --- web/asset_manifest.go | 102 +++++++++++++++++++++++++++++++++++++ web/asset_manifest_test.go | 75 +++++++++++++++++++++++++++ web/web.go | 38 +++++++++++--- 3 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 web/asset_manifest.go create mode 100644 web/asset_manifest_test.go diff --git a/web/asset_manifest.go b/web/asset_manifest.go new file mode 100644 index 00000000..54726523 --- /dev/null +++ b/web/asset_manifest.go @@ -0,0 +1,102 @@ +package web + +import ( + "encoding/json" + "fmt" + "io/fs" + "path" + "strings" +) + +type assetManifest map[string]string + +type assetResolver struct { + basePath string + debug bool + manifest assetManifest +} + +func newAssetResolver(basePath string, debug bool, manifest assetManifest) assetResolver { + return assetResolver{ + basePath: basePath, + debug: debug, + manifest: manifest, + } +} + +func (r assetResolver) URL(logical string) string { + target := logical + if !r.debug { + hashed, ok := r.manifest[logical] + if !ok { + panic(fmt.Sprintf("missing asset manifest entry for %q", logical)) + } + target = hashed + } + return path.Join(r.basePath, "assets", target) +} + +func loadAssetManifest(raw []byte) (assetManifest, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("asset manifest is empty") + } + var manifest assetManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, err + } + if len(manifest) == 0 { + return nil, fmt.Errorf("asset manifest has no entries") + } + return manifest, nil +} + +func assetCacheControl(requestPath string) string { + if hasFingerprint(requestPath) { + return "public, max-age=31536000, immutable" + } + return "public, max-age=300" +} + +func assetRequestCacheControl(requestPath string, exists bool) string { + if exists { + return assetCacheControl(requestPath) + } + return "public, max-age=300" +} + +func assetExists(assetsFS fs.FS, assetPath string) bool { + if assetPath == "" { + return false + } + if _, err := fs.Stat(assetsFS, assetPath); err != nil { + return false + } + return true +} + +func hasFingerprint(requestPath string) bool { + base := path.Base(requestPath) + parts := strings.Split(base, ".") + if len(parts) < 2 { + return false + } + if isFingerprintHash(parts[len(parts)-1]) { + return true + } + if len(parts) >= 3 && isFingerprintHash(parts[len(parts)-2]) { + return true + } + return false +} + +func isFingerprintHash(hash string) bool { + if len(hash) < 6 || len(hash) > 64 { + return false + } + for _, ch := range hash { + if !strings.ContainsRune("0123456789abcdef", ch) { + return false + } + } + return true +} diff --git a/web/asset_manifest_test.go b/web/asset_manifest_test.go new file mode 100644 index 00000000..20817553 --- /dev/null +++ b/web/asset_manifest_test.go @@ -0,0 +1,75 @@ +package web + +import ( + "strings" + "testing" +) + +func TestAssetResolverReturnsFingerprintedPathInProduction(t *testing.T) { + resolver := newAssetResolver("/panel/", false, assetManifest{ + "js/websocket.js": "js/websocket.12345678.js", + }) + + got := resolver.URL("js/websocket.js") + want := "/panel/assets/js/websocket.12345678.js" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAssetResolverReturnsLogicalPathInDebug(t *testing.T) { + resolver := newAssetResolver("/panel/", true, nil) + + got := resolver.URL("js/websocket.js") + want := "/panel/assets/js/websocket.js" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAssetResolverPanicsOnMissingProductionAsset(t *testing.T) { + resolver := newAssetResolver("/", false, assetManifest{}) + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for missing manifest key") + } + }() + + resolver.URL("missing.js") +} + +func TestFingerprintCacheHeaderIncludesImmutable(t *testing.T) { + got := assetCacheControl("js/websocket.12345678.js") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control, got %q", got) + } +} + +func TestFingerprintCacheHeaderIncludesImmutableForDotfile(t *testing.T) { + got := assetCacheControl(".env.12345678") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control for dotfile, got %q", got) + } +} + +func TestFingerprintCacheHeaderSupportsVariableHexLength(t *testing.T) { + got := assetCacheControl("js/websocket.123456789abc.js") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control for variable-length hash, got %q", got) + } +} + +func TestFingerprintCacheHeaderRejectsObviousNonFingerprint(t *testing.T) { + got := assetCacheControl("js/websocket.nothex123.js") + if strings.Contains(got, "immutable") { + t.Fatalf("expected short-lived cache-control for non-fingerprint, got %q", got) + } +} + +func TestAssetRequestCacheControlDoesNotMarkMissingFingerprintPathImmutable(t *testing.T) { + got := assetRequestCacheControl("js/missing.123456789abc.js", false) + if strings.Contains(got, "immutable") { + t.Fatalf("expected missing asset path to avoid immutable cache-control, got %q", got) + } +} diff --git a/web/web.go b/web/web.go index 9e8eef91..84fae9e8 100644 --- a/web/web.go +++ b/web/web.go @@ -12,6 +12,7 @@ import ( "net" "net/http" "os" + "path" "strconv" "strings" "time" @@ -37,6 +38,12 @@ import ( //go:embed assets var assetsFS embed.FS +//go:embed public/assets +var publicAssetsFS embed.FS + +//go:embed public/assets-manifest.json +var assetsManifestRaw []byte + //go:embed html/* var htmlFS embed.FS @@ -44,13 +51,15 @@ var htmlFS embed.FS var i18nFS embed.FS var startTime = time.Now() +var productionAssetManifest assetManifest type wrapAssetsFS struct { embed.FS + root string } func (f *wrapAssetsFS) Open(name string) (fs.File, error) { - file, err := f.FS.Open("assets/" + name) + file, err := f.FS.Open(path.Join(f.root, name)) if err != nil { return nil, err } @@ -81,6 +90,17 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time { return startTime } +func init() { + if config.IsDebug() { + return + } + manifest, err := loadAssetManifest(assetsManifestRaw) + if err != nil { + panic(err) + } + productionAssetManifest = manifest +} + // EmbeddedHTML returns the embedded HTML templates filesystem for reuse by other servers. func EmbeddedHTML() embed.FS { return htmlFS @@ -202,6 +222,8 @@ func (s *Server) initRouter() (*gin.Engine, error) { } engine.Use(gzip.Gzip(gzip.DefaultCompression)) assetsBasePath := basePath + "assets/" + assetResolver := newAssetResolver(basePath, config.IsDebug(), productionAssetManifest) + var staticAssetsFS fs.FS store := cookie.NewStore(secret) // Configure default session cookie options, including expiration (MaxAge) @@ -218,9 +240,10 @@ func (s *Server) initRouter() (*gin.Engine, error) { c.Set("base_path", basePath) }) engine.Use(func(c *gin.Context) { - uri := c.Request.RequestURI + uri := c.Request.URL.Path if strings.HasPrefix(uri, assetsBasePath) { - c.Header("Cache-Control", "max-age=31536000") + assetPath := strings.TrimPrefix(uri, assetsBasePath) + c.Header("Cache-Control", assetRequestCacheControl(uri, assetExists(staticAssetsFS, assetPath))) } }) @@ -236,7 +259,8 @@ func (s *Server) initRouter() (*gin.Engine, error) { } // Register template functions before loading templates funcMap := template.FuncMap{ - "i18n": i18nWebFunc, + "i18n": i18nWebFunc, + "asset": assetResolver.URL, } engine.SetFuncMap(funcMap) engine.Use(locale.LocalizerMiddleware()) @@ -250,7 +274,8 @@ func (s *Server) initRouter() (*gin.Engine, error) { } // Use the registered func map with the loaded templates engine.LoadHTMLFiles(files...) - engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/assets"))) + staticAssetsFS = os.DirFS("web/assets") + engine.StaticFS(basePath+"assets", http.FS(staticAssetsFS)) } else { // for production template, err := s.getHtmlTemplate(funcMap) @@ -258,7 +283,8 @@ func (s *Server) initRouter() (*gin.Engine, error) { return nil, err } engine.SetHTMLTemplate(template) - engine.StaticFS(basePath+"assets", http.FS(&wrapAssetsFS{FS: assetsFS})) + staticAssetsFS = &wrapAssetsFS{FS: publicAssetsFS, root: "public/assets"} + engine.StaticFS(basePath+"assets", http.FS(staticAssetsFS)) } // Apply the redirect middleware (`/xui` to `/panel`)