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