1
0
mhsanaei 4 өдөр өмнө
parent
commit
4097ae4dab

+ 55 - 0
sub/sub.go

@@ -6,11 +6,14 @@ import (
 	"io"
 	"net"
 	"net/http"
+	"os"
+	"path/filepath"
 	"strconv"
 
 	"x-ui/config"
 	"x-ui/logger"
 	"x-ui/util/common"
+	"x-ui/web/locale"
 	"x-ui/web/middleware"
 	"x-ui/web/network"
 	"x-ui/web/service"
@@ -57,6 +60,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		engine.Use(middleware.DomainValidatorMiddleware(subDomain))
 	}
 
+	// Provide base_path in context for templates
+	engine.Use(func(c *gin.Context) {
+		c.Set("base_path", "/")
+	})
+
 	LinksPath, err := s.settingService.GetSubPath()
 	if err != nil {
 		return nil, err
@@ -112,6 +120,29 @@ 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...)
+		}
+		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")))
+
 	g := engine.Group("/")
 
 	s.sub = NewSUBController(
@@ -121,6 +152,30 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	return engine, nil
 }
 
+// getHtmlFiles loads templates from local folder (used in debug mode)
+func (s *Server) getHtmlFiles() ([]string, error) {
+	dir, _ := os.Getwd()
+	files := []string{}
+	// common layout
+	common := filepath.Join(dir, "web", "html", "common", "page.html")
+	if _, err := os.Stat(common); err == nil {
+		files = append(files, common)
+	}
+	// components used
+	theme := filepath.Join(dir, "web", "html", "component", "aThemeSwitch.html")
+	if _, err := os.Stat(theme); err == nil {
+		files = append(files, theme)
+	}
+	// page itself
+	page := filepath.Join(dir, "web", "html", "subscription.html")
+	if _, err := os.Stat(page); err == nil {
+		files = append(files, page)
+	} else {
+		return nil, err
+	}
+	return files, nil
+}
+
 func (s *Server) Start() (err error) {
 	// This is an anonymous function, no function name
 	defer func() {

+ 101 - 1
sub/subController.go

@@ -3,8 +3,11 @@ package sub
 import (
 	"encoding/base64"
 	"net"
+	"strconv"
 	"strings"
 
+	"x-ui/util/common"
+
 	"github.com/gin-gonic/gin"
 )
 
@@ -72,7 +75,7 @@ func (a *SUBController) subs(c *gin.Context) {
 			host = c.Request.Host
 		}
 	}
-	subs, header, err := a.subService.GetSubs(subId, host)
+	subs, header, lastOnline, err := a.subService.GetSubs(subId, host)
 	if err != nil || len(subs) == 0 {
 		c.String(400, "Error!")
 	} else {
@@ -85,6 +88,94 @@ func (a *SUBController) subs(c *gin.Context) {
 		c.Writer.Header().Set("Subscription-Userinfo", header)
 		c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
 		c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
+		// Also include whole subscription content in base64 as requested
+		c.Writer.Header().Set("Subscription-Content-Base64", base64.StdEncoding.EncodeToString([]byte(result)))
+
+		// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
+		accept := c.GetHeader("Accept")
+		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
+			// Determine scheme
+			scheme := "http"
+			if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
+				scheme = "https"
+			}
+
+			// Parse header values
+			var uploadByte, downloadByte, totalByte, expire int64
+			parts := strings.Split(header, ";")
+			for _, p := range parts {
+				kv := strings.Split(strings.TrimSpace(p), "=")
+				if len(kv) != 2 {
+					continue
+				}
+				key := strings.ToLower(strings.TrimSpace(kv[0]))
+				val := strings.TrimSpace(kv[1])
+				switch key {
+				case "upload":
+					if v, err := parseInt64(val); err == nil {
+						uploadByte = v
+					}
+				case "download":
+					if v, err := parseInt64(val); err == nil {
+						downloadByte = v
+					}
+				case "total":
+					if v, err := parseInt64(val); err == nil {
+						totalByte = v
+					}
+				case "expire":
+					if v, err := parseInt64(val); err == nil {
+						expire = v
+					}
+				}
+			}
+
+			download := common.FormatTraffic(downloadByte)
+			upload := common.FormatTraffic(uploadByte)
+			total := "∞"
+			used := common.FormatTraffic(uploadByte + downloadByte)
+			remained := ""
+			if totalByte > 0 {
+				total = common.FormatTraffic(totalByte)
+				left := max(totalByte-(uploadByte+downloadByte), 0)
+				remained = common.FormatTraffic(left)
+			}
+
+			// Build sub URL
+			subURL := scheme + "://" + host + strings.TrimRight(a.subPath, "/") + "/" + subId
+			if strings.HasSuffix(a.subPath, "/") {
+				subURL = scheme + "://" + host + a.subPath + subId
+			}
+
+			basePath := "/"
+			hostHeader := c.GetHeader("X-Forwarded-Host")
+			if hostHeader == "" {
+				hostHeader = c.GetHeader("X-Real-IP")
+			}
+			if hostHeader == "" {
+				hostHeader = host
+			}
+			c.HTML(200, "subscription.html", gin.H{
+				"title":        "subscription.title",
+				"host":         hostHeader,
+				"base_path":    basePath,
+				"sId":          subId,
+				"download":     download,
+				"upload":       upload,
+				"total":        total,
+				"used":         used,
+				"remained":     remained,
+				"expire":       expire,
+				"lastOnline":   lastOnline,
+				"datepicker":   a.subService.datepicker,
+				"downloadByte": downloadByte,
+				"uploadByte":   uploadByte,
+				"totalByte":    totalByte,
+				"subUrl":       subURL,
+				"result":       subs,
+			})
+			return
+		}
 
 		if a.subEncrypt {
 			c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
@@ -134,3 +225,12 @@ func getHostFromXFH(s string) (string, error) {
 	}
 	return s, nil
 }
+
+func parseInt64(s string) (int64, error) {
+	var n int64
+	var err error
+	// handle potential quotes
+	s = strings.Trim(s, "\"'")
+	n, err = strconv.ParseInt(s, 10, 64)
+	return n, err
+}

+ 10 - 5
sub/subService.go

@@ -34,19 +34,20 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
 	}
 }
 
