diff --git a/.gitignore b/.gitignore index 7ba03558..6277cfc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +.vscode tmp backup/ bin/ diff --git a/README.md b/README.md index 86c19e57..1e9e98df 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ apt-get install certbot -y certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com certbot renew --dry-run ``` -or you can use x-ui menu then number '16' (Apply for an SSL Certificate) +or you can use x-ui menu then number '16' (Apply for an SSL Certificate) # Default settings @@ -116,6 +116,7 @@ If you want to use routing to WARP follow steps as below: - For more advanced configuration items, please refer to the panel - Fix api routes (user setting will create with api) - Support to change configs by different items provided in panel +- Support export/import database from panel # Tg robot use @@ -194,7 +195,6 @@ Reference syntax: - Tron USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC` - # Pictures ![1](./media/1.png) diff --git a/database/db.go b/database/db.go index ae42a6de..d5caa702 100644 --- a/database/db.go +++ b/database/db.go @@ -1,6 +1,8 @@ package database import ( + "bytes" + "io" "io/fs" "os" "path" @@ -104,3 +106,13 @@ func GetDB() *gorm.DB { func IsNotFound(err error) bool { return err == gorm.ErrRecordNotFound } + +func IsSQLiteDB(file io.Reader) (bool, error) { + signature := []byte("SQLite format 3\x00") + buf := make([]byte, len(signature)) + _, err := file.Read(buf) + if err != nil { + return false, err + } + return bytes.Equal(buf, signature), nil +} diff --git a/main.go b/main.go index 54ff6bf3..3281048d 100644 --- a/main.go +++ b/main.go @@ -212,8 +212,7 @@ func migrateDb() { log.Fatal(err) } fmt.Println("Start migrating database...") - inboundService.MigrationRequirements() - inboundService.RemoveOrphanedTraffics() + inboundService.MigrateDB() fmt.Println("Migration done!") } diff --git a/web/assets/css/custom.css b/web/assets/css/custom.css index d13e3159..671ba24f 100644 --- a/web/assets/css/custom.css +++ b/web/assets/css/custom.css @@ -1,5 +1,5 @@ #app { - height: 100%; + height: 100vh; } .ant-space { diff --git a/web/assets/js/axios-init.js b/web/assets/js/axios-init.js index 22d14d76..bd55c3cf 100644 --- a/web/assets/js/axios-init.js +++ b/web/assets/js/axios-init.js @@ -3,10 +3,14 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; axios.interceptors.request.use( config => { - config.data = Qs.stringify(config.data, { - arrayFormat: 'repeat' - }); + if (config.data instanceof FormData) { + config.headers['Content-Type'] = 'multipart/form-data'; + } else { + config.data = Qs.stringify(config.data, { + arrayFormat: 'repeat', + }); + } return config; }, error => Promise.reject(error) -); \ No newline at end of file +); diff --git a/web/controller/server.go b/web/controller/server.go index c365ae4b..9e649e6c 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -41,6 +41,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.POST("/logs/:count", a.getLogs) g.POST("/getConfigJson", a.getConfigJson) g.GET("/getDb", a.getDb) + g.POST("/importDB", a.importDB) g.POST("/getNewX25519Cert", a.getNewX25519Cert) } @@ -99,8 +100,8 @@ func (a *ServerController) stopXrayService(c *gin.Context) { return } jsonMsg(c, "Xray stoped", err) - } + func (a *ServerController) restartXrayService(c *gin.Context) { err := a.serverService.RestartXrayService() if err != nil { @@ -108,7 +109,6 @@ func (a *ServerController) restartXrayService(c *gin.Context) { return } jsonMsg(c, "Xray restarted", err) - } func (a *ServerController) getLogs(c *gin.Context) { @@ -144,6 +144,28 @@ func (a *ServerController) getDb(c *gin.Context) { c.Writer.Write(db) } +func (a *ServerController) importDB(c *gin.Context) { + // Get the file from the request body + file, _, err := c.Request.FormFile("db") + if err != nil { + jsonMsg(c, "Error reading db file", err) + return + } + defer file.Close() + // Always restart Xray before return + defer a.serverService.RestartXrayService() + defer func() { + a.lastGetStatusTime = time.Now() + }() + // Import it + err = a.serverService.ImportDB(file) + if err != nil { + jsonMsg(c, "", err) + return + } + jsonObj(c, "Import DB", nil) +} + func (a *ServerController) getNewX25519Cert(c *gin.Context) { cert, err := a.serverService.GetNewX25519Cert() if err != nil { diff --git a/web/html/common/text_modal.html b/web/html/common/text_modal.html index b2da6160..ce77d0ca 100644 --- a/web/html/common/text_modal.html +++ b/web/html/common/text_modal.html @@ -4,7 +4,8 @@ :class="siderDrawer.isDarkTheme ? darkClass : ''" :ok-button-props="{attrs:{id:'txt-modal-ok-btn'}}"> + :href="'data:application/text;charset=utf-8,' + encodeURIComponent(txtModal.content)" + :download="txtModal.fileName"> {{ i18n "download" }} [[ txtModal.fileName ]] {{ i18n "menu.link" }}: - Log Reports - Config - Backup + {{ i18n "pages.index.logs" }} + {{ i18n "pages.index.config" }} + {{ i18n "pages.index.backup" }} @@ -188,6 +188,7 @@ + + - + + + +

+ + [[ backupModal.description ]] +

+ + + [[ backupModal.exportText ]] + + + [[ backupModal.importText ]] + + +
+ {{template "js" .}} {{template "textModal"}} @@ -339,6 +359,29 @@ }, }; + const backupModal = { + visible: false, + title: '', + description: '', + exportText: '', + importText: '', + show({ + title = '{{ i18n "pages.index.backupTitle" }}', + description = '{{ i18n "pages.index.backupDescription" }}', + exportText = '{{ i18n "pages.index.exportDatabase" }}', + importText = '{{ i18n "pages.index.importDatabase" }}', + }) { + this.title = title; + this.description = description; + this.exportText = exportText; + this.importText = importText; + this.visible = true; + }, + hide() { + this.visible = false; + }, + }; + const app = new Vue({ delimiters: ['[[', ']]'], el: '#app', @@ -347,6 +390,7 @@ status: new Status(), versionModal, logModal, + backupModal, spinning: false, loadingTip: '{{ i18n "loading"}}', }, @@ -388,7 +432,6 @@ }, }); }, - //here add stop xray function async stopXrayService() { this.loading(true); const msg = await HttpUtil.post('server/stopXrayService'); @@ -397,7 +440,6 @@ return; } }, - //here add restart xray function async restartXrayService() { this.loading(true); const msg = await HttpUtil.post('server/restartXrayService'); @@ -413,20 +455,60 @@ if (!msg.success) { return; } - logModal.show(msg.obj,rows); + logModal.show(msg.obj, rows); }, - async openConfig(){ + async openConfig() { this.loading(true); const msg = await HttpUtil.post('server/getConfigJson'); this.loading(false); if (!msg.success) { return; } - txtModal.show('config.json',JSON.stringify(msg.obj, null, 2),'config.json'); + txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json'); }, - getBackup(){ + openBackup() { + backupModal.show({ + title: '{{ i18n "pages.index.backupTitle" }}', + description: '{{ i18n "pages.index.backupDescription" }}', + exportText: '{{ i18n "pages.index.exportDatabase" }}', + importText: '{{ i18n "pages.index.importDatabase" }}', + }); + }, + exportDatabase() { window.location = basePath + 'server/getDb'; - } + }, + importDatabase() { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.db'; + fileInput.addEventListener('change', async (event) => { + const dbFile = event.target.files[0]; + if (dbFile) { + const formData = new FormData(); + formData.append('db', dbFile); + backupModal.hide(); + this.loading(true); + const uploadMsg = await HttpUtil.post('server/importDB', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + } + }); + this.loading(false); + if (!uploadMsg.success) { + return; + } + this.loading(true); + const restartMsg = await HttpUtil.post("/xui/setting/restartPanel"); + this.loading(false); + if (restartMsg.success) { + this.loading(true); + await PromiseUtil.sleep(5000); + location.reload(); + } + } + }); + fileInput.click(); + }, }, async mounted() { while (true) { diff --git a/web/html/xui/settings.html b/web/html/xui/settings.html index 5b5a91dc..e1237971 100644 --- a/web/html/xui/settings.html +++ b/web/html/xui/settings.html @@ -74,7 +74,7 @@ - + diff --git a/web/service/inbound.go b/web/service/inbound.go index dd633c4b..b14f7b4f 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -595,6 +595,7 @@ func (s *InboundService) DisableInvalidInbounds() (int64, error) { count := result.RowsAffected return count, err } + func (s *InboundService) DisableInvalidClients() (int64, error) { db := database.GetDB() now := time.Now().Unix() * 1000 @@ -605,7 +606,8 @@ func (s *InboundService) DisableInvalidClients() (int64, error) { count := result.RowsAffected return count, err } -func (s *InboundService) RemoveOrphanedTraffics() { + +func (s *InboundService) MigrationRemoveOrphanedTraffics() { db := database.GetDB() db.Exec(` DELETE FROM client_traffics @@ -616,6 +618,7 @@ func (s *InboundService) RemoveOrphanedTraffics() { ) `) } + func (s *InboundService) AddClientStat(inboundId int, client *model.Client) error { db := database.GetDB() @@ -634,6 +637,7 @@ func (s *InboundService) AddClientStat(inboundId int, client *model.Client) erro } return nil } + func (s *InboundService) UpdateClientStat(email string, client *model.Client) error { db := database.GetDB() @@ -1166,3 +1170,8 @@ func (s *InboundService) MigrationRequirements() { // Remove orphaned traffics db.Where("inbound_id = 0").Delete(xray.ClientTraffic{}) } + +func (s *InboundService) MigrateDB() { + s.MigrationRequirements() + s.MigrationRemoveOrphanedTraffics() +} diff --git a/web/service/server.go b/web/service/server.go index 1108926b..d8a2239b 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "io/fs" + "mime/multipart" "net/http" "os" "os/exec" @@ -14,7 +15,9 @@ import ( "strings" "time" "x-ui/config" + "x-ui/database" "x-ui/logger" + "x-ui/util/common" "x-ui/util/sys" "x-ui/xray" @@ -73,7 +76,8 @@ type Release struct { } type ServerService struct { - xrayService XrayService + xrayService XrayService + inboundService InboundService } func (s *ServerService) GetStatus(lastStatus *Status) *Status { @@ -395,6 +399,106 @@ func (s *ServerService) GetDb() ([]byte, error) { return fileContents, nil } +func (s *ServerService) ImportDB(file multipart.File) error { + // Check if the file is a SQLite database + isValidDb, err := database.IsSQLiteDB(file) + if err != nil { + return common.NewErrorf("Error checking db file format: %v", err) + } + if !isValidDb { + return common.NewError("Invalid db file format") + } + + // Reset the file reader to the beginning + _, err = file.Seek(0, 0) + if err != nil { + return common.NewErrorf("Error resetting file reader: %v", err) + } + + // Save the file as temporary file + tempPath := fmt.Sprintf("%s.temp", config.GetDBPath()) + // Remove the existing fallback file (if any) before creating one + _, err = os.Stat(tempPath) + if err == nil { + errRemove := os.Remove(tempPath) + if errRemove != nil { + return common.NewErrorf("Error removing existing temporary db file: %v", errRemove) + } + } + // Create the temporary file + tempFile, err := os.Create(tempPath) + if err != nil { + return common.NewErrorf("Error creating temporary db file: %v", err) + } + defer tempFile.Close() + + // Remove temp file before returning + defer os.Remove(tempPath) + + // Save uploaded file to temporary file + _, err = io.Copy(tempFile, file) + if err != nil { + return common.NewErrorf("Error saving db: %v", err) + } + + // Check if we can init db or not + err = database.InitDB(tempPath) + if err != nil { + return common.NewErrorf("Error checking db: %v", err) + } + + // Stop Xray + s.StopXrayService() + + // Backup the current database for fallback + fallbackPath := fmt.Sprintf("%s.backup", config.GetDBPath()) + // Remove the existing fallback file (if any) + _, err = os.Stat(fallbackPath) + if err == nil { + errRemove := os.Remove(fallbackPath) + if errRemove != nil { + return common.NewErrorf("Error removing existing fallback db file: %v", errRemove) + } + } + // Move the current database to the fallback location + err = os.Rename(config.GetDBPath(), fallbackPath) + if err != nil { + return common.NewErrorf("Error backing up temporary db file: %v", err) + } + + // Remove the temporary file before returning + defer os.Remove(fallbackPath) + + // Move temp to DB path + err = os.Rename(tempPath, config.GetDBPath()) + if err != nil { + errRename := os.Rename(fallbackPath, config.GetDBPath()) + if errRename != nil { + return common.NewErrorf("Error moving db file and restoring fallback: %v", errRename) + } + return common.NewErrorf("Error moving db file: %v", err) + } + + // Migrate DB + err = database.InitDB(config.GetDBPath()) + if err != nil { + errRename := os.Rename(fallbackPath, config.GetDBPath()) + if errRename != nil { + return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename) + } + return common.NewErrorf("Error migrating db: %v", err) + } + s.inboundService.MigrateDB() + + // Start Xray + err = s.RestartXrayService() + if err != nil { + return common.NewErrorf("Imported DB but Failed to start Xray: %v", err) + } + + return nil +} + func (s *ServerService) GetNewX25519Cert() (interface{}, error) { // Run the command cmd := exec.Command(xray.GetBinaryPath(), "x25519") diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index bc5bb942..42d67daf 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -90,6 +90,13 @@ "xraySwitchVersionDialog" = "Switch Xray Version" "xraySwitchVersionDialogDesc" = "Are you sure you want to switch the Xray version to" "dontRefresh" = "Installation is in progress, please do not refresh this page." +"logs" = "Logs" +"config" = "Config" +"backup" = "Backup" +"backupTitle" = "Backup Database" +"backupDescription" = "Remember to backup before importing a new database." +"exportDatabase" = "Download Database" +"importDatabase" = "Upload Database" [pages.inbounds] "title" = "Inbounds" diff --git a/web/translation/translate.fa_IR.toml b/web/translation/translate.fa_IR.toml index f2e7e93f..b0bb6a8c 100644 --- a/web/translation/translate.fa_IR.toml +++ b/web/translation/translate.fa_IR.toml @@ -90,6 +90,13 @@ "xraySwitchVersionDialog" = "تغییر ورژن" "xraySwitchVersionDialogDesc" = "آیا از تغییر ورژن مطمئن هستین" "dontRefresh" = "در حال نصب ، لطفا رفرش نکنید " +"logs" = "گزارش ها" +"config" = "تنظیمات" +"backup" = "پشتیبان گیری" +"backupTitle" = "پشتیبان گیری دیتابیس" +"backupDescription" = "به یاد داشته باشید که قبل از وارد کردن یک دیتابیس جدید، نسخه پشتیبان تهیه کنید." +"exportDatabase" = "دانلود دیتابیس" +"importDatabase" = "آپلود دیتابیس" [pages.inbounds] "title" = "کاربران" diff --git a/web/translation/translate.zh_Hans.toml b/web/translation/translate.zh_Hans.toml index c0ba24c6..6e78be09 100644 --- a/web/translation/translate.zh_Hans.toml +++ b/web/translation/translate.zh_Hans.toml @@ -90,6 +90,13 @@ "xraySwitchVersionDialog" = "切换 xray 版本" "xraySwitchVersionDialogDesc" = "是否切换 xray 版本至" "dontRefresh" = "安装中,请不要刷新此页面" +"logs" = "日志" +"config" = "配置" +"backup" = "备份" +"backupTitle" = "备份数据库" +"backupDescription" = "请记住在导入新数据库之前进行备份。" +"exportDatabase" = "下载数据库" +"importDatabase" = "上传数据库" [pages.inbounds] "title" = "入站列表"