feat: load fingerprinted asset manifest

This commit is contained in:
Sora39831 2026-04-07 12:24:33 +08:00
parent faeb8dd244
commit e6752e04db
3 changed files with 209 additions and 6 deletions

102
web/asset_manifest.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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`)