-func (s *SubService) GetSubs(subId string, host string) ([]string, string, error) {
+func (s *SubService) GetSubs(subId string, host string) ([]string, string, int64, error) {
 	s.address = host
 	var result []string
 	var header string
 	var traffic xray.ClientTraffic
+	var lastOnline int64
 	var clientTraffics []xray.ClientTraffic
 	inbounds, err := s.getInboundsBySubId(subId)
 	if err != nil {
-		return nil, "", err
+		return nil, "", 0, err
 	}
 
 	if len(inbounds) == 0 {
-		return nil, "", common.NewError("No inbounds found with ", subId)
+		return nil, "", 0, common.NewError("No inbounds found with ", subId)
 	}
 
 	s.datepicker, err = s.settingService.GetDatepicker()
@@ -73,7 +74,11 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
 			if client.Enable && client.SubID == subId {
 				link := s.getLink(inbound, client.Email)
 				result = append(result, link)
-				clientTraffics = append(clientTraffics, s.getClientTraffics(inbound.ClientStats, client.Email))
+				ct := s.getClientTraffics(inbound.ClientStats, client.Email)
+				clientTraffics = append(clientTraffics, ct)
+				if ct.LastOnline > lastOnline {
+					lastOnline = ct.LastOnline
+				}
 			}
 		}
 	}
@@ -101,7 +106,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
 		}
 	}
 	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
-	return result, header, nil
+	return result, header, lastOnline, nil
 }
 
 func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
web/assets/css/custom.min.css


+ 118 - 0
web/assets/js/subscription.js

