refactor: resolve template assets through manifest helper

This commit is contained in:
Sora39831 2026-04-07 16:41:55 +08:00
parent e6752e04db
commit cfb169d2fb
10 changed files with 216 additions and 55 deletions

View file

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

89
sub/sub_test.go Normal file
View file

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

View file

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

View file

@ -6,8 +6,8 @@
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="{{ .base_path }}assets/ant-design-vue/antd.min.css">
<link rel="stylesheet" href="{{ .base_path }}assets/css/custom.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ asset "ant-design-vue/antd.min.css" }}">
<link rel="stylesheet" href="{{ asset "css/custom.min.css" }}">
<style>
[v-cloak] {
display: none;
@ -18,7 +18,7 @@
font-family: 'Vazirmatn';
font-style: normal;
font-weight: 400;
src: url('{{ .base_path }}assets/Vazirmatn-UI-NL-Regular.woff2') format('woff2');
src: url('{{ asset "Vazirmatn-UI-NL-Regular.woff2" }}') format('woff2');
unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC, U+0030-0039;
}
body {
@ -72,21 +72,21 @@
{{ end }}
{{ define "page/body_scripts" }}
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
<script src="{{ .base_path }}assets/axios/axios.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qs/qs.min.js"></script>
<script src="{{ .base_path }}assets/js/axios-init.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script src="{{ asset "vue/vue.min.js" }}"></script>
<script src="{{ asset "moment/moment.min.js" }}"></script>
<script src="{{ asset "ant-design-vue/antd.min.js" }}"></script>
<script src="{{ asset "axios/axios.min.js" }}"></script>
<script src="{{ asset "qs/qs.min.js" }}"></script>
<script src="{{ asset "js/axios-init.js" }}"></script>
<script src="{{ asset "js/util/index.js" }}"></script>
<script>
const basePath = '{{ .base_path }}';
axios.defaults.baseURL = basePath;
</script>
<script src="{{ .base_path }}assets/js/websocket.js?{{ .cur_ver }}"></script>
<script src="{{ asset "js/websocket.js" }}"></script>
{{ end }}
{{ define "page/body_end" }}
</body>
</html>
{{ end }}
{{ end }}

View file

@ -13,9 +13,9 @@
{{end}}
{{define "component/aPersianDatepicker"}}
<link rel="stylesheet" href="{{ .base_path }}assets/persian-datepicker/persian-datepicker.min.css?{{ .cur_ver }}" />
<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/persian-datepicker/persian-datepicker.min.js?{{ .cur_ver }}"></script>
<link rel="stylesheet" href="{{ asset "persian-datepicker/persian-datepicker.min.css" }}" />
<script src="{{ asset "moment/moment-jalali.min.js" }}"></script>
<script src="{{ asset "persian-datepicker/persian-datepicker.min.js" }}"></script>
<script>
const persianDatepicker = {};
@ -69,4 +69,4 @@
}
});
</script>
{{end}}
{{end}}

View file

@ -601,11 +601,11 @@
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/uri/URI.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/reality_targets.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/inbound.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<script src="{{ asset "uri/URI.min.js" }}"></script>
<script src="{{ asset "js/model/reality_targets.js" }}"></script>
<script src="{{ asset "js/model/inbound.js" }}"></script>
<script src="{{ asset "js/model/dbinbound.js" }}"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aCustomStatistic" .}}

View file

@ -108,9 +108,9 @@
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/otpauth/otpauth.umd.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/setting.js?{{ .cur_ver }}"></script>
<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<script src="{{ asset "otpauth/otpauth.umd.min.js" }}"></script>
<script src="{{ asset "js/model/setting.js" }}"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aSettingListItem" .}}

View file

@ -1,10 +1,10 @@
{{ template "page/head_start" .}}
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<script src="{{ asset "moment/moment.min.js" }}"></script>
<script src="{{ asset "moment/moment-jalali.min.js" }}"></script>
<script src="{{ asset "vue/vue.min.js" }}"></script>
<script src="{{ asset "ant-design-vue/antd.min.js" }}"></script>
<script src="{{ asset "js/util/index.js" }}"></script>
<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<style>
.subscription-page .subscription-link-box {
cursor: pointer;
@ -251,6 +251,6 @@
{{ end }}</textarea>
{{template "component/aThemeSwitch" .}}
<script src="{{ .base_path }}assets/js/subscription.js?{{ .cur_ver }}"></script>
<script src="{{ asset "js/subscription.js" }}"></script>
{{ template "page/body_end" .}}
{{ template "page/body_end" .}}

View file

@ -1,11 +1,11 @@
{{ template "page/head_start" .}}
<link rel="stylesheet"
href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}">
href="{{ asset "codemirror/codemirror.min.css" }}">
<link rel="stylesheet"
href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
href="{{ asset "codemirror/fold/foldgutter.css" }}">
<link rel="stylesheet"
href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css">
href="{{ asset "codemirror/xq.min.css" }}">
<link rel="stylesheet" href="{{ asset "codemirror/lint/lint.css" }}">
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
@ -149,20 +149,20 @@
</a-layout>
{{template "page/body_scripts" .}}
<script
src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script>
src="{{ asset "js/model/outbound.js" }}"></script>
<script
src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/codemirror/javascript.js"></script>
<script src="{{ .base_path }}assets/codemirror/jshint.js"></script>
<script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script>
<script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script>
src="{{ asset "codemirror/codemirror.min.js" }}"></script>
<script src="{{ asset "codemirror/javascript.js" }}"></script>
<script src="{{ asset "codemirror/jshint.js" }}"></script>
<script src="{{ asset "codemirror/jsonlint.js" }}"></script>
<script src="{{ asset "codemirror/lint/lint.js" }}"></script>
<script
src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script>
src="{{ asset "codemirror/lint/javascript-lint.js" }}"></script>
<script
src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script>
src="{{ asset "codemirror/hint/javascript-hint.js" }}"></script>
<script src="{{ asset "codemirror/fold/foldcode.js" }}"></script>
<script src="{{ asset "codemirror/fold/foldgutter.js" }}"></script>
<script src="{{ asset "codemirror/fold/brace-fold.js" }}"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aTableSortable" .}}

View file

@ -111,6 +111,16 @@ func EmbeddedAssets() embed.FS {
return assetsFS
}
// EmbeddedPublicAssets returns the embedded fingerprinted assets filesystem for reuse by other servers.
func EmbeddedPublicAssets() embed.FS {
return publicAssetsFS
}
// EmbeddedAssetsManifest returns the embedded fingerprinted asset manifest bytes.
func EmbeddedAssetsManifest() []byte {
return append([]byte(nil), assetsManifestRaw...)
}
// Server represents the main web server for the 3x-ui panel with controllers, services, and scheduled jobs.
type Server struct {
httpServer *http.Server