From cfb169d2fb26d4e8d5d3adff254f425e81d9a86d Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 16:41:55 +0800 Subject: [PATCH] refactor: resolve template assets through manifest helper --- sub/sub.go | 66 ++++++++++++-- sub/sub_test.go | 89 +++++++++++++++++++ web/asset_manifest_test.go | 12 +++ web/html/common/page.html | 24 ++--- web/html/component/aPersianDatepicker.html | 8 +- web/html/inbounds.html | 10 +-- web/html/settings.html | 6 +- .../settings/panel/subscription/subpage.html | 16 ++-- web/html/xray.html | 30 +++---- web/web.go | 10 +++ 10 files changed, 216 insertions(+), 55 deletions(-) create mode 100644 sub/sub_test.go diff --git a/sub/sub.go b/sub/sub.go index 1dcd9601..7d1fde9f 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -5,12 +5,15 @@ package sub import ( "context" "crypto/tls" + "encoding/json" + "fmt" "html/template" "io" "io/fs" "net" "net/http" "os" + "path" "path/filepath" "strconv" "strings" @@ -26,6 +29,8 @@ import ( "github.com/gin-gonic/gin" ) +type subscriptionAssetManifest map[string]string + // setEmbeddedTemplates parses and sets embedded templates on the engine func setEmbeddedTemplates(engine *gin.Engine) error { t, err := template.New("").Funcs(engine.FuncMap).ParseFS( @@ -41,6 +46,50 @@ func setEmbeddedTemplates(engine *gin.Engine) error { return nil } +func subscriptionTemplateFuncMap(basePath string, manifest subscriptionAssetManifest) template.FuncMap { + i18nWebFunc := func(key string, params ...string) string { + return locale.I18n(locale.Web, key, params...) + } + assetFunc := func(logical string) string { + target := logical + if manifest != nil { + if hashed, ok := manifest[logical]; ok { + target = hashed + } + } + return path.Join(basePath, "assets", target) + } + return template.FuncMap{ + "i18n": i18nWebFunc, + "asset": assetFunc, + } +} + +func loadSubscriptionAssetManifest() (subscriptionAssetManifest, error) { + if raw, err := os.ReadFile("web/public/assets-manifest.json"); err == nil { + return decodeSubscriptionAssetManifest(raw) + } else if !os.IsNotExist(err) { + return nil, err + } + + return decodeSubscriptionAssetManifest(webpkg.EmbeddedAssetsManifest()) +} + +func decodeSubscriptionAssetManifest(raw []byte) (subscriptionAssetManifest, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("subscription asset manifest is empty") + } + + var manifest subscriptionAssetManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, err + } + if len(manifest) == 0 { + return nil, fmt.Errorf("subscription asset manifest has no entries") + } + return manifest, nil +} + // Server represents the subscription server that serves subscription links and JSON configurations. type Server struct { httpServer *http.Server @@ -181,11 +230,12 @@ func (s *Server) initRouter() (*gin.Engine, error) { // set per-request localizer from headers/cookies engine.Use(locale.LocalizerMiddleware()) - // register i18n function similar to web server - i18nWebFunc := func(key string, params ...string) string { - return locale.I18n(locale.Web, key, params...) + assetManifest, err := loadSubscriptionAssetManifest() + if err != nil { + return nil, err } - engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc}) + + engine.SetFuncMap(subscriptionTemplateFuncMap(basePath, assetManifest)) // Templates: prefer embedded; fallback to disk if necessary if err := setEmbeddedTemplates(engine); err != nil { @@ -212,10 +262,10 @@ func (s *Server) initRouter() (*gin.Engine, error) { // Mount assets in multiple paths to handle different URL patterns var assetsFS http.FileSystem - if _, err := os.Stat("web/assets"); err == nil { - assetsFS = http.FS(os.DirFS("web/assets")) + if _, err := os.Stat("web/public/assets"); err == nil { + assetsFS = http.FS(os.DirFS("web/public/assets")) } else { - if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { + if subFS, err := fs.Sub(webpkg.EmbeddedPublicAssets(), "public/assets"); err == nil { assetsFS = http.FS(subFS) } else { logger.Error("sub: failed to mount embedded assets:", err) @@ -277,7 +327,7 @@ func (s *Server) getHtmlFiles() ([]string, error) { files = append(files, theme) } // page itself - page := filepath.Join(dir, "web", "html", "subpage.html") + page := filepath.Join(dir, "web", "html", "settings", "panel", "subscription", "subpage.html") if _, err := os.Stat(page); err == nil { files = append(files, page) } else { diff --git a/sub/sub_test.go b/sub/sub_test.go new file mode 100644 index 00000000..c4df2edc --- /dev/null +++ b/sub/sub_test.go @@ -0,0 +1,89 @@ +package sub + +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestSubscriptionTemplatesUseAssetHelper(t *testing.T) { + engine := gin.New() + engine.SetFuncMap(subscriptionTemplateFuncMap("/sub/", subscriptionAssetManifest{ + "moment/moment.min.js": "moment/moment.min.12345678.js", + })) + + if err := setEmbeddedTemplates(engine); err != nil { + t.Fatalf("setEmbeddedTemplates() error = %v", err) + } + + recorder := httptest.NewRecorder() + rendered := engine.HTMLRender.Instance("subpage.html", gin.H{ + "title": "subscription.title", + "host": "example.com", + "base_path": "/sub/test-subid/", + }) + + if err := rendered.Render(recorder); err != nil { + t.Fatalf("rendered.Render() error = %v", err) + } + + body := recorder.Body.String() + if !strings.Contains(body, `/sub/assets/moment/moment.min.12345678.js`) { + t.Fatalf("rendered body missing subscription asset path: %s", body) + } +} + +func TestGetHtmlFilesReturnsCurrentSubscriptionTemplatePath(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd() error = %v", err) + } + + tempDir := t.TempDir() + writeTestFile(t, filepath.Join(tempDir, "web", "html", "common", "page.html")) + writeTestFile(t, filepath.Join(tempDir, "web", "html", "component", "aThemeSwitch.html")) + currentTemplatePath := filepath.Join(tempDir, "web", "html", "settings", "panel", "subscription", "subpage.html") + writeTestFile(t, currentTemplatePath) + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("os.Chdir() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(wd); err != nil { + t.Fatalf("restore working directory: %v", err) + } + }) + + server := NewServer() + files, err := server.getHtmlFiles() + if err != nil { + t.Fatalf("getHtmlFiles() error = %v", err) + } + + if !containsPath(files, currentTemplatePath) { + t.Fatalf("getHtmlFiles() missing current subscription template path %q in %v", currentTemplatePath, files) + } +} + +func writeTestFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", path, err) + } + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", path, err) + } +} + +func containsPath(paths []string, want string) bool { + for _, path := range paths { + if path == want { + return true + } + } + return false +} diff --git a/web/asset_manifest_test.go b/web/asset_manifest_test.go index 20817553..1d06532c 100644 --- a/web/asset_manifest_test.go +++ b/web/asset_manifest_test.go @@ -27,6 +27,18 @@ func TestAssetResolverReturnsLogicalPathInDebug(t *testing.T) { } } +func TestAssetResolverPreservesBasePathWithoutDoubleSlash(t *testing.T) { + resolver := newAssetResolver("/xui/", false, assetManifest{ + "css/custom.min.css": "css/custom.min.11111111.css", + }) + + got := resolver.URL("css/custom.min.css") + want := "/xui/assets/css/custom.min.11111111.css" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + func TestAssetResolverPanicsOnMissingProductionAsset(t *testing.T) { resolver := newAssetResolver("/", false, assetManifest{}) diff --git a/web/html/common/page.html b/web/html/common/page.html index 058682d5..24a0cf03 100644 --- a/web/html/common/page.html +++ b/web/html/common/page.html @@ -6,8 +6,8 @@ - - + +