@@ -0,0 +1,118 @@
+(function(){
+  // Vue app for Subscription page
+  const el = document.getElementById('subscription-data');
+  if (!el) return;
+  const textarea = document.getElementById('subscription-links');
+  const rawLinks = (textarea?.value || '').split('\n').filter(Boolean);
+
+  const data = {
+    sId: el.getAttribute('data-sid') || '',
+    subUrl: el.getAttribute('data-sub-url') || '',
+    download: el.getAttribute('data-download') || '',
+    upload: el.getAttribute('data-upload') || '',
+    used: el.getAttribute('data-used') || '',
+    total: el.getAttribute('data-total') || '',
+    remained: el.getAttribute('data-remained') || '',
+  expireMs: (parseInt(el.getAttribute('data-expire') || '0', 10) || 0) * 1000,
+  lastOnlineMs: (parseInt(el.getAttribute('data-lastonline') || '0', 10) || 0),
+    downloadByte: parseInt(el.getAttribute('data-downloadbyte') || '0', 10) || 0,
+    uploadByte: parseInt(el.getAttribute('data-uploadbyte') || '0', 10) || 0,
+    totalByte: parseInt(el.getAttribute('data-totalbyte') || '0', 10) || 0,
+    datepicker: el.getAttribute('data-datepicker') || 'gregorian',
+  };
+
+  // Normalize lastOnline to milliseconds if it looks like seconds
+  if (data.lastOnlineMs && data.lastOnlineMs < 10_000_000_000) {
+    data.lastOnlineMs *= 1000;
+  }
+
+  function renderLink(item) {
+    return (
+      Vue.h('a-list-item', {}, [
+        Vue.h('a-space', {props:{size:'small'}}, [
+          Vue.h('a-button', {props:{size:'small'}, on:{click: () => copy(item)}}, [Vue.h('a-icon', {props:{type:'copy'}})]),
+          Vue.h('span', {class:'break-all'}, item)
+        ])
+      ])
+    );
+  }
+
+  function copy(text){
+    ClipboardManager.copyText(text).then(ok => {
+      const messageType = ok ? 'success' : 'error';
+      Vue.prototype.$message[messageType](ok ? 'Copied' : 'Copy failed');
+    });
+  }
+
+  function open(url){
+    window.location.href = url;
+  }
+
+  function drawQR(value){
+    try{ new QRious({ element: document.getElementById('qrcode'), value, size: 220 }); }catch(e){ console.warn(e); }
+  }
+
+  // Try to extract a human label (email/ps) from different link types
+  function linkName(link, idx){
+    try{
+      if (link.startsWith('vmess://')){
+        const json = JSON.parse(atob(link.replace('vmess://','')));
+        if (json.ps) return json.ps;
+        if (json.add && json.id) return json.add; // fallback host
+      } else if (link.startsWith('vless://') || link.startsWith('trojan://')){
+        // vless://<id>@host:port?...#name
+        const hashIdx = link.indexOf('#');
+        if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx+1));
+        // email sometimes in query params like sni or remark
+        const qIdx = link.indexOf('?');
+        if (qIdx !== -1){
+          const qs = new URL('http://x/?' + link.substring(qIdx+1, hashIdx !== -1 ? hashIdx : undefined)).searchParams;
+          if (qs.get('remark')) return qs.get('remark');
+          if (qs.get('email')) return qs.get('email');
+        }
+        // else take user@host
+        const at = link.indexOf('@');
+        const protSep = link.indexOf('://');
+        if (at !== -1 && protSep !== -1) return link.substring(protSep+3, at);
+      } else if (link.startsWith('ss://')){
+        // shadowsocks: label often after #
+        const hashIdx = link.indexOf('#');
+        if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx+1));
+      }
+    }catch(e){ /* ignore and fallback */ }
+    return 'Link ' + (idx+1);
+  }
+
+  const app = new Vue({
+    delimiters: ['[[', ']]'],
+    el: '#app',
+    data: {
+      themeSwitcher,
+      app: data,
+      links: rawLinks,
+      lang: '',
+      viewportWidth: (typeof window !== 'undefined' ? window.innerWidth : 1024),
+    },
+    async mounted(){
+      this.lang = LanguageManager.getLanguage();
+      drawQR(this.app.subUrl);
+      // Track viewport width for responsive behavior
+      this._onResize = () => { this.viewportWidth = window.innerWidth; };
+      window.addEventListener('resize', this._onResize);
+    },
+    beforeDestroy(){
+      if (this._onResize) window.removeEventListener('resize', this._onResize);
+    },
+    computed: {
+      isMobile(){ return this.viewportWidth < 576; },
+      isUnlimited(){ return !this.app.totalByte; },
+      isActive(){
+        const now = Date.now();
+        const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
+        const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
+        return expiryOk && trafficOk;
+      },
+    },
+    methods: { renderLink, copy, open, linkName, i18nLabel(key){ return '{{ i18n "' + key + '" }}'; } },
+  });
+})();

