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 ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json"
"fmt"
"html/template" "html/template"
"io" "io"
"io/fs" "io/fs"
"net" "net"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@ -26,6 +29,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type subscriptionAssetManifest map[string]string
// setEmbeddedTemplates parses and sets embedded templates on the engine // setEmbeddedTemplates parses and sets embedded templates on the engine
func setEmbeddedTemplates(engine *gin.Engine) error { func setEmbeddedTemplates(engine *gin.Engine) error {
t, err := template.New("").Funcs(engine.FuncMap).ParseFS( t, err := template.New("").Funcs(engine.FuncMap).ParseFS(
@ -41,6 +46,50 @@ func setEmbeddedTemplates(engine *gin.Engine) error {
return nil 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. // Server represents the subscription server that serves subscription links and JSON configurations.
type Server struct { type Server struct {
httpServer *http.Server httpServer *http.Server
@ -181,11 +230,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
// set per-request localizer from headers/cookies // set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware()) engine.Use(locale.LocalizerMiddleware())
// register i18n function similar to web server assetManifest, err := loadSubscriptionAssetManifest()
i18nWebFunc := func(key string, params ...string) string { if err != nil {
return locale.I18n(locale.Web, key, params...) return nil, err
} }
engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc})
engine.SetFuncMap(subscriptionTemplateFuncMap(basePath, assetManifest))
// Templates: prefer embedded; fallback to disk if necessary // Templates: prefer embedded; fallback to disk if necessary
if err := setEmbeddedTemplates(engine); err != nil { 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 // Mount assets in multiple paths to handle different URL patterns
var assetsFS http.FileSystem var assetsFS http.FileSystem
if _, err := os.Stat("web/assets"); err == nil { if _, err := os.Stat("web/public/assets"); err == nil {
assetsFS = http.FS(os.DirFS("web/assets")) assetsFS = http.FS(os.DirFS("web/public/assets"))
} else { } 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) assetsFS = http.FS(subFS)
} else { } else {
logger.Error("sub: failed to mount embedded assets:", err) logger.Error("sub: failed to mount embedded assets:", err)
@ -277,7 +327,7 @@ func (s *Server) getHtmlFiles() ([]string, error) {
files = append(files, theme) files = append(files, theme)
} }
// page itself // 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 { if _, err := os.Stat(page); err == nil {
files = append(files, page) files = append(files, page)
} else { } 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) { func TestAssetResolverPanicsOnMissingProductionAsset(t *testing.T) {
resolver := newAssetResolver("/", false, assetManifest{}) resolver := newAssetResolver("/", false, assetManifest{})

View file

@ -6,8 +6,8 @@
<meta name="renderer" content="webkit"> <meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="{{ .base_path }}assets/ant-design-vue/antd.min.css"> <link rel="stylesheet" href="{{ asset "ant-design-vue/antd.min.css" }}">
<link rel="stylesheet" href="{{ .base_path }}assets/css/custom.min.css?{{ .cur_ver }}"> <link rel="stylesheet" href="{{ asset "css/custom.min.css" }}">
<style> <style>
[v-cloak] { [v-cloak] {
display: none; display: none;
@ -18,7 +18,7 @@
font-family: 'Vazirmatn'; font-family: 'Vazirmatn';
font-style: normal; font-style: normal;
font-weight: 400; 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; 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 { body {
@ -72,18 +72,18 @@
{{ end }} {{ end }}
{{ define "page/body_scripts" }} {{ define "page/body_scripts" }}
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script> <script src="{{ asset "vue/vue.min.js" }}"></script>
<script src="{{ .base_path }}assets/moment/moment.min.js"></script> <script src="{{ asset "moment/moment.min.js" }}"></script>
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script> <script src="{{ asset "ant-design-vue/antd.min.js" }}"></script>
<script src="{{ .base_path }}assets/axios/axios.min.js?{{ .cur_ver }}"></script> <script src="{{ asset "axios/axios.min.js" }}"></script>
<script src="{{ .base_path }}assets/qs/qs.min.js"></script> <script src="{{ asset "qs/qs.min.js" }}"></script>
<script src="{{ .base_path }}assets/js/axios-init.js?{{ .cur_ver }}"></script> <script src="{{ asset "js/axios-init.js" }}"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script> <script src="{{ asset "js/util/index.js" }}"></script>
<script> <script>
const basePath = '{{ .base_path }}'; const basePath = '{{ .base_path }}';
axios.defaults.baseURL = basePath; axios.defaults.baseURL = basePath;
</script> </script>
<script src="{{ .base_path }}assets/js/websocket.js?{{ .cur_ver }}"></script> <script src="{{ asset "js/websocket.js" }}"></script>
{{ end }} {{ end }}
{{ define "page/body_end" }} {{ define "page/body_end" }}

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
{{ template "page/head_start" .}} {{ template "page/head_start" .}}
<script src="{{ .base_path }}assets/moment/moment.min.js"></script> <script src="{{ asset "moment/moment.min.js" }}"></script>
<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script> <script src="{{ asset "moment/moment-jalali.min.js" }}"></script>
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script> <script src="{{ asset "vue/vue.min.js" }}"></script>
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script> <script src="{{ asset "ant-design-vue/antd.min.js" }}"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script> <script src="{{ asset "js/util/index.js" }}"></script>
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script> <script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<style> <style>
.subscription-page .subscription-link-box { .subscription-page .subscription-link-box {
cursor: pointer; cursor: pointer;
@ -251,6 +251,6 @@
{{ end }}</textarea> {{ end }}</textarea>
{{template "component/aThemeSwitch" .}} {{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" .}} {{ template "page/head_start" .}}
<link rel="stylesheet" <link rel="stylesheet"
href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}"> href="{{ asset "codemirror/codemirror.min.css" }}">
<link rel="stylesheet" <link rel="stylesheet"
href="{{ .base_path }}assets/codemirror/fold/foldgutter.css"> href="{{ asset "codemirror/fold/foldgutter.css" }}">
<link rel="stylesheet" <link rel="stylesheet"
href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}"> href="{{ asset "codemirror/xq.min.css" }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css"> <link rel="stylesheet" href="{{ asset "codemirror/lint/lint.css" }}">
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
@ -149,20 +149,20 @@
</a-layout> </a-layout>
{{template "page/body_scripts" .}} {{template "page/body_scripts" .}}
<script <script
src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script> src="{{ asset "js/model/outbound.js" }}"></script>
<script <script
src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script> src="{{ asset "codemirror/codemirror.min.js" }}"></script>
<script src="{{ .base_path }}assets/codemirror/javascript.js"></script> <script src="{{ asset "codemirror/javascript.js" }}"></script>
<script src="{{ .base_path }}assets/codemirror/jshint.js"></script> <script src="{{ asset "codemirror/jshint.js" }}"></script>
<script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script> <script src="{{ asset "codemirror/jsonlint.js" }}"></script>
<script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script> <script src="{{ asset "codemirror/lint/lint.js" }}"></script>
<script <script
src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script> src="{{ asset "codemirror/lint/javascript-lint.js" }}"></script>
<script <script
src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script> src="{{ asset "codemirror/hint/javascript-hint.js" }}"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script> <script src="{{ asset "codemirror/fold/foldcode.js" }}"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script> <script src="{{ asset "codemirror/fold/foldgutter.js" }}"></script>
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script> <script src="{{ asset "codemirror/fold/brace-fold.js" }}"></script>
{{template "component/aSidebar" .}} {{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}} {{template "component/aThemeSwitch" .}}
{{template "component/aTableSortable" .}} {{template "component/aTableSortable" .}}

View file

@ -111,6 +111,16 @@ func EmbeddedAssets() embed.FS {
return assetsFS 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. // Server represents the main web server for the 3x-ui panel with controllers, services, and scheduled jobs.
type Server struct { type Server struct {
httpServer *http.Server httpServer *http.Server