diff --git a/config/version b/config/version index 9aa34646..6533b668 100644 --- a/config/version +++ b/config/version @@ -1 +1 @@ -2.7.0 \ No newline at end of file +2.8.0 \ No newline at end of file diff --git a/sub/sub.go b/sub/sub.go index dce57243..7931b24d 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -3,16 +3,18 @@ package sub import ( "context" "crypto/tls" + "html/template" "io" + "io/fs" "net" "net/http" "os" "path/filepath" "strconv" - "x-ui/config" "x-ui/logger" "x-ui/util/common" + webpkg "x-ui/web" "x-ui/web/locale" "x-ui/web/middleware" "x-ui/web/network" @@ -21,6 +23,21 @@ import ( "github.com/gin-gonic/gin" ) +// setEmbeddedTemplates parses and sets embedded templates on the engine +func setEmbeddedTemplates(engine *gin.Engine) error { + t, err := template.New("").Funcs(engine.FuncMap).ParseFS( + webpkg.EmbeddedHTML(), + "html/common/page.html", + "html/component/aThemeSwitch.html", + "html/subscription.html", + ) + if err != nil { + return err + } + engine.SetHTMLTemplate(t) + return nil +} + type Server struct { httpServer *http.Server listener net.Listener @@ -41,13 +58,10 @@ func NewServer() *Server { } func (s *Server) initRouter() (*gin.Engine, error) { - if config.IsDebug() { - gin.SetMode(gin.DebugMode) - } else { - gin.DefaultWriter = io.Discard - gin.DefaultErrorWriter = io.Discard - gin.SetMode(gin.ReleaseMode) - } + // Always run in release mode for the subscription server + gin.DefaultWriter = io.Discard + gin.DefaultErrorWriter = io.Discard + gin.SetMode(gin.ReleaseMode) engine := gin.Default() @@ -120,28 +134,35 @@ func (s *Server) initRouter() (*gin.Engine, error) { SubTitle = "" } - // init i18n for sub server using disk FS so templates can use {{ i18n }} - // Root FS is project root; translation files are under web/translation - if err := locale.InitLocalizerFS(os.DirFS("web"), &s.settingService); err != nil { - logger.Warning("sub: i18n init failed:", err) - } // set per-request localizer from headers/cookies engine.Use(locale.LocalizerMiddleware()) - // load HTML templates needed for subscription page (common layout + page + component + subscription) - if files, err := s.getHtmlFiles(); err != nil { - logger.Warning("sub: getHtmlFiles failed:", err) - } else { - // register i18n function similar to web server - i18nWebFunc := func(key string, params ...string) string { - return locale.I18n(locale.Web, key, params...) + // register i18n function similar to web server + i18nWebFunc := func(key string, params ...string) string { + return locale.I18n(locale.Web, key, params...) + } + engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc}) + + // Templates: prefer embedded; fallback to disk if necessary + if err := setEmbeddedTemplates(engine); err != nil { + logger.Warning("sub: failed to parse embedded templates:", err) + if files, derr := s.getHtmlFiles(); derr == nil { + engine.LoadHTMLFiles(files...) + } else { + logger.Error("sub: no templates available (embedded parse and disk load failed)", err, derr) } - engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc}) - engine.LoadHTMLFiles(files...) } - // serve assets from web/assets to use shared JS/CSS like other pages - engine.StaticFS("/assets", http.FS(os.DirFS("web/assets"))) + // Assets: use disk if present, fallback to embedded + if _, err := os.Stat("web/assets"); err == nil { + engine.StaticFS("/assets", http.FS(os.DirFS("web/assets"))) + } else { + if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { + engine.StaticFS("/assets", http.FS(subFS)) + } else { + logger.Error("sub: failed to mount embedded assets:", err) + } + } g := engine.Group("/") diff --git a/web/html/subscription.html b/web/html/subscription.html index 710bfe43..e96a78f1 100644 --- a/web/html/subscription.html +++ b/web/html/subscription.html @@ -4,6 +4,7 @@ + {{ template "page/head_end" .}} diff --git a/web/locale/locale.go b/web/locale/locale.go index 8d4179ae..c071dc68 100644 --- a/web/locale/locale.go +++ b/web/locale/locale.go @@ -3,6 +3,7 @@ package locale import ( "embed" "io/fs" + "os" "strings" "x-ui/logger" @@ -48,22 +49,6 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error { return nil } -// InitLocalizerFS allows initializing i18n from any fs.FS (e.g., disk), rooted at a directory containing a "translation" folder -func InitLocalizerFS(fsys fs.FS, settingService SettingService) error { - // set default bundle to english - i18nBundle = i18n.NewBundle(language.MustParse("en-US")) - i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) - - if err := parseTranslationFiles(fsys, i18nBundle); err != nil { - return err - } - - if err := initTGBotLocalizer(settingService); err != nil { - return err - } - return nil -} - func createTemplateData(params []string, seperator ...string) map[string]any { var sep string = "==" if len(seperator) > 0 { @@ -94,6 +79,11 @@ func I18n(i18nType I18nType, key string, params ...string) string { templateData := createTemplateData(params) + if localizer == nil { + // Fallback to key if localizer not ready; prevents nil panic on pages like sub + return key + } + msg, err := localizer.Localize(&i18n.LocalizeConfig{ MessageID: key, TemplateData: templateData, @@ -118,6 +108,15 @@ func initTGBotLocalizer(settingService SettingService) error { func LocalizerMiddleware() gin.HandlerFunc { return func(c *gin.Context) { + // Ensure bundle is initialized so creating a Localizer won't panic + if i18nBundle == nil { + i18nBundle = i18n.NewBundle(language.MustParse("en-US")) + i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + // Try lazy-load from disk when running sub server without InitLocalizer + if err := loadTranslationsFromDisk(i18nBundle); err != nil { + logger.Warning("i18n lazy load failed:", err) + } + } var lang string if cookie, err := c.Request.Cookie("lang"); err == nil { @@ -134,8 +133,27 @@ func LocalizerMiddleware() gin.HandlerFunc { } } -func parseTranslationFiles(fsys fs.FS, i18nBundle *i18n.Bundle) error { - err := fs.WalkDir(fsys, "translation", +// loadTranslationsFromDisk attempts to load translation files from "web/translation" using the local filesystem. +func loadTranslationsFromDisk(bundle *i18n.Bundle) error { + root := os.DirFS("web") + return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + data, err := fs.ReadFile(root, path) + if err != nil { + return err + } + _, err = bundle.ParseMessageFileBytes(data, path) + return err + }) +} + +func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error { + err := fs.WalkDir(i18nFS, "translation", func(path string, d fs.DirEntry, err error) error { if err != nil { return err @@ -145,7 +163,7 @@ func parseTranslationFiles(fsys fs.FS, i18nBundle *i18n.Bundle) error { return nil } - data, err := fs.ReadFile(fsys, path) + data, err := i18nFS.ReadFile(path) if err != nil { return err } diff --git a/web/web.go b/web/web.go index 35255104..cfd4de5f 100644 --- a/web/web.go +++ b/web/web.go @@ -78,6 +78,15 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time { return startTime } +// Expose embedded resources for reuse by other servers (e.g., sub server) +func EmbeddedHTML() embed.FS { + return htmlFS +} + +func EmbeddedAssets() embed.FS { + return assetsFS +} + type Server struct { httpServer *http.Server listener net.Listener