+ 236 - 0
web/html/subscription.html

@@ -0,0 +1,236 @@
+{{ template "page/head_start" .}}
+{{ template "page/head_end" .}}
+
+{{ template "page/body_start" .}}
+<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme">
+    <a-layout-content class="p-2">
+        <a-row type="flex" justify="center" class="mt-2">
+            <a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
+                <a-card hoverable class="subscription-card">
+                    <template #title>
+                        <a-space>
+                            <span>{{ i18n "subscription.title" }}</span>
+                            <a-tag>{{ .sId }}</a-tag>
+                        </a-space>
+                    </template>
+                    <template #extra>
+                        <a-popover
+                            :overlay-class-name="themeSwitcher.currentTheme"
+                            title='{{ i18n "menu.settings" }}'
+                            placement="bottomRight" trigger="click">
+                            <template #content>
+                                <a-space direction="vertical" :size="10">
+                                    <a-theme-switch-login></a-theme-switch-login>
+                                    <span>{{ i18n "pages.settings.language"
+                                        }}</span>
+                                    <a-select ref="selectLang" class="w-100"
+                                        v-model="lang"
+                                        @change="LanguageManager.setLanguage(lang)"
+                                        :dropdown-class-name="themeSwitcher.currentTheme">
+                                        <a-select-option :value="l.value"
+                                            label="English"
+                                            v-for="l in LanguageManager.supportedLanguages"
+                                            :key="l.value">
+                                            <span role="img"
+                                                :aria-label="l.name"
+                                                v-text="l.icon"></span>
+                                            &nbsp;&nbsp;<span
+                                                v-text="l.name"></span>
+                                        </a-select-option>
+                                    </a-select>
+                                </a-space>
+                            </template>
+                            <a-button shape="circle" icon="setting"></a-button>
+                        </a-popover>
+                    </template>
+
+                    <a-form layout="vertical">
+                        <a-form-item>
+                            <a-space direction="vertical" align="center">
+                                <tr-qr-box class="qr-box">
+                                    <tr-qr-bg class="qr-bg-sub">
+                                        <tr-qr-bg-inner class="qr-bg-sub-inner">
+                                            <canvas id="qrcode" class="qr-cv"
+                                                title='{{ i18n "copy" }}'
+                                                @click="copy(app.subUrl)"></canvas>
+                                        </tr-qr-bg-inner>
+                                    </tr-qr-bg>
+                                </tr-qr-box>
+                            </a-space>
+                        </a-form-item>
+
+                        <a-form-item>
+                            <a-descriptions bordered :column="1" size="small">
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.subId" }}'>[[
+                                    app.sId
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.status" }}'>
+                                    <template v-if="isUnlimited">
+                                        <a-tag color="purple">{{ i18n
+                                            "subscription.unlimited" }}</a-tag>
+                                    </template>
+                                    <template v-else>
+                                        <a-tag
+                                            :color="isActive ? 'green' : 'red'">[[
+                                            isActive ? '{{ i18n
+                                            "subscription.active" }}' : '{{ i18n
+                                            "subscription.inactive" }}'
+                                            ]]</a-tag>
+                                    </template>
+                                </a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.downloaded" }}'>[[
+                                    app.download
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.uploaded" }}'>[[
+                                    app.upload
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "usage" }}'>[[ app.used
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.totalQuota" }}'>[[
+                                    app.total
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item v-if="app.totalByte > 0"
+                                    label='{{ i18n "remained" }}'>[[
+                                    app.remained ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "lastOnline" }}'>
+                                    <template v-if="app.lastOnlineMs > 0">
+                                        <template
+                                            v-if="app.datepicker === 'gregorian'">
+                                            [[
+                                            DateUtil.formatMillis(app.lastOnlineMs)
+                                            ]]
+                                        </template>
+                                        <template v-else>
+                                            [[
+                                            DateUtil.convertToJalalian(moment(app.lastOnlineMs))
+                                            ]]
+                                        </template>
+                                    </template>
+                                    <template v-else>
+                                        <span>-</span>
+                                    </template>
+                                </a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.expiry" }}'>
+                                    <template v-if="app.expireMs === 0">
+                                        {{ i18n "subscription.noExpiry" }}
+                                    </template>
+                                    <template v-else>
+                                        <template
+                                            v-if="app.datepicker === 'gregorian'">
+                                            [[
+                                            DateUtil.formatMillis(app.expireMs)
+                                            ]]
+                                        </template>
+                                        <template v-else>
+                                            [[
+                                            DateUtil.convertToJalalian(moment(app.expireMs))
+                                            ]]
+                                        </template>
+                                    </template>
+                                </a-descriptions-item>
+                            </a-descriptions>
+                        </a-form-item>
+                    </a-form>
+
+                    <br />
+                    <a-list bordered>
+                        <a-list-item v-for="(link, idx) in links" :key="link">
+                            <div style="width:100%; text-align:center;">
+                                <a-button type="primary" :block="isMobile"
+                                    @click="copy(link)">[[ linkName(link, idx)
+                                    ]]</a-button>
+                            </div>
+                        </a-list-item>
+                    </a-list>
+                    <br />
+
+                    <a-form layout="vertical">
+                        <a-form-item>
+                            <a-row type="flex" justify="center" :gutter="[8,8]"
+                                style="width:100%">
+                                <a-col :xs="24" :sm="12"
+                                    style="text-align:center;">
+                                    <!-- Android dropdown -->
+                                    <a-dropdown :trigger="['click']">
+                                        <a-button :block="isMobile"
+                                            :style="{ marginTop: isMobile ? '6px' : 0 }"
+                                            size="large" type="primary">
+                                            Android <a-icon type="down" />
+                                        </a-button>
+                                        <a-menu slot="overlay"
+                                            :class="themeSwitcher.currentTheme">
+                                            <a-menu-item key="android-v2box"
+                                                @click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
+                                            <a-menu-item key="android-streisand"
+                                                @click="open('streisand://import/' + encodeURIComponent(app.subUrl))">streisand</a-menu-item>
+                                            <a-menu-item key="android-v2rayng"
+                                                @click="open('v2rayng://install-config?url=' + encodeURIComponent(app.subUrl))">V2RayNG</a-menu-item>
+                                            <a-menu-item key="android-v2raytun" 
+                                                @click="copy(app.subUrl)">V2RayTun</a-menu-item>
+                                            <a-menu-item key="android-npvtunnel" 
+                                                @click="copy(app.subUrl)">NPV Tunnel</a-menu-item>
+                                        </a-menu>
+                                    </a-dropdown>
+                                </a-col>
+                                <a-col :xs="24" :sm="12"
+                                    style="text-align:center;">
+                                    <!-- iOS dropdown -->
+                                    <a-dropdown :trigger="['click']">
+                                        <a-button :block="isMobile"
+                                            :style="{ marginTop: isMobile ? '6px' : 0 }"
+                                            size="large" type="primary">
+                                            iOS <a-icon type="down" />
+                                        </a-button>
+                                        <a-menu slot="overlay"
+                                            :class="themeSwitcher.currentTheme">
+                                            <a-menu-item key="ios-shadowrocket"
+                                                @click="open('shadowrocket://add/subscription?url=' + encodeURIComponent(app.subUrl) + '&remark=' + encodeURIComponent(app.sId))">Shadowrocket</a-menu-item>
+                                            <a-menu-item key="ios-v2box"
+                                                @click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
+                                            <a-menu-item key="ios-streisand"
+                                                @click="open('streisand://import/' + encodeURIComponent(app.subUrl))">Streisand</a-menu-item>
+                                            <a-menu-item key="ios-v2raytun"
+                                                @click="copy(app.subUrl)">V2RayTun</a-menu-item>
+                                            <a-menu-item key="ios-npvtunnel" 
+                                                @click="copy(app.subUrl)">NPV Tunnel</a-menu-item>
+                                        </a-menu>
+                                    </a-dropdown>
+                                </a-col>
+                            </a-row>
+                        </a-form-item>
+                    </a-form>
+                </a-card>
+            </a-col>
+        </a-row>
+    </a-layout-content>
+</a-layout>
+
+<!-- Bootstrap data for external JS -->
+<template id="subscription-data" data-sid="{{ .sId }}"
+    data-sub-url="{{ .subUrl }}" data-download="{{ .download }}"
+    data-upload="{{ .upload }}" data-used="{{ .used }}"
+    data-total="{{ .total }}" data-remained="{{ .remained }}"
+    data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
+    data-downloadbyte="{{ .downloadByte }}"
+    data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
+    data-datepicker="{{ .datepicker }}"></template>
+<textarea id="subscription-links"
+    style="display:none">{{ range .result }}{{ . }}
+{{ end }}</textarea>
+
+{{template "page/body_scripts" .}}
+<script
+    src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
+<script
+    src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
+{{template "component/aThemeSwitch" .}}
+<script src="{{ .base_path }}assets/js/subscription.js?{{ .cur_ver }}"></script>
+{{ template "page/body_end" .}}

