From ac9408c37f023b0fbe357735f1cb0915430ed596 Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 18:44:18 +0430 Subject: update sub remark for shadowsocks --- web/service/sub.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/service/sub.go b/web/service/sub.go index f39fdb1e..b9ea49bd 100644 --- a/web/service/sub.go +++ b/web/service/sub.go @@ -603,7 +603,8 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st } } encPart := fmt.Sprintf("%s:%s:%s", method, inboundPassword, clients[clientIndex].Password) - return fmt.Sprintf("ss://%s@%s:%d#%s", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port, clients[clientIndex].Email) + remark := fmt.Sprintf("%s-%s", inbound.Remark, clients[clientIndex].Email) + return fmt.Sprintf("ss://%s@%s:%d#%s", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port, remark) } func searchKey(data interface{}, key string) (interface{}, bool) { -- cgit v1.2.3 From a48745cb3e8fa4e1fb443cf157c497081aad8130 Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 18:46:50 +0430 Subject: fix tls settings --- web/html/xui/form/tls_settings.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/html/xui/form/tls_settings.html b/web/html/xui/form/tls_settings.html index 91642727..35f101ca 100644 --- a/web/html/xui/form/tls_settings.html +++ b/web/html/xui/form/tls_settings.html @@ -10,7 +10,7 @@ Reality @@ -22,7 +22,7 @@ XTLS @@ -100,7 +100,7 @@ - + -- cgit v1.2.3 From 419a1938ee51e011f079777e8b6ac83d908e5366 Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 18:57:10 +0430 Subject: update settings ui --- web/html/xui/inbounds.html | 8 ++--- web/html/xui/settings.html | 86 ++++++++++++++++++++++++++++++---------------- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/web/html/xui/inbounds.html b/web/html/xui/inbounds.html index 6fdf0c43..a66e84a9 100644 --- a/web/html/xui/inbounds.html +++ b/web/html/xui/inbounds.html @@ -105,6 +105,10 @@ + + {{ i18n "none" }} @@ -112,10 +116,6 @@ {{ i18n "depleted" }} {{ i18n "depletingSoon" }} - - .ant-tabs-top-bar { background: white; } + + .alert-msg { + color: rgb(194, 117, 18); + font-weight: bold; + font-size: 20px; + margin-top: 5px; + padding: 16px 6px; + text-align: center; + border-bottom: 1px solid; + } + + .alert-msg > i { + color: inherit; + font-size: 24px; + } + + .collapse-title { + color: inherit; + font-weight: bold; + font-size: 18px; + padding: 10px 20px; + border-bottom: 2px solid; + } + + .collapse-title > i { + color: inherit; + font-size: 24px; + } @@ -35,8 +63,14 @@ {{ i18n "pages.settings.save" }} {{ i18n "pages.settings.restartPanel" }} - + + +

+ + {{ i18n "pages.settings.infoDesc" }} +

+
@@ -72,12 +106,6 @@ - -

- - {{ i18n "pages.settings.infoDesc" }} -

-
@@ -144,8 +172,8 @@ {{ i18n "pages.settings.templates.title"}} -

- +

+ {{ i18n "pages.settings.infoDesc" }}

@@ -154,8 +182,8 @@ -

- +

+ {{ i18n "pages.settings.templates.generalConfigsDesc" }}

@@ -199,8 +227,8 @@
-

- +

+ {{ i18n "pages.settings.templates.blockConfigsDesc" }}

@@ -212,8 +240,8 @@
-

- +

+ {{ i18n "pages.settings.templates.blockCountryConfigsDesc" }}

@@ -226,8 +254,8 @@
-

- +

+ {{ i18n "pages.settings.templates.directCountryConfigsDesc" }}

@@ -240,8 +268,8 @@
-

- +

+ {{ i18n "pages.settings.templates.ipv4ConfigsDesc" }}

@@ -250,8 +278,8 @@
-

- +

+ {{ i18n "pages.settings.templates.warpConfigsDesc" }}

@@ -262,8 +290,8 @@
-

- +

+ {{ i18n "pages.settings.templates.manualListsDesc" }}

@@ -295,6 +323,12 @@
+ +

+ + {{ i18n "pages.settings.infoDesc" }} +

+
@@ -303,12 +337,6 @@ - -

- - {{ i18n "pages.settings.infoDesc" }} -

-
-- cgit v1.2.3 From c7e300f14d5fc8cb4d025892461a766fa8308562 Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 18:58:51 +0430 Subject: FIX redirect after restart panel --- web/html/xui/settings.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/html/xui/settings.html b/web/html/xui/settings.html index acff52f3..8f9e2b7b 100644 --- a/web/html/xui/settings.html +++ b/web/html/xui/settings.html @@ -480,7 +480,12 @@ if (msg.success) { this.loading(true); await PromiseUtil.sleep(5000); - window.location.replace(this.allSetting.webBasePath + "panel/settings"); + let protocol = "http://"; + if (this.allSetting.webCertFile !== "") { + protocol = "https://"; + } + const { host, pathname } = window.location; + window.location.replace(protocol + host + this.allSetting.webBasePath + pathname.slice(1)); } }, async fetchUserSecret() { -- cgit v1.2.3 From f50ccce9ec7d920919fd28a1fa2ac2b0b9b44e37 Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 19:02:37 +0430 Subject: Add manual list for ipv4 and warp and fixed it --- web/html/xui/settings.html | 120 ++++++++++++++++++++------------------------- 1 file changed, 53 insertions(+), 67 deletions(-) diff --git a/web/html/xui/settings.html b/web/html/xui/settings.html index 8f9e2b7b..7dd503a6 100644 --- a/web/html/xui/settings.html +++ b/web/html/xui/settings.html @@ -299,6 +299,8 @@ + + @@ -617,30 +619,30 @@ computed: { templateSettings: { get: function () { return this.allSetting.xrayTemplateConfig ? JSON.parse(this.allSetting.xrayTemplateConfig) : null; }, - set: function (newValue) { this.allSetting.xrayTemplateConfig = JSON.stringify(newValue, null, 2) }, + set: function (newValue) { this.allSetting.xrayTemplateConfig = JSON.stringify(newValue, null, 2); }, }, inboundSettings: { get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.inbounds, null, 2) : null; }, set: function (newValue) { newTemplateSettings = this.templateSettings; - newTemplateSettings.inbounds = JSON.parse(newValue) - this.templateSettings = newTemplateSettings + newTemplateSettings.inbounds = JSON.parse(newValue); + this.templateSettings = newTemplateSettings; }, }, outboundSettings: { get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.outbounds, null, 2) : null; }, set: function (newValue) { newTemplateSettings = this.templateSettings; - newTemplateSettings.outbounds = JSON.parse(newValue) - this.templateSettings = newTemplateSettings + newTemplateSettings.outbounds = JSON.parse(newValue); + this.templateSettings = newTemplateSettings; }, }, routingRuleSettings: { get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.routing.rules, null, 2) : null; }, set: function (newValue) { newTemplateSettings = this.templateSettings; - newTemplateSettings.routing.rules = JSON.parse(newValue) - this.templateSettings = newTemplateSettings + newTemplateSettings.routing.rules = JSON.parse(newValue); + this.templateSettings = newTemplateSettings; }, }, freedomStrategy: { @@ -715,6 +717,24 @@ this.syncRulesWithOutbound("direct", this.directSettings); } }, + ipv4Domains: { + get: function () { + return this.templateRuleGetter({ outboundTag: "IPv4", property: "domain" }); + }, + set: function (newValue) { + this.templateRuleSetter({ outboundTag: "IPv4", property: "domain", data: newValue }); + this.syncRulesWithOutbound("IPv4", this.ipv4Settings); + } + }, + warpDomains: { + get: function () { + return this.templateRuleGetter({ outboundTag: "WARP", property: "domain" }); + }, + set: function (newValue) { + this.templateRuleSetter({ outboundTag: "WARP", property: "domain", data: newValue }); + this.syncRulesWithOutbound("WARP", this.warpSettings); + } + }, manualBlockedIPs: { get: function () { return JSON.stringify(this.blockedIPs, null, 2); }, set: debounce(function (value) { this.blockedIPs = JSON.parse(value); }, 1000) @@ -731,6 +751,14 @@ get: function () { return JSON.stringify(this.directDomains, null, 2); }, set: debounce(function (value) { this.directDomains = JSON.parse(value); }, 1000) }, + manualIPv4Domains: { + get: function () { return JSON.stringify(this.ipv4Domains, null, 2); }, + set: debounce(function (value) { this.ipv4Domains = JSON.parse(value); }, 1000) + }, + manualWARPDomains: { + get: function () { return JSON.stringify(this.warpDomains, null, 2); }, + set: debounce(function (value) { this.warpDomains = JSON.parse(value); }, 1000) + }, torrentSettings: { get: function () { return doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols); @@ -796,40 +824,26 @@ }, GoogleIPv4Settings: { get: function () { - return doAllItemsExist(this.settingsData.domains.google, this.templateRuleGetter({ outboundTag: "IPv4", property: "domain" })); + return doAllItemsExist(this.settingsData.domains.google, this.ipv4Domains); }, set: function (newValue) { - oldData = this.templateRuleGetter({ outboundTag: "IPv4", property: "domain" }); if (newValue) { - oldData = [...oldData, ...this.settingsData.domains.google]; + this.ipv4Domains = [...this.ipv4Domains, ...this.settingsData.domains.google]; } else { - oldData = oldData.filter(data => !this.settingsData.domains.google.includes(data)) + this.ipv4Domains = this.ipv4Domains.filter(data => !this.settingsData.domains.google.includes(data)); } - this.templateRuleSetter({ - outboundTag: "IPv4", - property: "domain", - data: oldData - }); - this.syncRulesWithOutbound("IPv4", this.ipv4Settings); }, }, NetflixIPv4Settings: { get: function () { - return doAllItemsExist(this.settingsData.domains.netflix, this.templateRuleGetter({ outboundTag: "IPv4", property: "domain" })); + return doAllItemsExist(this.settingsData.domains.netflix, this.ipv4Domains); }, set: function (newValue) { - oldData = this.templateRuleGetter({ outboundTag: "IPv4", property: "domain" }); if (newValue) { - oldData = [...oldData, ...this.settingsData.domains.netflix]; + this.ipv4Domains = [...this.ipv4Domains, ...this.settingsData.domains.netflix]; } else { - oldData = oldData.filter(data => !this.settingsData.domains.netflix.includes(data)) + this.ipv4Domains = this.ipv4Domains.filter(data => !this.settingsData.domains.netflix.includes(data)); } - this.templateRuleSetter({ - outboundTag: "IPv4", - property: "domain", - data: oldData - }); - this.syncRulesWithOutbound("IPv4", this.ipv4Settings); }, }, IRIpSettings: { @@ -978,78 +992,50 @@ }, GoogleWARPSettings: { get: function () { - return doAllItemsExist(this.settingsData.domains.google, this.templateRuleGetter({ outboundTag: "WARP", property: "domain" })); + return doAllItemsExist(this.settingsData.domains.google, this.warpDomains); }, set: function (newValue) { - oldData = this.templateRuleGetter({ outboundTag: "WARP", property: "domain" }); if (newValue) { - oldData = [...oldData, ...this.settingsData.domains.google]; + this.warpDomains = [...this.warpDomains, ...this.settingsData.domains.google]; } else { - oldData = oldData.filter(data => !this.settingsData.domains.google.includes(data)) + this.warpDomains = this.warpDomains.filter(data => !this.settingsData.domains.google.includes(data)); } - this.templateRuleSetter({ - outboundTag: "WARP", - property: "domain", - data: oldData - }); - this.syncRulesWithOutbound("WARP", this.warpSettings); }, }, OpenAIWARPSettings: { get: function () { - return doAllItemsExist(this.settingsData.domains.openai, this.templateRuleGetter({ outboundTag: "WARP", property: "domain" })); + return doAllItemsExist(this.settingsData.domains.openai, this.warpDomains); }, set: function (newValue) { - oldData = this.templateRuleGetter({ outboundTag: "WARP", property: "domain" }); if (newValue) { - oldData = [...oldData, ...this.settingsData.domains.openai]; + this.warpDomains = [...this.warpDomains, ...this.settingsData.domains.openai]; } else { - oldData = oldData.filter(data => !this.settingsData.domains.openai.includes(data)) + this.warpDomains = this.warpDomains.filter(data => !this.settingsData.domains.openai.includes(data)); } - this.templateRuleSetter({ - outboundTag: "WARP", - property: "domain", - data: oldData - }); - this.syncRulesWithOutbound("WARP", this.warpSettings); }, }, NetflixWARPSettings: { get: function () { - return doAllItemsExist(this.settingsData.domains.netflix, this.templateRuleGetter({ outboundTag: "WARP", property: "domain" })); + return doAllItemsExist(this.settingsData.domains.netflix, this.warpDomains); }, set: function (newValue) { - oldData = this.templateRuleGetter({ outboundTag: "WARP", property: "domain" }); if (newValue) { - oldData = [...oldData, ...this.settingsData.domains.netflix]; + this.warpDomains = [...this.warpDomains, ...this.settingsData.domains.netflix]; } else { - oldData = oldData.filter(data => !this.settingsData.domains.netflix.includes(data)) + this.warpDomains = this.warpDomains.filter(data => !this.settingsData.domains.netflix.includes(data)); } - this.templateRuleSetter({ - outboundTag: "WARP", - property: "domain", - data: oldData - }); - this.syncRulesWithOutbound("WARP", this.warpSettings); }, }, SpotifyWARPSettings: { get: function () { - return doAllItemsExist(this.settingsData.domains.spotify, this.templateRuleGetter({ outboundTag: "WARP", property: "domain" })); + return doAllItemsExist(this.settingsData.domains.spotify, this.warpDomains); }, set: function (newValue) { - oldData = this.templateRuleGetter({ outboundTag: "WARP", property: "domain" }); if (newValue) { - oldData = [...oldData, ...this.settingsData.domains.spotify]; + this.warpDomains = [...this.warpDomains, ...this.settingsData.domains.spotify]; } else { - oldData = oldData.filter(data => !this.settingsData.domains.spotify.includes(data)) + this.warpDomains = this.warpDomains.filter(data => !this.settingsData.domains.spotify.includes(data)); } - this.templateRuleSetter({ - outboundTag: "WARP", - property: "domain", - data: oldData - }); - this.syncRulesWithOutbound("WARP", this.warpSettings); }, }, }, -- cgit v1.2.3 From 3166d497f99df530e3a909ab1ba134fe53ef39ff Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 19:03:31 +0430 Subject: Update .gitignore --- .gitignore | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 6277cfc9..158f4ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,15 @@ .idea .vscode +.cache +.sync* +*.tar.gz +access.log +error.log tmp +main backup/ bin/ dist/ -x-ui-*.tar.gz -/x-ui -/release.sh -.sync* -main release/ -access.log -error.log -.cache +/release.sh +/x-ui -- cgit v1.2.3 From 4831c2f1b2c73c1e40f23a61e728530b7cf4afe9 Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 19:15:20 +0430 Subject: Add tgLang option --- web/assets/js/model/models.js | 1 + web/entity/entity.go | 1 + web/network/auto_https_conn.go | 67 ++++++++++++++++++++++++++++++++++++++++++ web/network/autp_https_conn.go | 67 ------------------------------------------ web/service/setting.go | 5 ++++ 5 files changed, 74 insertions(+), 67 deletions(-) create mode 100644 web/network/auto_https_conn.go delete mode 100644 web/network/autp_https_conn.go diff --git a/web/assets/js/model/models.js b/web/assets/js/model/models.js index a3fd2633..e1fb5d02 100644 --- a/web/assets/js/model/models.js +++ b/web/assets/js/model/models.js @@ -181,6 +181,7 @@ class AllSetting { this.tgRunTime = "@daily"; this.tgBotBackup = false; this.tgCpu = ""; + this.tgLang = ""; this.xrayTemplateConfig = ""; this.secretEnable = false; diff --git a/web/entity/entity.go b/web/entity/entity.go index b370b7ba..52f26769 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -41,6 +41,7 @@ type AllSetting struct { TgRunTime string `json:"tgRunTime" form:"tgRunTime"` TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` TgCpu int `json:"tgCpu" form:"tgCpu"` + TgLang string `json:"tgLang" form:"tgLang"` XrayTemplateConfig string `json:"xrayTemplateConfig" form:"xrayTemplateConfig"` TimeLocation string `json:"timeLocation" form:"timeLocation"` SecretEnable bool `json:"secretEnable" form:"secretEnable"` diff --git a/web/network/auto_https_conn.go b/web/network/auto_https_conn.go new file mode 100644 index 00000000..d1a9d521 --- /dev/null +++ b/web/network/auto_https_conn.go @@ -0,0 +1,67 @@ +package network + +import ( + "bufio" + "bytes" + "fmt" + "net" + "net/http" + "sync" +) + +type AutoHttpsConn struct { + net.Conn + + firstBuf []byte + bufStart int + + readRequestOnce sync.Once +} + +func NewAutoHttpsConn(conn net.Conn) net.Conn { + return &AutoHttpsConn{ + Conn: conn, + } +} + +func (c *AutoHttpsConn) readRequest() bool { + c.firstBuf = make([]byte, 2048) + n, err := c.Conn.Read(c.firstBuf) + c.firstBuf = c.firstBuf[:n] + if err != nil { + return false + } + reader := bytes.NewReader(c.firstBuf) + bufReader := bufio.NewReader(reader) + request, err := http.ReadRequest(bufReader) + if err != nil { + return false + } + resp := http.Response{ + Header: http.Header{}, + } + resp.StatusCode = http.StatusTemporaryRedirect + location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI) + resp.Header.Set("Location", location) + resp.Write(c.Conn) + c.Close() + c.firstBuf = nil + return true +} + +func (c *AutoHttpsConn) Read(buf []byte) (int, error) { + c.readRequestOnce.Do(func() { + c.readRequest() + }) + + if c.firstBuf != nil { + n := copy(buf, c.firstBuf[c.bufStart:]) + c.bufStart += n + if c.bufStart >= len(c.firstBuf) { + c.firstBuf = nil + } + return n, nil + } + + return c.Conn.Read(buf) +} diff --git a/web/network/autp_https_conn.go b/web/network/autp_https_conn.go deleted file mode 100644 index d1a9d521..00000000 --- a/web/network/autp_https_conn.go +++ /dev/null @@ -1,67 +0,0 @@ -package network - -import ( - "bufio" - "bytes" - "fmt" - "net" - "net/http" - "sync" -) - -type AutoHttpsConn struct { - net.Conn - - firstBuf []byte - bufStart int - - readRequestOnce sync.Once -} - -func NewAutoHttpsConn(conn net.Conn) net.Conn { - return &AutoHttpsConn{ - Conn: conn, - } -} - -func (c *AutoHttpsConn) readRequest() bool { - c.firstBuf = make([]byte, 2048) - n, err := c.Conn.Read(c.firstBuf) - c.firstBuf = c.firstBuf[:n] - if err != nil { - return false - } - reader := bytes.NewReader(c.firstBuf) - bufReader := bufio.NewReader(reader) - request, err := http.ReadRequest(bufReader) - if err != nil { - return false - } - resp := http.Response{ - Header: http.Header{}, - } - resp.StatusCode = http.StatusTemporaryRedirect - location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI) - resp.Header.Set("Location", location) - resp.Write(c.Conn) - c.Close() - c.firstBuf = nil - return true -} - -func (c *AutoHttpsConn) Read(buf []byte) (int, error) { - c.readRequestOnce.Do(func() { - c.readRequest() - }) - - if c.firstBuf != nil { - n := copy(buf, c.firstBuf[c.bufStart:]) - c.bufStart += n - if c.bufStart >= len(c.firstBuf) { - c.firstBuf = nil - } - return n, nil - } - - return c.Conn.Read(buf) -} diff --git a/web/service/setting.go b/web/service/setting.go index d3072252..fec324af 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -39,6 +39,7 @@ var defaultValueMap = map[string]string{ "tgRunTime": "@daily", "tgBotBackup": "false", "tgCpu": "0", + "tgLang": "en-US", "secretEnable": "false", } @@ -256,6 +257,10 @@ func (s *SettingService) GetTgCpu() (int, error) { return s.getInt("tgCpu") } +func (s *SettingService) GetTgLang() (string, error) { + return s.getString("tgLang") +} + func (s *SettingService) GetPort() (int, error) { return s.getInt("webPort") } -- cgit v1.2.3 From 91360a3f49895d80d34183a0421f2dcc5d64b18e Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 19:20:54 +0430 Subject: add tgLang to settings --- web/html/xui/settings.html | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/web/html/xui/settings.html b/web/html/xui/settings.html index 7dd503a6..f5ea4994 100644 --- a/web/html/xui/settings.html +++ b/web/html/xui/settings.html @@ -338,6 +338,29 @@ + + + + + + + + + + +
-- cgit v1.2.3 From 795835c54fbbaf35755c13855fb2b64e4e85e49c Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 19:21:10 +0430 Subject: Update translations --- web/translation/translate.en_US.toml | 4 +++- web/translation/translate.fa_IR.toml | 2 ++ web/translation/translate.ru_RU.toml | 2 ++ web/translation/translate.zh_Hans.toml | 2 ++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 075bf6e9..4855bf5e 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -209,7 +209,7 @@ [pages.settings] "title" = "Settings" "save" = "Save" -"infoDesc" = "Every change made here needs to be saved. Please restart the panel for the changes to take effect." +"infoDesc" = "Every change made here needs to be saved. Please restart the panel to apply changes." "restartPanel" = "Restart Panel " "restartPanelDesc" = "Are you sure you want to restart the panel? Click OK to restart after 3 seconds. If you cannot access the panel after restarting, please view the panel log information on the server." "actions" = "Actions" @@ -336,6 +336,8 @@ "manualBlockedDomains" = "List of Blocked Domains" "manualDirectIPs" = "List of Direct IPs" "manualDirectDomains" = "List of Direct Domains" +"manualIPv4Domains" = "List of IPv4 Domains" +"manualWARPDomains" = "List of WARP Domains" [pages.settings.security] "admin" = "Admin" diff --git a/web/translation/translate.fa_IR.toml b/web/translation/translate.fa_IR.toml index 9e31f4ef..7bcca58a 100644 --- a/web/translation/translate.fa_IR.toml +++ b/web/translation/translate.fa_IR.toml @@ -336,6 +336,8 @@ "manualBlockedDomains" = "لیست دامنه های مسدود شده" "manualDirectIPs" = "لیست آی‌پی های مستقیم" "manualDirectDomains" = "لیست دامنه های مستقیم" +"manualIPv4Domains" = "لیست دامنه‌های IPv4" +"manualWARPDomains" = "لیست دامنه های WARP" [pages.settings.security] "admin" = "مدیر" diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index 15e45b84..7b5175ea 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -336,6 +336,8 @@ "manualBlockedDomains" = "Список заблокированных доменов" "manualDirectIPs" = "Список прямых IP адресов" "manualDirectDomains" = "Список прямых доменов" +"manualIPv4Domains" = "Список доменов IPv4" +"manualWARPDomains" = "Список доменов WARP" [pages.settings.security] "admin" = "Админ" diff --git a/web/translation/translate.zh_Hans.toml b/web/translation/translate.zh_Hans.toml index a1205447..ecdfb540 100644 --- a/web/translation/translate.zh_Hans.toml +++ b/web/translation/translate.zh_Hans.toml @@ -336,6 +336,8 @@ "manualBlockedDomains" = "被阻止的域列表" "manualDirectIPs" = "直接 IP 列表" "manualDirectDomains" = "直接域列表" +"manualIPv4Domains" = "IPv4 域名列表" +"manualWARPDomains" = "WARP域名列表" [pages.settings.security] "admin" = "行政" -- cgit v1.2.3 From 678962d4ca3045bd869930efef0a196a0f8a29ba Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 19:39:01 +0430 Subject: some fix and prune for tgbot --- web/controller/index.go | 5 ++- web/service/tgbot.go | 104 ++++++++++++++++++++++++++++-------------------- web/web.go | 2 +- 3 files changed, 64 insertions(+), 47 deletions(-) diff --git a/web/controller/index.go b/web/controller/index.go index ac2ceca1..7a7a0cce 100644 --- a/web/controller/index.go +++ b/web/controller/index.go @@ -60,15 +60,16 @@ func (a *IndexController) login(c *gin.Context) { pureJsonMsg(c, false, I18n(c, "pages.login.toasts.emptyPassword")) return } + user := a.userService.CheckUser(form.Username, form.Password, form.LoginSecret) timeStr := time.Now().Format("2006-01-02 15:04:05") if user == nil { - a.tgbot.UserLoginNotify(form.Username, getRemoteIp(c), timeStr, 0) logger.Infof("wrong username or password: \"%s\" \"%s\"", form.Username, form.Password) + a.tgbot.UserLoginNotify(form.Username, getRemoteIp(c), timeStr, 0) pureJsonMsg(c, false, I18n(c, "pages.login.toasts.wrongUsernameOrPassword")) return } else { - logger.Infof("%s login success,Ip Address:%s\n", form.Username, getRemoteIp(c)) + logger.Infof("%s login success, Ip Address: %s\n", form.Username, getRemoteIp(c)) a.tgbot.UserLoginNotify(form.Username, getRemoteIp(c), timeStr, 1) } diff --git a/web/service/tgbot.go b/web/service/tgbot.go index 0b301e29..72f8ae1a 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -81,7 +81,7 @@ func (t *Tgbot) Start() error { return nil } -func (t *Tgbot) IsRunnging() bool { +func (t *Tgbot) IsRunning() bool { return isRunning } @@ -102,19 +102,19 @@ func (t *Tgbot) OnReceive() { botHandler, _ = th.NewBotHandler(bot, updates) - botHandler.HandleMessage(func(bot *telego.Bot, message telego.Message) { + botHandler.HandleMessage(func(_ *telego.Bot, message telego.Message) { t.SendMsgToTgbot(message.Chat.ID, "Custom Keyboard Closed!", tu.ReplyKeyboardRemove()) }, th.TextEqual("❌ Close Keyboard")) - botHandler.HandleMessage(func(bot *telego.Bot, message telego.Message) { + botHandler.HandleMessage(func(_ *telego.Bot, message telego.Message) { t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID)) }, th.AnyCommand()) - botHandler.HandleCallbackQuery(func(bot *telego.Bot, query telego.CallbackQuery) { + botHandler.HandleCallbackQuery(func(_ *telego.Bot, query telego.CallbackQuery) { t.asnwerCallback(&query, checkAdmin(query.From.ID)) }, th.AnyCallbackQueryWithMessage()) - botHandler.HandleMessage(func(bot *telego.Bot, message telego.Message) { + botHandler.HandleMessage(func(_ *telego.Bot, message telego.Message) { if message.UserShared != nil { if checkAdmin(message.From.ID) { err := t.inboundService.SetClientTelegramUserID(message.UserShared.RequestID, strconv.FormatInt(message.UserShared.UserID, 10)) @@ -424,32 +424,32 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) { tu.InlineKeyboardButton("Commands").WithCallbackData("client_commands"), ), ) - params := telego.SendMessageParams{ - ChatID: tu.ID(chatId), - Text: msg, - ParseMode: "HTML", - } + var ReplyMarkup telego.ReplyMarkup if isAdmin { - params.ReplyMarkup = numericKeyboard + ReplyMarkup = numericKeyboard } else { - params.ReplyMarkup = numericKeyboardClient - } - _, err := bot.SendMessage(¶ms) - if err != nil { - logger.Warning("Error sending telegram message :", err) + ReplyMarkup = numericKeyboardClient } + t.SendMsgToTgbot(chatId, msg, ReplyMarkup) } func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) { if !isRunning { return } + if msg == "" { + logger.Info("[tgbot] message is empty!") + return + } + var allMessages []string limit := 2000 + // paging message if it is big if len(msg) > limit { messages := strings.Split(msg, "\r\n \r\n") lastIndex := -1 + for _, message := range messages { if (len(allMessages) == 0) || (len(allMessages[lastIndex])+len(message) > limit) { allMessages = append(allMessages, message) @@ -510,12 +510,12 @@ func (t *Tgbot) SendBackUP(c *gin.Context) { func (t *Tgbot) getServerUsage() string { var info string //get hostname - name, err := os.Hostname() + hostname, err := os.Hostname() if err != nil { logger.Error("get hostname error:", err) - name = "" + hostname = "" } - info = fmt.Sprintf("💻 Hostname: %s\r\n", name) + info = fmt.Sprintf("💻 Hostname: %s\r\n", hostname) info += fmt.Sprintf("🚀X-UI Version: %s\r\n", config.GetVersion()) //get ip address var ip string @@ -557,25 +557,32 @@ func (t *Tgbot) getServerUsage() string { } func (t *Tgbot) UserLoginNotify(username string, ip string, time string, status LoginStatus) { + if !t.IsRunning() { + return + } + if username == "" || ip == "" || time == "" { - logger.Warning("UserLoginNotify failed,invalid info") + logger.Warning("UserLoginNotify failed, invalid info!") return } - var msg string + // Get hostname - name, err := os.Hostname() + hostname, err := os.Hostname() if err != nil { logger.Warning("get hostname error:", err) return } + + msg := "" if status == LoginSuccess { - msg = fmt.Sprintf("✅ Successfully logged-in to the panel\r\nHostname:%s\r\n", name) + msg = fmt.Sprintf("✅ Successfully logged-in to the panel\r\nHostname:%s\r\n", hostname) } else if status == LoginFail { - msg = fmt.Sprintf("❗ Login to the panel was unsuccessful\r\nHostname:%s\r\n", name) + msg = fmt.Sprintf("❗ Login to the panel was unsuccessful\r\nHostname:%s\r\n", hostname) } msg += fmt.Sprintf("⏰ Time:%s\r\n", time) msg += fmt.Sprintf("🆔 Username:%s\r\n", username) msg += fmt.Sprintf("🌐 IP:%s\r\n", ip) + t.SendMsgToTgbotAdmins(msg) } @@ -686,11 +693,12 @@ func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ... t.SendMsgToTgbot(chatId, msg) return } - tdId := "None" + tgId := "None" if len(client.TgID) > 0 { - tdId = client.TgID + tgId = client.TgID } - output := fmt.Sprintf("📧 Email: %s\r\n👤 Telegram User: %s\r\n", email, tdId) + + output := fmt.Sprintf("📧 Email: %s\r\n👤 Telegram User: %s\r\n", email, tgId) inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( tu.InlineKeyboardButton("🔄 Refresh").WithCallbackData("tgid_refresh "+email), @@ -699,6 +707,7 @@ func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ... tu.InlineKeyboardButton("❌ Remove Telegram User").WithCallbackData("tgid_remove "+email), ), ) + if len(messageID) > 0 { t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) } else { @@ -732,6 +741,7 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { t.SendMsgToTgbot(chatId, msg) return } + expiryTime := "" if traffic.ExpiryTime == 0 { expiryTime = "♾Unlimited" @@ -740,15 +750,18 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { } else { expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05") } + total := "" if traffic.Total == 0 { total = "♾Unlimited" } else { total = common.FormatTraffic((traffic.Total)) } + output := fmt.Sprintf("💡 Active: %t\r\n📧 Email: %s\r\n🔼 Upload↑: %s\r\n🔽 Download↓: %s\r\n🔄 Total: %s / %s\r\n📅 Expire in: %s\r\n", traffic.Enable, traffic.Email, common.FormatTraffic(traffic.Up), common.FormatTraffic(traffic.Down), common.FormatTraffic((traffic.Up + traffic.Down)), total, expiryTime) + inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( tu.InlineKeyboardButton("🔄 Refresh").WithCallbackData("client_refresh "+email), @@ -770,6 +783,7 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { tu.InlineKeyboardButton("🔘 Enable / Disable").WithCallbackData("toggle_enable "+email), ), ) + if len(messageID) > 0 { t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) } else { @@ -785,6 +799,12 @@ func (t *Tgbot) searchInbound(chatId int64, remark string) { t.SendMsgToTgbot(chatId, msg) return } + if len(inbouds) == 0 { + msg := "❌ No inbounds found!" + t.SendMsgToTgbot(chatId, msg) + return + } + for _, inbound := range inbouds { info := "" info += fmt.Sprintf("📍Inbound:%s\r\nPort:%d\r\n", inbound.Remark, inbound.Port) @@ -795,6 +815,7 @@ func (t *Tgbot) searchInbound(chatId int64, remark string) { info += fmt.Sprintf("Expire date:%s\r\n \r\n", time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")) } t.SendMsgToTgbot(chatId, info) + for _, traffic := range inbound.ClientStats { expiryTime := "" if traffic.ExpiryTime == 0 { @@ -804,6 +825,7 @@ func (t *Tgbot) searchInbound(chatId int64, remark string) { } else { expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05") } + total := "" if traffic.Total == 0 { total = "♾Unlimited" @@ -831,6 +853,7 @@ func (t *Tgbot) searchForClient(chatId int64, query string) { t.SendMsgToTgbot(chatId, msg) return } + expiryTime := "" if traffic.ExpiryTime == 0 { expiryTime = "♾Unlimited" @@ -839,12 +862,14 @@ func (t *Tgbot) searchForClient(chatId int64, query string) { } else { expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05") } + total := "" if traffic.Total == 0 { total = "♾Unlimited" } else { total = common.FormatTraffic((traffic.Total)) } + output := fmt.Sprintf("💡 Active: %t\r\n📧 Email: %s\r\n🔼 Upload↑: %s\r\n🔽 Download↓: %s\r\n🔄 Total: %s / %s\r\n📅 Expire in: %s\r\n", traffic.Enable, traffic.Email, common.FormatTraffic(traffic.Up), common.FormatTraffic(traffic.Down), common.FormatTraffic((traffic.Up + traffic.Down)), total, expiryTime) @@ -859,6 +884,7 @@ func (t *Tgbot) getExhausted() string { var exhaustedClients []xray.ClientTraffic var disabledInbounds []model.Inbound var disabledClients []xray.ClientTraffic + output := "" TrafficThreshold, err := t.settingService.GetTrafficDiff() if err == nil && TrafficThreshold > 0 { @@ -872,6 +898,7 @@ func (t *Tgbot) getExhausted() string { if err != nil { logger.Warning("Unable to load Inbounds", err) } + for _, inbound := range inbounds { if inbound.Enable { if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) || @@ -894,6 +921,7 @@ func (t *Tgbot) getExhausted() string { disabledInbounds = append(disabledInbounds, *inbound) } } + output += fmt.Sprintf("Exhausted Inbounds count:\r\n🛑 Disabled: %d\r\n🔜 Deplete soon: %d\r\n \r\n", len(disabledInbounds), len(exhaustedInbounds)) if len(exhaustedInbounds) > 0 { output += "Exhausted Inbounds:\r\n" @@ -906,6 +934,7 @@ func (t *Tgbot) getExhausted() string { } } } + output += fmt.Sprintf("Exhausted Clients count:\r\n🛑 Exhausted: %d\r\n🔜 Deplete soon: %d\r\n \r\n", len(disabledClients), len(exhaustedClients)) if len(exhaustedClients) > 0 { output += "Exhausted Clients:\r\n" @@ -934,6 +963,10 @@ func (t *Tgbot) getExhausted() string { } func (t *Tgbot) sendBackup(chatId int64) { + if !t.IsRunning() { + return + } + sendingTime := time.Now().Format("2006-01-02 15:04:05") t.SendMsgToTgbot(chatId, "Backup time: "+sendingTime) file, err := os.Open(config.GetDBPath()) @@ -997,20 +1030,3 @@ func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlin logger.Warning(err) } } - -func fromChat(u *telego.Update) *telego.Chat { - switch { - case u.Message != nil: - return &u.Message.Chat - case u.EditedMessage != nil: - return &u.EditedMessage.Chat - case u.ChannelPost != nil: - return &u.ChannelPost.Chat - case u.EditedChannelPost != nil: - return &u.EditedChannelPost.Chat - case u.CallbackQuery != nil: - return &u.CallbackQuery.Message.Chat - default: - return nil - } -} diff --git a/web/web.go b/web/web.go index 1795e1d4..bd1c4721 100644 --- a/web/web.go +++ b/web/web.go @@ -453,7 +453,7 @@ func (s *Server) Stop() error { if s.cron != nil { s.cron.Stop() } - if s.tgbotService.IsRunnging() { + if s.tgbotService.IsRunning() { s.tgbotService.Stop() } var err1 error -- cgit v1.2.3 From 0b7aa8a9e03647a622b0de064c61e9634c20a958 Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 19:41:08 +0430 Subject: Refactor i18n localizer --- web/locale/locale.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 web/locale/locale.go diff --git a/web/locale/locale.go b/web/locale/locale.go new file mode 100644 index 00000000..ba7a30e5 --- /dev/null +++ b/web/locale/locale.go @@ -0,0 +1,110 @@ +package locale + +import ( + "embed" + "io/fs" + "strings" + "x-ui/logger" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/pelletier/go-toml/v2" + "golang.org/x/text/language" +) + +var i18nBundle *i18n.Bundle +var LocalizerWeb *i18n.Localizer +var LocalizerBot *i18n.Localizer + +type I18nType string + +const ( + Bot I18nType = "bot" + Web I18nType = "web" +) + +type SettingService interface { + GetTgLang() (string, error) +} + +func InitLocalizer(i18nFS embed.FS, settingService SettingService) error { + // set default bundle to english + i18nBundle = i18n.NewBundle(language.English) + i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + + // parse files + if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil { + return err + } + + return nil +} + +func createTemplateData(params []string, seperator ...string) map[string]interface{} { + var sep string = "==" + if len(seperator) > 0 { + sep = seperator[0] + } + + templateData := make(map[string]interface{}) + for _, param := range params { + parts := strings.SplitN(param, sep, 2) + templateData[parts[0]] = parts[1] + } + + return templateData +} + +func I18n(i18nType I18nType, key string, params ...string) string { + var localizer *i18n.Localizer + + switch i18nType { + case "bot": + localizer = LocalizerBot + case "web": + localizer = LocalizerWeb + default: + logger.Errorf("Invalid type for I18n: %s", i18nType) + return "" + } + + templateData := createTemplateData(params) + + msg, err := localizer.Localize(&i18n.LocalizeConfig{ + MessageID: key, + TemplateData: templateData, + }) + + if err != nil { + logger.Errorf("Failed to localize message: %v", err) + return "" + } + + return msg +} + +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 + } + + if d.IsDir() { + return nil + } + + data, err := i18nFS.ReadFile(path) + if err != nil { + return err + } + + _, err = i18nBundle.ParseMessageFileBytes(data, path) + return err + }) + + if err != nil { + return err + } + + return nil +} -- cgit v1.2.3 From 92eaff9608f924f7a78a7837daecec6cfb252b50 Mon Sep 17 00:00:00 2001 From: Hamidreza Ghavami <70919649+hamid-gh98@users.noreply.github.com> Date: Sat, 20 May 2023 19:43:59 +0430 Subject: replace new localizer to web.go --- web/web.go | 101 ++++++------------------------------------------------------- 1 file changed, 10 insertions(+), 91 deletions(-) diff --git a/web/web.go b/web/web.go index bd1c4721..afac077f 100644 --- a/web/web.go +++ b/web/web.go @@ -18,16 +18,14 @@ import ( "x-ui/util/common" "x-ui/web/controller" "x-ui/web/job" + "x-ui/web/locale" "x-ui/web/network" "x-ui/web/service" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" - "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/pelletier/go-toml/v2" "github.com/robfig/cron/v3" - "golang.org/x/text/language" ) //go:embed assets/* @@ -202,13 +200,16 @@ func (s *Server) initRouter() (*gin.Engine, error) { c.Header("Cache-Control", "max-age=31536000") } }) - err = s.initI18n(engine) + + // init i18n + err = locale.InitLocalizer(i18nFS, &s.settingService) if err != nil { return nil, err } + // set static files and template if config.IsDebug() { - // for develop + // for development files, err := s.getHtmlFiles() if err != nil { return nil, err @@ -216,12 +217,12 @@ func (s *Server) initRouter() (*gin.Engine, error) { engine.LoadHTMLFiles(files...) engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/assets"))) } else { - // for prod - t, err := s.getHtmlTemplate(engine.FuncMap) + // for production + template, err := s.getHtmlTemplate(engine.FuncMap) if err != nil { return nil, err } - engine.SetHTMLTemplate(t) + engine.SetHTMLTemplate(template) engine.StaticFS(basePath+"assets", http.FS(&wrapAssetsFS{FS: assetsFS})) } @@ -239,87 +240,6 @@ func (s *Server) initRouter() (*gin.Engine, error) { return engine, nil } -func (s *Server) initI18n(engine *gin.Engine) error { - bundle := i18n.NewBundle(language.SimplifiedChinese) - bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) - err := fs.WalkDir(i18nFS, "translation", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - data, err := i18nFS.ReadFile(path) - if err != nil { - return err - } - _, err = bundle.ParseMessageFileBytes(data, path) - return err - }) - if err != nil { - return err - } - - findI18nParamNames := func(key string) []string { - names := make([]string, 0) - keyLen := len(key) - for i := 0; i < keyLen-1; i++ { - if key[i:i+2] == "{{" { // 判断开头 "{{" - j := i + 2 - isFind := false - for ; j < keyLen-1; j++ { - if key[j:j+2] == "}}" { // 结尾 "}}" - isFind = true - break - } - } - if isFind { - names = append(names, key[i+3:j]) - } - } - } - return names - } - - var localizer *i18n.Localizer - - I18n := func(key string, params ...string) (string, error) { - names := findI18nParamNames(key) - if len(names) != len(params) { - return "", common.NewError("find names:", names, "---------- params:", params, "---------- num not equal") - } - templateData := map[string]interface{}{} - for i := range names { - templateData[names[i]] = params[i] - } - return localizer.Localize(&i18n.LocalizeConfig{ - MessageID: key, - TemplateData: templateData, - }) - } - - engine.FuncMap["i18n"] = I18n - - engine.Use(func(c *gin.Context) { - //accept := c.GetHeader("Accept-Language") - - var lang string - - if cookie, err := c.Request.Cookie("lang"); err == nil { - lang = cookie.Value - } else { - lang = c.GetHeader("Accept-Language") - } - - localizer = i18n.NewLocalizer(bundle, lang) - c.Set("localizer", localizer) - c.Set("I18n", I18n) - c.Next() - }) - - return nil -} - func (s *Server) startTask() { err := s.xrayService.RestartXray(true) if err != nil { @@ -346,7 +266,7 @@ func (s *Server) startTask() { if (err == nil) && (isTgbotenabled) { runtime, err := s.settingService.GetTgbotRuntime() if err != nil || runtime == "" { - logger.Errorf("Add NewStatsNotifyJob error[%s],Runtime[%s] invalid,wil run default", err, runtime) + logger.Errorf("Add NewStatsNotifyJob error[%s], Runtime[%s] invalid, will run default", err, runtime) runtime = "@daily" } logger.Infof("Tg notify enabled,run at %s", runtime) @@ -361,7 +281,6 @@ func (s *Server) startTask() { if (err == nil) && (cpuThreshold > 0) { s.cron.AddJob("@every 10s", job.NewCheckCpuJob()) } - } else { s.cron.Remove(entry) } -- cgit v1.2.3 From 4865754b3d3d2d6a3296c8b07bb69a7f0bb0c489 Mon Sep 17 00:00:00 2001 Fro