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/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`)
|
||||
|
|
|
|||
Loading…
Reference in a new issue