+ 19 - 3
web/locale/locale.go

@@ -48,6 +48,22 @@ 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 {
@@ -118,8 +134,8 @@ func LocalizerMiddleware() gin.HandlerFunc {
 	}
 }
 
-func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
-	err := fs.WalkDir(i18nFS, "translation",
+func parseTranslationFiles(fsys fs.FS, i18nBundle *i18n.Bundle) error {
+	err := fs.WalkDir(fsys, "translation",
 		func(path string, d fs.DirEntry, err error) error {
 			if err != nil {
 				return err
@@ -129,7 +145,7 @@ func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
 				return nil
 			}
 
-			data, err := i18nFS.ReadFile(path)
+			data, err := fs.ReadFile(fsys, path)
 			if err != nil {
 				return err
 			}

+ 14 - 0
web/translation/translate.ar_EG.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
 "somethingWentWrong" = "حدث خطأ ما"
 
+[subscription]
+"title" = "معلومات الاشتراك"
+"subId" = "معرّف الاشتراك"
+"status" = "الحالة"
+"downloaded" = "التنزيل"
+"uploaded" = "الرفع"
+"expiry" = "تاريخ الانتهاء"
+"totalQuota" = "الحصة الإجمالية"
+"individualLinks" = "روابط فردية"
+"active" = "نشط"
+"inactive" = "غير نشط"
+"unlimited" = "غير محدود"
+"noExpiry" = "بدون انتهاء"
+
 [menu]
 "theme" = "الثيم"
 "dark" = "داكن"

+ 14 - 0
web/translation/translate.en_US.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "No added reverse proxies."
 "somethingWentWrong" = "Something went wrong"
 
+[subscription]
+"title" = "Subscription info"
+"subId" = "Subscription ID"
+"status" = "Status"
+"downloaded" = "Downloaded"
+"uploaded" = "Uploaded"
+"expiry" = "Expiry"
+"totalQuota" = "Total quota"
+"individualLinks" = "Individual links"
+"active" = "Active"
+"inactive" = "Inactive"
+"unlimited" = "Unlimited"
+"noExpiry" = "No expiry"
+
 [menu]
 "theme" = "Theme"
 "dark" = "Dark"

+ 14 - 0
web/translation/translate.es_ES.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "No hay proxies inversos añadidos."
 "somethingWentWrong" = "Algo salió mal"
 
+[subscription]
+"title" = "Información de suscripción"
+"subId" = "ID de suscripción"
+"status" = "Estado"
+"downloaded" = "Descargado"
+"uploaded" = "Subido"
+"expiry" = "Caducidad"
+"totalQuota" = "Cuota total"
+"individualLinks" = "Enlaces individuales"
+"active" = "Activo"
+"inactive" = "Inactivo"
+"unlimited" = "Ilimitado"
+"noExpiry" = "Sin caducidad"
+
 [menu]
 "theme" = "Tema"
 "dark" = "Oscuro"

+ 14 - 0
web/translation/translate.fa_IR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
 "somethingWentWrong" = "مشکلی پیش آمد"
 
+[subscription]
+"title" = "اطلاعات سابسکریپشن"
+"subId" = "شناسه اشتراک"
+"status" = "وضعیت"
+"downloaded" = "دانلود"
+"uploaded" = "آپلود"
+"expiry" = "تاریخ پایان"
+"totalQuota" = "حجم کلی"
+"individualLinks" = "لینک‌های تکی"
+"active" = "فعال"
+"inactive" = "غیرفعال"
+"unlimited" = "نامحدود"
+"noExpiry" = "بدون انقضا"
+
 [menu]
 "theme" = "تم"
 "dark" = "تیره"

+ 14 - 0
web/translation/translate.id_ID.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
 "somethingWentWrong" = "Terjadi kesalahan"
 
+[subscription]
+"title" = "Info langganan"
+"subId" = "ID langganan"
+"status" = "Status"
+"downloaded" = "Diunduh"
+"uploaded" = "Diunggah"
+"expiry" = "Kedaluwarsa"
+"totalQuota" = "Kuota total"
+"individualLinks" = "Tautan individual"
+"active" = "Aktif"
+"inactive" = "Nonaktif"
+"unlimited" = "Tanpa batas"
+"noExpiry" = "Tanpa kedaluwarsa"
+
 [menu]
 "theme" = "Tema"
 "dark" = "Gelap"

+ 14 - 0
web/translation/translate.ja_JP.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "追加されたリバースプロキシはありません。"
 "somethingWentWrong" = "エラーが発生しました"
 
+[subscription]
+"title" = "サブスクリプション情報"
+"subId" = "サブスクリプションID"
+"status" = "ステータス"
+"downloaded" = "ダウンロード"
+"uploaded" = "アップロード"
+"expiry" = "有効期限"
+"totalQuota" = "合計クォータ"
+"individualLinks" = "個別リンク"
+"active" = "有効"
+"inactive" = "無効"
+"unlimited" = "無制限"
+"noExpiry" = "期限なし"
+
 [menu]
 "theme" = "テーマ"
 "dark" = "ダーク"

+ 14 - 0
web/translation/translate.pt_BR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Nenhum proxy reverso adicionado."
 "somethingWentWrong" = "Algo deu errado"
 
+[subscription]
+"title" = "Informações da assinatura"
+"subId" = "ID da assinatura"
+"status" = "Status"
+"downloaded" = "Baixado"
+"uploaded" = "Enviado"
+"expiry" = "Validade"
+"totalQuota" = "Cota total"
+"individualLinks" = "Links individuais"
+"active" = "Ativo"
+"inactive" = "Inativo"
+"unlimited" = "Ilimitado"
+"noExpiry" = "Sem validade"
+
 [menu]
 "theme" = "Tema"
 "dark" = "Escuro"

+ 14 - 0
web/translation/translate.ru_RU.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Нет добавленных реверс-прокси."
 "somethingWentWrong" = "Что-то пошло не так"
 
+[subscription]
+"title" = "Информация о подписке"
+"subId" = "ID подписки"
+"status" = "Статус"
+"downloaded" = "Загружено"
+"uploaded" = "Отправлено"
+"expiry" = "Срок действия"
+"totalQuota" = "Общий лимит"
+"individualLinks" = "Индивидуальные ссылки"
+"active" = "Активна"
+"inactive" = "Неактивна"
+"unlimited" = "Безлимит"
+"noExpiry" = "Без срока"
+
 [menu]
 "theme" = "Тема"
 "dark" = "Темная"

+ 14 - 0
web/translation/translate.tr_TR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Eklenmiş ters proxy yok."
 "somethingWentWrong" = "Bir şeyler yanlış gitti"
 
+[subscription]
+"title" = "Abonelik Bilgisi"
+"subId" = "Abonelik Kimliği"
+"status" = "Durum"
+"downloaded" = "İndirilen"
+"uploaded" = "Yüklenen"
+"expiry" = "Son Kullanma"
+"totalQuota" = "Toplam Kota"
+"individualLinks" = "Bireysel Bağlantılar"
+"active" = "Aktif"
+"inactive" = "Pasif"
+"unlimited" = "Sınırsız"
+"noExpiry" = "Süresiz"
+
 [menu]
 "theme" = "Tema"
 "dark" = "Koyu"

+ 14 - 0
web/translation/translate.uk_UA.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Немає доданих зворотних проксі."
 "somethingWentWrong" = "Щось пішло не так"
 
+[subscription]
+"title" = "Інформація про підписку"
+"subId" = "ID підписки"
+"status" = "Статус"
+"downloaded" = "Завантажено"
+"uploaded" = "Відвантажено"
+"expiry" = "Термін дії"
+"totalQuota" = "Загальна квота"
+"individualLinks" = "Окремі посилання"
+"active" = "Активна"
+"inactive" = "Неактивна"
+"unlimited" = "Безліміт"
+"noExpiry" = "Без строку"
+
 [menu]
 "theme" = "Тема"
 "dark" = "Темна"

+ 14 - 0
web/translation/translate.vi_VN.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Không có proxy ngược nào được thêm."
 "somethingWentWrong" = "Đã xảy ra lỗi"
 
+[subscription]
+"title" = "Thông tin đăng ký"
+"subId" = "ID đăng ký"
+"status" = "Trạng thái"
+"downloaded" = "Đã tải xuống"
+"uploaded" = "Đã tải lên"
+"expiry" = "Hết hạn"
+"totalQuota" = "Tổng hạn mức"
+"individualLinks" = "Liên kết riêng lẻ"
+"active" = "Hoạt động"
+"inactive" = "Không hoạt động"
+"unlimited" = "Không giới hạn"
+"noExpiry" = "Không hết hạn"
+
 [menu]
 "theme" = "Chủ đề"
 "dark" = "Tối"

+ 14 - 0
web/translation/translate.zh_CN.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "未添加反向代理。"
 "somethingWentWrong" = "出了点问题"
 
+[subscription]
+"title" = "订阅信息"
+"subId" = "订阅 ID"
+"status" = "状态"
+"downloaded" = "已下载"
+"uploaded" = "已上传"
+"expiry" = "到期"
+"totalQuota" = "总配额"
+"individualLinks" = "单独链接"
+"active" = "启用"
+"inactive" = "停用"
+"unlimited" = "无限制"
+"noExpiry" = "无到期"
+
 [menu]
 "theme" = "主题"
 "dark" = "暗色"

+ 14 - 0
web/translation/translate.zh_TW.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "未添加反向代理。"
 "somethingWentWrong" = "發生錯誤"
 
+[subscription]
+"title" = "訂閱資訊"
+"subId" = "訂閱 ID"
+"status" = "狀態"
+"downloaded" = "已下載"
+"uploaded" = "已上傳"
+"expiry" = "到期"
+"totalQuota" = "總配額"
+"individualLinks" = "個別連結"
+"active" = "啟用"
+"inactive" = "停用"
+"unlimited" = "無限制"
+"noExpiry" = "無到期"
+
 [menu]
 "theme" = "主題"
 "dark" = "深色"

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно