mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
feat: load fingerprinted asset manifest
This commit is contained in:
parent
faeb8dd244
commit
e6752e04db
3 changed files with 209 additions and 6 deletions
102
web/asset_manifest.go
Normal file
102
web/asset_manifest.go
Normal 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
|
||||||
|
}
|
||||||
75
web/asset_manifest_test.go
Normal file
75
web/asset_manifest_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
web/web.go
38
web/web.go
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -37,6 +38,12 @@ import (
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
var assetsFS embed.FS
|
var assetsFS embed.FS
|
||||||
|
|
||||||
|
//go:embed public/assets
|
||||||
|
var publicAssetsFS embed.FS
|
||||||
|
|
||||||
|
//go:embed public/assets-manifest.json
|
||||||
|
var assetsManifestRaw []byte
|
||||||
|
|
||||||
//go:embed html/*
|
//go:embed html/*
|
||||||
var htmlFS embed.FS
|
var htmlFS embed.FS
|
||||||
|
|
||||||
|
|
@ -44,13 +51,15 @@ var htmlFS embed.FS
|
||||||
var i18nFS embed.FS
|
var i18nFS embed.FS
|
||||||
|
|
||||||
var startTime = time.Now()
|
var startTime = time.Now()
|
||||||
|
var productionAssetManifest assetManifest
|
||||||
|
|
||||||
type wrapAssetsFS struct {
|
type wrapAssetsFS struct {
|
||||||
embed.FS
|
embed.FS
|
||||||
|
root string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *wrapAssetsFS) Open(name string) (fs.File, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +90,17 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time {
|
||||||
return startTime
|
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.
|
// EmbeddedHTML returns the embedded HTML templates filesystem for reuse by other servers.
|
||||||
func EmbeddedHTML() embed.FS {
|
func EmbeddedHTML() embed.FS {
|
||||||
return htmlFS
|
return htmlFS
|
||||||
|
|
@ -202,6 +222,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
}
|
}
|
||||||
engine.Use(gzip.Gzip(gzip.DefaultCompression))
|
engine.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
assetsBasePath := basePath + "assets/"
|
assetsBasePath := basePath + "assets/"
|
||||||
|
assetResolver := newAssetResolver(basePath, config.IsDebug(), productionAssetManifest)
|
||||||
|
var staticAssetsFS fs.FS
|
||||||
|
|
||||||
store := cookie.NewStore(secret)
|
store := cookie.NewStore(secret)
|
||||||
// Configure default session cookie options, including expiration (MaxAge)
|
// Configure default session cookie options, including expiration (MaxAge)
|
||||||
|
|
@ -218,9 +240,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
c.Set("base_path", basePath)
|
c.Set("base_path", basePath)
|
||||||
})
|
})
|
||||||
engine.Use(func(c *gin.Context) {
|
engine.Use(func(c *gin.Context) {
|
||||||
uri := c.Request.RequestURI
|
uri := c.Request.URL.Path
|
||||||
if strings.HasPrefix(uri, assetsBasePath) {
|
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
|
// Register template functions before loading templates
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"i18n": i18nWebFunc,
|
"i18n": i18nWebFunc,
|
||||||
|
"asset": assetResolver.URL,
|
||||||
}
|
}
|
||||||
engine.SetFuncMap(funcMap)
|
engine.SetFuncMap(funcMap)
|
||||||
engine.Use(locale.LocalizerMiddleware())
|
engine.Use(locale.LocalizerMiddleware())
|
||||||
|
|
@ -250,7 +274,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
}
|
}
|
||||||
// Use the registered func map with the loaded templates
|
// Use the registered func map with the loaded templates
|
||||||
engine.LoadHTMLFiles(files...)
|
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 {
|
} else {
|
||||||
// for production
|
// for production
|
||||||
template, err := s.getHtmlTemplate(funcMap)
|
template, err := s.getHtmlTemplate(funcMap)
|
||||||
|
|
@ -258,7 +283,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
engine.SetHTMLTemplate(template)
|
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`)
|
// Apply the redirect middleware (`/xui` to `/panel`)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue